parent
0464111330
commit
ccded8bc4a
@ -0,0 +1,221 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { createForm, type Form } from '$libs/forms';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { StandardSchemaV1 } from '$libs/standard-schema';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import {
|
||||
FormErrorSummaryProvider,
|
||||
FormProvider,
|
||||
FormResetProvider,
|
||||
FormSubmitProvider
|
||||
} from './form-provider.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() {
|
||||
const form = createForm({
|
||||
defaults: { email: '' },
|
||||
schema: loginSchema()
|
||||
});
|
||||
form.registerField('email', {});
|
||||
return form;
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
ts: vi.fn((key: string) => (key.includes('reset') ? 'Reset' : 'Submit'))
|
||||
},
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(FormProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function formOpts(form: Form<LoginValues>, root: HTMLFormElement) {
|
||||
return {
|
||||
id: state('login-form'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
form: state(form),
|
||||
schema: state<StandardSchemaV1 | undefined>(loginSchema()),
|
||||
noValidate: state(true),
|
||||
focusFirstError: state(true)
|
||||
};
|
||||
}
|
||||
|
||||
function submitEvent() {
|
||||
return {
|
||||
preventDefault: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
describe('FormProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('submits through the form handle and focuses the first invalid field', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const focusSpy = vi.spyOn(dom, 'focus');
|
||||
const root = document.createElement('form');
|
||||
const field = document.createElement('div');
|
||||
const input = document.createElement('input');
|
||||
field.setAttribute('data-field', '');
|
||||
field.setAttribute('data-field-name', 'email');
|
||||
field.appendChild(input);
|
||||
root.appendChild(field);
|
||||
document.body.appendChild(root);
|
||||
const form = createLoginForm();
|
||||
const opts = formOpts(form, root);
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => FormProvider.create(opts));
|
||||
|
||||
expect(provider.props).toMatchObject({
|
||||
id: 'login-form',
|
||||
'data-form': '',
|
||||
novalidate: true,
|
||||
onsubmit: provider.handleSubmit
|
||||
});
|
||||
expect(provider.getFieldElementId('email')).toBe('login-form-field-email');
|
||||
expect(provider.snippetProps).toMatchObject({
|
||||
isPending: false,
|
||||
isValid: true,
|
||||
isDirty: false,
|
||||
isTouched: false,
|
||||
submitCount: 0
|
||||
});
|
||||
|
||||
const event = submitEvent();
|
||||
await provider.handleSubmit(event as unknown as SubmitEvent);
|
||||
await tick();
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(form.submitCount).toBe(1);
|
||||
expect(provider.isSubmitted).toBe(true);
|
||||
expect(provider.isInvalid).toBe(true);
|
||||
expect(focusSpy).toHaveBeenCalledWith(input);
|
||||
expect(root.getAttribute('data-invalid')).toBe('');
|
||||
expect(root.getAttribute('data-submitted')).toBe('');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('projects submit/reset/error-summary state from the form', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('form');
|
||||
document.body.appendChild(root);
|
||||
const form = createLoginForm();
|
||||
form.values.email = 'bad';
|
||||
form.validate();
|
||||
const opts = formOpts(form, root);
|
||||
opts.focusFirstError.current = false;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = FormProvider.create(opts);
|
||||
vi.spyOn(FormProvider, 'require').mockReturnValue(provider);
|
||||
const submit = FormSubmitProvider.create({
|
||||
id: state('form-submit'),
|
||||
ref: state<HTMLElement | null>(document.createElement('button')),
|
||||
ariaLabel: state<string | undefined>(undefined)
|
||||
});
|
||||
const reset = FormResetProvider.create({
|
||||
id: state('form-reset'),
|
||||
ref: state<HTMLElement | null>(document.createElement('button')),
|
||||
ariaLabel: state<string | undefined>('Clear form'),
|
||||
disableWhenPristine: state(true)
|
||||
});
|
||||
const summary = FormErrorSummaryProvider.create({
|
||||
id: state('form-summary'),
|
||||
ref: state<HTMLElement | null>(document.createElement('div')),
|
||||
hideUntilSubmitted: state(true)
|
||||
});
|
||||
return { provider, submit, reset, summary };
|
||||
});
|
||||
|
||||
expect(result.submit.props).toMatchObject({
|
||||
type: 'submit',
|
||||
'aria-label': 'Submit',
|
||||
'aria-busy': 'false',
|
||||
disabled: undefined
|
||||
});
|
||||
expect(result.reset.props).toMatchObject({
|
||||
type: 'button',
|
||||
'aria-label': 'Clear form',
|
||||
disabled: undefined,
|
||||
'data-dirty': ''
|
||||
});
|
||||
expect(result.summary.count).toBe(1);
|
||||
expect(result.summary.fieldErrors).toEqual([{ field: 'email', message: 'Invalid email' }]);
|
||||
expect(result.summary.props).toMatchObject({
|
||||
role: 'alert',
|
||||
'aria-live': 'polite',
|
||||
hidden: true,
|
||||
'data-hidden': ''
|
||||
});
|
||||
|
||||
await result.provider.handleSubmit(submitEvent() as unknown as SubmitEvent);
|
||||
await tick();
|
||||
expect(result.summary.props.hidden).toBeUndefined();
|
||||
expect(result.summary.props['data-hidden']).toBeUndefined();
|
||||
|
||||
result.reset.onclick();
|
||||
await tick();
|
||||
expect(form.values.email).toBe('');
|
||||
expect(result.reset.props.disabled).toBe(true);
|
||||
expect(result.summary.count).toBe(0);
|
||||
expect(result.summary.props.hidden).toBe(true);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue