Skip to content

Commit bac214f

Browse files
authored
Frontend/form submission validation visibility (#878)
### Requirements List - _None_ ### Description List - Update practitioner registration and obtain privileges form to include a feature that scrolls and focuses the user to the first invalid required input in the form (from top to bottom) - Undoes the prior flow which includes disabled continue buttons until all required fields are valid - Improve unrelated test coverage - Fix mobile bug where nav would scroll behind the fixed next / previous buttons in the obtain privileges flow - Update payment summary screen title ### Testing List - `yarn test:unit:all` should run without errors or warnings - `yarn serve` should run without errors or warnings - `yarn build` should run without errors or warnings - `yarn test:unit:coverage` confirm the branches line item has an updated acceptable percentage - Code review - Testing - Proceed to the practitioner registration form and confirm that the continue button is no longer disabled and will auto position screen to the first invalid required field in the form, as well as direct focus on that input element, with the exception of radio buttons which will focus the entire input group - Confirm that form can be successfully submitted as expected - Confirm that same experience in the obtain privileges form flow with a slight exception to the select privileges screen which disables the continue button initially until at least 1 state is selected - Confirm that form can be successfully submitted as expected Closes #855 Closes #863 Closes #605 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Forms now automatically scroll and focus to the first invalid input when validation fails. * Payment summary screen displays a new overlay payment button when submission is disabled. * **Bug Fixes** * Validation for checkboxes and attestations now correctly enforces that required boxes must be checked. * Attestation inputs for state selection are now managed more reliably, improving form behavior. * **Style** * Improved spacing and layout for form navigation and payment buttons. * Enhanced error highlighting for checkbox fields. * Main navigation now appears above more elements due to increased stacking order. * **Localization** * Updated Spanish translation for "payment summary". * **Tests** * Added tests for form validation, scrolling to invalid inputs, and date formatting utilities. * **Refactor** * Simplified logic for enabling submit buttons in several forms. * Streamlined attestation input management in state selection forms. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c01b7d6 commit bac214f

File tree

17 files changed

+339
-72
lines changed

17 files changed

+339
-72
lines changed

webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
<template>
99
<div
10+
:id="formInput.id"
1011
class="input-container radio-group-container"
1112
:class="{
1213
'has-error': !!formInput.errorMessage,

webroot/src/components/Forms/_mixins/form.mixin.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ class MixinForm extends Vue {
4242
return values;
4343
}
4444

45+
get formInputs(): Array<FormInput> {
46+
return this.formKeys
47+
.map((key) => this.formData[key])
48+
.filter((input) => input instanceof FormInput);
49+
}
50+
4551
get formSubmitInputs(): Array<FormInput> {
4652
const { formData } = this;
4753

@@ -155,23 +161,23 @@ class MixinForm extends Vue {
155161
}
156162

157163
validateAll(config: any = {}): void {
158-
const { formData } = this;
159-
160-
this.formKeys.forEach((key) => {
164+
this.formInputs.forEach((input) => {
161165
if (config.asTouched) {
162-
formData[key].isTouched = true;
166+
input.isTouched = true;
163167
}
164-
165-
formData[key].validate();
168+
input.validate();
166169
});
167170

168171
this.checkValidForAll();
172+
173+
// Scroll to first invalid input unless explicitly skipped
174+
if (!this.isFormValid && !config.skipErrorScroll) {
175+
this.showInvalidFormError();
176+
}
169177
}
170178

171179
checkValidForAll(): void {
172-
const { formData } = this;
173-
174-
this.isFormValid = this.formKeys.every((key) => formData[key].isValid);
180+
this.isFormValid = this.formInputs.every((input) => input.isValid);
175181
}
176182

177183
updateFormSubmitSuccess(message: string): void {
@@ -211,6 +217,31 @@ class MixinForm extends Vue {
211217
this.updateFormSubmitError(errorMessage);
212218
}
213219

220+
showInvalidFormError(): void {
221+
// Find the first invalid input that has a validation schema
222+
const firstInvalidInput = this.formInputs.find((input) =>
223+
!input.isSubmitInput && !input.isValid);
224+
225+
// If we found an invalid input, try to scroll to and focus its input by name
226+
if (firstInvalidInput) {
227+
this.scrollToInput(firstInvalidInput);
228+
}
229+
}
230+
231+
protected scrollToInput(formInput: FormInput): void {
232+
const element = document.getElementsByName(formInput.name)[0] as HTMLElement | undefined;
233+
234+
if (element) {
235+
// Scroll to the element
236+
element.scrollIntoView({
237+
behavior: 'smooth',
238+
block: 'center'
239+
});
240+
241+
element.focus();
242+
}
243+
}
244+
214245
@Watch('locale') localeChanged() {
215246
// @TODO: For now we just brute force the form re-init on language change. Making all the layers reactive (messages inside Joi schemas, etc) was messy.
216247
this.initFormInputs();

webroot/src/components/Forms/_mixins/mixins.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { mountShallow } from '@tests/helpers/setup';
99
import FormMixin from '@components/Forms/_mixins/form.mixin';
1010
import InputMixin from '@components/Forms/_mixins/input.mixin';
1111
import { FormInput } from '@models/FormInput/FormInput.model';
12+
import Joi from 'joi';
13+
import sinon from 'sinon';
1214

1315
const chaiMatchPattern = require('chai-match-pattern');
1416
const chai = require('chai').use(chaiMatchPattern);
@@ -124,6 +126,96 @@ describe('Form mixin', async () => {
124126

125127
expect(component.isFormError).to.equal(true);
126128
});
129+
it('should show invalid form error when there is an invalid input', async () => {
130+
const wrapper = await mountShallow(FormMixin);
131+
const component = wrapper.vm;
132+
const formInput = new FormInput({
133+
name: 'test-input',
134+
isValid: false,
135+
isSubmitInput: false,
136+
validation: Joi.string().required()
137+
});
138+
const spy = sinon.spy();
139+
140+
component.formData.testInput = formInput;
141+
component.scrollToInput = spy;
142+
component.showInvalidFormError();
143+
144+
expect(spy.calledOnce).to.equal(true);
145+
});
146+
it('should not call scrollToInput when all inputs are valid', async () => {
147+
const wrapper = await mountShallow(FormMixin);
148+
const component = wrapper.vm;
149+
const formInput = new FormInput({
150+
name: 'test-input',
151+
isValid: true,
152+
isSubmitInput: false,
153+
});
154+
const spy = sinon.spy();
155+
156+
component.formData.testInput = formInput;
157+
component.scrollToInput = spy;
158+
component.showInvalidFormError();
159+
160+
expect(spy.notCalled).to.equal(true);
161+
});
162+
it('should skip submit inputs when finding invalid form inputs', async () => {
163+
const wrapper = await mountShallow(FormMixin);
164+
const component = wrapper.vm;
165+
const submitInput = new FormInput({
166+
name: 'submit-input',
167+
isValid: false,
168+
isSubmitInput: true,
169+
validation: Joi.string().required()
170+
});
171+
const regularInput = new FormInput({
172+
name: 'regular-input',
173+
isValid: false,
174+
isSubmitInput: false,
175+
validation: Joi.string().required()
176+
});
177+
const spy = sinon.spy();
178+
179+
component.formData.submitInput = submitInput;
180+
component.formData.regularInput = regularInput;
181+
component.scrollToInput = spy;
182+
component.showInvalidFormError();
183+
184+
expect(spy.calledOnce).to.equal(true);
185+
expect(spy.firstCall.args[0].name).to.equal(regularInput.name);
186+
});
187+
it('should scroll to input element when element exists', async () => {
188+
const wrapper = await mountShallow(FormMixin);
189+
const component = wrapper.vm;
190+
const formInput = new FormInput({
191+
name: 'test-input',
192+
});
193+
const scrollSpy = sinon.spy();
194+
const focusSpy = sinon.spy();
195+
const mockElement = {
196+
scrollIntoView: scrollSpy,
197+
focus: focusSpy
198+
} as unknown as HTMLElement;
199+
200+
sinon.stub(document, 'getElementsByName').callsFake((name: string) => {
201+
if (name === 'test-input') {
202+
return {
203+
length: 1,
204+
0: mockElement,
205+
item: (index: number) => (index === 0 ? mockElement : null),
206+
} as unknown as NodeListOf<HTMLElement>;
207+
}
208+
return {
209+
length: 0,
210+
item: () => null,
211+
} as unknown as NodeListOf<HTMLElement>;
212+
});
213+
214+
component.scrollToInput(formInput);
215+
216+
expect(scrollSpy.calledOnce).to.be.true;
217+
expect(focusSpy.calledOnce).to.be.true;
218+
});
127219
});
128220
describe('Input mixin', async () => {
129221
it('should mount the component', async () => {

webroot/src/components/Page/PageMainNav/PageMainNav.less

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
position: fixed;
1212
top: @appHeaderHeight;
1313
left: 0;
14-
z-index: 1;
14+
z-index: 4;
1515
display: flex;
1616
flex-direction: column;
1717
width: 0;

webroot/src/components/PrivilegeCard/PrivilegeCard.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135
? $t('common.loading')
136136
: $t('licensing.confirmPrivilegeDeactivateSubmit')"
137137
:isWarning="true"
138-
:isEnabled="isFormValid && !isFormLoading"
138+
:isEnabled="!isFormLoading"
139139
/>
140140
</div>
141141
</form>

webroot/src/components/PrivilegePurchaseFinalize/PrivilegePurchaseFinalize.less

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,25 @@
115115
}
116116
}
117117
}
118+
119+
.payment-button-container {
120+
position: relative;
121+
flex-grow: 1;
122+
123+
.payment-overlay-button {
124+
position: absolute;
125+
top: 0;
126+
left: 0;
127+
z-index: 1;
128+
justify-content: start;
129+
width: 100%;
130+
height: 100%;
131+
132+
:deep(.input-button) {
133+
width: 100%;
134+
}
135+
}
136+
}
118137
}
119138

120139
.form-row {

webroot/src/components/PrivilegePurchaseFinalize/PrivilegePurchaseFinalize.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ export default class PrivilegePurchaseFinalize extends mixins(MixinForm) {
265265
id: 'no-refunds-check',
266266
name: 'no-refunds-check',
267267
label: this.$t('licensing.noRefundsMessage'),
268-
validation: Joi.boolean().required(),
268+
validation: Joi.boolean().invalid(false).required().messages(this.joiMessages.boolean),
269269
value: false,
270270
isDisabled: false
271271
}),
@@ -382,4 +382,13 @@ export default class PrivilegePurchaseFinalize extends mixins(MixinForm) {
382382

383383
formButtons?.scrollIntoView({ behavior: 'smooth', block: 'center' });
384384
}
385+
386+
handlePaymentButtonClick(): void {
387+
// Validate all inputs first to ensure we have current validation state
388+
this.validateAll({ asTouched: true });
389+
390+
if (!this.isFormValid) {
391+
this.showInvalidFormError();
392+
}
393+
}
385394
}

webroot/src/components/PrivilegePurchaseFinalize/PrivilegePurchaseFinalize.vue

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<form class="complete-purchase-form" @submit.prevent="() => null">
1111
<div class="finalize-purchase-container">
1212
<div class="finalize-purchase-title-row">
13-
<h1 class="finalize-purchase-title">{{$t('payment.payment')}}</h1>
13+
<h1 class="finalize-purchase-title">{{$t('payment.paymentSummary')}}</h1>
1414
</div>
1515
<MockPopulate :isEnabled="isMockPopulateEnabled" @selected="mockPopulate" />
1616
<div class="cost-breakdown-container">
@@ -55,14 +55,23 @@
5555
<div v-if="formErrorMessage" class="form-error-message">{{formErrorMessage}}</div>
5656
<div id="button-row" class="button-row">
5757
<div class="form-nav-buttons">
58-
<PrivilegePurchaseAcceptUI
59-
class="form-nav-button accept-ui"
60-
:paymentSdkConfig="currentCompactPaymentSdkConfig"
61-
:buttonLabel="$t('common.next')"
62-
:isEnabled="!isFormLoading && isSubmitEnabled"
63-
@success="acceptUiSuccessResponse"
64-
@error="acceptUiErrorResponse"
65-
/>
58+
<div class="payment-button-container">
59+
<InputButton
60+
v-if="!isSubmitEnabled"
61+
:label="$t('payment.payment')"
62+
:isDisabled="isFormLoading || !isSubmitEnabled"
63+
class="payment-overlay-button"
64+
@click="handlePaymentButtonClick"
65+
/>
66+
<PrivilegePurchaseAcceptUI
67+
class="form-nav-button accept-ui"
68+
:paymentSdkConfig="currentCompactPaymentSdkConfig"
69+
:buttonLabel="$t('payment.payment')"
70+
:isEnabled="!isFormLoading && isSubmitEnabled"
71+
@success="acceptUiSuccessResponse"
72+
@error="acceptUiErrorResponse"
73+
/>
74+
</div>
6675
<InputButton
6776
:label="$t('common.back')"
6877
:isTransparent="true"

0 commit comments

Comments
 (0)