From 4dddcd9460051ce1cae3dc1d3fe0e1eb88944ae1 Mon Sep 17 00:00:00 2001 From: dylankelly Date: Mon, 2 Mar 2026 10:00:02 +1100 Subject: [PATCH 1/7] feat(ripple-ui-forms): adds ability to control form step progression --- packages/ripple-ui-core/src/components.ts | 1 + .../src/components/RplForm/RplForm.cy.ts | 101 +++++++ .../src/components/RplForm/RplForm.vue | 2 +- .../RplForm/RplFormMultiStep.stories.ts | 246 ++++++++++++++++++ .../RplForm/fixtures/before-step-change.ts | 79 ++++++ .../RplFormReview/RplFormReview.vue | 1 + .../components/RplFormStep/RplFormStep.vue | 31 ++- .../components/RplFormSteps/RplFormSteps.vue | 32 ++- packages/ripple-ui-forms/src/types.ts | 1 + 9 files changed, 480 insertions(+), 14 deletions(-) create mode 100644 packages/ripple-ui-forms/src/components/RplForm/RplFormMultiStep.stories.ts create mode 100644 packages/ripple-ui-forms/src/components/RplForm/fixtures/before-step-change.ts diff --git a/packages/ripple-ui-core/src/components.ts b/packages/ripple-ui-core/src/components.ts index 64f8fc70c2..d59c2a812a 100644 --- a/packages/ripple-ui-core/src/components.ts +++ b/packages/ripple-ui-core/src/components.ts @@ -45,6 +45,7 @@ export { default as RplPrimaryCampaign } from './components/campaign-banner/RplP export { default as RplPrimaryNav } from './components/primary-nav/RplPrimaryNav.vue' export { default as RplProfile } from './components/profile/RplProfile.vue' export { default as RplPromoCard } from './components/card/RplPromoCard.vue' +export { default as RplProgress } from './components/progress/RplProgress.vue' export { default as RplRelatedLinks } from './components/related-links/RplRelatedLinks.vue' export { default as RplResultListing } from './components/result-listing/RplResultListing.vue' export { default as RplResultListingItem } from './components/result-listing/RplResultListingItem.vue' diff --git a/packages/ripple-ui-forms/src/components/RplForm/RplForm.cy.ts b/packages/ripple-ui-forms/src/components/RplForm/RplForm.cy.ts index 72dac79899..8d631e75e3 100644 --- a/packages/ripple-ui-forms/src/components/RplForm/RplForm.cy.ts +++ b/packages/ripple-ui-forms/src/components/RplForm/RplForm.cy.ts @@ -1,6 +1,60 @@ import RplForm from './RplForm.vue' import { schema } from './fixtures/sample' +const multiStepSchema: any[] = [ + { + $step: true, + id: 'step-one', + key: 'step-one', + name: 'step-one', + title: 'Step one', + nextButton: 'Next', + schema: [ + { + $formkit: 'RplFormText', + id: 'first_name', + name: 'first_name', + label: 'First name', + validation: [['required']], + value: '' + } + ] + }, + { + $step: true, + id: 'step-two', + key: 'step-two', + name: 'step-two', + title: 'Step two', + nextButton: 'Review', + prevButton: 'Back', + schema: [ + { + $formkit: 'RplFormText', + id: 'email', + name: 'email', + label: 'Email', + value: '' + } + ] + }, + { + $step: true, + id: 'review', + key: 'review', + name: 'review', + title: 'Review', + nextButton: 'Submit', + prevButton: 'Back', + schema: [ + { + $formkit: 'RplFormReview', + key: 'review_component' + } + ] + } +] + describe('', () => { it('renders', () => { // see: https://test-utils.vuejs.org/guide/ @@ -37,4 +91,51 @@ describe('', () => { cy.get('button[type="submit"]').should('be.disabled') cy.get('button[type="reset"]').should('be.disabled') }) + + it('validates and navigates between multi-step form steps', () => { + cy.mount(RplForm, { + props: { + id: 'test-multi-step-form', + schema: multiStepSchema + } + }) + + cy.contains('Step 1 of 3').should('be.visible') + cy.contains('h3', 'Step one').should('be.visible') + + cy.contains('button', 'Next').click() + cy.contains('There is a problem').should('be.visible') + cy.contains('Step 1 of 3').should('be.visible') + + cy.get('[name="first_name"]').type('Taylor') + cy.contains('button', 'Next').click() + + cy.contains('Step 2 of 3').should('be.visible') + cy.contains('h3', 'Step two').should('be.visible') + + cy.contains('button', 'Back').click() + cy.contains('Step 1 of 3').should('be.visible') + cy.contains('h3', 'Step one').should('be.visible') + }) + + it('renders review step content in multi-step form', () => { + cy.viewport('macbook-13') + cy.mount(RplForm, { + props: { + id: 'test-multi-step-review-form', + schema: multiStepSchema + } + }) + + cy.get('[name="first_name"]').type('Taylor') + cy.contains('button', 'Next').click() + cy.get('[name="email"]').type('taylor@example.com') + cy.contains('button', 'Review').click() + + cy.contains('Step 3 of 3').should('be.visible') + cy.contains('h3', 'Review').should('be.visible') + cy.contains('Step one').should('be.visible') + cy.contains('Step two').should('be.visible') + cy.contains('Change').should('be.visible') + }) }) diff --git a/packages/ripple-ui-forms/src/components/RplForm/RplForm.vue b/packages/ripple-ui-forms/src/components/RplForm/RplForm.vue index 6f3de3c909..e97e7afc06 100644 --- a/packages/ripple-ui-forms/src/components/RplForm/RplForm.vue +++ b/packages/ripple-ui-forms/src/components/RplForm/RplForm.vue @@ -421,7 +421,7 @@ const handleStepChange = async ({ // Get the current steps errors when it's invalid, and we're trying to proceed if (!currentStep.isValid && forwards) { - cachedErrors.value = getErrorMessages(getNode(currentStep.id)) + cachedErrors.value = getErrorMessages(currentStep.node) } if (isStepValid) { diff --git a/packages/ripple-ui-forms/src/components/RplForm/RplFormMultiStep.stories.ts b/packages/ripple-ui-forms/src/components/RplForm/RplFormMultiStep.stories.ts new file mode 100644 index 0000000000..6fcbdd7634 --- /dev/null +++ b/packages/ripple-ui-forms/src/components/RplForm/RplFormMultiStep.stories.ts @@ -0,0 +1,246 @@ +import type { Meta, StoryObj } from '@storybook/vue3' +import RplForm from './RplForm.vue' +import { handleEligibilityStepChange } from './fixtures/before-step-change' +import '@dpc-sdp/ripple-ui-core/style/components' + +export default { + title: 'Forms/Multi step form', + component: RplForm +} satisfies Meta + +type Story = StoryObj + +export const DefaultStory: Story = { + name: 'Default', + render: (args) => ({ + components: { RplForm }, + setup() { + return { args } + }, + template: ` + + + + ` + }), + args: { + id: 'multi-step-form', + schema: [ + { + $step: true, + id: 'eligibility', + key: 'eligibility', + name: 'eligibility', + title: 'Eligibility', + nextButton: 'Next', + beforeStepChange: handleEligibilityStepChange, + schema: [ + { + $formkit: 'RplFormFieldset', + legend: 'Address', + name: 'address', + children: [ + { + $formkit: 'RplFormText', + id: 'forms_address_organization', + name: 'organization', + label: 'Organization', + validation: [['required']], + validationMessages: { + required: 'The message field is required', + matches: 'Please enter between 10 and 50 characters' + }, + value: '' + }, + { + $formkit: 'RplFormText', + id: 'forms_address_given_name', + name: 'given_name', + label: 'Given name', + validation: [], + value: '' + }, + { + $formkit: 'RplFormText', + id: 'forms_address_family_name', + name: 'family_name', + label: 'Family name', + validation: [], + value: '' + }, + { + $formkit: 'RplFormText', + id: 'forms_address_address_line1', + name: 'address_line1', + label: 'Street address', + validation: [], + value: '' + }, + { + $formkit: 'RplFormText', + id: 'forms_address_address_line2', + name: 'address_line2', + label: 'Street address line 2', + validation: [], + value: '' + }, + { + $formkit: 'RplFormText', + id: 'forms_address_locality', + name: 'locality', + label: 'Suburb', + columnClasses: 'rpl-col-12 rpl-col-5-m', + validation: [], + value: '' + }, + { + $formkit: 'RplFormDropdown', + id: 'forms_address_administrative_area', + name: 'administrative_area', + label: 'State', + columnClasses: 'rpl-col-12 rpl-col-5-m', + options: [ + { + id: 'VIC', + value: 'VIC', + label: 'Victoria' + }, + { + id: 'NSW', + value: 'NSW', + label: 'New South Wales' + }, + { + id: 'WA', + value: 'WA', + label: 'Western Australia' + }, + { + id: 'QLD', + value: 'QLD', + label: 'Queensland' + }, + { + id: 'ACT', + value: 'ACT', + label: 'Australian Capital Territory' + }, + { + id: 'NT', + value: 'NT', + label: 'Northern Territory' + }, + { + id: 'SA', + value: 'SA', + label: 'South Australia' + }, + { + id: 'TAS', + value: 'TAS', + label: 'Tasmania' + } + ], + validation: [], + value: '', + pii: false + }, + { + $formkit: 'RplFormText', + id: 'forms_address_postal_code', + name: 'postal_code', + label: 'Postcode', + columnClasses: 'rpl-col-6 rpl-col-3-m', + validation: [], + value: '' + }, + { + $formkit: 'hidden', + id: 'forms_address_country_code', + name: 'country_code', + value: 'AU' + } + ] + } + ] + }, + { + $step: true, + id: 'not-eligible', + key: 'not-eligible', + name: 'not-eligible', + title: 'Not eligible', + nextButton: 'Exit', + prevButton: 'Go backwards', + beforeStepChange: handleEligibilityStepChange, + parentStep: 'eligibility', + schema: [ + { + $el: 'div', + attrs: { + class: 'rpl-content' + }, + children: [ + { + $el: 'p', + children: + 'Unfortunately, based on the information you have provided, you are not eligible to apply for this grant.' + } + ] + } + ] + }, + { + $step: true, + id: 'eligible', + key: 'eligible', + name: 'eligible', + title: 'You are eligible', + nextButton: 'Go forwards', + prevButton: 'Go backwards', + beforeStepChange: handleEligibilityStepChange, + parentStep: 'eligibility', + schema: [ + { + $el: 'div', + attrs: { + class: ['rpl-content', 'rpl-u-padding-b-4'] + }, + children: [ + { + $el: 'p', + children: + 'Congratulations! Based on the information you have provided, you are eligible to apply for this grant.' + } + ] + } + ] + }, + { + $step: true, + id: 'review', + key: 'review', + name: 'review', + title: 'Review', + nextButton: 'Submit', + prevButton: 'Go backwards', + schema: [ + { + $formkit: 'RplFormReview', + key: 'review_component' + } + ] + } + ] + } +} diff --git a/packages/ripple-ui-forms/src/components/RplForm/fixtures/before-step-change.ts b/packages/ripple-ui-forms/src/components/RplForm/fixtures/before-step-change.ts new file mode 100644 index 0000000000..5bf9609fce --- /dev/null +++ b/packages/ripple-ui-forms/src/components/RplForm/fixtures/before-step-change.ts @@ -0,0 +1,79 @@ +import type { StepChangeData } from '@formkit/addons' + +export const handleEligibilityStepChange = async ({ + currentStep, + targetStep, + delta +}: StepChangeData) => { + console.log('beforeStepChange', { currentStep, targetStep, delta }) + + const parentBeforeStepChange = + currentStep.node.parent?.props?.beforeStepChange + + if (typeof parentBeforeStepChange === 'function') { + const isParentAllowed = await parentBeforeStepChange({ + currentStep, + targetStep, + delta + }) + + if (!isParentAllowed) { + return false + } + } + + if (delta > 0 && !currentStep.isValid) { + return false + } + + // Delta < 0 indicates navigating backwards. + // We want to allow users to navigate back to the eligibility step, but prevent navigating back to the branch steps once they've moved past them. + if (delta < 0) { + const branchSteps = ['eligible', 'not-eligible'] + const isCurrentBranchStep = branchSteps.includes(currentStep.node.name) + const isTargetBranchStep = branchSteps.includes(targetStep.node.name) + + // Allow branch step -> eligibility directly (no interception). + if (isCurrentBranchStep && targetStep.node.name === 'eligibility') { + return true + } + + // Prevent navigating backwards between branch steps. + if (isCurrentBranchStep && isTargetBranchStep) { + currentStep.node.parent?.goTo('eligibility') + return false + } + + // Prevent review from going back to a branch step directly. + if (currentStep.node.name === 'review' && isTargetBranchStep) { + currentStep.node.parent?.goTo('eligibility') + return false + } + + return true + } + + if (currentStep.node.name === 'eligibility') { + const organization = + currentStep.value?.address?.organization ?? + currentStep.value?.address?.orginisation + + const desiredStep = organization === 'test' ? 'eligible' : 'not-eligible' + + if (desiredStep !== targetStep.node.name) { + currentStep.node.parent?.goTo(desiredStep) + return false + } + } + + if (currentStep.node.name === 'eligible') { + return true + } + + if (currentStep.node.name === 'not-eligible') { + currentStep.node.parent?.goTo('eligibility') + return false + } + + return true +} diff --git a/packages/ripple-ui-forms/src/components/RplFormReview/RplFormReview.vue b/packages/ripple-ui-forms/src/components/RplFormReview/RplFormReview.vue index 99f3bd7804..3019f82768 100644 --- a/packages/ripple-ui-forms/src/components/RplFormReview/RplFormReview.vue +++ b/packages/ripple-ui-forms/src/components/RplFormReview/RplFormReview.vue @@ -3,6 +3,7 @@ import { computed, inject } from 'vue' import { FormKitSchemaNode } from '@formkit/core' import { isValid, parse } from 'date-fns' import { formatDate } from '@dpc-sdp/ripple-ui-core' +import { RplSummaryList } from '@dpc-sdp/ripple-ui-core/vue' import { IRplFormProvidedState, RplFormKitStepNode } from '../../types' interface Props { diff --git a/packages/ripple-ui-forms/src/components/RplFormStep/RplFormStep.vue b/packages/ripple-ui-forms/src/components/RplFormStep/RplFormStep.vue index 50fcbec795..d6a9992115 100644 --- a/packages/ripple-ui-forms/src/components/RplFormStep/RplFormStep.vue +++ b/packages/ripple-ui-forms/src/components/RplFormStep/RplFormStep.vue @@ -7,6 +7,8 @@ :input-errors="inputErrors" :previous-label="prevButton" :next-label="nextButton" + :before-step-change="beforeStepChange" + @node="setStepNode" >