Skip to content

Commit

Permalink
feat: added tests and changed native validation enum value
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 15, 2024
1 parent 29efb32 commit 0040ac6
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 38 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ dist
.DS_STORE

lerna-debug.log
packages/*/dist/**
packages/*/src/playground.ts
4 changes: 2 additions & 2 deletions packages/core/src/useForm/formContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as u
import { FormSnapshot } from './formSnapshot';
import { isObject, merge } from '../../../shared/src';

export type FormValidationMode = 'native' | 'schema';
export type FormValidationMode = 'aggregate' | 'schema';

export interface BaseFormContext<TForm extends FormObject = FormObject> {
id: string;
Expand Down Expand Up @@ -226,7 +226,7 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
}

function getValidationMode(): FormValidationMode {
return schema ? 'schema' : 'native';
return schema ? 'schema' : 'aggregate';
}

return {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/useFormField/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type FormField<TValue> = {
schema: TypedSchema<TValue> | undefined;
validate(mutate?: boolean): Promise<ValidationResult>;
getPath: Getter<string | undefined>;
getName: Getter<string | undefined>;
setValue: (value: TValue | undefined) => void;
setTouched: (touched: boolean) => void;
setErrors: (messages: Arrayable<string>) => void;
Expand Down Expand Up @@ -78,7 +79,7 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
function createValidationResult(result: Omit<ValidationResult, 'type' | 'path'>): ValidationResult {
return {
type: 'FIELD',
path: getPath() || '',
path: (formGroup ? toValue(opts?.path) : getPath()) || '',
...result,
};
}
Expand Down Expand Up @@ -113,6 +114,7 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
schema: opts?.schema,
validate,
getPath,
getName: () => toValue(opts?.path),
setValue,
setTouched,
setErrors,
Expand Down
166 changes: 154 additions & 12 deletions packages/core/src/useFormGroup/useFormGroup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@ import { TypedSchema, useTextField, useForm, useFormGroup } from '@core/index';
import { fireEvent, render, screen } from '@testing-library/vue';
import { flush } from '@test-utils/flush';

test('warns if no form is present', async () => {
const warnFn = vi.spyOn(console, 'warn');

await renderSetup(() => {
return useFormGroup({ name: 'test' });
});

expect(warnFn).toHaveBeenCalledOnce();
warnFn.mockRestore();
});

function createInputComponent(): Component {
return {
inheritAttrs: false,
Expand Down Expand Up @@ -49,6 +38,17 @@ function createGroupComponent(fn?: (fg: ReturnType<typeof useFormGroup>) => void
};
}

test('warns if no form is present', async () => {
const warnFn = vi.spyOn(console, 'warn');

await renderSetup(() => {
return useFormGroup({ name: 'test' });
});

expect(warnFn).toHaveBeenCalledOnce();
warnFn.mockRestore();
});

test('prefixes path values with its name', async () => {
let form!: ReturnType<typeof useForm>;
await render({
Expand Down Expand Up @@ -169,8 +169,150 @@ test('tracks its valid state', async () => {
expect(groups[0].isValid.value).toBe(false);
expect(groups[1].isValid.value).toBe(true);
await fireEvent.update(screen.getByTestId('field1'), 'test');
await fireEvent.blur(screen.getByTestId('field1'), 'test');
await fireEvent.blur(screen.getByTestId('field1'));
await flush();
expect(groups[0].isValid.value).toBe(true);
expect(groups[1].isValid.value).toBe(true);
});

test('validates with a typed schema', async () => {
let form!: ReturnType<typeof useForm>;
const groups: ReturnType<typeof useFormGroup>[] = [];
const schema: TypedSchema<{ field: string }> = {
async parse(value) {
return {
errors: value.field ? [] : [{ path: 'field', messages: ['error'] }],
};
},
};

await render({
components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) },
setup() {
form = useForm();

return {
schema,
};
},
template: `
<TGroup name="group" :schema="schema">
<TInput name="field" />
</TGroup>
`,
});

await flush();
expect(groups[0].isValid.value).toBe(false);
expect(form.getError('group.field')).toBe('error');
await fireEvent.update(screen.getByTestId('field'), 'test');
await fireEvent.blur(screen.getByTestId('field'));

await flush();
expect(groups[0].isValid.value).toBe(true);
expect(form.getError('group.field')).toBeUndefined();
});

test('validation combines schema with form schema', async () => {
let form!: ReturnType<typeof useForm>;
const groups: ReturnType<typeof useFormGroup>[] = [];
const groupSchema: TypedSchema<{ field: string }> = {
async parse(value) {
return {
errors: value.field ? [] : [{ path: 'field', messages: ['error'] }],
};
},
};

const formSchema: TypedSchema<{ other: string }> = {
async parse(value) {
return {
errors: value.other ? [] : [{ path: 'other', messages: ['error'] }],
};
},
};

await render({
components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) },
setup() {
form = useForm({
schema: formSchema,
}) as any;

return {
groupSchema,
};
},
template: `
<TGroup name="group" :schema="groupSchema">
<TInput name="field" />
</TGroup>
<TInput name="other" />
`,
});

await flush();
expect(form.getErrors()).toHaveLength(2);
await fireEvent.update(screen.getByTestId('field'), 'test');
await fireEvent.blur(screen.getByTestId('other'));
await flush();
expect(form.getErrors()).toHaveLength(1);
await fireEvent.update(screen.getByTestId('other'), 'test');
await fireEvent.blur(screen.getByTestId('other'));
await flush();
expect(form.getErrors()).toHaveLength(0);
});

test('submission combines group data with form data', async () => {
const submitHandler = vi.fn();
const groupSchema: TypedSchema<{ first: string }> = {
async parse() {
return {
output: { first: 'wow', second: 'how' },
errors: [],
};
},
};
await render({
components: { TInput: createInputComponent(), TGroup: createGroupComponent() },
setup() {
const { handleSubmit } = useForm({});

const onSubmit = handleSubmit(submitHandler);

return {
onSubmit,
groupSchema,
};
},
template: `
<TGroup name="group" :schema="groupSchema">
<TInput name="first" />
</TGroup>
<TGroup name="other" >
<TInput name="second" />
</TGroup>
<TInput name="third" />
<button @click="onSubmit">Submit</button>
`,
});

await flush();
expect(submitHandler).not.toHaveBeenCalled();
await fireEvent.update(screen.getByTestId('first'), 'first');
await fireEvent.update(screen.getByTestId('second'), 'second');
await fireEvent.update(screen.getByTestId('third'), 'third');
await flush();
await screen.getByText('Submit').click();
await flush();
expect(submitHandler).toHaveBeenCalledOnce();
expect(submitHandler).toHaveBeenLastCalledWith({
group: { first: 'wow', second: 'how' },
other: { second: 'second' },
third: 'third',
});
});
3 changes: 3 additions & 0 deletions packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { FormKey } from '../useForm';
import { useValidationProvider } from '../validation/useValidationProvider';
import { FormValidationMode } from '../useForm/formContext';

export interface FormGroupProps<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput> {
name: string;
Expand All @@ -42,6 +43,7 @@ interface FormGroupContext<TOutput extends FormObject = FormObject> {
prefixPath: (path: string | undefined) => string | undefined;
onValidationDispatch(cb: (enqueue: (promise: Promise<ValidationResult>) => void) => void): void;
requestValidation(): Promise<GroupValidationResult<TOutput>>;
getValidationMode(): FormValidationMode;
}

export const FormGroupKey: InjectionKey<FormGroupContext> = Symbol('FormGroup');
Expand Down Expand Up @@ -134,6 +136,7 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
prefixPath,
onValidationDispatch,
requestValidation,
getValidationMode: () => (props.schema ? 'schema' : 'aggregate'),
};

// Whenever the form is validated, it is deferred to the form group to do that.
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ interface InputValidityOptions {
export function useInputValidity(opts: InputValidityOptions) {
const form = inject(FormKey, null);
const formGroup = inject(FormGroupKey, null);
const { setErrors, errorMessage, schema, validate: validateField, getPath, fieldValue } = opts.field;
const { setErrors, errorMessage, schema, validate: validateField, getPath, getName, fieldValue } = opts.field;
const validityDetails = shallowRef<ValidityState>();
const validationMode = form?.getValidationMode() ?? 'native';
const validationMode = (formGroup || form)?.getValidationMode() ?? 'aggregate';
useMessageCustomValiditySync(errorMessage, opts.inputRef);

function validateNative(mutate?: boolean): ValidationResult {
Expand All @@ -29,15 +29,15 @@ export function useInputValidity(opts: InputValidityOptions) {

return {
type: 'FIELD',
path: getPath() || '',
path: (formGroup ? getName() : getPath()) || '',
output: cloneDeep(fieldValue.value),
isValid: !messages.length,
errors: [{ messages, path: getPath() || '' }],
};
}

function _updateValidity() {
if (validationMode === 'native') {
if (validationMode === 'aggregate') {
return schema ? validateField(true) : validateNative(true);
}

Expand All @@ -59,13 +59,13 @@ export function useInputValidity(opts: InputValidityOptions) {
return;
}

if (validationMode === 'native') {
if (validationMode === 'aggregate') {
enqueue(Promise.resolve(validateNative(false)));
return;
}
});

if (validationMode === 'native') {
if (validationMode === 'aggregate') {
// It should self-mutate the field errors because this is fired by a native validation and not sourced by the form.
useEventListener(opts.inputRef, opts?.events || ['invalid'], () => validateNative(true));
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/validation/useValidationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,15 @@ export function useValidationProvider<
if (type === 'FORM') {
return {
type,
mode: schema ? 'schema' : 'native',
mode: schema ? 'schema' : 'aggregate',
...base,
} as TResult;
}

return {
type: 'GROUP',
path: getPath?.() || '',
mode: schema ? 'schema' : 'native',
mode: schema ? 'schema' : 'aggregate',
...base,
} as TResult;
}
Expand Down
8 changes: 1 addition & 7 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,7 @@ const groupSchema = defineSchema(
}),
);
const { getErrors, values, handleSubmit } = useForm({
schema: defineSchema(
z.object({
fullName: z.string().min(1),
}),
),
});
const { getErrors, values, handleSubmit } = useForm({});
const onSubmit = handleSubmit(values => {
console.log(values);
Expand Down
17 changes: 9 additions & 8 deletions packages/playground/src/components/FormGroup.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<template>
<fieldset v-bind="groupProps" class="p-2 border border-gray-400 rounded-lg">
<legend v-bind="labelProps">{{label}}</legend>
<legend v-bind="labelProps">{{ label }}</legend>

<slot :display-error="displayError" :get-error="getError" />

<!-- <pre class="bg-gray-600 text-white text-xs p-2.5 rounded-lg">Errors: {{getErrors()}}-->
<!-- </pre>-->
<!-- <pre class="bg-gray-600 text-white text-xs p-2.5 rounded-lg">Errors: {{getErrors()}}-->
<!-- </pre>-->

<!-- <div>values: {{ getValues()}}</div>-->
<!-- <div>touched: {{ isTouched }}</div>-->
<!-- <div>dirty: {{ isDirty }}</div>-->
<!-- <div>valid: {{ isValid }}</div>-->
<!-- <div>values: {{ getValues()}}</div>-->
<!-- <div>touched: {{ isTouched }}</div>-->
<!-- <div>dirty: {{ isDirty }}</div>-->
<div>valid: {{ isValid }}</div>
</fieldset>
</template>

Expand All @@ -19,5 +19,6 @@ import { FormGroupProps, useFormGroup } from '@formwerk/core';
const props = defineProps<FormGroupProps>();
const { labelProps, groupProps, getErrors, getError, displayError, getValues, isValid, isDirty, isTouched } = useFormGroup(props);
const { labelProps, groupProps, getErrors, getError, displayError, getValues, isValid, isDirty, isTouched } =
useFormGroup(props);
</script>

0 comments on commit 0040ac6

Please sign in to comment.