Skip to content

Commit

Permalink
feat: Form Groups (#48)
Browse files Browse the repository at this point in the history
* feat: basic form group impl with aria support

* feat: add inline component implementation

* feat: form group context and path prefixes

* refactor: move useformfield to top level

* refactor: move form folder to useform

* feat: implement field level schema

* fix: util bug

* fix: combine field and form level errors

* test: use flush before submit

* feat: implement useFormGroup state aggregator

* feat: form-group level validation

* feat: added support for output merging

* style: fix lint rule

* feat: added tests and changed native validation enum value

* fix: sort merged outputs correctly

* refactor: rename merge to stitch because it is cooler

* fix: import path

* feat: added schema prop to all input composables
  • Loading branch information
logaretm authored Aug 16, 2024
1 parent a99b99c commit a722f97
Show file tree
Hide file tree
Showing 40 changed files with 1,062 additions and 153 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
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default tseslint.config(
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
{
Expand Down
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;
1 change: 1 addition & 0 deletions packages/core/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const FieldTypePrefixes = {
RadioButtonGroup: 'rbg',
Slider: 'sl',
SearchField: 'sf',
FormGroup: 'fg',
} as const;

export const NOOP = () => {};
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './useNumberField';
export * from './useSpinButton';
export * from './types';
export * from './config';
export * from './form';
export * from './useForm';
export * from './useFormGroup';
export * from './validation';
export { normalizePath } from './utils/path';
2 changes: 1 addition & 1 deletion packages/core/src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type Numberish = number | `${number}`;

export type AriaLabelProps = {
id: string;
for: string;
for?: string;
};

export type AriaDescriptionProps = {
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/types/forms.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import { Schema, Simplify } from 'type-fest';
import { FormObject } from './common';
import { Path } from './paths';
import { TypedSchemaError } from './typedSchema';
import { FormValidationMode } from '../useForm/formContext';

export type TouchedSchema<TForm extends FormObject> = Simplify<Schema<TForm, boolean>>;

export type DisabledSchema<TForm extends FormObject> = Partial<Record<Path<TForm>, boolean>>;

export type ErrorsSchema<TForm extends FormObject> = Partial<Record<Path<TForm>, string[]>>;

type BaseValidationResult = {
isValid: boolean;
errors: TypedSchemaError[];
};

export interface ValidationResult<TValue = unknown> extends BaseValidationResult {
type: 'FIELD';
output: TValue;
path: string;
}

export interface GroupValidationResult<TOutput extends FormObject = FormObject> extends BaseValidationResult {
type: 'GROUP';
path: string;
output: TOutput;
mode: FormValidationMode;
}

export interface FormValidationResult<TOutput extends FormObject = FormObject> extends BaseValidationResult {
type: 'FORM';
output: TOutput;
mode: FormValidationMode;
}

export type AnyValidationResult = GroupValidationResult | ValidationResult;
11 changes: 7 additions & 4 deletions packages/core/src/useCheckbox/useCheckbox.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Ref, computed, inject, nextTick, ref, toValue } from 'vue';
import { isEqual, normalizeProps, useUniqId, withRefCapture } from '../utils/common';
import { AriaLabelableProps, Reactivify, InputBaseAttributes, RovingTabIndex } from '../types';
import { AriaLabelableProps, Reactivify, InputBaseAttributes, RovingTabIndex, TypedSchema } from '../types';
import { useLabel } from '../a11y/useLabel';
import { CheckboxGroupContext, CheckboxGroupKey } from './useCheckboxGroup';
import { useFormField } from '../form/useFormField';
import { useFormField } from '../useFormField';
import { FieldTypePrefixes } from '../constants';

export interface CheckboxProps<TValue = string> {
Expand All @@ -14,6 +14,8 @@ export interface CheckboxProps<TValue = string> {
trueValue?: TValue;
falseValue?: TValue;
indeterminate?: boolean;

schema?: TypedSchema<TValue>;
}

export interface CheckboxDomInputProps extends AriaLabelableProps, InputBaseAttributes {
Expand All @@ -30,10 +32,10 @@ export interface CheckboxDomProps extends AriaLabelableProps {
}

export function useCheckbox<TValue = string>(
_props: Reactivify<CheckboxProps<TValue>>,
_props: Reactivify<CheckboxProps<TValue>, 'schema'>,
elementRef?: Ref<HTMLInputElement | undefined>,
) {
const props = normalizeProps(_props);
const props = normalizeProps(_props, ['schema']);
const inputId = useUniqId(FieldTypePrefixes.Checkbox);
const getTrueValue = () => (toValue(props.trueValue) as TValue) ?? (true as TValue);
const getFalseValue = () => (toValue(props.falseValue) as TValue) ?? (false as TValue);
Expand All @@ -45,6 +47,7 @@ export function useCheckbox<TValue = string>(
path: props.name,
initialValue: toValue(props.modelValue) as TValue,
disabled: props.disabled,
schema: props.schema,
});

const checked = computed({
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/useCheckbox/useCheckboxGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import {
Direction,
Reactivify,
Arrayable,
TypedSchema,
} from '../types';
import { useUniqId, createDescribedByProps, normalizeProps, isEqual } from '../utils/common';
import { useLocale } from '../i18n/useLocale';
import { useFormField } from '../form/useFormField';
import { useFormField } from '../useFormField';
import { FieldTypePrefixes } from '../constants';
import { useErrorDisplay } from '../form/useErrorDisplay';
import { useErrorDisplay } from '../useFormField/useErrorDisplay';

export type CheckboxGroupValue<TCheckbox> = TCheckbox[];

Expand Down Expand Up @@ -59,15 +60,17 @@ export interface CheckboxGroupProps<TCheckbox = unknown> {
disabled?: boolean;
readonly?: boolean;
required?: boolean;

schema?: TypedSchema<CheckboxGroupValue<TCheckbox>>;
}

interface CheckboxGroupDomProps extends AriaLabelableProps, AriaDescribableProps, AriaValidatableProps {
role: 'group';
dir: Direction;
}

export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProps<TCheckbox>>) {
const props = normalizeProps(_props);
export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProps<TCheckbox>, 'schema'>) {
const props = normalizeProps(_props, ['schema']);
const groupId = useUniqId(FieldTypePrefixes.CheckboxGroup);
const { direction } = useLocale();
const checkboxes: CheckboxContext[] = [];
Expand All @@ -79,6 +82,7 @@ export function useCheckboxGroup<TCheckbox>(_props: Reactivify<CheckboxGroupProp
const field = useFormField({
path: props.name,
initialValue: toValue(props.modelValue),
schema: props.schema,
});

const { displayError } = useErrorDisplay(field);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ 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';
export type FormValidationMode = 'aggregate' | 'schema';

export interface BaseFormContext<TForm extends FormObject = FormObject> {
id: string;
Expand All @@ -36,7 +36,7 @@ export interface BaseFormContext<TForm extends FormObject = FormObject> {
setFieldErrors<TPath extends Path<TForm>>(path: TPath, message: Arrayable<string>): void;
getValidationMode(): FormValidationMode;
getErrors: () => TypedSchemaError[];
clearErrors: () => void;
clearErrors: (path?: string) => void;
hasErrors: () => boolean;
getValues: () => TForm;
setValues: (newValues: Partial<TForm>, opts?: SetValueOptions) => void;
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 Expand Up @@ -199,8 +204,17 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
merge(touched, newTouched);
}

function clearErrors() {
errors.value = {} as ErrorsSchema<TForm>;
function clearErrors(path?: string) {
if (!path) {
errors.value = {} as ErrorsSchema<TForm>;
return;
}

Object.keys(errors.value).forEach(key => {
if (key === path || key.startsWith(path)) {
delete errors.value[key as Path<TForm>];
}
});
}

function revertValues() {
Expand All @@ -212,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
File renamed without changes.
1 change: 1 addition & 0 deletions packages/core/src/useForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useForm';
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { flush, renderSetup } from '@test-utils/index';
import { useForm } from './useForm';
import { useFormField } from './useFormField';
import { useFormField } from '../useFormField';
import { Component, nextTick, Ref, ref } from 'vue';
import { useInputValidity } from '../validation/useInputValidity';
import { fireEvent, render, screen } from '@testing-library/vue';
Expand Down Expand Up @@ -417,7 +417,8 @@ describe('form validation', () => {
inheritAttrs: false,
setup: (_, { attrs }) => {
const name = (attrs.name || 'test') as string;
const { errorMessage, inputProps } = useTextField({ name, label: name });
const schema = attrs.schema as TypedSchema<any>;
const { errorMessage, inputProps } = useTextField({ name, label: name, schema });

return { errorMessage: errorMessage, inputProps, name };
},
Expand Down Expand Up @@ -489,7 +490,7 @@ describe('form validation', () => {
});

await fireEvent.click(screen.getByText('Submit'));
await nextTick();
await flush();
expect(screen.getByTestId('err').textContent).toBe('error');
expect(screen.getByTestId('form-err').textContent).toBe('error');
expect(handler).not.toHaveBeenCalled();
Expand Down Expand Up @@ -534,7 +535,7 @@ describe('form validation', () => {
expect(screen.getByTestId('err').textContent).toBe('error');
expect(screen.getByTestId('form-err').textContent).toBe('error');
await fireEvent.click(screen.getByText('Submit'));
await nextTick();
await flush();
expect(handler).toHaveBeenCalledOnce();
expect(screen.getByTestId('err').textContent).toBe('');
expect(screen.getByTestId('form-err').textContent).toBe('');
Expand Down Expand Up @@ -577,7 +578,7 @@ describe('form validation', () => {
});

await fireEvent.click(screen.getByText('Submit'));
await nextTick();
await flush();
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenLastCalledWith({ test: true, foo: 'bar' });
});
Expand Down Expand Up @@ -633,6 +634,52 @@ describe('form validation', () => {

expect(values).toEqual({ test: 'foo' });
});

test('combines errors from field-level schemas', async () => {
const handler = vi.fn();
const schema: TypedSchema<object, object> = {
async parse() {
return {
errors: [{ path: 'test', messages: ['error'] }],
};
},
};

const fieldSchema: TypedSchema<object, object> = {
async parse() {
return {
errors: [{ path: 'field', messages: ['field error'] }],
};
},
};

await render({
components: { Child: createInputComponent() },
setup() {
const { handleSubmit, getError } = useForm({
schema,
});

return { getError, onSubmit: handleSubmit(handler), fieldSchema };
},
template: `
<form @submit="onSubmit" novalidate>
<Child />
<Child name="field" :schema="fieldSchema" />
<span data-testid="form-err">{{ getError('test') }}</span>
<span data-testid="field-err">{{ getError('field') }}</span>
<button type="submit">Submit</button>
</form>
`,
});

await fireEvent.click(screen.getByText('Submit'));
await flush();
expect(screen.getByTestId('form-err').textContent).toBe('error');
expect(screen.getByTestId('field-err').textContent).toBe('field error');
expect(handler).not.toHaveBeenCalled();
});
});

test('form reset clears errors', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import {
ErrorsSchema,
Path,
TypedSchema,
ValidationResult,
FormValidationResult,
GroupValidationResult,
} from '../types';
import { createFormContext, BaseFormContext } from './formContext';
import { FormTransactionManager, useFormTransactions } from './useFormTransactions';
import { FormValidationResult, useFormActions } from './useFormActions';
import { useFormActions } from './useFormActions';
import { useFormSnapshots } from './formSnapshot';
import { findLeaf } from '../utils/path';

Expand All @@ -28,7 +31,9 @@ export interface FormContext<TForm extends FormObject = FormObject, TOutput exte
FormTransactionManager<TForm> {
requestValidation(): Promise<FormValidationResult<TOutput>>;
onSubmitAttempt(cb: () => void): void;
onNativeValidationDispatch(cb: () => void): void;
onValidationDispatch(
cb: (enqueue: (promise: Promise<ValidationResult | GroupValidationResult>) => void) => void,
): void;
}

export const FormKey: InjectionKey<FormContext<any>> = Symbol('Formwerk FormKey');
Expand Down
Loading

0 comments on commit a722f97

Please sign in to comment.