Skip to content

Commit

Permalink
feature #39: select controls for ui-vue
Browse files Browse the repository at this point in the history
  • Loading branch information
sadiqkhoja committed Apr 24, 2024
1 parent 959419b commit fa4d4d6
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 14 deletions.
25 changes: 17 additions & 8 deletions packages/ui-vue/e2e/vue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,26 @@ test('All forms are rendered and there is no console error', async ({ page, brow

await page.keyboard.press(browserName == 'webkit' ? 'Alt+Tab' : 'Tab');

const isEditableTextbox = await page.evaluate(() => {
const activeElement = document.activeElement;
return (
activeElement?.tagName === 'INPUT' &&
(activeElement as HTMLInputElement).type === 'text' &&
!activeElement.hasAttribute('readonly')
);
const inputType = await page.evaluate(() => {
const activeElement = document.activeElement as HTMLInputElement;

if (
activeElement?.tagName !== 'INPUT' ||
activeElement.hasAttribute('readonly') ||
activeElement.hasAttribute('disabled')
) {
return false;
}

return activeElement.type;
});

if (isEditableTextbox) {
if (inputType === 'text') {
await page.keyboard.type(faker.internet.displayName());
} else if (inputType === 'radio') {
await page.keyboard.press('ArrowDown');
} else if (inputType === 'checkbox') {
await page.keyboard.press('Space');
}
}

Expand Down
8 changes: 6 additions & 2 deletions packages/ui-vue/src/components/FormQuestion.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<script setup lang="ts">
import type { AnyLeafNode, StringNode } from '@odk-web-forms/xforms-engine';
import type { AnyLeafNode, SelectNode, StringNode } from '@odk-web-forms/xforms-engine';
import InputText from './controls/InputText.vue';
import SelectControl from './controls/SelectControl.vue';
import UnsupportedControl from './controls/UnsupportedControl.vue';
defineProps<{question: AnyLeafNode}>();
const isStringNode = (n: AnyLeafNode) : n is StringNode => n.nodeType === 'string';
const isSelectNode = (n: AnyLeafNode) : n is SelectNode => n.nodeType === 'select';
</script>
Expand All @@ -14,12 +16,14 @@ const isStringNode = (n: AnyLeafNode) : n is StringNode => n.nodeType === 'strin
<div class="flex flex-column gap-2">
<InputText v-if="isStringNode(question)" :question="question" />

<SelectControl v-else-if="isSelectNode(question)" :question="question" />

<UnsupportedControl v-else :question="question" />
</div>
</template>

<style>
input:read-only {
input:disabled {
cursor: not-allowed;
}
</style>
71 changes: 71 additions & 0 deletions packages/ui-vue/src/components/controls/SelectControl.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { SelectItem, SelectNode } from '@odk-web-forms/xforms-engine';
import PrimeVueCheckbox from 'primevue/checkbox';
import PrimeVueRadioButton from 'primevue/radiobutton';
import ControlLabel from '../ControlLabel.vue';
import { computed } from 'vue';
const props = defineProps<{question: SelectNode}>();
const handleChange = (e: Event, item: SelectItem) => {
if (item == null) {
const currentItem = props.question.currentState.value[0];
if (currentItem != null) {
props.question.deselect(currentItem);
}
} else {
props.question.select(item);
}
};
const handleChangeN = (e: Event, item: SelectItem) => {
const checkbox = e.target as HTMLInputElement;
if(checkbox.checked) {
props.question.select(item);
}
else{
props.question.deselect(item);
}
}
const value = computed(() => {
const [item] = props.question.currentState.value;
return item?.value ?? null
})
</script>

<template>
<ControlLabel :question="question" />

<template v-if="question.definition.bodyElement.type === 'select1'">
<div v-for="option in question.currentState.valueOptions" :key="option.value" class="select1">
<PrimeVueRadioButton
:input-id="question.nodeId + '_' + option.value"
:name="question.nodeId"
:value="option.value"
:model-value="value"
@change="handleChange($event, option)"
/>
<label :for="question.nodeId + '_' + option.value">{{ option.label?.asString }}</label>
</div>
</template>

<template v-else>
<div v-for="option of question.currentState.valueOptions" :key="option.value" class="selectN">
<PrimeVueCheckbox
:input-id="question.nodeId + '_' + option.value"
:name="question.nodeId"
:value="option.value"
:model-value="question.currentState.value.map(v => v.value)"
@change="handleChangeN($event, option)"
/>
<label :for="question.nodeId + '_' + option.value">{{ option.label?.asString }}</label>
</div>
</template>
</template>
11 changes: 11 additions & 0 deletions packages/ui-vue/src/themes/2024-light/theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -230,5 +230,16 @@ $primary50: #d8ecf5;
.p-inputtext.p-variant-filled:enabled:focus {
background-color: var(--surface-100);
}

.select1,
.selectN {
display: flex;
align-items: center;

label {
margin-left: 0.5rem;
cursor: pointer;
}
}
}
}
49 changes: 45 additions & 4 deletions packages/ui-vue/tests/components/FormQuestion.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import InputText from '@/components/controls/InputText.vue';
import SelectControl from '@/components/controls/SelectControl.vue';
import UnsupportedControl from '@/components/controls/UnsupportedControl.vue';
import type { StringNode } from '@odk-web-forms/xforms-engine';
import { mount } from '@vue/test-utils';
import type { SelectNode, StringNode } from '@odk-web-forms/xforms-engine';
import { mount, type MountingOptions } from '@vue/test-utils';
import PrimeVue from 'primevue/config';
import { assocPath } from 'ramda';
import { describe, expect, it } from 'vitest';
import FormQuestion from '../../src/components/FormQuestion.vue';

type GlobalMountOptions = Required<MountingOptions<unknown, unknown>>['global'];

const baseQuestion = {
nodeType: 'string',
currentState: {
Expand All @@ -16,6 +20,13 @@ const baseQuestion = {
},
} as StringNode;

const globalOptions: GlobalMountOptions = {
plugins: [[PrimeVue, { ripple: false }]],
stubs: {
teleport: true,
},
};

describe('FormQuestion', () => {
it('shows InputText control for string nodes', () => {
const component = mount(FormQuestion, {
Expand All @@ -31,17 +42,47 @@ describe('FormQuestion', () => {
expect(component.text()).toBe('* First Name');
});

it('shows Select control for select nodes', () => {
const selectQuestion = {
nodeType: 'select',
definition: {
bodyElement: { type: 'select1' },
},
currentState: {
required: true,
value: [],
label: {
asString: 'Country',
},
valueOptions: [{ value: 'ca', label: { asString: 'Canada' } }],
},
} as unknown as SelectNode;

const component = mount(FormQuestion, {
props: {
question: selectQuestion,
},
global: globalOptions,
});

const selectControl = component.findComponent(SelectControl);

expect(selectControl.exists()).to.be.true;

expect(component.find('label').text()).to.be.eql('* Country');
});

it('shows UnsupportedControl for unsupported / unimplemented question type', () => {
const component = mount(FormQuestion, {
props: {
question: assocPath(['nodeType'], 'select', baseQuestion),
question: assocPath(['nodeType'], 'date', baseQuestion),
},
});

const unsupported = component.findComponent(UnsupportedControl);

expect(unsupported.exists()).toBe(true);

expect(component.text()).toBe('Unsupported field {select} in the form definition.');
expect(component.text()).to.be.eql('Unsupported field {date} in the form definition.');
});
});
49 changes: 49 additions & 0 deletions packages/ui-vue/tests/components/SelectControl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { DOMWrapper, mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import SelectControl from '@/components/controls/SelectControl.vue';
import { getReactiveForm, globalMountOptions } from '../helpers';
import type { SelectNode } from '@odk-web-forms/xforms-engine';

const mountComponent = async (questionNumber: number) => {
const xform = await getReactiveForm('select/1-static-selects.xml');

return mount(SelectControl, {
props: {
question: xform.currentState.children[questionNumber] as SelectNode,
},
global: globalMountOptions,
});
};

describe('SelectControl', () => {
it('shows radio buttons for select1', async () => {
const component = await mountComponent(0);
const cherry: DOMWrapper<HTMLInputElement> = component.find('input[value="cherry"]');
const mango: DOMWrapper<HTMLInputElement> = component.find('input[value="mango"]');

expect(cherry.element.type).to.be.eql('radio');
expect(cherry.element.checked).to.be.true;

await mango.trigger('click');

expect(cherry.element.checked).to.be.false;
expect(mango.element.checked).to.be.true;
});

it('shows checkboxes for select many', async () => {
const component = await mountComponent(1);

const watermelon: DOMWrapper<HTMLInputElement> = component.find('input[value="watermelon"]');
const peach: DOMWrapper<HTMLInputElement> = component.find('input[value="peach"]');

expect(watermelon.element.type).to.be.eql('checkbox');
expect(watermelon.element.checked).to.be.false;
expect(peach.element.checked).to.be.true;

await watermelon.trigger('click');
await peach.trigger('click');

expect(watermelon.element.checked).to.be.true;
expect(peach.element.checked).to.be.false;
});
});
27 changes: 27 additions & 0 deletions packages/ui-vue/tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { initializeForm, type RootNode } from '@odk-web-forms/xforms-engine';
// eslint-disable-next-line no-restricted-imports -- in test environemnt
import { readFile } from 'fs/promises';
import { reactive } from 'vue';
import PrimeVue from 'primevue/config';
import type { MountingOptions } from '@vue/test-utils';

export const getReactiveForm = async (formPath: string): Promise<RootNode> => {
const formXml = await readFile(`../ui-solid/fixtures/xforms/${formPath}`, 'utf8');

return await initializeForm(formXml, {
config: {
stateFactory: <T extends object>(o: T) => {
return reactive(o) as T;
},
},
});
};

type GlobalMountOptions = Required<MountingOptions<unknown>>['global'];

export const globalMountOptions: GlobalMountOptions = {
plugins: [[PrimeVue, { ripple: false }]],
stubs: {
teleport: true,
},
};

0 comments on commit fa4d4d6

Please sign in to comment.