@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
531 lines • 26.6 kB
JavaScript
import { createInjector, Injector } from '@furystack/inject';
import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades';
import { using, usingAsync } from '@furystack/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Form, FormContextToken, createFormService } from './form.js';
describe('FormService', () => {
describe('initialization', () => {
it('should initialize with null validatedFormData', () => {
using(createFormService(), (service) => {
expect(service.validatedFormData.getValue()).toBeNull();
});
});
it('should initialize with null rawFormData', () => {
using(createFormService(), (service) => {
expect(service.rawFormData.getValue()).toBeNull();
});
});
it('should initialize with unknown validation result', () => {
using(createFormService(), (service) => {
expect(service.validationResult.getValue()).toEqual({ isValid: null });
});
});
it('should initialize with empty fieldErrors', () => {
using(createFormService(), (service) => {
expect(service.fieldErrors.getValue()).toEqual({});
});
});
it('should initialize with empty inputs set', () => {
using(createFormService(), (service) => {
expect(service.inputs.size).toBe(0);
});
});
it('should initialize isSubmitting as false', () => {
using(createFormService(), (service) => {
expect(service.isSubmitting.getValue()).toBe(false);
});
});
it('should initialize submitError as undefined', () => {
using(createFormService(), (service) => {
expect(service.submitError.getValue()).toBeUndefined();
});
});
});
describe('setFieldState', () => {
it('should update field errors with valid result', () => {
using(createFormService(), (service) => {
const validity = { valid: true };
service.setFieldState('email', { isValid: true }, validity);
expect(service.fieldErrors.getValue()).toEqual({
email: { validationResult: { isValid: true }, validity },
});
});
});
it('should update field errors with invalid result', () => {
using(createFormService(), (service) => {
const validity = { valid: false, valueMissing: true };
const validationResult = { isValid: false, message: 'Email is required' };
service.setFieldState('email', validationResult, validity);
expect(service.fieldErrors.getValue()).toEqual({
email: { validationResult, validity },
});
});
});
it('should merge field errors when updating multiple fields', () => {
using(createFormService(), (service) => {
const validity = { valid: true };
service.setFieldState('email', { isValid: true }, validity);
service.setFieldState('password', { isValid: true }, validity);
const errors = service.fieldErrors.getValue();
expect(errors.email).toBeDefined();
expect(errors.password).toBeDefined();
});
});
});
describe('disposal', () => {
it('should dispose all observables', () => {
const service = createFormService();
const validatedFormDataDisposeSpy = vi.spyOn(service.validatedFormData, Symbol.dispose);
const rawFormDataDisposeSpy = vi.spyOn(service.rawFormData, Symbol.dispose);
const validationResultDisposeSpy = vi.spyOn(service.validationResult, Symbol.dispose);
const fieldErrorsDisposeSpy = vi.spyOn(service.fieldErrors, Symbol.dispose);
const isSubmittingDisposeSpy = vi.spyOn(service.isSubmitting, Symbol.dispose);
const submitErrorDisposeSpy = vi.spyOn(service.submitError, Symbol.dispose);
service[Symbol.dispose]();
expect(validatedFormDataDisposeSpy).toHaveBeenCalled();
expect(rawFormDataDisposeSpy).toHaveBeenCalled();
expect(validationResultDisposeSpy).toHaveBeenCalled();
expect(fieldErrorsDisposeSpy).toHaveBeenCalled();
expect(isSubmittingDisposeSpy).toHaveBeenCalled();
expect(submitErrorDisposeSpy).toHaveBeenCalled();
});
});
});
describe('Form component', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>';
});
afterEach(() => {
document.body.innerHTML = '';
});
it('should render children', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
expect(form).not.toBeNull();
expect(form?.querySelector('input[name="name"]')).not.toBeNull();
expect(form?.querySelector('button[type="submit"]')).not.toBeNull();
});
});
it('should call onSubmit with validated data when form is valid', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onSubmit = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: onSubmit, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text", value: "Test Name" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test Name';
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await flushUpdates();
expect(onSubmit).toHaveBeenCalledWith({ name: 'Test Name' });
});
});
it('should not call onSubmit when validation fails', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onSubmit = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: onSubmit, validate: (data) => {
const d = data;
return typeof d.name === 'string' && typeof d.email === 'string' && d.email.includes('@');
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("input", { name: "email", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const nameInput = form.querySelector('input[name="name"]');
const emailInput = form.querySelector('input[name="email"]');
nameInput.value = 'Test';
emailInput.value = 'invalid-email';
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await flushUpdates();
expect(onSubmit).not.toHaveBeenCalled();
});
});
it('should set validation result to validation-failed when validate returns false', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (data) => {
const d = data;
return typeof d.email === 'string' && d.email.includes('@');
} },
createComponent("input", { name: "email", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="email"]');
input.value = 'no-at-sign';
const changeEvent = new Event('change', { bubbles: true });
form.dispatchEvent(changeEvent);
await flushUpdates();
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
expect(formService.validationResult.getValue()).toEqual({
isValid: false,
reason: 'validation-failed',
});
});
});
it('should reset form state on reset event', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const onReset = vi.fn();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, onReset: onReset, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"),
createComponent("button", { type: "reset" }, "Reset"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test';
const changeEvent = new Event('change', { bubbles: true });
form.dispatchEvent(changeEvent);
await flushUpdates();
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
expect(formService.rawFormData.getValue()).toEqual({ name: 'Test' });
const resetEvent = new Event('reset', { bubbles: true });
form.dispatchEvent(resetEvent);
await flushUpdates();
expect(formService.rawFormData.getValue()).toBeNull();
expect(formService.validationResult.getValue()).toEqual({ isValid: null });
expect(formService.validatedFormData.getValue()).toBeNull();
});
});
it('should update rawFormData on change event', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (data) => {
const d = data;
return typeof d.username === 'string';
} },
createComponent("input", { name: "username", type: "text" }))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="username"]');
input.value = 'testuser';
const changeEvent = new Event('change', { bubbles: true });
form.dispatchEvent(changeEvent);
await flushUpdates();
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
expect(formService.rawFormData.getValue()).toEqual({ username: 'testuser' });
});
});
it('should set validatedFormData when validation passes', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (data) => {
const d = data;
return typeof d.title === 'string';
} },
createComponent("input", { name: "title", type: "text" }))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="title"]');
input.value = 'My Title';
const changeEvent = new Event('change', { bubbles: true });
form.dispatchEvent(changeEvent);
await flushUpdates();
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
expect(formService.validatedFormData.getValue()).toEqual({ title: 'My Title' });
expect(formService.validationResult.getValue()).toEqual({ isValid: true });
});
});
it('should prevent default on submit event', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (_data) => true },
createComponent("input", { name: "field", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(submitEvent, 'preventDefault');
form.dispatchEvent(submitEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
});
it('should create child injector with FormService', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (_data) => true },
createComponent("input", { name: "data", type: "text" }))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const formInjector = form.injector;
expect(formInjector).toBeInstanceOf(Injector);
expect(formInjector).not.toBe(injector);
const formService = formInjector.get(FormContextToken);
expect(formService).toBeDefined();
});
});
it('should handle oninvalid event and trigger validation', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => { }, validate: (data) => {
const d = data;
return typeof d.required === 'string' && d.required.length > 0;
} },
createComponent("input", { name: "required", type: "text", required: true }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="required"]');
const invalidEvent = new Event('invalid', { bubbles: true });
input.dispatchEvent(invalidEvent);
await flushUpdates();
});
});
it('should set isSubmitting during async onSubmit and reset after', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
let resolveSubmit;
const submitPromise = new Promise((resolve) => {
resolveSubmit = resolve;
});
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => submitPromise, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test';
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
expect(formService.isSubmitting.getValue()).toBe(false);
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await flushUpdates();
expect(formService.isSubmitting.getValue()).toBe(true);
resolveSubmit();
await flushUpdates();
expect(formService.isSubmitting.getValue()).toBe(false);
});
});
it('should reset isSubmitting to false and set submitError when onSubmit throws', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const submitError = new Error('Submit failed');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: async () => {
throw submitError;
}, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test';
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await flushUpdates();
expect(formService.isSubmitting.getValue()).toBe(false);
expect(formService.submitError.getValue()).toBe(submitError);
});
});
it('should clear submitError before a new submission', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
let shouldThrow = true;
let resolveSubmit;
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: async () => {
if (shouldThrow) {
throw new Error('First submit fails');
}
return new Promise((resolve) => {
resolveSubmit = resolve;
});
}, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test';
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await flushUpdates();
expect(formService.submitError.getValue()).toBeInstanceOf(Error);
shouldThrow = false;
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await flushUpdates();
expect(formService.submitError.getValue()).toBeUndefined();
expect(formService.isSubmitting.getValue()).toBe(true);
resolveSubmit();
await flushUpdates();
expect(formService.isSubmitting.getValue()).toBe(false);
});
});
it('should set inert on form element when disableOnSubmit is true during async submit', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
let resolveSubmit;
const submitPromise = new Promise((resolve) => {
resolveSubmit = resolve;
});
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => submitPromise, disableOnSubmit: true, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test';
expect(form.inert).toBeFalsy();
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await flushUpdates();
expect(form.inert).toBe(true);
resolveSubmit();
await flushUpdates();
expect(form.inert).toBe(false);
});
});
it('should not set inert when disableOnSubmit is not provided', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
let resolveSubmit;
const submitPromise = new Promise((resolve) => {
resolveSubmit = resolve;
});
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: () => submitPromise, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test';
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await flushUpdates();
expect(form.inert).toBeFalsy();
resolveSubmit();
await flushUpdates();
});
});
it('should remove inert even if onSubmit throws when disableOnSubmit is true', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Form, { onSubmit: async () => {
throw new Error('Submit failed');
}, disableOnSubmit: true, validate: (data) => {
const d = data;
return typeof d.name === 'string';
} },
createComponent("input", { name: "name", type: "text" }),
createComponent("button", { type: "submit" }, "Submit"))),
});
await flushUpdates();
const form = document.querySelector('form[is="shade-form"]');
const input = form.querySelector('input[name="name"]');
input.value = 'Test';
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
await flushUpdates();
expect(form.inert).toBe(false);
const formInjector = form.injector;
const formService = formInjector.get(FormContextToken);
expect(formService.isSubmitting.getValue()).toBe(false);
});
});
});
//# sourceMappingURL=form.spec.js.map