diff --git a/docs/config.json b/docs/config.json index 09280cf69..1fdfdced1 100644 --- a/docs/config.json +++ b/docs/config.json @@ -154,6 +154,10 @@ "label": "SSR/TanStack Start/Next.js", "to": "framework/react/guides/ssr" }, + { + "label": "Server Errors & Success Flows", + "to": "framework/react/guides/server-errors-and-success" + }, { "label": "Debugging", "to": "framework/react/guides/debugging" diff --git a/docs/framework/react/guides/server-errors-and-success.md b/docs/framework/react/guides/server-errors-and-success.md new file mode 100644 index 000000000..c87bf2efe --- /dev/null +++ b/docs/framework/react/guides/server-errors-and-success.md @@ -0,0 +1,342 @@ +# Server Errors & Success Flows + +TanStack Form provides utilities for handling server-side validation errors and success responses through the `@tanstack/form-server` package. + +## Installation + +```bash +npm install @tanstack/form-server +``` + +## Overview + +The form-server package provides three main utilities: + +- **`mapServerErrors`** - Normalizes various server error formats into a consistent structure +- **`applyServerErrors`** - Applies mapped errors to form fields and form-level state +- **`onServerSuccess`** - Handles successful responses with configurable reset and callback options + +## Mapping Server Errors + +The `mapServerErrors` function converts different server error formats into a standardized structure: + +```tsx +import { mapServerErrors } from '@tanstack/form-server' + +const zodError = { + issues: [ + { path: ['name'], message: 'Name is required' }, + { path: ['email'], message: 'Invalid email format' }, + ], +} + +const mapped = mapServerErrors(zodError) +``` + +### Supported Error Formats + +The function automatically detects and handles various server error formats: + +#### Zod-style Validation Errors + +```tsx +const zodError = { + issues: [{ path: ['items', 0, 'price'], message: 'Price must be positive' }], +} +``` + +#### Rails-style Errors + +```tsx +const railsError = { + errors: { + name: 'Name is required', + email: ['Invalid email', 'Email already taken'], + }, +} +``` + +#### Custom Field/Form Errors + +```tsx +const customError = { + fieldErrors: [{ path: 'name', message: 'Name is required' }], + formError: { message: 'Form submission failed' }, +} +``` + +### Path Mapping + +Use custom path mappers to handle different naming conventions: + +```tsx +const pathMapper = (path: string) => + path.replace(/_attributes/g, '').replace(/\[(\w+)\]/g, '.$1') + +const mapped = mapServerErrors(railsError, { pathMapper }) +``` + +## Applying Errors to Forms + +Use `applyServerErrors` to inject server errors into your form: + +```tsx +import { useForm } from '@tanstack/react-form' +import { mapServerErrors, applyServerErrors } from '@tanstack/form-server' + +function MyForm() { + const form = useForm({ + defaultValues: { name: '', email: '' }, + onSubmit: async ({ value }) => { + try { + await submitForm(value) + } catch (serverError) { + const mappedErrors = mapServerErrors(serverError) + applyServerErrors(form, mappedErrors) + } + }, + }) + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }} + > + + {(field) => ( +
+ field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

{error}

+ ))} +
+ )} +
+
+ ) +} +``` + +## Handling Success Responses + +The `onServerSuccess` function provides configurable success handling: + +```tsx +import { onServerSuccess } from '@tanstack/form-server' + +const form = useForm({ + onSubmit: async ({ value }) => { + try { + const result = await submitForm(value) + + await onServerSuccess(form, result, { + flash: { + set: (message) => setFlashMessage(message), + message: 'Form saved successfully!', + }, + after: async () => { + router.push('/success') + }, + }) + } catch (error) {} + }, +}) +``` + +### Reset Strategies + +- **`'none'`** - Don't reset the form +- **`'values'`** - Reset form values but keep validation state +- **`'all'`** - Reset everything including validation state + +## Complete Example + +```tsx +import { useForm } from '@tanstack/react-form' +import { + mapServerErrors, + applyServerErrors, + onServerSuccess, +} from '@tanstack/form-server' +import { useState } from 'react' + +function UserForm() { + const [flashMessage, setFlashMessage] = useState('') + + const form = useForm({ + defaultValues: { + name: '', + email: '', + profile: { bio: '' }, + }, + onSubmit: async ({ value }) => { + try { + const result = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(value), + }) + + if (!result.ok) { + const error = await result.json() + const mappedErrors = mapServerErrors(error, { + fallbackFormMessage: 'Failed to create user', + }) + applyServerErrors(form, mappedErrors) + return + } + + const userData = await result.json() + await onServerSuccess(form, userData, { + resetStrategy: 'all', + flash: { + set: setFlashMessage, + message: 'User created successfully!', + }, + }) + } catch (error) { + const mappedErrors = mapServerErrors(error) + applyServerErrors(form, mappedErrors) + } + }, + }) + + return ( +
+ {flashMessage &&
{flashMessage}
} + +
{ + e.preventDefault() + form.handleSubmit() + }} + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

+ {error} +

+ ))} +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

+ {error} +

+ ))} +
+ )} +
+ + +
+
+ ) +} +``` + +## Framework Integration + +### Next.js App Router + +```tsx +import { + mapServerErrors, + applyServerErrors, + onServerSuccess, +} from '@tanstack/form-server' + +async function createUser(formData: FormData) { + 'use server' + + try { + const result = await db.user.create({ + data: Object.fromEntries(formData), + }) + return { success: true, user: result } + } catch (error) { + return { success: false, error } + } +} + +function UserForm() { + const form = useForm({ + onSubmit: async ({ value }) => { + const formData = new FormData() + Object.entries(value).forEach(([key, val]) => { + formData.append(key, val as string) + }) + + const result = await createUser(formData) + + if (result.success) { + await onServerSuccess(form, result.user, { + resetStrategy: 'all', + flash: { set: toast.success, message: 'User created!' }, + }) + } else { + const mappedErrors = mapServerErrors(result.error) + applyServerErrors(form, mappedErrors) + } + }, + }) +} +``` + +### Remix + +```tsx +import { mapServerErrors, applyServerErrors } from '@tanstack/form-server' +import { useActionData } from '@remix-run/react' + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData() + + try { + const user = await createUser(Object.fromEntries(formData)) + return redirect('/users') + } catch (error) { + return json({ error }, { status: 400 }) + } +} + +function UserForm() { + const actionData = useActionData() + + const form = useForm({ + onSubmit: ({ value }) => {}, + }) + + useEffect(() => { + if (actionData?.error) { + const mappedErrors = mapServerErrors(actionData.error) + applyServerErrors(form, mappedErrors) + } + }, [actionData]) +} +``` diff --git a/packages/form-server/eslint.config.js b/packages/form-server/eslint.config.js new file mode 100644 index 000000000..8ce6ad05f --- /dev/null +++ b/packages/form-server/eslint.config.js @@ -0,0 +1,5 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +export default [...rootConfig] diff --git a/packages/form-server/package.json b/packages/form-server/package.json new file mode 100644 index 000000000..40738fb0f --- /dev/null +++ b/packages/form-server/package.json @@ -0,0 +1,56 @@ +{ + "name": "@tanstack/form-server", + "version": "1.20.0", + "description": "Server-side utilities for TanStack Form.", + "author": "tannerlinsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TanStack/form.git", + "directory": "packages/form-server" + }, + "homepage": "https://tanstack.com/form", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "scripts": { + "clean": "premove ./dist ./coverage", + "test:eslint": "eslint ./src ./tests", + "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", + "test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js", + "test:types:ts55": "node ../../node_modules/typescript55/lib/tsc.js", + "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", + "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js", + "test:types:ts58": "tsc", + "test:lib": "vitest", + "test:lib:dev": "pnpm run test:lib --watch", + "test:build": "publint --strict", + "build": "vite build" + }, + "type": "module", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/form-core": "workspace:*" + } +} diff --git a/packages/form-server/src/index.ts b/packages/form-server/src/index.ts new file mode 100644 index 000000000..823aa35c1 --- /dev/null +++ b/packages/form-server/src/index.ts @@ -0,0 +1,438 @@ +export type ServerFieldError = { + path: string + message: string + code?: string +} + +export type ServerFormError = { + message: string + code?: string +} + +export type MappedServerErrors = { + fields: Record + form?: string +} + +export type ApplyErrorsOptions = { + multipleMessages?: 'first' | 'join' | 'array' + separator?: string +} + +export type SuccessOptions = { + resetStrategy?: 'none' | 'values' | 'all' + flash?: { set: (msg: string) => void; message?: string } + after?: (result: TResult) => void | Promise + storeResult?: boolean + onRedirect?: (response: TResult) => void | Promise +} + +function isZodError( + err: unknown, +): err is { issues: Array<{ path: (string | number)[]; message: string }> } { + return ( + typeof err === 'object' && + err !== null && + 'issues' in err && + Array.isArray((err as Record).issues) + ) +} + +function isRailsError( + err: unknown, +): err is { errors: Record } { + return ( + typeof err === 'object' && + err !== null && + 'errors' in err && + typeof (err as Record).errors === 'object' && + (err as Record).errors !== null + ) +} + +function isNestJSError( + err: unknown, +): err is { message: Array<{ field: string; message: string }> } { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + Array.isArray((err as Record).message) + ) +} + +function isCustomFieldError( + err: unknown, +): err is { fieldErrors: ServerFieldError[] } { + return ( + typeof err === 'object' && + err !== null && + 'fieldErrors' in err && + Array.isArray((err as Record).fieldErrors) + ) +} + +function isCustomFormError( + err: unknown, +): err is { formError: ServerFormError } { + return ( + typeof err === 'object' && + err !== null && + 'formError' in err && + typeof (err as Record).formError === 'object' + ) +} + +function isStandardSchemaError( + err: unknown, +): err is { issues: Array<{ path: (string | number)[]; message: string }> } { + if (typeof err !== 'object' || err === null || !('issues' in err)) { + return false + } + + const issues = (err as Record).issues + if (!Array.isArray(issues) || issues.length === 0) { + return false + } + + return issues.every((issue: unknown) => + typeof issue === 'object' && + issue !== null && + 'path' in issue && + 'message' in issue && + Array.isArray((issue as Record).path) && + typeof (issue as Record).message === 'string' && + (issue as Record).message !== '' + ) +} + +function hasStringMessage(err: unknown): err is { message: string } { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as Record).message === 'string' + ) +} + +function defaultPathMapper(serverPath: string): string { + if (typeof serverPath !== 'string') { + return '' + } + + return serverPath + .replace(/\[(\d+)\]/g, '.$1') + .replace(/\[([^\]]+)\]/g, '.$1') + .replace(/^\./, '') +} + +export function mapServerErrors( + err: unknown, + opts?: { + pathMapper?: (serverPath: string) => string + fallbackFormMessage?: string + }, +): MappedServerErrors { + const pathMapper = opts?.pathMapper || defaultPathMapper + const fallbackFormMessage = opts?.fallbackFormMessage || 'An error occurred' + + const result: MappedServerErrors = { + fields: {}, + form: undefined + } + + if (!err || typeof err !== 'object') { + return { fields: {}, form: fallbackFormMessage } + } + + if (isStandardSchemaError(err)) { + for (const issue of err.issues) { + const path = Array.isArray(issue.path) ? issue.path.join('.') : String(issue.path) + const mappedPath = pathMapper(path) + if (mappedPath) { + if (!result.fields[mappedPath]) { + result.fields[mappedPath] = [] + } + result.fields[mappedPath].push(issue.message) + } + } + return result + } + + if (isZodError(err)) { + for (const issue of err.issues) { + const path = Array.isArray(issue.path) ? issue.path.join('.') : String(issue.path) + const mappedPath = pathMapper(path) + if (mappedPath) { + if (!result.fields[mappedPath]) { + result.fields[mappedPath] = [] + } + result.fields[mappedPath].push(issue.message) + } + } + return result + } + + if (isRailsError(err)) { + for (const [key, value] of Object.entries(err.errors)) { + if (typeof key === 'string') { + const path = pathMapper(key) + if (path) { + const messages = Array.isArray(value) ? value : [value] + const validMessages = messages.filter(msg => typeof msg === 'string' && msg.length > 0) + if (validMessages.length > 0) { + result.fields[path] = validMessages + } + } + } + } + return result + } + + if (isNestJSError(err)) { + for (const item of err.message) { + if ( + typeof item === 'object' && + 'field' in item && + 'message' in item + ) { + const field = item.field + const message = item.message + if ( + typeof field === 'string' && + typeof message === 'string' && + message.length > 0 + ) { + const path = pathMapper(field) + if (path) { + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(message) + } + } + } + } + return result + } + + if (isCustomFieldError(err)) { + for (const fieldError of err.fieldErrors) { + if (fieldError.path && fieldError.message && + typeof fieldError.path === 'string' && typeof fieldError.message === 'string' && + fieldError.message.length > 0) { + const path = pathMapper(fieldError.path) + if (path) { + if (!result.fields[path]) result.fields[path] = [] + result.fields[path].push(fieldError.message) + } + } + } + + if (isCustomFormError(err)) { + if ( + typeof err.formError.message === 'string' && + err.formError.message.length > 0 + ) { + result.form = err.formError.message + } + } + + return result + } + + if (isCustomFormError(err)) { + if ( + typeof err.formError.message === 'string' && + err.formError.message.length > 0 + ) { + result.form = err.formError.message + } + } else if (hasStringMessage(err)) { + result.form = err.message + return result + } + + result.form = fallbackFormMessage + return result +} + +export function applyServerErrors>( + form: TFormApi, + mapped: MappedServerErrors, + opts?: ApplyErrorsOptions, +): void { + const { multipleMessages = 'first', separator = '; ' } = opts || {} + + for (const [path, messages] of Object.entries(mapped.fields)) { + if (Array.isArray(messages) && messages.length > 0) { + let errorMessage: string | string[] + + switch (multipleMessages) { + case 'join': { + errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0).join(separator) + break + } + case 'array': { + errorMessage = messages.filter(msg => typeof msg === 'string' && msg.length > 0) + break + } + default: { + const firstValid = messages.find(msg => typeof msg === 'string' && msg.length > 0) + errorMessage = firstValid || '' + break + } + } + + if (errorMessage && 'setFieldMeta' in form && typeof form.setFieldMeta === 'function') { + form.setFieldMeta(path, (prev: unknown) => { + const prevMeta = (prev as Record) + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: errorMessage, + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) + } + } + } + + if (mapped.form && typeof mapped.form === 'string' && mapped.form.length > 0) { + if ('setFormMeta' in form && typeof form.setFormMeta === 'function') { + form.setFormMeta((prev: unknown) => { + const prevMeta = (prev as Record) + return { + ...prevMeta, + errorMap: { + ...(prevMeta.errorMap as Record), + onServer: mapped.form, + }, + errorSourceMap: { + ...(prevMeta.errorSourceMap as Record), + onServer: 'server', + }, + } + }) + } + } +} + +export async function onServerSuccess< + TFormApi extends { + reset?: (options?: { resetValidation?: boolean }) => void + setFormMeta?: (updater: (prev: unknown) => unknown) => void + }, + TResult = unknown, +>( + form: TFormApi, + result: TResult, + opts?: SuccessOptions, +): Promise { + if (typeof form !== 'object') { + return + } + + const { + resetStrategy = 'none', + flash, + after, + storeResult = false, + } = opts || {} + + if (storeResult && 'setFormMeta' in form && typeof form.setFormMeta === 'function') { + form.setFormMeta((prev: unknown) => { + const prevMeta = (prev as Record) + return { + ...prevMeta, + _serverResponse: result, + _serverFormError: '', + } + }) + } + + if (resetStrategy !== 'none' && 'reset' in form && typeof form.reset === 'function') { + if (resetStrategy === 'values') { + form.reset({ resetValidation: false }) + } else { + form.reset() + } + } + + if (flash?.set && flash.message && typeof flash.set === 'function') { + flash.set(flash.message) + } + + if (after && typeof after === 'function') { + await after(result) + } +} + +export const selectServerResponse = ( + store: unknown, +): T | undefined => { + if (store && typeof store === 'object' && '_serverResponse' in store) { + return (store as Record)._serverResponse as T + } + return undefined +} + +export const selectServerFormError = (store: unknown): string | undefined => { + if (store && typeof store === 'object' && '_serverFormError' in store) { + return (store as Record)._serverFormError as string + } + return undefined +} + +export function createServerErrorResponse( + error: unknown, + opts?: { + pathMapper?: (serverPath: string) => string + fallbackFormMessage?: string + } +): { + success: false + errors: MappedServerErrors +} { + return { + success: false, + errors: mapServerErrors(error, opts) + } +} + +export function createServerSuccessResponse( + data: T +): { + success: true + data: T +} { + return { + success: true, + data + } +} + +export function getFormError(mapped: MappedServerErrors): string | undefined { + return mapped.form +} + +export function hasFieldErrors(mapped: MappedServerErrors): boolean { + return Object.keys(mapped.fields).length > 0 +} + +export function getAllErrorMessages(mapped: MappedServerErrors): string[] { + const messages: string[] = [] + + for (const fieldErrors of Object.values(mapped.fields)) { + messages.push(...fieldErrors) + } + + if (mapped.form) { + messages.push(mapped.form) + } + + return messages +} diff --git a/packages/form-server/tests/applyServerErrors.test.ts b/packages/form-server/tests/applyServerErrors.test.ts new file mode 100644 index 000000000..bd0e3f9aa --- /dev/null +++ b/packages/form-server/tests/applyServerErrors.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyServerErrors } from '../src/index' +import type { ApplyErrorsOptions, MappedServerErrors } from '../src/index' + +describe('applyServerErrors', () => { + it('should apply field errors to form', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: ['Name is required'], + email: ['Invalid email format'], + }, + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'name', + expect.any(Function), + ) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'email', + expect.any(Function), + ) + expect(mockForm.setFormMeta).not.toHaveBeenCalled() + + const nameCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const prevMeta = { errorMap: {}, errorSourceMap: {} } + const newMeta = nameCallback?.(prevMeta) + + expect(newMeta).toEqual({ + errorMap: { onServer: 'Name is required' }, + errorSourceMap: { onServer: 'server' }, + }) + }) + + it('should apply form-level error', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: {}, + form: 'Form submission failed', + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).not.toHaveBeenCalled() + expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) + + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] + const prevMeta = { errorMap: {}, errorSourceMap: {} } + const newMeta = formCallback?.(prevMeta) + + expect(newMeta).toEqual({ + errorMap: { onServer: 'Form submission failed' }, + errorSourceMap: { onServer: 'server' }, + }) + }) + + it('should apply both field and form errors', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: ['Name is required'], + }, + form: 'Form has errors', + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'name', + expect.any(Function), + ) + expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('should preserve existing error maps', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: ['Server error'], + }, + form: 'Server form error', + } + + applyServerErrors(mockForm, mappedErrors) + + const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const prevFieldMeta = { + errorMap: { onChange: 'Client validation error' }, + errorSourceMap: { onChange: 'client' }, + } + const newFieldMeta = fieldCallback?.(prevFieldMeta) + + expect(newFieldMeta).toEqual({ + errorMap: { + onChange: 'Client validation error', + onServer: 'Server error', + }, + errorSourceMap: { + onChange: 'client', + onServer: 'server', + }, + }) + + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] + const prevFormMeta = { + errorMap: { onSubmit: 'Client form error' }, + errorSourceMap: { onSubmit: 'client' }, + } + const newFormMeta = formCallback?.(prevFormMeta) + + expect(newFormMeta).toEqual({ + errorMap: { + onSubmit: 'Client form error', + onServer: 'Server form error', + }, + errorSourceMap: { + onSubmit: 'client', + onServer: 'server', + }, + }) + }) + + it('should handle empty field arrays', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + name: [], + email: ['Email error'], + }, + } + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledTimes(1) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'email', + expect.any(Function), + ) + }) + + it('should use first error message when multiple exist', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: ['First error', 'Second error', 'Third error'], + }, + } + + applyServerErrors(mockForm, mappedErrors) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + + expect(newMeta.errorMap.onServer).toBe('First error') + }) + + it('should handle multiple messages with first strategy (default)', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: [ + 'Invalid email format', + 'Email already exists', + 'Email too long', + ], + }, + } + + applyServerErrors(mockForm, mappedErrors) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + expect(newMeta?.errorMap.onServer).toBe('Invalid email format') + }) + + it('should handle multiple messages with join strategy', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: ['Invalid email format', 'Email already exists'], + }, + } + + const options: ApplyErrorsOptions = { + multipleMessages: 'join', + separator: ' | ', + } + + applyServerErrors(mockForm, mappedErrors, options) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + expect(newMeta?.errorMap.onServer).toBe( + 'Invalid email format | Email already exists', + ) + }) + + it('should handle multiple messages with array strategy', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const mappedErrors: MappedServerErrors = { + fields: { + email: ['Invalid email format', 'Email already exists'], + }, + } + + const options: ApplyErrorsOptions = { + multipleMessages: 'array', + } + + applyServerErrors(mockForm, mappedErrors, options) + + const callback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const newMeta = callback?.({ errorMap: {}, errorSourceMap: {} }) + expect(newMeta?.errorMap.onServer).toEqual([ + 'Invalid email format', + 'Email already exists', + ]) + }) +}) diff --git a/packages/form-server/tests/integration.test.ts b/packages/form-server/tests/integration.test.ts new file mode 100644 index 000000000..72cf445d4 --- /dev/null +++ b/packages/form-server/tests/integration.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest' +import { + applyServerErrors, + mapServerErrors, + onServerSuccess, +} from '../src/index' + +describe('integration tests', () => { + it('should handle complete error mapping and application flow', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + reset: vi.fn(), + } + + const serverError = { + issues: [ + { path: ['name'], message: 'Name is required' }, + { path: ['email'], message: 'Invalid email format' }, + { path: ['items', 0, 'price'], message: 'Price must be positive' }, + ], + } + + const mappedErrors = mapServerErrors(serverError) + + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledTimes(3) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'name', + expect.any(Function), + ) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'email', + expect.any(Function), + ) + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'items.0.price', + expect.any(Function), + ) + }) + + it('should handle success flow with all options', async () => { + const mockForm = { + reset: vi.fn(), + } + const mockFlashSet = vi.fn() + const mockAfter = vi.fn() + + const serverResponse = { id: 123, message: 'User created successfully' } + + await onServerSuccess(mockForm, serverResponse, { + resetStrategy: 'all', + flash: { + set: mockFlashSet, + message: 'User created successfully!', + }, + after: mockAfter, + }) + + expect(mockForm.reset).toHaveBeenCalledWith() + expect(mockFlashSet).toHaveBeenCalledWith('User created successfully!') + expect(mockAfter).toHaveBeenCalled() + }) + + it('should handle mixed field and form errors', () => { + const mockForm = { + setFieldMeta: vi.fn(), + setFormMeta: vi.fn(), + } + + const serverError = { + fieldErrors: [{ path: 'username', message: 'Username already taken' }], + formError: { message: 'Account creation failed' }, + } + + const mappedErrors = mapServerErrors(serverError) + applyServerErrors(mockForm, mappedErrors) + + expect(mockForm.setFieldMeta).toHaveBeenCalledWith( + 'username', + expect.any(Function), + ) + expect(mockForm.setFormMeta).toHaveBeenCalledWith(expect.any(Function)) + + const fieldCallback = mockForm.setFieldMeta.mock.calls[0]?.[1] + const fieldMeta = fieldCallback?.({ errorMap: {}, errorSourceMap: {} }) + expect(fieldMeta?.errorMap.onServer).toBe('Username already taken') + + const formCallback = mockForm.setFormMeta.mock.calls[0]?.[0] + const formMeta = formCallback?.({ errorMap: {}, errorSourceMap: {} }) + expect(formMeta?.errorMap.onServer).toBe('Account creation failed') + }) + + it('should handle path mapping with complex nested structures', () => { + const serverError = { + errors: { + 'user[profile][addresses][0][street]': 'Street is required', + 'items[1][variants][0][price]': 'Price must be positive', + }, + } + + const mappedErrors = mapServerErrors(serverError) + + expect(mappedErrors).toEqual({ + fields: { + 'user.profile.addresses.0.street': ['Street is required'], + 'items.1.variants.0.price': ['Price must be positive'], + }, + }) + }) + + it('should handle custom path mapper with real-world scenario', () => { + const railsError = { + errors: { + 'user_attributes[profile_attributes][name]': 'Name is required', + 'items_attributes[0][name]': 'Item name is required', + 'items_attributes[0][price]': 'Price must be positive', + }, + } + + const pathMapper = (path: string) => { + return path + .replace(/_attributes/g, '') + .replace(/\[(\d+)\]/g, '.$1') + .replace(/\[([^\]]+)\]/g, '.$1') + .replace(/^\./, '') + } + + const mappedErrors = mapServerErrors(railsError, { pathMapper }) + + expect(mappedErrors).toEqual({ + fields: { + 'user.profile.name': ['Name is required'], + 'items.0.name': ['Item name is required'], + 'items.0.price': ['Price must be positive'], + }, + }) + }) +}) diff --git a/packages/form-server/tests/mapServerErrors.test.ts b/packages/form-server/tests/mapServerErrors.test.ts new file mode 100644 index 000000000..582e2573d --- /dev/null +++ b/packages/form-server/tests/mapServerErrors.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' +import { mapServerErrors } from '../src/index' + +describe('mapServerErrors', () => { + describe('Zod-like errors', () => { + it('should map zod validation errors', () => { + const zodError = { + issues: [ + { path: ['name'], message: 'Name is required' }, + { path: ['email'], message: 'Invalid email format' }, + { path: ['items', 0, 'price'], message: 'Price must be positive' }, + ], + } + + const result = mapServerErrors(zodError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email format'], + 'items.0.price': ['Price must be positive'], + }, + }) + }) + + it('should handle nested array paths', () => { + const zodError = { + issues: [ + { + path: ['users', 1, 'addresses', 0, 'street'], + message: 'Street is required', + }, + ], + } + + const result = mapServerErrors(zodError) + + expect(result).toEqual({ + fields: { + 'users.1.addresses.0.street': ['Street is required'], + }, + }) + }) + }) + + describe('Rails-like errors', () => { + it('should map rails validation errors', () => { + const railsError = { + errors: { + name: 'Name is required', + email: ['Invalid email', 'Email already taken'], + 'user.profile.bio': 'Bio is too long', + }, + } + + const result = mapServerErrors(railsError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email', 'Email already taken'], + 'user.profile.bio': ['Bio is too long'], + }, + }) + }) + }) + + describe('NestJS-like errors', () => { + it('should map nestjs validation errors', () => { + const nestError = { + message: [ + { field: 'name', message: 'Name is required' }, + { field: 'email', message: 'Invalid email' }, + ], + } + + const result = mapServerErrors(nestError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email'], + }, + }) + }) + }) + + describe('Custom field/form error format', () => { + it('should map field and form errors', () => { + const customError = { + fieldErrors: [ + { path: 'name', message: 'Name is required' }, + { path: 'email', message: 'Invalid email' }, + ], + formError: { message: 'Form submission failed' }, + } + + const result = mapServerErrors(customError) + + expect(result).toEqual({ + fields: { + name: ['Name is required'], + email: ['Invalid email'], + }, + form: 'Form submission failed', + }) + }) + }) + + describe('Path mapping', () => { + it('should use custom path mapper', () => { + const error = { + issues: [ + { path: ['items[0].price'], message: 'Price is required' }, + { path: ['user[profile][name]'], message: 'Name is required' }, + ], + } + + const pathMapper = (path: string) => path.replace(/\[(\w+)\]/g, '.$1') + const result = mapServerErrors(error, { pathMapper }) + + expect(result).toEqual({ + fields: { + 'items.0.price': ['Price is required'], + 'user.profile.name': ['Name is required'], + }, + }) + }) + + it('should handle bracket notation with default mapper', () => { + const error = { + errors: { + 'items[0].name': 'Name is required', + 'items[1][price]': 'Price is required', + 'user[addresses][0][street]': 'Street is required', + }, + } + + const result = mapServerErrors(error) + + expect(result).toEqual({ + fields: { + 'items.0.name': ['Name is required'], + 'items.1.price': ['Price is required'], + 'user.addresses.0.street': ['Street is required'], + }, + }) + }) + }) + + describe('Fallback handling', () => { + it('should use fallback message for unknown error format', () => { + const unknownError = { someRandomProperty: 'value' } + + const result = mapServerErrors(unknownError) + + expect(result).toEqual({ + fields: {}, + form: 'An error occurred', + }) + }) + + it('should use custom fallback message', () => { + const unknownError = { someRandomProperty: 'value' } + + const result = mapServerErrors(unknownError, { + fallbackFormMessage: 'Custom error message', + }) + + expect(result).toEqual({ + fields: {}, + form: 'Custom error message', + }) + }) + + it('should handle non-object errors', () => { + expect(mapServerErrors(null)).toEqual({ + fields: {}, + form: 'An error occurred', + }) + + expect(mapServerErrors('string error')).toEqual({ + fields: {}, + form: 'An error occurred', + }) + + expect(mapServerErrors(undefined)).toEqual({ + fields: {}, + form: 'An error occurred', + }) + }) + + it('should extract message from generic error object', () => { + const error = { message: 'Something went wrong' } + + const result = mapServerErrors(error) + + expect(result).toEqual({ + fields: {}, + form: 'Something went wrong', + }) + }) + }) +}) diff --git a/packages/form-server/tests/onServerSuccess.test.ts b/packages/form-server/tests/onServerSuccess.test.ts new file mode 100644 index 000000000..ad264e8bf --- /dev/null +++ b/packages/form-server/tests/onServerSuccess.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest' +import { onServerSuccess } from '../src/index' +import type { SuccessOptions } from '../src/index' + +describe('onServerSuccess', () => { + it('should handle success with no options', async () => { + const mockForm = { + reset: vi.fn(), + } + + await onServerSuccess(mockForm, { success: true }) + + expect(mockForm.reset).not.toHaveBeenCalled() + }) + + it('should reset values only when resetStrategy is "values"', async () => { + const mockForm = { + reset: vi.fn(), + } + + const options: SuccessOptions = { + resetStrategy: 'values', + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockForm.reset).toHaveBeenCalledWith({ resetValidation: false }) + }) + + it('should reset all when resetStrategy is "all"', async () => { + const mockForm = { + reset: vi.fn(), + } + + const options: SuccessOptions = { + resetStrategy: 'all', + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockForm.reset).toHaveBeenCalledWith() + }) + + it('should not reset when resetStrategy is "none"', async () => { + const mockForm = { + reset: vi.fn(), + } + + const options: SuccessOptions = { + resetStrategy: 'none', + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockForm.reset).not.toHaveBeenCalled() + }) + + it('should set flash message when provided', async () => { + const mockForm = {} + const mockFlashSet = vi.fn() + + const options: SuccessOptions = { + flash: { + set: mockFlashSet, + message: 'Success! Data saved.', + }, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockFlashSet).toHaveBeenCalledWith('Success! Data saved.') + }) + + it('should not set flash message when no message provided', async () => { + const mockForm = {} + const mockFlashSet = vi.fn() + + const options: SuccessOptions = { + flash: { + set: mockFlashSet, + }, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockFlashSet).not.toHaveBeenCalled() + }) + + it('should execute after callback', async () => { + const mockForm = {} + const mockAfter = vi.fn() + + const options: SuccessOptions = { + after: mockAfter, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockAfter).toHaveBeenCalled() + }) + + it('should execute async after callback', async () => { + const mockForm = {} + const mockAfter = vi.fn().mockResolvedValue(undefined) + + const options: SuccessOptions = { + after: mockAfter, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockAfter).toHaveBeenCalled() + }) + + it('should execute operations in correct order', async () => { + const mockForm = { + reset: vi.fn(), + } + const mockFlashSet = vi.fn() + const mockAfter = vi.fn() + + const options: SuccessOptions = { + resetStrategy: 'all', + flash: { + set: mockFlashSet, + message: 'Success!', + }, + after: mockAfter, + } + + await onServerSuccess(mockForm, { success: true }, options) + + expect(mockForm.reset).toHaveBeenCalled() + expect(mockFlashSet).toHaveBeenCalledWith('Success!') + expect(mockAfter).toHaveBeenCalled() + + const resetCallOrder = mockForm.reset.mock.invocationCallOrder[0]! + const flashCallOrder = mockFlashSet.mock.invocationCallOrder[0]! + const afterCallOrder = mockAfter.mock.invocationCallOrder[0]! + + expect(resetCallOrder).toBeLessThan(flashCallOrder) + expect(flashCallOrder).toBeLessThan(afterCallOrder) + }) + + it('should handle form without reset method', async () => { + const mockForm = {} + + const options: SuccessOptions = { + resetStrategy: 'all', + } + + await expect( + onServerSuccess(mockForm, { success: true }, options), + ).resolves.toBeUndefined() + }) + + it('should handle all options together', async () => { + const mockForm = { + reset: vi.fn(), + } + const mockFlashSet = vi.fn() + const mockAfter = vi.fn() + + const options: SuccessOptions = { + resetStrategy: 'values', + flash: { + set: mockFlashSet, + message: 'Data saved successfully!', + }, + after: mockAfter, + } + + await onServerSuccess(mockForm, { id: 123, name: 'Test' }, options) + + expect(mockForm.reset).toHaveBeenCalledWith({ resetValidation: false }) + expect(mockFlashSet).toHaveBeenCalledWith('Data saved successfully!') + expect(mockAfter).toHaveBeenCalled() + }) +}) diff --git a/packages/form-server/tests/selectors.test.ts b/packages/form-server/tests/selectors.test.ts new file mode 100644 index 000000000..54816c6a0 --- /dev/null +++ b/packages/form-server/tests/selectors.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { selectServerFormError, selectServerResponse } from '../src/index' + +describe('selectors', () => { + describe('selectServerResponse', () => { + it('should return server response when present', () => { + const store = { + _serverResponse: { id: 123, name: 'Test' }, + otherData: 'value', + } + + const result = selectServerResponse(store) + + expect(result).toEqual({ id: 123, name: 'Test' }) + }) + + it('should return undefined when server response not present', () => { + const store = { + otherData: 'value', + } + + const result = selectServerResponse(store) + + expect(result).toBeUndefined() + }) + + it('should return undefined for non-object store', () => { + expect(selectServerResponse(null)).toBeUndefined() + expect(selectServerResponse(undefined)).toBeUndefined() + expect(selectServerResponse('string')).toBeUndefined() + expect(selectServerResponse(123)).toBeUndefined() + }) + + it('should return typed response', () => { + interface UserResponse { + id: number + name: string + } + + const store = { + _serverResponse: { id: 123, name: 'Test' }, + } + + const result = selectServerResponse(store) + + expect(result).toEqual({ id: 123, name: 'Test' }) + + if (result) { + expect(typeof result.id).toBe('number') + expect(typeof result.name).toBe('string') + } + }) + }) + + describe('selectServerFormError', () => { + it('should return server form error when present', () => { + const store = { + _serverFormError: 'Form submission failed', + otherData: 'value', + } + + const result = selectServerFormError(store) + + expect(result).toBe('Form submission failed') + }) + + it('should return undefined when server form error not present', () => { + const store = { + otherData: 'value', + } + + const result = selectServerFormError(store) + + expect(result).toBeUndefined() + }) + + it('should return undefined for non-object store', () => { + expect(selectServerFormError(null)).toBeUndefined() + expect(selectServerFormError(undefined)).toBeUndefined() + expect(selectServerFormError('string')).toBeUndefined() + expect(selectServerFormError(123)).toBeUndefined() + }) + + it('should handle non-string server form error', () => { + const store = { + _serverFormError: 123, + } + + const result = selectServerFormError(store) + + expect(result).toBe(123) + }) + }) + + describe('integration with complex store', () => { + it('should work with complex store structure', () => { + const store = { + form: { + values: { name: 'test' }, + errors: {}, + }, + _serverResponse: { success: true, id: 456 }, + _serverFormError: 'Server validation failed', + ui: { + loading: false, + }, + } + + expect(selectServerResponse(store)).toEqual({ success: true, id: 456 }) + expect(selectServerFormError(store)).toBe('Server validation failed') + }) + }) +}) diff --git a/packages/form-server/tests/serverHelpers.test.ts b/packages/form-server/tests/serverHelpers.test.ts new file mode 100644 index 000000000..530eefe49 --- /dev/null +++ b/packages/form-server/tests/serverHelpers.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import { + createServerErrorResponse, + createServerSuccessResponse, + getAllErrorMessages, + getFormError, + hasFieldErrors +} from '../src/index' + +describe('Server helper functions', () => { + describe('createServerErrorResponse', () => { + it('should create error response with mapped errors', () => { + const zodError = { + issues: [ + { path: ['name'], message: 'Name is required' } + ] + } + + const result = createServerErrorResponse(zodError) + + expect(result.success).toBe(false) + expect(result.errors.fields.name).toEqual(['Name is required']) + }) + + it('should apply path mapper in error response', () => { + const error = { + issues: [ + { path: ['user[0].name'], message: 'Name is required' } + ] + } + + const result = createServerErrorResponse(error, { + pathMapper: (path) => path.replace(/\[(\d+)\]/g, '.$1') + }) + + expect(result.success).toBe(false) + expect(result.errors.fields['user.0.name']).toEqual(['Name is required']) + }) + }) + + describe('createServerSuccessResponse', () => { + it('should create success response with data', () => { + const data = { id: 1, name: 'John' } + const result = createServerSuccessResponse(data) + + expect(result.success).toBe(true) + expect(result.data).toEqual(data) + }) + + it('should handle undefined data', () => { + const result = createServerSuccessResponse(undefined) + + expect(result.success).toBe(true) + expect(result.data).toBeUndefined() + }) + }) + + describe('getFormError', () => { + it('should extract form error', () => { + const mapped = { + fields: { name: ['Name error'] }, + form: 'Form level error' + } + + expect(getFormError(mapped)).toBe('Form level error') + }) + + it('should return undefined when no form error', () => { + const mapped = { + fields: { name: ['Name error'] } + } + + expect(getFormError(mapped)).toBeUndefined() + }) + }) + + describe('hasFieldErrors', () => { + it('should return true when field errors exist', () => { + const mapped = { + fields: { name: ['Name error'] }, + form: 'Form error' + } + + expect(hasFieldErrors(mapped)).toBe(true) + }) + + it('should return false when no field errors', () => { + const mapped = { + fields: {}, + form: 'Form error' + } + + expect(hasFieldErrors(mapped)).toBe(false) + }) + }) + + describe('getAllErrorMessages', () => { + it('should collect all error messages', () => { + const mapped = { + fields: { + name: ['Name is required'], + email: ['Invalid email', 'Email taken'] + }, + form: 'Form level error' + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual([ + 'Name is required', + 'Invalid email', + 'Email taken', + 'Form level error' + ]) + }) + + it('should handle empty fields', () => { + const mapped = { + fields: {}, + form: 'Form error' + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual(['Form error']) + }) + + it('should handle no form error', () => { + const mapped = { + fields: { + name: ['Name error'] + } + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual(['Name error']) + }) + + it('should handle completely empty errors', () => { + const mapped = { + fields: {} + } + + const messages = getAllErrorMessages(mapped) + + expect(messages).toEqual([]) + }) + }) +}) diff --git a/packages/form-server/tests/standardSchema.test.ts b/packages/form-server/tests/standardSchema.test.ts new file mode 100644 index 000000000..eb572016c --- /dev/null +++ b/packages/form-server/tests/standardSchema.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { mapServerErrors } from '../src/index' + +describe('Standard Schema v1 support', () => { + it('should handle Standard Schema errors with highest priority', () => { + const standardSchemaError = { + issues: [ + { + path: ['name'], + message: 'Name is required' + }, + { + path: ['email'], + message: 'Invalid email format' + } + ] + } + + const result = mapServerErrors(standardSchemaError) + + expect(result.fields.name).toEqual(['Name is required']) + expect(result.fields.email).toEqual(['Invalid email format']) + expect(result.form).toBeUndefined() + }) + + it('should handle nested path arrays in Standard Schema', () => { + const standardSchemaError = { + issues: [ + { + path: ['user', 'profile', 'age'], + message: 'Age must be a number' + } + ] + } + + const result = mapServerErrors(standardSchemaError) + + expect(result.fields['user.profile.age']).toEqual(['Age must be a number']) + }) + + it('should prioritize Standard Schema over Zod when both formats are present', () => { + const mixedError = { + issues: [ + { + path: ['name'], + message: 'Standard Schema error' + } + ] + } + + const result = mapServerErrors(mixedError) + + expect(result.fields.name).toEqual(['Standard Schema error']) + }) + + it('should handle invalid Standard Schema format as generic error', () => { + const invalidStandardSchema = { + issues: [ + { + message: 'Invalid format' + }, + { + path: 'invalid-path', + message: 'Another error' + } + ] + } + + const result = mapServerErrors(invalidStandardSchema, { + fallbackFormMessage: 'Fallback error' + }) + + expect(result.fields.undefined).toEqual(['Invalid format']) + expect(result.fields['invalid-path']).toEqual(['Another error']) + }) +}) diff --git a/packages/form-server/tsconfig.json b/packages/form-server/tsconfig.json new file mode 100644 index 000000000..e04bee269 --- /dev/null +++ b/packages/form-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "moduleResolution": "Bundler" + }, + "include": ["src", "tests", "eslint.config.js", "vite.config.ts"] +} diff --git a/packages/form-server/vite.config.ts b/packages/form-server/vite.config.ts new file mode 100644 index 000000000..6334a53d9 --- /dev/null +++ b/packages/form-server/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/config/vite' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: './src/index.ts', + srcDir: './src', + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ef23b4c2..214c6b78b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1103,6 +1103,12 @@ importers: specifier: ^3.25.76 version: 3.25.76 + packages/form-server: + dependencies: + '@tanstack/form-core': + specifier: workspace:* + version: link:../form-core + packages/lit-form: dependencies: '@tanstack/form-core':