Skip to content

Commit

Permalink
feat: implement useFormGroup state aggregator
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 14, 2024
1 parent 0fe6ae2 commit ff97060
Show file tree
Hide file tree
Showing 8 changed files with 258 additions and 24 deletions.
1 change: 1 addition & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare const __DEV__: boolean;
9 changes: 7 additions & 2 deletions packages/core/src/useForm/formContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { cloneDeep, normalizeArrayable } from '../utils/common';
import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path';
import { FormSnapshot } from './formSnapshot';
import { merge } from '../../../shared/src';
import { isObject, merge } from '../../../shared/src';

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

Expand Down Expand Up @@ -83,7 +83,12 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
}

function isFieldTouched<TPath extends Path<TForm>>(path: TPath) {
return !!getFromPath(touched, path);
const value = getFromPath(touched, path);
if (isObject(value)) {
return !!findLeaf(value, v => !!v);
}

return !!value;
}

function isFieldSet<TPath extends Path<TForm>>(path: TPath) {
Expand Down
176 changes: 176 additions & 0 deletions packages/core/src/useFormGroup/useFormGroup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { renderSetup } from '@test-utils/renderSetup';
import { Component } from 'vue';
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,
setup: (_, { attrs }) => {
const name = (attrs.name || 'test') as string;
const schema = attrs.schema as TypedSchema<any>;
const { errorMessage, inputProps } = useTextField({ name, label: name, schema });

return { errorMessage: errorMessage, inputProps, name };
},
template: `
<input v-bind="inputProps" :data-testid="name" />
<span data-testid="err">{{ errorMessage }}</span>
`,
};
}

function createGroupComponent(fn?: (fg: ReturnType<typeof useFormGroup>) => void): Component {
return {
inheritAttrs: false,
setup: (_, { attrs }) => {
const name = (attrs.name || 'test') as string;
const schema = attrs.schema as TypedSchema<any>;
const fg = useFormGroup({ name, label: name, schema });
fn?.(fg);

return {};
},
template: `
<slot />
`,
};
}

test('prefixes path values with its name', async () => {
let form!: ReturnType<typeof useForm>;
await render({
components: { TInput: createInputComponent(), TGroup: createGroupComponent() },
setup() {
form = useForm();

return {};
},
template: `
<TGroup name="groupTest">
<TInput name="field1" />
</TGroup>
<TGroup name="nestedGroup.deep">
<TInput name="field2" />
</TGroup>
`,
});

await flush();
await fireEvent.update(screen.getByTestId('field1'), 'test 1');
await fireEvent.update(screen.getByTestId('field2'), 'test 2');
await flush();

expect(form.values).toEqual({ groupTest: { field1: 'test 1' }, nestedGroup: { deep: { field2: 'test 2' } } });
});

test('tracks its dirty state', async () => {
const groups: ReturnType<typeof useFormGroup>[] = [];
await render({
components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) },
setup() {
useForm();

return {};
},
template: `
<TGroup name="groupTest">
<TInput name="field1" />
</TGroup>
<TGroup name="nestedGroup.deep">
<TInput name="field2" />
</TGroup>
`,
});

await flush();
expect(groups[0].isDirty.value).toBe(false);
expect(groups[1].isDirty.value).toBe(false);
await fireEvent.update(screen.getByTestId('field1'), 'test 1');
await flush();
expect(groups[0].isDirty.value).toBe(true);
expect(groups[1].isDirty.value).toBe(false);
});

test('tracks its touched state', async () => {
const groups: ReturnType<typeof useFormGroup>[] = [];
await render({
components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) },
setup() {
useForm();

return {};
},
template: `
<TGroup name="groupTest">
<TInput name="field1" />
</TGroup>
<TGroup name="nestedGroup.deep">
<TInput name="field2" />
</TGroup>
`,
});

await flush();
expect(groups[0].isTouched.value).toBe(false);
expect(groups[1].isTouched.value).toBe(false);
await fireEvent.touch(screen.getByTestId('field1'));
await flush();
expect(groups[0].isTouched.value).toBe(true);
expect(groups[1].isTouched.value).toBe(false);
});

test('tracks its valid state', async () => {
const groups: ReturnType<typeof useFormGroup>[] = [];
const schema: TypedSchema<string> = {
async parse(value) {
return {
errors: value ? [] : [{ path: 'groupTest.field1', messages: ['error'] }],
};
},
};

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

return {
schema,
};
},
template: `
<TGroup name="groupTest">
<TInput name="field1" :schema="schema" />
</TGroup>
<TGroup name="nestedGroup.deep">
<TInput name="field2" />
</TGroup>
`,
});

await flush();
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 flush();
expect(groups[0].isValid.value).toBe(true);
expect(groups[1].isValid.value).toBe(true);
});
47 changes: 45 additions & 2 deletions packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createBlock,
defineComponent,
Fragment,
inject,
InjectionKey,
openBlock,
provide,
Expand All @@ -14,10 +15,11 @@ import {
import { useLabel } from '../a11y/useLabel';
import { FieldTypePrefixes } from '../constants';
import { AriaLabelableProps, AriaLabelProps, FormObject, Reactivify, TypedSchema } from '../types';
import { normalizeProps, useUniqId, withRefCapture } from '../utils/common';
import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { FormKey } from '@core/useForm';

export interface FormGroupProps<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput> {
name?: string;
name: string;
label?: string;
schema?: TypedSchema<TInput, TOutput>;
}
Expand All @@ -40,6 +42,10 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
const id = useUniqId(FieldTypePrefixes.FormGroup);
const props = normalizeProps(_props);
const groupRef = elementRef || shallowRef<HTMLInputElement>();
const form = inject(FormKey, null);
if (!form) {
warn('Form groups must have a parent form. Please make sure to call `useForm` at a parent component.');
}

const { labelProps, labelledByProps } = useLabel({
for: id,
Expand All @@ -63,6 +69,36 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext

const FormGroup = createInlineFormGroupComponent({ groupProps, labelProps });

function getValues() {
return form?.getFieldValue(toValue(props.name)) ?? {};
}

function getErrors() {
const path = toValue(props.name);
const allErrors = form?.getErrors() || [];

return allErrors.filter(e => e.path.startsWith(path));
}

const isValid = computed(() => getErrors().length === 0);
const isTouched = computed(() => form?.isFieldTouched(toValue(props.name)) ?? false);
const isDirty = computed(() => {
const path = toValue(props.name);

return !isEqual(getValues(), form?.getFieldOriginalValue(path) ?? {});
});

function getError(name: string) {
return form?.getFieldErrors(ctx.prefixPath(name) ?? '')?.[0];
}

function displayError(name: string) {
const msg = getError(name);
const path = ctx.prefixPath(name) ?? '';

return form?.isFieldTouched(path) ? msg : undefined;
}

const ctx: FormGroupContext = {
prefixPath(path: string | undefined) {
return prefixGroupPath(toValue(props.name), path);
Expand All @@ -76,6 +112,13 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
labelProps,
groupProps,
FormGroup,
isDirty,
isValid,
isTouched,
getErrors,
getValues,
getError,
displayError,
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,9 @@ export function batchAsync<TFunction extends (...args: any) => Promise<any>, TRe
return new Promise<TResult>(resolve => resolves.push(resolve));
};
}

export function warn(message: string) {
if (__DEV__) {
console.warn(`[Formwerk]: ${message}`);
}
}
25 changes: 10 additions & 15 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,32 +1,27 @@
<template>
<div class="flex flex-col">
<!-- <FormGroup v-slot="{ groupProps, labelProps }">
<div v-bind="groupProps">
<div v-bind="labelProps">Shipping Address</div>
<InputText label="deep" name="deep.path" />
<InputText label="arr" name="array.0.path" />
</div>
</FormGroup> -->

<FormGroup name="group1" label="Group 1">
<InputText label="Email" name="email" type="email" :schema="defineSchema(z.string().email())" />
<InputText label="Other" name="other" required />
</FormGroup>

<button @click="onSubmit">Submit</button>
<FormGroup name="group2" label="Group 2" class="mt-6">
<InputText label="Email" name="email" type="email" :schema="defineSchema(z.string().email())" />
<InputText label="Other" name="other" required />
</FormGroup>

<pre>{{ values }}</pre>
<pre>{{ getErrors() }}</pre>

<button @click="onSubmit">Submit</button>
</div>
</template>

<script lang="ts" setup>
import InputText from '@/components/InputText.vue';
// import FormGroup from '@/components/FormGroup.vue';
import { useForm, useFormGroup } from '@formwerk/core';
import FormGroup from '@/components/FormGroup.vue';
import { useForm } from '@formwerk/core';
import { defineSchema } from '@formwerk/schema-zod';
import { z } from 'zod';
// const { FormGroup } = useFormGroup({ name: 'some.deep', label: 'Shipping Information' });
const { getErrors, values, handleSubmit } = useForm({
schema: defineSchema(
Expand Down
16 changes: 12 additions & 4 deletions packages/playground/src/components/FormGroup.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
<template>
<fieldset v-bind="groupProps">
<legend v-bind="labelProps">Shipping Address</legend>
<fieldset v-bind="groupProps" class="p-2 border border-gray-400 rounded-lg">
<legend v-bind="labelProps">{{label}}</legend>

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

<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>
</fieldset>
</template>

Expand All @@ -11,5 +19,5 @@ import { FormGroupProps, useFormGroup } from '@formwerk/core';
const props = defineProps<FormGroupProps>();
const { labelProps, groupProps } = useFormGroup(props);
const { labelProps, groupProps, getErrors, getError, displayError, getValues, isValid, isDirty, isTouched } = useFormGroup(props);
</script>
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@
"types": ["vitest/globals"],
"typeRoots": ["node_modules/@types", "node_modules"]
},
"include": ["packages/*/src", "packages/*/tests", "commitlint.config.ts"],
"include": ["packages/*/src", "packages/*/tests", "commitlint.config.ts", "global.d.ts"],
"exclude": ["packages/playground"]
}

0 comments on commit ff97060

Please sign in to comment.