From bc3f53065d7a9c4851ab0f5a75bc2ddbe60b35b6 Mon Sep 17 00:00:00 2001 From: Andres Jimenez Date: Mon, 9 Feb 2026 20:52:40 -0600 Subject: [PATCH 1/2] feat(form-solid): add SolidJS bindings package with auto-submit support Introduce a new @lucas-barake/effect-form-solid package providing SolidJS bindings for the Effect-powered form library. The package includes: - Full integration with SolidJS reactive primitives - Support for both manual and auto-submit form modes - Comprehensive test suite covering debouncing, race conditions, and edge cases - Proper TypeScript configuration with SolidJS JSX support - Build configuration with Babel for SolidJS compatibility The implementation reuses the core form logic from @lucas-barake/effect-form while providing a SolidJS-specific API surface with components and hooks. --- package.json | 1 + packages/form-react/tsconfig.json | 10 +- packages/form-solid/CHANGELOG.md | 7 + packages/form-solid/LICENSE | 21 + packages/form-solid/README.md | 5 + packages/form-solid/package.json | 64 + packages/form-solid/src/FormSolid.tsx | 498 ++++ packages/form-solid/src/index.ts | 3 + packages/form-solid/test/FormSolid.test.tsx | 2132 +++++++++++++++++ .../internal/debounce-auto-submit.test.tsx | 416 ++++ packages/form-solid/tsconfig.json | 17 + packages/form-solid/vitest-setup.ts | 7 + packages/form-solid/vitest.config.ts | 16 + pnpm-lock.yaml | 206 ++ tsconfig.base.json | 1 - tsconfig.json | 4 +- tsconfig.packages.json | 3 +- 17 files changed, 3407 insertions(+), 4 deletions(-) create mode 100644 packages/form-solid/CHANGELOG.md create mode 100644 packages/form-solid/LICENSE create mode 100644 packages/form-solid/README.md create mode 100644 packages/form-solid/package.json create mode 100644 packages/form-solid/src/FormSolid.tsx create mode 100644 packages/form-solid/src/index.ts create mode 100644 packages/form-solid/test/FormSolid.test.tsx create mode 100644 packages/form-solid/test/internal/debounce-auto-submit.test.tsx create mode 100644 packages/form-solid/tsconfig.json create mode 100644 packages/form-solid/vitest-setup.ts create mode 100644 packages/form-solid/vitest.config.ts diff --git a/package.json b/package.json index a4e76f8..d4916da 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", + "@solidjs/testing-library": "0.8.10", "@types/jsdom": "^27.0.0", "@types/node": "^24.10.1", "@types/react": "^19.2.5", diff --git a/packages/form-react/tsconfig.json b/packages/form-react/tsconfig.json index 3fdeadb..e76a3f5 100644 --- a/packages/form-react/tsconfig.json +++ b/packages/form-react/tsconfig.json @@ -1,7 +1,15 @@ { "$schema": "http://json.schemastore.org/tsconfig", "extends": "../../tsconfig.base.json", - "include": ["src"], + "compilerOptions": { + "rootDir": ".", + "jsx": "react-jsx", + "types": ["node", "vitest/globals", "@testing-library/react", "@testing-library/jest-dom"], + "paths": { + "@lucas-barake/effect-form-react": ["./src/index.ts"] + } + }, + "include": ["src", "test"], "references": [ { "path": "../form" } ] diff --git a/packages/form-solid/CHANGELOG.md b/packages/form-solid/CHANGELOG.md new file mode 100644 index 0000000..7849e17 --- /dev/null +++ b/packages/form-solid/CHANGELOG.md @@ -0,0 +1,7 @@ +# @lucas-barake/effect-form-solid + +## 0.22.0 + +### Minor Changes + +- Initial release of SolidJS bindings for effect-form diff --git a/packages/form-solid/LICENSE b/packages/form-solid/LICENSE new file mode 100644 index 0000000..0cff889 --- /dev/null +++ b/packages/form-solid/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Lucas Barake + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/form-solid/README.md b/packages/form-solid/README.md new file mode 100644 index 0000000..25e5ba7 --- /dev/null +++ b/packages/form-solid/README.md @@ -0,0 +1,5 @@ +# @lucas-barake/effect-form-solid + +SolidJS bindings for Effect-powered forms with type-safe validation. + +See [main documentation](https://github.com/lucas-barake/effect-form) for usage. diff --git a/packages/form-solid/package.json b/packages/form-solid/package.json new file mode 100644 index 0000000..b67e0f2 --- /dev/null +++ b/packages/form-solid/package.json @@ -0,0 +1,64 @@ +{ + "name": "@lucas-barake/effect-form-solid", + "version": "0.22.0", + "description": "SolidJS bindings for @lucas-barake/effect-form", + "type": "module", + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./package.json": "./package.json", + ".": "./dist/index.js", + "./*": "./dist/*.js", + "./internal/*": null + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null + }, + "files": [ + "src/**/*.ts", + "src/**/*.tsx", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map" + ], + "repository": { + "type": "git", + "url": "https://github.com/lucas-barake/effect-form.git" + }, + "homepage": "https://github.com/lucas-barake/effect-form", + "scripts": { + "build": "tsc -b tsconfig.json && pnpm babel", + "build:tsgo": "tsgo -b tsconfig.json && pnpm babel", + "babel": "babel dist --plugins annotate-pure-calls --presets babel-preset-solid --out-dir dist --source-maps" + }, + "keywords": [ + "effect", + "form", + "solid", + "schema", + "validation", + "typescript" + ], + "author": "Andres Jimenez ", + "license": "MIT", + "sideEffects": [], + "dependencies": { + "@lucas-barake/effect-form": "workspace:^" + }, + "devDependencies": { + "babel-preset-solid": "^1.9.10", + "vite-plugin-solid": "^2.11.10" + }, + "peerDependencies": { + "@effect-atom/atom": "^0.5.0", + "@effectify/solid-effect-atom": "^0.2.3", + "effect": "^3.19.15", + "solid-js": "^1.9.11" + } +} diff --git a/packages/form-solid/src/FormSolid.tsx b/packages/form-solid/src/FormSolid.tsx new file mode 100644 index 0000000..cbadda8 --- /dev/null +++ b/packages/form-solid/src/FormSolid.tsx @@ -0,0 +1,498 @@ +import { RegistryContext, useAtom, useAtomMount, useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import * as Atom from "@effect-atom/atom/Atom" +import type * as Registry from "@effect-atom/atom/Registry" +import { Field, FormAtoms } from "@lucas-barake/effect-form" +import type { FieldState as FieldStateModule, Mode } from "@lucas-barake/effect-form" +import type * as FormBuilder from "@lucas-barake/effect-form/FormBuilder" +import { getNestedValue } from "@lucas-barake/effect-form/Path" +import type * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import type * as ParseResult from "effect/ParseResult" +import type * as Schema from "effect/Schema" +import { createContext, useContext, createMemo, createEffect, createSignal, onCleanup, onMount, Show } from "solid-js" +import type { Component, JSX, Accessor } from "solid-js" + +export type FieldValue = FieldStateModule.FieldValue + +export type FieldState = FieldStateModule.FieldState + +export interface ArrayFieldOperations { + readonly items: () => ReadonlyArray + readonly append: (value?: TItem) => void + readonly remove: (index: number) => void + readonly swap: (indexA: number, indexB: number) => void + readonly move: (from: number, to: number) => void +} + +export interface FieldComponentProps,> { + readonly field: FieldState + readonly props: P +} + +export type FieldComponent,> = Component, P>> + +export type ExtractExtraProps = C extends Component> ? P : Record + +type StructFieldsFromSchema = S extends Schema.Struct ? Fields + : S extends { readonly from: infer From } ? StructFieldsFromSchema + : never + +export type ArrayItemComponentMap = StructFieldsFromSchema extends + Schema.Struct.Fields ? { + readonly [K in keyof StructFieldsFromSchema]: StructFieldsFromSchema[K] extends Schema.Schema.Any + ? Component[K]>, any>> + : never + } + : Component, any>> + +export type FieldComponentMap = { + readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef + ? Component, any>> + : TFields[K] extends Field.ArrayFieldDef ? ArrayItemComponentMap + : never +} + +export type FieldRefs = FormAtoms.FieldRefs + +export type BuiltForm< + TFields extends Field.FieldsRecord, + R, + A = void, + E = never, + SubmitArgs = void, + CM extends FieldComponentMap = FieldComponentMap, +> = { + readonly values: Atom.Atom>> + readonly isDirty: Atom.Atom + readonly hasChangedSinceSubmit: Atom.Atom + readonly lastSubmittedValues: Atom.Atom>> + readonly submitCount: Atom.Atom + + readonly schema: Schema.Schema, Field.EncodedFromFields, R> + readonly fields: FieldRefs + + readonly Initialize: Component<{ + readonly defaultValues: Field.EncodedFromFields + readonly children: JSX.Element + }> + + readonly submit: Atom.AtomResultFn + readonly reset: Atom.Writable + readonly revertToLastSubmit: Atom.Writable + readonly setValues: Atom.Writable> + readonly getFieldAtoms: (field: FormBuilder.FieldRef) => FormAtoms.PublicFieldAtoms + + readonly mount: Atom.Atom + readonly KeepAlive: Component +} & FieldComponents + +type FieldComponents,> = { + readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef ? Component> + : TFields[K] extends Field.ArrayFieldDef + ? ArrayFieldComponent> + : never +} + +type ExtractArrayItemExtraProps = StructFieldsFromSchema extends + Schema.Struct.Fields ? { + readonly [K in keyof StructFieldsFromSchema]: CM extends { readonly [P in K]: infer C } ? ExtractExtraProps + : never + } + : CM extends Component> ? P + : never + +type ArrayFieldComponent = + & Component<{ + readonly children: (ops: ArrayFieldOperations>) => JSX.Element + }> + & { + readonly Item: Component<{ + readonly index: number + readonly children: JSX.Element | ((props: { readonly remove: () => void }) => JSX.Element) + }> + } + & (StructFieldsFromSchema extends Schema.Struct.Fields ? { + readonly [K in keyof StructFieldsFromSchema]: Component< + ExtraPropsMap extends { readonly [P in K]: infer EP } ? (EP extends Record ? EP : Record) : Record + > + } + : unknown) + +interface ArrayItemContextValue { + readonly index: Accessor + readonly parentPath: Accessor +} + +const ArrayItemContext = createContext(null) + +const makeFieldComponent = >( + fieldKey: string, + fieldDef: Field.FieldDef, + getOrCreateFieldAtoms: (fieldPath: string, schema: Schema.Schema.Any) => FormAtoms.FieldAtoms, + Component: Component, P>>, + onBlurSubmitAtom: Atom.Writable +): Component

=> { + const InnerFieldComponent: Component<{ atoms: FormAtoms.FieldAtoms; props: P }> = (props) => { + const [value, setValue] = useAtom(props.atoms.valueAtom) + const [isTouched, setTouched] = useAtom(props.atoms.touchedAtom) + const displayError = useAtomValue(props.atoms.displayErrorAtom) + const isDirty = useAtomValue(props.atoms.isDirtyAtom) + const validation = useAtomValue(props.atoms.validationAtom) + const setOnBlurSubmit = useAtomSet(onBlurSubmitAtom) + + useAtomMount(props.atoms.triggerValidationAtom) + + const onChange = (newValue: Schema.Schema.Encoded) => setValue(newValue) + + const onBlur = () => { + setTouched(true) + setOnBlurSubmit() + } + + const fieldState = { + get value() { return value() }, + onChange, + onBlur, + get error() { return displayError() }, + get isTouched() { return isTouched() }, + get isValidating() { return (validation() as any).waiting }, + get isDirty() { return isDirty() } + } + + return + } + + const FieldComponent: Component

= (extraProps) => { + const arrayCtx = useContext(ArrayItemContext) + const fieldPath = createMemo(() => arrayCtx ? `${arrayCtx.parentPath()}.${fieldKey}` : fieldKey) + + const fieldAtoms = createMemo(() => getOrCreateFieldAtoms(fieldPath(), fieldDef.schema)) + + return ( + + {(atoms) => } + + ) + } + return FieldComponent +} + +const makeArrayFieldComponent = ( + fieldKey: string, + def: Field.ArrayFieldDef, + stateAtom: Atom.Writable>, Option.Option>>, + getOrCreateFieldAtoms: (fieldPath: string, schema: Schema.Schema.Any) => FormAtoms.FieldAtoms, + operations: FormAtoms.FormOperations, + componentMap: ArrayItemComponentMap, + onBlurSubmitAtom: Atom.Writable +): ArrayFieldComponent => { + const ArrayWrapper: Component<{ + readonly children: (ops: ArrayFieldOperations>) => JSX.Element + }> = (props) => { + const arrayCtx = useContext(ArrayItemContext) + const [formStateOption, setFormState] = useAtom(stateAtom) + + const fieldPath = createMemo(() => arrayCtx ? `${arrayCtx.parentPath()}.${fieldKey}` : fieldKey) + + const items = createMemo(() => { + const state = formStateOption() + if (Option.isNone(state)) return [] + return (getNestedValue(state.value.values, fieldPath()) ?? []) as ReadonlyArray> + }) + + const append = (value?: Schema.Schema.Encoded) => { + setFormState((prev) => { + if (Option.isNone(prev)) return prev + return Option.some(operations.appendArrayItem(prev.value, fieldPath(), def.itemSchema, value)) + }) + } + + const remove = (index: number) => { + setFormState((prev) => { + if (Option.isNone(prev)) return prev + return Option.some(operations.removeArrayItem(prev.value, fieldPath(), index)) + }) + } + + const swap = (indexA: number, indexB: number) => { + setFormState((prev) => { + if (Option.isNone(prev)) return prev + return Option.some(operations.swapArrayItems(prev.value, fieldPath(), indexA, indexB)) + }) + } + + const move = (from: number, to: number) => { + setFormState((prev) => { + if (Option.isNone(prev)) return prev + return Option.some(operations.moveArrayItem(prev.value, fieldPath(), from, to)) + }) + } + + return <>{props.children({ items, append, remove, swap, move })} + } + + const ItemWrapper: Component<{ + readonly index: number + readonly children: JSX.Element | ((props: { readonly remove: () => void }) => JSX.Element) + }> = (props) => { + const arrayCtx = useContext(ArrayItemContext) + const setFormState = useAtomSet(stateAtom) + + const parentPath = createMemo(() => arrayCtx ? `${arrayCtx.parentPath()}.${fieldKey}` : fieldKey) + const itemPath = createMemo(() => `${parentPath()}[${props.index}]`) + + const remove = () => { + setFormState((prev) => { + if (Option.isNone(prev)) return prev + return Option.some(operations.removeArrayItem(prev.value, parentPath(), props.index)) + }) + } + + const contextValue: ArrayItemContextValue = { + index: () => props.index, + parentPath: itemPath + } + + return ( + + {typeof props.children === "function" ? props.children({ remove }) : props.children} + + ) + } + + const itemFieldComponents: Record = {} + + const subFieldDefs = Field.extractStructFieldDefs(def.itemSchema) + if (subFieldDefs) { + for (const subDef of subFieldDefs) { + const itemComponent = (componentMap as Record>>)[subDef.key] + itemFieldComponents[subDef.key] = makeFieldComponent( + subDef.key, + subDef, + getOrCreateFieldAtoms, + itemComponent, + onBlurSubmitAtom + ) + } + } + + const properties: Record = { + Item: ItemWrapper, + ...itemFieldComponents + } + + return new Proxy(ArrayWrapper, { + get(target, prop) { + if (prop in properties) { + return properties[prop as string] + } + return Reflect.get(target, prop) + } + }) as ArrayFieldComponent +} + +const makeFieldComponents = ,>( + fields: TFields, + stateAtom: Atom.Writable< + Option.Option>, + Option.Option> + >, + getOrCreateFieldAtoms: (fieldPath: string, schema: Schema.Schema.Any) => FormAtoms.FieldAtoms, + operations: FormAtoms.FormOperations, + componentMap: CM, + onBlurSubmitAtom: Atom.Writable +): FieldComponents => { + const components: Record = {} + + for (const [key, def] of Object.entries(fields)) { + if (Field.isArrayFieldDef(def)) { + const arrayComponentMap = (componentMap as Record)[key] + components[key] = makeArrayFieldComponent( + key, + def as Field.ArrayFieldDef, + stateAtom, + getOrCreateFieldAtoms, + operations, + arrayComponentMap, + onBlurSubmitAtom + ) + } else if (Field.isFieldDef(def)) { + const fieldComponent = (componentMap as Record>>)[key] + components[key] = makeFieldComponent( + key, + def, + getOrCreateFieldAtoms, + fieldComponent, + onBlurSubmitAtom + ) + } + } + + return components as FieldComponents +} + +export const make: { + < + TFields extends Field.FieldsRecord, + R extends Registry.AtomRegistry, + A, + E, + SubmitArgs = void, + CM extends FieldComponentMap = FieldComponentMap, + >( + self: FormBuilder.FormBuilder, + options: { + readonly runtime?: Atom.AtomRuntime + readonly fields: CM + readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit + readonly reactivityKeys?: ReadonlyArray | Readonly>> | undefined + readonly onSubmit: ( + args: SubmitArgs, + ctx: { + readonly decoded: Field.DecodedFromFields + readonly encoded: Field.EncodedFromFields + readonly get: Atom.FnContext + } + ) => A | Effect.Effect + } + ): BuiltForm + + < + TFields extends Field.FieldsRecord, + R, + A, + E, + SubmitArgs = void, + ER = never, + CM extends FieldComponentMap = FieldComponentMap, + >( + self: FormBuilder.FormBuilder, + options: { + readonly runtime: Atom.AtomRuntime + readonly fields: CM + readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit + readonly reactivityKeys?: ReadonlyArray | Readonly>> | undefined + readonly onSubmit: ( + args: SubmitArgs, + ctx: { + readonly decoded: Field.DecodedFromFields + readonly encoded: Field.EncodedFromFields + readonly get: Atom.FnContext + } + ) => A | Effect.Effect + } + ): BuiltForm +} = (self: any, options: any): any => { + const { fields: components, mode, onSubmit, runtime: providedRuntime, reactivityKeys } = options + const runtime = providedRuntime ?? Atom.runtime(Layer.empty) + + const formAtoms = FormAtoms.make({ + formBuilder: self, + runtime, + onSubmit, + reactivityKeys, + mode + }) + + const { + autoSubmitAtom, + combinedSchema, + fieldRefs, + getFieldAtoms, + getOrCreateFieldAtoms, + hasChangedSinceSubmitAtom, + isDirtyAtom, + keepAliveActiveAtom, + lastSubmittedValuesAtom, + mountAtom, + onBlurSubmitAtom, + operations, + resetAtom, + revertToLastSubmitAtom, + rootErrorAtom, + setValuesAtom, + stateAtom, + submitAtom, + submitCountAtom, + valuesAtom + } = formAtoms + + const InitializeComponent: Component<{ + readonly defaultValues: any + readonly children: JSX.Element + }> = (props) => { + const registry = useContext(RegistryContext) + const state = useAtomValue(stateAtom) + const setFormState = useAtomSet(stateAtom) + const [isInitialized, setIsInitialized] = createSignal(false) + + createEffect(() => { + const isKeptAlive = registry.get(keepAliveActiveAtom) + if (!isKeptAlive || Option.isNone(registry.get(stateAtom))) { + setFormState(Option.some(operations.createInitialState(props.defaultValues))) + } + setIsInitialized(true) + }) + + const shouldRender = createMemo(() => isInitialized() && Option.isSome(state())) + + return ( + <> + + + {props.children} + + + ) + } + + const AutoSubmitHandler: Component<{ atom: Atom.Atom }> = (props) => { + useAtomMount(props.atom) + return null + } + + const fieldComponents = makeFieldComponents( + self.fields, + stateAtom, + getOrCreateFieldAtoms, + operations, + components, + onBlurSubmitAtom + ) + + const KeepAlive: Component = () => { + const setKeepAliveActive = useAtomSet(keepAliveActiveAtom) + + onMount(() => { + setKeepAliveActive(true) + }) + + onCleanup(() => { + setKeepAliveActive(false) + }) + + useAtomMount(mountAtom) + return null + } + + return { + values: valuesAtom, + isDirty: isDirtyAtom, + hasChangedSinceSubmit: hasChangedSinceSubmitAtom, + lastSubmittedValues: lastSubmittedValuesAtom, + submitCount: submitCountAtom, + rootError: rootErrorAtom, + schema: combinedSchema, + fields: fieldRefs, + Initialize: InitializeComponent, + submit: submitAtom, + reset: resetAtom, + revertToLastSubmit: revertToLastSubmitAtom, + setValues: setValuesAtom, + getFieldAtoms, + mount: mountAtom, + KeepAlive, + ...fieldComponents + } +} diff --git a/packages/form-solid/src/index.ts b/packages/form-solid/src/index.ts new file mode 100644 index 0000000..10c905a --- /dev/null +++ b/packages/form-solid/src/index.ts @@ -0,0 +1,3 @@ +export { Field, FieldState, FormBuilder } from "@lucas-barake/effect-form" + +export * as FormSolid from "./FormSolid.tsx" diff --git a/packages/form-solid/test/FormSolid.test.tsx b/packages/form-solid/test/FormSolid.test.tsx new file mode 100644 index 0000000..eeff920 --- /dev/null +++ b/packages/form-solid/test/FormSolid.test.tsx @@ -0,0 +1,2132 @@ +import { useAtomSet, useAtomSubscribe, useAtomValue } from "@effectify/solid-effect-atom" +import * as Atom from "@effect-atom/atom/Atom" +import * as Registry from "@effect-atom/atom/Registry" +import * as Result from "@effect-atom/atom/Result" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import { render, screen, waitFor, cleanup } from "@solidjs/testing-library" +import { userEvent } from "@testing-library/user-event" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { createSignal, Show, For, createEffect } from "solid-js" +import { describe, expect, expectTypeOf, it, vi, afterEach } from "vitest" + +afterEach(() => { + cleanup() +}) + +const TextInput: FormSolid.FieldComponent = (props) => ( +

+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="text-input" + /> + + {Option.getOrNull(props.field.error)} + +
+) + +const makeSubmitButton = (submitAtom: Atom.AtomResultFn, args: A) => { + const SubmitButton = () => { + const submit = useAtomSet(submitAtom) + return + } + return SubmitButton +} + +describe("FormSolid.make", () => { + describe("Initialize Component", () => { + it("initializes with default values", () => { + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => {} + }) + + render(() => ( + + + + )) + + expect(screen.getByTestId("text-input")).toHaveValue("John") + }) + }) + + describe("Field Component", () => { + it("updates value on change", async () => { + const user = userEvent.setup() + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => {} + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + await user.type(input, "Jane") + expect(input).toHaveValue("Jane") + }) + + it("shows validation error after touch (onBlur mode)", async () => { + const user = userEvent.setup() + const NonEmpty = Schema.String.pipe(Schema.minLength(1, { message: () => "Required" })) + const NameField = Field.makeField("name", NonEmpty) + const formBuilder = FormBuilder.empty.addField(NameField) + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + mode: { validation: "onBlur" }, + onSubmit: () => {} + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + await user.click(input) + await user.tab() + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Required") + }) + }) + }) + + describe("isDirty atom", () => { + it("returns isDirty = false when values match initial", () => { + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => {} + }) + + let isDirty: boolean | undefined + + const TestComponent = () => { + useAtomSubscribe(form.isDirty, (dirty) => { + isDirty = dirty + }, { immediate: true }) + return null + } + + render(() => ( + + + + + )) + + expect(isDirty).toBe(false) + }) + + it("returns isDirty = true when values differ from initial", async () => { + const user = userEvent.setup() + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => {} + }) + + let isDirty: boolean | undefined + + const TestComponent = () => { + useAtomSubscribe(form.isDirty, (dirty) => { + isDirty = dirty + }, { immediate: true }) + return null + } + + render(() => ( + + + + + )) + + const input = screen.getByTestId("text-input") + await user.type(input, "changed") + expect(isDirty).toBe(true) + }) + + it("submit calls onSubmit with decoded values", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const NameField = Field.makeField("name", Schema.String) + const AgeField = Field.makeField("age", Schema.NumberFromString) + const formBuilder = FormBuilder.empty.addField(NameField).addField(AgeField) + + const NumberFromStringInput: FormSolid.FieldComponent = (props) => ( + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="number-input" + /> + ) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput, age: NumberFromStringInput }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(submitHandler).toHaveBeenCalledWith({ name: "John", age: 42 }) + }) + }) + }) + + describe("multiple fields", () => { + it("renders multiple fields correctly", async () => { + const user = userEvent.setup() + + const FirstNameField = Field.makeField("firstName", Schema.String) + const LastNameField = Field.makeField("lastName", Schema.String) + const formBuilder = FormBuilder.empty.addField(FirstNameField).addField(LastNameField) + + const NamedInput: FormSolid.FieldComponent = (props) => ( + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid={props.props.name} + /> + ) + + const FirstNameInput: FormSolid.FieldComponent = (props) => ( + + ) + + const LastNameInput: FormSolid.FieldComponent = (props) => ( + + ) + + const form = FormSolid.make(formBuilder, { + fields: { + firstName: FirstNameInput, + lastName: LastNameInput + }, + onSubmit: () => {} + }) + + render(() => ( + + + + + )) + + await user.type(screen.getByTestId("firstName"), "John") + await user.type(screen.getByTestId("lastName"), "Doe") + + expect(screen.getByTestId("firstName")).toHaveValue("John") + expect(screen.getByTestId("lastName")).toHaveValue("Doe") + }) + }) + + describe("array fields", () => { + const ItemNameInput: FormSolid.FieldComponent = (props) => ( + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="item-name" + /> + ) + + it("renders array field with items", async () => { + const user = userEvent.setup() + const TitleField = Field.makeField("title", Schema.String) + const ItemsArrayField = Field.makeArrayField("items", Schema.Struct({ name: Schema.String })) + const formBuilder = FormBuilder.empty.addField(TitleField).addField(ItemsArrayField) + + const form = FormSolid.make(formBuilder, { + fields: { + title: TextInput, + items: { name: ItemNameInput } + }, + onSubmit: () => {} + }) + + render(() => ( + + + + {({ append, items }) => ( + <> + + {(item, i) => ( + + + + )} + + + + )} + + + )) + + expect(screen.getByTestId("text-input")).toHaveValue("My List") + expect(screen.getByTestId("item-name")).toHaveValue("Item 1") + + await user.click(screen.getByTestId("add")) + + await waitFor(() => { + expect(screen.getAllByTestId("item-name")).toHaveLength(2) + }) + }) + + it("renders array item subfields when item schema uses filterEffect", async () => { + const user = userEvent.setup() + + const ItemSchema = Schema.Struct({ name: Schema.String }).pipe( + Schema.filterEffect(() => Effect.succeed(true)) + ) + const ItemsArrayField = Field.makeArrayField("items", ItemSchema) + const formBuilder = FormBuilder.empty.addField(ItemsArrayField) + + const form = FormSolid.make(formBuilder, { + fields: { items: { name: ItemNameInput } }, + onSubmit: () => {} + }) + + render(() => ( + + + {({ items }) => ( + + {(item, i) => ( + + + + )} + + )} + + + )) + + expect((screen.getByTestId("item-name") as HTMLInputElement).value).toBe("First") + + await user.type(screen.getByTestId("item-name"), "A") + + expect((screen.getByTestId("item-name") as HTMLInputElement).value).toBe("FirstA") + }) + + it("remove() removes item at specified index", async () => { + const user = userEvent.setup() + const ItemsArrayField = Field.makeArrayField("items", Schema.Struct({ name: Schema.String })) + const formBuilder = FormBuilder.empty.addField(ItemsArrayField) + + const form = FormSolid.make(formBuilder, { + fields: { items: { name: ItemNameInput } }, + onSubmit: () => {} + }) + + render(() => ( + + + {({ items, remove }) => ( + + {(item, i) => ( +
+ + + + +
+ )} +
+ )} +
+
+ )) + + expect(screen.getAllByTestId("item-name")).toHaveLength(3) + const inputs = screen.getAllByTestId("item-name") as Array + expect(inputs[0].value).toBe("A") + expect(inputs[1].value).toBe("B") + expect(inputs[2].value).toBe("C") + + await user.click(screen.getByTestId("remove-1")) + + await waitFor(() => { + expect(screen.getAllByTestId("item-name")).toHaveLength(2) + const updatedInputs = screen.getAllByTestId("item-name") as Array + expect(updatedInputs[0].value).toBe("A") + expect(updatedInputs[1].value).toBe("C") + }) + }) + + it("swap() exchanges items at two indices", async () => { + const user = userEvent.setup() + + const ItemsArrayField = Field.makeArrayField("items", Schema.Struct({ name: Schema.String })) + const formBuilder = FormBuilder.empty.addField(ItemsArrayField) + + const form = FormSolid.make(formBuilder, { + fields: { items: { name: ItemNameInput } }, + onSubmit: () => {} + }) + + render(() => ( + + + {({ items, swap }) => ( + <> + + {(item, i) => ( + + + + )} + + + + )} + + + )) + + const initialInputs = screen.getAllByTestId("item-name") as Array + expect(initialInputs[0].value).toBe("First") + expect(initialInputs[1].value).toBe("Second") + expect(initialInputs[2].value).toBe("Third") + + await user.click(screen.getByTestId("swap")) + + await waitFor(() => { + const swappedInputs = screen.getAllByTestId("item-name") as Array + expect(swappedInputs[0].value).toBe("Third") + expect(swappedInputs[1].value).toBe("Second") + expect(swappedInputs[2].value).toBe("First") + }) + }) + + it("move() relocates item from one index to another", async () => { + const user = userEvent.setup() + + const ItemsArrayField = Field.makeArrayField("items", Schema.Struct({ name: Schema.String })) + const formBuilder = FormBuilder.empty.addField(ItemsArrayField) + + const form = FormSolid.make(formBuilder, { + fields: { items: { name: ItemNameInput } }, + onSubmit: () => {} + }) + + render(() => ( + + + {({ items, move }) => ( + <> + + {(item, i) => ( + + + + )} + + + + )} + + + )) + + const initialInputs = screen.getAllByTestId("item-name") as Array + expect(initialInputs.map((i) => i.value)).toEqual(["A", "B", "C", "D"]) + + await user.click(screen.getByTestId("move")) + + await waitFor(() => { + const movedInputs = screen.getAllByTestId("item-name") as Array + expect(movedInputs.map((i) => i.value)).toEqual(["B", "C", "A", "D"]) + }) + }) + + it("Item render prop provides remove function", async () => { + const user = userEvent.setup() + + const ItemsArrayField = Field.makeArrayField("items", Schema.Struct({ name: Schema.String })) + const formBuilder = FormBuilder.empty.addField(ItemsArrayField) + + const form = FormSolid.make(formBuilder, { + fields: { items: { name: ItemNameInput } }, + onSubmit: () => {} + }) + + render(() => ( + + + {({ items }) => ( + + {(item, i) => ( + + {({ remove }) => ( + <> + + + + )} + + )} + + )} + + + )) + + expect(screen.getAllByTestId("item-name")).toHaveLength(2) + + await user.click(screen.getByTestId("item-remove-0")) + + await waitFor(() => { + expect(screen.getAllByTestId("item-name")).toHaveLength(1) + expect((screen.getByTestId("item-name") as HTMLInputElement).value).toBe("Item 2") + }) + }) + }) + + describe("async validation", () => { + it("submit works with async schema validation (filterEffect)", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const AsyncEmail = Schema.String.pipe( + Schema.filterEffect(() => Effect.succeed(true).pipe(Effect.delay("10 millis"))) + ) + + const EmailField = Field.makeField("email", AsyncEmail) + const formBuilder = FormBuilder.empty.addField(EmailField) + + const form = FormSolid.make(formBuilder, { + fields: { email: TextInput }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(submitHandler).toHaveBeenCalledWith({ email: "test@example.com" }) + }, { timeout: 1000 }) + }) + + it("exposes isValidating state during async validation", async () => { + const user = userEvent.setup() + + const AsyncField = Schema.String.pipe( + Schema.filterEffect(() => Effect.succeed(true).pipe(Effect.delay("100 millis"))) + ) + + const ValidatingInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="async-input" + /> + {String(props.field.isValidating)} +
+ ) + + const AsyncFieldDef = Field.makeField("asyncField", AsyncField) + const formBuilder = FormBuilder.empty.addField(AsyncFieldDef) + + const form = FormSolid.make(formBuilder, { + fields: { asyncField: ValidatingInput }, + mode: { validation: "onBlur" }, + onSubmit: () => {} + }) + + render(() => ( + + + + )) + + expect(screen.getByTestId("is-validating")).toHaveTextContent("false") + + const input = screen.getByTestId("async-input") + await user.type(input, "test") + await user.tab() + + await waitFor(() => { + expect(screen.getByTestId("is-validating")).toHaveTextContent("true") + }) + + await waitFor(() => { + expect(screen.getByTestId("is-validating")).toHaveTextContent("false") + }, { timeout: 500 }) + }) + }) + + describe("cross-field validation", () => { + it("FormBuilder.refine validates across fields and routes error to specific field", async () => { + const user = userEvent.setup() + + const PasswordInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="password" + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const ConfirmPasswordInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="confirm-password" + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const PasswordField = Field.makeField("password", Schema.String) + const ConfirmPasswordField = Field.makeField("confirmPassword", Schema.String) + const formBuilder = FormBuilder.empty.addField(PasswordField).addField(ConfirmPasswordField) + .refine((values) => { + if (values.password !== values.confirmPassword) { + return { path: ["confirmPassword"], message: "Passwords must match" } + } + }) + + const form = FormSolid.make(formBuilder, { + fields: { + password: PasswordInput, + confirmPassword: ConfirmPasswordInput + }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("confirm-password-error")).toHaveTextContent("Passwords must match") + }) + + expect(screen.queryByTestId("password-error")).not.toBeInTheDocument() + }) + + it("refineEffect performs async cross-field validation", async () => { + const user = userEvent.setup() + + const UsernameInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="username" + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const UsernameField = Field.makeField("username", Schema.String) + const formBuilder = FormBuilder.empty + .addField(UsernameField) + .refineEffect((values) => + Effect.gen(function*() { + yield* Effect.sleep("20 millis") + if (values.username === "taken") { + return { path: ["username"], message: "Username is already taken" } + } + }) + ) + + const form = FormSolid.make(formBuilder, { + fields: { username: UsernameInput }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("username-error")).toHaveTextContent("Username is already taken") + }, { timeout: 200 }) + }) + + it("refineEffect works with Effect services from runtime", async () => { + const user = userEvent.setup() + + class UsernameValidator extends Context.Tag("UsernameValidator")< + UsernameValidator, + { readonly isTaken: (username: string) => Effect.Effect } + >() {} + + const UsernameValidatorLive = Layer.succeed(UsernameValidator, { + isTaken: (username) => Effect.succeed(username === "taken") + }) + + const UsernameInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="username" + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const UsernameField = Field.makeField("username", Schema.String) + const formBuilder = FormBuilder.empty + .addField(UsernameField) + .refineEffect((values) => + Effect.gen(function*() { + const registry = yield* Registry.AtomRegistry + expect(typeof registry.get).toBe("function") + + const validator = yield* UsernameValidator + const isTaken = yield* validator.isTaken(values.username) + if (isTaken) { + return { path: ["username"], message: "Username is already taken" } + } + }) + ) + + const runtime = Atom.runtime(UsernameValidatorLive) + const form = FormSolid.make(formBuilder, { + runtime, + fields: { username: UsernameInput }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("username-error")).toHaveTextContent("Username is already taken") + }) + }) + + it("multiple chained refine() calls are all executed", async () => { + const user = userEvent.setup() + + const FieldInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid={props.props.testId} + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const FieldAInput: FormSolid.FieldComponent = (props) => ( + + ) + const FieldBInput: FormSolid.FieldComponent = (props) => ( + + ) + + const FieldAField = Field.makeField("fieldA", Schema.String) + const FieldBField = Field.makeField("fieldB", Schema.String) + const formBuilder = FormBuilder.empty + .addField(FieldAField) + .addField(FieldBField) + .refine((values) => { + if (values.fieldA === "error1") { + return { path: ["fieldA"], message: "First validation failed" } + } + }) + .refine((values) => { + if (values.fieldB === "error2") { + return { path: ["fieldB"], message: "Second validation failed" } + } + }) + + const form = FormSolid.make(formBuilder, { + fields: { fieldA: FieldAInput, fieldB: FieldBInput }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + const FormWrapper = (props: { key: string, values: any }) => ( + + + + + + + + ) + + const [state, setState] = createSignal({ key: "1", values: { fieldA: "error1", fieldB: "valid" } }) + + render(() => ) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("fieldA-error")).toHaveTextContent("First validation failed") + }) + expect(screen.queryByTestId("fieldB-error")).not.toBeInTheDocument() + + setState({ key: "2", values: { fieldA: "valid", fieldB: "error2" } }) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("fieldB-error")).toHaveTextContent("Second validation failed") + }) + expect(screen.queryByTestId("fieldA-error")).not.toBeInTheDocument() + }) + + it("cross-field error persists when typing after failed submit (still invalid)", async () => { + const user = userEvent.setup() + + const FieldInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid={props.props.testId} + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const PasswordInput: FormSolid.FieldComponent = (props) => ( + + ) + const ConfirmInput: FormSolid.FieldComponent = (props) => ( + + ) + + const PasswordField = Field.makeField("password", Schema.String) + const ConfirmField = Field.makeField("confirm", Schema.String) + const formBuilder = FormBuilder.empty + .addField(PasswordField) + .addField(ConfirmField) + .refine((values) => { + if (values.password !== values.confirm) { + return { path: ["confirm"], message: "Passwords must match" } + } + }) + + const form = FormSolid.make(formBuilder, { + fields: { password: PasswordInput, confirm: ConfirmInput }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + }) + + const confirmInput = screen.getByTestId("confirm") + await user.type(confirmInput, "x") + + await new Promise((r) => setTimeout(r, 50)) + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + }) + + it("cross-field error clears when fixed and resubmitted", async () => { + const user = userEvent.setup() + + const FieldInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid={props.props.testId} + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const PasswordInput: FormSolid.FieldComponent = (props) => ( + + ) + const ConfirmInput: FormSolid.FieldComponent = (props) => ( + + ) + + const PasswordField = Field.makeField("password", Schema.String) + const ConfirmField = Field.makeField("confirm", Schema.String) + const formBuilder = FormBuilder.empty + .addField(PasswordField) + .addField(ConfirmField) + .refine((values) => { + if (values.password !== values.confirm) { + return { path: ["confirm"], message: "Passwords must match" } + } + }) + + const form = FormSolid.make(formBuilder, { + fields: { password: PasswordInput, confirm: ConfirmInput }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + }) + + const confirmInput = screen.getByTestId("confirm") + await user.clear(confirmInput) + await user.type(confirmInput, "secret") + + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.queryByTestId("confirm-error")).not.toBeInTheDocument() + }) + }) + + it("routes cross-field errors to nested array item fields", async () => { + const user = userEvent.setup() + + const ItemNameInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid="item-name" + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + + const ItemSchema = Schema.Struct({ + name: Schema.String.pipe(Schema.minLength(3, { message: () => "Name must be at least 3 characters" })) + }) + + const ItemsArrayField = Field.makeArrayField("items", ItemSchema) + const formBuilder = FormBuilder.empty.addField(ItemsArrayField) + + const form = FormSolid.make(formBuilder, { + fields: { items: { name: ItemNameInput } }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + {({ items }) => ( + <> + + {(item, i) => ( + + + + )} + + + )} + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("item-name-error")).toHaveTextContent( + "Name must be at least 3 characters" + ) + }) + }) + }) + + describe("validation modes", () => { + it("onSubmit mode shows errors after submit attempt", async () => { + const user = userEvent.setup() + + const NonEmpty = Schema.String.pipe(Schema.minLength(1, { message: () => "Required" })) + const NameField = Field.makeField("name", NonEmpty) + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + mode: { validation: "onSubmit" }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + )) + + expect(screen.queryByTestId("error")).not.toBeInTheDocument() + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Required") + }) + }) + + it("onChange mode shows errors immediately without needing blur", async () => { + const user = userEvent.setup() + + const MinLength = Schema.String.pipe(Schema.minLength(3, { message: () => "Min 3 chars" })) + const NameField = Field.makeField("name", MinLength) + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + mode: { validation: "onChange" }, + onSubmit: () => {} + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "ab") + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Min 3 chars") + }) + + await user.type(input, "c") + + await waitFor(() => { + expect(screen.queryByTestId("error")).not.toBeInTheDocument() + }) + }) + + it("onSubmit mode keeps errors when typing still-invalid values after failed submit", async () => { + const user = userEvent.setup() + + const MinLength = Schema.String.pipe(Schema.minLength(8, { message: () => "Min 8 chars" })) + const PasswordField = Field.makeField("password", MinLength) + const formBuilder = FormBuilder.empty.addField(PasswordField) + + const form = FormSolid.make(formBuilder, { + fields: { password: TextInput }, + mode: { validation: "onSubmit" }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "short") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Min 8 chars") + }) + + await user.type(input, "x") + + await new Promise((r) => setTimeout(r, 50)) + expect(screen.getByTestId("error")).toHaveTextContent("Min 8 chars") + }) + + it("onSubmit mode clears errors when typing valid values after failed submit", async () => { + const user = userEvent.setup() + + const MinLength = Schema.String.pipe(Schema.minLength(8, { message: () => "Min 8 chars" })) + const PasswordField = Field.makeField("password", MinLength) + const formBuilder = FormBuilder.empty.addField(PasswordField) + + const form = FormSolid.make(formBuilder, { + fields: { password: TextInput }, + mode: { validation: "onSubmit" }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "short") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Min 8 chars") + }) + + await user.type(input, "123") + + await waitFor(() => { + expect(screen.queryByTestId("error")).not.toBeInTheDocument() + }) + }) + + it("cross-field refinement errors persist until re-submit", async () => { + const user = userEvent.setup() + + const PasswordField = Field.makeField("password", Schema.String.pipe(Schema.minLength(4))) + const ConfirmField = Field.makeField("confirm", Schema.String) + + const formBuilder = FormBuilder.empty + .addField(PasswordField) + .addField(ConfirmField) + .refine((values) => { + if (values.password !== values.confirm) { + return { path: ["confirm"], message: "Passwords must match" } + } + }) + + const form = FormSolid.make(formBuilder, { + fields: { + password: TextInput, + confirm: (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + }, + mode: { validation: "onSubmit" }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + }) + + await user.clear(screen.getByTestId("confirm-input")) + await user.type(screen.getByTestId("confirm-input"), "test") + + await new Promise((r) => setTimeout(r, 100)) + + // Refinement errors persist until re-submit + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.queryByTestId("confirm-error")).not.toBeInTheDocument() + }) + }) + + it("per-field errors clear on valid input while refinement errors persist", async () => { + const user = userEvent.setup() + + const PasswordField = Field.makeField( + "password", + Schema.String.pipe(Schema.minLength(8, { message: () => "Min 8 chars" })) + ) + const ConfirmField = Field.makeField("confirm", Schema.String) + + const formBuilder = FormBuilder.empty + .addField(PasswordField) + .addField(ConfirmField) + .refine((values) => { + if (values.password !== values.confirm) { + return { path: ["confirm"], message: "Must match password" } + } + }) + + const form = FormSolid.make(formBuilder, { + fields: { + password: TextInput, + confirm: (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + }, + mode: { validation: "onSubmit" }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Min 8 chars") + }) + + await user.type(screen.getByTestId("text-input"), "1234") + + await waitFor(() => { + expect(screen.queryByTestId("error")).not.toBeInTheDocument() + }) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Must match password") + }) + + await user.clear(screen.getByTestId("confirm-input")) + await user.type(screen.getByTestId("confirm-input"), "short1234") + + await new Promise((r) => setTimeout(r, 100)) + + // Refinement errors persist until re-submit + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Must match password") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.queryByTestId("confirm-error")).not.toBeInTheDocument() + }) + }) + + it("hides stored field error while async validation is pending (async gap)", async () => { + const user = userEvent.setup() + + const AsyncMinLength = Schema.String.pipe( + Schema.minLength(8, { message: () => "Too short" }), + Schema.filterEffect((_value) => + Effect.gen(function*() { + yield* Effect.sleep("200 millis") + return undefined + }) + ) + ) + const PasswordField = Field.makeField("password", AsyncMinLength) + const formBuilder = FormBuilder.empty.addField(PasswordField) + + const form = FormSolid.make(formBuilder, { + fields: { password: TextInput }, + mode: { validation: "onSubmit" }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Too short") + }) + + await user.type(screen.getByTestId("text-input"), "1234567") + + // Stored field error hidden while async validation pending (isValidating = true) + await waitFor(() => { + expect(screen.queryByTestId("error")).not.toBeInTheDocument() + }) + }) + + it("persists refinement errors across field unmount/remount", async () => { + const user = userEvent.setup() + + const PasswordField = Field.makeField("password", Schema.String) + const ConfirmField = Field.makeField("confirm", Schema.String) + + const formBuilder = FormBuilder.empty + .addField(PasswordField) + .addField(ConfirmField) + .refine((values) => { + if (values.password !== values.confirm) { + return { path: ["confirm"], message: "Passwords must match" } + } + }) + + const form = FormSolid.make(formBuilder, { + fields: { + password: TextInput, + confirm: (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + /> + + {Option.getOrNull(props.field.error)} + +
+ ) + }, + mode: { validation: "onSubmit" }, + onSubmit: () => {} + }) + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + const ToggleableForm = () => { + const [showConfirm, setShowConfirm] = createSignal(true) + return ( + + + + + + + + + ) + } + + render(() => ) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + }) + + await user.click(screen.getByTestId("toggle")) + expect(screen.queryByTestId("confirm-input")).not.toBeInTheDocument() + + await user.click(screen.getByTestId("toggle")) + expect(screen.getByTestId("confirm-input")).toBeInTheDocument() + + // Error persists across unmount/remount (stored in atoms, not component state) + expect(screen.getByTestId("confirm-error")).toHaveTextContent("Passwords must match") + }) + }) + + describe("error handling", () => { + it("captures error when onSubmit Effect fails", async () => { + const user = userEvent.setup() + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => Effect.fail(new Error("Submission failed")) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + const SubmitResultDisplay = () => { + const submitResult = useAtomValue(form.submit) + return ( + <> + {submitResult()._tag} + {String(submitResult().waiting)} + + ) + } + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + expect(screen.getByTestId("result-tag")).toHaveTextContent("Initial") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("result-tag")).toHaveTextContent("Failure") + expect(screen.getByTestId("result-waiting")).toHaveTextContent("false") + }) + }) + }) + + describe("form atoms", () => { + it("exposes submitResult with initial state", () => { + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + let capturedIsDirty: boolean | undefined + let capturedSubmitResult: Result.Result | undefined + + const Consumer = () => { + useAtomSubscribe(form.isDirty, (v) => { + capturedIsDirty = v + }, { immediate: true }) + useAtomSubscribe(form.submit, (v) => { + capturedSubmitResult = v + }, { immediate: true }) + return null + } + + render(() => ( + + + + + )) + + expect(capturedIsDirty).toBe(false) + expect(Result.isInitial(capturedSubmitResult!)).toBe(true) + }) + + it("exposes submitResult.waiting during submission", async () => { + const user = userEvent.setup() + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => Effect.void.pipe(Effect.delay("50 millis")) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + const states: Array<{ waiting: boolean; tag: string }> = [] + + const Consumer = () => { + const submitResult = useAtomValue(form.submit) + createEffect(() => { + states.push({ waiting: submitResult().waiting, tag: submitResult()._tag }) + }) + return null + } + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(states.some((s) => s.waiting)).toBe(true) + }) + + await waitFor(() => { + const lastState = states[states.length - 1] + expect(lastState.tag).toBe("Success") + expect(lastState.waiting).toBe(false) + }, { timeout: 1000 }) + }) + + it("exposes submitResult with failure on validation error", async () => { + const user = userEvent.setup() + + const NonEmpty = Schema.String.pipe(Schema.minLength(1, { message: () => "Required" })) + const NameField = Field.makeField("name", NonEmpty) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + let capturedResult: Result.Result | undefined + + const Consumer = () => { + useAtomSubscribe(form.submit, (v) => { + capturedResult = v + }, { immediate: true }) + return null + } + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(capturedResult).toBeDefined() + expect(Result.isFailure(capturedResult!)).toBe(true) + }) + }) + + it("updates isDirty when values change", async () => { + const user = userEvent.setup() + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + const dirtyStates: Array = [] + + const Consumer = () => { + const isDirty = useAtomValue(form.isDirty) + createEffect(() => { + dirtyStates.push(isDirty()) + }) + return null + } + + render(() => ( + + + + + )) + + expect(dirtyStates[dirtyStates.length - 1]).toBe(false) + + const input = screen.getByTestId("text-input") + await user.clear(input) + await user.type(input, "changed") + + await waitFor(() => { + expect(dirtyStates[dirtyStates.length - 1]).toBe(true) + }) + }) + }) + + describe("isDirty lifecycle", () => { + it("form reinitializes when using Show keyed to force remount", async () => { + const user = userEvent.setup() + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => {} + }) + + let isDirty: boolean | undefined + + const TestComponent = () => { + useAtomSubscribe(form.isDirty, (v) => { + isDirty = v + }, { immediate: true }) + return null + } + + const FormWrapper = () => { + const [key, setKey] = createSignal(1) + const [defaultName, setDefaultName] = createSignal("initial") + + const remount = () => { + setDefaultName("new-initial") + setKey(2) + } + + return ( + <> + + + + + + + + + ) + } + + render(() => ) + + expect(isDirty).toBe(false) + + const input = screen.getByTestId("text-input") + await user.clear(input) + await user.type(input, "modified") + + expect(isDirty).toBe(true) + + await user.click(screen.getByTestId("remount")) + + await waitFor(() => { + expect(screen.getByTestId("text-input")).toHaveValue("new-initial") + expect(isDirty).toBe(false) + }) + }) + + it("key-change remount with parent subscription does not render stale values", async () => { + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => {} + }) + + const renderedValues: Array = [] + + const ValueTracker = () => { + const values = useAtomValue(form.values) + createEffect(() => { + const val = values() + if (Option.isSome(val)) { + renderedValues.push(val.value.name as string) + } + }) + return null + } + + const Parent = (props: { variantId: string; defaultName: string }) => { + useAtomValue(form.values) + return ( + + + + + + + ) + } + + const [state, setState] = createSignal({ variantId: "1", defaultName: "alice" }) + + render(() => ) + + await waitFor(() => { + expect(screen.getByTestId("text-input")).toHaveValue("alice") + }) + + renderedValues.length = 0 + + setState({ variantId: "2", defaultName: "bob" }) + + await waitFor(() => { + expect(screen.getByTestId("text-input")).toHaveValue("bob") + }) + + expect(renderedValues.every((v) => v === "bob")).toBe(true) + }) + + it("isDirty becomes false when value returns to initial", async () => { + const user = userEvent.setup() + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + let isDirty: boolean | undefined + + const TestComponent = () => { + useAtomSubscribe(form.isDirty, (v) => { + isDirty = v + }, { immediate: true }) + return null + } + + render(() => ( + + + + + )) + + expect(isDirty).toBe(false) + + const input = screen.getByTestId("text-input") + await user.clear(input) + await user.type(input, "changed") + expect(isDirty).toBe(true) + + await user.clear(input) + await user.type(input, "initial") + expect(isDirty).toBe(false) + }) + + it("isDirty remains after successful submission", async () => { + const user = userEvent.setup() + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + const TestComponent = () => { + const isDirty = useAtomValue(form.isDirty) + const submit = useAtomSet(form.submit) + return ( + <> + {String(isDirty())} + + + ) + } + + render(() => ( + + + + + )) + + const input = screen.getByTestId("text-input") + await user.clear(input) + await user.type(input, "changed") + expect(screen.getByTestId("isDirty")).toHaveTextContent("true") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("isDirty")).toHaveTextContent("true") + }) + }) + + it("reset() restores form to initial values", async () => { + const user = userEvent.setup() + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit + }) + + const TestComponent = () => { + const isDirty = useAtomValue(form.isDirty) + const submitResult = useAtomValue(form.submit) + const submit = useAtomSet(form.submit) + const reset = useAtomSet(form.reset) + return ( + <> + {String(isDirty())} + {submitResult()._tag} + + + + ) + } + + render(() => ( + + + + + )) + + expect(screen.getByTestId("isDirty")).toHaveTextContent("false") + expect(screen.getByTestId("submitResultTag")).toHaveTextContent("Initial") + + const input = screen.getByTestId("text-input") + await user.clear(input) + await user.type(input, "modified") + expect(screen.getByTestId("isDirty")).toHaveTextContent("true") + expect(screen.getByTestId("text-input")).toHaveValue("modified") + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("submitResultTag")).toHaveTextContent("Success") + }) + + await user.click(screen.getByTestId("reset")) + + await waitFor(() => { + expect(screen.getByTestId("text-input")).toHaveValue("initial") + expect(screen.getByTestId("isDirty")).toHaveTextContent("false") + expect(screen.getByTestId("submitResultTag")).toHaveTextContent("Initial") + }) + }) + }) + + describe("reactivity", () => { + it("reactivityKeys triggers invalidation after successful submit", async () => { + const user = userEvent.setup() + + let rebuilds = 0 + const counterAtom = Atom.make(() => rebuilds++).pipe( + Atom.withReactivity(["form-submit"]), + Atom.keepAlive + ) + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + reactivityKeys: ["form-submit"], + onSubmit: () => {} + }) + + const CounterDisplay = () => { + const count = useAtomValue(counterAtom) + return {count()} + } + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await waitFor(() => { + expect(screen.getByTestId("rebuild-count")).toHaveTextContent("0") + }) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("rebuild-count")).toHaveTextContent("1") + }) + }) + + it("no invalidation when reactivityKeys is not provided", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + let rebuilds = 0 + const counterAtom = Atom.make(() => rebuilds++).pipe( + Atom.withReactivity(["no-keys-test"]), + Atom.keepAlive + ) + + const NameField = Field.makeField("name", Schema.String) + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => submitHandler() + }) + + const CounterDisplay = () => { + const count = useAtomValue(counterAtom) + return {count()} + } + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await waitFor(() => { + expect(screen.getByTestId("rebuild-count")).toHaveTextContent("0") + }) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(submitHandler).toHaveBeenCalled() + }) + + expect(screen.getByTestId("rebuild-count")).toHaveTextContent("0") + }) + + it("no invalidation on validation failure", async () => { + const user = userEvent.setup() + + let rebuilds = 0 + const counterAtom = Atom.make(() => rebuilds++).pipe( + Atom.withReactivity(["validation-fail-test"]), + Atom.keepAlive + ) + + const NonEmpty = Schema.String.pipe(Schema.minLength(1, { message: () => "Required" })) + const NameField = Field.makeField("name", NonEmpty) + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + reactivityKeys: ["validation-fail-test"], + onSubmit: () => {} + }) + + const CounterDisplay = () => { + const count = useAtomValue(counterAtom) + return {count()} + } + + const SubmitButton = makeSubmitButton(form.submit, undefined) + + render(() => ( + + + + + + )) + + await waitFor(() => { + expect(screen.getByTestId("rebuild-count")).toHaveTextContent("0") + }) + + await user.click(screen.getByTestId("submit")) + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Required") + }) + + expect(screen.getByTestId("rebuild-count")).toHaveTextContent("0") + }) + }) + + describe("runtime optionality", () => { + it("does not require runtime when R is only AtomRegistry", () => { + const NameField = Field.makeField( + "name", + Schema.String.pipe( + Schema.filterEffect(() => + Effect.gen(function*() { + yield* Registry.AtomRegistry + return true as const + }) + ) + ) + ) + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + fields: { name: TextInput }, + onSubmit: () => {} + }) + + expectTypeOf(form).not.toBeAny() + expectTypeOf(form.submit).not.toBeAny() + expectTypeOf(form.Initialize).not.toBeAny() + }) + }) +}) diff --git a/packages/form-solid/test/internal/debounce-auto-submit.test.tsx b/packages/form-solid/test/internal/debounce-auto-submit.test.tsx new file mode 100644 index 0000000..4ab0eee --- /dev/null +++ b/packages/form-solid/test/internal/debounce-auto-submit.test.tsx @@ -0,0 +1,416 @@ +import * as Atom from "@effect-atom/atom/Atom" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import { render, screen, waitFor, cleanup } from "@solidjs/testing-library" +import { userEvent } from "@testing-library/user-event" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Show } from "solid-js" +import { describe, expect, it, vi, afterEach } from "vitest" + +afterEach(() => { + cleanup() +}) + +const createRuntime = () => Atom.runtime(Layer.empty) + +const TextInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + data-testid={props.props.testId ?? "text-input"} + /> + + {Option.getOrNull(props.field.error)} + +
+) + +const NameInput: FormSolid.FieldComponent = (props) => ( + +) + +const AgeInput: FormSolid.FieldComponent = (props) => ( + +) + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const NameField = Field.makeField("name", Schema.String) +const NameFieldMinLength = Field.makeField( + "name", + Schema.String.pipe( + Schema.minLength(5, { message: () => "Must be at least 5 characters" }) + ) +) +const AgeField = Field.makeField("age", Schema.String) + +describe("Debounce and Auto-Submit", () => { + describe("Manual Submit Debounce", () => { + it("should debounce validation updates in onChange mode", async () => { + const user = userEvent.setup() + + const formBuilder = FormBuilder.empty.addField(NameFieldMinLength) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onChange", debounce: "300 millis" }, + onSubmit + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + await user.clear(input) + await user.type(input, "Bad") + await user.tab() + + // userEvent operations take some time but < 300ms + expect(screen.queryByTestId("text-input-error")).not.toBeInTheDocument() + + await waitFor( + () => { + expect(screen.getByTestId("text-input-error")).toHaveTextContent( + "Must be at least 5 characters" + ) + }, + { timeout: 500 } + ) + }) + }) + + describe("Auto-Submit Happy Path", () => { + it("should NOT auto-submit on initial mount without changes", async () => { + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onChange", debounce: "50 millis", autoSubmit: true }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + render(() => ( + + + + )) + + await delay(120) + + expect(submitHandler).not.toHaveBeenCalled() + }) + + it("should auto-submit valid form data after debounce", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onChange", debounce: "100 millis", autoSubmit: true }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "Lucas") + + expect(submitHandler).not.toHaveBeenCalled() + + await waitFor( + () => { + expect(submitHandler).toHaveBeenCalledWith({ name: "Lucas" }) + }, + { timeout: 300 } + ) + }) + + it("should NOT re-trigger auto-submit after submission completes", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onChange", debounce: "50 millis", autoSubmit: true }, + onSubmit: async (_: void, { decoded }) => { + // Async submit to ensure stateAtom update happens after debounce window + await delay(50) + submitHandler(decoded) + } + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + await user.type(input, "Lucas") + + // Wait for first submit to complete + await waitFor( + () => { + expect(submitHandler).toHaveBeenCalledTimes(1) + }, + { timeout: 300 } + ) + + // Wait additional time - if bug exists, more submits will occur + await delay(200) + + // Should still only be 1 submit, not an infinite loop + expect(submitHandler).toHaveBeenCalledTimes(1) + expect(submitHandler).toHaveBeenCalledWith({ name: "Lucas" }) + }) + }) + + describe("Race Condition Guard", () => { + it("should batch updates from multiple fields into a single auto-submission", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty + .addField(NameField) + .addField(AgeField) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: NameInput, age: AgeInput }, + mode: { validation: "onChange", debounce: "100 millis", autoSubmit: true }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + render(() => ( + + + + + )) + + const nameInput = screen.getByTestId("name-input") + const ageInput = screen.getByTestId("age-input") + await user.type(nameInput, "Lucas") + await user.type(ageInput, "30") + + // Debounce resets with each field change + await delay(50) + expect(submitHandler).not.toHaveBeenCalled() + + await waitFor( + () => { + expect(submitHandler).toHaveBeenCalledTimes(1) + expect(submitHandler).toHaveBeenCalledWith({ name: "Lucas", age: "30" }) + }, + { timeout: 300 } + ) + }) + }) + + describe("Invalid State Guard", () => { + it("should NOT auto-submit if validation fails", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty.addField(NameFieldMinLength) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onChange", debounce: "50 millis", autoSubmit: true }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "Bad") + await user.tab() + + await waitFor( + () => { + expect(screen.getByTestId("text-input-error")).toHaveTextContent( + "Must be at least 5 characters" + ) + }, + { timeout: 200 } + ) + + await delay(100) + + expect(submitHandler).not.toHaveBeenCalled() + }) + }) + + describe("Unmount Safety", () => { + it("should cancel pending submission on unmount", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onChange", debounce: "100 millis", autoSubmit: true }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + const { unmount } = render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "Lucas") + + unmount() + + await delay(200) + + expect(submitHandler).not.toHaveBeenCalled() + }) + }) + + describe("onBlur Auto-Submit", () => { + it("should auto-submit on blur when mode is onBlur with autoSubmit", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onBlur", autoSubmit: true }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "Lucas") + + expect(submitHandler).not.toHaveBeenCalled() + + await user.tab() + + await waitFor( + () => { + expect(submitHandler).toHaveBeenCalledWith({ name: "Lucas" }) + }, + { timeout: 200 } + ) + }) + + it("should NOT re-submit on blur if values unchanged since last submission", async () => { + const user = userEvent.setup() + const submitHandler = vi.fn() + + const formBuilder = FormBuilder.empty.addField(NameField) + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onBlur", autoSubmit: true }, + onSubmit: (_: void, { decoded }) => submitHandler(decoded) + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + + await user.type(input, "Lucas") + await user.tab() + + await waitFor( + () => { + expect(submitHandler).toHaveBeenCalledTimes(1) + }, + { timeout: 200 } + ) + + await user.click(input) + await user.tab() + + await delay(100) + + expect(submitHandler).toHaveBeenCalledTimes(1) + }) + }) + + describe("No debounce in simple onChange mode", () => { + it("should validate immediately in onChange mode without debounce config", async () => { + const user = userEvent.setup() + + const formBuilder = FormBuilder.empty.addField(NameFieldMinLength) + + const onSubmit = () => {} + + const form = FormSolid.make(formBuilder, { + runtime: createRuntime(), + fields: { name: TextInput }, + mode: { validation: "onChange" }, + onSubmit + }) + + render(() => ( + + + + )) + + const input = screen.getByTestId("text-input") + + await user.clear(input) + await user.type(input, "Bad") + await user.tab() + + await waitFor(() => { + expect(screen.getByTestId("text-input-error")).toHaveTextContent( + "Must be at least 5 characters" + ) + }) + }) + }) +}) diff --git a/packages/form-solid/tsconfig.json b/packages/form-solid/tsconfig.json new file mode 100644 index 0000000..650e6d8 --- /dev/null +++ b/packages/form-solid/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["node", "vitest/globals", "@testing-library/jest-dom"], + "paths": { + "@lucas-barake/effect-form-solid": ["./src/index.ts"] + } + }, + "include": ["src", "test"], + "references": [ + { "path": "../form" } + ] +} diff --git a/packages/form-solid/vitest-setup.ts b/packages/form-solid/vitest-setup.ts new file mode 100644 index 0000000..ff10930 --- /dev/null +++ b/packages/form-solid/vitest-setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest" +import { cleanup } from "@solidjs/testing-library" +import { afterEach } from "vitest" + +afterEach(() => { + cleanup() +}) diff --git a/packages/form-solid/vitest.config.ts b/packages/form-solid/vitest.config.ts new file mode 100644 index 0000000..0080c5e --- /dev/null +++ b/packages/form-solid/vitest.config.ts @@ -0,0 +1,16 @@ +import solidjs from "vite-plugin-solid" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + plugins: [solidjs()], + test: { + include: ["./test/**/*.test.tsx"], + environment: "jsdom", + setupFiles: ["./vitest-setup.ts"], + server: { + deps: { + inline: [/solid-js/, /@effectify\/solid-effect-atom/] + } + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f24518a..94e02d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@effect/vitest': specifier: ^0.27.0 version: 0.27.0(effect@3.19.15)(vitest@4.0.16(@types/node@24.10.4)(jsdom@27.3.0)(tsx@4.21.0)) + '@solidjs/testing-library': + specifier: 0.8.10 + version: 0.8.10(solid-js@1.9.11) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -152,6 +155,31 @@ importers: specifier: ^5.1.1 version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)) + packages/form-solid: + dependencies: + '@effect-atom/atom': + specifier: ^0.5.0 + version: 0.5.0(@effect/experimental@0.58.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(@effect/platform@0.94.2(effect@3.19.15))(@effect/rpc@0.73.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(effect@3.19.15) + '@effectify/solid-effect-atom': + specifier: ^0.2.3 + version: 0.2.3(@effect-atom/atom@0.5.0(@effect/experimental@0.58.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(@effect/platform@0.94.2(effect@3.19.15))(@effect/rpc@0.73.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(effect@3.19.15))(effect@3.19.15)(solid-js@1.9.11) + '@lucas-barake/effect-form': + specifier: workspace:^ + version: link:../form + effect: + specifier: ^3.19.15 + version: 3.19.15 + solid-js: + specifier: ^1.9.11 + version: 1.9.11 + devDependencies: + babel-preset-solid: + specifier: ^1.9.10 + version: 1.9.10(@babel/core@7.28.5)(solid-js@1.9.11) + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)) + packages: '@acemir/cssom@0.9.30': @@ -204,6 +232,10 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.27.1': resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} @@ -218,6 +250,10 @@ packages: resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -239,6 +275,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -476,6 +518,13 @@ packages: effect: ^3.19.0 vitest: ^3.2.0 + '@effectify/solid-effect-atom@0.2.3': + resolution: {integrity: sha512-FdMZ1m0ixu7hj6Wlv3W048dO9iU+QNLvkNyvn3Zffh/94hVPGdNd6vHejP/mDb5+ruwNx9Y3Bmi6iuCaNAHewA==} + peerDependencies: + '@effect-atom/atom': ^0.5.0 + effect: ^3.19.16 + solid-js: ^1.9.11 + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1029,6 +1078,16 @@ packages: cpu: [x64] os: [win32] + '@solidjs/testing-library@0.8.10': + resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/router': '>=0.9.0' + solid-js: '>=1.0.0' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1317,6 +1376,20 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + babel-plugin-jsx-dom-expressions@0.40.3: + resolution: {integrity: sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.10: + resolution: {integrity: sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.10 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1740,6 +1813,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1838,6 +1914,10 @@ packages: is-url@1.2.4: resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -1950,6 +2030,10 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2304,6 +2388,16 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + engines: {node: '>=10'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2330,6 +2424,14 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + solid-js@1.9.11: + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2493,6 +2595,16 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + vite-plugin-solid@2.11.10: + resolution: {integrity: sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2573,6 +2685,14 @@ packages: yaml: optional: true + vitefu@1.1.1: + resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.0.16: resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2774,6 +2894,10 @@ snapshots: '@babel/helper-globals@7.28.0': {} + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.28.5 + '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 @@ -2792,6 +2916,8 @@ snapshots: '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -2807,6 +2933,11 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -3109,6 +3240,12 @@ snapshots: effect: 3.19.15 vitest: 4.0.16(@types/node@24.10.4)(jsdom@27.3.0)(tsx@4.21.0) + '@effectify/solid-effect-atom@0.2.3(@effect-atom/atom@0.5.0(@effect/experimental@0.58.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(@effect/platform@0.94.2(effect@3.19.15))(@effect/rpc@0.73.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(effect@3.19.15))(effect@3.19.15)(solid-js@1.9.11)': + dependencies: + '@effect-atom/atom': 0.5.0(@effect/experimental@0.58.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(@effect/platform@0.94.2(effect@3.19.15))(@effect/rpc@0.73.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(effect@3.19.15) + effect: 3.19.15 + solid-js: 1.9.11 + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -3447,6 +3584,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.54.0': optional: true + '@solidjs/testing-library@0.8.10(solid-js@1.9.11)': + dependencies: + '@testing-library/dom': 10.4.1 + solid-js: 1.9.11 + '@standard-schema/spec@1.1.0': {} '@testing-library/dom@10.4.1': @@ -3784,6 +3926,22 @@ snapshots: dependencies: '@babel/core': 7.28.5 + babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5) + '@babel/types': 7.28.5 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-preset-solid@1.9.10(@babel/core@7.28.5)(solid-js@1.9.11): + dependencies: + '@babel/core': 7.28.5 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.28.5) + optionalDependencies: + solid-js: 1.9.11 + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -4268,6 +4426,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-proxy-agent@7.0.2: @@ -4346,6 +4506,8 @@ snapshots: is-url@1.2.4: {} + is-what@4.1.16: {} + is-windows@1.0.2: {} isexe@2.0.0: {} @@ -4482,6 +4644,10 @@ snapshots: mdn-data@2.12.2: {} + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge2@1.4.1: {} micromatch@4.0.8: @@ -4835,6 +5001,12 @@ snapshots: semver@7.7.3: {} + seroval-plugins@1.5.0(seroval@1.5.0): + dependencies: + seroval: 1.5.0 + + seroval@1.5.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4851,6 +5023,21 @@ snapshots: slash@3.0.0: {} + solid-js@1.9.11: + dependencies: + csstype: 3.2.3 + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) + + solid-refresh@0.6.3(solid-js@1.9.11): + dependencies: + '@babel/generator': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/types': 7.28.5 + solid-js: 1.9.11 + transitivePeerDependencies: + - supports-color + source-map-js@1.2.1: {} source-map@0.6.1: @@ -4997,6 +5184,21 @@ snapshots: uuid@11.1.0: {} + vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)): + dependencies: + '@babel/core': 7.28.5 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.10(@babel/core@7.28.5)(solid-js@1.9.11) + merge-anything: 5.1.7 + solid-js: 1.9.11 + solid-refresh: 0.6.3(solid-js@1.9.11) + vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0) + vitefu: 1.1.1(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)) + optionalDependencies: + '@testing-library/jest-dom': 6.9.1 + transitivePeerDependencies: + - supports-color + vite@6.4.1(@types/node@24.10.4)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -5023,6 +5225,10 @@ snapshots: fsevents: 2.3.3 tsx: 4.21.0 + vitefu@1.1.1(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)): + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0) + vitest@4.0.16(@types/node@24.10.4)(jsdom@27.3.0)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.16 diff --git a/tsconfig.base.json b/tsconfig.base.json index e375648..624444f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,7 +25,6 @@ "skipLibCheck": true, "noErrorTruncation": true, "types": [], - "jsx": "react-jsx", "plugins": [{ "name": "@effect/language-service" }] } } diff --git a/tsconfig.json b/tsconfig.json index 245b479..59d754b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,9 @@ "@lucas-barake/effect-form": ["./packages/form/src/index.ts"], "@lucas-barake/effect-form/*": ["./packages/form/src/*.ts"], "@lucas-barake/effect-form-react": ["./packages/form-react/src/index.ts"], - "@lucas-barake/effect-form-react/*": ["./packages/form-react/src/*.ts"] + "@lucas-barake/effect-form-react/*": ["./packages/form-react/src/*.ts"], + "@lucas-barake/effect-form-solid": ["./packages/form-solid/src/index.ts"], + "@lucas-barake/effect-form-solid/*": ["./packages/form-solid/src/*.ts"] }, "plugins": [{ "name": "@effect/language-service" }] } diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 83d2191..bd456af 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -4,6 +4,7 @@ "include": [], "references": [ { "path": "packages/form" }, - { "path": "packages/form-react" } + { "path": "packages/form-react" }, + { "path": "packages/form-solid" } ] } From d1b23ff74ef1a5cbd4d632eb2a03433de402eaa5 Mon Sep 17 00:00:00 2001 From: Andres Jimenez Date: Mon, 9 Feb 2026 21:17:44 -0600 Subject: [PATCH 2/2] feat(examples): add React and SolidJS example applications Add comprehensive example applications for both React and SolidJS frameworks to demonstrate the usage of the effect-form library. Each example includes: - Complete project setup with Vite, TypeScript, and framework-specific configuration - Eight interactive form examples covering basic forms, validation modes, array fields, cross-field validation, async validation, auto-submit, multi-step wizards, and revert changes functionality - Shared CSS styling and reusable components - Proper workspace configuration in pnpm-workspace.yaml - Dependency management with workspace package references The examples showcase the full capabilities of the form library including type-safe error handling, Effect services integration, and framework-specific implementations. --- examples/{ => react}/index.html | 0 examples/{ => react}/package.json | 0 examples/{ => react}/src/app.tsx | 0 .../{ => react}/src/components/text-input.tsx | 0 .../src/examples/01-basic-form.tsx | 0 .../src/examples/02-validation-modes.tsx | 0 .../src/examples/03-array-fields.tsx | 0 .../examples/04-cross-field-validation.tsx | 0 .../src/examples/05-async-validation.tsx | 0 .../src/examples/06-auto-submit.tsx | 0 .../src/examples/07-multi-step-wizard.tsx | 0 .../src/examples/08-revert-changes.tsx | 0 examples/{ => react}/src/main.tsx | 0 .../{ => react}/src/styles/app.module.css | 0 .../{ => react}/src/styles/form.module.css | 0 examples/{ => react}/tsconfig.json | 0 examples/{ => react}/vite.config.ts | 4 +- examples/solid/index.html | 12 + examples/solid/package.json | 24 + examples/solid/src/app.tsx | 53 +++ examples/solid/src/components/text-input.tsx | 26 + examples/solid/src/examples/01-basic-form.tsx | 220 +++++++++ .../src/examples/02-validation-modes.tsx | 122 +++++ .../solid/src/examples/03-array-fields.tsx | 163 +++++++ .../examples/04-cross-field-validation.tsx | 110 +++++ .../src/examples/05-async-validation.tsx | 116 +++++ .../solid/src/examples/06-auto-submit.tsx | 135 ++++++ .../src/examples/07-multi-step-wizard.tsx | 380 +++++++++++++++ .../solid/src/examples/08-revert-changes.tsx | 206 ++++++++ examples/solid/src/main.tsx | 4 + examples/solid/src/styles/app.module.css | 56 +++ examples/solid/src/styles/form.module.css | 446 ++++++++++++++++++ examples/solid/tsconfig.json | 17 + examples/solid/vite.config.ts | 13 + pnpm-lock.yaml | 56 ++- pnpm-workspace.yaml | 3 +- 36 files changed, 2160 insertions(+), 6 deletions(-) rename examples/{ => react}/index.html (100%) rename examples/{ => react}/package.json (100%) rename examples/{ => react}/src/app.tsx (100%) rename examples/{ => react}/src/components/text-input.tsx (100%) rename examples/{ => react}/src/examples/01-basic-form.tsx (100%) rename examples/{ => react}/src/examples/02-validation-modes.tsx (100%) rename examples/{ => react}/src/examples/03-array-fields.tsx (100%) rename examples/{ => react}/src/examples/04-cross-field-validation.tsx (100%) rename examples/{ => react}/src/examples/05-async-validation.tsx (100%) rename examples/{ => react}/src/examples/06-auto-submit.tsx (100%) rename examples/{ => react}/src/examples/07-multi-step-wizard.tsx (100%) rename examples/{ => react}/src/examples/08-revert-changes.tsx (100%) rename examples/{ => react}/src/main.tsx (100%) rename examples/{ => react}/src/styles/app.module.css (100%) rename examples/{ => react}/src/styles/form.module.css (100%) rename examples/{ => react}/tsconfig.json (100%) rename examples/{ => react}/vite.config.ts (68%) create mode 100644 examples/solid/index.html create mode 100644 examples/solid/package.json create mode 100644 examples/solid/src/app.tsx create mode 100644 examples/solid/src/components/text-input.tsx create mode 100644 examples/solid/src/examples/01-basic-form.tsx create mode 100644 examples/solid/src/examples/02-validation-modes.tsx create mode 100644 examples/solid/src/examples/03-array-fields.tsx create mode 100644 examples/solid/src/examples/04-cross-field-validation.tsx create mode 100644 examples/solid/src/examples/05-async-validation.tsx create mode 100644 examples/solid/src/examples/06-auto-submit.tsx create mode 100644 examples/solid/src/examples/07-multi-step-wizard.tsx create mode 100644 examples/solid/src/examples/08-revert-changes.tsx create mode 100644 examples/solid/src/main.tsx create mode 100644 examples/solid/src/styles/app.module.css create mode 100644 examples/solid/src/styles/form.module.css create mode 100644 examples/solid/tsconfig.json create mode 100644 examples/solid/vite.config.ts diff --git a/examples/index.html b/examples/react/index.html similarity index 100% rename from examples/index.html rename to examples/react/index.html diff --git a/examples/package.json b/examples/react/package.json similarity index 100% rename from examples/package.json rename to examples/react/package.json diff --git a/examples/src/app.tsx b/examples/react/src/app.tsx similarity index 100% rename from examples/src/app.tsx rename to examples/react/src/app.tsx diff --git a/examples/src/components/text-input.tsx b/examples/react/src/components/text-input.tsx similarity index 100% rename from examples/src/components/text-input.tsx rename to examples/react/src/components/text-input.tsx diff --git a/examples/src/examples/01-basic-form.tsx b/examples/react/src/examples/01-basic-form.tsx similarity index 100% rename from examples/src/examples/01-basic-form.tsx rename to examples/react/src/examples/01-basic-form.tsx diff --git a/examples/src/examples/02-validation-modes.tsx b/examples/react/src/examples/02-validation-modes.tsx similarity index 100% rename from examples/src/examples/02-validation-modes.tsx rename to examples/react/src/examples/02-validation-modes.tsx diff --git a/examples/src/examples/03-array-fields.tsx b/examples/react/src/examples/03-array-fields.tsx similarity index 100% rename from examples/src/examples/03-array-fields.tsx rename to examples/react/src/examples/03-array-fields.tsx diff --git a/examples/src/examples/04-cross-field-validation.tsx b/examples/react/src/examples/04-cross-field-validation.tsx similarity index 100% rename from examples/src/examples/04-cross-field-validation.tsx rename to examples/react/src/examples/04-cross-field-validation.tsx diff --git a/examples/src/examples/05-async-validation.tsx b/examples/react/src/examples/05-async-validation.tsx similarity index 100% rename from examples/src/examples/05-async-validation.tsx rename to examples/react/src/examples/05-async-validation.tsx diff --git a/examples/src/examples/06-auto-submit.tsx b/examples/react/src/examples/06-auto-submit.tsx similarity index 100% rename from examples/src/examples/06-auto-submit.tsx rename to examples/react/src/examples/06-auto-submit.tsx diff --git a/examples/src/examples/07-multi-step-wizard.tsx b/examples/react/src/examples/07-multi-step-wizard.tsx similarity index 100% rename from examples/src/examples/07-multi-step-wizard.tsx rename to examples/react/src/examples/07-multi-step-wizard.tsx diff --git a/examples/src/examples/08-revert-changes.tsx b/examples/react/src/examples/08-revert-changes.tsx similarity index 100% rename from examples/src/examples/08-revert-changes.tsx rename to examples/react/src/examples/08-revert-changes.tsx diff --git a/examples/src/main.tsx b/examples/react/src/main.tsx similarity index 100% rename from examples/src/main.tsx rename to examples/react/src/main.tsx diff --git a/examples/src/styles/app.module.css b/examples/react/src/styles/app.module.css similarity index 100% rename from examples/src/styles/app.module.css rename to examples/react/src/styles/app.module.css diff --git a/examples/src/styles/form.module.css b/examples/react/src/styles/form.module.css similarity index 100% rename from examples/src/styles/form.module.css rename to examples/react/src/styles/form.module.css diff --git a/examples/tsconfig.json b/examples/react/tsconfig.json similarity index 100% rename from examples/tsconfig.json rename to examples/react/tsconfig.json diff --git a/examples/vite.config.ts b/examples/react/vite.config.ts similarity index 68% rename from examples/vite.config.ts rename to examples/react/vite.config.ts index cef626c..eae4357 100644 --- a/examples/vite.config.ts +++ b/examples/react/vite.config.ts @@ -6,8 +6,8 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - "@lucas-barake/effect-form-react": path.resolve(__dirname, "../packages/form-react/src"), - "@lucas-barake/effect-form": path.resolve(__dirname, "../packages/form/src") + "@lucas-barake/effect-form-react": path.resolve(__dirname, "../../packages/form-react/src"), + "@lucas-barake/effect-form": path.resolve(__dirname, "../../packages/form/src") } } }) diff --git a/examples/solid/index.html b/examples/solid/index.html new file mode 100644 index 0000000..4914117 --- /dev/null +++ b/examples/solid/index.html @@ -0,0 +1,12 @@ + + + + + + Effect Form Examples + + +
+ + + diff --git a/examples/solid/package.json b/examples/solid/package.json new file mode 100644 index 0000000..b8d3a86 --- /dev/null +++ b/examples/solid/package.json @@ -0,0 +1,24 @@ +{ + "name": "@lucas-barake/effect-form-examples-solid", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build:app": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@lucas-barake/effect-form": "workspace:^", + "@lucas-barake/effect-form-solid": "workspace:^", + "@effect-atom/atom": "^0.5.0", + "@effectify/solid-effect-atom": "^0.2.3", + "effect": "^3.19.15", + "solid-js": "^1.9.11" + }, + "devDependencies": { + "vite-plugin-solid": "^2.11.10", + "typescript": "^5.9.3", + "vite": "^6.0.0" + } +} diff --git a/examples/solid/src/app.tsx b/examples/solid/src/app.tsx new file mode 100644 index 0000000..9953a3b --- /dev/null +++ b/examples/solid/src/app.tsx @@ -0,0 +1,53 @@ +import { createSignal, For } from "solid-js" +import { Dynamic } from "solid-js/web" +import { BasicForm } from "./examples/01-basic-form" +import { ValidationModes } from "./examples/02-validation-modes" +import { ArrayFields } from "./examples/03-array-fields" +import { CrossFieldValidation } from "./examples/04-cross-field-validation" +import { AsyncValidation } from "./examples/05-async-validation" +import { AutoSubmit } from "./examples/06-auto-submit" +import { MultiStepWizard } from "./examples/07-multi-step-wizard" +import { RevertChanges } from "./examples/08-revert-changes" +import styles from "./styles/app.module.css" + +const examples = [ + { id: "basic", label: "Basic Form", component: BasicForm }, + { id: "validation-modes", label: "Validation Modes", component: ValidationModes }, + { id: "array-fields", label: "Array Fields", component: ArrayFields }, + { id: "cross-field", label: "Cross-Field Validation", component: CrossFieldValidation }, + { id: "async", label: "Async Validation", component: AsyncValidation }, + { id: "auto-submit", label: "Auto-Submit", component: AutoSubmit }, + { id: "multi-step", label: "Multi-Step Wizard", component: MultiStepWizard }, + { id: "revert", label: "Revert Changes", component: RevertChanges } +] as const + +export function App() { + const [activeExample, setActiveExample] = createSignal("basic") + + const activeComponent = () => examples.find((e) => e.id === activeExample())?.component ?? BasicForm + + return ( +
+ +
+ +
+
+ ) +} diff --git a/examples/solid/src/components/text-input.tsx b/examples/solid/src/components/text-input.tsx new file mode 100644 index 0000000..f813144 --- /dev/null +++ b/examples/solid/src/components/text-input.tsx @@ -0,0 +1,26 @@ +import type { FormSolid } from "@lucas-barake/effect-form-solid" +import * as Option from "effect/Option" +import { Show } from "solid-js" +import styles from "../styles/form.module.css" + +export const TextInput: FormSolid.FieldComponent = (props) => ( +
+ props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + + + Validating... + + + + + {Option.getOrElse(props.field.error, () => "")} + + +
+) diff --git a/examples/solid/src/examples/01-basic-form.tsx b/examples/solid/src/examples/01-basic-form.tsx new file mode 100644 index 0000000..6d23636 --- /dev/null +++ b/examples/solid/src/examples/01-basic-form.tsx @@ -0,0 +1,220 @@ +import { useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import * as Result from "@effect-atom/atom/Result" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Show } from "solid-js" +import styles from "../styles/form.module.css" + +class InvalidCredentialsError extends Data.TaggedError("InvalidCredentialsError")<{ + readonly email: string +}> {} + +class AccountLockedError extends Data.TaggedError("AccountLockedError")<{ + readonly email: string + readonly unlockAt: Date +}> {} + +const EmailField = Field.makeField( + "email", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "Email is required" })) +) + +const PasswordField = Field.makeField( + "password", + Schema.String.pipe(Schema.minLength(8, { message: () => "Password must be at least 8 characters" })) +) + +const loginFormBuilder = FormBuilder.empty + .addField(EmailField) + .addField(PasswordField) + +const EmailInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + + + Validating... + + + + + {Option.getOrElse(props.field.error, () => "")} + + +
+) + +const PasswordInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + + + {Option.getOrElse(props.field.error, () => "")} + + +
+) + +const loginForm = FormSolid.make(loginFormBuilder, { + fields: { + email: EmailInput, + password: PasswordInput + }, + onSubmit: (_, { decoded }) => + Effect.gen(function*() { + yield* Effect.sleep("500 millis") + + if (decoded.email === "locked@example.com") { + return yield* new AccountLockedError({ + email: decoded.email, + unlockAt: new Date(Date.now() + 1000 * 60 * 30) + }) + } + + if (decoded.email === "invalid@example.com") { + return yield* new InvalidCredentialsError({ email: decoded.email }) + } + + yield* Effect.log(`Login successful: ${decoded.email}`) + return { email: decoded.email } + }) +}) + +function SubmitButton() { + const isDirty = useAtomValue(loginForm.isDirty) + const submitResult = useAtomValue(loginForm.submit) + + return ( + + ) +} + +function SubmitStatus() { + const submitResult = useAtomValue(loginForm.submit) + + return ( + <> + {Result.builder(submitResult()) + .onWaiting(() => null) + .onSuccess((value) => ( +
+ Login successful! Welcome, {value.email} +
+ )) + .onErrorTag( + "InvalidCredentialsError", + (error) => ( +
+ Invalid credentials for {error.email}. Please check your email and password. +
+ ) + ) + .onErrorTag( + "AccountLockedError", + (error) => ( +
+ Account {error.email} is locked. Try again at {error.unlockAt.toLocaleTimeString()}. +
+ ) + ) + .onErrorTag( + "ParseError", + () => ( +
+ Please fix the validation errors above. +
+ ) + ) + .onDefect((defect) => ( +
+ Unexpected error: {String(defect)} +
+ )) + .orNull()} + + ) +} + +function FormDebug() { + const isDirty = useAtomValue(loginForm.isDirty) + const submitCount = useAtomValue(loginForm.submitCount) + const values = useAtomValue(loginForm.values) + + return ( +
+ Form State: +
+        {JSON.stringify(
+          {
+            isDirty: isDirty(),
+            submitCount: submitCount(),
+            values: Option.getOrNull(values()),
+          },
+          null,
+          2
+        )}
+      
+
+ ) +} + +export function BasicForm() { + const submit = useAtomSet(loginForm.submit) + + return ( +
+

Basic Form

+

+ Simple login form with type-safe error handling using Data.TaggedError and{" "} + Result.builder(). +

+

+ Try: invalid@example.com for credentials error, locked@example.com for account locked. +

+ + +
{ + e.preventDefault() + submit() + }} + > + + + + + + +
+
+ ) +} diff --git a/examples/solid/src/examples/02-validation-modes.tsx b/examples/solid/src/examples/02-validation-modes.tsx new file mode 100644 index 0000000..2ce5fc0 --- /dev/null +++ b/examples/solid/src/examples/02-validation-modes.tsx @@ -0,0 +1,122 @@ +import { useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Show } from "solid-js" +import styles from "../styles/form.module.css" + +const UsernameField = Field.makeField( + "username", + Schema.String.pipe(Schema.minLength(3, { message: () => "Username must be at least 3 characters" })) +) + +const formBuilder = FormBuilder.empty.addField(UsernameField) + +const UsernameInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + Validating... + {Option.getOrElse(props.field.error, () => "")} +
+) + +const onSubmitForm = FormSolid.make(formBuilder, { + mode: { validation: "onSubmit" }, + fields: { username: UsernameInput }, + onSubmit: () => Effect.log("Submitted (onSubmit mode)") +}) + +const onBlurForm = FormSolid.make(formBuilder, { + mode: { validation: "onBlur" }, + fields: { username: UsernameInput }, + onSubmit: () => Effect.log("Submitted (onBlur mode)") +}) + +const onChangeForm = FormSolid.make(formBuilder, { + mode: { validation: "onChange" }, + fields: { username: UsernameInput }, + onSubmit: () => Effect.log("Submitted (onChange mode)") +}) + +const debouncedForm = FormSolid.make(formBuilder, { + mode: { validation: "onChange", debounce: "300 millis" }, + fields: { username: UsernameInput }, + onSubmit: () => Effect.log("Submitted (debounced mode)") +}) + +function FormCard(props: { + title: string + description: string + form: typeof onSubmitForm +}) { + const isDirty = useAtomValue(props.form.isDirty) + const submitResult = useAtomValue(props.form.submit) + const submit = useAtomSet(props.form.submit) + + return ( +
+

{props.title}

+

{props.description}

+ +
{ + e.preventDefault() + submit() + }} + > + + + +
+
+ ) +} + +export function ValidationModes() { + return ( +
+

Validation Modes

+

+ Different validation timing strategies. Type less than 3 characters to see errors. +

+ + + + + + + + +
+ ) +} diff --git a/examples/solid/src/examples/03-array-fields.tsx b/examples/solid/src/examples/03-array-fields.tsx new file mode 100644 index 0000000..70bd206 --- /dev/null +++ b/examples/solid/src/examples/03-array-fields.tsx @@ -0,0 +1,163 @@ +import { useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { For, Show } from "solid-js" +import styles from "../styles/form.module.css" + +const TodosField = Field.makeArrayField( + "todos", + Schema.Struct({ + text: Schema.String.pipe(Schema.nonEmptyString({ message: () => "Todo text is required" })), + completed: Schema.Boolean + }) +) + +const todoFormBuilder = FormBuilder.empty.addField(TodosField) + +const todoForm = FormSolid.make(todoFormBuilder, { + fields: { + todos: { + text: (props) => ( + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + placeholder="What needs to be done?" + class={`${styles.listItemInput} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + ), + completed: (props) => ( + props.field.onChange(e.currentTarget.checked)} + class={styles.checkbox} + /> + ) + } + }, + onSubmit: (_, { decoded }) => + Effect.gen(function*() { + yield* Effect.log(`Submitting ${decoded.todos.length} todos`) + return { count: decoded.todos.length } + }) +}) + +function SubmitButton() { + const isDirty = useAtomValue(todoForm.isDirty) + const submitResult = useAtomValue(todoForm.submit) + + return ( + + ) +} + +function TodoList() { + return ( + + {(api) => ( +
+

No todos yet. Add one below!

+ + + {(_, index) => ( + + {(itemApi) => ( +
+ + + +
+ )} +
+ )} +
+ +
+ + + = 2}> + <> + + + + +
+
+ )} +
+ ) +} + +function FormState() { + const values = useAtomValue(todoForm.values) + + return ( +
+ Form Values: +
+        {JSON.stringify(Option.getOrNull(values()), null, 2)}
+      
+
+ ) +} + +export function ArrayFields() { + const submit = useAtomSet(todoForm.submit) + + return ( +
+

Array Fields

+

+ Dynamic list with append, remove, swap, and move operations. +

+ + +
{ + e.preventDefault() + submit() + }} + > + +
+ +
+ + +
+
+ ) +} diff --git a/examples/solid/src/examples/04-cross-field-validation.tsx b/examples/solid/src/examples/04-cross-field-validation.tsx new file mode 100644 index 0000000..f9fba4f --- /dev/null +++ b/examples/solid/src/examples/04-cross-field-validation.tsx @@ -0,0 +1,110 @@ +import { useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Show } from "solid-js" +import styles from "../styles/form.module.css" + +const PasswordField = Field.makeField( + "password", + Schema.String.pipe(Schema.minLength(8, { message: () => "Password must be at least 8 characters" })) +) + +const ConfirmPasswordField = Field.makeField( + "confirmPassword", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "Please confirm your password" })) +) + +const signupFormBuilder = FormBuilder.empty + .addField(PasswordField) + .addField(ConfirmPasswordField) + .refine((values) => { + if (values.password !== values.confirmPassword) { + return { path: ["confirmPassword"], message: "Passwords must match" } + } + }) + +const PasswordInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const ConfirmPasswordInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const signupForm = FormSolid.make(signupFormBuilder, { + mode: { validation: "onBlur" }, + fields: { + password: PasswordInput, + confirmPassword: ConfirmPasswordInput + }, + onSubmit: () => + Effect.gen(function*() { + yield* Effect.sleep("500 millis") + yield* Effect.log("Password set for signup") + return { success: true } + }) +}) + +function SubmitButton() { + const isDirty = useAtomValue(signupForm.isDirty) + const submitResult = useAtomValue(signupForm.submit) + + return ( + + ) +} + +export function CrossFieldValidation() { + const submit = useAtomSet(signupForm.submit) + + return ( +
+

Cross-Field Validation

+

+ Using .refine() for synchronous cross-field validation. Error is routed to the{" "} + confirmPassword field. +

+ + +
{ + e.preventDefault() + submit() + }} + > + + + + +
+
+ ) +} diff --git a/examples/solid/src/examples/05-async-validation.tsx b/examples/solid/src/examples/05-async-validation.tsx new file mode 100644 index 0000000..bfd7478 --- /dev/null +++ b/examples/solid/src/examples/05-async-validation.tsx @@ -0,0 +1,116 @@ +import { useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import * as Atom from "@effect-atom/atom/Atom" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Show } from "solid-js" +import styles from "../styles/form.module.css" + +class UsernameValidator extends Context.Tag("UsernameValidator")< + UsernameValidator, + { readonly isTaken: (username: string) => Effect.Effect } +>() {} + +const UsernameValidatorLive = Layer.succeed(UsernameValidator, { + isTaken: (username) => + Effect.gen(function*() { + yield* Effect.sleep("800 millis") + const reserved = ["admin", "root", "taken"] + return reserved.includes(username.toLowerCase()) + }) +}) + +const runtime = Atom.runtime(UsernameValidatorLive) + +const UsernameField = Field.makeField( + "username", + Schema.String.pipe( + Schema.minLength(3, { message: () => "Username must be at least 3 characters" }), + Schema.pattern(/^[a-zA-Z0-9_]+$/, { message: () => "Only letters, numbers, and underscores" }) + ) +) + +const usernameFormBuilder = FormBuilder.empty + .addField(UsernameField) + .refineEffect((values) => + Effect.gen(function*() { + const validator = yield* UsernameValidator + const isTaken = yield* validator.isTaken(values.username) + if (isTaken) { + return { path: ["username"], message: "This username is already taken" } + } + }) + ) + +const UsernameInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + Checking availability... + {Option.getOrElse(props.field.error, () => "")} +
+) + +const usernameForm = FormSolid.make(usernameFormBuilder, { + runtime, + mode: { validation: "onChange", debounce: "300 millis" }, + fields: { username: UsernameInput }, + onSubmit: (_, { decoded }) => + Effect.gen(function*() { + yield* Effect.sleep("500 millis") + yield* Effect.log(`Username registered: ${decoded.username}`) + return { username: decoded.username } + }) +}) + +function SubmitButton() { + const isDirty = useAtomValue(usernameForm.isDirty) + const submitResult = useAtomValue(usernameForm.submit) + + return ( + + ) +} + +export function AsyncValidation() { + const submit = useAtomSet(usernameForm.submit) + + return ( +
+

Async Validation

+

+ Using .refineEffect() with Effect services. Validation runs asynchronously with debouncing. +

+

+ Reserved usernames: admin, root, taken +

+ + +
{ + e.preventDefault() + submit() + }} + > + + + +
+
+ ) +} diff --git a/examples/solid/src/examples/06-auto-submit.tsx b/examples/solid/src/examples/06-auto-submit.tsx new file mode 100644 index 0000000..ed4c9b6 --- /dev/null +++ b/examples/solid/src/examples/06-auto-submit.tsx @@ -0,0 +1,135 @@ +import { useAtomValue } from "@effectify/solid-effect-atom" +import * as Result from "@effect-atom/atom/Result" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Show } from "solid-js" +import styles from "../styles/form.module.css" + +const DisplayNameField = Field.makeField( + "displayName", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "Display name is required" })) +) + +const BioField = Field.makeField("bio", Schema.String) + +const settingsFormBuilder = FormBuilder.empty.addField(DisplayNameField).addField(BioField) + +const DisplayNameInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const BioInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={styles.input} + /> +
+) + +const autoSubmitOnChangeForm = FormSolid.make(settingsFormBuilder, { + mode: { validation: "onChange", debounce: "500 millis", autoSubmit: true }, + fields: { + displayName: DisplayNameInput, + bio: BioInput + }, + onSubmit: (_, { decoded }) => + Effect.gen(function*() { + yield* Effect.sleep("300 millis") + yield* Effect.log(`Auto-saved: ${decoded.displayName}`) + return { savedAt: new Date() } + }) +}) + +const autoSubmitOnBlurForm = FormSolid.make(settingsFormBuilder, { + mode: { validation: "onBlur", autoSubmit: true }, + fields: { + displayName: DisplayNameInput, + bio: BioInput + }, + onSubmit: (_, { decoded }) => + Effect.gen(function*() { + yield* Effect.sleep("300 millis") + yield* Effect.log(`Auto-saved on blur: ${decoded.displayName}`) + return { savedAt: new Date() } + }) +}) + +function SaveStatus(props: { form: typeof autoSubmitOnChangeForm }) { + const submitResult = useAtomValue(props.form.submit) + + return ( + <> + {Result.builder(submitResult()) + .onWaiting(() => ( +
+ Saving... +
+ )) + .onSuccess((value) => ( +
+ Saved at {value.savedAt.toLocaleTimeString()} +
+ )) + .onError(() => ( +
+ Failed to save +
+ )) + .orNull()} + + ) +} + +export function AutoSubmit() { + return ( +
+

Auto-Submit

+

+ Forms that automatically save when you make changes. No submit button needed! +

+ +
+
+

Auto-save on Change

+

+ Saves 500ms after you stop typing +

+ + + + + +
+ +
+

Auto-save on Blur

+

+ Saves when you leave a field +

+ + + + + +
+
+
+ ) +} diff --git a/examples/solid/src/examples/07-multi-step-wizard.tsx b/examples/solid/src/examples/07-multi-step-wizard.tsx new file mode 100644 index 0000000..803514c --- /dev/null +++ b/examples/solid/src/examples/07-multi-step-wizard.tsx @@ -0,0 +1,380 @@ +import { useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import * as Result from "@effect-atom/atom/Result" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Effect from "effect/Effect" +import { constNull } from "effect/Function" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { createSignal, createEffect, on, For, Show } from "solid-js" +import styles from "../styles/form.module.css" + +const FirstNameField = Field.makeField( + "firstName", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "First name is required" })) +) + +const LastNameField = Field.makeField( + "lastName", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "Last name is required" })) +) + +const step1Builder = FormBuilder.empty.addField(FirstNameField).addField(LastNameField) + +const FirstNameInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const LastNameInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const step1Form = FormSolid.make(step1Builder, { + mode: { validation: "onBlur" }, + fields: { + firstName: FirstNameInput, + lastName: LastNameInput + }, + onSubmit: (_, { decoded }) => Effect.succeed(decoded) +}) + +const StreetField = Field.makeField( + "street", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "Street is required" })) +) + +const CityField = Field.makeField( + "city", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "City is required" })) +) + +const ZipField = Field.makeField( + "zip", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "ZIP code is required" })) +) + +const step2Builder = FormBuilder.empty.addField(StreetField).addField(CityField).addField(ZipField) + +const StreetInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const CityInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const ZipInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const step2Form = FormSolid.make(step2Builder, { + mode: { validation: "onBlur" }, + fields: { + street: StreetInput, + city: CityInput, + zip: ZipInput + }, + onSubmit: (_, { decoded }) => Effect.succeed(decoded) +}) + +const finalBuilder = FormBuilder.empty.merge(step1Builder).merge(step2Builder) + +const finalForm = FormSolid.make(finalBuilder, { + fields: { + firstName: constNull, + lastName: constNull, + street: constNull, + city: constNull, + zip: constNull + }, + onSubmit: (_, { decoded }) => + Effect.gen(function*() { + yield* Effect.sleep("1 second") + yield* Effect.log(`Order submitted for ${decoded.firstName} ${decoded.lastName}`) + return { orderId: `ORD-${Date.now()}` } + }) +}) + +function FinalSubmitButton() { + const submitResult = useAtomValue(finalForm.submit) + + return ( + + ) +} + +type StepData = { + step1: { firstName: string; lastName: string } | null + step2: { street: string; city: string; zip: string } | null +} + +function Step1(props: { onComplete: (data: StepData["step1"]) => void }) { + const submit = useAtomSet(step1Form.submit) + const isDirty = useAtomValue(step1Form.isDirty) + const submitResult = useAtomValue(step1Form.submit) + + createEffect(on(submitResult, (result) => { + if (Result.isSuccess(result) && !result.waiting) { + props.onComplete(result.value) + } + }, { defer: true })) + + const handleNext = () => { + const res = submitResult() + if (isDirty()) { + submit() + } else if (Result.isSuccess(res)) { + props.onComplete(res.value) + } + } + + return ( + +
{ + e.preventDefault() + handleNext() + }} + > + + + + +
+ ) +} + +function Step2(props: { + onComplete: (data: StepData["step2"]) => void + onBack: () => void +}) { + const submit = useAtomSet(step2Form.submit) + const isDirty = useAtomValue(step2Form.isDirty) + const submitResult = useAtomValue(step2Form.submit) + + createEffect(on(submitResult, (result) => { + if (Result.isSuccess(result) && !result.waiting) { + props.onComplete(result.value) + } + }, { defer: true })) + + const handleNext = () => { + const res = submitResult() + if (isDirty()) { + submit() + } else if (Result.isSuccess(res)) { + props.onComplete(res.value) + } + } + + return ( + +
{ + e.preventDefault() + handleNext() + }} + > + + + +
+ + +
+ +
+ ) +} + +function Step3(props: { + data: StepData + onBack: () => void +}) { + const submit = useAtomSet(finalForm.submit) + const submitResult = useAtomValue(finalForm.submit) + + return ( + +
{ + e.preventDefault() + submit() + }} + > +
+

Review Your Order

+
+
+ Personal Info +

+ {props.data.step1?.firstName} {props.data.step1?.lastName} +

+
+
+ Shipping Address +

{props.data.step2?.street}

+

+ {props.data.step2?.city}, {props.data.step2?.zip} +

+
+
+
+ + +
+ Order submitted! Order ID: {(submitResult() as any).value.orderId} +
+
+ +
+ + +
+
+
+ ) +} + +export function MultiStepWizard() { + const [currentStep, setCurrentStep] = createSignal(1) + const [stepData, setStepData] = createSignal({ step1: null, step2: null }) + + return ( +
+ + + +

Multi-Step Wizard

+

+ Three-step form using .merge(){" "} + to combine builders. Each step validates independently, then merges into final form. +

+ +
+ {[1, 2, 3].map((step) => ( +
+ ))} +
+ +
+

+ Step {currentStep()} of 3: {currentStep() === 1 ? "Personal Info" : currentStep() === 2 ? "Address" : "Review"} +

+ + + { + setStepData((prev) => ({ ...prev, step1: data })) + setCurrentStep(2) + }} + /> + + + + { + setStepData((prev) => ({ ...prev, step2: data })) + setCurrentStep(3) + }} + onBack={() => setCurrentStep(1)} + /> + + + + setCurrentStep(2)} /> + +
+
+ ) +} diff --git a/examples/solid/src/examples/08-revert-changes.tsx b/examples/solid/src/examples/08-revert-changes.tsx new file mode 100644 index 0000000..699046f --- /dev/null +++ b/examples/solid/src/examples/08-revert-changes.tsx @@ -0,0 +1,206 @@ +import { useAtomSet, useAtomValue } from "@effectify/solid-effect-atom" +import * as Result from "@effect-atom/atom/Result" +import { Field, FormBuilder, FormSolid } from "@lucas-barake/effect-form-solid" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { Show } from "solid-js" +import styles from "../styles/form.module.css" + +const NameField = Field.makeField( + "name", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "Name is required" })) +) + +const EmailField = Field.makeField( + "email", + Schema.String.pipe(Schema.nonEmptyString({ message: () => "Email is required" })) +) + +const profileFormBuilder = FormBuilder.empty.addField(NameField).addField(EmailField) + +const NameInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const EmailInput: FormSolid.FieldComponent = (props) => ( +
+ + props.field.onChange(e.currentTarget.value)} + onBlur={props.field.onBlur} + class={`${styles.input} ${Option.isSome(props.field.error) ? styles.error : ""}`} + /> + {Option.getOrElse(props.field.error, () => "")} +
+) + +const profileForm = FormSolid.make(profileFormBuilder, { + mode: { validation: "onBlur" }, + fields: { + name: NameInput, + email: EmailInput + }, + onSubmit: (_, { decoded }) => + Effect.gen(function*() { + yield* Effect.sleep("500 millis") + yield* Effect.log(`Profile updated: ${decoded.name}`) + return { savedAt: new Date() } + }) +}) + +function UnsavedChangesBanner() { + const hasChangedSinceSubmit = useAtomValue(profileForm.hasChangedSinceSubmit) + const revertToLastSubmit = useAtomSet(profileForm.revertToLastSubmit) + + return ( + +
+ You have unsaved changes + +
+
+ ) +} + +function SubmitButton() { + const isDirty = useAtomValue(profileForm.isDirty) + const submitResult = useAtomValue(profileForm.submit) + + return ( + + ) +} + +function FormActions() { + const isDirty = useAtomValue(profileForm.isDirty) + const reset = useAtomSet(profileForm.reset) + + return ( +
+ + +
+ ) +} + +function SaveStatus() { + const submitResult = useAtomValue(profileForm.submit) + + return ( + <> + {Result.builder(submitResult()) + .onWaiting(() => ( +
+ Saving... +
+ )) + .onSuccess((value) => ( +
+ Last saved at {value.savedAt.toLocaleTimeString()} +
+ )) + .orNull()} + + ) +} + +function StateComparison() { + const values = useAtomValue(profileForm.values) + const lastSubmittedValues = useAtomValue(profileForm.lastSubmittedValues) + const isDirty = useAtomValue(profileForm.isDirty) + const hasChangedSinceSubmit = useAtomValue(profileForm.hasChangedSinceSubmit) + const submitCount = useAtomValue(profileForm.submitCount) + + return ( +
+ Form State: +
+
+
Current Values:
+
+            {JSON.stringify(Option.getOrNull(values()), null, 2)}
+          
+
+
+
Last Submitted:
+
+            {JSON.stringify(Option.getOrNull(lastSubmittedValues()), null, 2)}
+          
+
+
+
+ + isDirty: {String(isDirty())} + + + hasChangedSinceSubmit: {String(hasChangedSinceSubmit())} + + + submitCount: {submitCount()} + +
+
+ ) +} + +export function RevertChanges() { + const submit = useAtomSet(profileForm.submit) + + return ( +
+

Revert Changes

+

+ Track changes since last submit with hasChangedSinceSubmit and{" "} + revertToLastSubmit. Shows "unsaved changes" banner when form differs from last submitted state. +

+ + +
{ + e.preventDefault() + submit() + }} + > + + + + + + + +
+
+ ) +} diff --git a/examples/solid/src/main.tsx b/examples/solid/src/main.tsx new file mode 100644 index 0000000..787c39c --- /dev/null +++ b/examples/solid/src/main.tsx @@ -0,0 +1,4 @@ +import { render } from "solid-js/web" +import { App } from "./app" + +render(() => , document.getElementById("root")!) diff --git a/examples/solid/src/styles/app.module.css b/examples/solid/src/styles/app.module.css new file mode 100644 index 0000000..961b549 --- /dev/null +++ b/examples/solid/src/styles/app.module.css @@ -0,0 +1,56 @@ +.container { + display: flex; + min-height: 100vh; + font-family: system-ui, sans-serif; + background-color: #0f0f0f; + color: #e5e5e5; +} + +.nav { + width: 220px; + padding: 16px; + border-right: 1px solid #2a2a2a; + background-color: #171717; +} + +.navTitle { + margin: 0 0 16px; + font-size: 16px; + font-weight: 600; + color: #f5f5f5; +} + +.navList { + list-style: none; + padding: 0; + margin: 0; +} + +.navButton { + width: 100%; + padding: 8px 12px; + margin-bottom: 4px; + border: none; + border-radius: 4px; + background-color: transparent; + color: #a3a3a3; + cursor: pointer; + text-align: left; + font-size: 14px; +} + +.navButton:hover { + background-color: #262626; + color: #e5e5e5; +} + +.navButton.active { + background-color: #2563eb; + color: white; +} + +.main { + flex: 1; + padding: 32px; + background-color: #0f0f0f; +} diff --git a/examples/solid/src/styles/form.module.css b/examples/solid/src/styles/form.module.css new file mode 100644 index 0000000..e249e68 --- /dev/null +++ b/examples/solid/src/styles/form.module.css @@ -0,0 +1,446 @@ +/* Field layout */ +.fieldContainer { + margin-bottom: 16px; +} + +.label { + display: block; + margin-bottom: 4px; + font-weight: 500; + color: #e5e5e5; +} + +.dirtyIndicator { + color: #a3a3a3; + margin-left: 4px; +} + +/* Inputs */ +.input { + padding: 8px 12px; + border: 1px solid #3f3f3f; + border-radius: 4px; + width: 100%; + box-sizing: border-box; + background-color: #1a1a1a; + color: #e5e5e5; +} + +.input::placeholder { + color: #737373; +} + +.input:focus { + outline: none; + border-color: #3b82f6; +} + +.input.error { + border-color: #ef4444; +} + +.textarea { + padding: 8px 12px; + border: 1px solid #3f3f3f; + border-radius: 4px; + width: 100%; + box-sizing: border-box; + font-family: inherit; + min-height: 100px; + resize: vertical; + background-color: #1a1a1a; + color: #e5e5e5; +} + +.textarea::placeholder { + color: #737373; +} + +.textarea:focus { + outline: none; + border-color: #3b82f6; +} + +.textarea.error { + border-color: #ef4444; +} + +.checkbox { + width: 20px; + height: 20px; + accent-color: #3b82f6; +} + +/* Field messages */ +.validatingText { + color: #a3a3a3; + font-size: 12px; + margin-top: 4px; + display: block; +} + +.errorText { + color: #ef4444; + font-size: 12px; + margin-top: 4px; + display: block; +} + +/* Buttons */ +.button { + padding: 10px 20px; + background-color: #2563eb; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; +} + +.button:hover { + background-color: #1d4ed8; +} + +.button:disabled { + background-color: #404040; + color: #737373; + cursor: not-allowed; +} + +.buttonSecondary { + padding: 10px 20px; + background-color: #404040; + color: #e5e5e5; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.buttonSecondary:hover { + background-color: #525252; +} + +.buttonSecondary:disabled { + background-color: #2a2a2a; + color: #737373; + cursor: not-allowed; +} + +.buttonDanger { + padding: 6px 12px; + background-color: #dc2626; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.buttonDanger:hover { + background-color: #b91c1c; +} + +.buttonSmall { + padding: 6px 12px; +} + +.buttonSuccess { + padding: 8px 16px; + background-color: #059669; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.buttonSuccess:hover { + background-color: #047857; +} + +.buttonIndigo { + padding: 8px 16px; + background-color: #4f46e5; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.buttonIndigo:hover { + background-color: #4338ca; +} + +.buttonPurple { + padding: 8px 16px; + background-color: #7c3aed; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.buttonPurple:hover { + background-color: #6d28d9; +} + +.buttonWarning { + padding: 6px 12px; + background-color: #d97706; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} + +.buttonWarning:hover { + background-color: #b45309; +} + +.buttonGroup { + display: flex; + gap: 8px; +} + +/* Alerts */ +.alertSuccess { + padding: 12px; + background-color: #052e16; + border: 1px solid #166534; + border-radius: 4px; + margin-top: 16px; + color: #4ade80; +} + +.alertError { + padding: 12px; + background-color: #2a0a0a; + border: 1px solid #7f1d1d; + border-radius: 4px; + margin-top: 16px; + color: #f87171; +} + +.alertWarning { + padding: 12px; + background-color: #1c1405; + border: 1px solid #78350f; + border-radius: 4px; + margin-top: 16px; + color: #fbbf24; +} + +.alertInfo { + padding: 8px; + background-color: #0c1929; + border: 1px solid #1e40af; + border-radius: 4px; + margin-top: 16px; + font-size: 13px; + color: #60a5fa; +} + +.alertSmall { + padding: 8px; + font-size: 13px; + margin-top: 0; +} + +/* Debug panel */ +.debugBox { + margin-top: 24px; + padding: 16px; + background-color: #171717; + border: 1px solid #2a2a2a; + border-radius: 4px; + font-size: 12px; + color: #a3a3a3; +} + +.debugPre { + margin: 8px 0 0; + white-space: pre-wrap; + color: #e5e5e5; +} + +/* Page layout */ +.pageContainer { + max-width: 400px; +} + +.pageContainerMedium { + max-width: 500px; +} + +.pageContainerLarge { + max-width: 600px; +} + +.pageTitle { + margin-top: 0; + margin-bottom: 8px; + color: #f5f5f5; +} + +.pageDescription { + color: #a3a3a3; + margin-bottom: 24px; +} + +.pageHint { + color: #a3a3a3; + font-size: 13px; + margin-bottom: 24px; +} + +/* Cards */ +.card { + padding: 16px; + border: 1px solid #2a2a2a; + border-radius: 8px; + background-color: #171717; +} + +.cardTitle { + margin: 0 0 4px; + color: #f5f5f5; +} + +.cardDescription { + color: #a3a3a3; + font-size: 14px; + margin: 0 0 16px; +} + +/* Array fields */ +.emptyState { + color: #737373; + font-style: italic; +} + +.listItem { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; + padding: 8px; + background-color: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 4px; +} + +.listItemInput { + flex: 1; + padding: 8px 12px; + border: 1px solid #3f3f3f; + border-radius: 4px; + background-color: #0f0f0f; + color: #e5e5e5; +} + +.listItemInput:focus { + outline: none; + border-color: #3b82f6; +} + +.listItemInput.error { + border-color: #ef4444; +} + +/* Grid layouts */ +.grid2Col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.gridGap16 { + gap: 16px; +} + +/* Review sections */ +.reviewSection { + background-color: #171717; + border: 1px solid #2a2a2a; + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; +} + +.reviewTitle { + margin: 0 0 12px; + color: #f5f5f5; +} + +.reviewItem { + margin: 4px 0; + color: #a3a3a3; +} + +/* Progress indicator */ +.progressBar { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +.progressStep { + flex: 1; + height: 4px; + background-color: #2a2a2a; + border-radius: 2px; +} + +.progressStep.active { + background-color: #2563eb; +} + +/* Inline flex */ +.inlineFlex { + display: flex; + gap: 8px; + align-items: center; +} + +.marginTop16 { + margin-top: 16px; +} + +.marginTop24 { + margin-top: 24px; +} + +.marginBottom16 { + margin-bottom: 16px; +} + +/* Unsaved changes banner */ +.unsavedBanner { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background-color: #1c1405; + border: 1px solid #78350f; + border-radius: 4px; + margin-bottom: 16px; + color: #fbbf24; +} + +/* State comparison */ +.stateLabel { + font-weight: 600; + margin-bottom: 4px; + color: #e5e5e5; +} + +.statePre { + margin: 0; + white-space: pre-wrap; + color: #a3a3a3; +} + +.stateFlags { + margin-top: 12px; + display: flex; + gap: 16px; +} diff --git a/examples/solid/tsconfig.json b/examples/solid/tsconfig.json new file mode 100644 index 0000000..aaa7597 --- /dev/null +++ b/examples/solid/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "skipLibCheck": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/examples/solid/vite.config.ts b/examples/solid/vite.config.ts new file mode 100644 index 0000000..0e0b0e8 --- /dev/null +++ b/examples/solid/vite.config.ts @@ -0,0 +1,13 @@ +import solid from "vite-plugin-solid" +import * as path from "path" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [solid()], + resolve: { + alias: { + "@lucas-barake/effect-form-solid": path.resolve(__dirname, "../../packages/form-solid/src"), + "@lucas-barake/effect-form": path.resolve(__dirname, "../../packages/form/src") + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94e02d5..44eee97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: specifier: ^4.0.16 version: 4.0.16(@types/node@24.10.4)(jsdom@27.3.0)(tsx@4.21.0) - examples: + examples/react: dependencies: '@effect-atom/atom': specifier: ^0.5.0 @@ -94,10 +94,10 @@ importers: version: 0.5.0(@effect/experimental@0.58.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(@effect/platform@0.94.2(effect@3.19.15))(@effect/rpc@0.73.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(effect@3.19.15)(react@19.2.3)(scheduler@0.27.0) '@lucas-barake/effect-form': specifier: workspace:^ - version: link:../packages/form + version: link:../../packages/form '@lucas-barake/effect-form-react': specifier: workspace:^ - version: link:../packages/form-react + version: link:../../packages/form-react effect: specifier: ^3.19.15 version: 3.19.15 @@ -124,6 +124,37 @@ importers: specifier: ^6.0.0 version: 6.4.1(@types/node@24.10.4)(tsx@4.21.0) + examples/solid: + dependencies: + '@effect-atom/atom': + specifier: ^0.5.0 + version: 0.5.0(@effect/experimental@0.58.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(@effect/platform@0.94.2(effect@3.19.15))(@effect/rpc@0.73.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(effect@3.19.15) + '@effectify/solid-effect-atom': + specifier: ^0.2.3 + version: 0.2.3(@effect-atom/atom@0.5.0(@effect/experimental@0.58.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(@effect/platform@0.94.2(effect@3.19.15))(@effect/rpc@0.73.0(@effect/platform@0.94.2(effect@3.19.15))(effect@3.19.15))(effect@3.19.15))(effect@3.19.15)(solid-js@1.9.11) + '@lucas-barake/effect-form': + specifier: workspace:^ + version: link:../../packages/form + '@lucas-barake/effect-form-solid': + specifier: workspace:^ + version: link:../../packages/form-solid + effect: + specifier: ^3.19.15 + version: 3.19.15 + solid-js: + specifier: ^1.9.11 + version: 1.9.11 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1(@types/node@24.10.4)(tsx@4.21.0) + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@6.4.1(@types/node@24.10.4)(tsx@4.21.0)) + packages/form: dependencies: '@effect-atom/atom': @@ -5184,6 +5215,21 @@ snapshots: uuid@11.1.0: {} + vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@6.4.1(@types/node@24.10.4)(tsx@4.21.0)): + dependencies: + '@babel/core': 7.28.5 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.10(@babel/core@7.28.5)(solid-js@1.9.11) + merge-anything: 5.1.7 + solid-js: 1.9.11 + solid-refresh: 0.6.3(solid-js@1.9.11) + vite: 6.4.1(@types/node@24.10.4)(tsx@4.21.0) + vitefu: 1.1.1(vite@6.4.1(@types/node@24.10.4)(tsx@4.21.0)) + optionalDependencies: + '@testing-library/jest-dom': 6.9.1 + transitivePeerDependencies: + - supports-color + vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)): dependencies: '@babel/core': 7.28.5 @@ -5225,6 +5271,10 @@ snapshots: fsevents: 2.3.3 tsx: 4.21.0 + vitefu@1.1.1(vite@6.4.1(@types/node@24.10.4)(tsx@4.21.0)): + optionalDependencies: + vite: 6.4.1(@types/node@24.10.4)(tsx@4.21.0) + vitefu@1.1.1(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)): optionalDependencies: vite: 7.3.0(@types/node@24.10.4)(tsx@4.21.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 692895e..0831e00 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* - - examples + - examples/react + - examples/solid onlyBuiltDependencies: dprint