Skip to content

Commit 0b30be9

Browse files
authored
feat: add isValidated flag to field state (#232)
* feat: introduce isValidated flag * chore: changeset * feat: added submit done event * fix: only set it in invalid events if html validation is enabled
1 parent 6ef765d commit 0b30be9

File tree

9 files changed

+437
-10
lines changed

9 files changed

+437
-10
lines changed

.changeset/busy-pigs-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@formwerk/core': patch
3+
---
4+
5+
feat: introduce isValidated field-level flag

packages/core/src/useForm/useForm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export interface FormContext<TInput extends FormObject = FormObject, TOutput ext
239239
FormTransactionManager<TInput> {
240240
requestValidation(): Promise<FormValidationResult<TOutput>>;
241241
onSubmitAttempt(cb: () => void): void;
242+
onSubmitDone(cb: () => void): void;
242243
onValidationDone(cb: () => void): void;
243244
isHtmlValidationDisabled(): boolean;
244245
onValidationDispatch(

packages/core/src/useForm/useFormActions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
103103
const isSubmitAttempted = shallowRef(false);
104104
const wasSubmitted = shallowRef(false);
105105
const [dispatchSubmit, onSubmitAttempt] = createEventDispatcher<void>('submit');
106+
const [dispatchSubmitDone, onSubmitDone] = createEventDispatcher<void>('submit:done');
106107
const {
107108
validate: _validate,
108109
onValidationDispatch,
@@ -129,6 +130,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
129130

130131
// Prevent submission if the form has errors
131132
if (!isValid) {
133+
dispatchSubmitDone();
132134
isSubmitting.value = false;
133135
scrollToFirstInvalidField(form.id, scrollToInvalidFieldOnSubmit);
134136
return;
@@ -143,6 +145,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
143145
unsetPath(output, path, true);
144146
}
145147

148+
dispatchSubmitDone();
146149
const result = await onSuccess(asConsumableData(output), { event: e, form: e?.target as HTMLFormElement });
147150
isSubmitting.value = false;
148151
wasSubmitted.value = true;
@@ -332,6 +335,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
332335
onSubmitAttempt,
333336
onValidationDispatch,
334337
onValidationDone,
338+
onSubmitDone,
335339
isSubmitting,
336340
submitAttemptsCount,
337341
wasSubmitted,

packages/core/src/useFormField/useFieldState.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
nextTick,
77
provide,
88
readonly,
9+
ref,
910
Ref,
1011
shallowRef,
1112
toValue,
@@ -42,6 +43,7 @@ export type FieldState<TValue> = {
4243
isDirty: Ref<boolean>;
4344
isBlurred: Ref<boolean>;
4445
isValid: Ref<boolean>;
46+
isValidated: Ref<boolean>;
4547
isDisabled: Ref<boolean>;
4648
errors: Ref<string[]>;
4749
errorMessage: Ref<string>;
@@ -55,6 +57,7 @@ export type FieldState<TValue> = {
5557
setTouched: (touched: boolean) => void;
5658
setBlurred: (blurred: boolean) => void;
5759
setErrors: (messages: Arrayable<string>) => void;
60+
setIsValidated: (isValidated: boolean) => void;
5861
form?: FormContext | null;
5962
};
6063

@@ -72,6 +75,7 @@ export function useFieldState<TValue = unknown>(opts?: Partial<FieldStateInit<TV
7275
const { fieldValue, pathlessValue, setValue } = useFieldValue(getPath, form, initialValue);
7376
const { isTouched, pathlessTouched, setTouched } = useFieldTouched(getPath, form);
7477
const { isBlurred, pathlessBlurred, setBlurred } = useFieldBlurred(getPath, form);
78+
const { isValidated, setIsValidated } = useFieldIsValidated();
7579

7680
const { errors, setErrors, isValid, errorMessage, pathlessValidity, submitErrors, submitErrorMessage } =
7781
useFieldValidity(getPath, isDisabled, form);
@@ -132,6 +136,7 @@ export function useFieldState<TValue = unknown>(opts?: Partial<FieldStateInit<TV
132136
isBlurred: readonly(isBlurred) as Ref<boolean>,
133137
isDirty,
134138
isValid,
139+
isValidated,
135140
errors,
136141
errorMessage,
137142
isDisabled,
@@ -143,6 +148,7 @@ export function useFieldState<TValue = unknown>(opts?: Partial<FieldStateInit<TV
143148
setTouched,
144149
setBlurred,
145150
setErrors,
151+
setIsValidated,
146152
submitErrors,
147153
submitErrorMessage,
148154
};
@@ -164,6 +170,10 @@ export function useFieldState<TValue = unknown>(opts?: Partial<FieldStateInit<TV
164170
setTouched(true);
165171
});
166172

173+
form.onSubmitDone(() => {
174+
setIsValidated(true);
175+
});
176+
167177
tryOnScopeDispose(() => {
168178
const path = getPath();
169179
if (!path) {
@@ -491,3 +501,21 @@ export function resolveFieldState<TValue = unknown, TInitialValue = TValue>(
491501
useFieldState<TValue | undefined>(getStateInit<TValue, TInitialValue>(props, resolveValue))
492502
);
493503
}
504+
505+
/**
506+
* Tracks whether the field has been validated.
507+
* This is useful for determining whether to show validation errors or not.
508+
*/
509+
export function useFieldIsValidated() {
510+
// Right now, there is no need to track it in a form.
511+
const isValidated = ref(false);
512+
513+
function setIsValidated(value: boolean) {
514+
isValidated.value = value;
515+
}
516+
517+
return {
518+
isValidated,
519+
setIsValidated,
520+
};
521+
}

packages/core/src/useFormField/useFormField.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,118 @@ test('validate warns and skips validation on a disabled field', async () => {
294294
// Clean up the mocks
295295
consoleWarnSpy.mockRestore();
296296
});
297+
298+
describe('isValidated state', () => {
299+
test('field starts with isValidated as false', async () => {
300+
const { isValidated } = await renderSetup(() => {
301+
return useFormField({ label: 'Field', initialValue: 'bar' }).state;
302+
});
303+
304+
expect(isValidated.value).toBe(false);
305+
});
306+
307+
test('isValidated remains false after programmatic validation without schema', async () => {
308+
const { isValidated, validate } = await renderSetup(() => {
309+
return useFormField({ label: 'Field', initialValue: 'bar' }).state;
310+
});
311+
312+
expect(isValidated.value).toBe(false);
313+
await validate();
314+
expect(isValidated.value).toBe(false); // Should remain false - programmatic validation
315+
});
316+
317+
test('isValidated remains false after programmatic validation with schema', async () => {
318+
const { isValidated, validate } = await renderSetup(() => {
319+
return useFormField({
320+
label: 'Field',
321+
initialValue: 'bar',
322+
schema: defineStandardSchema(async () => {
323+
return { value: 'bar' };
324+
}),
325+
}).state;
326+
});
327+
328+
expect(isValidated.value).toBe(false);
329+
await validate();
330+
expect(isValidated.value).toBe(false); // Should remain false - programmatic validation
331+
});
332+
333+
test('isValidated remains false after programmatic validation with errors', async () => {
334+
const { isValidated, validate } = await renderSetup(() => {
335+
return useFormField({
336+
label: 'Field',
337+
initialValue: '',
338+
schema: defineStandardSchema(async () => {
339+
return { issues: [{ message: 'Required', path: ['field'] }] };
340+
}),
341+
}).state;
342+
});
343+
344+
expect(isValidated.value).toBe(false);
345+
await validate(true);
346+
expect(isValidated.value).toBe(false); // Should remain false - programmatic validation
347+
});
348+
349+
test('isValidated can be set manually', async () => {
350+
const { isValidated, setIsValidated } = await renderSetup(() => {
351+
return useFormField({ label: 'Field', initialValue: 'bar' }).state;
352+
});
353+
354+
expect(isValidated.value).toBe(false);
355+
setIsValidated(true);
356+
expect(isValidated.value).toBe(true);
357+
setIsValidated(false);
358+
expect(isValidated.value).toBe(false);
359+
});
360+
361+
test('isValidated becomes true after form submit attempt', async () => {
362+
const { form, field } = await renderSetup(
363+
() => {
364+
const form = useForm({
365+
initialValues: { field: 'valid' },
366+
schema: defineStandardSchema<{ field: string }>(async () => {
367+
return { value: { field: 'valid' } };
368+
}),
369+
});
370+
return { form };
371+
},
372+
() => {
373+
const field = useFormField({
374+
path: 'field',
375+
schema: defineStandardSchema(async () => {
376+
return { value: 'valid' };
377+
}),
378+
});
379+
return { field };
380+
},
381+
);
382+
383+
expect(field.state.isValidated.value).toBe(false);
384+
385+
// Submit attempt is a user interaction
386+
const handleSubmit = form.handleSubmit(async () => {});
387+
await handleSubmit();
388+
389+
expect(field.state.isValidated.value).toBe(true);
390+
});
391+
392+
test('isValidated is independent for pathless fields', async () => {
393+
const { field1, field2 } = await renderSetup(() => {
394+
const field1 = useFormField({ label: 'Field1', initialValue: 'bar' });
395+
const field2 = useFormField({ label: 'Field2', initialValue: 'baz' });
396+
return { field1, field2 };
397+
});
398+
399+
expect(field1.state.isValidated.value).toBe(false);
400+
expect(field2.state.isValidated.value).toBe(false);
401+
402+
// Manually set isValidated (simulating user interaction)
403+
field1.state.setIsValidated(true);
404+
expect(field1.state.isValidated.value).toBe(true);
405+
expect(field2.state.isValidated.value).toBe(false);
406+
407+
field2.state.setIsValidated(true);
408+
expect(field1.state.isValidated.value).toBe(true);
409+
expect(field2.state.isValidated.value).toBe(true);
410+
});
411+
});

packages/core/src/useFormField/useFormField.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ export type ExposedField<TValue> = {
115115
*/
116116
isValid: Ref<boolean>;
117117

118+
/**
119+
* Whether the field is validated, used to determine whether to show validation errors or not.
120+
*/
121+
isValidated: Ref<boolean>;
122+
118123
/**
119124
* Whether the field is disabled.
120125
*/
@@ -135,6 +140,11 @@ export type ExposedField<TValue> = {
135140
*/
136141
setBlurred: (blurred: boolean) => void;
137142

143+
/**
144+
* Sets the validated state for the field.
145+
*/
146+
setIsValidated: (isValidated: boolean) => void;
147+
138148
/**
139149
* Sets the value for the field.
140150
*/
@@ -176,6 +186,7 @@ export function exposeField<TReturns extends object, TValue>(
176186
isTouched: field.state.isTouched,
177187
isBlurred: field.state.isBlurred,
178188
isValid: field.state.isValid,
189+
isValidated: field.state.isValidated,
179190
isDisabled: field.state.isDisabled,
180191
labelProps: field.labelProps,
181192
descriptionProps: field.descriptionProps,
@@ -191,6 +202,7 @@ export function exposeField<TReturns extends object, TValue>(
191202
: field.state.setErrors,
192203
setTouched: field.state.setTouched,
193204
setBlurred: field.state.setBlurred,
205+
setIsValidated: field.state.setIsValidated,
194206
setValue: field.state.setValue,
195207
validate: (mutate = true) => field.state.validate(mutate),
196208
...obj,

0 commit comments

Comments
 (0)