diff --git a/packages/form/CHANGELOG.md b/packages/form/CHANGELOG.md
index 4a7b8be8..a5664e84 100644
--- a/packages/form/CHANGELOG.md
+++ b/packages/form/CHANGELOG.md
@@ -1,16 +1,16 @@
## 0.9.0 (2024-06-20)
-
### π Features
-- **monorepo:** Upgrade base storm packages ([8b461be](https://github.com/storm-software/cyclone-ui/commit/8b461be))
+- **monorepo:** Upgrade base storm packages
+ ([8b461be](https://github.com/storm-software/cyclone-ui/commit/8b461be))
## 0.8.0 (2024-06-19)
-
### π Features
-- **form:** Added the `useForm` hook to generate an API object ([a499b30](https://github.com/storm-software/cyclone-ui/commit/a499b30))
+- **form:** Added the `useForm` hook to generate an API object
+ ([a499b30](https://github.com/storm-software/cyclone-ui/commit/a499b30))
## 0.7.0 (2024-06-19)
diff --git a/packages/form/README.md b/packages/form/README.md
index fff4ba93..66d1503c 100644
--- a/packages/form/README.md
+++ b/packages/form/README.md
@@ -1,4 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+This package is part of the β‘cyclone-ui monorepo. The cyclone-ui packages include CLI utility applications, tools, and various libraries used to create modern, scalable web applications.
+
+
+π» Visit stormsoftware.com to stay up to date with this developer
+
+[![Version](https://img.shields.io/badge/version-0.9.0-1fb2a6.svg?style=for-the-badge&color=1fb2a6)](https://prettier.io/)
+[![Nx](https://img.shields.io/badge/Nx-17.0.2-lightgrey?style=for-the-badge&logo=nx&logoWidth=20&&color=1fb2a6)](http://nx.dev/) [![NextJs](https://img.shields.io/badge/Next.js-14.0.2-lightgrey?style=for-the-badge&logo=nextdotjs&logoWidth=20&color=1fb2a6)](https://nextjs.org/) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg?style=for-the-badge&logo=commitlint&color=1fb2a6)](http://commitizen.github.io/cz-cli/) ![Semantic-Release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=for-the-badge&color=1fb2a6) [![documented with docusaurus](https://img.shields.io/badge/documented_with-docusaurus-success.svg?style=for-the-badge&logo=readthedocs&color=1fb2a6)](https://docusaurus.io/) ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/storm-software/storm-ops/cr.yml?style=for-the-badge&logo=github-actions&color=1fb2a6)
+
+> [!IMPORTANT]
+> This repository, and the apps, libraries, and tools contained within, is still in it's initial development phase. As a result, bugs and issues are expected with it's usage. When the main development phase completes, a proper release will be performed, the packages will be availible through NPM (and other distributions), and this message will be removed. However, in the meantime, please feel free to report any issues you may come across.
+
+
+
+
+
+
+
# form
@@ -60,4 +89,107 @@ Run `nx test form` to execute the unit tests via [Jest](https://jestjs.io).
Run `nx lint form` to run [ESLint](https://eslint.org/) on the package.
+
+
+
+
+## Storm Workspaces
+
+Storm workspaces are built using Nx, a set of extensible dev tools for monorepos, which helps you develop like Google, Facebook, and Microsoft. Building on top of Nx, the Open System provides a set of tools and patterns that help you scale your monorepo to many teams while keeping the codebase maintainable.
+
+## Roadmap
+
+See the [open issues](https://github.com/storm-software/cyclone-ui/issues) for a list of proposed features (and known issues).
+
+- [Top Feature Requests](https://github.com/storm-software/cyclone-ui/issues?q=label%3Aenhancement+is%3Aopen+sort%3Areactions-%2B1-desc) (Add your votes using the π reaction)
+- [Top Bugs](https://github.com/storm-software/cyclone-ui/issues?q=is%3Aissue+is%3Aopen+label%3Abug+sort%3Areactions-%2B1-desc) (Add your votes using the π reaction)
+- [Newest Bugs](https://github.com/storm-software/cyclone-ui/issues?q=is%3Aopen+is%3Aissue+label%3Abug)
+
+## Support
+
+Reach out to the maintainer at one of the following places:
+
+- [Contact](https://stormsoftware.com/contact)
+- [GitHub discussions](https://github.com/storm-software/cyclone-ui/discussions)
+-
+
+## License
+
+This project is licensed under the **Apache License 2.0**. Feel free to edit and distribute this template as you like.
+
+See [LICENSE](LICENSE) for more information.
+
+## Changelog
+
+This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every release, along with the migration instructions, is documented in the [CHANGELOG](CHANGELOG.md) file
+
+## Contributing
+
+First off, thanks for taking the time to contribute! Contributions are what makes the open-source community such an amazing place to learn, inspire, and create. Any contributions you make will benefit everybody else and are **greatly appreciated**.
+
+Please try to create bug reports that are:
+
+- _Reproducible._ Include steps to reproduce the problem.
+- _Specific._ Include as much detail as possible: which version, what environment, etc.
+- _Unique._ Do not duplicate existing opened issues.
+- _Scoped to a Single Bug._ One bug per report.
+
+Please adhere to this project's [code of conduct](.github/CODE_OF_CONDUCT.md).
+
+You can use [markdownlint-cli](https://github.com/storm-software/cyclone-ui/markdownlint-cli) to check for common markdown style inconsistency.
+
+## Contributors
+
+Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
+
+
+
+
+
+
+
+This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
+
+
+
+
+
+
+
+
+
+
+
+
Fingerprint: 1BD2 7192 7770 2549 F4C9 F238 E6AD C420 DA5C 4C2D
+
+
+Storm Software is an open source software development organization and creator of Acidic, StormStack and StormCloud. Our mission is to make software development more accessible. Our ideal future is one where anyone can create software without years of prior development experience serving as a barrier to entry. We hope to achieve this via LLMs, Generative AI, and intuitive, high-level data modeling/programming languagues.
+
+If this sounds interesting, and you would like to help us in creating the next generation of development tools, please reach out on our website!
+
+π» Visit stormsoftware.com to stay up to date with this developer
+
+
+
+
+
diff --git a/packages/form/package.json b/packages/form/package.json
index b71c2056..18c6be60 100644
--- a/packages/form/package.json
+++ b/packages/form/package.json
@@ -16,20 +16,21 @@
"peerDependencies": {
"react": "19.0.0-rc-fb9a90fa48-20240614",
"react-dom": "19.0.0-rc-fb9a90fa48-20240614",
- "react-native": "0.73.2"
+ "react-native": "0.73.2",
+ "zustand": ">=4.3.9"
},
"dependencies": {
"@tamagui/core": "^1.98.0",
"@tamagui/lucide-icons": "^1.98.0",
"@tamagui/web": "^1.98.0",
- "@tanstack/form-core": "^0.21.0",
- "@tanstack/react-form": "^0.21.0",
+ "decode-formdata": "^0.7.5",
"react-native-svg": "^15.2.0"
},
"devDependencies": {
"react": "19.0.0-rc-fb9a90fa48-20240614",
"react-dom": "19.0.0-rc-fb9a90fa48-20240614",
- "react-native": "0.73.2"
+ "react-native": "0.73.2",
+ "zustand": ">=4.3.9"
},
"publishConfig": {
"access": "public"
diff --git a/packages/form/project.json b/packages/form/project.json
index 270496cd..c903de36 100644
--- a/packages/form/project.json
+++ b/packages/form/project.json
@@ -13,7 +13,7 @@
"tsConfig": "packages/form/tsconfig.json",
"project": "packages/form/package.json",
"defaultConfiguration": "production",
- "external": ["@cyclone-ui/*", "react", "react-native"],
+ "external": ["@cyclone-ui/*", "react", "react-dom", "react-native"],
"assets": [
{
"input": "packages/form",
diff --git a/packages/form/src/create-form-factory.ts b/packages/form/src/create-form-factory.ts
index 7e06c722..f98ed34d 100644
--- a/packages/form/src/create-form-factory.ts
+++ b/packages/form/src/create-form-factory.ts
@@ -4,6 +4,15 @@ import {
FormOptions,
createFormFactory as reactFormCreateFormFactory
} from "@tanstack/react-form";
+import type { FormOptions } from "./form-api";
+import type { Validator } from "./types";
+
+export function getFormOptions<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+>(defaultOpts?: FormOptions) {
+ return defaultOpts;
+}
export const DEFAULT_FORM_OPTIONS = {
asyncDebounceMs: 250
diff --git a/packages/form/src/field-api.ts b/packages/form/src/field-api.ts
new file mode 100644
index 00000000..d40230ed
--- /dev/null
+++ b/packages/form/src/field-api.ts
@@ -0,0 +1,550 @@
+import React, { useState, type FunctionComponent } from "react";
+import type { DeepKeys, DeepValue, NoInfer } from "@cyclone-ui/types";
+import { Store } from "@tanstack/store";
+import type {
+ FieldApiOptions,
+ FieldAsyncValidateOrFn,
+ FieldComponent,
+ FieldState,
+ FieldValidateFn,
+ FieldValidateOrFn,
+ IFieldApi,
+ ValidationCause,
+ ValidationError,
+ ValidationErrorMap,
+ Validator
+} from "./types";
+import { getAsyncValidatorArray, getBy, getSyncValidatorArray } from "./utils";
+import type { AsyncValidator, SyncValidator, Updater } from "./utils";
+
+export class FieldApi<
+ TParentData,
+ TName extends DeepKeys,
+ TFieldValidator extends
+ | Validator, unknown>
+ | undefined = undefined,
+ TFormValidator extends
+ | Validator
+ | undefined = undefined,
+ TData extends DeepValue = DeepValue
+> implements
+ IFieldApi
+{
+ form: FieldApiOptions<
+ TParentData,
+ TName,
+ TFieldValidator,
+ TFormValidator,
+ TData
+ >["form"];
+ name!: DeepKeys;
+ options: FieldApiOptions<
+ TParentData,
+ TName,
+ TFieldValidator,
+ TFormValidator,
+ TData
+ > = {} as any;
+ store!: Store>;
+ state!: FieldState;
+ prevState!: FieldState;
+
+ constructor(
+ opts: FieldApiOptions<
+ TParentData,
+ TName,
+ TFieldValidator,
+ TFormValidator,
+ TData
+ >
+ ) {
+ this.form = opts.form as never;
+ this.name = opts.name as never;
+
+ if (opts.defaultValue !== undefined) {
+ this.form.setFieldValue(this.name, opts.defaultValue as never);
+ }
+
+ this.store = new Store>(
+ {
+ value: this.getValue(),
+
+ meta: this._getMeta() ?? {
+ isValidating: false,
+ isTouched: false,
+ isDirty: false,
+ isPristine: true,
+ touchedErrors: [],
+ errors: [],
+ errorMap: {},
+ ...opts.defaultMeta
+ }
+ },
+ {
+ onUpdate: () => {
+ const state = this.store.state;
+
+ state.meta.errors = Object.values(state.meta.errorMap).filter(
+ (val: unknown) => val !== undefined
+ );
+
+ state.meta.touchedErrors = state.meta.isTouched
+ ? state.meta.errors
+ : [];
+
+ state.meta.isPristine = !state.meta.isDirty;
+
+ this.prevState = state;
+ this.state = state;
+ }
+ }
+ );
+
+ this.state = this.store.state;
+ this.prevState = this.state;
+ this.options = opts as never;
+ }
+
+ Field!: FieldComponent;
+
+ runValidator<
+ TValue extends { value: TData; fieldApi: FieldApi },
+ TType extends "validate" | "validateAsync"
+ >(props: {
+ validate: TType extends "validate"
+ ? FieldValidateOrFn
+ : FieldAsyncValidateOrFn;
+ value: TValue;
+ type: TType;
+ }): ReturnType>[TType]> {
+ const adapters = [
+ this.form.options.validatorAdapter,
+ this.options.validatorAdapter
+ ] as const;
+ for (const adapter of adapters) {
+ if (adapter && typeof props.validate !== "function") {
+ return adapter()[props.type](
+ props.value as never,
+ props.validate
+ ) as never;
+ }
+ }
+
+ return (props.validate as FieldValidateFn)(props.value) as never;
+ }
+
+ mount = () => {
+ const info = this.getInfo();
+ info.instance = this as never;
+ const unsubscribe = this.form.store.subscribe(() => {
+ this.store.batch(() => {
+ const nextValue = this.getValue();
+ const nextMeta = this.getMeta();
+
+ if (nextValue !== this.state.value) {
+ this.store.setState(prev => ({ ...prev, value: nextValue }));
+ }
+
+ if (nextMeta !== this.state.meta) {
+ this.store.setState(prev => ({ ...prev, meta: nextMeta }));
+ }
+ });
+ });
+
+ this.update(this.options as never);
+ const { onMount } = this.options.validators || {};
+
+ if (onMount) {
+ const error = this.runValidator({
+ validate: onMount,
+ value: {
+ value: this.state.value,
+ fieldApi: this
+ },
+ type: "validate"
+ });
+ if (error) {
+ this.setMeta(prev => ({
+ ...prev,
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ errorMap: { ...prev?.errorMap, onMount: error }
+ }));
+ }
+ }
+
+ return () => {
+ const preserveValue = this.options.preserveValue;
+ unsubscribe();
+ if (!preserveValue) {
+ this.form.deleteField(this.name);
+ }
+ };
+ };
+
+ update = (
+ opts: FieldApiOptions<
+ TParentData,
+ TName,
+ TFieldValidator,
+ TFormValidator,
+ TData
+ >
+ ) => {
+ // Default Value
+
+ if (this.state.value === undefined) {
+ const formDefault = getBy(opts.form.options.defaultValues, opts.name);
+
+ if (opts.defaultValue !== undefined) {
+ this.setValue(opts.defaultValue as never);
+ } else if (formDefault !== undefined) {
+ this.setValue(formDefault as never);
+ }
+ }
+
+ // Default Meta
+ if (this._getMeta() === undefined) {
+ this.setMeta(this.state.meta);
+ }
+
+ this.options = opts as never;
+ };
+
+ getValue = (): TData => {
+ return this.form.getFieldValue(this.name) as TData;
+ };
+
+ setValue = (
+ updater: Updater,
+ options?: { touch?: boolean; notify?: boolean }
+ ) => {
+ this.form.setFieldValue(this.name, updater as never, options);
+ this.validate("change");
+ };
+
+ _getMeta = () => this.form.getFieldMeta(this.name);
+ getMeta = () =>
+ this._getMeta() ??
+ ({
+ isValidating: false,
+ isTouched: false,
+ isDirty: false,
+ isPristine: true,
+ touchedErrors: [],
+ errors: [],
+ errorMap: {},
+ ...this.options.defaultMeta
+ } as FieldMeta);
+
+ setMeta = (updater: Updater) =>
+ this.form.setFieldMeta(this.name, updater);
+
+ getInfo = () => this.form.getFieldInfo(this.name);
+
+ pushValue = (
+ value: TData extends any[] ? TData[number] : never,
+ opts?: { touch?: boolean }
+ ) => this.form.pushFieldValue(this.name, value as any, opts);
+
+ insertValue = (
+ index: number,
+ value: TData extends any[] ? TData[number] : never,
+ opts?: { touch?: boolean }
+ ) => this.form.insertFieldValue(this.name, index, value as any, opts);
+
+ replaceValue = (
+ index: number,
+ value: TData extends any[] ? TData[number] : never,
+ opts?: { touch?: boolean }
+ ) => this.form.replaceFieldValue(this.name, index, value as any, opts);
+
+ removeValue = (index: number, opts?: { touch: boolean }) =>
+ this.form.removeFieldValue(this.name, index, opts);
+
+ swapValues = (aIndex: number, bIndex: number, opts?: { touch?: boolean }) =>
+ this.form.swapFieldValues(this.name, aIndex, bIndex, opts);
+
+ moveValue = (aIndex: number, bIndex: number, opts?: { touch?: boolean }) =>
+ this.form.moveFieldValues(this.name, aIndex, bIndex, opts);
+
+ getLinkedFields = (cause: ValidationCause) => {
+ const fields = Object.values(this.form.fieldInfo) as FieldInfo<
+ any,
+ TFormValidator
+ >[];
+
+ const linkedFields: FieldApi[] = [];
+ for (const field of fields) {
+ if (!field.instance) continue;
+ const { onChangeListenTo, onBlurListenTo } =
+ field.instance.options.validators || {};
+ if (
+ cause === "change" &&
+ onChangeListenTo?.includes(this.name as string)
+ ) {
+ linkedFields.push(field.instance);
+ }
+ if (cause === "blur" && onBlurListenTo?.includes(this.name as string)) {
+ linkedFields.push(field.instance);
+ }
+ }
+
+ return linkedFields;
+ };
+
+ validateSync = (cause: ValidationCause) => {
+ const validates = getSyncValidatorArray(cause, this.options);
+
+ const linkedFields = this.getLinkedFields(cause);
+ const linkedFieldValidates = linkedFields.reduce(
+ (acc, field) => {
+ const fieldValidates = getSyncValidatorArray(cause, field.options);
+ fieldValidates.forEach(validate => {
+ (validate as any).field = field;
+ });
+ return acc.concat(fieldValidates as never);
+ },
+ [] as Array & { field: FieldApi }>
+ );
+
+ // Needs type cast as eslint errantly believes this is always falsy
+ let hasErrored = false as boolean;
+
+ this.form.store.batch(() => {
+ const validateFieldFn = (
+ field: FieldApi,
+ validateObj: SyncValidator
+ ) => {
+ const error = normalizeError(
+ field.runValidator({
+ validate: validateObj.validate,
+ value: { value: field.getValue(), fieldApi: field },
+ type: "validate"
+ })
+ );
+ const errorMapKey = getErrorMapKey(validateObj.cause);
+ if (field.state.meta.errorMap[errorMapKey] !== error) {
+ field.setMeta(prev => ({
+ ...prev,
+ errorMap: {
+ ...prev.errorMap,
+ [getErrorMapKey(validateObj.cause)]: error
+ }
+ }));
+ }
+ if (error) {
+ hasErrored = true;
+ }
+ };
+
+ for (const validateObj of validates) {
+ if (!validateObj.validate) continue;
+ validateFieldFn(this, validateObj);
+ }
+ for (const fieldValitateObj of linkedFieldValidates) {
+ if (!fieldValitateObj.validate) continue;
+ validateFieldFn(fieldValitateObj.field, fieldValitateObj);
+ }
+ });
+
+ /**
+ * when we have an error for onSubmit in the state, we want
+ * to clear the error as soon as the user enters a valid value in the field
+ */
+ const submitErrKey = getErrorMapKey("submit");
+ if (
+ this.state.meta.errorMap[submitErrKey] &&
+ cause !== "submit" &&
+ !hasErrored
+ ) {
+ this.setMeta(prev => ({
+ ...prev,
+ errorMap: {
+ ...prev.errorMap,
+ [submitErrKey]: undefined
+ }
+ }));
+ }
+
+ return { hasErrored };
+ };
+
+ validateAsync = async (cause: ValidationCause) => {
+ const validates = getAsyncValidatorArray(cause, this.options);
+
+ const linkedFields = this.getLinkedFields(cause);
+ const linkedFieldValidates = linkedFields.reduce(
+ (acc, field) => {
+ const fieldValidates = getAsyncValidatorArray(cause, field.options);
+ fieldValidates.forEach(validate => {
+ (validate as any).field = field;
+ });
+ return acc.concat(fieldValidates as never);
+ },
+ [] as Array & { field: FieldApi }>
+ );
+
+ if (!this.state.meta.isValidating) {
+ this.setMeta(prev => ({ ...prev, isValidating: true }));
+ }
+
+ for (const linkedField of linkedFields) {
+ linkedField.setMeta(prev => ({ ...prev, isValidating: true }));
+ }
+
+ /**
+ * We have to use a for loop and generate our promises this way, otherwise it won't be sync
+ * when there are no validators needed to be run
+ */
+ const validatesPromises: Promise[] = [];
+ const linkedPromises: Promise[] = [];
+
+ const validateFieldAsyncFn = (
+ field: FieldApi,
+ validateObj: AsyncValidator,
+ promises: Promise[]
+ ) => {
+ const key = getErrorMapKey(validateObj.cause);
+ const fieldValidatorMeta = field.getInfo().validationMetaMap[key];
+
+ fieldValidatorMeta?.lastAbortController.abort();
+ const controller = new AbortController();
+
+ this.getInfo().validationMetaMap[key] = {
+ lastAbortController: controller
+ };
+
+ promises.push(
+ new Promise(async resolve => {
+ let rawError!: ValidationError | undefined;
+ try {
+ rawError = await new Promise((rawResolve, rawReject) => {
+ setTimeout(async () => {
+ if (controller.signal.aborted) return rawResolve(undefined);
+ try {
+ rawResolve(
+ await this.runValidator({
+ validate: validateObj.validate,
+ value: {
+ value: field.getValue(),
+ fieldApi: field,
+ signal: controller.signal
+ },
+ type: "validateAsync"
+ })
+ );
+ } catch (e) {
+ rawReject(e);
+ }
+ }, validateObj.debounceMs);
+ });
+ } catch (e: unknown) {
+ rawError = e as ValidationError;
+ }
+ const error = normalizeError(rawError);
+ field.setMeta(prev => {
+ return {
+ ...prev,
+ errorMap: {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ ...prev?.errorMap,
+ [getErrorMapKey(cause)]: error
+ }
+ };
+ });
+
+ resolve(error);
+ })
+ );
+ };
+
+ // TODO: Dedupe this logic to reduce bundle size
+ for (const validateObj of validates) {
+ if (!validateObj.validate) continue;
+ validateFieldAsyncFn(this, validateObj, validatesPromises);
+ }
+ for (const fieldValitateObj of linkedFieldValidates) {
+ if (!fieldValitateObj.validate) continue;
+ validateFieldAsyncFn(
+ fieldValitateObj.field,
+ fieldValitateObj,
+ linkedPromises
+ );
+ }
+
+ let results: ValidationError[] = [];
+ if (validatesPromises.length || linkedPromises.length) {
+ results = await Promise.all(validatesPromises);
+ await Promise.all(linkedPromises);
+ }
+
+ this.setMeta(prev => ({ ...prev, isValidating: false }));
+
+ for (const linkedField of linkedFields) {
+ linkedField.setMeta(prev => ({ ...prev, isValidating: false }));
+ }
+
+ return results.filter(Boolean);
+ };
+
+ validate = (
+ cause: ValidationCause
+ ): ValidationError[] | Promise => {
+ // If the field is pristine and validatePristine is false, do not validate
+ if (!this.state.meta.isTouched) return [];
+
+ try {
+ this.form.validate(cause);
+ } catch (_) {}
+
+ // Attempt to sync validate first
+ const { hasErrored } = this.validateSync(cause);
+
+ if (hasErrored && !this.options.asyncAlways) {
+ return this.state.meta.errors;
+ }
+ // No error? Attempt async validation
+ return this.validateAsync(cause);
+ };
+
+ handleChange = (updater: Updater) => {
+ this.setValue(updater, { touch: true });
+ };
+
+ handleBlur = () => {
+ const prevTouched = this.state.meta.isTouched;
+ if (!prevTouched) {
+ this.setMeta(prev => ({ ...prev, isTouched: true }));
+ this.validate("change");
+ }
+ this.validate("blur");
+ };
+}
+
+function normalizeError(rawError?: ValidationError) {
+ if (rawError) {
+ if (typeof rawError !== "string") {
+ return "Invalid Form Values";
+ }
+
+ return rawError;
+ }
+
+ return undefined;
+}
+
+function getErrorMapKey(cause: ValidationCause) {
+ switch (cause) {
+ case "submit":
+ return "onSubmit";
+ case "blur":
+ return "onBlur";
+ case "mount":
+ return "onMount";
+ case "server":
+ return "onServer";
+ case "change":
+ default:
+ return "onChange";
+ }
+}
diff --git a/packages/form/src/form-api.ts b/packages/form/src/form-api.ts
new file mode 100644
index 00000000..147d9cbd
--- /dev/null
+++ b/packages/form/src/form-api.ts
@@ -0,0 +1,1366 @@
+import { ReactNode } from "react";
+import {
+ createStore,
+ getBy,
+ setBy,
+ SetRecordParam,
+ State,
+ type GetRecord,
+ type SetRecord,
+ type StoreApi
+} from "@cyclone-ui/store";
+import type { DeepKeys, DeepValue, NoInfer } from "@cyclone-ui/types";
+import type {
+ FieldComponent,
+ FieldInfo,
+ FieldMeta,
+ FieldState,
+ FormAsyncValidateOrFn,
+ FormOptions,
+ FormState,
+ FormValidateFn,
+ FormValidateOrFn,
+ FormValidators,
+ IFormApi,
+ NodeType,
+ UseField,
+ ValidationCause,
+ ValidationError,
+ ValidationErrorMap,
+ ValidationErrorMapKeys,
+ Validator
+} from "./types";
+import {
+ getAsyncValidatorArray,
+ getSyncValidatorArray,
+ isNonEmptyArray
+} from "./utils";
+
+function getDefaultFormState<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+>(
+ defaultState: Partial>
+): Omit, "options" | "name"> {
+ return {
+ values: defaultState.values ?? ({} as never),
+ errors: defaultState.errors ?? [],
+ errorMap: defaultState.errorMap ?? {},
+ fieldMeta: defaultState.fieldMeta ?? ({} as never),
+ _fieldInfo: {} as Record<
+ DeepKeys,
+ FieldInfo
+ >,
+ canSubmit: defaultState.canSubmit ?? true,
+ isFieldsValid: defaultState.isFieldsValid ?? false,
+ isFieldsValidating: defaultState.isFieldsValidating ?? false,
+ isFormValid: defaultState.isFormValid ?? false,
+ isFormValidating: defaultState.isFormValidating ?? false,
+ isSubmitted: defaultState.isSubmitted ?? false,
+ isSubmitting: defaultState.isSubmitting ?? false,
+ isTouched: defaultState.isTouched ?? false,
+ isPristine: defaultState.isPristine ?? true,
+ isDirty: defaultState.isDirty ?? false,
+ isValid: defaultState.isValid ?? false,
+ isValidating: defaultState.isValidating ?? false,
+ submissionAttempts: defaultState.submissionAttempts ?? 0,
+ validationMetaMap: defaultState.validationMetaMap ?? {
+ onChange: undefined,
+ onBlur: undefined,
+ onSubmit: undefined,
+ onMount: undefined,
+ onServer: undefined
+ }
+ };
+}
+
+export const createFormStore = <
+ TFormData extends State,
+ TFormValidator extends Validator | undefined = undefined
+>(
+ name: string,
+ options: FormOptions = {}
+) => {
+ return createStore(name)>({
+ ...getDefaultFormState({
+ ...(options?.defaultState as any),
+ values: options?.defaultValues ?? options?.defaultState?.values,
+ isFormValid: true
+ }),
+ name,
+ options
+ })
+ .extendSelectors((state, get, api) => ({
+ isFieldsValidating: () => {
+ return (
+ Object.values(get.fieldMeta()) as (FieldMeta | undefined)[]
+ ).some(field => field?.isValidating);
+ },
+ isFieldsValid: () => {
+ return (
+ Object.values(get.fieldMeta()) as (FieldMeta | undefined)[]
+ ).some(
+ field =>
+ field?.errorMap &&
+ isNonEmptyArray(Object.values(field.errorMap).filter(Boolean))
+ );
+ },
+ isTouched: () => {
+ return (
+ Object.values(get.fieldMeta()) as (FieldMeta | undefined)[]
+ ).some(field => field?.isTouched);
+ },
+ isDirty: () => {
+ return (
+ Object.values(get.fieldMeta()) as (FieldMeta | undefined)[]
+ ).some(field => field?.isDirty);
+ },
+ isPristine: () => {
+ return !get.isDirty();
+ },
+ isValidating: () => {
+ return get.isFieldsValidating() || get.isFormValidating();
+ },
+ isFormValid: () => {
+ return get.errors().length === 0;
+ },
+ isValid: () => {
+ return get.isFieldsValid() && get.isFormValid();
+ },
+ canSubmit: () => {
+ return (
+ (get.submissionAttempts() === 0 && !get.isTouched()) ||
+ (!get.isValidating() && !get.isSubmitting() && get.isValid())
+ );
+ },
+ // errors: () => {
+ // return Object.values(state.errorMap).filter(
+ // (val: unknown) => val !== undefined
+ // );
+ // },
+ // fieldValue: >(
+ // field: TField
+ // ): DeepValue => getBy(get.values(), field),
+ // fieldMeta: >(
+ // field: TField
+ // ): FieldMeta | undefined => get.fieldMeta()[field],
+ fieldInfo: >(
+ field: TField
+ ): FieldInfo => {
+ let info = get._fieldInfo(field);
+ return (info ?? {
+ instance: null,
+ validationMetaMap: {
+ onChange: undefined,
+ onBlur: undefined,
+ onSubmit: undefined,
+ onMount: undefined,
+ onServer: undefined
+ }
+ }) as FieldInfo;
+ }
+ }))
+ .extendActions((set, get, api) => ({
+ _runValidator: <
+ TValue extends {
+ value: TFormData;
+ api: StoreApi>;
+ },
+ TType extends "validate" | "validateAsync"
+ >(props: {
+ validate: TType extends "validate"
+ ? FormValidateOrFn
+ : FormAsyncValidateOrFn;
+ value: TValue;
+ type: TType;
+ }): ReturnType>[TType]> => {
+ const adapter = get.options().validatorAdapter as TFormValidator;
+ if (adapter && typeof props.validate !== "function") {
+ return adapter()[props.type](props.value, props.validate) as never;
+ }
+
+ return (props.validate as FormValidateFn)(
+ props.value
+ ) as never;
+ },
+ update: (options?: FormOptions) => {
+ if (!options) {
+ return;
+ }
+
+ const oldOptions = get.options();
+
+ // Options need to be updated first so that when the store is updated, the state is correct for the derived state
+ set.options(options as any);
+ set.state(prev =>
+ getDefaultFormState(
+ Object.assign(
+ {},
+ prev,
+ options.defaultState !== oldOptions.defaultState &&
+ !get.isTouched()
+ ? options.defaultState
+ : {},
+ options.defaultValues &&
+ options.defaultValues !== oldOptions.defaultValues &&
+ !get.isTouched()
+ ? { values: options.defaultValues }
+ : {}
+ )
+ )
+ );
+
+ set.mergeState(
+ getDefaultFormState({
+ ...get.state(),
+ values:
+ get.options().defaultValues ?? get.options().defaultState?.values
+ })
+ );
+ },
+ resetFieldMeta: >(
+ fieldMeta: Record
+ ): Record => {
+ return Object.keys(fieldMeta).reduce(
+ (acc: Record, key) => {
+ const fieldKey = key as TField;
+ acc[fieldKey] = {
+ isValidating: false,
+ isTouched: false,
+ isDirty: false,
+ isPristine: true,
+ touchedErrors: [],
+ errors: [],
+ errorMap: {}
+ };
+ return acc;
+ },
+ {} as Record
+ );
+ },
+ validateAllFields: async (cause: ValidationCause) => {
+ const fieldValidationPromises: Promise[] = [] as any;
+
+ void (
+ Object.values(get._fieldInfo()) as FieldInfo[]
+ ).forEach(field => {
+ if (!field.instance) {
+ return;
+ }
+
+ const fieldInstance = field.instance;
+ // Validate the field
+ fieldValidationPromises.push(
+ Promise.resolve().then(() => fieldInstance.validate(cause))
+ );
+
+ // If any fields are not touched
+ if (!field.instance.state.meta.isTouched) {
+ // Mark them as touched
+ field.instance.setMeta(prev => ({ ...prev, isTouched: true }));
+ }
+ });
+
+ const fieldErrorMapMap = await Promise.all(fieldValidationPromises);
+ return fieldErrorMapMap.flat();
+ },
+ validateField: >(
+ field: TField,
+ cause: ValidationCause
+ ) => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const fieldInstance = get.fieldInfo(field)?.instance;
+ if (!fieldInstance) {
+ return [];
+ }
+
+ // If the field is not touched (same logic as in validateAllFields)
+ if (!fieldInstance.state.meta.isTouched) {
+ // Mark it as touched
+ fieldInstance.setMeta(prev => ({ ...prev, isTouched: true }));
+ }
+
+ return fieldInstance.validate(cause);
+ }
+ }))
+ .extendActions((set, get, api) => ({
+ mount: () => {
+ const { onMount } = (get.options().validators || {}) as FormValidators<
+ TFormData,
+ TFormValidator
+ >;
+ if (!onMount) {
+ return;
+ }
+
+ const error = set._runValidator({
+ validate: onMount,
+ value: {
+ value: get.values(),
+ api
+ },
+ type: "validate"
+ });
+ if (error) {
+ set.errorMap({ ...get.errorMap(), onMount: error });
+ }
+ },
+ reset: () => {
+ const fields = set.resetFieldMeta(get.fieldMeta());
+ set.mergeState(
+ getDefaultFormState({
+ ...get.state(),
+ values:
+ get.options().defaultValues ?? get.options().defaultState?.values,
+ fieldMeta: fields
+ })
+ );
+ },
+ validateArrayFieldsStartingFrom: async <
+ TField extends DeepKeys
+ >(
+ field: TField,
+ index: number,
+ cause: ValidationCause
+ ) => {
+ const currentValue = get.values(field);
+ const lastIndex = Array.isArray(currentValue)
+ ? Math.max(currentValue.length - 1, 0)
+ : null;
+
+ // We have to validate all fields that have shifted (at least the current field)
+ const fieldKeysToValidate = [`${field}[${index}]`];
+ for (let i = index + 1; i <= (lastIndex ?? 0); i++) {
+ fieldKeysToValidate.push(`${field}[${i}]`);
+ }
+
+ // We also have to include all fields that are nested in the shifted fields
+ const fieldsToValidate = Object.keys(get._fieldInfo()).filter(
+ fieldKey => fieldKeysToValidate.some(key => fieldKey.startsWith(key))
+ ) as DeepKeys[];
+
+ // Validate the fields
+ const fieldValidationPromises: Promise[] = [] as any;
+ fieldsToValidate.forEach(nestedField => {
+ fieldValidationPromises.push(
+ Promise.resolve().then(() => set.validateField(nestedField, cause))
+ );
+ });
+
+ const fieldErrorMapMap = await Promise.all(fieldValidationPromises);
+ return fieldErrorMapMap.flat();
+ },
+
+ values: >(
+ name: TField,
+ value: SetRecordParam,
+ opts?: { touch?: boolean }
+ ) => {
+ const touch = opts?.touch;
+ if (touch) {
+ set.fieldMeta(prev => {
+ prev[name].isTouched = true;
+ prev[name].isDirty = true;
+
+ return prev;
+ });
+ }
+
+ set.values(prev => setBy(prev, name, value));
+ },
+
+ deleteField: >(field: TField) => {
+ if (touch) {
+ set.fieldMeta(prev => {
+ prev[name].isTouched = true;
+ prev[name].isDirty = true;
+
+ return prev;
+ });
+ }
+
+ api.remove.values(field);
+
+ set.values(prev => deleteBy(prev, field));
+ set.fieldMeta(prev => ({
+ ...prev,
+ [field]: undefined
+ }));
+
+ set._fieldInfo(prev => ({
+ ...prev,
+ [field]: undefined
+ }));
+ },
+
+ pushFieldValue: >(
+ field: TField,
+ value: DeepValue extends any[]
+ ? DeepValue[number]
+ : never,
+ opts?: { touch?: boolean }
+ ) => {
+ set.values(
+ field,
+ prev => [...(Array.isArray(prev) ? prev : []), value] as any,
+ opts
+ );
+ this.validateField(field, "change");
+ },
+
+ insertFieldValue: async >(
+ field: TField,
+ index: number,
+ value: DeepValue extends any[]
+ ? DeepValue[number]
+ : never,
+ opts?: { touch?: boolean }
+ ) => {
+ this.setFieldValue(
+ field,
+ prev => {
+ return [
+ ...(prev as DeepValue[]).slice(0, index),
+ value,
+ ...(prev as DeepValue[]).slice(index)
+ ] as any;
+ },
+ opts
+ );
+
+ // Validate the whole array + all fields that have shifted
+ await this.validateField(field, "change");
+ }
+ }));
+ // .extendActions((set, get, api) => ({
+
+ // }));
+ // })).extendActions((set, get, api) => ({
+ // deleteField: >(field: TField) => {
+ // set.values(prev => deleteBy(prev, field));
+ // set.fieldMeta(prev => ({
+ // ...prev,
+ // [field]: undefined
+ // }));
+
+ // set._fieldInfo(prev => ({
+ // ...prev,
+ // [field]: undefined
+ // }));
+ // },
+
+ // pushFieldValue: >(
+ // field: TField,
+ // value: DeepValue extends any[]
+ // ? DeepValue[number]
+ // : never,
+ // opts?: { touch?: boolean }
+ // ) => {
+ // set.values(
+ // field,
+ // prev => [...(Array.isArray(prev) ? prev : []), value] as any,
+ // opts
+ // );
+ // this.validateField(field, "change");
+ // },
+
+ // insertFieldValue: async >(
+ // field: TField,
+ // index: number,
+ // value: DeepValue extends any[]
+ // ? DeepValue[number]
+ // : never,
+ // opts?: { touch?: boolean }
+ // ) => {
+ // this.setFieldValue(
+ // field,
+ // prev => {
+ // return [
+ // ...(prev as DeepValue[]).slice(0, index),
+ // value,
+ // ...(prev as DeepValue[]).slice(index)
+ // ] as any;
+ // },
+ // opts
+ // );
+
+ // // Validate the whole array + all fields that have shifted
+ // await this.validateField(field, "change");
+ // },
+
+ // replaceFieldValue: async >(
+ // field: TField,
+ // index: number,
+ // value: DeepValue extends any[]
+ // ? DeepValue[number]
+ // : never,
+ // opts?: { touch?: boolean }
+ // ) => {
+ // this.setFieldValue(
+ // field,
+ // prev => {
+ // return (prev as DeepValue[]).map((d, i) =>
+ // i === index ? value : d
+ // ) as any;
+ // },
+ // opts
+ // );
+
+ // // Validate the whole array + all fields that have shifted
+ // await this.validateField(field, "change");
+ // await this.validateArrayFieldsStartingFrom(field, index, "change");
+ // },
+
+ // removeFieldValue: async >(
+ // field: TField,
+ // index: number,
+ // opts?: { touch?: boolean }
+ // ) => {
+ // const fieldValue = this.getFieldValue(field);
+
+ // const lastIndex = Array.isArray(fieldValue)
+ // ? Math.max(fieldValue.length - 1, 0)
+ // : null;
+
+ // this.setFieldValue(
+ // field,
+ // prev => {
+ // return (prev as DeepValue[]).filter(
+ // (_d, i) => i !== index
+ // ) as any;
+ // },
+ // opts
+ // );
+
+ // if (lastIndex !== null) {
+ // const start = `${field}[${lastIndex}]`;
+ // const fieldsToDelete = Object.keys(this.fieldInfo).filter(f =>
+ // f.startsWith(start)
+ // );
+
+ // // Cleanup the last fields
+ // fieldsToDelete.forEach(f => this.deleteField(f as TField));
+ // }
+
+ // // Validate the whole array + all fields that have shifted
+ // await this.validateField(field, "change");
+ // await this.validateArrayFieldsStartingFrom(field, index, "change");
+ // },
+
+ // swapFieldValues: >(
+ // field: TField,
+ // index1: number,
+ // index2: number,
+ // opts?: { touch?: boolean }
+ // ) => {
+ // this.setFieldValue(
+ // field,
+ // (prev: any) => {
+ // const prev1 = prev[index1]!;
+ // const prev2 = prev[index2]!;
+ // return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1);
+ // },
+ // opts
+ // );
+
+ // // Validate the whole array
+ // this.validateField(field, "change");
+ // // Validate the swapped fields
+ // this.validateField(
+ // `${field}[${index1}]` as DeepKeys,
+ // "change"
+ // );
+ // this.validateField(
+ // `${field}[${index2}]` as DeepKeys,
+ // "change"
+ // );
+ // },
+
+ // moveFieldValues: >(
+ // field: TField,
+ // index1: number,
+ // index2: number,
+ // opts?: { touch?: boolean }
+ // ) => {
+ // this.setFieldValue(
+ // field,
+ // (prev: any) => {
+ // prev.splice(index2, 0, prev.splice(index1, 1)[0]);
+ // return prev;
+ // },
+ // opts
+ // );
+
+ // // Validate the whole array
+ // this.validateField(field, "change");
+ // // Validate the moved fields
+ // this.validateField(
+ // `${field}[${index1}]` as DeepKeys,
+ // "change"
+ // );
+ // this.validateField(
+ // `${field}[${index2}]` as DeepKeys,
+ // "change"
+ // );
+ // }
+ // }));
+};
+
+// export class FormApi<
+// TFormData,
+// TFormValidator extends Validator | undefined = undefined
+// > implements IFormApi
+// {
+// public options: FormOptions = {};
+// public store!: StoreApi>;
+// // Do not use __state directly, as it is not reactive.
+// // Please use form.useStore() utility to subscribe to state
+// public state!: FormState;
+// // // This carries the context for nested fields
+// public fieldInfo: Record<
+// DeepKeys,
+// FieldInfo
+// > = {} as any;
+
+// public prevTransformArray: unknown[] = [];
+
+// public constructor(opts?: FormOptions) {
+// this.store = new Store>(
+// getDefaultFormState({
+// ...(opts?.defaultState as any),
+// values: opts?.defaultValues ?? opts?.defaultState?.values,
+// isFormValid: true
+// }),
+// {
+// onUpdate: () => {
+// let { state } = this.store;
+// // Computed state
+// const fieldMetaValues = Object.values(state.fieldMeta) as (
+// | FieldMeta
+// | undefined
+// )[];
+
+// const isFieldsValidating = fieldMetaValues.some(
+// field => field?.isValidating
+// );
+
+// const isFieldsValid = !fieldMetaValues.some(
+// field =>
+// field?.errorMap &&
+// isNonEmptyArray(Object.values(field.errorMap).filter(Boolean))
+// );
+
+// const isTouched = fieldMetaValues.some(field => field?.isTouched);
+
+// const isDirty = fieldMetaValues.some(field => field?.isDirty);
+// const isPristine = !isDirty;
+
+// const isValidating = isFieldsValidating || state.isFormValidating;
+// state.errors = Object.values(state.errorMap).filter(
+// (val: unknown) => val !== undefined
+// );
+// const isFormValid = state.errors.length === 0;
+// const isValid = isFieldsValid && isFormValid;
+// const canSubmit =
+// (state.submissionAttempts === 0 && !isTouched) ||
+// (!isValidating && !state.isSubmitting && isValid);
+
+// state = {
+// ...state,
+// isFieldsValidating,
+// isFieldsValid,
+// isFormValid,
+// isValid,
+// canSubmit,
+// isTouched,
+// isPristine,
+// isDirty
+// };
+
+// this.state = state;
+// this.store.state = this.state;
+
+// // Only run transform if state has shallowly changed - IE how React.useEffect works
+// const transformArray = this.options.transform?.deps ?? [];
+// const shouldTransform =
+// transformArray.length !== this.prevTransformArray.length ||
+// transformArray.some((val, i) => val !== this.prevTransformArray[i]);
+
+// if (shouldTransform) {
+// // This mutates the state
+// this.options.transform?.fn(this);
+// this.store.state = this.state;
+// this.prevTransformArray = transformArray;
+// }
+// }
+// }
+// );
+
+// this.state = this.store.state;
+// this.update(opts || {});
+// }
+
+// public Field!: FieldComponent;
+// public useField!: UseField;
+// public useStore!: >(
+// selector?: ((state: FormState) => TSelected) | undefined
+// ) => TSelected;
+// public Subscribe!: >(props: {
+// selector?: ((state: FormState) => TSelected) | undefined;
+// children: ((state: NoInfer) => NodeType) | NodeType;
+// }) => ReactNode;
+
+// public runValidator = <
+// TValue extends { value: TFormData; formApi: FormApi },
+// TType extends "validate" | "validateAsync"
+// >(props: {
+// validate: TType extends "validate"
+// ? FormValidateOrFn
+// : FormAsyncValidateOrFn;
+// value: TValue;
+// type: TType;
+// }): ReturnType>[TType]> => {
+// const adapter = this.options.validatorAdapter;
+// if (adapter && typeof props.validate !== "function") {
+// return adapter()[props.type](props.value, props.validate) as never;
+// }
+
+// return (props.validate as FormValidateFn)(props.value) as never;
+// };
+
+// public mount = () => {
+// const { onMount } = this.options.validators || {};
+// if (!onMount) return;
+// const error = this.runValidator({
+// validate: onMount,
+// value: {
+// value: this.state.values,
+// formApi: this
+// },
+// type: "validate"
+// });
+// if (error) {
+// this.store.setState(prev => ({
+// ...prev,
+// errorMap: { ...prev.errorMap, onMount: error }
+// }));
+// }
+// };
+
+// public update = (options?: FormOptions) => {
+// if (!options) return;
+
+// const oldOptions = this.options;
+
+// // Options need to be updated first so that when the store is updated, the state is correct for the derived state
+// this.options = options;
+
+// this.store.batch(() => {
+// const shouldUpdateValues =
+// options.defaultValues &&
+// options.defaultValues !== oldOptions.defaultValues &&
+// !this.state.isTouched;
+
+// const shouldUpdateState =
+// options.defaultState !== oldOptions.defaultState &&
+// !this.state.isTouched;
+
+// this.store.setState(() =>
+// getDefaultFormState(
+// Object.assign(
+// {},
+// this.state as any,
+
+// shouldUpdateState ? options.defaultState : {},
+
+// shouldUpdateValues
+// ? {
+// values: options.defaultValues
+// }
+// : {}
+// )
+// )
+// );
+// });
+// };
+
+// public reset = () => {
+// const { fieldMeta: currentFieldMeta } = this.state;
+// const fieldMeta = this.resetFieldMeta(currentFieldMeta);
+// this.store.setState(() =>
+// getDefaultFormState({
+// ...(this.options.defaultState as any),
+// values: this.options.defaultValues ?? this.options.defaultState?.values,
+// fieldMeta
+// })
+// );
+// };
+
+// public validateAllFields = async (cause: ValidationCause) => {
+// const fieldValidationPromises: Promise[] = [] as any;
+// this.store.batch(() => {
+// void (
+// Object.values(this.fieldInfo) as FieldInfo[]
+// ).forEach(field => {
+// if (!field.instance) {
+// return;
+// }
+
+// const fieldInstance = field.instance;
+// // Validate the field
+// fieldValidationPromises.push(
+// Promise.resolve().then(() => fieldInstance.validate(cause))
+// );
+// // If any fields are not touched
+// if (!field.instance.state.meta.isTouched) {
+// // Mark them as touched
+// field.instance.setMeta(prev => ({ ...prev, isTouched: true }));
+// }
+// });
+// });
+
+// const fieldErrorMapMap = await Promise.all(fieldValidationPromises);
+// return fieldErrorMapMap.flat();
+// };
+
+// public validateArrayFieldsStartingFrom = async <
+// TField extends DeepKeys
+// >(
+// field: TField,
+// index: number,
+// cause: ValidationCause
+// ) => {
+// const currentValue = this.getFieldValue(field);
+
+// const lastIndex = Array.isArray(currentValue)
+// ? Math.max(currentValue.length - 1, 0)
+// : null;
+
+// // We have to validate all fields that have shifted (at least the current field)
+// const fieldKeysToValidate = [`${field}[${index}]`];
+// for (let i = index + 1; i <= (lastIndex ?? 0); i++) {
+// fieldKeysToValidate.push(`${field}[${i}]`);
+// }
+
+// // We also have to include all fields that are nested in the shifted fields
+// const fieldsToValidate = Object.keys(this.fieldInfo).filter(fieldKey =>
+// fieldKeysToValidate.some(key => fieldKey.startsWith(key))
+// ) as DeepKeys[];
+
+// // Validate the fields
+// const fieldValidationPromises: Promise[] = [] as any;
+// this.store.batch(() => {
+// fieldsToValidate.forEach(nestedField => {
+// fieldValidationPromises.push(
+// Promise.resolve().then(() => this.validateField(nestedField, cause))
+// );
+// });
+// });
+
+// const fieldErrorMapMap = await Promise.all(fieldValidationPromises);
+// return fieldErrorMapMap.flat();
+// };
+
+// public validateField = >(
+// field: TField,
+// cause: ValidationCause
+// ) => {
+// // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+// const fieldInstance = this.fieldInfo[field]?.instance;
+// if (!fieldInstance) return [];
+
+// // If the field is not touched (same logic as in validateAllFields)
+// if (!fieldInstance.state.meta.isTouched) {
+// // Mark it as touched
+// fieldInstance.setMeta(prev => ({ ...prev, isTouched: true }));
+// }
+
+// return fieldInstance.validate(cause);
+// };
+
+// // TODO: This code is copied from FieldApi, we should refactor to share
+// public validateSync = (cause: ValidationCause) => {
+// const validates = getSyncValidatorArray(cause, this.options);
+// let hasErrored = false as boolean;
+
+// this.store.batch(() => {
+// for (const validateObj of validates) {
+// if (!validateObj.validate) continue;
+
+// const error = normalizeError(
+// this.runValidator({
+// validate: validateObj.validate,
+// value: {
+// value: this.state.values,
+// formApi: this
+// },
+// type: "validate"
+// })
+// );
+// const errorMapKey = getErrorMapKey(validateObj.cause);
+// if (this.state.errorMap[errorMapKey] !== error) {
+// this.store.setState(prev => ({
+// ...prev,
+// errorMap: {
+// ...prev.errorMap,
+// [errorMapKey]: error
+// }
+// }));
+// }
+// if (error) {
+// hasErrored = true;
+// }
+// }
+// });
+
+// /**
+// * when we have an error for onSubmit in the state, we want
+// * to clear the error as soon as the user enters a valid value in the field
+// */
+// const submitErrKey = getErrorMapKey("submit");
+// if (
+// this.state.errorMap[submitErrKey] &&
+// cause !== "submit" &&
+// !hasErrored
+// ) {
+// this.store.setState(prev => ({
+// ...prev,
+// errorMap: {
+// ...prev.errorMap,
+// [submitErrKey]: undefined
+// }
+// }));
+// }
+
+// return { hasErrored };
+// };
+
+// public validateAsync = async (
+// cause: ValidationCause
+// ): Promise => {
+// const validates = getAsyncValidatorArray(cause, this.options);
+
+// if (!this.state.isFormValidating) {
+// this.store.setState(prev => ({ ...prev, isFormValidating: true }));
+// }
+
+// /**
+// * We have to use a for loop and generate our promises this way, otherwise it won't be sync
+// * when there are no validators needed to be run
+// */
+// const promises: Promise[] = [];
+
+// for (const validateObj of validates) {
+// if (!validateObj.validate) continue;
+// const key = getErrorMapKey(validateObj.cause);
+// const fieldValidatorMeta = this.state.validationMetaMap[key];
+
+// fieldValidatorMeta?.lastAbortController.abort();
+// const controller = new AbortController();
+
+// this.state.validationMetaMap[key] = {
+// lastAbortController: controller
+// };
+
+// promises.push(
+// new Promise(async resolve => {
+// let rawError!: ValidationError | undefined;
+// try {
+// rawError = await new Promise((rawResolve, rawReject) => {
+// setTimeout(async () => {
+// if (controller.signal.aborted) return rawResolve(undefined);
+// try {
+// rawResolve(
+// await this.runValidator({
+// validate: validateObj.validate!,
+// value: {
+// value: this.state.values,
+// formApi: this,
+// signal: controller.signal
+// },
+// type: "validateAsync"
+// })
+// );
+// } catch (e) {
+// rawReject(e);
+// }
+// }, validateObj.debounceMs);
+// });
+// } catch (e: unknown) {
+// rawError = e as ValidationError;
+// }
+// const error = normalizeError(rawError);
+// this.store.setState(prev => ({
+// ...prev,
+// errorMap: {
+// ...prev.errorMap,
+// [getErrorMapKey(cause)]: error
+// }
+// }));
+
+// resolve(error);
+// })
+// );
+// }
+
+// let results: ValidationError[] = [];
+// if (promises.length) {
+// results = await Promise.all(promises);
+// }
+
+// this.store.setState(prev => ({
+// ...prev,
+// isFormValidating: false
+// }));
+
+// return results.filter(Boolean);
+// };
+
+// public validate = (
+// cause: ValidationCause
+// ): ValidationError[] | Promise => {
+// // Attempt to sync validate first
+// const { hasErrored } = this.validateSync(cause);
+
+// if (hasErrored && !this.options.asyncAlways) {
+// return this.state.errors;
+// }
+
+// // No error? Attempt async validation
+// return this.validateAsync(cause);
+// };
+
+// public handleSubmit = async () => {
+// // Check to see that the form and all fields have been touched
+// // If they have not, touch them all and run validation
+// // Run form validation
+// // Submit the form
+
+// this.store.setState(old => ({
+// ...old,
+// // Submission attempts mark the form as not submitted
+// isSubmitted: false,
+// // Count submission attempts
+// submissionAttempts: old.submissionAttempts + 1
+// }));
+
+// // Don't let invalid forms submit
+// if (!this.state.canSubmit) return;
+
+// this.store.setState(d => ({ ...d, isSubmitting: true }));
+
+// const done = () => {
+// this.store.setState(prev => ({ ...prev, isSubmitting: false }));
+// };
+
+// // Validate all fields
+// await this.validateAllFields("submit");
+
+// // Fields are invalid, do not submit
+// if (!this.state.isFieldsValid) {
+// done();
+// this.options.onSubmitInvalid?.({
+// value: this.state.values,
+// formApi: this
+// });
+// return;
+// }
+
+// // Run validation for the form
+// await this.validate("submit");
+
+// if (!this.state.isValid) {
+// done();
+// this.options.onSubmitInvalid?.({
+// value: this.state.values,
+// formApi: this
+// });
+// return;
+// }
+
+// try {
+// // Run the submit code
+// await this.options.onSubmit?.({
+// value: this.state.values,
+// formApi: this
+// });
+
+// this.store.batch(() => {
+// this.store.setState(prev => ({ ...prev, isSubmitted: true }));
+// done();
+// });
+// } catch (err) {
+// done();
+// throw err;
+// }
+// };
+
+// public getFieldValue = >(
+// field: TField
+// ): DeepValue => getBy(this.state.values, field);
+
+// public getFieldMeta = >(
+// field: TField
+// ): FieldMeta | undefined => {
+// return this.state.fieldMeta[field];
+// };
+
+// public getFieldInfo = >(
+// field: TField
+// ): FieldInfo => {
+// // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+// return (this.fieldInfo[field] ||= {
+// instance: null,
+// validationMetaMap: {
+// onChange: undefined,
+// onBlur: undefined,
+// onSubmit: undefined,
+// onMount: undefined,
+// onServer: undefined
+// }
+// });
+// };
+
+// public setFieldMeta = >(
+// field: TField,
+// updater: Updater
+// ) => {
+// this.store.setState(prev => {
+// return {
+// ...prev,
+// fieldMeta: {
+// ...prev.fieldMeta,
+// [field]: functionalUpdate(updater, prev.fieldMeta[field])
+// }
+// };
+// });
+// };
+
+// public resetFieldMeta = >(
+// fieldMeta: Record
+// ): Record => {
+// return Object.keys(fieldMeta).reduce(
+// (acc: Record, key) => {
+// const fieldKey = key as TField;
+// acc[fieldKey] = {
+// isValidating: false,
+// isTouched: false,
+// isDirty: false,
+// isPristine: true,
+// touchedErrors: [],
+// errors: [],
+// errorMap: {}
+// };
+// return acc;
+// },
+// {} as Record
+// );
+// };
+
+// public setFieldValue = >(
+// field: TField,
+// updater: Updater>,
+// opts?: { touch?: boolean }
+// ) => {
+// const touch = opts?.touch;
+
+// this.store.batch(() => {
+// if (touch) {
+// this.setFieldMeta(field, prev => ({
+// ...prev,
+// isTouched: true,
+// isDirty: true
+// }));
+// }
+
+// this.store.setState(prev => {
+// return {
+// ...prev,
+// values: setBy(prev.values, field, updater)
+// };
+// });
+// });
+// };
+
+// public deleteField = >(field: TField) => {
+// this.store.setState(prev => {
+// const newState = { ...prev };
+// newState.values = deleteBy(newState.values, field);
+// delete newState.fieldMeta[field];
+
+// return newState;
+// });
+// delete this.fieldInfo[field];
+// };
+
+// public pushFieldValue = >(
+// field: TField,
+// value: DeepValue extends any[]
+// ? DeepValue[number]
+// : never,
+// opts?: { touch?: boolean }
+// ) => {
+// this.setFieldValue(
+// field,
+// prev => [...(Array.isArray(prev) ? prev : []), value] as any,
+// opts
+// );
+// this.validateField(field, "change");
+// };
+
+// public insertFieldValue = async >(
+// field: TField,
+// index: number,
+// value: DeepValue extends any[]
+// ? DeepValue[number]
+// : never,
+// opts?: { touch?: boolean }
+// ) => {
+// this.setFieldValue(
+// field,
+// prev => {
+// return [
+// ...(prev as DeepValue[]).slice(0, index),
+// value,
+// ...(prev as DeepValue[]).slice(index)
+// ] as any;
+// },
+// opts
+// );
+
+// // Validate the whole array + all fields that have shifted
+// await this.validateField(field, "change");
+// };
+
+// public replaceFieldValue = async >(
+// field: TField,
+// index: number,
+// value: DeepValue extends any[]
+// ? DeepValue[number]
+// : never,
+// opts?: { touch?: boolean }
+// ) => {
+// this.setFieldValue(
+// field,
+// prev => {
+// return (prev as DeepValue[]).map((d, i) =>
+// i === index ? value : d
+// ) as any;
+// },
+// opts
+// );
+
+// // Validate the whole array + all fields that have shifted
+// await this.validateField(field, "change");
+// await this.validateArrayFieldsStartingFrom(field, index, "change");
+// };
+
+// public removeFieldValue = async >(
+// field: TField,
+// index: number,
+// opts?: { touch?: boolean }
+// ) => {
+// const fieldValue = this.getFieldValue(field);
+
+// const lastIndex = Array.isArray(fieldValue)
+// ? Math.max(fieldValue.length - 1, 0)
+// : null;
+
+// this.setFieldValue(
+// field,
+// prev => {
+// return (prev as DeepValue[]).filter(
+// (_d, i) => i !== index
+// ) as any;
+// },
+// opts
+// );
+
+// if (lastIndex !== null) {
+// const start = `${field}[${lastIndex}]`;
+// const fieldsToDelete = Object.keys(this.fieldInfo).filter(f =>
+// f.startsWith(start)
+// );
+
+// // Cleanup the last fields
+// fieldsToDelete.forEach(f => this.deleteField(f as TField));
+// }
+
+// // Validate the whole array + all fields that have shifted
+// await this.validateField(field, "change");
+// await this.validateArrayFieldsStartingFrom(field, index, "change");
+// };
+
+// public swapFieldValues = >(
+// field: TField,
+// index1: number,
+// index2: number,
+// opts?: { touch?: boolean }
+// ) => {
+// this.setFieldValue(
+// field,
+// (prev: any) => {
+// const prev1 = prev[index1]!;
+// const prev2 = prev[index2]!;
+// return setBy(setBy(prev, `${index1}`, prev2), `${index2}`, prev1);
+// },
+// opts
+// );
+
+// // Validate the whole array
+// this.validateField(field, "change");
+// // Validate the swapped fields
+// this.validateField(`${field}[${index1}]` as DeepKeys, "change");
+// this.validateField(`${field}[${index2}]` as DeepKeys, "change");
+// };
+
+// public moveFieldValues = >(
+// field: TField,
+// index1: number,
+// index2: number,
+// opts?: { touch?: boolean }
+// ) => {
+// this.setFieldValue(
+// field,
+// (prev: any) => {
+// prev.splice(index2, 0, prev.splice(index1, 1)[0]);
+// return prev;
+// },
+// opts
+// );
+
+// // Validate the whole array
+// this.validateField(field, "change");
+// // Validate the moved fields
+// this.validateField(`${field}[${index1}]` as DeepKeys, "change");
+// this.validateField(`${field}[${index2}]` as DeepKeys, "change");
+// };
+// }
+
+// function normalizeError(rawError?: ValidationError) {
+// if (rawError) {
+// if (typeof rawError !== "string") {
+// return "Invalid Form Values";
+// }
+
+// return rawError;
+// }
+
+// return undefined;
+// }
+
+// function getErrorMapKey(cause: ValidationCause) {
+// switch (cause) {
+// case "submit":
+// return "onSubmit";
+// case "blur":
+// return "onBlur";
+// case "mount":
+// return "onMount";
+// case "server":
+// return "onServer";
+// case "change":
+// default:
+// return "onChange";
+// }
+// }
diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts
index 5ed795e0..230d4d7c 100644
--- a/packages/form/src/index.ts
+++ b/packages/form/src/index.ts
@@ -9,3 +9,7 @@
export * from "./create-form-factory";
export * from "./use-form";
+export * from "./types";
+export * from "./form-api";
+export * from "./field-api";
+export * from "./utils";
diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts
new file mode 100644
index 00000000..3d22e8f5
--- /dev/null
+++ b/packages/form/src/types.ts
@@ -0,0 +1,580 @@
+import type { FunctionComponent } from "react";
+import { State, type StoreApi } from "@cyclone-ui/store";
+import { DeepKeys, DeepValue, NoInfer } from "@cyclone-ui/types";
+
+export type ValidationError = undefined | false | null | string;
+
+// If/when TypeScript supports higher-kinded types, this should not be `unknown` anymore
+export type Validator = () => {
+ validate(options: { value: Type }, fn: Fn): ValidationError;
+ validateAsync(options: { value: Type }, fn: Fn): Promise;
+};
+
+// "server" is only intended for SSR/SSG validation and should not execute anything
+export type ValidationCause = "change" | "blur" | "submit" | "mount" | "server";
+
+export type ValidationErrorMapKeys = `on${Capitalize}`;
+
+export type ValidationErrorMap = {
+ [K in ValidationErrorMapKeys]?: ValidationError;
+};
+
+export interface FieldApiOptions<
+ TParentData,
+ TName extends DeepKeys,
+ TFieldValidator extends
+ | Validator, unknown>
+ | undefined = undefined,
+ TFormValidator extends
+ | Validator
+ | undefined = undefined,
+ TData extends DeepValue = DeepValue
+> extends FieldOptions<
+ TParentData,
+ TName,
+ TFieldValidator,
+ TFormValidator,
+ TData
+ > {
+ form: IFormApi;
+}
+
+/**
+ * The field options.
+ */
+export type UseFieldOptions<
+ TParentData,
+ TName extends DeepKeys,
+ TFieldValidator extends
+ | Validator, unknown>
+ | undefined = undefined,
+ TFormValidator extends
+ | Validator
+ | undefined = undefined,
+ TData extends DeepValue = DeepValue
+> = FieldApiOptions<
+ TParentData,
+ TName,
+ TFieldValidator,
+ TFormValidator,
+ TData
+> & {
+ mode?: "value" | "array";
+};
+
+export interface IFieldApi<
+ TParentData,
+ TName extends DeepKeys,
+ TFieldValidator extends
+ | Validator, unknown>
+ | undefined = undefined,
+ TFormValidator extends
+ | Validator
+ | undefined = undefined,
+ TData extends DeepValue = DeepValue
+> {
+ /**
+ * A pre-bound and type-safe sub-field component using this field as a root.
+ */
+ Field: FieldComponent;
+}
+
+/**
+ * A type representing a hook for using a field in a form with the given form data type.
+ *
+ * A function that takes an optional object with a `name` property and field options, and returns a `FieldApi` instance for the specified field.
+ */
+export type UseField<
+ TParentData,
+ TFormValidator extends Validator | undefined = undefined
+> = <
+ TName extends DeepKeys,
+ TFieldValidator extends
+ | Validator, unknown>
+ | undefined = undefined,
+ TData extends DeepValue = DeepValue
+>(
+ opts: Omit<
+ UseFieldOptions,
+ "form"
+ >
+) => IFieldApi;
+
+export type FormValidateFn<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> = (props: {
+ value: TFormData;
+ api: StoreApi>;
+}) => ValidationError;
+
+export type FormValidateOrFn<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> =
+ TFormValidator extends Validator
+ ? TFN
+ : FormValidateFn;
+
+export type FormValidateAsyncFn<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> = (props: {
+ value: TFormData;
+ formApi: IFormApi;
+ signal: AbortSignal;
+}) => ValidationError | Promise;
+
+export type FormAsyncValidateOrFn<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> =
+ TFormValidator extends Validator
+ ? FFN | FormValidateAsyncFn
+ : FormValidateAsyncFn;
+
+export interface FormValidators<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> {
+ onMount?: FormValidateOrFn;
+ onChange?: FormValidateOrFn;
+ onChangeAsync?: FormAsyncValidateOrFn;
+ onChangeAsyncDebounceMs?: number;
+ onBlur?: FormValidateOrFn;
+ onBlurAsync?: FormAsyncValidateOrFn;
+ onBlurAsyncDebounceMs?: number;
+ onSubmit?: FormValidateOrFn;
+ onSubmitAsync?: FormAsyncValidateOrFn;
+}
+
+export interface FormTransform<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> {
+ fn: (
+ formBase: IFormApi
+ ) => IFormApi;
+ deps: unknown[];
+}
+
+export interface FormOptions<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> {
+ defaultValues?: TFormData;
+ defaultState?: Partial>;
+ asyncAlways?: boolean;
+ asyncDebounceMs?: number;
+ validatorAdapter?: TFormValidator;
+ validators?: FormValidators;
+ onSubmit?: (props: {
+ value: TFormData;
+ formApi: IFormApi;
+ }) => any | Promise;
+ onSubmitInvalid?: (props: {
+ value: TFormData;
+ formApi: IFormApi;
+ }) => void;
+ transform?: FormTransform;
+}
+
+export type ValidationMeta = {
+ lastAbortController: AbortController;
+};
+
+export type FieldInfo<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> = {
+ instance: IFieldApi<
+ TFormData,
+ any,
+ Validator | undefined,
+ TFormValidator
+ > | null;
+ validationMetaMap: Record;
+};
+
+export type FieldMeta = {
+ isTouched: boolean;
+ isPristine: boolean;
+ isDirty: boolean;
+ touchedErrors: ValidationError[];
+ errors: ValidationError[];
+ errorMap: ValidationErrorMap;
+ isValidating: boolean;
+};
+
+export type FieldState = {
+ value: TData;
+ meta: FieldMeta;
+};
+
+export type ResolveName = unknown extends TParentData
+ ? string
+ : DeepKeys;
+
+export interface FieldOptions<
+ TParentData,
+ TFieldName extends DeepKeys,
+ TFieldValidator extends
+ | Validator, unknown>
+ | undefined = undefined,
+ TFormValidator extends
+ | Validator
+ | undefined = undefined,
+ TData extends DeepValue = DeepValue<
+ TParentData,
+ TFieldName
+ >
+> {
+ name: TFieldName;
+ defaultValue?: NoInfer;
+ asyncDebounceMs?: number;
+ asyncAlways?: boolean;
+ preserveValue?: boolean;
+ validatorAdapter?: TFieldValidator;
+ validators?: FieldValidators<
+ TParentData,
+ TFieldName,
+ TFieldValidator,
+ TFormValidator,
+ TData
+ >;
+ defaultMeta?: Partial;
+}
+
+export type FormState<
+ TFormData,
+ TFormValidator extends Validator | undefined = undefined
+> = State & {
+ // Form Meta
+ options: FormOptions;
+
+ // Form Data
+ values: TFormData;
+ // Form Validation
+ isFormValidating: boolean;
+ isFormValid: boolean;
+ errors: ValidationError[];
+ errorMap: ValidationErrorMap;
+ validationMetaMap: Record;
+ // Fields
+ fieldMeta: Record, FieldMeta>;
+ _fieldInfo: Record, any>;
+ isFieldsValidating: boolean;
+ isFieldsValid: boolean;
+ isSubmitting: boolean;
+ // General
+ isTouched: boolean;
+ isDirty: boolean;
+ isPristine: boolean;
+ isSubmitted: boolean;
+ isValidating: boolean;
+ isValid: boolean;
+ canSubmit: boolean;
+ submissionAttempts: number;
+};
+
+/**
+ * When using `@tanstack/react-form`, the core form API is extended at type level with additional methods for React-specific functionality:
+ */
+export interface IFormApi<
+ TFormData,
+ TFormValidator extends Validator | undefined
+> {
+ /**
+ * A React component to render form fields. With this, you can render and manage individual form fields.
+ */
+ Field: FieldComponent;
+
+ /**
+ * A custom React hook that provides functionalities related to individual form fields. It gives you access to field values, errors, and allows you to set or update field values.
+ */
+ useField: UseField;
+
+ /**
+ * A `useStore` hook that connects to the internal store of the form. It can be used to access the form's current state or any other related state information. You can optionally pass in a selector function to cherry-pick specific parts of the state
+ */
+ useStore: >>(
+ selector?: (state: NoInfer>) => TSelected
+ ) => TSelected;
+
+ /**
+ * A `Subscribe` function that allows you to listen and react to changes in the form's state. It's especially useful when you need to execute side effects or render specific components in response to state updates.
+ */
+ Subscribe: >>(props: {
+ /**
+ TypeScript versions <=5.0.4 have a bug that prevents
+ the type of the `TSelected` generic from being inferred
+ from the return type of this method.
+
+ In these versions, `TSelected` will fall back to the default
+ type (or `unknown` if that's not defined).
+
+ @see {@link https://github.com/TanStack/form/pull/606/files#r1506715714 | This discussion on GitHub for the details}
+ @see {@link https://github.com/microsoft/TypeScript/issues/52786 | The bug report in `microsoft/TypeScript`}
+ */
+ selector?: (state: NoInfer>) => TSelected;
+
+ children: ((state: NoInfer) => NodeType) | NodeType;
+ }) => NodeType;
+
+ options: FormOptions;
+
+ store: StoreApi