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