Skip to content

Commit

Permalink
feat: Implement Yup Form Provider (#45)
Browse files Browse the repository at this point in the history
* chore: extract several utils to shared

* chore: add yup provider deps

* chore: refactor validation module export

* chore: added flush-promises dep

* feat: support casting

* feat: yup schema provider implementation

* feat: rename cast to defaults

* chore: ci
  • Loading branch information
logaretm authored Aug 11, 2024
1 parent 0f3c174 commit 129ccc0
Show file tree
Hide file tree
Showing 20 changed files with 426 additions and 80 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Type Check
run: pnpm typecheck
- name: Lint
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-promise": "^7.1.0",
"filesize": "^10.1.4",
"flush-promises": "^1.0.2",
"fs-extra": "^11.2.0",
"globals": "^15.9.0",
"gzip-size": "^7.0.0",
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/form/formContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
ErrorsSchema,
TypedSchemaError,
} from '../types';
import { cloneDeep, merge, normalizeArrayable } from '../utils/common';
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';

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

Expand Down Expand Up @@ -125,8 +126,8 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput

function getErrors(): TypedSchemaError[] {
return Object.entries(errors.value)
.map<TypedSchemaError>(([key, value]) => ({ path: key, errors: value as string[] }))
.filter(e => e.errors.length > 0);
.map<TypedSchemaError>(([key, value]) => ({ path: key, messages: value as string[] }))
.filter(e => e.messages.length > 0);
}

function setInitialValues(newValues: Partial<TForm>, opts?: SetValueOptions) {
Expand Down
21 changes: 12 additions & 9 deletions packages/core/src/form/formSnapshot.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
import { Ref, shallowRef, toValue } from 'vue';
import { FormObject, MaybeGetter, MaybeAsync } from '../types';
import { FormObject, MaybeGetter, MaybeAsync, TypedSchema } from '../types';
import { cloneDeep, isPromise } from '../utils/common';

interface FormSnapshotOptions<TForm extends FormObject> {
interface FormSnapshotOptions<TForm extends FormObject, TOutput extends FormObject = TForm> {
onAsyncInit?: (values: TForm) => void;
schema?: TypedSchema<TForm, TOutput>;
}

export interface FormSnapshot<TForm extends FormObject> {
initials: Ref<TForm>;
originals: Ref<TForm>;
}

export function useFormSnapshots<TForm extends FormObject>(
export function useFormSnapshots<TForm extends FormObject, TOutput extends FormObject = TForm>(
provider: MaybeGetter<MaybeAsync<TForm>> | undefined,
opts?: FormSnapshotOptions<TForm>,
opts?: FormSnapshotOptions<TForm, TOutput>,
): FormSnapshot<TForm> {
// We need two copies of the initial values
const initials = shallowRef<TForm>({} as TForm) as Ref<TForm>;
const originals = shallowRef<TForm>({} as TForm) as Ref<TForm>;

const initialValuesUnref = toValue(provider);
if (isPromise(initialValuesUnref)) {
initialValuesUnref.then(inits => {
const provided = toValue(provider);
if (isPromise(provided)) {
provided.then(resolved => {
const inits = opts?.schema?.defaults?.(resolved) ?? resolved;
initials.value = cloneDeep(inits || {}) as TForm;
originals.value = cloneDeep(inits || {}) as TForm;
opts?.onAsyncInit?.(cloneDeep(inits));
});
} else {
initials.value = cloneDeep(initialValuesUnref || {}) as TForm;
originals.value = cloneDeep(initialValuesUnref || {}) as TForm;
const inits = opts?.schema?.defaults?.(provided || ({} as TForm)) ?? provided;
initials.value = cloneDeep(inits || {}) as TForm;
originals.value = cloneDeep(inits || {}) as TForm;
}

return {
Expand Down
29 changes: 23 additions & 6 deletions packages/core/src/form/useForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ describe('validation', () => {
await nextTick();
await fireEvent.click(screen.getByText('Submit'));
expect(handler).not.toHaveBeenCalled();
await fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } });
await fireEvent.update(screen.getByTestId('input'), 'test');
await fireEvent.click(screen.getByText('Submit'));
await nextTick();
expect(handler).toHaveBeenCalledOnce();
Expand All @@ -422,7 +422,7 @@ describe('validation', () => {
const schema: TypedSchema<object, object> = {
async parse() {
return {
errors: [{ path: 'test', errors: ['error'] }],
errors: [{ path: 'test', messages: ['error'] }],
};
},
};
Expand Down Expand Up @@ -454,7 +454,7 @@ describe('validation', () => {
const schema: TypedSchema<object, object> = {
async parse() {
return {
errors: shouldError ? [{ path: 'test', errors: ['error'] }] : [],
errors: shouldError ? [{ path: 'test', messages: ['error'] }] : [],
};
},
};
Expand Down Expand Up @@ -577,7 +577,7 @@ describe('validation', () => {
const schema: TypedSchema<object, object> = {
async parse() {
return {
errors: [{ path: 'test', errors: ['error'] }],
errors: [{ path: 'test', messages: ['error'] }],
};
},
};
Expand All @@ -603,7 +603,7 @@ describe('validation', () => {
const schema: TypedSchema<{ test: string }> = {
async parse() {
return {
errors: [{ path: 'test', errors: ['error'] }],
errors: [{ path: 'test', messages: ['error'] }],
};
},
};
Expand All @@ -625,7 +625,7 @@ describe('validation', () => {
const schema: TypedSchema<{ test: string }> = {
async parse() {
return {
errors: [{ path: 'test', errors: wasReset ? ['reset'] : ['error'] }],
errors: [{ path: 'test', messages: wasReset ? ['reset'] : ['error'] }],
};
},
};
Expand All @@ -642,4 +642,21 @@ describe('validation', () => {
await reset({ revalidate: true });
expect(getError('test')).toBe('reset');
});

test('typed schema can initialize with default values', async () => {
const { values } = await renderSetup(() => {
return useForm({
schema: {
defaults: () => ({ test: 'foo' }),
async parse() {
return {
errors: [],
};
},
},
});
});

expect(values).toEqual({ test: 'foo' });
});
});
3 changes: 2 additions & 1 deletion packages/core/src/form/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ export function useForm<TForm extends FormObject = FormObject, TOutput extends F
opts?: Partial<FormOptions<TForm, TOutput>>,
) {
const touchedSnapshot = useFormSnapshots(opts?.initialTouched);
const valuesSnapshot = useFormSnapshots<TForm>(opts?.initialValues, {
const valuesSnapshot = useFormSnapshots<TForm, TOutput>(opts?.initialValues, {
onAsyncInit,
schema: opts?.schema,
});

const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as TForm;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/form/useFormActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex

function applyErrors(errors: TypedSchemaError[]) {
for (const entry of errors) {
form.setFieldErrors(entry.path as Path<TForm>, entry.errors);
form.setFieldErrors(entry.path as Path<TForm>, entry.messages);
}
}

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 @@ -6,6 +6,7 @@ export * from './useSlider';
export * from './useCheckbox';
export * from './useNumberField';
export * from './useSpinButton';
export * from './types/index';
export * from './types';
export * from './config';
export * from './form';
export * from './validation';
4 changes: 2 additions & 2 deletions packages/core/src/types/typedSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FormObject } from './common';

export interface TypedSchemaError {
path: string;
errors: string[];
messages: string[];
}

export interface TypedSchemaContext {
Expand All @@ -11,7 +11,7 @@ export interface TypedSchemaContext {

export interface TypedSchema<TInput = any, TOutput = TInput> {
parse(values: TInput, context?: TypedSchemaContext): Promise<{ output?: TOutput; errors: TypedSchemaError[] }>;
cast?(values: Partial<TInput>): TInput;
defaults?(values: TInput): TInput;
}

export type InferOutput<TSchema extends TypedSchema> =
Expand Down
51 changes: 0 additions & 51 deletions packages/core/src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,6 @@ export function normalizeArrayable<T>(value: Arrayable<T>): T[] {
return Array.isArray(value) ? [...value] : [value];
}

export const isObject = (obj: unknown): obj is Record<string, unknown> =>
obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj);

export function isIndex(value: unknown): value is number {
return Number(value) >= 0;
}

/**
* Clones a value deeply. I wish we could use `structuredClone` but it's not supported because of the deep Proxy usage by Vue.
* I added some shortcuts here to avoid cloning some known types we don't plan to support.
Expand All @@ -152,50 +145,6 @@ export function cloneDeep<T>(value: T): T {
return klona(value);
}

export function isObjectLike(value: unknown) {
return typeof value === 'object' && value !== null;
}

export function getTag(value: unknown) {
if (value == null) {
return value === undefined ? '[object Undefined]' : '[object Null]';
}
return Object.prototype.toString.call(value);
}

// Reference: https://github.com/lodash/lodash/blob/master/isPlainObject.js
export function isPlainObject(value: unknown) {
if (!isObjectLike(value) || getTag(value) !== '[object Object]') {
return false;
}
if (Object.getPrototypeOf(value) === null) {
return true;
}
let proto = value;
while (Object.getPrototypeOf(proto) !== null) {
proto = Object.getPrototypeOf(proto);
}

return Object.getPrototypeOf(value) === proto;
}

export function merge(target: any, source: any) {
Object.keys(source).forEach(key => {
if (isPlainObject(source[key]) && isPlainObject(target[key])) {
if (!target[key]) {
target[key] = {};
}

merge(target[key], source[key]);
return;
}

target[key] = source[key];
});

return target;
}

export function isPromise(value: unknown): value is Promise<unknown> {
return value instanceof Promise;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/utils/path.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isIndex, isNullOrUndefined, isObject } from './common';
import { isIndex, isObject } from '../../../shared/src';
import { isNullOrUndefined } from './common';

export function isContainerValue(value: unknown): value is Record<string, unknown> {
return isObject(value) || Array.isArray(value);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/validation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useInputValidity';
9 changes: 5 additions & 4 deletions packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ export function useInputValidity(opts: InputValidityOptions) {
const validationMode = form?.getValidationMode() ?? 'native';

function updateValiditySync() {
validityDetails.value = opts.inputRef?.value?.validity;

if (validationMode === 'native') {
setErrors(opts.inputRef?.value?.validationMessage || []);
if (validationMode !== 'native') {
return;
}

validityDetails.value = opts.inputRef?.value?.validity;
setErrors(opts.inputRef?.value?.validationMessage || []);
}

async function updateValidity() {
Expand Down
3 changes: 3 additions & 0 deletions packages/schema-yup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Yup

This is the typed schema implementation for the `yup` provider.
34 changes: 34 additions & 0 deletions packages/schema-yup/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@formwerk/schema-yup",
"version": "0.0.1",
"description": "",
"sideEffects": false,
"module": "dist/schema-yup.esm.js",
"unpkg": "dist/schema-yup.js",
"main": "dist/schema-yup.js",
"types": "dist/schema-yup.d.ts",
"repository": {
"url": "https://github.com/formwerkjs/formwerk.git",
"type": "git",
"directory": "packages/schema-yup"
},
"keywords": [
"VueJS",
"Vue",
"validation",
"validator",
"inputs",
"form"
],
"files": [
"dist/*.js",
"dist/*.d.ts"
],
"dependencies": {
"@formwerk/core": "workspace:*",
"type-fest": "^4.24.0",
"yup": "^1.3.2"
},
"author": "",
"license": "MIT"
}
Loading

0 comments on commit 129ccc0

Please sign in to comment.