diff --git a/.changeset/config.json b/.changeset/config.json
index 6df7b1fd..b4c8ae9a 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -5,7 +5,7 @@
"fixed": [],
"linked": [],
"access": "public",
- "baseBranch": "main",
+ "baseBranch": "origin/main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
diff --git a/.changeset/lucky-walls-chew.md b/.changeset/lucky-walls-chew.md
new file mode 100644
index 00000000..3eb9e6e2
--- /dev/null
+++ b/.changeset/lucky-walls-chew.md
@@ -0,0 +1,17 @@
+---
+'counter': patch
+'basic': patch
+'google-webfonts-client': patch
+'openapi-express': patch
+'feature-logger': patch
+'feature-fetch': patch
+'feature-react': patch
+'feature-state': patch
+'figma-connect': patch
+'feature-form': patch
+'@ibg/config': patch
+'@ibg/utils': patch
+'@ibg/cli': patch
+---
+
+fixed typos
diff --git a/.gitignore b/.gitignore
index 4f0ee03b..e771821c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,4 @@ yarn-error.log*
.DS_Store
*.pem
*-why.html
+*-why-*.html
diff --git a/README.md b/README.md
index 93fa46e6..9afbb69e 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
-
+
@@ -20,10 +20,22 @@ A collection of open source libraries maintained by [inbeta.group](https://inbet
| [cli](https://github.com/inbeta-group/monorepo/blob/develop/packages/cli) | Straightforward CLI to bundle Typescript libraries with presets, powered by Rollup and Esbuild | [`@ibg/cli`](https://www.npmjs.com/package/@ibg/cli) |
| [config](https://github.com/inbeta-group/monorepo/blob/develop/packages/cli) | Collection of ESLint, Vite, and Typescript configurations | [`@ibg/config`](https://www.npmjs.com/package/@ibg/config) |
| [feature-fetch](https://github.com/inbeta-group/monorepo/blob/develop/packages/feature-fetch) | Straightforward, typesafe, and feature-based fetch wrapper supporting OpenAPI types | [`feature-fetch`](https://www.npmjs.com/package/feature-fetch) |
+| [feature-form](https://github.com/inbeta-group/monorepo/blob/develop/packages/feature-form) | Straightforward, typesafe, and feature-based form library | [`feature-form`](https://www.npmjs.com/package/feature-form) |
| [feature-logger](https://github.com/inbeta-group/monorepo/blob/develop/packages/feature-logger) | Straightforward, typesafe, and feature-based logging library | [`feature-logger`](https://www.npmjs.com/package/feature-logger) |
| [feature-state](https://github.com/inbeta-group/monorepo/blob/develop/packages/feature-state) | Straightforward, typesafe, and feature-based state management library for ReactJs | [`feature-state`](https://www.npmjs.com/package/feature-state) |
| [feature-state-react](https://github.com/inbeta-group/monorepo/blob/develop/packages/feature-state-react) | ReactJs extension for the feature-state library, providing hooks and features for easy state management in ReactJs | [`feature-state-react`](https://www.npmjs.com/package/feature-state-react) |
| [figma-connect](https://github.com/inbeta-group/monorepo/blob/develop/packages/figma-connect) | Straightforward and typesafe wrapper around the communication between the app/ui (iframe) and plugin (sandbox) part of a Figma Plugin | [`figma-connect`](https://www.npmjs.com/package/figma-connect) |
| [google-webfonts-client](https://github.com/inbeta-group/monorepo/blob/develop/packages/google-webfonts-client) | Typesafe and straightforward fetch client for interacting with the Google Web Fonts API using feature-fetch | [`google-webfonts-client`](https://www.npmjs.com/package/google-webfonts-client) |
| [openapi-express](https://github.com/inbeta-group/monorepo/blob/develop/packages/openapi-express) | Typesafe Express Router wrapper supporting OpenAPI types | [`openapi-express`](https://www.npmjs.com/package/openapi-express) |
-| [utils](https://github.com/inbeta-group/monorepo/blob/develop/packages/utils) | Straightforward, typesafe, and tree-shakable collection of utility functions | [`@ibg/utils`](https://www.npmjs.com/package/@ibg/utils) |
\ No newline at end of file
+| [utils](https://github.com/inbeta-group/monorepo/blob/develop/packages/utils) | Straightforward, typesafe, and tree-shakable collection of utility functions | [`@ibg/utils`](https://www.npmjs.com/package/@ibg/utils) |
+
+## 👀 Examples
+
+> See [`/examples`](https://github.com/inbeta-group/monorepo/tree/develop/examples)
+
+### `feature-state`
+- [`feature-state/react/counter`](https://github.com/inbeta-group/monorepo/tree/develop/examples/feature-state/react/counter)
+
+### `feature-form`
+- [`feature-form/react/basic`](https://github.com/inbeta-group/monorepo/tree/develop/examples/feature-form/react/basic)
+
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 00000000..9f83a603
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,5 @@
+
+### React Example Setup
+```
+pnpm create vite example-name --template react-ts
+```
\ No newline at end of file
diff --git a/examples/feature-form/react/basic/.eslintrc.cjs b/examples/feature-form/react/basic/.eslintrc.cjs
new file mode 100644
index 00000000..d6c95379
--- /dev/null
+++ b/examples/feature-form/react/basic/.eslintrc.cjs
@@ -0,0 +1,18 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+}
diff --git a/examples/feature-form/react/basic/.gitignore b/examples/feature-form/react/basic/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/examples/feature-form/react/basic/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/examples/feature-form/react/basic/index.html b/examples/feature-form/react/basic/index.html
new file mode 100644
index 00000000..e4b78eae
--- /dev/null
+++ b/examples/feature-form/react/basic/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/examples/feature-form/react/basic/package.json b/examples/feature-form/react/basic/package.json
new file mode 100644
index 00000000..5a8b05e2
--- /dev/null
+++ b/examples/feature-form/react/basic/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "basic",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "feature-form": "workspace:*",
+ "feature-react": "workspace:*",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "valibot": "^0.35.0",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.66",
+ "@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.2.0",
+ "@typescript-eslint/parser": "^7.2.0",
+ "@vitejs/plugin-react": "^4.2.1",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.6",
+ "typescript": "^5.2.2",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/examples/feature-form/react/basic/public/vite.svg b/examples/feature-form/react/basic/public/vite.svg
new file mode 100644
index 00000000..e7b8dfb1
--- /dev/null
+++ b/examples/feature-form/react/basic/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/feature-form/react/basic/src/App.css b/examples/feature-form/react/basic/src/App.css
new file mode 100644
index 00000000..9fca8d70
--- /dev/null
+++ b/examples/feature-form/react/basic/src/App.css
@@ -0,0 +1,76 @@
+body {
+ background: #0e101c;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
+ 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+}
+
+form {
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+h1 {
+ font-weight: 100;
+ color: white;
+ text-align: center;
+ padding-bottom: 10px;
+ border-bottom: 1px solid rgb(79, 98, 148);
+}
+
+p {
+ color: white;
+}
+
+.form {
+ background: #0e101c;
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.error {
+ color: #bf1650;
+}
+
+.error::before {
+ display: inline;
+ content: '⚠ ';
+}
+
+textarea,
+input {
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ border-radius: 4px;
+ border: 1px solid white;
+ padding: 10px 15px;
+ margin-bottom: 10px;
+ font-size: 14px;
+}
+
+label {
+ line-height: 2;
+ text-align: left;
+ display: block;
+ margin-bottom: 13px;
+ margin-top: 20px;
+ color: white;
+ font-size: 14px;
+ font-weight: 200;
+}
+
+.App {
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+button {
+ display: block;
+ appearance: none;
+ margin-top: 40px;
+ border: 1px solid #333;
+ margin-bottom: 20px;
+ text-transform: uppercase;
+ padding: 10px 20px;
+ border-radius: 4px;
+}
diff --git a/examples/feature-form/react/basic/src/App.tsx b/examples/feature-form/react/basic/src/App.tsx
new file mode 100644
index 00000000..915efe6a
--- /dev/null
+++ b/examples/feature-form/react/basic/src/App.tsx
@@ -0,0 +1,204 @@
+import {
+ bitwiseFlag,
+ createForm,
+ createValidator,
+ FormFieldReValidateMode,
+ FormFieldValidateMode,
+ TFormFieldValidator,
+ valibotValidator,
+ zodValidator
+} from 'feature-form';
+import { useForm } from 'feature-react/form';
+import { withGlobalBind } from 'feature-react/state';
+import React from 'react';
+import * as v from 'valibot';
+import * as z from 'zod';
+
+import './App.css';
+
+import { StatusMessage } from './components';
+import { isLightColor, randomColor } from './utils';
+
+type TGender = 'male' | 'female' | 'diverse';
+
+type TFormData = {
+ // [key: string]: string; // https://stackoverflow.com/questions/65799316/why-cant-an-interface-be-assigned-to-recordstring-unknown
+ firstName: string;
+ lastName: string;
+ gender: TGender;
+ email: string;
+ image: {
+ id: string;
+ color: string;
+ };
+};
+
+const valibotNameValidator = valibotValidator(
+ v.pipe(v.string(), v.minLength(2), v.maxLength(10), v.regex(/^([^0-9]*)$/))
+);
+
+const $form = withGlobalBind(
+ '_form',
+ createForm({
+ fields: {
+ firstName: {
+ validator: valibotNameValidator.clone().append(
+ createValidator([
+ {
+ key: 'jeff',
+ validate: (formField) => {
+ if (formField.get() !== 'Jeff') {
+ formField.status.registerNextError({
+ code: 'jeff',
+ message: 'Only the name Jeff is allowed.'
+ });
+ }
+ }
+ }
+ ])
+ ),
+ defaultValue: ''
+ },
+ lastName: {
+ validator: valibotNameValidator,
+ defaultValue: ''
+ },
+ gender: {
+ validator: createValidator([
+ {
+ key: 'gender',
+ validate: (formField) => {
+ if (
+ formField.get() !== 'female' &&
+ formField.get() !== 'male' &&
+ formField.get() !== 'diverse'
+ ) {
+ formField.status.registerNextError({
+ code: 'invalid-gender',
+ message: 'Unknown gender.'
+ });
+ }
+ }
+ }
+ ]) as TFormFieldValidator,
+ defaultValue: 'female'
+ },
+ email: {
+ validator: zodValidator(z.string().email().max(30).min(1)),
+ defaultValue: ''
+ },
+ image: {
+ validator: createValidator([
+ {
+ key: 'color',
+ validate: (formField) => {
+ const color = formField.get()?.color;
+ if (color != null && !isLightColor(color)) {
+ formField.status.registerNextError({
+ code: 'too-dark',
+ message: 'The image is too dark.'
+ });
+ }
+ }
+ }
+ ]),
+ defaultValue: {
+ id: '',
+ color: randomColor()
+ }
+ }
+ },
+ onValidSubmit: (data, additionalData) => {
+ console.log('ValidSubmit', { data, additionalData });
+ },
+ onInvalidSubmit: (errors, additionalData) => {
+ console.log('Invalid Submit', { errors, additionalData });
+ },
+ notifyOnStatusChange: false,
+ validateMode: bitwiseFlag(FormFieldValidateMode.OnSubmit),
+ reValidateMode: bitwiseFlag(FormFieldReValidateMode.OnBlur, FormFieldReValidateMode.OnChange)
+ })
+);
+
+let renderCount = 0;
+
+function App() {
+ const { handleSubmit, status, field, register } = useForm($form);
+ const [data, setData] = React.useState('');
+
+ renderCount++;
+
+ return (
+
+ );
+}
+
+export default App;
diff --git a/examples/feature-form/react/basic/src/components/StatusMessage.tsx b/examples/feature-form/react/basic/src/components/StatusMessage.tsx
new file mode 100644
index 00000000..6f704356
--- /dev/null
+++ b/examples/feature-form/react/basic/src/components/StatusMessage.tsx
@@ -0,0 +1,18 @@
+import { TFormFieldStatus } from 'feature-form';
+import { useGlobalState } from 'feature-react/state';
+import React from 'react';
+
+export const StatusMessage: React.FC = (props) => {
+ const { $status } = props;
+ const status = useGlobalState($status);
+
+ if (status.type === 'INVALID') {
+ return {status.errors[0].message}
;
+ }
+
+ return null;
+};
+
+interface TProps {
+ $status: TFormFieldStatus;
+}
diff --git a/examples/feature-form/react/basic/src/components/index.ts b/examples/feature-form/react/basic/src/components/index.ts
new file mode 100644
index 00000000..82bd8c0c
--- /dev/null
+++ b/examples/feature-form/react/basic/src/components/index.ts
@@ -0,0 +1 @@
+export * from './StatusMessage';
diff --git a/examples/feature-form/react/basic/src/main.tsx b/examples/feature-form/react/basic/src/main.tsx
new file mode 100644
index 00000000..1016b28c
--- /dev/null
+++ b/examples/feature-form/react/basic/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+
+import App from './App.tsx';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/examples/feature-form/react/basic/src/utils.ts b/examples/feature-form/react/basic/src/utils.ts
new file mode 100644
index 00000000..9c8021fa
--- /dev/null
+++ b/examples/feature-form/react/basic/src/utils.ts
@@ -0,0 +1,13 @@
+export function randomColor(): string {
+ return '#' + ((Math.random() * 0xffffff) << 0).toString(16).padStart(6, '0');
+}
+
+export function isLightColor(color: string): boolean {
+ const hex = color.replace('#', '');
+ const c_r = parseInt(hex.substr(0, 2), 16);
+ const c_g = parseInt(hex.substr(2, 2), 16);
+ const c_b = parseInt(hex.substr(4, 2), 16);
+ const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
+
+ return brightness > 155;
+}
diff --git a/examples/feature-form/react/basic/src/vite-env.d.ts b/examples/feature-form/react/basic/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/examples/feature-form/react/basic/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/feature-form/react/basic/tsconfig.json b/examples/feature-form/react/basic/tsconfig.json
new file mode 100644
index 00000000..a7fc6fbf
--- /dev/null
+++ b/examples/feature-form/react/basic/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/examples/feature-form/react/basic/tsconfig.node.json b/examples/feature-form/react/basic/tsconfig.node.json
new file mode 100644
index 00000000..97ede7ee
--- /dev/null
+++ b/examples/feature-form/react/basic/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/examples/feature-form/react/basic/vite.config.ts b/examples/feature-form/react/basic/vite.config.ts
new file mode 100644
index 00000000..5a33944a
--- /dev/null
+++ b/examples/feature-form/react/basic/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/examples/feature-state/react/counter/.eslintrc.cjs b/examples/feature-state/react/counter/.eslintrc.cjs
new file mode 100644
index 00000000..d6c95379
--- /dev/null
+++ b/examples/feature-state/react/counter/.eslintrc.cjs
@@ -0,0 +1,18 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+}
diff --git a/examples/feature-state/react/counter/.gitignore b/examples/feature-state/react/counter/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/examples/feature-state/react/counter/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/examples/feature-state/react/counter/index.html b/examples/feature-state/react/counter/index.html
new file mode 100644
index 00000000..e4b78eae
--- /dev/null
+++ b/examples/feature-state/react/counter/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/examples/feature-state/react/counter/package.json b/examples/feature-state/react/counter/package.json
new file mode 100644
index 00000000..eaf6e5c1
--- /dev/null
+++ b/examples/feature-state/react/counter/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "counter",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "feature-react": "workspace:*",
+ "feature-state": "workspace:*",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.66",
+ "@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.2.0",
+ "@typescript-eslint/parser": "^7.2.0",
+ "@vitejs/plugin-react": "^4.2.1",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.6",
+ "typescript": "^5.2.2",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/examples/feature-state/react/counter/public/vite.svg b/examples/feature-state/react/counter/public/vite.svg
new file mode 100644
index 00000000..e7b8dfb1
--- /dev/null
+++ b/examples/feature-state/react/counter/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/feature-state/react/counter/src/App.tsx b/examples/feature-state/react/counter/src/App.tsx
new file mode 100644
index 00000000..d6199a49
--- /dev/null
+++ b/examples/feature-state/react/counter/src/App.tsx
@@ -0,0 +1,41 @@
+import { useGlobalState } from 'feature-react/state';
+
+import { $counter } from './store';
+
+export default function App() {
+ const counter = useGlobalState($counter);
+
+ return (
+
+
{counter}
+
+
+
+
+
+ );
+}
diff --git a/examples/feature-state/react/counter/src/main.tsx b/examples/feature-state/react/counter/src/main.tsx
new file mode 100644
index 00000000..1016b28c
--- /dev/null
+++ b/examples/feature-state/react/counter/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+
+import App from './App.tsx';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/examples/feature-state/react/counter/src/store.ts b/examples/feature-state/react/counter/src/store.ts
new file mode 100644
index 00000000..5d93be80
--- /dev/null
+++ b/examples/feature-state/react/counter/src/store.ts
@@ -0,0 +1,3 @@
+import { createState, withUndo } from 'feature-state';
+
+export const $counter = withUndo(createState(0));
diff --git a/examples/feature-state/react/counter/src/vite-env.d.ts b/examples/feature-state/react/counter/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/examples/feature-state/react/counter/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/feature-state/react/counter/tsconfig.json b/examples/feature-state/react/counter/tsconfig.json
new file mode 100644
index 00000000..a7fc6fbf
--- /dev/null
+++ b/examples/feature-state/react/counter/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/examples/feature-state/react/counter/tsconfig.node.json b/examples/feature-state/react/counter/tsconfig.node.json
new file mode 100644
index 00000000..97ede7ee
--- /dev/null
+++ b/examples/feature-state/react/counter/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/examples/feature-state/react/counter/vite.config.ts b/examples/feature-state/react/counter/vite.config.ts
new file mode 100644
index 00000000..5a33944a
--- /dev/null
+++ b/examples/feature-state/react/counter/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/package.json b/package.json
index 256940b0..821876fa 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"homepage": "https://inbeta.group/?source=github",
"devDependencies": {
"@changesets/cli": "^2.27.5",
+ "@changesets/changelog-github": "^0.5.0",
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@ibg/cli": "workspace:*",
"@ibg/config": "workspace:*",
@@ -35,19 +36,16 @@
"@size-limit/esbuild-why": "^11.1.4",
"@size-limit/preset-small-lib": "^11.1.4",
"eslint": "^8.57.0",
- "prettier": "^3.3.0",
+ "prettier": "^3.3.1",
"prettier-plugin-tailwindcss": "^0.6.1",
"shx": "^0.3.4",
"size-limit": "^11.1.4",
- "turbo": "latest",
+ "turbo": "^2.0.3",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
},
- "packageManager": "pnpm@9.0.6",
+ "packageManager": "pnpm@9.2.0",
"engines": {
- "node": ">=18"
- },
- "dependencies": {
- "@changesets/changelog-github": "^0.5.0"
+ "node": ">=20"
}
}
diff --git a/packages/_deprecated/logger/package.json b/packages/_deprecated/logger/package.json
index 420d0c56..466cd9b0 100644
--- a/packages/_deprecated/logger/package.json
+++ b/packages/_deprecated/logger/package.json
@@ -10,7 +10,8 @@
"clean": "shx rm -rf dist && shx rm -rf node_modules && shx rm -rf .turbo",
"install:clean": "pnpm run clean && pnpm install",
"test": "vitest run",
- "update:latest": "pnpm update --latest"
+ "update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks"
},
"source": "./src/index.ts",
"main": "./dist/cjs/index.js",
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 398fb113..5bf0e9ca 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 0fc00185..dd05c8b7 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -13,7 +13,8 @@
"clean": "shx rm -rf dist && shx rm -rf node_modules && shx rm -rf .turbo",
"install:clean": "pnpm run clean && pnpm install",
"test": "echo \"Error: no test specified\" && exit 1",
- "update:latest": "pnpm update --latest"
+ "update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks"
},
"source": "./src/index.ts",
"main": "./dist/index.js",
diff --git a/packages/cli/src/commands/bundle/index.ts b/packages/cli/src/commands/bundle/index.ts
index a70b7b6f..595d2e8b 100644
--- a/packages/cli/src/commands/bundle/index.ts
+++ b/packages/cli/src/commands/bundle/index.ts
@@ -135,7 +135,7 @@ export default class Bundle extends DynCommand {
await generateDts(this, { tsConfigPath });
break;
default:
- this.error(`Unknown build strategy '${flags.buildStrategy}'!`, { exit: 1 });
+ this.error(`Unknown bundle strategy '${flags.bundleStrategy}'!`, { exit: 1 });
}
this.log(`\n`);
diff --git a/packages/cli/src/services/rollup/configs/node/rollup.config.base.ts b/packages/cli/src/services/rollup/configs/node/rollup.config.base.ts
index a2fda952..3c8529a6 100644
--- a/packages/cli/src/services/rollup/configs/node/rollup.config.base.ts
+++ b/packages/cli/src/services/rollup/configs/node/rollup.config.base.ts
@@ -64,7 +64,7 @@ export async function createBaseRollupConfig(
return dependencies
.map(
(dependency) =>
- `${dependency.license} -- ${dependency.name}:${dependency.version}`
+ `${dependency.license?.toString() ?? '_'} -- ${dependency.name?.toString() ?? '_'}:${dependency.version?.toString() ?? '_'}`
)
.join('\n');
}
diff --git a/packages/cli/src/services/rollup/plugins/rollup-plugin-bundle-size.ts b/packages/cli/src/services/rollup/plugins/rollup-plugin-bundle-size.ts
index 6bc03664..b0348ab7 100644
--- a/packages/cli/src/services/rollup/plugins/rollup-plugin-bundle-size.ts
+++ b/packages/cli/src/services/rollup/plugins/rollup-plugin-bundle-size.ts
@@ -28,9 +28,15 @@ async function bundleSize(command: DynCommand): Promise {
chalk.underline(options.name)
)} (${chalk.blue(options.format)}):`
);
- command.log(` - Original Size: ${chalk.green(chalk.underline(`${originalSize}`))}`);
- command.log(` - Minified Size: ${chalk.green(chalk.underline(`${minifiedSize}`))}`);
- command.log(` - Gzipped Size: ${chalk.green(chalk.underline(`${compressedSize}`))}`);
+ command.log(
+ ` - Original Size: ${chalk.green(chalk.underline(originalSize?.toString()))}`
+ );
+ command.log(
+ ` - Minified Size: ${chalk.green(chalk.underline(minifiedSize?.toString()))}`
+ );
+ command.log(
+ ` - Gzipped Size: ${chalk.green(chalk.underline(compressedSize?.toString()))}`
+ );
}
}
},
@@ -42,7 +48,7 @@ async function bundleSize(command: DynCommand): Promise {
command.log(
`Created directory for ${chalk.magenta(chalk.underline(options.name))} (${chalk.blue(
options.format
- )}): ${chalk.green(chalk.underline(`${dirSize} bytes`))}`
+ )}): ${chalk.green(chalk.underline(`${dirSize.toString()} bytes`))}`
);
}
}
diff --git a/packages/cli/src/utils/execa-verbose.ts b/packages/cli/src/utils/execa-verbose.ts
index dd29e28a..545338b9 100644
--- a/packages/cli/src/utils/execa-verbose.ts
+++ b/packages/cli/src/utils/execa-verbose.ts
@@ -12,11 +12,11 @@ export async function execaVerbose(
const subprocess = execa(toExecuteCommand, args, { verbose, ...execaConfig });
if (verbose) {
- subprocess.stdout?.on('data', (data) => {
+ subprocess.stdout?.on('data', (data: string) => {
command.log(chalk.gray(`\t${data}`));
});
- subprocess.stderr?.on('data', (data) => {
+ subprocess.stderr?.on('data', (data: string) => {
command.log(chalk.gray(`\t${data}`));
});
}
@@ -27,7 +27,7 @@ export async function execaVerbose(
command.error(
`An error occured while executing command: ${chalk.yellow(
`${toExecuteCommand} ${args.join(' ')}`
- )} \n\n ${chalk.gray(`\t${error as any}`)}`
+ )} \n\n ${chalk.gray(`\t${error as string}`)}`
);
process.exit(1);
}
diff --git a/packages/cli/src/utils/is-external.ts b/packages/cli/src/utils/is-external.ts
index 897c5d85..8dd8d01e 100644
--- a/packages/cli/src/utils/is-external.ts
+++ b/packages/cli/src/utils/is-external.ts
@@ -6,8 +6,8 @@ export function isExternal(
): TIsExternal {
const { fileTypesAsExternal = [], packageJsonDepsAsExternal = true } = options;
const allDepKeys = Object.keys({
- ...(packageJson.dependencies || {}),
- ...(packageJson.peerDependencies || {})
+ ...(packageJson.dependencies ?? {}),
+ ...(packageJson.peerDependencies ?? {})
});
return (source: string) => {
diff --git a/packages/config/README.md b/packages/config/README.md
index ee585a2c..a0fa7953 100644
--- a/packages/config/README.md
+++ b/packages/config/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/packages/config/package.json b/packages/config/package.json
index 4423e03c..d097da4c 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -4,10 +4,10 @@
"version": "0.0.11",
"private": false,
"scripts": {
- "lint": "eslint --ext .js,.ts,.jsx,.tsx src/",
"clean": "shx rm -rf dist && shx rm -rf node_modules && shx rm -rf .turbo",
"install:clean": "pnpm run clean && pnpm install",
- "update:latest": "pnpm update --latest"
+ "update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks"
},
"repository": {
"type": "git",
@@ -22,7 +22,7 @@
"homepage": "https://inbeta.group/?source=github",
"dependencies": {
"@vercel/style-guide": "^6.0.0",
- "eslint-config-turbo": "^1.13.3",
+ "eslint-config-turbo": "^2.0.4",
"vite-tsconfig-paths": "^4.3.2"
},
"peerDependencies": {
diff --git a/packages/feature-fetch/.eslintrc.js b/packages/feature-fetch/.eslintrc.js
index a1744d32..8c94ec40 100644
--- a/packages/feature-fetch/.eslintrc.js
+++ b/packages/feature-fetch/.eslintrc.js
@@ -3,5 +3,6 @@
*/
module.exports = {
root: true,
- extends: [require.resolve('@ibg/config/eslint/library')]
+ extends: [require.resolve('@ibg/config/eslint/library')],
+ ignorePatterns: ['src/__tests__/*']
};
diff --git a/packages/feature-fetch/README.md b/packages/feature-fetch/README.md
index a4ea3be3..fdf74137 100644
--- a/packages/feature-fetch/README.md
+++ b/packages/feature-fetch/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/packages/feature-fetch/package.json b/packages/feature-fetch/package.json
index aaa403e6..d1677aa6 100644
--- a/packages/feature-fetch/package.json
+++ b/packages/feature-fetch/package.json
@@ -11,6 +11,7 @@
"install:clean": "pnpm run clean && pnpm install",
"test": "vitest run",
"update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks",
"size": "size-limit --why"
},
"source": "./src/index.ts",
diff --git a/packages/feature-fetch/src/features/with-api/with-api.ts b/packages/feature-fetch/src/features/with-api/with-api.ts
index f4b31390..2251a61f 100644
--- a/packages/feature-fetch/src/features/with-api/with-api.ts
+++ b/packages/feature-fetch/src/features/with-api/with-api.ts
@@ -3,8 +3,6 @@ import type { TEnforceFeatures, TFeatureKeys, TFetchClient, TSelectFeatures } fr
export function withApi(
fetchClient: TFetchClient>
): TFetchClient<['api', ...GSelectedFeatureKeys]> {
- fetchClient._features.push('api');
-
const apiFeature: TSelectFeatures<['api']> = {
get(this: TFetchClient<['base']>, path, options = {}) {
return this._baseFetch(path, 'GET', options);
@@ -21,7 +19,10 @@ export function withApi(
};
// Merge existing features from the fetch client with the new api feature
- const _fetchClient = Object.assign(fetchClient, apiFeature);
+ const _fetchClient = Object.assign(fetchClient, apiFeature) as TFetchClient<
+ ['api', ...GSelectedFeatureKeys]
+ >;
+ _fetchClient._features.push('api');
- return _fetchClient as TFetchClient<['api', ...GSelectedFeatureKeys]>;
+ return _fetchClient;
}
diff --git a/packages/feature-fetch/src/features/with-openapi/with-openapi.ts b/packages/feature-fetch/src/features/with-openapi/with-openapi.ts
index 59127f79..f3a1bd2b 100644
--- a/packages/feature-fetch/src/features/with-openapi/with-openapi.ts
+++ b/packages/feature-fetch/src/features/with-openapi/with-openapi.ts
@@ -6,8 +6,6 @@ export function withOpenApi<
>(
fetchClient: TFetchClient, GPaths>
): TFetchClient<['openapi', ...GSelectedFeatureKeys], GPaths> {
- fetchClient._features.push('openapi');
-
const openApiFeature: TSelectFeatures<['openapi'], GPaths> = {
get(this: TFetchClient<['base'], GPaths>, path, options) {
return this._baseFetch(path as string, 'GET', options as any);
@@ -30,7 +28,11 @@ export function withOpenApi<
};
// Merge existing features from the fetch client with the new openapi feature
- const _fetchClient = Object.assign(fetchClient, openApiFeature);
+ const _fetchClient = Object.assign(fetchClient, openApiFeature) as TFetchClient<
+ ['openapi', ...GSelectedFeatureKeys],
+ GPaths
+ >;
+ _fetchClient._features.push('openapi');
- return _fetchClient as TFetchClient<['openapi', ...GSelectedFeatureKeys], GPaths>;
+ return _fetchClient;
}
diff --git a/packages/feature-fetch/src/helper/has-features.ts b/packages/feature-fetch/src/helper/has-features.ts
index 99c2e1eb..e33e93c4 100644
--- a/packages/feature-fetch/src/helper/has-features.ts
+++ b/packages/feature-fetch/src/helper/has-features.ts
@@ -6,6 +6,6 @@ export function hasFeatures<
>(
fetchClient: TFetchClient,
features: GHasFeatureKeys
-): fetchClient is TFetchClient {
+): fetchClient is TFetchClient<(GFeatureKeys[number] | GHasFeatureKeys[number])[]> {
return features.every((feature) => fetchClient._features.includes(feature));
}
diff --git a/packages/feature-fetch/src/types/features/index.ts b/packages/feature-fetch/src/types/features/index.ts
index 33e4a6cc..acf7ad6a 100644
--- a/packages/feature-fetch/src/types/features/index.ts
+++ b/packages/feature-fetch/src/types/features/index.ts
@@ -12,11 +12,11 @@ export type TFeatures = {
openapi: TOpenApiFeature;
retry: { _: null };
delay: { _: null };
-} & TThirdPartyFeatures;
+} & TThirdPartyFeatures;
// Global registry for third party features
// eslint-disable-next-line @typescript-eslint/no-empty-interface -- Overwritten by third party libraries
-export interface TThirdPartyFeatures {}
+export interface TThirdPartyFeatures {}
export type TFeatureKeys = keyof TFeatures;
@@ -40,6 +40,6 @@ export type TEnforceFeatures<
GFeatureKeys extends TFeatureKeys[],
GToEnforceFeatureKeys extends TFeatureKeys[]
> =
- Exclude extends never
+ Exclude extends never
? GFeatureKeys
- : GFeatureKeys | Exclude;
+ : GFeatureKeys | GToEnforceFeatureKeys;
diff --git a/packages/feature-state-react/.eslintrc.js b/packages/feature-form/.eslintrc.js
similarity index 100%
rename from packages/feature-state-react/.eslintrc.js
rename to packages/feature-form/.eslintrc.js
diff --git a/packages/feature-form/.github/banner.svg b/packages/feature-form/.github/banner.svg
new file mode 100644
index 00000000..474a27ec
--- /dev/null
+++ b/packages/feature-form/.github/banner.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/feature-form/README.md b/packages/feature-form/README.md
new file mode 100644
index 00000000..27eb8c86
--- /dev/null
+++ b/packages/feature-form/README.md
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> Status: Experimental
+
+`feature-form` is a straightforward, typesafe, and feature-based form library.
+
+- **Lightweight & Tree Shakable**: Function-based and modular design
+- **Fast**: Optimized for speed and efficiency, ensuring smooth user experience
+- **Modular & Extendable**: Easily extendable with features
+- **Typesafe**: Build with TypeScript for strong type safety
+- **Standalone**: Zero external dependencies, ensuring ease of use in various environments
+
+### 🏖️ Code Sandbox
+- [ReactJs Basic](https://codesandbox.io/p/sandbox/basic-c4gd3t)
+
+### Motivation
+
+Provide a typesafe, straightforward, and lightweight form library designed to be modular and extendable with features.
+
+### Alternatives
+- [react-hook-form](https://github.com/react-hook-form/react-hook-form)
+
+## 📖 Usage
+
+```tsx
+import { createForm, zodValidator } from 'feature-form';
+import { useForm } from 'feature-react/form';
+import * as z from 'zod';
+
+const $form = createForm({
+ fields: {
+ firstName: {
+ validator: zodValidator(z.string().min(2).max(10)),
+ defaultValue: ''
+ }
+ },
+ onValidSubmit: (data) => console.log('ValidSubmit', data),
+ onInvalidSubmit: (errors) => console.log('InvalidSubmit', errors)
+});
+
+export const Component: React.FC = () => {
+ const { handleSubmit, register, status } = useForm($form);
+
+ return (
+
+ );
+}
+```
+
+### Validators
+
+Supports various validators such as [Zod](https://github.com/colinhacks/zod), [Yup](https://github.com/jquense/yup), [Valibot](https://github.com/fabian-hiller/valibot) and more.
+
+```ts
+import { zodValidator, valibotValidator } from 'feature-form';
+import * as z from 'zod';
+import * as v from 'valibot';
+
+const zodNameValidator = zodValidator(
+ z.string().min(2).max(10).regex(/^([^0-9]*)$/)
+);
+
+const valibotNameValidator = valibotValidator(
+ v.pipe(v.string(), v.minLength(2), v.maxLength(10), v.regex(/^([^0-9]*)$/))
+);
+```
\ No newline at end of file
diff --git a/packages/feature-state-react/package.json b/packages/feature-form/package.json
similarity index 68%
rename from packages/feature-state-react/package.json
rename to packages/feature-form/package.json
index bdbdf080..3707b3e5 100644
--- a/packages/feature-state-react/package.json
+++ b/packages/feature-form/package.json
@@ -1,7 +1,7 @@
{
- "name": "feature-state-react",
- "description": "ReactJs extension features for feature-state",
- "version": "0.0.7",
+ "name": "feature-form",
+ "description": "Straightforward, typesafe, and feature-based form library for ReactJs",
+ "version": "0.0.6",
"private": false,
"scripts": {
"build": "shx rm -rf dist && ../../scripts/cli.sh bundle",
@@ -11,6 +11,7 @@
"install:clean": "pnpm run clean && pnpm install",
"test": "vitest run",
"update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks",
"size": "size-limit --why"
},
"source": "./src/index.ts",
@@ -27,18 +28,17 @@
"bugs": {
"url": "https://github.com/inbeta-group/monorepo/issues"
},
- "homepage": "https://inbeta.group/?source=package-json",
+ "homepage": "https://inbeta.group/?source=github",
"dependencies": {
+ "@ibg/utils": "workspace:*",
"feature-state": "workspace:*"
},
- "peerDependencies": {
- "react": "^18.2.0"
- },
"devDependencies": {
"@ibg/config": "workspace:*",
"@types/node": "^20.14.1",
- "@types/react": "^18.3.3",
- "react": "^18.3.1"
+ "valibot": "^0.35.0",
+ "yup": "^1.4.0",
+ "zod": "^3.23.8"
},
"files": [
"dist",
@@ -46,7 +46,12 @@
],
"size-limit": [
{
- "path": "dist/esm/index.js"
+ "path": "dist/esm/index.js",
+ "ignore": [
+ "zod",
+ "yup",
+ "valibot"
+ ]
}
]
}
diff --git a/packages/feature-form/src/create-form.test.ts b/packages/feature-form/src/create-form.test.ts
new file mode 100644
index 00000000..216c2c9c
--- /dev/null
+++ b/packages/feature-form/src/create-form.test.ts
@@ -0,0 +1,69 @@
+import * as v from 'valibot';
+import { describe, expect, it } from 'vitest';
+import * as yup from 'yup';
+import * as zod from 'zod';
+
+import { createForm, fromValidator } from './create-form';
+import { createValidator, valibotValidator, yupValidator, zodValidator } from './form-field';
+
+describe('createForm function', () => {
+ it('shoudl work', async () => {
+ const form = createForm({
+ onSubmit: (data) => {
+ console.log(data);
+ },
+ fields: {
+ item1: fromValidator(yupValidator(yup.number().required().positive().integer()), {
+ defaultValue: 10
+ }),
+ item2: fromValidator(zodValidator(zod.string().email()), {
+ defaultValue: 'test@gmail.com'
+ }),
+ item3: fromValidator(
+ yupValidator(
+ yup.object({
+ nested: yup.string().required()
+ })
+ ),
+ { defaultValue: { nested: 'object' } }
+ ),
+ item4: fromValidator(
+ valibotValidator(
+ v.object({
+ name: v.string(),
+ url: v.pipe(v.string(), v.url())
+ })
+ ),
+ {
+ defaultValue: {
+ name: 'Jeff',
+ url: 'https://jeff.com'
+ }
+ }
+ ),
+ item5: fromValidator(
+ createValidator([
+ {
+ key: 'date',
+ validate: (formField) => {
+ if (formField._value != null) {
+ const date = new Date(formField._value);
+ }
+ }
+ }
+ ]),
+ {
+ defaultValue: Date.now()
+ }
+ )
+ }
+ });
+
+ const test = form.getField('item3');
+
+ const isValid = await form.validate();
+ await form.submit();
+
+ expect(form).not.toBeNull();
+ });
+});
diff --git a/packages/feature-form/src/create-form.ts b/packages/feature-form/src/create-form.ts
new file mode 100644
index 00000000..7e7d83b1
--- /dev/null
+++ b/packages/feature-form/src/create-form.ts
@@ -0,0 +1,262 @@
+import { bitwiseFlag, deepCopy, type BitwiseFlag, type TEntries } from '@ibg/utils';
+
+import { createFormField } from './form-field';
+import {
+ FormFieldReValidateMode,
+ FormFieldValidateMode,
+ type TForm,
+ type TFormConfig,
+ type TFormData,
+ type TFormFields,
+ type TFormFieldStateConfig,
+ type TFormFieldValidator,
+ type TInvalidFormFieldError,
+ type TInvalidFormFieldErrors,
+ type TInvalidSubmitCallback,
+ type TValidSubmitCallback
+} from './types';
+
+export function createForm(
+ config: TCreateFormConfig
+): TForm {
+ const {
+ fields,
+ collectErrorMode = 'firstError',
+ disabled = false,
+ validateMode = bitwiseFlag(FormFieldValidateMode.OnSubmit),
+ reValidateMode = bitwiseFlag(FormFieldReValidateMode.OnBlur),
+ onValidSubmit,
+ onInvalidSubmit,
+ notifyOnStatusChange = true
+ } = config;
+
+ const form: TForm = {
+ _: null,
+ _features: ['base'],
+ _config: {
+ collectErrorMode,
+ disabled
+ },
+ _validSubmitCallbacks: onValidSubmit != null ? [onValidSubmit] : [],
+ _invalidSubmitCallbacks: onInvalidSubmit != null ? [onInvalidSubmit] : [],
+ fields: Object.fromEntries(
+ Object.entries(fields).map(
+ ([fieldKey, field]: [string, TCreateFormConfigFormField]) => [
+ fieldKey,
+ createFormField(field.defaultValue, {
+ key: fieldKey,
+ validator: field.validator,
+ collectErrorMode: field.collectErrorMode ?? collectErrorMode,
+ validateMode: field.validateMode ?? validateMode,
+ reValidateMode: field.reValidateMode ?? reValidateMode,
+ editable: field.editable ?? true,
+ notifyOnStatusChange
+ })
+ ]
+ )
+ ) as TFormFields,
+ isValid: false,
+ isValidating: false,
+ isSubmitted: false,
+ isSubmitting: false,
+ async _revalidate(this: TForm, cached = false) {
+ const formFields = Object.values(this.fields) as TFormFields[keyof GFormData][];
+
+ if (!cached) {
+ this.isValidating = true;
+ await Promise.all(formFields.map((formField) => formField.validate()));
+ this.isValidating = false;
+ }
+
+ this.isValid = formFields.every((formField) => formField.isValid());
+ return this.isValid;
+ },
+
+ async submit(this: TForm, options = {}) {
+ const {
+ additionalData,
+ assignToInitial = false,
+ onInvalidSubmit: _onInvalidSubmit,
+ onValidSubmit: _onValidSubmit
+ } = options;
+ this.isSubmitting = true;
+
+ const validationPromises: Promise[] = [];
+ for (const formField of Object.values(
+ this.fields
+ ) as TFormFields[keyof GFormData][]) {
+ if (
+ (formField.isSubmitted &&
+ formField._config.reValidateMode.has(FormFieldReValidateMode.OnSubmit)) ||
+ (!formField.isSubmitted &&
+ formField._config.validateMode.has(FormFieldValidateMode.OnSubmit))
+ ) {
+ validationPromises.push(formField.validate());
+ }
+ this.isSubmitting = true;
+ }
+ await Promise.all(validationPromises);
+
+ const data = this.getData();
+ if (data != null) {
+ const promises = this._validSubmitCallbacks.map((callback) =>
+ callback(data, additionalData)
+ );
+ if (typeof _onValidSubmit === 'function') {
+ promises.push(_onValidSubmit(data, additionalData));
+ }
+ await Promise.all(promises);
+ } else {
+ const errors = this.getErrors();
+ const promises = this._invalidSubmitCallbacks.map((callback) =>
+ callback(errors, additionalData)
+ );
+ if (typeof _onInvalidSubmit === 'function') {
+ promises.push(_onInvalidSubmit(errors, additionalData));
+ }
+ await Promise.all(promises);
+ }
+
+ for (const [fieldKey, formField] of Object.entries(this.fields) as TEntries<
+ TFormFields
+ >) {
+ if (data != null && Object.prototype.hasOwnProperty.call(data, fieldKey)) {
+ if (assignToInitial) {
+ formField._intialValue = deepCopy(data[fieldKey]);
+ }
+ }
+ formField.isSubmitted = true;
+ formField.isSubmitting = false;
+ }
+
+ this.isSubmitted = true;
+ this.isSubmitting = false;
+
+ return this.isValid;
+ },
+ async validate(this: TForm) {
+ return this._revalidate(false);
+ },
+ getField(this: TForm, fieldKey) {
+ return this.fields[fieldKey];
+ },
+ getData(this: TForm) {
+ if (!this.isValid) {
+ return null;
+ }
+
+ // @ts-expect-error - Filled below
+ const preparedData: Readonly = {};
+
+ for (const [fieldKey, formField] of Object.entries(this.fields) as TEntries<
+ TFormFields
+ >) {
+ // @ts-expect-error - GFormFields is based on GFormData and the keys should be identical
+ preparedData[fieldKey] = formField.get();
+ }
+
+ return preparedData;
+ },
+ getErrors(this: TForm) {
+ const errors: TInvalidFormFieldErrors = {};
+
+ for (const [fieldKey, formField] of Object.entries(this.fields) as TEntries<
+ TFormFields
+ >) {
+ switch (formField.status._value.type) {
+ case 'INVALID':
+ errors[fieldKey] = formField.status._value.errors;
+ break;
+ case 'UNVALIDATED':
+ errors[fieldKey] = [
+ {
+ code: 'unvalidated',
+ message: `${fieldKey.toString()} was not yet validated!`,
+ path: fieldKey
+ } as TInvalidFormFieldError
+ ];
+ break;
+ default:
+ }
+ }
+
+ return errors;
+ },
+ reset(this: TForm) {
+ for (const formField of Object.values(
+ this.fields
+ ) as TFormFields[keyof GFormData][]) {
+ formField.reset();
+ }
+ this.isSubmitted = false;
+ // isValid is reset by form field
+ }
+ };
+
+ for (const field of Object.values(form.fields) as TFormFields[keyof GFormData][]) {
+ field.listen(
+ async ({ source }) => {
+ if (source === 'set') {
+ if (
+ (field.isSubmitted &&
+ field._config.reValidateMode.has(FormFieldReValidateMode.OnChange)) ||
+ (!field.isSubmitted &&
+ field._config.validateMode.has(FormFieldValidateMode.OnChange)) ||
+ (field._config.validateMode.has(FormFieldValidateMode.OnTouched) && field.isTouched)
+ ) {
+ await field.validate();
+ }
+ }
+ },
+ { key: 'form' }
+ );
+ field.status.listen(
+ async () => {
+ await form._revalidate(true);
+ },
+ { key: 'form' }
+ );
+ }
+
+ return form;
+}
+
+export interface TCreateFormConfig extends Partial {
+ /**
+ * Form fields
+ */
+ fields: TCreateFormConfigFormFields;
+ /**
+ * Validation strategy **before** submitting.
+ */
+ validateMode?: BitwiseFlag;
+ /**
+ * Validation strategy **after** submitting.
+ */
+ reValidateMode?: BitwiseFlag;
+ /**
+ * Whether to notify the form field if its status has changed
+ */
+ notifyOnStatusChange?: boolean;
+
+ onInvalidSubmit?: TInvalidSubmitCallback;
+ onValidSubmit?: TValidSubmitCallback;
+}
+
+export type TCreateFormConfigFormFields = {
+ [Key in keyof GFormData]: TCreateFormConfigFormField;
+};
+
+export interface TCreateFormConfigFormField extends Partial {
+ defaultValue?: GValue;
+ validator: TFormFieldValidator;
+}
+
+// Helper function to make type inference work
+// https://github.com/microsoft/TypeScript/issues/26242
+export function fromValidator(
+ validator: TFormFieldValidator,
+ config: Omit, 'validator'>
+): TCreateFormConfigFormField {
+ return { validator, ...config };
+}
diff --git a/packages/feature-form/src/form-field/create-form-field.ts b/packages/feature-form/src/form-field/create-form-field.ts
new file mode 100644
index 00000000..8fde9bf4
--- /dev/null
+++ b/packages/feature-form/src/form-field/create-form-field.ts
@@ -0,0 +1,96 @@
+import { createState } from 'feature-state';
+import { bitwiseFlag, deepCopy } from '@ibg/utils';
+
+import {
+ FormFieldReValidateMode,
+ FormFieldValidateMode,
+ type TFormField,
+ type TFormFieldStateConfig,
+ type TFormFieldStateFeature,
+ type TFormFieldValidator
+} from '../types';
+import { createStatus } from './create-status';
+
+export function createFormField(
+ initialValue: GValue | undefined,
+ config: TCreateFormFieldConfig
+): TFormField {
+ const {
+ key,
+ validator,
+ editable = true,
+ reValidateMode = bitwiseFlag(FormFieldReValidateMode.OnBlur),
+ validateMode = bitwiseFlag(FormFieldValidateMode.OnSubmit),
+ collectErrorMode = 'firstError',
+ notifyOnStatusChange = true
+ } = config;
+ const formFieldState = createState(initialValue);
+
+ const status = createStatus({ type: 'UNVALIDATED' });
+
+ // Notify form field listeners if status has changed
+ if (notifyOnStatusChange) {
+ status.listen(
+ () => {
+ formFieldState._notify({ additionalData: { source: 'status' } });
+ },
+ { key: 'form-field' }
+ );
+ }
+
+ const formFieldFeature: TFormFieldStateFeature = {
+ _config: {
+ editable,
+ validateMode,
+ reValidateMode,
+ collectErrorMode
+ },
+ _intialValue: deepCopy(formFieldState._value),
+ key,
+ isTouched: false,
+ isSubmitted: false,
+ isSubmitting: false,
+ validator,
+ status,
+ async validate(this: TFormField) {
+ return this.validator.validate(this);
+ },
+ isValid(this: TFormField) {
+ return this.status.get().type === 'VALID';
+ },
+ blur(this: TFormField) {
+ if (
+ (this.isSubmitted && this._config.reValidateMode.has(FormFieldReValidateMode.OnBlur)) ||
+ (!this.isSubmitted &&
+ (this._config.validateMode.has(FormFieldValidateMode.OnBlur) ||
+ (this._config.validateMode.has(FormFieldValidateMode.OnTouched) && !this.isTouched)))
+ ) {
+ void this.validate();
+ }
+
+ this.isTouched = true;
+ },
+ reset(this: TFormField) {
+ this.set(this._intialValue);
+ this.isTouched = false;
+ this.isSubmitted = false;
+ this.isSubmitting = false;
+ this.status.set({ type: 'UNVALIDATED' });
+ }
+ };
+
+ // Merge existing features from the state with the new form field feature
+ const _formField = Object.assign(
+ formFieldState,
+ formFieldFeature
+ ) as unknown as TFormField;
+ _formField._features.push('form-field');
+
+ return _formField;
+}
+
+export interface TCreateFormFieldConfig extends Partial {
+ key: string;
+ validator: TFormFieldValidator;
+ notifyOnStatusChange?: boolean;
+}
diff --git a/packages/feature-form/src/form-field/create-status.ts b/packages/feature-form/src/form-field/create-status.ts
new file mode 100644
index 00000000..259d7522
--- /dev/null
+++ b/packages/feature-form/src/form-field/create-status.ts
@@ -0,0 +1,27 @@
+import { createState, type TSelectFeatures } from 'feature-state';
+
+import { type TFormFieldStatus, type TFormFieldStatusValue } from '../types';
+
+export function createStatus(initialValue: TFormFieldStatusValue): TFormFieldStatus {
+ const formFieldStatusState = createState(initialValue);
+
+ const formFieldStatusFeature: TSelectFeatures = {
+ _nextValue: undefined,
+ registerNextError(this: TFormFieldStatus, error) {
+ if (this._nextValue?.type === 'INVALID') {
+ this._nextValue.errors.push(error);
+ } else {
+ this._nextValue = { type: 'INVALID', errors: [error] };
+ }
+ }
+ };
+
+ // Merge existing features from the state with the new form field status feature
+ const _formFieldStatus = Object.assign(
+ formFieldStatusState,
+ formFieldStatusFeature
+ ) as unknown as TFormFieldStatus;
+ _formFieldStatus._features.push('form-field-status');
+
+ return _formFieldStatus;
+}
diff --git a/packages/feature-form/src/form-field/index.ts b/packages/feature-form/src/form-field/index.ts
new file mode 100644
index 00000000..76d37ab2
--- /dev/null
+++ b/packages/feature-form/src/form-field/index.ts
@@ -0,0 +1,4 @@
+export * from './create-form-field';
+export * from './create-status';
+export * from './is-form-field';
+export * from './validator';
diff --git a/packages/feature-form/src/form-field/is-form-field.ts b/packages/feature-form/src/form-field/is-form-field.ts
new file mode 100644
index 00000000..4ac3b609
--- /dev/null
+++ b/packages/feature-form/src/form-field/is-form-field.ts
@@ -0,0 +1,11 @@
+import { type TFormField } from '../types';
+
+export function isFormField(value: unknown): value is TFormField {
+ return (
+ typeof value === 'object' &&
+ value != null &&
+ '_features' in value &&
+ Array.isArray(value._features) &&
+ value._features.includes('form-field')
+ );
+}
diff --git a/packages/feature-form/src/form-field/validator/create-validator.ts b/packages/feature-form/src/form-field/validator/create-validator.ts
new file mode 100644
index 00000000..3af847cf
--- /dev/null
+++ b/packages/feature-form/src/form-field/validator/create-validator.ts
@@ -0,0 +1,47 @@
+import { deepCopy } from '@ibg/utils';
+
+import { type TFormFieldValidationChain, type TFormFieldValidator } from '../../types';
+
+export function createValidator(
+ validationChain: TFormFieldValidationChain
+): TFormFieldValidator {
+ return {
+ _validationChain: validationChain,
+ isValidating: false,
+ push(validateFunctions) {
+ this._validationChain.push(validateFunctions);
+ },
+ append(validator) {
+ this._validationChain.push(...validator._validationChain);
+ return this;
+ },
+ async validate(formField) {
+ this.isValidating = true;
+
+ for (const validationLink of this._validationChain) {
+ await validationLink.validate(formField);
+ if (
+ formField._config.collectErrorMode === 'firstError' &&
+ formField.status._nextValue?.type === 'INVALID'
+ ) {
+ break;
+ }
+ }
+
+ // If no error was registered we assume its valid
+ if (formField.status._nextValue == null) {
+ formField.status.set({ type: 'VALID' });
+ } else {
+ formField.status.set(formField.status._nextValue);
+ }
+
+ formField.status._nextValue = undefined;
+ this.isValidating = false;
+
+ return formField.status.get().type === 'VALID';
+ },
+ clone() {
+ return createValidator(deepCopy(this._validationChain));
+ }
+ };
+}
diff --git a/packages/feature-form/src/form-field/validator/index.ts b/packages/feature-form/src/form-field/validator/index.ts
new file mode 100644
index 00000000..24289af6
--- /dev/null
+++ b/packages/feature-form/src/form-field/validator/index.ts
@@ -0,0 +1,4 @@
+export * from './create-validator';
+export * from './valibot';
+export * from './yup';
+export * from './zod';
diff --git a/packages/feature-form/src/form-field/validator/valibot.ts b/packages/feature-form/src/form-field/validator/valibot.ts
new file mode 100644
index 00000000..203054ac
--- /dev/null
+++ b/packages/feature-form/src/form-field/validator/valibot.ts
@@ -0,0 +1,29 @@
+import { getDotPath, safeParseAsync, type BaseIssue, type BaseSchema } from 'valibot';
+
+import { type TFormFieldValidator } from '../../types';
+import { createValidator } from './create-validator';
+
+export function valibotValidator(
+ schema: BaseSchema>
+): TFormFieldValidator {
+ return createValidator([
+ {
+ key: 'valibot',
+ validate: async (formField) => {
+ const result = await safeParseAsync(schema, formField.get(), {
+ abortPipeEarly: formField._config.collectErrorMode === 'firstError'
+ });
+
+ if (result.issues != null) {
+ for (const issue of result.issues) {
+ formField.status.registerNextError({
+ code: issue.type,
+ message: issue.message,
+ path: getDotPath(issue) ?? undefined
+ });
+ }
+ }
+ }
+ }
+ ]);
+}
diff --git a/packages/feature-form/src/form-field/validator/yup.ts b/packages/feature-form/src/form-field/validator/yup.ts
new file mode 100644
index 00000000..3738d6cf
--- /dev/null
+++ b/packages/feature-form/src/form-field/validator/yup.ts
@@ -0,0 +1,48 @@
+import { ValidationError, type Schema } from 'yup';
+
+import { type TFormFieldValidator } from '../../types';
+import { createValidator } from './create-validator';
+
+export function yupValidator(schema: Schema): TFormFieldValidator {
+ return createValidator([
+ {
+ key: 'yup',
+ validate: async (formField) => {
+ try {
+ await schema.validate(formField.get(), {
+ abortEarly: formField._config.collectErrorMode === 'firstError'
+ });
+ } catch (err) {
+ if (isYupError(err)) {
+ if (err.inner.length === 0) {
+ formField.status.registerNextError({
+ code: err.type ?? 'unknown',
+ message: err.message.replace('this', formField.key),
+ path: err.path
+ });
+ }
+ for (const innerErr of err.inner) {
+ formField.status.registerNextError({
+ code: innerErr.type ?? 'unknown',
+ message: innerErr.message.replace('this', formField.key),
+ path: innerErr.path
+ });
+ }
+ } else {
+ console.warn(
+ 'Parse error is not an instance of a ValidationError. Ensure Yup is correctly installed.',
+ err
+ );
+ }
+ }
+ }
+ }
+ ]);
+}
+
+export function isYupError(err: unknown): err is ValidationError {
+ return (
+ err instanceof ValidationError ||
+ (err instanceof Error && 'inner' in err && Array.isArray(err.inner))
+ );
+}
diff --git a/packages/feature-form/src/form-field/validator/zod.ts b/packages/feature-form/src/form-field/validator/zod.ts
new file mode 100644
index 00000000..e03a21b9
--- /dev/null
+++ b/packages/feature-form/src/form-field/validator/zod.ts
@@ -0,0 +1,39 @@
+import { ZodError, type Schema } from 'zod';
+
+import { type TFormFieldValidator } from '../../types';
+import { createValidator } from './create-validator';
+
+export function zodValidator(schema: Schema): TFormFieldValidator {
+ return createValidator([
+ {
+ key: 'zod',
+ validate: (formField) => {
+ try {
+ schema.parse(formField.get());
+ } catch (err) {
+ if (isZodError(err)) {
+ for (const issue of err.errors) {
+ formField.status.registerNextError({
+ code: issue.code,
+ message: issue.message,
+ path: issue.path.join('.')
+ });
+ }
+ } else {
+ console.warn(
+ 'Parse error is not an instance of a ZodError. Ensure Zod is correctly installed.',
+ err
+ );
+ }
+ }
+ }
+ }
+ ]);
+}
+
+export function isZodError(err: unknown): err is ZodError {
+ return (
+ err instanceof ZodError ||
+ (err instanceof Error && 'errors' in err && Array.isArray(err.errors))
+ );
+}
diff --git a/packages/feature-form/src/has-features.ts b/packages/feature-form/src/has-features.ts
new file mode 100644
index 00000000..4d8ba208
--- /dev/null
+++ b/packages/feature-form/src/has-features.ts
@@ -0,0 +1,12 @@
+import type { TFeatureKeys, TForm, TFormData } from './types';
+
+export function hasFeatures<
+ GFormData extends TFormData,
+ GFeatureKeys extends TFeatureKeys[],
+ GHasFeatureKeys extends TFeatureKeys[]
+>(
+ form: TForm,
+ features: GHasFeatureKeys
+): form is TForm {
+ return features.every((feature) => form._features.includes(feature));
+}
diff --git a/packages/feature-form/src/index.ts b/packages/feature-form/src/index.ts
new file mode 100644
index 00000000..409158ba
--- /dev/null
+++ b/packages/feature-form/src/index.ts
@@ -0,0 +1,13 @@
+import type { TFormFieldStateFeature, TFormFielStatusStateFeature } from './types';
+
+export { BitwiseFlag, bitwiseFlag } from '@ibg/utils';
+export * from './create-form';
+export * from './form-field';
+export * from './types';
+
+declare module 'feature-state' {
+ interface TThirdPartyFeatures {
+ 'form-field': TFormFieldStateFeature;
+ 'form-field-status': TFormFielStatusStateFeature;
+ }
+}
diff --git a/packages/feature-form/src/types/features.ts b/packages/feature-form/src/types/features.ts
new file mode 100644
index 00000000..35f7decc
--- /dev/null
+++ b/packages/feature-form/src/types/features.ts
@@ -0,0 +1,37 @@
+import type { TUnionToIntersection } from '@ibg/utils';
+
+import { type TFormData } from './form';
+
+export type TFeatures = {
+ base: { _: null }; // TODO: Placeholder Feature: Figure out how to make the TS infer work with [] (empty array -> no feature)
+} & TThirdPartyFeatures;
+
+// Global registry for third party features
+// eslint-disable-next-line @typescript-eslint/no-empty-interface -- Overwritten by third party libraries
+export interface TThirdPartyFeatures {}
+
+export type TFeatureKeys = keyof TFeatures;
+
+export type TSelectFeatureObjects<
+ GFormData extends TFormData,
+ GSelectedFeatureKeys extends TFeatureKeys[]
+> = {
+ [K in GSelectedFeatureKeys[number]]: TFeatures[K];
+};
+
+export type TSelectFeatures<
+ GFormData extends TFormData,
+ GSelectedFeatureKeys extends TFeatureKeys[],
+ GSelectedFeatureObjects extends TSelectFeatureObjects<
+ GFormData,
+ GSelectedFeatureKeys
+ > = TSelectFeatureObjects
+> = TUnionToIntersection;
+
+export type TEnforceFeatures<
+ GFeatureKeys extends TFeatureKeys[],
+ GToEnforceFeatureKeys extends TFeatureKeys[]
+> =
+ Exclude extends never
+ ? GFeatureKeys
+ : GFeatureKeys | GToEnforceFeatureKeys;
diff --git a/packages/feature-form/src/types/form-field.ts b/packages/feature-form/src/types/form-field.ts
new file mode 100644
index 00000000..4653f6f0
--- /dev/null
+++ b/packages/feature-form/src/types/form-field.ts
@@ -0,0 +1,105 @@
+import { type TState } from 'feature-state';
+import { type BitwiseFlag } from '@ibg/utils';
+
+import { type TCollectErrorMode } from './form';
+
+export type TFormField = TState;
+
+export interface TFormFieldStateFeature {
+ _config: TFormFieldStateConfig;
+ _intialValue: GValue | undefined;
+ key: string;
+ isTouched: boolean;
+ isSubmitted: boolean;
+ isSubmitting: boolean;
+ validator: TFormFieldValidator;
+ status: TFormFieldStatus;
+ validate: () => Promise;
+ isValid: () => boolean;
+ blur: () => void;
+ reset: () => void;
+}
+
+export interface TFormFieldStateConfig {
+ editable: boolean;
+ /**
+ * Validation strategy before submitting.
+ */
+ // TODO: Is BitwiseFlag to confusing for enduser?
+ validateMode: BitwiseFlag;
+ /**
+ * Validation strategy after submitting.
+ */
+ // TODO: Is BitwiseFlag to confusing for enduser?
+ reValidateMode: BitwiseFlag;
+ collectErrorMode: TCollectErrorMode;
+}
+
+export enum FormFieldValidateMode {
+ // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member, no-bitwise -- ok here
+ OnBlur = 1 << 0, // 1
+ // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member, no-bitwise -- ok here
+ OnChange = 1 << 1, // 2
+ // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member, no-bitwise -- ok here
+ OnSubmit = 1 << 2, // 4
+ // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member, no-bitwise -- ok here
+ OnTouched = 1 << 3 // 8
+}
+
+export enum FormFieldReValidateMode {
+ // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member, no-bitwise -- ok here
+ OnBlur = 1 << 0, // 1
+ // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member, no-bitwise -- ok here
+ OnChange = 1 << 1, // 2
+ // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member, no-bitwise -- ok here
+ OnSubmit = 1 << 2 // 4
+}
+
+export type TFormFieldStatus = TState;
+
+export interface TFormFielStatusStateFeature {
+ _nextValue?: TFormFieldStatusValue;
+ registerNextError: (error: TInvalidFormFieldError) => void;
+}
+
+export type TFormFieldStatusValue =
+ | TInvalidFormFieldStatus
+ | TValidFormFieldStatus
+ | TUnvalidatedFormFieldStatus;
+
+export interface TInvalidFormFieldStatus {
+ type: 'INVALID';
+ errors: TInvalidFormFieldError[];
+}
+
+export interface TValidFormFieldStatus {
+ type: 'VALID';
+}
+
+export interface TUnvalidatedFormFieldStatus {
+ type: 'UNVALIDATED';
+}
+
+export interface TInvalidFormFieldError {
+ code: string;
+ message?: string;
+ path?: string;
+}
+
+export type TValidateFormFieldFunction = (
+ formField: TFormField
+) => Promise | void;
+export interface TFormFieldValidationLink {
+ key: string;
+ validate: TValidateFormFieldFunction;
+}
+export type TFormFieldValidationChain = TFormFieldValidationLink[];
+
+export interface TFormFieldValidator {
+ _validationChain: TFormFieldValidationChain;
+ isValidating: boolean;
+ validate: (formField: TFormField) => Promise;
+ append: (validator: TFormFieldValidator) => TFormFieldValidator;
+ clone: () => TFormFieldValidator;
+ push: (...validateFunctions: TFormFieldValidationLink[]) => void;
+}
diff --git a/packages/feature-form/src/types/form.ts b/packages/feature-form/src/types/form.ts
new file mode 100644
index 00000000..4d21e06d
--- /dev/null
+++ b/packages/feature-form/src/types/form.ts
@@ -0,0 +1,77 @@
+import { type TFeatureKeys, type TSelectFeatures } from './features';
+import {
+ type TFormField,
+ type TFormFieldValidator,
+ type TInvalidFormFieldError
+} from './form-field';
+
+// Note: TForm is not itself a state because of type issues mainly because GFormData is the main generic,
+// but the State value was TFormFields. Thus We had to check if GValue extends TFormFields,
+// which was unreliable in TypeScript if deeply nested.
+export type TForm = {
+ _features: string[];
+ _config: TFormConfig;
+ _validSubmitCallbacks: TValidSubmitCallback[];
+ _invalidSubmitCallbacks: TInvalidSubmitCallback[];
+ fields: TFormFields;
+ isValid: boolean;
+ isValidating: boolean;
+ isSubmitted: boolean;
+ isSubmitting: boolean;
+ _revalidate: (cached?: boolean) => Promise;
+ submit: (options?: TSubmitOptions) => Promise;
+ validate: () => Promise;
+ getField: >(key: GKey) => TFormFields[GKey];
+ getData: () => Readonly | null;
+ getErrors: () => TInvalidFormFieldErrors;
+ reset: () => void;
+} & TSelectFeatures;
+
+export type TFormFields = {
+ [Key in keyof GFormData]: TFormField;
+};
+
+export type TFormValidators = {
+ [Key in keyof GFormData]: TFormFieldValidator;
+};
+
+export type TFormData = Record;
+
+export interface TSubmitOptions {
+ onValidSubmit?: TValidSubmitCallback;
+ onInvalidSubmit?: TInvalidSubmitCallback;
+ additionalData?: TAdditionalSubmitCallbackData;
+ assignToInitial?: boolean;
+}
+
+export type TValidSubmitCallback = (
+ formData: Readonly,
+ additionalData?: TAdditionalSubmitCallbackData
+) => Promise | void;
+
+export type TInvalidSubmitCallback = (
+ errors: TInvalidFormFieldErrors,
+ additionalData?: TAdditionalSubmitCallbackData
+) => Promise | void;
+
+export interface TAdditionalSubmitCallbackData {
+ [key: string]: unknown;
+ event?: unknown;
+}
+
+export type TInvalidFormFieldErrors = {
+ [Key in keyof GFormData]?: Readonly;
+};
+
+export interface TFormConfig {
+ /**
+ * Indicates if the form is disabled.
+ */
+ disabled: boolean;
+ /**
+ * Error collection mode. 'firstError' gathers only the first error per field, 'all' gathers all errors.
+ */
+ collectErrorMode: TCollectErrorMode;
+}
+
+export type TCollectErrorMode = 'firstError' | 'all';
diff --git a/packages/feature-form/src/types/index.ts b/packages/feature-form/src/types/index.ts
new file mode 100644
index 00000000..70e7bffe
--- /dev/null
+++ b/packages/feature-form/src/types/index.ts
@@ -0,0 +1,3 @@
+export * from './features';
+export * from './form';
+export * from './form-field';
diff --git a/packages/feature-form/tsconfig.json b/packages/feature-form/tsconfig.json
new file mode 100644
index 00000000..ef0489a9
--- /dev/null
+++ b/packages/feature-form/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@ibg/config/shared-library.tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declarationDir": "./dist/types"
+ },
+ "include": ["src"]
+}
diff --git a/packages/feature-state-react/tsconfig.prod.json b/packages/feature-form/tsconfig.prod.json
similarity index 100%
rename from packages/feature-state-react/tsconfig.prod.json
rename to packages/feature-form/tsconfig.prod.json
diff --git a/packages/feature-state-react/vitest.config.js b/packages/feature-form/vitest.config.js
similarity index 100%
rename from packages/feature-state-react/vitest.config.js
rename to packages/feature-form/vitest.config.js
diff --git a/packages/feature-logger/README.md b/packages/feature-logger/README.md
index 48bbfcd1..c1a388b7 100644
--- a/packages/feature-logger/README.md
+++ b/packages/feature-logger/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/packages/feature-logger/package.json b/packages/feature-logger/package.json
index 986039f2..efddea71 100644
--- a/packages/feature-logger/package.json
+++ b/packages/feature-logger/package.json
@@ -11,6 +11,7 @@
"install:clean": "pnpm run clean && pnpm install",
"test": "vitest run",
"update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks",
"size": "size-limit --why"
},
"source": "./src/index.ts",
diff --git a/packages/feature-logger/src/__tests__/mock-console.ts b/packages/feature-logger/src/__tests__/mock-console.ts
index 19086f7a..5fb6ffac 100644
--- a/packages/feature-logger/src/__tests__/mock-console.ts
+++ b/packages/feature-logger/src/__tests__/mock-console.ts
@@ -1,4 +1,4 @@
-import { MockInstance, vi } from 'vitest';
+import { type MockInstance, vi } from 'vitest';
export function mockConsole(spyOnMethods: TConsoleMethod[], consoleSpies: TConsoleSpies) {
spyOnMethods.forEach((type) => {
diff --git a/packages/feature-logger/src/create-logger.test.ts b/packages/feature-logger/src/create-logger.test.ts
index cfa3068c..6ada7f20 100644
--- a/packages/feature-logger/src/create-logger.test.ts
+++ b/packages/feature-logger/src/create-logger.test.ts
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { mockConsole, restoreConsoleMock, TConsoleSpies } from './__tests__/mock-console';
+import { mockConsole, restoreConsoleMock, type TConsoleSpies } from './__tests__/mock-console';
import { createLogger, LOG_LEVEL } from './create-logger';
-import { TLoggerMiddleware } from './types';
+import { type TLoggerMiddleware } from './types';
describe('createLogger function', () => {
const consoleSpies: TConsoleSpies = {};
diff --git a/packages/feature-logger/src/create-logger.ts b/packages/feature-logger/src/create-logger.ts
index 4c602392..9061b8ca 100644
--- a/packages/feature-logger/src/create-logger.ts
+++ b/packages/feature-logger/src/create-logger.ts
@@ -1,11 +1,7 @@
-import { TInvokeConsole, TLogger, TLoggerConfig, TLoggerOptions, TLogMethod } from './types';
+import { type TInvokeConsole, type TLogger, type TLoggerOptions, type TLogMethod } from './types';
export function createLogger(options: TLoggerOptions = {}): TLogger<['base']> {
- const config: TLoggerConfig = {
- active: options.active ?? true,
- level: options.level ?? 0,
- middlewares: options.middlewares ?? []
- };
+ const { active = true, level = 0, middlewares = [] } = options;
let invokeConsole: TInvokeConsole;
if (typeof options.invokeConsole === 'function') {
@@ -19,7 +15,11 @@ export function createLogger(options: TLoggerOptions = {}): TLogger<['base']> {
return {
_: null,
_features: ['base'],
- _config: config,
+ _config: {
+ active,
+ level,
+ middlewares
+ },
_invokeConsole: invokeConsole,
_baseLog(category, data) {
if (this._config.active && category.level >= this._config.level) {
diff --git a/packages/feature-logger/src/features/with-method-prefix.test.ts b/packages/feature-logger/src/features/with-method-prefix.test.ts
index aab33071..548e6a13 100644
--- a/packages/feature-logger/src/features/with-method-prefix.test.ts
+++ b/packages/feature-logger/src/features/with-method-prefix.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { mockConsole, restoreConsoleMock, TConsoleSpies } from '../__tests__/mock-console';
+import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console';
import { createLogger } from '../create-logger';
import { withMethodPrefix } from './with-method-prefix';
import { withTimestamp } from './with-timestamp';
diff --git a/packages/feature-logger/src/features/with-method-prefix.ts b/packages/feature-logger/src/features/with-method-prefix.ts
index 551a84e9..66671295 100644
--- a/packages/feature-logger/src/features/with-method-prefix.ts
+++ b/packages/feature-logger/src/features/with-method-prefix.ts
@@ -1,5 +1,5 @@
import { hasFeatures } from '../has-features';
-import { TEnforceFeatures, TFeatureKeys, TLogger } from '../types';
+import { type TEnforceFeatures, type TFeatureKeys, type TLogger } from '../types';
export function withMethodPrefix(
logger: TLogger>
@@ -18,5 +18,5 @@ export function withMethodPrefix(
};
});
- return logger as TLogger<['methodPrefix', ...GSelectedFeatureKeys]>;
+ return logger;
}
diff --git a/packages/feature-logger/src/features/with-prefix.test.ts b/packages/feature-logger/src/features/with-prefix.test.ts
index 775a14d2..55ba62da 100644
--- a/packages/feature-logger/src/features/with-prefix.test.ts
+++ b/packages/feature-logger/src/features/with-prefix.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { mockConsole, restoreConsoleMock, TConsoleSpies } from '../__tests__/mock-console';
+import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console';
import { createLogger } from '../create-logger';
import { withPrefix } from './with-prefix';
diff --git a/packages/feature-logger/src/features/with-prefix.ts b/packages/feature-logger/src/features/with-prefix.ts
index 96a058dd..726b40ed 100644
--- a/packages/feature-logger/src/features/with-prefix.ts
+++ b/packages/feature-logger/src/features/with-prefix.ts
@@ -1,4 +1,4 @@
-import { TEnforceFeatures, TFeatureKeys, TLogger } from '../types';
+import { type TEnforceFeatures, type TFeatureKeys, type TLogger } from '../types';
export function withPrefix(
logger: TLogger>,
@@ -12,5 +12,5 @@ export function withPrefix(
};
});
- return logger as TLogger<['prefix', ...GSelectedFeatureKeys]>;
+ return logger;
}
diff --git a/packages/feature-logger/src/features/with-timestamp.test.ts b/packages/feature-logger/src/features/with-timestamp.test.ts
index 939adfbf..54e3185b 100644
--- a/packages/feature-logger/src/features/with-timestamp.test.ts
+++ b/packages/feature-logger/src/features/with-timestamp.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { mockConsole, restoreConsoleMock, TConsoleSpies } from '../__tests__/mock-console';
+import { mockConsole, restoreConsoleMock, type TConsoleSpies } from '../__tests__/mock-console';
import { createLogger } from '../create-logger';
import { withTimestamp } from './with-timestamp';
diff --git a/packages/feature-logger/src/features/with-timestamp.ts b/packages/feature-logger/src/features/with-timestamp.ts
index 7bd10b32..81783935 100644
--- a/packages/feature-logger/src/features/with-timestamp.ts
+++ b/packages/feature-logger/src/features/with-timestamp.ts
@@ -1,4 +1,4 @@
-import { TEnforceFeatures, TFeatureKeys, TLogger } from '../types';
+import { type TEnforceFeatures, type TFeatureKeys, type TLogger } from '../types';
export function withTimestamp(
logger: TLogger>
@@ -11,5 +11,5 @@ export function withTimestamp(
};
});
- return logger as TLogger<['timestamp', ...GSelectedFeatureKeys]>;
+ return logger;
}
diff --git a/packages/feature-logger/src/has-features.ts b/packages/feature-logger/src/has-features.ts
index bd0c92c4..b946830b 100644
--- a/packages/feature-logger/src/has-features.ts
+++ b/packages/feature-logger/src/has-features.ts
@@ -3,6 +3,9 @@ import type { TFeatureKeys, TLogger } from './types';
export function hasFeatures<
GFeatureKeys extends TFeatureKeys[],
GHasFeatureKeys extends TFeatureKeys[]
->(state: TLogger, features: GHasFeatureKeys): state is TLogger {
- return features.every((feature) => state._features.includes(feature));
+>(
+ logger: TLogger,
+ features: GHasFeatureKeys
+): logger is TLogger<(GFeatureKeys[number] | GHasFeatureKeys[number])[]> {
+ return features.every((feature) => logger._features.includes(feature));
}
diff --git a/packages/feature-logger/src/types/features.ts b/packages/feature-logger/src/types/features.ts
index 860291c1..c5edc1a7 100644
--- a/packages/feature-logger/src/types/features.ts
+++ b/packages/feature-logger/src/types/features.ts
@@ -27,6 +27,6 @@ export type TEnforceFeatures<
GFeatureKeys extends TFeatureKeys[],
GToEnforceFeatureKeys extends TFeatureKeys[]
> =
- Exclude extends never
+ Exclude extends never
? GFeatureKeys
- : GFeatureKeys | Exclude;
+ : GFeatureKeys | GToEnforceFeatureKeys;
diff --git a/packages/feature-logger/src/types/logger.ts b/packages/feature-logger/src/types/logger.ts
index 49388598..0f005b3d 100644
--- a/packages/feature-logger/src/types/logger.ts
+++ b/packages/feature-logger/src/types/logger.ts
@@ -1,10 +1,10 @@
-import { TFeatureKeys, TSelectFeatures } from './features';
+import { type TFeatureKeys, type TSelectFeatures } from './features';
export type TLogger = {
_features: string[];
_config: TLoggerConfig;
_invokeConsole: TInvokeConsole;
- _baseLog(category: TLoggerCategory, data: unknown[]): void;
+ _baseLog: (category: TLoggerCategory, data: unknown[]) => void;
trace: (message?: unknown, ...optionalParams: unknown[]) => void;
log: (message?: unknown, ...optionalParams: unknown[]) => void;
info: (message?: unknown, ...optionalParams: unknown[]) => void;
diff --git a/packages/feature-react/.eslintrc.js b/packages/feature-react/.eslintrc.js
new file mode 100644
index 00000000..a1744d32
--- /dev/null
+++ b/packages/feature-react/.eslintrc.js
@@ -0,0 +1,7 @@
+/**
+ * @type {import('eslint').Linter.Config}
+ */
+module.exports = {
+ root: true,
+ extends: [require.resolve('@ibg/config/eslint/library')]
+};
diff --git a/packages/feature-state-react/.github/banner.svg b/packages/feature-react/.github/banner.svg
similarity index 100%
rename from packages/feature-state-react/.github/banner.svg
rename to packages/feature-react/.github/banner.svg
diff --git a/packages/feature-state-react/CHANGELOG.md b/packages/feature-react/CHANGELOG.md
similarity index 100%
rename from packages/feature-state-react/CHANGELOG.md
rename to packages/feature-react/CHANGELOG.md
diff --git a/packages/feature-state-react/README.md b/packages/feature-react/README.md
similarity index 96%
rename from packages/feature-state-react/README.md
rename to packages/feature-react/README.md
index 6778f48d..3b8eeaba 100644
--- a/packages/feature-state-react/README.md
+++ b/packages/feature-react/README.md
@@ -13,7 +13,7 @@
-
+
@@ -65,3 +65,8 @@ state.addTask({ id: 1, title: 'Task 1' });
```
- **`key`**: The key used to identify the state in `localStorage`.
+
+### `withGlobalBind()`
+
+todo
+
\ No newline at end of file
diff --git a/packages/feature-react/package.json b/packages/feature-react/package.json
new file mode 100644
index 00000000..a2133e1f
--- /dev/null
+++ b/packages/feature-react/package.json
@@ -0,0 +1,78 @@
+{
+ "name": "feature-react",
+ "description": "ReactJs extension features for feature-state",
+ "version": "0.0.13",
+ "private": false,
+ "scripts": {
+ "build": "shx rm -rf dist && ../../scripts/cli.sh bundle",
+ "start:dev": "tsc -w",
+ "lint": "eslint --ext .js,.ts src/",
+ "clean": "shx rm -rf dist && shx rm -rf node_modules && shx rm -rf .turbo",
+ "install:clean": "pnpm run clean && pnpm install",
+ "test": "vitest run",
+ "update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks",
+ "size": "size-limit --why"
+ },
+ "exports": {
+ "./state": {
+ "source": "./src/state/index.ts",
+ "require": "./dist/state/cjs/index.js",
+ "import": "./dist/state/esm/index.js",
+ "types": "./dist/types/state/index.d.ts"
+ },
+ "./form": {
+ "source": "./src/form/index.ts",
+ "require": "./dist/form/cjs/index.js",
+ "import": "./dist/form/esm/index.js",
+ "types": "./dist/types/form/index.d.ts"
+ }
+ },
+ "typesVersions": {
+ "*": {
+ "state": [
+ "./dist/types/state/index.d.ts"
+ ],
+ "form": [
+ "./dist/types/form/index.d.ts"
+ ]
+ }
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/inbeta-group/monorepo.git"
+ },
+ "keywords": [],
+ "author": "@bennoinbeta",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/inbeta-group/monorepo/issues"
+ },
+ "homepage": "https://inbeta.group/?source=package-json",
+ "dependencies": {
+ "@ibg/utils": "workspace:*",
+ "feature-form": "workspace:*",
+ "feature-state": "workspace:*"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0"
+ },
+ "devDependencies": {
+ "@ibg/config": "workspace:*",
+ "@types/node": "^20.14.1",
+ "@types/react": "^18.3.3",
+ "react": "^18.3.1"
+ },
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "size-limit": [
+ {
+ "path": "dist/state/esm/index.js"
+ },
+ {
+ "path": "dist/form/esm/index.js"
+ }
+ ]
+}
diff --git a/packages/feature-react/src/form/hooks/index.ts b/packages/feature-react/src/form/hooks/index.ts
new file mode 100644
index 00000000..602759f3
--- /dev/null
+++ b/packages/feature-react/src/form/hooks/index.ts
@@ -0,0 +1 @@
+export * from './use-form';
diff --git a/packages/feature-react/src/form/hooks/use-form.ts b/packages/feature-react/src/form/hooks/use-form.ts
new file mode 100644
index 00000000..a9ef234d
--- /dev/null
+++ b/packages/feature-react/src/form/hooks/use-form.ts
@@ -0,0 +1,78 @@
+import {
+ type TForm,
+ type TFormData,
+ type TFormField,
+ type TFormFieldStatus,
+ type TSubmitOptions
+} from 'feature-form';
+import React from 'react';
+
+import { registerFormField, type TRegisterFormFieldResponse } from '../register-form-field';
+
+export function useForm(
+ form: TForm
+): TUseFormResponse {
+ const [, forceRender] = React.useReducer((s: number) => s + 1, 0);
+
+ React.useEffect(() => {
+ const unbindCallbacks: (() => void)[] = [];
+ for (const formField of Object.values(form.fields) as TFormField[]) {
+ const unbind = formField.listen(
+ ({ background }) => {
+ if (!background) {
+ forceRender();
+ }
+ },
+ { key: 'use-form' }
+ );
+ unbindCallbacks.push(unbind);
+ }
+ return () => {
+ unbindCallbacks.forEach((callback) => {
+ callback();
+ });
+ };
+ }, [form.fields]);
+
+ return {
+ register(formFieldKey: GKey, controlled = false) {
+ return registerFormField(form.getField(formFieldKey), controlled);
+ },
+ handleSubmit: (options = {}) => {
+ const { preventDefault = true, ...submitOptions } = options;
+ return (event?: React.BaseSyntheticEvent) => {
+ if (preventDefault) {
+ event?.preventDefault();
+ }
+
+ if (submitOptions.additionalData != null) {
+ submitOptions.additionalData.event = event;
+ } else {
+ submitOptions.additionalData = { event };
+ }
+
+ return form.submit(submitOptions);
+ };
+ },
+ field(formFieldKey) {
+ return form.getField(formFieldKey);
+ },
+ status(formFieldKey) {
+ return form.getField(formFieldKey).status;
+ }
+ };
+}
+
+export interface TUseFormResponse {
+ handleSubmit: (options?: THandleSubmitOptions) => () => Promise;
+ register: (
+ formFieldKey: GKey,
+ controlled?: boolean
+ ) => TRegisterFormFieldResponse;
+ field: (formFieldKey: GKey) => TFormField;
+ status: (formFieldKey: GKey) => TFormFieldStatus;
+}
+
+interface THandleSubmitOptions extends TSubmitOptions {
+ preventDefault?: boolean;
+}
diff --git a/packages/feature-react/src/form/index.ts b/packages/feature-react/src/form/index.ts
new file mode 100644
index 00000000..13912785
--- /dev/null
+++ b/packages/feature-react/src/form/index.ts
@@ -0,0 +1,2 @@
+export * from './hooks';
+export * from './register-form-field';
diff --git a/packages/feature-react/src/form/register-form-field.ts b/packages/feature-react/src/form/register-form-field.ts
new file mode 100644
index 00000000..eef7d336
--- /dev/null
+++ b/packages/feature-react/src/form/register-form-field.ts
@@ -0,0 +1,34 @@
+import { type TFormField } from 'feature-form';
+import { type ChangeEventHandler, type FocusEventHandler } from 'react';
+import { hasProperty } from '@ibg/utils';
+
+export function registerFormField(
+ formField: TFormField,
+ controlled?: boolean
+): TRegisterFormFieldResponse {
+ return {
+ name: formField.key,
+ defaultValue: formField._intialValue,
+ value: controlled ? formField._value : undefined,
+ onBlur: () => {
+ formField.blur();
+ },
+ onChange(event) {
+ if (hasProperty(event.target, 'value')) {
+ formField.set(event.target.value as any, {
+ additionalData: {
+ background: !controlled
+ }
+ });
+ }
+ }
+ };
+}
+
+export interface TRegisterFormFieldResponse {
+ defaultValue?: GValue;
+ value?: GValue;
+ name?: GKey | string;
+ onChange?: ChangeEventHandler;
+ onBlur?: FocusEventHandler;
+}
diff --git a/packages/feature-state-react/src/features/index.ts b/packages/feature-react/src/state/features/index.ts
similarity index 56%
rename from packages/feature-state-react/src/features/index.ts
rename to packages/feature-react/src/state/features/index.ts
index 0ced18c5..228a504c 100644
--- a/packages/feature-state-react/src/features/index.ts
+++ b/packages/feature-react/src/state/features/index.ts
@@ -1 +1,2 @@
+export * from './with-global-bind';
export * from './with-persist-local-storage';
diff --git a/packages/feature-react/src/state/features/with-global-bind.ts b/packages/feature-react/src/state/features/with-global-bind.ts
new file mode 100644
index 00000000..6ee5d858
--- /dev/null
+++ b/packages/feature-react/src/state/features/with-global-bind.ts
@@ -0,0 +1,9 @@
+import { isObject } from '@ibg/utils';
+
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis
+export function withGlobalBind(key: string, value: T): T {
+ if (isObject(globalThis)) {
+ (globalThis as Record)[key] = value;
+ }
+ return value;
+}
diff --git a/packages/feature-state-react/src/features/with-persist-local-storage.ts b/packages/feature-react/src/state/features/with-persist-local-storage.ts
similarity index 80%
rename from packages/feature-state-react/src/features/with-persist-local-storage.ts
rename to packages/feature-react/src/state/features/with-persist-local-storage.ts
index ac2a3bde..fe8c5696 100644
--- a/packages/feature-state-react/src/features/with-persist-local-storage.ts
+++ b/packages/feature-react/src/state/features/with-persist-local-storage.ts
@@ -8,17 +8,15 @@ import {
} from 'feature-state';
class LocalStorageInterface implements StorageInterface {
- async save(key: string, value: GValue): Promise {
+ save(key: string, value: GValue): boolean {
localStorage.setItem(key, JSON.stringify(value));
return true;
}
-
- async load(key: string): Promise {
+ load(key: string): GValue | typeof FAILED_TO_LOAD_IDENTIFIER {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as GValue) : FAILED_TO_LOAD_IDENTIFIER;
}
-
- async delete(key: string): Promise {
+ delete(key: string): boolean {
localStorage.removeItem(key);
return true;
}
diff --git a/packages/feature-state-react/src/hooks/index.ts b/packages/feature-react/src/state/hooks/index.ts
similarity index 100%
rename from packages/feature-state-react/src/hooks/index.ts
rename to packages/feature-react/src/state/hooks/index.ts
diff --git a/packages/feature-state-react/src/hooks/use-global-state.ts b/packages/feature-react/src/state/hooks/use-global-state.ts
similarity index 58%
rename from packages/feature-state-react/src/hooks/use-global-state.ts
rename to packages/feature-react/src/state/hooks/use-global-state.ts
index bf12e4a6..b4dd643a 100644
--- a/packages/feature-state-react/src/hooks/use-global-state.ts
+++ b/packages/feature-react/src/state/hooks/use-global-state.ts
@@ -1,17 +1,23 @@
import type { TState } from 'feature-state';
import React from 'react';
-export function useGlobalState(state: TState): GValue {
+export function useGlobalState(state: TState): Readonly {
const [, forceRender] = React.useReducer((s: number) => s + 1, 0);
React.useEffect(() => {
- const unbind = state.listen(() => {
- forceRender();
- });
+ const unbind = state.listen(
+ ({ background }) => {
+ if (!background) {
+ forceRender();
+ }
+ },
+ { key: 'use-global-state' }
+ );
+
return () => {
unbind();
};
}, []);
- return state._value;
+ return state.get();
}
diff --git a/packages/feature-state-react/src/index.ts b/packages/feature-react/src/state/index.ts
similarity index 100%
rename from packages/feature-state-react/src/index.ts
rename to packages/feature-react/src/state/index.ts
diff --git a/packages/feature-state-react/tsconfig.json b/packages/feature-react/tsconfig.json
similarity index 100%
rename from packages/feature-state-react/tsconfig.json
rename to packages/feature-react/tsconfig.json
diff --git a/packages/feature-react/tsconfig.prod.json b/packages/feature-react/tsconfig.prod.json
new file mode 100644
index 00000000..d23e458a
--- /dev/null
+++ b/packages/feature-react/tsconfig.prod.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["**/__tests__/*", "**/*.test.ts"]
+}
diff --git a/packages/feature-react/vitest.config.js b/packages/feature-react/vitest.config.js
new file mode 100644
index 00000000..5c291644
--- /dev/null
+++ b/packages/feature-react/vitest.config.js
@@ -0,0 +1,4 @@
+const { defineConfig, mergeConfig } = require('vitest/config');
+const { nodeConfig } = require('@ibg/config/vite/node.config');
+
+module.exports = mergeConfig(nodeConfig, defineConfig({}));
diff --git a/packages/feature-state/README.md b/packages/feature-state/README.md
index ae0243ce..63158c12 100644
--- a/packages/feature-state/README.md
+++ b/packages/feature-state/README.md
@@ -13,7 +13,7 @@
-
+
@@ -25,6 +25,9 @@
- **Typesafe**: Build with TypeScript for strong type safety
- **Standalone**: Zero dependencies, ensuring ease of use in various environments
+### 🏖️ Code Sandbox
+- [ReactJs Counter](https://codesandbox.io/p/sandbox/counter-k74k9k)
+
### Motivation
Provide a typesafe, straightforward, and lightweight state management library designed to be modular and extendable with features like `withPersist()`, `withUndo()`, .. Having previously built [AgileTs](https://agile-ts.org/), I realized the importance of simplicity and modularity. AgileTs, while powerful, became bloated and complex. Learning from that experience, I followed the KISS (Keep It Simple, Stupid) principle for `feature-state`, aiming to provide a more streamlined and efficient solution. Because no code is the best code.
diff --git a/packages/feature-state/package.json b/packages/feature-state/package.json
index 225d3b06..725f5b34 100644
--- a/packages/feature-state/package.json
+++ b/packages/feature-state/package.json
@@ -1,7 +1,7 @@
{
"name": "feature-state",
"description": "Straightforward, typesafe, and feature-based state management library for ReactJs",
- "version": "0.0.7",
+ "version": "0.0.12",
"private": false,
"scripts": {
"build": "shx rm -rf dist && ../../scripts/cli.sh bundle",
@@ -11,6 +11,7 @@
"install:clean": "pnpm run clean && pnpm install",
"test": "vitest run",
"update:latest": "pnpm update --latest",
+ "publish:patch": "pnpm build && pnpm version patch && pnpm publish --no-git-checks",
"size": "size-limit --why"
},
"source": "./src/index.ts",
diff --git a/packages/feature-state/src/create-state.ts b/packages/feature-state/src/create-state.ts
index a56c4848..f08eb89f 100644
--- a/packages/feature-state/src/create-state.ts
+++ b/packages/feature-state/src/create-state.ts
@@ -1,70 +1,90 @@
-import type { TListener, TListenerQueueItem, TReadonlyIfObject, TState } from './types';
+import type { TListener, TListenerQueueItem, TState } from './types';
-const LISTENER_QUEUE: TListenerQueueItem[] = [];
+const GLOBAL_LISTENER_QUEUE: TListenerQueueItem[] = [];
+
+export function createState(
+ initialValue: GValue,
+ options: TCreateStateOptions = {}
+): TState {
+ const { deferred = true } = options;
-export function createState(value: GValue, deferred = true): TState {
return {
_: null,
_features: ['base'],
_listeners: [],
- _value: value,
+ _value: initialValue,
+ _notify(notifyOptions = {}) {
+ const { processListenerQueue = true, additionalData = {} } = notifyOptions;
+
+ // Push current state's listeners to the queue
+ this._listeners.forEach((listener) => {
+ GLOBAL_LISTENER_QUEUE.push({
+ data: { ...additionalData, value: this._value as Readonly },
+ callback: listener.callback,
+ level: listener.level
+ });
+ });
+
+ // Process queue
+ if (processListenerQueue) {
+ // Defer processing using setTimeout
+ deferred
+ ? setTimeout(() => {
+ void processQueue();
+ })
+ : void processQueue();
+ }
+ },
get() {
return this._value;
},
- set(newValue) {
+ set(newValue, setOptions = {}) {
if (this._value !== newValue) {
+ const { additionalData = {}, processListenerQueue = true } = setOptions;
+ additionalData.source = additionalData.source ?? 'set';
this._value = newValue;
- this._notify(true);
+ this._notify({
+ additionalData,
+ processListenerQueue
+ });
}
},
- listen(callback, level) {
+ listen(callback, listenOptions = {}) {
+ const { level = 0, key } = listenOptions;
const listener: TListener = {
- callback,
- level: level ?? 0
+ key,
+ level,
+ callback
};
this._listeners.push(listener);
// Undbind
return () => {
const index = this._listeners.indexOf(listener);
- // eslint-disable-next-line no-bitwise -- .
- if (~index) {
+ if (index !== -1) {
this._listeners.splice(index, 1);
}
};
},
- subscribe(callback, level) {
- const unbind = this.listen(callback, level);
- callback(this._value as TReadonlyIfObject);
+ subscribe(callback, subscribeOptions) {
+ const unbind = this.listen(callback, subscribeOptions);
+ void callback({ value: this._value });
return unbind;
- },
- _notify(process) {
- // Add current state's listeners to the queue
- this._listeners.forEach((listener) => {
- const queueItem: TListenerQueueItem = {
- value: this._value,
- ...listener
- };
- LISTENER_QUEUE.push(queueItem as TListenerQueueItem);
- });
-
- // Process queue
- if (process) {
- // Defer processing using setTimeout
- deferred ? setTimeout(processQueue) : processQueue();
- }
}
};
}
-function processQueue(): void {
+export interface TCreateStateOptions {
+ deferred?: boolean;
+}
+
+async function processQueue(): Promise {
// Drain the queue
- const queueToProcess = LISTENER_QUEUE.splice(0, LISTENER_QUEUE.length);
+ const queueToProcess = GLOBAL_LISTENER_QUEUE.splice(0, GLOBAL_LISTENER_QUEUE.length);
+ queueToProcess.sort((a, b) => a.level - b.level);
- // Sort the drained listeners by level and execute the callbacks
- queueToProcess
- .sort((a, b) => a.level - b.level)
- .forEach((queueItem) => {
- queueItem.callback(queueItem.value);
- });
+ // Process each item in the queue sequentially
+ for (const queueItem of queueToProcess) {
+ await queueItem.callback(queueItem.data);
+ }
}
diff --git a/packages/feature-state/src/features/with-multi-undo.ts b/packages/feature-state/src/features/with-multi-undo.ts
index 0f8043d0..a4216f8a 100644
--- a/packages/feature-state/src/features/with-multi-undo.ts
+++ b/packages/feature-state/src/features/with-multi-undo.ts
@@ -5,8 +5,6 @@ export function withMultiUndo>
): TState {
if (hasFeatures(state, ['undo'])) {
- state._features.push('multiundo');
-
const multiUndoFeature: TSelectFeatures = {
multiUndo(this: TState, count: number) {
for (let i = 0; i < count; i++) {
@@ -16,9 +14,13 @@ export function withMultiUndo;
+ _state._features.push('multiundo');
- return _state as TState;
+ return _state;
}
throw Error('State must have "undo" feature to use withMultiUndo');
diff --git a/packages/feature-state/src/features/with-persist.ts b/packages/feature-state/src/features/with-persist.ts
index 485c768f..21598625 100644
--- a/packages/feature-state/src/features/with-persist.ts
+++ b/packages/feature-state/src/features/with-persist.ts
@@ -2,19 +2,28 @@ import type { TEnforceFeatures, TFeatureKeys, TSelectFeatures, TState } from '..
export const FAILED_TO_LOAD_IDENTIFIER = undefined;
-export interface StorageInterface {
- save: (key: string, value: GValue) => Promise;
- load: (key: string) => Promise;
- delete: (key: string) => Promise;
+export interface StorageInterface {
+ save: (key: string, value: GStorageValue) => Promise | boolean;
+ load: (
+ key: string
+ ) =>
+ | Promise
+ | GStorageValue
+ | typeof FAILED_TO_LOAD_IDENTIFIER;
+ delete: (key: string) => Promise | boolean;
}
-export function withPersist[]>(
+export function withPersist<
+ GValue,
+ GSelectedFeatureKeys extends TFeatureKeys[],
+ // TODO: For whatever reason Typescript infers type from storage and not state argument
+ // thus I need this extra GStorageValue
+ GStorageValue extends GValue = GValue
+>(
state: TState>,
- storage: StorageInterface,
+ storage: StorageInterface,
key: string
): TState {
- state._features.push('persist');
-
const persistFeature: TSelectFeatures = {
async persist() {
let success = false;
@@ -25,20 +34,16 @@ export function withPersist {
- storage
- .save(key, value)
- .then(() => {
- /* do nothing*/
- })
- .catch(() => {
- /* do nothing*/
- });
- });
+ state.listen(
+ async ({ value }) => {
+ await storage.save(key, value as GStorageValue);
+ },
+ { key: 'with-persist' }
+ );
return success;
},
@@ -48,7 +53,11 @@ export function withPersist;
+ _state._features.push('persist');
- return _state as TState;
+ return _state;
}
diff --git a/packages/feature-state/src/features/with-undo.ts b/packages/feature-state/src/features/with-undo.ts
index d06b96d4..8f409bd9 100644
--- a/packages/feature-state/src/features/with-undo.ts
+++ b/packages/feature-state/src/features/with-undo.ts
@@ -4,30 +4,34 @@ export function withUndo>,
historyLimit = 50
): TState {
- state._features.push('undo');
-
const undoFeature: TSelectFeatures = {
_history: [state._value],
- undo(this: TState) {
- this._history.pop(); // Pop current value
- const newValue = this._history.pop(); // Pop previous value
- if (newValue != null) {
- this.set(newValue);
+ undo(this: TState, options) {
+ if (this._history.length > 1) {
+ this._history.pop(); // Pop current value
+ const newValue = this._history.pop(); // Pop previous value
+ if (newValue != null) {
+ this.set(newValue, options);
+ }
}
}
};
// Merge existing features from the state with the new undo feature
- const _state = Object.assign(state, undoFeature);
+ const _state = Object.assign(state, undoFeature) as unknown as TState;
+ _state._features.push('undo');
- _state.listen((value) => {
- // Maintaining the history stack size
- if (_state._history.length >= historyLimit) {
- _state._history.shift(); // Remove oldest state
- }
+ _state.listen(
+ ({ value }) => {
+ // Maintaining the history stack size
+ if (_state._history.length >= historyLimit) {
+ _state._history.shift(); // Remove oldest state
+ }
- _state._history.push(value);
- });
+ _state._history.push(value);
+ },
+ { key: 'with-undo' }
+ );
- return _state as TState;
+ return _state as unknown as TState;
}
diff --git a/packages/feature-state/src/has-features.test.ts b/packages/feature-state/src/has-features.test.ts
index f0c98f33..e1ac9d1e 100644
--- a/packages/feature-state/src/has-features.test.ts
+++ b/packages/feature-state/src/has-features.test.ts
@@ -6,17 +6,17 @@ import { hasFeatures } from './has-features';
describe('hasFeatures function', () => {
it('should return true if the state has all the requested features', () => {
- const state = withUndo(createState(10, false));
+ const state = withUndo(createState(10));
expect(hasFeatures(state, ['base', 'undo'])).toBe(true);
});
it('should return false if the state is missing any of the requested features', () => {
- const state = createState(10, false);
+ const state = createState(10);
expect(hasFeatures(state, ['base', 'undo'])).toBe(false);
});
it('should return true for a state with only the specified features', () => {
- const state = createState(10, false);
+ const state = createState(10);
expect(hasFeatures(state, ['base'])).toBe(true);
});
@@ -26,7 +26,7 @@ describe('hasFeatures function', () => {
});
it('should return true if checking for an empty feature set', () => {
- const state = createState(10, false);
+ const state = createState(10);
expect(hasFeatures(state, [])).toBe(true);
});
});
diff --git a/packages/feature-state/src/has-features.ts b/packages/feature-state/src/has-features.ts
index b1cae4c7..c1c84a3e 100644
--- a/packages/feature-state/src/has-features.ts
+++ b/packages/feature-state/src/has-features.ts
@@ -7,6 +7,6 @@ export function hasFeatures<
>(
state: TState,
features: GHasFeatureKeys
-): state is TState {
- return features.every((feature) => state._features.includes(feature));
+): state is TState {
+ return features.every((feature) => state._features.includes(feature as GFeatureKeys[number]));
}
diff --git a/packages/feature-state/src/types/features.ts b/packages/feature-state/src/types/features.ts
index 19927853..bb4f18bb 100644
--- a/packages/feature-state/src/types/features.ts
+++ b/packages/feature-state/src/types/features.ts
@@ -1,17 +1,19 @@
import type { TUnionToIntersection } from '@ibg/utils';
+import { type TStateSetOptions } from './state';
+
export type TFeatures = {
base: { _: null }; // TODO: Placeholder Feature: Figure out how to make the TS infer work with [] (empty array -> no feature)
- undo: { undo: () => void; _history: GValue[] };
+ undo: { undo: (options?: TStateSetOptions) => void; _history: GValue[] };
multiundo: {
multiUndo: (count: number) => void;
};
persist: { persist: () => Promise; deletePersisted: () => Promise };
-} & TThirdPartyFeatures;
+} & TThirdPartyFeatures;
// Global registry for third party features
// eslint-disable-next-line @typescript-eslint/no-empty-interface -- Overwritten by third party libraries
-export interface TThirdPartyFeatures {}
+export interface TThirdPartyFeatures {}
export type TFeatureKeys = keyof TFeatures