UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

531 lines 26.6 kB
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