diff --git a/packages/common/src/fixtures/other/multi-step.xform.xml b/packages/common/src/fixtures/other/multi-step.xform.xml new file mode 100644 index 000000000..be83df2b3 --- /dev/null +++ b/packages/common/src/fixtures/other/multi-step.xform.xml @@ -0,0 +1,38 @@ + + + + Multi Step XForm + + + + + + + + + + + + + + + + + + + + First question + + + Second question + + + Third question + + + diff --git a/packages/web-forms/src/components/FormGroup.vue b/packages/web-forms/src/components/FormGroup.vue index 968ae5592..3537c1885 100644 --- a/packages/web-forms/src/components/FormGroup.vue +++ b/packages/web-forms/src/components/FormGroup.vue @@ -4,7 +4,9 @@ import { computed } from 'vue'; import FormPanel from './FormPanel.vue'; import QuestionList from './QuestionList.vue'; -const props = defineProps<{ node: GroupNode }>(); +const props = withDefaults(defineProps<{ node: GroupNode, toggleable?: boolean }>(), { + toggleable: false, +}); const classes = ['group']; @@ -18,7 +20,7 @@ const tableLayout = computed(() => { - + diff --git a/packages/web-forms/src/components/FormPanel.vue b/packages/web-forms/src/components/FormPanel.vue index f673a5e04..5396b5639 100644 --- a/packages/web-forms/src/components/FormPanel.vue +++ b/packages/web-forms/src/components/FormPanel.vue @@ -12,6 +12,7 @@ export interface PanelProps { class?: string[] | string; labelIcon?: string; labelNumber?: number; + toggleable?: boolean; } const props = withDefaults(defineProps(), { @@ -21,6 +22,7 @@ const props = withDefaults(defineProps(), { class: undefined, labelIcon: undefined, labelNumber: undefined, + toggleable: false, }); const panelClass = computed(() => [ @@ -31,7 +33,9 @@ const panelClass = computed(() => [ const panelState = ref(false); const toggle = () => { - panelState.value = !panelState.value; + if (props.toggleable) { + panelState.value = !panelState.value; + } }; const menu = ref(); @@ -40,10 +44,11 @@ const toggleMenu = (event: Event) => { menu.value?.toggle(event); }; + - + - + {{ labelNumber }} @@ -51,6 +56,13 @@ const toggleMenu = (event: Event) => { + + + {{ labelNumber }} + {{ title }} + + + @@ -129,9 +141,8 @@ h2 { } :deep(.p-panel-content) { - border-left: 2px solid var(--gray-200); + box-shadow: none; margin-left: 10px; - border-radius: 0; padding: 0 0 0 1.5rem; } @@ -140,6 +151,11 @@ h2 { } } +.toggleable-enabled :deep(.p-panel-content) { + border-left: 2px solid var(--gray-200); + border-radius: 0; +} + .content-wrapper { display: flex; flex-direction: column; diff --git a/packages/web-forms/src/components/OdkWebForm.vue b/packages/web-forms/src/components/OdkWebForm.vue index fa2b23a09..6767b0979 100644 --- a/packages/web-forms/src/components/OdkWebForm.vue +++ b/packages/web-forms/src/components/OdkWebForm.vue @@ -22,6 +22,7 @@ import { computed, getCurrentInstance, provide, ref, watchEffect } from 'vue'; import FormLoadFailureDialog from './Form/FormLoadFailureDialog.vue'; import FormHeader from './FormHeader.vue'; import QuestionList from './QuestionList.vue'; +import QuestionStepper from './QuestionStepper.vue'; const webFormsVersion = __WEB_FORMS_VERSION__; @@ -30,6 +31,13 @@ export interface OdkWebFormsProps { readonly fetchFormAttachment: FetchFormAttachment; readonly missingResourceBehavior?: MissingResourceBehavior; + /** + * Note: by default all questions will be displayed in a single list, + * with collapsable groups. This param changes to a stepper layout + * closer to Collect. + */ + readonly stepperLayout?: boolean; + /** * Note: this parameter must be set when subscribing to the * {@link OdkWebFormEmits.submitChunked | submitChunked} event. @@ -43,7 +51,9 @@ export interface OdkWebFormsProps { readonly editInstance?: EditInstanceOptions; } -const props = defineProps(); +const props = withDefaults(defineProps(), { + stepperLayout: false, +}); const hostSubmissionResultCallbackFactory = ( currentState: FormStateSuccessResult @@ -229,18 +239,20 @@ watchEffect(() => { - + + + - - + Powered by { - + diff --git a/packages/web-forms/src/components/QuestionStepper.vue b/packages/web-forms/src/components/QuestionStepper.vue new file mode 100644 index 000000000..03d14a40f --- /dev/null +++ b/packages/web-forms/src/components/QuestionStepper.vue @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web-forms/tests/components/OdkWebForm.test.ts b/packages/web-forms/tests/components/OdkWebForm.test.ts index e3d6f959f..6d145e590 100644 --- a/packages/web-forms/tests/components/OdkWebForm.test.ts +++ b/packages/web-forms/tests/components/OdkWebForm.test.ts @@ -443,4 +443,154 @@ describe('OdkWebForm', () => { expect(textInput.element.value).toBe(expectedPostSubmissionValue); }); }); + + describe('stepper layout', () => { + beforeEach(async () => { + // Replace form with one including two questions + formXML = await getFormXml('multi-step.xform.xml'); + }); + + it('renders stepper layout when stepperLayout prop is true', async () => { + const component = mountComponent(formXML, { + overrideProps: { stepperLayout: true }, + }); + await flushPromises(); + + expect(component.find('.stepper-container').exists()).toBe(true); + expect(component.find('.navigation-button').exists()).toBe(true); + }); + + it('shows only one question at a time in stepper layout and allows navigation', async () => { + const component = mountComponent(formXML, { + overrideProps: { stepperLayout: true }, + }); + await flushPromises(); + + // --- First Question --- + let visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + expect(visibleQuestions.length).toBe(1); + expect( + visibleQuestions[0].findAll('span').some((span) => span.text().includes('First question')) + ).toBe(true); + + // Fill first input (assuming it's an input element) + await visibleQuestions[0].find('input').setValue('Answer 1'); + + expect(component.find('button[aria-label="Back"]').exists()).toBe(false); + let nextButton = component.find('button[aria-label="Next"]'); + expect(nextButton.exists()).toBe(true); + expect(component.find('button[aria-label="Send"]').exists()).toBe(false); + + await nextButton.trigger('click'); + + // --- Second Question --- + visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + expect(visibleQuestions.length).toBe(1); + expect( + visibleQuestions[0].findAll('span').some((span) => span.text().includes('Second question')) + ).toBe(true); + + // Fill second input + await visibleQuestions[0].find('input').setValue('Answer 2'); + + const backButton = component.find('button[aria-label="Back"]'); + expect(backButton.exists()).toBe(true); + expect(component.find('button[aria-label="Next"]').exists()).toBe(true); + expect(component.find('button[aria-label="Send"]').exists()).toBe(false); + + // Test back button works (return to first question) + await backButton.trigger('click'); + + visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + expect(visibleQuestions.length).toBe(1); + expect( + visibleQuestions[0].findAll('span').some((span) => span.text().includes('First question')) + ).toBe(true); + + // Move to final / third question + nextButton = component.find('button[aria-label="Next"]'); + await nextButton.trigger('click'); + nextButton = component.find('button[aria-label="Next"]'); + await nextButton.trigger('click'); + + // --- Third Question --- + visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + expect(visibleQuestions.length).toBe(1); + expect( + visibleQuestions[0].findAll('span').some((span) => span.text().includes('Third question')) + ).toBe(true); + + // Fill third input + await visibleQuestions[0].find('input').setValue('Answer 3'); + + expect(component.find('button[aria-label="Back"]').exists()).toBe(true); + expect(component.find('button[aria-label="Next"]').exists()).toBe(false); + expect(component.find('button[aria-label="Send"]').exists()).toBe(true); + + // Submit form + const sendButton = component.find('button[aria-label="Send"]'); + await sendButton.trigger('click'); + + // Ensure no form error messages are shown + const errorMessages = component.findAll('.form-error-message').filter((el) => el.isVisible()); + expect(errorMessages.length).toBe(0); + }); + + it('blocks navigation and send in stepper layout if current step is invalid', async () => { + const component = mountComponent(formXML, { + overrideProps: { stepperLayout: true }, + }); + await flushPromises(); + + let nextButton = component.find('button[aria-label="Next"]'); + await nextButton.trigger('click'); + + // Displays an error, remains on first question + expect(component.get('.validation-message').isVisible()).toBe(true); + expect(component.get('.validation-message').text()).toBe('Condition not satisfied: required'); + let visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + expect(visibleQuestions.length).toBe(1); + expect( + visibleQuestions[0].findAll('span').some((span) => span.text().includes('First question')) + ).toBe(true); + }); + + it('allows submission once all steps are valid in stepper layout', async () => { + const mockSubmit = vi.fn(); + const component = mountComponent(formXML, { + overrideProps: { stepperLayout: true }, + onSubmit: mockSubmit, + }); + await flushPromises(); + + // Progress to end of form + let visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + await visibleQuestions[0].find('input').setValue('Answer 1'); + let nextButton = component.find('button[aria-label="Next"]'); + nextButton = component.find('button[aria-label="Next"]'); + await nextButton.trigger('click'); + visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + await visibleQuestions[0].find('input').setValue('Answer 2'); + nextButton = component.find('button[aria-label="Next"]'); + await nextButton.trigger('click'); + + // Check entire form validation works + expect(component.get('.form-error-message').isVisible()).toBe(false); + const sendButton = component.find('button[aria-label="Send"]'); + await sendButton.trigger('click'); + expect(component.get('.form-error-message').isVisible()).toBe(true); + + // Fill final question to make form valid, and check submission sends + visibleQuestions = component.findAll('.question-container').filter((q) => q.isVisible()); + await visibleQuestions[0].find('input').setValue('Answer 3'); + await sendButton.trigger('click'); + expect(component.get('.form-error-message').isVisible()).toBe(false); + expect(mockSubmit).toHaveBeenCalled(); + }); + + it('always shows the stepper next and back buttons at the bottom of the screen', async () => { + // TODO + console.log('here'); + }); + }); });