Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions packages/varden/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@ Remember to pass `form` prop to both form and field components and apply `field`

<template>
<varden-form :form>
<varden-input :form path="name" v-slot="{ field, error }">
<varden-input :form path="name" v-slot="{ field, errors }">
<label>Name</label>
<input v-bind="field" />
{{ error }}
{{ errors }}
</varden-input>

<varden-input :form path="password" v-slot="{ field, error }">
<varden-input :form path="password" v-slot="{ field, errors }">
<label>Password</label>
<input v-bind="field" />
{{ error }}
{{ errors }}
</varden-input>

<button type="submit">Submit</button>
Expand All @@ -75,19 +75,19 @@ is more flexible but requires more boilerplate code. You can use it if you want
},
})

const [name, nameBlur, nameError] = form.useField('name')
const [password, passwordBlur, passwordError] = form.useField('password')
const [name, nameBlur, nameErrors] = form.useField('name')
const [password, passwordBlur, passwordErrors] = form.useField('password')
</script>

<template>
<form @submit.prevent="form.submit" @reset.prevent="form.reset">
<label>Name</label>
<input v-model="name" @blur="nameBlur" />
{{ nameError }}
{{ nameErrors }}

<label>Name</label>
<label>Password</label>
<input v-model="password" @blur="passwordBlur" />
{{ passwordError }}
{{ passwordErrors }}

<button type="submit">Submit</button>
</form>
Expand Down
6 changes: 3 additions & 3 deletions packages/varden/src/components/VardenError.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<script lang="ts" setup generic="T, Path extends Paths<T>">
import type { Paths } from '../path';
import type { FormContext } from '../lib';
import { useFieldError } from '../composables';
import { useFieldErrors } from '../composables';

const props = defineProps<{
form: FormContext<T>;
path: Path;
}>();

const error = useFieldError(props.form, props.path);
const errors = useFieldErrors(props.form, props.path);
</script>

<template>
<slot :error />
<slot :errors />
</template>
4 changes: 2 additions & 2 deletions packages/varden/src/components/VardenField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('meta management', () => {
// @ts-expect-error typed vue component
props: { form, path: 'name' },
slots: {
default: ({ field, error }: { field: { modelValue: string }; error: string | null }) => h('div', [
default: ({ field, errors }: { field: { modelValue: string }; errors: readonly string[] | null }) => h('div', [
h('input', {
'data-testid': 'name-input',
value: field.modelValue,
Expand All @@ -33,7 +33,7 @@ describe('meta management', () => {
field.modelValue = (e.target as HTMLInputElement).value;
},
}),
h('span', { 'data-testid': 'error' }, error ?? ''),
h('span', { 'data-testid': 'error' }, errors?.[0] ?? ''),
]),
},
});
Expand Down
4 changes: 2 additions & 2 deletions packages/varden/src/components/VardenField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const props = defineProps<{
const [
modelValue,
onBlur,
error,
errors,
] = useField(props.form, props.path);

function update(value: Get<T, Path>) {
Expand All @@ -22,6 +22,6 @@ function update(value: Get<T, Path>) {
<template>
<slot
:field="{ modelValue, 'onUpdate:modelValue': update, onBlur }"
:error
:errors
/>
</template>
14 changes: 7 additions & 7 deletions packages/varden/src/composables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function acquireField<T, Path extends Paths<T>>(form: FormContext<T>, path: Path
return;
}

(form as _FormContext<T>).__meta.set(path, createFieldMeta(false, false, '', 1));
(form as _FormContext<T>).__meta.set(path, createFieldMeta(false, false, null, 1));
}

export function useFieldDirty<T, Path extends Paths<T>>(
Expand Down Expand Up @@ -75,11 +75,11 @@ export function useFieldValue<T>(
});
}

export function useFieldError<T, Path extends Paths<T>>(
export function useFieldErrors<T, Path extends Paths<T>>(
form: FormContext<T>,
path: MaybeRefOrGetter<Path>,
): ComputedRef<string | null> {
return computed(() => form.getError(toValue(path)));
): ComputedRef<Readonly<string[]> | null> {
return computed(() => form.getErrors(toValue(path)));
}

export function useField<T>(
Expand All @@ -88,15 +88,15 @@ export function useField<T>(
): [
modelValue: WritableComputedRef<Get<T, Paths<T>>>,
touch: () => void,
error: ComputedRef<string | null>,
errors: ComputedRef<Readonly<string[]> | null>,
] {
const modelValue = useFieldValue(form, path);
const touch = () => form.setTouched(toValue(path), true);
const error = useFieldError(form, path);
const errors = useFieldErrors(form, path);

return [
modelValue,
touch,
error,
errors,
] as const;
}
4 changes: 2 additions & 2 deletions packages/varden/src/field-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
export interface FieldMeta {
touched: boolean;
dirty: boolean;
error: string | null;
error: string[] | null;
refCount: number;
}

export function createFieldMeta(
touched: boolean,
dirty: boolean,
error: string | null,
error: string[] | null,
refCount: number,
): FieldMeta {
return {
Expand Down
2 changes: 1 addition & 1 deletion packages/varden/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export {
} from './lib';

export {
useFieldValue, useFieldDirty, useFieldError, useFieldTouched, useField,
useFieldValue, useFieldDirty, useFieldErrors, useFieldTouched, useField,
} from './composables';

export { default as VardenForm } from './components/VardenForm.vue';
Expand Down
41 changes: 24 additions & 17 deletions packages/varden/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type DeepReadonly,
} from 'vue';

import { getIssuePath, type StandardSchemaV1 } from './standard-schema';
import { getIssues, type StandardSchemaV1 } from './standard-schema';
import {
type Paths, type Get, get, set, del, toCompiledPath,
Empty,
Expand Down Expand Up @@ -46,7 +46,7 @@ export interface FormContext<T> {
isTouched<Path extends Paths<T>>(path: Path): boolean;
valid: Ref<boolean>;
isDirty<Path extends Paths<T>>(path: Path): boolean;
getError<Path extends Paths<T>>(path: Path): string | null;
getErrors<Path extends Paths<T>>(path: Path): string[] | null;
submit(): void;
pop<Path extends ArrayPaths<T>>(path: Path): undefined | GetArray<T, Path>;
shift<Path extends ArrayPaths<T>>(path: Path): undefined | GetArray<T, Path>;
Expand Down Expand Up @@ -161,34 +161,41 @@ export function useForm<T, O>(props: FormProps<T, O>): FormContext<T> {
outputValues = result.value;
}

const issues = [...(result.issues ?? [])];
const paths = issues.map(getIssuePath);
const issues = getIssues(result.issues);

valid.value = issues.length === 0;

for (const [field, meta] of fields) {
const index = paths.indexOf(field);
if (index === -1) {
meta.error = '';
const fieldIssues = issues.filter((val) => val.path === field);
if (fieldIssues.length === 0) {
meta.error = null;
// eslint-disable-next-line no-continue
continue;
} else {
meta.error = fieldIssues.map((val) => val.message);
}

meta.error = issues[index]!.message;

issues.splice(index, 1);
paths.splice(index, 1);
for (let i = 0; i < fieldIssues.length; i += 1) {
issues.splice(issues.indexOf(fieldIssues[i]!), 1);
}
}
if (!issues.length) {
return;
}

// proceed with unregistered paths
for (let index = 0; index < paths.length; index += 1) {
const path = paths[index]!;
const error = issues[index]!.message;
for (let index = 0; index < issues.length; index += 1) {
const { path, message } = issues[index]!;
if (!path) {
// eslint-disable-next-line no-continue
continue;
}

fields.set(path, createFieldMeta(false, false, error, 0));
if (fields.has(path)) {
fields.get(path)!.error!.push(message);
} else {
fields.set(path, createFieldMeta(false, false, [message], 0));
}
}
}

Expand All @@ -212,7 +219,7 @@ export function useForm<T, O>(props: FormProps<T, O>): FormContext<T> {
if (meta) {
meta.dirty = isDirty;
} else {
fields.set(stringPath, createFieldMeta(false, isDirty, '', 0));
fields.set(stringPath, createFieldMeta(false, isDirty, null, 0));
}

// cleanup child fields
Expand Down Expand Up @@ -297,7 +304,7 @@ export function useForm<T, O>(props: FormProps<T, O>): FormContext<T> {
isTouched<Path extends Paths<T>>(path: Path): boolean {
return fields.get(path)?.touched ?? false;
},
getError<Path extends Paths<T>>(path: Path): string | null {
getErrors<Path extends Paths<T>>(path: Path): string[] | null {
return fields.get(path)?.error ?? null;
},
// arrays
Expand Down
20 changes: 18 additions & 2 deletions packages/varden/src/standard-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,25 @@ export declare namespace StandardSchemaV1 {
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output'];
}

export function getIssuePath(issue: StandardSchemaV1.Issue) {
interface GenericIssue {
path?: string;
message: string;
}

export function getIssues(issues?: readonly StandardSchemaV1.Issue[]): GenericIssue[] {
if (issues === undefined) {
return [];
}

return issues.map((issue) => ({
path: getIssuePath(issue),
message: issue.message,
}));
}

function getIssuePath(issue: StandardSchemaV1.Issue) {
if (!issue.path) {
return '';
return issue.path;
}

const propertyKeys = issue.path.map((p) => {
Expand Down
38 changes: 38 additions & 0 deletions packages/varden/tests/form-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import * as v from 'valibot';

import { useForm } from '../src/lib';

describe('form errors', () => {
it('should return multiple errors for a field', () => {
const form = useForm({
onSubmit: () => {},
schema: v.object({
email: v.pipe(
v.string(),
v.minLength(5, 'too short'),
v.email('invalid email'),
),
}),
});

form.setValue('email', 'a@');

const errors = form.getErrors('email');
expect(errors).toEqual(['too short', 'invalid email']);
});

it('should return null when field has no errors', () => {
const form = useForm({
onSubmit: () => {},
schema: v.object({
name: v.string(),
}),
});

form.setValue('name', 'valid');

const errors = form.getErrors('name');
expect(errors).toBeNull();
});
});
Loading