diff --git a/.changeset/build-config-cleanup.md b/.changeset/build-config-cleanup.md new file mode 100644 index 0000000..0755f3c --- /dev/null +++ b/.changeset/build-config-cleanup.md @@ -0,0 +1,10 @@ +--- +"@ilokesto/form": patch +--- + +Build config cleanup: enable sourcemaps, preserve JSDoc in declarations, remove dead vite build config, remove redundant .npmignore. + +- `tsconfig.json`: `sourceMap: true` — consumers can now debug into library source. +- `tsconfig.json`: `removeComments: false` — Korean JSDoc preserved in `.d.ts` files, visible in IDE tooltips. +- `vite.config.ts`: removed dead `build.lib` block (production build uses `tsc`, not `vite build`). File now clearly scoped to vitest config only. +- Removed `.npmignore` — `package.json` `files` field already controls published files; `.npmignore` was redundant. \ No newline at end of file diff --git a/.changeset/core-unit-tests.md b/.changeset/core-unit-tests.md new file mode 100644 index 0000000..7de1f94 --- /dev/null +++ b/.changeset/core-unit-tests.md @@ -0,0 +1,11 @@ +--- +"@ilokesto/form": patch +--- + +Add dedicated unit tests for core modules. + +- `test/core/ValueHelper.test.ts` — covers path-based get/set and values reconstruction. +- `test/core/FormPath.test.ts` — covers path ↔ key conversion and path normalization. +- `test/core/FormStateInitializer.test.ts` — covers defaultValues → FormState initialization. +- `test/core/FormArrayMutationPlanner.test.ts` — covers push, insert, remove, move, swap, and replace planning. +- `test/core/FormArrayRebaser.test.ts` — covers field metadata rebase and arrayKeys updates. diff --git a/.changeset/svelte-use-field.md b/.changeset/svelte-use-field.md new file mode 100644 index 0000000..8769846 --- /dev/null +++ b/.changeset/svelte-use-field.md @@ -0,0 +1,13 @@ +--- +"@ilokesto/form": patch +--- + +Add `useField` to the Svelte adapter for API parity with React/Vue/Solid. + +- `useForm(form).useField(options)` returns `{ props, value, setValue, errors, dirty, touched }` matching the shape of the React, Vue, and Solid `useField` hooks. +- `props` is a Svelte `use:`-compatible register action bound to the field's `RegisterOptions`, so `` works directly. +- `value`, `errors`, `dirty`, and `touched` read the latest field state from the core form on every access, staying reactive through the existing `form.subscribe` flow. +- `setValue(value)` updates the field programmatically with `{ source: 'program' }`. +- Field-local `schema` in `RegisterOptions` is supported via the existing register action, with cleanup on action destroy restoring the form-level schema. + +Resolves #17 \ No newline at end of file diff --git a/.npmignore b/.npmignore deleted file mode 100644 index ce53855..0000000 --- a/.npmignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -.git* -node_modules/ -src/ -vite.config.ts -tsconfig.json -stats.html -.omx -.examplesdocs/ diff --git a/src/svelte/index.ts b/src/svelte/index.ts index b272f44..86a4cd2 100644 --- a/src/svelte/index.ts +++ b/src/svelte/index.ts @@ -1,4 +1,5 @@ export { useForm } from './useForm'; export type { RegisterOptions, + SvelteFieldReturn, } from './types'; diff --git a/src/svelte/types.ts b/src/svelte/types.ts index afdabdf..b9a9df2 100644 --- a/src/svelte/types.ts +++ b/src/svelte/types.ts @@ -1,6 +1,6 @@ import type { Action } from 'svelte/action'; import type { Readable } from 'svelte/store'; -import type { Form } from '../core/index'; +import type { Form, FormError } from '../core/index'; import type { FormStateSummary } from '../adapters/FormStateSummary'; import type { RegisterOptions, SubmitHandler, SubmitInvalidHandler, SubmitValidHandler } from '../adapters/dom'; @@ -8,11 +8,29 @@ export type { RegisterOptions } from '../adapters/dom'; export type SvelteRegisterAction = Action; +/** 한 field의 binding action, value, meta, setter를 함께 제공한다. */ +export type SvelteFieldReturn = { + /** ``에 바로 전달할 수 있는, options가 고정된 register action이다. */ + readonly props: SvelteRegisterAction; + /** 현재 field value다. */ + readonly value: unknown; + /** field value를 programmatic하게 갱신한다. */ + setValue(value: unknown): void; + /** 현재 field에 붙어 있는 validation errors다. */ + readonly errors: FormError[]; + /** 현재 값이 initial value와 다른지 여부다. */ + readonly dirty: boolean; + /** field가 한 번 이상 blur 되었는지 여부다. */ + readonly touched: boolean; +}; + /** `useForm(form)`이 반환하는 Svelte action 중심 surface다. */ export type SvelteForm = { form: Form; /** ``처럼 사용하는 Svelte action이다. */ register: SvelteRegisterAction; + /** 한 field의 binding, value, meta, setter를 함께 반환한다. */ + useField(options: RegisterOptions): SvelteFieldReturn; /** form-wide aggregate state를 Svelte readable store로 반환한다. */ useFormState(): Readable>; /** submit event를 막고 core submit 흐름을 실행하는 handler factory다. */ diff --git a/src/svelte/useField.ts b/src/svelte/useField.ts new file mode 100644 index 0000000..3073739 --- /dev/null +++ b/src/svelte/useField.ts @@ -0,0 +1,35 @@ +import type { Form } from '../core/index'; +import { getFieldState } from '../adapters/dom'; +import type { RegisterOptions } from '../adapters/dom'; +import { createRegisterAction } from './RegisterAction'; +import type { SvelteFieldReturn, SvelteRegisterAction } from './types'; + +/** 한 field의 binding action, reactive value/meta, setter를 함께 반환한다. */ +export function useFieldWithForm(form: Form, options: RegisterOptions): SvelteFieldReturn { + const props = createBoundRegisterAction(form, options); + + return { + props, + get value() { + return getFieldState(form, form.getState(), options.name).value; + }, + setValue(value: unknown) { + form.setValue(options.name, value, { source: 'program' }); + }, + get errors() { + return [...getFieldState(form, form.getState(), options.name).errors]; + }, + get dirty() { + return getFieldState(form, form.getState(), options.name).dirty; + }, + get touched() { + return getFieldState(form, form.getState(), options.name).touched; + }, + }; +} + +function createBoundRegisterAction(form: Form, options: RegisterOptions): SvelteRegisterAction { + const register = createRegisterAction(form); + + return (node, overrideOptions) => register(node, overrideOptions ?? options); +} \ No newline at end of file diff --git a/src/svelte/useForm.ts b/src/svelte/useForm.ts index 885e429..ac2ebd0 100644 --- a/src/svelte/useForm.ts +++ b/src/svelte/useForm.ts @@ -3,6 +3,7 @@ import { createFormFromOptions, isFormInstance, type FormInput } from '../adapte import type { CreateFormOptions, Form } from '../core/index'; import type { SvelteForm } from './types'; import { createRegisterAction } from './RegisterAction'; +import { useFieldWithForm } from './useField'; import { useFormStateWithForm } from './useFormState'; /** Svelte action surface를 core Form 인스턴스에 바인딩한다. */ @@ -13,6 +14,9 @@ export function useForm(input: FormInput): SvelteForm return { form, register: createRegisterAction(form), + useField(options) { + return useFieldWithForm(form, options); + }, useFormState() { return useFormStateWithForm(form); }, diff --git a/test/core/FormArrayMutationPlanner.test.ts b/test/core/FormArrayMutationPlanner.test.ts new file mode 100644 index 0000000..cee3c4c --- /dev/null +++ b/test/core/FormArrayMutationPlanner.test.ts @@ -0,0 +1,89 @@ +import { test, expect } from 'vitest'; + +import { FormArrayMutationPlanner } from '../../src/core/array/FormArrayMutationPlanner'; + +const planner = new FormArrayMutationPlanner(); + +test('push adds a new item at the end', () => { + const mutation = planner.push(['a', 'b'], ['k0', 'k1'], 'c', 'k2'); + + expect(mutation.values).toEqual(['a', 'b', 'c']); + expect(mutation.keys).toEqual(['k0', 'k1', 'k2']); + expect(mutation.mapPreviousIndex(0)).toBe(0); + expect(mutation.mapPreviousIndex(1)).toBe(1); + expect(mutation.mapPreviousIndex(2)).toBeUndefined(); +}); + +test('insert places a new item at the requested index', () => { + const mutation = planner.insert(['a', 'b'], ['k0', 'k1'], 1, 'X', 'k2'); + + expect(mutation.values).toEqual(['a', 'X', 'b']); + expect(mutation.keys).toEqual(['k0', 'k2', 'k1']); + expect(mutation.mapPreviousIndex(0)).toBe(0); + expect(mutation.mapPreviousIndex(1)).toBe(2); + expect(mutation.mapPreviousIndex(2)).toBeUndefined(); +}); + +test('insert clamps index to array bounds', () => { + const insertAtStart = planner.insert(['a', 'b'], ['k0', 'k1'], -1, 'X', 'k2'); + const insertAtEnd = planner.insert(['a', 'b'], ['k0', 'k1'], 10, 'X', 'k2'); + + expect(insertAtStart.values).toEqual(['X', 'a', 'b']); + expect(insertAtEnd.values).toEqual(['a', 'b', 'X']); +}); + +test('remove drops item and maps remaining items', () => { + const mutation = planner.remove(['a', 'b', 'c'], ['k0', 'k1', 'k2'], 1); + + expect(mutation?.values).toEqual(['a', 'c']); + expect(mutation?.keys).toEqual(['k0', 'k2']); + expect(mutation?.mapPreviousIndex(0)).toBe(0); + expect(mutation?.mapPreviousIndex(1)).toBeUndefined(); + expect(mutation?.mapPreviousIndex(2)).toBe(1); +}); + +test('remove returns undefined for invalid index', () => { + expect(planner.remove(['a', 'b'], ['k0', 'k1'], -1)).toBeUndefined(); + expect(planner.remove(['a', 'b'], ['k0', 'k1'], 2)).toBeUndefined(); +}); + +test('move shifts item from one index to another', () => { + const mutation = planner.move(['a', 'b', 'c'], ['k0', 'k1', 'k2'], 1, 0); + + expect(mutation?.values).toEqual(['b', 'a', 'c']); + expect(mutation?.keys).toEqual(['k1', 'k0', 'k2']); + expect(mutation?.mapPreviousIndex(0)).toBe(1); + expect(mutation?.mapPreviousIndex(1)).toBe(0); + expect(mutation?.mapPreviousIndex(2)).toBe(2); +}); + +test('move returns undefined for invalid or same index', () => { + expect(planner.move(['a', 'b'], ['k0', 'k1'], -1, 0)).toBeUndefined(); + expect(planner.move(['a', 'b'], ['k0', 'k1'], 0, 2)).toBeUndefined(); + expect(planner.move(['a', 'b'], ['k0', 'k1'], 0, 0)).toBeUndefined(); +}); + +test('swap exchanges two item positions', () => { + const mutation = planner.swap(['a', 'b', 'c'], ['k0', 'k1', 'k2'], 0, 2); + + expect(mutation?.values).toEqual(['c', 'b', 'a']); + expect(mutation?.keys).toEqual(['k2', 'k1', 'k0']); + expect(mutation?.mapPreviousIndex(0)).toBe(2); + expect(mutation?.mapPreviousIndex(1)).toBe(1); + expect(mutation?.mapPreviousIndex(2)).toBe(0); +}); + +test('swap returns undefined for invalid or same index', () => { + expect(planner.swap(['a', 'b'], ['k0', 'k1'], -1, 1)).toBeUndefined(); + expect(planner.swap(['a', 'b'], ['k0', 'k1'], 0, 2)).toBeUndefined(); + expect(planner.swap(['a', 'b'], ['k0', 'k1'], 1, 1)).toBeUndefined(); +}); + +test('replace returns new values and keys without previous mapping', () => { + const mutation = planner.replace(['x', 'y'], ['new0', 'new1']); + + expect(mutation.values).toEqual(['x', 'y']); + expect(mutation.keys).toEqual(['new0', 'new1']); + expect(mutation.mapPreviousIndex(0)).toBeUndefined(); + expect(mutation.mapPreviousIndex(1)).toBeUndefined(); +}); diff --git a/test/core/FormArrayRebaser.test.ts b/test/core/FormArrayRebaser.test.ts new file mode 100644 index 0000000..93b6247 --- /dev/null +++ b/test/core/FormArrayRebaser.test.ts @@ -0,0 +1,75 @@ +import { test, expect } from 'vitest'; + +import { FormArrayRebaser } from '../../src/core/array/FormArrayRebaser'; +import { FormArrayMutationPlanner } from '../../src/core/array/FormArrayMutationPlanner'; +import { FormStateStore } from '../../src/core/state/FormStateStore'; +import { FormStateInitializer } from '../../src/core/state/FormStateInitializer'; +import { FormPath } from '../../src/core/path/FormPath'; +import { ValueHelper } from '../../src/core/value/ValueHelper'; + +test('rebase preserves non-array fields', () => { + const store = new FormStateStore({ email: 'a@example.com', items: [{ name: 'x' }] }); + const planner = new FormArrayMutationPlanner(); + const mutation = planner.push([{ name: 'x' }], ['k0'], { name: 'y' }, 'k1'); + + const nextState = FormArrayRebaser.rebase(store, ['items'], mutation.values, mutation.keys, mutation.mapPreviousIndex); + + expect(nextState.fields[FormPath.pathToKey(['email'])]).toMatchObject({ value: 'a@example.com' }); +}); + +test('rebase moves child metadata to new index', () => { + const store = new FormStateStore({ items: [{ name: 'a' }, { name: 'b' }] }); + const previousKey = FormPath.pathToKey(['items', 1, 'name']); + store.focusField(previousKey); + store.touchField(previousKey); + store.setErrorsByKey(previousKey, [{ message: 'Keep me' }]); + + const planner = new FormArrayMutationPlanner(); + const mutation = planner.move(store.getValueAtPath(['items']) as unknown[], store.getState().arrayKeys[FormPath.pathToKey(['items'])], 1, 0); + + const nextState = FormArrayRebaser.rebase(store, ['items'], mutation!.values, mutation!.keys, mutation!.mapPreviousIndex); + + const movedField = nextState.fields[FormPath.pathToKey(['items', 0, 'name'])]; + expect(movedField.touched).toBe(true); + expect(movedField.errors).toEqual([{ message: 'Keep me' }]); + expect(movedField.isFocused).toBe(true); +}); + +test('rebase drops metadata for removed items', () => { + const store = new FormStateStore({ items: [{ name: 'a' }, { name: 'b' }] }); + const previousKey = FormPath.pathToKey(['items', 1, 'name']); + store.setErrorsByKey(previousKey, [{ message: 'Drop me' }]); + + const planner = new FormArrayMutationPlanner(); + const mutation = planner.remove(store.getValueAtPath(['items']) as unknown[], store.getState().arrayKeys[FormPath.pathToKey(['items'])], 1); + + const nextState = FormArrayRebaser.rebase(store, ['items'], mutation!.values, mutation!.keys, mutation!.mapPreviousIndex); + + expect(nextState.fields[FormPath.pathToKey(['items', 0, 'name'])]).toBeDefined(); + expect(nextState.fields[FormPath.pathToKey(['items', 1, 'name'])]).toBeUndefined(); +}); + +test('rebase updates arrayKeys', () => { + const store = new FormStateStore({ items: [{ name: 'a' }] }); + const planner = new FormArrayMutationPlanner(); + const mutation = planner.replace([{ name: 'x' }, { name: 'y' }], ['new0', 'new1']); + + const nextState = FormArrayRebaser.rebase(store, ['items'], mutation.values, mutation.keys, mutation.mapPreviousIndex); + + expect(nextState.arrayKeys[FormPath.pathToKey(['items'])]).toEqual(['new0', 'new1']); +}); + +test('rebase preserves submit state from the original state', () => { + const store = new FormStateStore({ items: [{ name: 'a' }] }); + store.beginSubmit(); + store.completeSubmit(true); + + const planner = new FormArrayMutationPlanner(); + const mutation = planner.push(store.getValueAtPath(['items']) as unknown[], store.getState().arrayKeys[FormPath.pathToKey(['items'])], { name: 'b' }, 'new'); + + const nextState = FormArrayRebaser.rebase(store, ['items'], mutation.values, mutation.keys, mutation.mapPreviousIndex); + + expect(nextState.submitCount).toBe(1); + expect(nextState.isSubmitted).toBe(true); + expect(nextState.isSubmitSuccessful).toBe(true); +}); diff --git a/test/core/FormPath.test.ts b/test/core/FormPath.test.ts new file mode 100644 index 0000000..04a0e5e --- /dev/null +++ b/test/core/FormPath.test.ts @@ -0,0 +1,56 @@ +import { test, expect } from 'vitest'; + +import { FormPath } from '../../src/core/path/FormPath'; + +test('pathToKey returns root key for empty path', () => { + expect(FormPath.pathToKey([])).toBe('$'); +}); + +test('pathToKey returns JSON string for single segment path', () => { + expect(FormPath.pathToKey(['email'])).toBe('["email"]'); +}); + +test('pathToKey returns JSON string for multi segment path', () => { + expect(FormPath.pathToKey(['user', 'name'])).toBe('["user","name"]'); +}); + +test('pathToKey handles array index segments', () => { + expect(FormPath.pathToKey(['items', 0, 'name'])).toBe('["items",0,"name"]'); +}); + +test('keyToPath round-trips paths through JSON string', () => { + const paths = [ + [], + ['email'], + ['user', 'name'], + ['items', 0, 'name'], + ]; + + paths.forEach(path => { + expect(FormPath.keyToPath(FormPath.pathToKey(path))).toEqual(path); + }); +}); + +test('keyToPath converts root key to empty path', () => { + expect(FormPath.keyToPath('$')).toEqual([]); +}); + +test('pathInputToKey converts string path to single segment key', () => { + expect(FormPath.pathInputToKey('user.name')).toBe('["user.name"]'); +}); + +test('pathInputToKey converts tuple path to key', () => { + expect(FormPath.pathInputToKey(['user', 'name'])).toBe('["user","name"]'); +}); + +test('toFieldPath converts string to single segment tuple', () => { + expect(FormPath.toFieldPath('user.name')).toEqual(['user.name']); +}); + +test('toFieldPath passes tuple path through', () => { + expect(FormPath.toFieldPath(['user', 'name'])).toEqual(['user', 'name']); +}); + +test('path helper returns segments as tuple', () => { + expect(FormPath.path('user', 'name')).toEqual(['user', 'name']); +}); diff --git a/test/core/FormStateInitializer.test.ts b/test/core/FormStateInitializer.test.ts new file mode 100644 index 0000000..86484ac --- /dev/null +++ b/test/core/FormStateInitializer.test.ts @@ -0,0 +1,63 @@ +import { test, expect } from 'vitest'; + +import { FormStateInitializer } from '../../src/core/state/FormStateInitializer'; +import { FormPath } from '../../src/core/path/FormPath'; + +test('initialize with primitive values creates leaf fields', () => { + const state = FormStateInitializer.initialize({ name: 'Ada', count: 42, active: true }); + + expect(state.fields[FormPath.pathToKey(['name'])]).toEqual({ + value: 'Ada', + errors: [], + touched: false, + dirty: false, + modified: false, + isFocused: false, + }); + expect(state.fields[FormPath.pathToKey(['count'])]).toMatchObject({ value: 42 }); + expect(state.fields[FormPath.pathToKey(['active'])]).toMatchObject({ value: true }); + expect(state.arrayKeys).toEqual({}); +}); + +test('initialize with nested objects creates nested leaf fields', () => { + const state = FormStateInitializer.initialize({ + user: { name: 'Ada', profile: { role: 'admin' } }, + }); + + expect(state.fields[FormPath.pathToKey(['user', 'name'])]).toMatchObject({ value: 'Ada' }); + expect(state.fields[FormPath.pathToKey(['user', 'profile', 'role'])]).toMatchObject({ value: 'admin' }); + expect(state.arrayKeys).toEqual({}); +}); + +test('initialize with arrays generates arrayKeys and nested fields', () => { + const state = FormStateInitializer.initialize({ + items: [{ name: 'a' }, { name: 'b' }], + }); + + expect(state.arrayKeys[FormPath.pathToKey(['items'])]).toEqual(['initial-0', 'initial-1']); + expect(state.fields[FormPath.pathToKey(['items', 0, 'name'])]).toMatchObject({ value: 'a' }); + expect(state.fields[FormPath.pathToKey(['items', 1, 'name'])]).toMatchObject({ value: 'b' }); +}); + +test('initialize with mixed null and undefined preserves leaf values', () => { + const state = FormStateInitializer.initialize({ + maybeNull: null, + maybeUndefined: undefined, + nested: { maybeNull: null }, + }); + + expect(state.fields[FormPath.pathToKey(['maybeNull'])]).toMatchObject({ value: null }); + expect(state.fields[FormPath.pathToKey(['maybeUndefined'])]).toMatchObject({ value: undefined }); + expect(state.fields[FormPath.pathToKey(['nested', 'maybeNull'])]).toMatchObject({ value: null }); +}); + +test('initialize records defaultValues and default submit state', () => { + const defaultValues = { name: 'Ada' }; + const state = FormStateInitializer.initialize(defaultValues); + + expect(state.defaultValues).toEqual(defaultValues); + expect(state.submitCount).toBe(0); + expect(state.isSubmitting).toBe(false); + expect(state.isSubmitted).toBe(false); + expect(state.isSubmitSuccessful).toBe(false); +}); diff --git a/test/core/ValueHelper.test.ts b/test/core/ValueHelper.test.ts new file mode 100644 index 0000000..a6b9d8a --- /dev/null +++ b/test/core/ValueHelper.test.ts @@ -0,0 +1,79 @@ +import { test, expect } from 'vitest'; + +import { ValueHelper } from '../../src/core/value/ValueHelper'; +import { FormPath } from '../../src/core/path/FormPath'; +import { FormStateInitializer } from '../../src/core/state/FormStateInitializer'; + +test('getValueAtPath reads nested object values', () => { + const source = { user: { name: 'Ada', role: 'admin' } }; + + expect(ValueHelper.getValueAtPath(source, ['user', 'name'])).toBe('Ada'); + expect(ValueHelper.getValueAtPath(source, ['user', 'role'])).toBe('admin'); +}); + +test('getValueAtPath reads array values', () => { + const source = { items: ['a', 'b', 'c'] }; + + expect(ValueHelper.getValueAtPath(source, ['items', 0])).toBe('a'); + expect(ValueHelper.getValueAtPath(source, ['items', 2])).toBe('c'); +}); + +test('getValueAtPath returns undefined for null or undefined root', () => { + expect(ValueHelper.getValueAtPath(null, ['value'])).toBeUndefined(); + expect(ValueHelper.getValueAtPath(undefined, ['value'])).toBeUndefined(); +}); + +test('getValueAtPath returns undefined for missing or primitive path', () => { + const source = { user: { name: 'Ada' } }; + + expect(ValueHelper.getValueAtPath(source, ['user', 'age'])).toBeUndefined(); + expect(ValueHelper.getValueAtPath(source, ['user', 'name', 'extra'])).toBeUndefined(); + expect(ValueHelper.getValueAtPath(source, ['unknown', 'path'])).toBeUndefined(); +}); + +test('setValueAtPath writes nested object values immutably', () => { + const source = { user: { name: 'Ada' } }; + const next = ValueHelper.setValueAtPath(source, ['user', 'name'], 'Grace'); + + expect(next).toEqual({ user: { name: 'Grace' } }); + expect(source).toEqual({ user: { name: 'Ada' } }); +}); + +test('setValueAtPath writes array values immutably', () => { + const source = { items: ['a', 'b', 'c'] }; + const next = ValueHelper.setValueAtPath(source, ['items', 1], 'B'); + + expect(next).toEqual({ items: ['a', 'B', 'c'] }); + expect(source).toEqual({ items: ['a', 'b', 'c'] }); +}); + +test('setValueAtPath creates intermediate containers', () => { + const source = {}; + const nextObjectContainer = ValueHelper.setValueAtPath(source, ['user', 'name'], 'Ada'); + const nextArrayContainer = ValueHelper.setValueAtPath(source, ['items', 0], 'a'); + + expect(nextObjectContainer).toEqual({ user: { name: 'Ada' } }); + expect(nextArrayContainer).toEqual({ items: ['a'] }); +}); + +test('setValueAtPath replaces root when path is empty', () => { + const source = { user: { name: 'Ada' } }; + const next = ValueHelper.setValueAtPath(source, [], { user: { name: 'Grace' } }); + + expect(next).toEqual({ user: { name: 'Grace' } }); +}); + +test('getValuesFromFields reconstructs nested values from flat fields', () => { + const state = FormStateInitializer.initialize({ + user: { name: 'Ada', role: 'admin' }, + items: ['a', 'b'], + }); + const fieldPaths = Object.fromEntries( + Object.keys(state.fields).map(key => [key, FormPath.keyToPath(key)]), + ); + + expect(ValueHelper.getValuesFromFields(state, fieldPaths)).toEqual({ + user: { name: 'Ada', role: 'admin' }, + items: ['a', 'b'], + }); +}); diff --git a/test/svelte.test.ts b/test/svelte.test.ts index e4d2467..52b4448 100644 --- a/test/svelte.test.ts +++ b/test/svelte.test.ts @@ -162,3 +162,102 @@ test('Svelte field-local schema overrides form-level schema while action is aliv await form.trigger('email'); expect(form.getFieldState('email').errors.map(error => error.message)).toEqual(['Form-level error']); }); + +test('Svelte useField returns props, value, setValue, errors, dirty, touched', () => { + const form = new CreateForm({ defaultValues: { email: '' } }); + const { useField } = useForm(form); + const field = useField({ name: 'email' }); + + expect(field.value).toBe(''); + expect(field.errors).toEqual([]); + expect(field.dirty).toBe(false); + expect(field.touched).toBe(false); + + field.setValue('ada@example.com'); + expect(field.value).toBe('ada@example.com'); + expect(form.getValue('email')).toBe('ada@example.com'); + expect(field.dirty).toBe(true); + + form.setErrors('email', [{ message: 'Required' }]); + expect(field.errors.map(error => error.message)).toEqual(['Required']); + + void form.blur('email'); + expect(field.touched).toBe(true); +}); + +test('Svelte useField props action binds input and syncs value', () => { + const form = new CreateForm({ defaultValues: { email: '' } }); + const { useField } = useForm(form); + const field = useField({ name: 'email' }); + const input = document.createElement('input'); + const action = field.props(input, { name: 'email' }); + + expect(input.name).toBe('email'); + + input.value = 'grace@example.com'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + + expect(field.value).toBe('grace@example.com'); + expect(form.getValue('email')).toBe('grace@example.com'); + + action?.destroy?.(); +}); + +test('Svelte useField with field-local schema overrides form-level schema while action is alive', async () => { + const form = new CreateForm({ + defaultValues: { email: '' }, + validateOn: ['blur', 'submit'], + schema: standardSchema(() => ({ + issues: [{ message: 'Form-level error', path: ['email'] }], + })), + }); + const emailSchema = standardSchema((value: string) => { + if (value.includes('@')) { + return { value }; + } + + return { issues: [{ message: 'Field-level error' }] }; + }); + const { useField } = useForm(form); + const field = useField({ name: 'email', schema: emailSchema }); + const input = document.createElement('input'); + const action = field.props(input, { name: 'email', schema: emailSchema }); + + await form.blur('email'); + expect(field.errors.map(error => error.message)).toEqual(['Field-level error']); + + action?.destroy?.(); + await form.trigger('email'); + expect(form.getFieldState('email').errors.map(error => error.message)).toEqual(['Form-level error']); +}); + +test('Svelte useField schema cleanup restores form-level schema after action destroy', async () => { + const form = new CreateForm({ + defaultValues: { email: '' }, + validateOn: ['blur', 'submit'], + schema: standardSchema(() => ({ + issues: [{ message: 'Form-level error', path: ['email'] }], + })), + }); + const emailSchema = standardSchema((value: string) => { + if (value.includes('@')) { + return { value }; + } + + return { issues: [{ message: 'Field-level error' }] }; + }); + const { useField } = useForm(form); + const field = useField({ name: 'email', schema: emailSchema }); + + expect(field.errors).toEqual([]); + + const input = document.createElement('input'); + const action = field.props(input, { name: 'email', schema: emailSchema }); + + await form.blur('email'); + expect(field.errors.map(error => error.message)).toEqual(['Field-level error']); + + action?.destroy?.(); + await form.trigger('email'); + expect(field.errors.map(error => error.message)).toEqual(['Form-level error']); +}); diff --git a/tsconfig.json b/tsconfig.json index c7c5c97..7318521 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "declaration": true, "declarationDir": "dist", "emitDeclarationOnly": false, + "sourceMap": true, "jsx": "react-jsx", "module": "ESNext", "moduleResolution": "bundler", @@ -13,7 +14,7 @@ "allowJs": true, "checkJs": true, "esModuleInterop": true, - "removeComments": true, + "removeComments": false, "types": ["node"] }, "include": ["src"] diff --git a/vite.config.ts b/vite.config.ts index 09d21a4..033e83b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,22 +1,9 @@ import { defineConfig } from 'vite'; +// This config is used by vitest only. Production builds use `tsc` (see package.json `build` script). +// The `build.lib` block was removed because it was dead code — `pnpm build` runs `rm -rf dist && tsc`, not `vite build`. export default defineConfig({ - build: { - lib: { - entry: { - index: 'src/index.ts', - 'react/index': 'src/react/index.ts', - 'vue/index': 'src/vue/index.ts', - 'solid/index': 'src/solid/index.ts', - 'svelte/index': 'src/svelte/index.ts', - }, - formats: ['es'], - fileName: (_format, entryName) => `${entryName}.js`, - }, - rollupOptions: { - external: ['@ilokesto/store', 'immer', 'react', 'vue', 'solid-js', 'svelte', 'svelte/action', 'svelte/store'], - }, - sourcemap: false, - emptyOutDir: false, + test: { + environment: 'jsdom', }, -}); +}); \ No newline at end of file