parent
02c1fb43ab
commit
d319ec6b66
@ -0,0 +1,140 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
|
||||||
|
import type { StandardSchemaV1 } from '$libs/standard-schema';
|
||||||
|
import { createForm, type CreateFormOpts } from './form-core.svelte';
|
||||||
|
|
||||||
|
type LoginValues = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||||
|
let result!: T;
|
||||||
|
const cleanup = $effect.root(() => {
|
||||||
|
result = fn();
|
||||||
|
});
|
||||||
|
return { result, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginSchema(): StandardSchemaV1<LoginValues> {
|
||||||
|
return {
|
||||||
|
'~standard': {
|
||||||
|
version: 1,
|
||||||
|
vendor: 'test',
|
||||||
|
validate(value) {
|
||||||
|
const values = value as LoginValues;
|
||||||
|
if (values.email.includes('@')) return { value: values };
|
||||||
|
return {
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
message: 'Invalid email',
|
||||||
|
path: ['email']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLoginForm(
|
||||||
|
patch: Partial<CreateFormOpts<StandardSchemaV1<LoginValues> | undefined, LoginValues>> = {}
|
||||||
|
) {
|
||||||
|
return createForm({
|
||||||
|
defaults: { email: '' },
|
||||||
|
...patch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createForm', () => {
|
||||||
|
it('tracks registered field dirty and touched state and resets in place', () => {
|
||||||
|
const { result: form, cleanup } = withEffectRoot(() => createLoginForm());
|
||||||
|
form.registerField('email', {});
|
||||||
|
|
||||||
|
expect(form.getFieldState('email')).toEqual({
|
||||||
|
touched: false,
|
||||||
|
dirty: false,
|
||||||
|
errors: [],
|
||||||
|
isInvalid: false
|
||||||
|
});
|
||||||
|
|
||||||
|
form.values.email = 'ada@example.com';
|
||||||
|
form.setFieldTouched('email', true);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(form.isDirty).toBe(true);
|
||||||
|
expect(form.isTouched).toBe(true);
|
||||||
|
expect(form.getFieldState('email')?.dirty).toBe(true);
|
||||||
|
expect(form.getFieldState('email')?.touched).toBe(true);
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(form.values.email).toBe('');
|
||||||
|
expect(form.isDirty).toBe(false);
|
||||||
|
expect(form.isTouched).toBe(false);
|
||||||
|
expect(form.submitCount).toBe(0);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups Standard Schema issues by field path', () => {
|
||||||
|
const { result: form, cleanup } = withEffectRoot(() =>
|
||||||
|
createLoginForm({ schema: loginSchema() })
|
||||||
|
);
|
||||||
|
form.registerField('email', {});
|
||||||
|
|
||||||
|
form.validate();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(form.errors).toEqual({ email: ['Invalid email'] });
|
||||||
|
expect(form.issues.email?.[0]?.message).toBe('Invalid email');
|
||||||
|
expect(form.firstInvalidField).toBe('email');
|
||||||
|
expect(form.isValid).toBe(false);
|
||||||
|
|
||||||
|
form.values.email = 'ada@example.com';
|
||||||
|
form.validate();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(form.errors).toEqual({});
|
||||||
|
expect(form.firstInvalidField).toBeUndefined();
|
||||||
|
expect(form.isValid).toBe(true);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks pending while an async submit handler is running', async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const submitted = vi.fn();
|
||||||
|
const { result: form, cleanup } = withEffectRoot(() =>
|
||||||
|
createLoginForm({
|
||||||
|
defaults: { email: 'ada@example.com' },
|
||||||
|
schema: loginSchema(),
|
||||||
|
onValidSubmit: async (values) => {
|
||||||
|
submitted(values);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const promise = form.submit(new SubmitEvent('submit'));
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(form.isPending).toBe(true);
|
||||||
|
expect(form.submitCount).toBe(1);
|
||||||
|
expect(submitted).toHaveBeenCalledWith(form.values);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await promise;
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(form.isPending).toBe(false);
|
||||||
|
expect(form.errors).toEqual({});
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export { createForm } from './form-core.svelte';
|
||||||
|
export type {
|
||||||
|
CreateFormOpts,
|
||||||
|
FieldState,
|
||||||
|
Form,
|
||||||
|
FormErrors,
|
||||||
|
FormIssue,
|
||||||
|
FormIssues,
|
||||||
|
SubmitFailureReason,
|
||||||
|
ValidationBehaviour
|
||||||
|
} from './form-core.svelte';
|
||||||
@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* Standard Schema v1 — minimal inline port of the specification.
|
|
||||||
*
|
|
||||||
* <https://standardschema.dev/>
|
|
||||||
*
|
|
||||||
* A thin type + helper layer that lets `soma/Form` accept any validation
|
|
||||||
* library that implements the `"~standard"` interface: Zod, Valibot, ArkType,
|
|
||||||
* Effect/Schema, Yup (via adapter), etc. No runtime dependency on any specific
|
|
||||||
* schema library.
|
|
||||||
*
|
|
||||||
* The consumer imports their schema from their library of choice and passes
|
|
||||||
* it to `createForm({ schema })`. Validation is invoked via
|
|
||||||
* `schema["~standard"].validate(values)`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
||||||
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace StandardSchemaV1 {
|
|
||||||
export interface Props<Input = unknown, Output = Input> {
|
|
||||||
/** Version number of the standard. */
|
|
||||||
readonly version: 1;
|
|
||||||
/** Vendor name (e.g. `"zod"`, `"valibot"`, `"arktype"`). */
|
|
||||||
readonly vendor: string;
|
|
||||||
/** Validates a value. May return a `Promise<Result>` for async schemas. */
|
|
||||||
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
|
|
||||||
/** Type metadata — compile-time only, undefined at runtime. */
|
|
||||||
readonly types?: Types<Input, Output>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Result<Output> = SuccessResult<Output> | FailureResult;
|
|
||||||
|
|
||||||
export interface SuccessResult<Output> {
|
|
||||||
readonly value: Output;
|
|
||||||
readonly issues?: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FailureResult {
|
|
||||||
readonly issues: ReadonlyArray<Issue>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Issue {
|
|
||||||
readonly message: string;
|
|
||||||
readonly path?: ReadonlyArray<PropertyKey | PathSegment>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PathSegment {
|
|
||||||
readonly key: PropertyKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Types<Input = unknown, Output = Input> {
|
|
||||||
readonly input: Input;
|
|
||||||
readonly output: Output;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InferInput<S extends StandardSchemaV1> = NonNullable<
|
|
||||||
S['~standard']['types']
|
|
||||||
>['input'];
|
|
||||||
|
|
||||||
export type InferOutput<S extends StandardSchemaV1> = NonNullable<
|
|
||||||
S['~standard']['types']
|
|
||||||
>['output'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** `true` when the value's first segment is a `PathSegment` object. */
|
|
||||||
function isPathSegment(v: PropertyKey | StandardSchemaV1.PathSegment): v is StandardSchemaV1.PathSegment {
|
|
||||||
return typeof v === 'object' && v !== null && 'key' in v;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize an issue's `path` to a string array of keys. */
|
|
||||||
export function pathKeys(issue: StandardSchemaV1.Issue): string[] {
|
|
||||||
if (!issue.path) return [];
|
|
||||||
return issue.path.map((p) => String(isPathSegment(p) ? p.key : p));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `true` when the validate call returned a Promise. */
|
|
||||||
export function isPromiseLike<T>(v: T | Promise<T>): v is Promise<T> {
|
|
||||||
return typeof (v as { then?: unknown })?.then === 'function';
|
|
||||||
}
|
|
||||||
Loading…
Reference in new issue