Skip to content

Commit

Permalink
feat: added support for output merging
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 15, 2024
1 parent fe44e8d commit 3d103af
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 62 deletions.
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',
},
},
{
Expand Down
24 changes: 23 additions & 1 deletion packages/core/src/types/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,36 @@ import { Schema, Simplify } from 'type-fest';
import { FormObject } from './common';
import { Path } from './paths';
import { TypedSchemaError } from './typedSchema';
import { FormValidationMode } from '@core/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[]>>;

export type ValidationResult = {
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;
8 changes: 6 additions & 2 deletions packages/core/src/useForm/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
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 @@ -29,7 +31,9 @@ export interface FormContext<TForm extends FormObject = FormObject, TOutput exte
FormTransactionManager<TForm> {
requestValidation(): Promise<FormValidationResult<TOutput>>;
onSubmitAttempt(cb: () => void): void;
onValidationDispatch(cb: (enqueue: (promise: Promise<ValidationResult>) => void) => void): void;
onValidationDispatch(
cb: (enqueue: (promise: Promise<ValidationResult | GroupValidationResult>) => void) => void,
): void;
}

export const FormKey: InjectionKey<FormContext<any>> = Symbol('Formwerk FormKey');
Expand Down
11 changes: 3 additions & 8 deletions packages/core/src/useForm/useFormActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { shallowRef } from 'vue';
import {
DisabledSchema,
FormObject,
FormValidationResult,
MaybeAsync,
Path,
TouchedSchema,
TypedSchema,
TypedSchemaError,
ValidationResult,
} from '../types';
import { createEventDispatcher } from '../utils/events';
import { BaseFormContext, FormValidationMode, SetValueOptions } from './formContext';
import { BaseFormContext, SetValueOptions } from './formContext';
import { unsetPath } from '../utils/path';
import { useValidationProvider } from '../validation/useValidationProvider';

Expand All @@ -25,11 +25,6 @@ export interface FormActionsOptions<TForm extends FormObject = FormObject, TOutp
disabled: DisabledSchema<TForm>;
}

export interface FormValidationResult<TOutput extends FormObject = FormObject> extends ValidationResult {
output: TOutput;
mode: FormValidationMode;
}

export function useFormActions<TForm extends FormObject = FormObject, TOutput extends FormObject = TForm>(
form: BaseFormContext<TForm>,
{ disabled, schema }: FormActionsOptions<TForm, TOutput>,
Expand All @@ -40,7 +35,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
validate: _validate,
onValidationDispatch,
defineValidationRequest,
} = useValidationProvider({ schema, getValues: () => form.getValues() });
} = useValidationProvider({ schema, getValues: () => form.getValues(), type: 'FORM' });
const requestValidation = defineValidationRequest(updateValidationStateFromResult);

function handleSubmit<TReturns>(onSuccess: (values: TOutput) => MaybeAsync<TReturns>) {
Expand Down
21 changes: 16 additions & 5 deletions packages/core/src/useFormField/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,32 @@ export function useFormField<TValue = unknown>(opts?: Partial<FormFieldOptions<T
});
}

async function validate(mutate?: boolean) {
function createValidationResult(result: Omit<ValidationResult, 'type' | 'path'>): ValidationResult {
return {
type: 'FIELD',
path: getPath() || '',
...result,
};
}

async function validate(mutate?: boolean): Promise<ValidationResult> {
const schema = opts?.schema;
if (!schema) {
return Promise.resolve({ isValid: true, errors: [] });
return Promise.resolve(
createValidationResult({ isValid: true, errors: [], output: cloneDeep(fieldValue.value) }),
);
}

const { errors } = await schema.parse(fieldValue.value as TValue);
const { errors, output } = await schema.parse(fieldValue.value as TValue);
if (mutate) {
setErrors(errors.map(e => e.messages).flat());
}

return {
return createValidationResult({
isValid: errors.length === 0,
output,
errors: errors.map(e => ({ messages: e.messages, path: getPath() || e.path })),
};
});
}

const field: FormField<TValue> = {
Expand Down
28 changes: 19 additions & 9 deletions packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ import {
} from 'vue';
import { useLabel } from '../a11y/useLabel';
import { FieldTypePrefixes } from '../constants';
import { AriaLabelableProps, AriaLabelProps, FormObject, Reactivify, TypedSchema, ValidationResult } from '../types';
import {
AriaLabelableProps,
AriaLabelProps,
FormObject,
GroupValidationResult,
Reactivify,
TypedSchema,
ValidationResult,
} from '../types';
import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { FormKey } from '../useForm';
import { useValidationProvider } from '../validation/useValidationProvider';
import { FormValidationResult } from '../useForm/useFormActions';

export interface FormGroupProps<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput> {
name: string;
Expand All @@ -34,7 +41,7 @@ interface GroupProps extends AriaLabelableProps {
interface FormGroupContext<TOutput extends FormObject = FormObject> {
prefixPath: (path: string | undefined) => string | undefined;
onValidationDispatch(cb: (enqueue: (promise: Promise<ValidationResult>) => void) => void): void;
requestValidation(): Promise<FormValidationResult<TOutput>>;
requestValidation(): Promise<GroupValidationResult<TOutput>>;
}

export const FormGroupKey: InjectionKey<FormGroupContext> = Symbol('FormGroup');
Expand All @@ -45,11 +52,14 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
) {
const id = useUniqId(FieldTypePrefixes.FormGroup);
const props = normalizeProps(_props, ['schema']);
const getPath = () => toValue(props.name);
const groupRef = elementRef || shallowRef<HTMLInputElement>();
const form = inject(FormKey, null);
const { validate, onValidationDispatch, defineValidationRequest } = useValidationProvider({
schema: props.schema,
getValues,
getPath,
schema: props.schema,
type: 'GROUP',
});

const requestValidation = defineValidationRequest(({ errors }) => {
Expand Down Expand Up @@ -87,20 +97,20 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
const FormGroup = createInlineFormGroupComponent({ groupProps, labelProps });

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

function getErrors() {
const path = toValue(props.name);
const path = getPath();
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 isTouched = computed(() => form?.isFieldTouched(getPath()) ?? false);
const isDirty = computed(() => {
const path = toValue(props.name);
const path = getPath();

return !isEqual(getValues(), form?.getFieldOriginalValue(path) ?? {});
});
Expand All @@ -117,7 +127,7 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
}

function prefixPath(path: string | undefined) {
return prefixGroupPath(toValue(props.name), path);
return prefixGroupPath(getPath(), path);
}

const ctx: FormGroupContext = {
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEventListener } from '../helpers/useEventListener';
import { FormKey } from '../useForm';
import { Maybe, ValidationResult } from '../types';
import { FormField } from '../useFormField';
import { normalizeArrayable } from '../utils/common';
import { cloneDeep, normalizeArrayable } from '../utils/common';
import { FormGroupKey } from '../useFormGroup';

interface InputValidityOptions {
Expand All @@ -15,7 +15,7 @@ interface InputValidityOptions {
export function useInputValidity(opts: InputValidityOptions) {
const form = inject(FormKey, null);
const formGroup = inject(FormGroupKey, null);
const { setErrors, errorMessage, schema, validate: validateField, getPath } = opts.field;
const { setErrors, errorMessage, schema, validate: validateField, getPath, fieldValue } = opts.field;
const validityDetails = shallowRef<ValidityState>();
const validationMode = form?.getValidationMode() ?? 'native';
useMessageCustomValiditySync(errorMessage, opts.inputRef);
Expand All @@ -28,6 +28,9 @@ export function useInputValidity(opts: InputValidityOptions) {
}

return {
type: 'FIELD',
path: getPath() || '',
output: cloneDeep(fieldValue.value),
isValid: !messages.length,
errors: [{ messages, path: getPath() || '' }],
};
Expand Down
99 changes: 80 additions & 19 deletions packages/core/src/validation/useValidationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
import { FormObject, TypedSchema, ValidationResult } from '../types';
import { FormValidationResult } from '../useForm/useFormActions';
import {
AnyValidationResult,
FormObject,
FormValidationResult,
GroupValidationResult,
TypedSchema,
ValidationResult,
} from '../types';
import { batchAsync, cloneDeep, withLatestCall } from '../utils/common';
import { createEventDispatcher } from '../utils/events';
import { SCHEMA_BATCH_MS } from '../constants';
import { setInPath } from '@core/utils/path';

interface ValidationProviderOptions<TInput extends FormObject, TOutput extends FormObject = TInput> {
type AggregatorResult<TOutput extends FormObject> = FormValidationResult<TOutput> | GroupValidationResult<TOutput>;

interface ValidationProviderOptions<
TInput extends FormObject,
TOutput extends FormObject,
TType extends AggregatorResult<TOutput>['type'],
> {
type: TType;
schema?: TypedSchema<TInput, TOutput>;
getValues: () => TInput;
getPath?: () => string;
}

export function useValidationProvider<TInput extends FormObject, TOutput extends FormObject = TInput>({
schema,
getValues,
}: ValidationProviderOptions<TInput, TOutput>) {
export function useValidationProvider<
TInput extends FormObject,
TOutput extends FormObject,
TType extends AggregatorResult<TOutput>['type'],
TResult extends AggregatorResult<TOutput> & { type: TType },
>({ schema, getValues, type, getPath }: ValidationProviderOptions<TInput, TOutput, TType>) {
const [dispatchValidate, onValidationDispatch] =
createEventDispatcher<(pending: Promise<ValidationResult>) => void>('validate');

/**
* Validates but tries to not mutate anything if possible.
*/
async function validate(): Promise<FormValidationResult<TOutput>> {
const validationQueue: Promise<ValidationResult>[] = [];
const enqueue = (promise: Promise<ValidationResult>) => validationQueue.push(promise);
async function validate(): Promise<TResult> {
const validationQueue: Promise<AnyValidationResult>[] = [];
const enqueue = (promise: Promise<AnyValidationResult>) => validationQueue.push(promise);
// This is meant to trigger a signal for all fields that can validate themselves to do so.
// Native validation is sync so no need to wait for pending validators.
// But field-level and group-level validations are async, so we need to wait for them.
Expand All @@ -32,26 +49,24 @@ export function useValidationProvider<TInput extends FormObject, TOutput extends
// If we are using native validation, then we don't stop the state mutation
// Because it already has happened, since validations are sourced from the fields.
if (!schema) {
return {
mode: 'native',
return createValidationResult({
isValid: !fieldErrors.length,
errors: fieldErrors,
output: cloneDeep(getValues() as unknown as TOutput),
};
output: mergeOutputs(getValues() as unknown as TOutput, results),
});
}

const { errors, output } = await schema.parse(getValues());
const allErrors = [...errors, ...fieldErrors];

return {
mode: 'schema',
return createValidationResult({
isValid: !allErrors.length,
errors: allErrors,
output: cloneDeep(output ?? (getValues() as unknown as TOutput)),
};
output: mergeOutputs(output ?? (getValues() as unknown as TOutput), results),
});
}

function defineValidationRequest(mutator: (result: FormValidationResult<TOutput>) => void) {
function defineValidationRequest(mutator: (result: TResult) => void) {
const requestValidation = withLatestCall(batchAsync(validate, SCHEMA_BATCH_MS), result => {
mutator(result);

Expand All @@ -61,6 +76,52 @@ export function useValidationProvider<TInput extends FormObject, TOutput extends
return requestValidation;
}

function createValidationResult(result: Omit<AggregatorResult<TOutput>, 'mode' | 'type'>): TResult {
const base = {
output: result.output,
errors: result.errors,
isValid: result.isValid,
};

if (type === 'FORM') {
return {
type,
mode: schema ? 'schema' : 'native',
...base,
} as TResult;
}

return {
type: 'GROUP',
path: getPath?.() || '',
mode: schema ? 'schema' : 'native',
...base,
} as TResult;
}

function mergeOutputs(base: TOutput, results: (ValidationResult | GroupValidationResult)[]): TOutput {
const all = cloneDeep(base);
// Make sure we start with groups first since it may override indivdual fields
const sorted = [...results].sort((a, b) => {
if (a.type === b.type) {
return 0;
}

return a.type === 'FIELD' ? -1 : 1;
});

for (const result of sorted) {
// Pathless fields will be dropped
if (!result.path) {
continue;
}

setInPath(all, result.path, result.output);
}

return all;
}

return {
validate,
onValidationDispatch,
Expand Down
Loading

0 comments on commit 3d103af

Please sign in to comment.