From 5b402522c499f46e77b87f4910e0de2d5e71c22f Mon Sep 17 00:00:00 2001 From: Dmitrii Pikulin Date: Wed, 5 Mar 2025 13:07:26 +0300 Subject: [PATCH 1/5] chore: set semantically-released version (#6) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de0c747..2ceb00e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hookform/lenses", "description": "Type-safe lenses for React Hook Form that enable precise control over nested form state. Build reusable form components with composable operations, array handling, and full TypeScript support.", - "version": "0.1.1", + "version": "0.0.0-semantically-released", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", From 898b2a9a43f29cba13cd7fb0d481d4d190d60270 Mon Sep 17 00:00:00 2001 From: Dmitrii Pikulin Date: Fri, 14 Mar 2025 18:18:38 +0300 Subject: [PATCH 2/5] allow array reflection (#7) --- README.md | 19 ++++- bun.lock | 33 +++++--- examples/.storybook/main.ts | 2 +- examples/package.json | 1 + .../stories/restructure/Reflect.story.tsx | 55 ++++++++++++- examples/stories/restructure/Reflect.test.tsx | 34 +++++++- examples/tests/lens.test.ts | 2 +- examples/tests/map.test.ts | 22 ++++-- examples/tests/reflect.test.ts | 30 ++++++++ examples/tsconfig.json | 3 +- package.json | 14 ++++ src/LensCore.ts | 77 ++++++++++++++----- src/rhf/index.ts | 1 + src/rhf/useFieldArray.ts | 68 ++++++++++++++++ src/types/lenses.ts | 4 +- tsup.config.ts | 4 +- 16 files changed, 323 insertions(+), 46 deletions(-) create mode 100644 src/rhf/index.ts create mode 100644 src/rhf/useFieldArray.ts diff --git a/README.md b/README.md index 2fcee54..805eac3 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ npm install @hookform/lenses ### Quickstart ```tsx -import { useFieldArray, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { Lens, useLens } from '@hookform/lenses'; +import { useFieldArray } from '@hookform/lenses/rhf'; function FormComponent() { const { handleSubmit, control } = useForm<{ @@ -170,6 +171,20 @@ function SharedComponent({ lens }: { lens: Lens<{ name: string; phoneNumber: str } ``` +Also, you can restructure array lens: + +```tsx +function ArrayComponent({ lens }: { lens: Lens<{ value: string }[]> }) { + return [{ data: l.focus('value') }])} />; +} + +function AnotherComponent({ lens }: { lens: Lens<{ data: string }[]> }) { + // ... +} +``` + +Pay attention that in case of array reflecting you have to pass an array with single item. + ##### `join` Combines two lenses into one. You have to provide a merger function because in runtime it is not clear to which prop path of two lenses subsequent lens operations will be applied. @@ -192,6 +207,8 @@ function Card(props: { person: Lens<{ name: string }>; contact: Lens<{ phoneNumb Maps over array fields with `useFieldArray` integration ```tsx +import { useFieldArray } from '@hookform/lenses/rhf'; + function ContactsList({ lens }: { lens: Lens }) { const { fields } = useFieldArray(lens.interop()); diff --git a/bun.lock b/bun.lock index 4443ce7..63fadaa 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,7 @@ "storybook": "8.6.3", "typescript": "^5.8.2", "vite": "^6.2.0", + "vite-plugin-checker": "^0.9.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.7", }, @@ -377,7 +378,7 @@ "ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -855,7 +856,7 @@ "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], - "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "nwsapi": ["nwsapi@2.2.18", "", {}, "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA=="], @@ -905,7 +906,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], @@ -1127,6 +1128,8 @@ "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="], "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], @@ -1141,10 +1144,14 @@ "vite-node": ["vite-node@3.0.7", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A=="], + "vite-plugin-checker": ["vite-plugin-checker@0.9.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "chokidar": "^4.0.3", "npm-run-path": "^6.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.2", "strip-ansi": "^7.1.0", "tiny-invariant": "^1.3.3", "tinyglobby": "^0.2.12", "vscode-uri": "^3.1.0" }, "peerDependencies": { "@biomejs/biome": ">=1.7", "eslint": ">=7", "meow": "^13.2.0", "optionator": "^0.9.1", "stylelint": ">=16", "typescript": "*", "vite": ">=2.0.0", "vls": "*", "vti": "*", "vue-tsc": "~2.2.2" }, "optionalPeers": ["@biomejs/biome", "eslint", "meow", "optionator", "stylelint", "typescript", "vls", "vti", "vue-tsc"] }, "sha512-gf/zc0KWX8ATEOgnpgAM1I+IbvWkkO80RB+FxlLtC5cabXSesbJmAUw6E+mMDDMGIT+VHAktmxJZpMTt3lSubQ=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], "vitest": ["vitest@3.0.7", "", { "dependencies": { "@vitest/expect": "3.0.7", "@vitest/mocker": "3.0.7", "@vitest/pretty-format": "^3.0.7", "@vitest/runner": "3.0.7", "@vitest/snapshot": "3.0.7", "@vitest/spy": "3.0.7", "@vitest/utils": "3.0.7", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.0.7", "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg=="], + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], @@ -1201,8 +1208,6 @@ "@joshwooding/vite-plugin-react-docgen-typescript/magic-string": ["magic-string@0.27.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.13" } }, "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA=="], - "@rollup/pluginutils/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], - "@storybook/core/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "@storybook/react-vite/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -1219,9 +1224,9 @@ "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "execa/npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], - "fdir/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -1233,10 +1238,14 @@ "make-dir/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1261,14 +1270,12 @@ "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1281,6 +1288,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], @@ -1291,10 +1300,14 @@ "source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/examples/.storybook/main.ts b/examples/.storybook/main.ts index 9a99edf..022dd48 100644 --- a/examples/.storybook/main.ts +++ b/examples/.storybook/main.ts @@ -13,7 +13,7 @@ const config: StorybookConfig = { const { mergeConfig } = await import('vite'); return mergeConfig(config, { - plugins: [checker({ typescript: true }), tsconfigPaths()], + plugins: [checker({ typescript: true, overlay: false }), tsconfigPaths()], }); }, }; diff --git a/examples/package.json b/examples/package.json index 2ac7636..32fb7ed 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,6 +31,7 @@ "storybook": "8.6.3", "typescript": "^5.8.2", "vite": "^6.2.0", + "vite-plugin-checker": "^0.9.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.7" } diff --git a/examples/stories/restructure/Reflect.story.tsx b/examples/stories/restructure/Reflect.story.tsx index b7bd6b5..a2303fa 100644 --- a/examples/stories/restructure/Reflect.story.tsx +++ b/examples/stories/restructure/Reflect.story.tsx @@ -1,5 +1,6 @@ import { SubmitHandler, useForm } from 'react-hook-form'; import { Lens, useLens } from '@hookform/lenses'; +import { useFieldArray } from '@hookform/lenses/rhf'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; @@ -11,7 +12,7 @@ export default { export interface ReflectFormData { firstName: string; - lastName: string; + lastName: { value: string }; } export interface ReflectProps { @@ -27,7 +28,7 @@ export function Reflect({ onSubmit = action('submit') }: ReflectProps) { ({ name: l.focus('firstName'), - surname: l.focus('lastName'), + surname: l.focus('lastName.value'), }))} /> @@ -51,3 +52,53 @@ function PersonForm({ lens }: { lens: Lens }) { ); } + +export interface Item { + value: { inside: string }; +} + +export interface ArrayReflectFormData { + items: Item[]; +} + +export interface ArrayReflectProps { + onSubmit: SubmitHandler; +} + +export function ArrayReflect({ onSubmit = action('submit') }: ArrayReflectProps) { + const { handleSubmit, control } = useForm({ + defaultValues: { items: [{ value: { inside: 'one' } }, { value: { inside: 'two' } }, { value: { inside: 'three' } }] }, + }); + const lens = useLens({ control }); + + return ( +
+ [{ data: l.focus('value').focus('inside') }])} /> +
+ +
+ + ); +} + +function Items({ lens }: { lens: Lens<{ data: string }[]> }) { + const { fields, append } = useFieldArray(lens.interop()); + + return ( +
+ {lens.map(fields, (l, key) => ( +
+ +
+ ))} + +
+ ); +} diff --git a/examples/stories/restructure/Reflect.test.tsx b/examples/stories/restructure/Reflect.test.tsx index af93b1c..333f89f 100644 --- a/examples/stories/restructure/Reflect.test.tsx +++ b/examples/stories/restructure/Reflect.test.tsx @@ -4,7 +4,7 @@ import user from '@testing-library/user-event'; import * as stories from './Reflect.story'; -const { Reflect } = composeStories(stories); +const { Reflect, ArrayReflect } = composeStories(stories); test('should change lens via reflect', async () => { const handleSubmit = vi.fn(); @@ -18,8 +18,36 @@ test('should change lens via reflect', async () => { expect(handleSubmit).toHaveBeenCalledWith( { firstName: 'joe', - lastName: 'doe', - }, + lastName: { value: 'doe' }, + } satisfies stories.ReflectFormData, + expect.anything(), + ); +}); + +test('should change array lens via reflect', async () => { + const handleSubmit = vi.fn(); + render(); + + expect(screen.getByPlaceholderText('items.0.value.inside')).toHaveValue('one'); + expect(screen.getByPlaceholderText('items.1.value.inside')).toHaveValue('two'); + expect(screen.getByPlaceholderText('items.2.value.inside')).toHaveValue('three'); + + await user.click(screen.getByText(/Add item/i)); + + await user.type(screen.getByPlaceholderText('items.3.value.inside'), ' four'); + expect(screen.getByPlaceholderText('items.3.value.inside')).toHaveValue('more four'); + + await user.click(screen.getByText(/submit/i)); + + expect(handleSubmit).toHaveBeenCalledWith( + { + items: [ + { value: { inside: 'one' } }, + { value: { inside: 'two' } }, + { value: { inside: 'three' } }, + { value: { inside: 'more four' } }, + ], + } satisfies stories.ArrayReflectFormData, expect.anything(), ); }); diff --git a/examples/tests/lens.test.ts b/examples/tests/lens.test.ts index 0bb17ab..b0eac55 100644 --- a/examples/tests/lens.test.ts +++ b/examples/tests/lens.test.ts @@ -11,7 +11,7 @@ test('lens can be created', () => { }); expectTypeOf(result.current.lens).toEqualTypeOf>(); - expect(result.current.lens.interop()).toEqual({ name: undefined, control: result.current.form.control }); + expect(result.current.lens.interop()).toEqual({ name: undefined, control: result.current.form.control, lens: result.current.lens }); }); test('lenses are different when created with different forms', () => { diff --git a/examples/tests/map.test.ts b/examples/tests/map.test.ts index fa80bc0..95ade67 100644 --- a/examples/tests/map.test.ts +++ b/examples/tests/map.test.ts @@ -16,8 +16,8 @@ test('map can create a new lens', () => { expectTypeOf(itemLenses).toEqualTypeOf[]>(); - expect(itemLenses[0]?.interop()).toEqual({ name: 'items.0.a', control: result.current.form.control }); - expect(itemLenses[1]?.interop()).toEqual({ name: 'items.1.a', control: result.current.form.control }); + expect(itemLenses[0]?.interop()).toEqual({ name: 'items.0.a', control: result.current.form.control, lens: itemLenses[0] }); + expect(itemLenses[1]?.interop()).toEqual({ name: 'items.1.a', control: result.current.form.control, lens: itemLenses[1] }); }); test('map callback accepts a keyName and index', () => { @@ -29,15 +29,25 @@ test('map callback accepts a keyName and index', () => { expectTypeOf(result.current.lens).toEqualTypeOf>(); - const itemLenses = result.current.lens.focus('items').map( + const items = result.current.lens.focus('items'); + const itemLenses = items.map( [ { a: '1', myId: 'one' }, { a: '2', myId: 'two' }, ], - (l, keyName, index) => ({ interop: l.interop(), keyName, index }), + (l, keyName, index) => ({ lens: l, interop: l.interop(), keyName, index }), 'myId', ); - expect(itemLenses[0]).toEqual({ interop: { name: 'items.0', control: result.current.form.control }, keyName: 'one', index: 0 }); - expect(itemLenses[1]).toEqual({ interop: { name: 'items.1', control: result.current.form.control }, keyName: 'two', index: 1 }); + expect(itemLenses[0]).toMatchObject({ + interop: { name: 'items.0', control: result.current.form.control, lens: itemLenses[0]?.lens }, + keyName: 'one', + index: 0, + }); + + expect(itemLenses[1]).toMatchObject({ + interop: { name: 'items.1', control: result.current.form.control, lens: itemLenses[1]?.lens }, + keyName: 'two', + index: 1, + }); }); diff --git a/examples/tests/reflect.test.ts b/examples/tests/reflect.test.ts index abb118f..bdfef14 100644 --- a/examples/tests/reflect.test.ts +++ b/examples/tests/reflect.test.ts @@ -1,5 +1,6 @@ import { useForm } from 'react-hook-form'; import { Lens, useLens } from '@hookform/lenses'; +import { useFieldArray } from '@hookform/lenses/rhf'; import { renderHook } from '@testing-library/react'; import { expectTypeOf } from 'vitest'; @@ -62,3 +63,32 @@ test('reflect can add props from another lens', () => { Lens<{ c: string; d: number }> >(); }); + +test('reflect can work with array', () => { + const { result } = renderHook(() => { + const form = useForm<{ items: { value: string }[] }>({ defaultValues: { items: [{ value: 'one' }, { value: 'two' }] } }); + + const lens = useLens({ control: form.control }) + .focus('items') + .reflect((l) => [{ another: l.focus('value') }]); + + const arr = useFieldArray(lens.interop()); + + return { form, lens, arr }; + }); + + expectTypeOf(result.current.lens).toEqualTypeOf>(); + + const [one, two] = result.current.lens.map(result.current.arr.fields, (l) => l.focus('another').interop()); + + expect(one).toEqual({ + name: 'items.0.value', + control: result.current.form.control, + lens: result.current.lens.focus('0').focus('another'), + }); + expect(two).toEqual({ + name: 'items.1.value', + control: result.current.form.control, + lens: result.current.lens.focus('1').focus('another'), + }); +}); diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 1f83fb2..569763e 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -6,7 +6,8 @@ "moduleResolution": "bundler", "baseUrl": "./", "paths": { - "@hookform/lenses": ["../src"] + "@hookform/lenses": ["../src"], + "@hookform/lenses/rhf": ["../src/rhf"] }, "types": ["vitest/globals"], "noEmit": true, diff --git a/package.json b/package.json index 2ceb00e..caddc45 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,15 @@ "name": "@hookform/lenses", "description": "Type-safe lenses for React Hook Form that enable precise control over nested form state. Build reusable form components with composable operations, array handling, and full TypeScript support.", "version": "0.0.0-semantically-released", + "repository": { + "type": "git", + "url": "git+https://github.com/react-hook-form/lenses.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/react-hook-form/lenses/issues" + }, + "homepage": "https://github.com/react-hook-form/lenses", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", @@ -30,6 +39,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./rhf": { + "types": "./dist/rhf/index.d.ts", + "import": "./dist/rhf/index.js", + "require": "./dist/rhf/index.cjs" } }, "scripts": { diff --git a/src/LensCore.ts b/src/LensCore.ts index 38c8209..f7c2ad4 100644 --- a/src/LensCore.ts +++ b/src/LensCore.ts @@ -1,26 +1,23 @@ import { type Control, type FieldValues, get } from 'react-hook-form'; -import { type LensesDeepMap, type LensesMap } from './types/helpers'; import type { Lens } from './types/lenses'; import type { LensesCache } from './utils'; interface Settings { - lensesMap?: LensesDeepMap | undefined; + lensesMap?: Record | [Record] | undefined; propPath?: string | undefined; + restructureSourcePath?: string | undefined; } export class LensCore { - private settings: Settings; - + public settings: Settings; public control: Control; public cache: LensesCache; - public name?: string | undefined; private constructor(control: Control, cache: LensesCache, settings: Settings = {}) { this.control = control; this.cache = cache; this.settings = settings; - this.name = settings.propPath; } public static create( @@ -31,18 +28,49 @@ export class LensCore { } public focus(propPath: string): LensCore { - const nestedPath = this.settings.propPath === undefined ? propPath : `${this.settings.propPath}.${propPath}`; + let nestedPath = this.settings.propPath === undefined ? propPath : `${this.settings.propPath}.${propPath}`; if (this.settings.lensesMap) { - const result = get(this.settings.lensesMap, nestedPath); - - if (result) { - return result; + if (Array.isArray(this.settings.lensesMap)) { + const arrayReflectMapper: LensCore | undefined = get(this.settings.lensesMap[0], propPath); + + if (arrayReflectMapper) { + const reflectedPropPath = arrayReflectMapper.settings.propPath?.slice(`${this.settings.restructureSourcePath}.`.length); + + if (reflectedPropPath) { + nestedPath = `${this.settings.propPath}.${reflectedPropPath}`; + + if (!this.cache.primitives.has(nestedPath)) { + const newLens = new LensCore(arrayReflectMapper.control, arrayReflectMapper.cache, { + ...arrayReflectMapper.settings, + propPath: nestedPath, + }); + this.cache.primitives.set(nestedPath, newLens); + } + + const focusedLens = this.cache.primitives.get(nestedPath); + + if (!focusedLens) { + throw new Error(`There is no focused lens: ${nestedPath}`); + } + + return focusedLens; + } + } + } else { + const result = get(this.settings.lensesMap, propPath); + if (result) { + return result; + } } } if (!this.cache.primitives.has(nestedPath)) { - const newLens = new LensCore(this.control, this.cache, { propPath: nestedPath }); + const newLens = new LensCore(this.control, this.cache, { + propPath: nestedPath, + lensesMap: this.settings.lensesMap, + restructureSourcePath: this.settings.restructureSourcePath, + }); this.cache.primitives.set(nestedPath, newLens); } @@ -55,7 +83,7 @@ export class LensCore { return focusedLens; } - public reflect(getter: (original: LensCore) => LensesMap): LensCore { + public reflect(getter: (original: LensCore) => Record | [Record]): LensCore { const fromCache = this.cache.complex.get(getter); if (fromCache) { @@ -63,14 +91,19 @@ export class LensCore { } const focusContext = getter(this); - const newLens = new LensCore(this.control, this.cache, { lensesMap: focusContext }); + + const newLens = new LensCore(this.control, this.cache, { + lensesMap: focusContext, + propPath: this.settings.propPath, + restructureSourcePath: this.settings.propPath, + }); this.cache.complex.set(getter, { lens: newLens }); return newLens; } - public join(another: LensCore, merger: (original: LensCore, another: LensCore) => LensesMap): LensCore { + public join(another: LensCore, merger: (original: LensCore, another: LensCore) => Record): LensCore { const fromCache = this.cache.complex.get(merger); if (fromCache) { @@ -78,7 +111,11 @@ export class LensCore { } const focusContext = merger(this, another); - const newLens = new LensCore(this.control, this.cache, { lensesMap: focusContext }); + const newLens = new LensCore(this.control, this.cache, { + lensesMap: focusContext, + propPath: this.settings.propPath, + restructureSourcePath: this.settings.propPath, + }); this.cache.complex.set(merger, { lens: newLens }); @@ -101,7 +138,11 @@ export class LensCore { }); } - public interop(cb?: (control: Control, name: string | undefined) => any): { control: Control; name: string | undefined } { - return cb ? cb(this.control, this.name) : { control: this.control, name: this.name }; + public interop(cb?: (control: Control, name: string | undefined, lens: LensCore) => any): { + control: Control; + name: string | undefined; + lens: LensCore; + } { + return cb ? cb(this.control, this.settings.propPath, this) : { control: this.control, name: this.settings.propPath, lens: this }; } } diff --git a/src/rhf/index.ts b/src/rhf/index.ts new file mode 100644 index 0000000..ddc3d36 --- /dev/null +++ b/src/rhf/index.ts @@ -0,0 +1 @@ +export * from './useFieldArray'; diff --git a/src/rhf/useFieldArray.ts b/src/rhf/useFieldArray.ts new file mode 100644 index 0000000..1f95df8 --- /dev/null +++ b/src/rhf/useFieldArray.ts @@ -0,0 +1,68 @@ +import { + type FieldArray, + type FieldArrayPath, + type FieldValues, + set, + useFieldArray as useFieldArrayOriginal, + type UseFieldArrayProps as UseFieldArrayPropsOriginal, + type UseFieldArrayReturn, +} from 'react-hook-form'; + +import type { LensCore } from '../LensCore'; + +interface UseFieldArrayProps< + TFieldValues extends FieldValues = FieldValues, + TFieldArrayName extends FieldArrayPath = FieldArrayPath, + TKeyName extends string = 'id', +> extends UseFieldArrayPropsOriginal { + lens?: LensCore; +} + +export function useFieldArray< + TFieldValues extends FieldValues = FieldValues, + TFieldArrayName extends FieldArrayPath = FieldArrayPath, + TKeyName extends string = 'id', +>(props: UseFieldArrayProps): UseFieldArrayReturn { + const result = useFieldArrayOriginal(props); + + const transformOnSet = (value: FieldArray) => { + if (!props.lens || !props.lens.settings.lensesMap || !value) { + return value; + } + + const newValue = {} as typeof value; + + Object.entries(value || {}).forEach(([key, value]) => { + // @ts-expect-error temporal workaround for array lense reflection + const restructuredLens = props.lens.settings.lensesMap?.[0]?.[key]; + const newKey = restructuredLens?.settings.propPath?.slice(`${props.lens?.settings.restructureSourcePath}.`.length); + set(newValue, newKey, value); + }); + + return newValue; + }; + + return { + ...result, + prepend: (value, options) => { + const newValue = Array.isArray(value) ? value.map(transformOnSet) : transformOnSet(value); + result.prepend(newValue, options); + }, + append: (value, options) => { + const newValue = Array.isArray(value) ? value.map(transformOnSet) : transformOnSet(value); + result.append(newValue, options); + }, + insert: (index, value, options) => { + const newValue = Array.isArray(value) ? value.map(transformOnSet) : transformOnSet(value); + result.insert(index, newValue, options); + }, + update: (index, value) => { + const newValue = transformOnSet(value); + result.update(index, newValue); + }, + replace: (value) => { + const newValue = Array.isArray(value) ? value.map(transformOnSet) : transformOnSet(value); + result.replace(newValue); + }, + }; +} diff --git a/src/types/lenses.ts b/src/types/lenses.ts index c307f23..a868d5e 100644 --- a/src/types/lenses.ts +++ b/src/types/lenses.ts @@ -260,7 +260,9 @@ export interface LensMap { map(fields: T, mapper: (value: Lens, key: string, index: number, array: this) => R, keyName?: string): R[]; } -export interface ArrayLens extends LensMap, LensFocus, LensReflect, LensJoin {} +export interface ArrayLens extends LensMap, LensFocus, LensJoin { + reflect: (getter: (original: Lens) => [LensesMap]) => Lens>[]>; +} export interface ObjectLens extends LensFocus, LensReflect, LensJoin {} export interface PrimitiveLens extends LensReflect, LensJoin {} diff --git a/tsup.config.ts b/tsup.config.ts index 3e4fefa..16c1e5f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,11 +1,11 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/rhf/index.ts'], format: ['esm', 'cjs'], dts: { resolve: true, - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/rhf/index.ts'], }, clean: true, sourcemap: true, From 976013f81749afe950be385703fcb2ea0c7c9aa7 Mon Sep 17 00:00:00 2001 From: Dmitrii Pikulin Date: Sat, 15 Mar 2025 10:55:17 +0300 Subject: [PATCH 3/5] create lenses storage cache (#8) --- README.md | 4 +-- src/LensCore.ts | 32 ++++++++++---------- src/LensesStorage.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 +- src/useLens.ts | 4 +-- src/utils.ts | 15 ---------- 6 files changed, 91 insertions(+), 36 deletions(-) create mode 100644 src/LensesStorage.ts delete mode 100644 src/utils.ts diff --git a/README.md b/README.md index 805eac3..a423baf 100644 --- a/README.md +++ b/README.md @@ -284,13 +284,13 @@ You can create lenses manually without `useLens` hook by utilizing the `LensCore ```tsx import { useMemo } from 'react'; import { useForm } from 'react-hook-form'; -import { createLensesCache, LensCore } from '@hookform/lenses'; +import { LensCore, LensesStorage } from '@hookform/lenses'; function App() { const { control } = useForm<{ firstName: string; lastName: string }>(); const lens = useMemo(() => { - const cache = createLensesCache(); + const cache = new LensesStorage(); return LensCore.create(control, cache); }, [control]); diff --git a/src/LensCore.ts b/src/LensCore.ts index f7c2ad4..b18cfb8 100644 --- a/src/LensCore.ts +++ b/src/LensCore.ts @@ -1,7 +1,7 @@ import { type Control, type FieldValues, get } from 'react-hook-form'; import type { Lens } from './types/lenses'; -import type { LensesCache } from './utils'; +import type { LensesStorage } from './LensesStorage'; interface Settings { lensesMap?: Record | [Record] | undefined; @@ -12,9 +12,9 @@ interface Settings { export class LensCore { public settings: Settings; public control: Control; - public cache: LensesCache; + public cache?: LensesStorage | undefined; - private constructor(control: Control, cache: LensesCache, settings: Settings = {}) { + private constructor(control: Control, cache?: LensesStorage, settings: Settings = {}) { this.control = control; this.cache = cache; this.settings = settings; @@ -22,7 +22,7 @@ export class LensCore { public static create( control: Control, - cache: LensesCache, + cache?: LensesStorage, ): Lens { return new LensCore(control, cache) as unknown as Lens; } @@ -40,15 +40,15 @@ export class LensCore { if (reflectedPropPath) { nestedPath = `${this.settings.propPath}.${reflectedPropPath}`; - if (!this.cache.primitives.has(nestedPath)) { + if (!this.cache?.has(nestedPath)) { const newLens = new LensCore(arrayReflectMapper.control, arrayReflectMapper.cache, { ...arrayReflectMapper.settings, propPath: nestedPath, }); - this.cache.primitives.set(nestedPath, newLens); + this.cache?.set(newLens, nestedPath); } - const focusedLens = this.cache.primitives.get(nestedPath); + const focusedLens = this.cache?.get(nestedPath); if (!focusedLens) { throw new Error(`There is no focused lens: ${nestedPath}`); @@ -65,16 +65,16 @@ export class LensCore { } } - if (!this.cache.primitives.has(nestedPath)) { + if (!this.cache?.has(nestedPath)) { const newLens = new LensCore(this.control, this.cache, { propPath: nestedPath, lensesMap: this.settings.lensesMap, restructureSourcePath: this.settings.restructureSourcePath, }); - this.cache.primitives.set(nestedPath, newLens); + this.cache?.set(newLens, nestedPath); } - const focusedLens = this.cache.primitives.get(nestedPath); + const focusedLens = this.cache?.get(nestedPath); if (!focusedLens) { throw new Error(`There is no focused lens: ${nestedPath}`); @@ -84,10 +84,10 @@ export class LensCore { } public reflect(getter: (original: LensCore) => Record | [Record]): LensCore { - const fromCache = this.cache.complex.get(getter); + const fromCache = this.cache?.get(this.settings.propPath ?? '', getter); if (fromCache) { - return fromCache.lens; + return fromCache; } const focusContext = getter(this); @@ -98,16 +98,16 @@ export class LensCore { restructureSourcePath: this.settings.propPath, }); - this.cache.complex.set(getter, { lens: newLens }); + this.cache?.set(newLens, this.settings.propPath ?? '', getter); return newLens; } public join(another: LensCore, merger: (original: LensCore, another: LensCore) => Record): LensCore { - const fromCache = this.cache.complex.get(merger); + const fromCache = this.cache?.get(this.settings.propPath ?? '', merger); if (fromCache) { - return fromCache.lens; + return fromCache; } const focusContext = merger(this, another); @@ -117,7 +117,7 @@ export class LensCore { restructureSourcePath: this.settings.propPath, }); - this.cache.complex.set(merger, { lens: newLens }); + this.cache?.set(newLens, this.settings.propPath ?? '', merger); return newLens; } diff --git a/src/LensesStorage.ts b/src/LensesStorage.ts new file mode 100644 index 0000000..9743a62 --- /dev/null +++ b/src/LensesStorage.ts @@ -0,0 +1,70 @@ +import type { LensCore } from './LensCore'; + +export type LensesStorageComplexKey = (...args: any[]) => any; + +export interface LensesStorageValue { + plain?: LensCore; + complex: WeakMap; +} + +export type LensCache = Map; + +export class LensesStorage { + private cache: LensCache; + + constructor() { + this.cache = new Map(); + } + + public get(propPath: string, complexKey?: LensesStorageComplexKey): LensCore | undefined { + const cached = this.cache.get(propPath); + + if (cached) { + if (complexKey) { + return cached.complex.get(complexKey); + } + + return cached.plain; + } + + return undefined; + } + + public set(lens: LensCore, propPath: string, complexKey?: LensesStorageComplexKey): void { + let cached = this.cache.get(propPath); + + if (!cached) { + cached = { + complex: new WeakMap(), + }; + + this.cache.set(propPath, cached); + } + + if (complexKey) { + cached.complex.set(complexKey, lens); + } else { + cached.plain = lens; + } + } + + public has(propPath: string, complexKey?: LensesStorageComplexKey): boolean { + if (complexKey) { + return this.cache.get(propPath)?.complex.has(complexKey) ?? false; + } + + return this.cache.has(propPath); + } + + public delete(propPath: string): void { + for (const key of this.cache.keys()) { + if (key.startsWith(propPath)) { + this.cache.delete(key); + } + } + } + + public clear(): void { + this.cache.clear(); + } +} diff --git a/src/index.ts b/src/index.ts index dbf2148..ea62da6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './LensCore'; +export * from './LensesStorage'; export * from './types/helpers'; export * from './types/lenses'; export * from './useLens'; -export * from './utils'; diff --git a/src/useLens.ts b/src/useLens.ts index 7526032..021db57 100644 --- a/src/useLens.ts +++ b/src/useLens.ts @@ -3,7 +3,7 @@ import type { Control, FieldValues } from 'react-hook-form'; import type { Lens } from './types/lenses'; import { LensCore } from './LensCore'; -import { createLensesCache } from './utils'; +import { LensesStorage } from './LensesStorage'; export interface UseLensProps { control: Control; @@ -28,7 +28,7 @@ export function useLens( deps: DependencyList = [], ): Lens { return useMemo(() => { - const cache = createLensesCache(); + const cache = new LensesStorage(); const lens = LensCore.create(props.control, cache); return lens; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index e13f6f3..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { LensCore } from './LensCore'; - -export interface LensesCache { - primitives: Map; - complex: WeakMap<(...args: any[]) => any, { lens: LensCore }>; -} - -export function createLensesCache(): LensesCache { - const cache: LensesCache = { - primitives: new Map(), - complex: new WeakMap(), - }; - - return cache; -} From 267fa5cfc637e22e40b8de3995fde155bed5aa97 Mon Sep 17 00:00:00 2001 From: Dmitrii Pikulin Date: Sat, 15 Mar 2025 17:43:17 +0300 Subject: [PATCH 4/5] remove join method (#9) --- README.md | 21 +++---- ...in.story.tsx => ReflectCombined.story.tsx} | 15 ++--- examples/tests/join.test.ts | 26 --------- src/LensCore.ts | 19 ------ src/types/lenses.ts | 58 ++----------------- 5 files changed, 20 insertions(+), 119 deletions(-) rename examples/stories/restructure/{Join.story.tsx => ReflectCombined.story.tsx} (64%) delete mode 100644 examples/tests/join.test.ts diff --git a/README.md b/README.md index a423baf..261f8e7 100644 --- a/README.md +++ b/README.md @@ -185,22 +185,19 @@ function AnotherComponent({ lens }: { lens: Lens<{ data: string }[]> }) { Pay attention that in case of array reflecting you have to pass an array with single item. -##### `join` - -Combines two lenses into one. You have to provide a merger function because in runtime it is not clear to which prop path of two lenses subsequent lens operations will be applied. +In addition you can use `reflect` to merge two lenses into one. ```tsx -function Card(props: { person: Lens<{ name: string }>; contact: Lens<{ phoneNumber: string }> }) { - return ( - ({ - name: person.focus('name'), - phoneNumber: contact.focus('phoneNumber'), - }))} - /> - ); +function Component({ lensA, lensB }: { lensA: Lens<{ firstName: string }>; lensB: Lens<{ lastName: string }> }) { + const combined = lensA.reflect((l) => ({ + firstName: l.focus('firstName'), + lastName: lensB.focus('lastName'), + })); + + // ... } ``` +Keep in mind that is such case the passed to `reflect` function is longer pure. ##### `map` (Array Lenses) diff --git a/examples/stories/restructure/Join.story.tsx b/examples/stories/restructure/ReflectCombined.story.tsx similarity index 64% rename from examples/stories/restructure/Join.story.tsx rename to examples/stories/restructure/ReflectCombined.story.tsx index 32dbaa8..ffb709c 100644 --- a/examples/stories/restructure/Join.story.tsx +++ b/examples/stories/restructure/ReflectCombined.story.tsx @@ -9,25 +9,22 @@ export default { title: 'Restructure', } satisfies Meta; -export interface JoinFormData { +export interface ReflectCombinedFormData { firstName: string; lastName: string; } -export interface JoinProps { - onSubmit: SubmitHandler; +export interface ReflectCombinedProps { + onSubmit: SubmitHandler; } -export function Join({ onSubmit = action('submit') }: JoinProps) { - const { handleSubmit, control } = useForm(); +export function ReflectCombined({ onSubmit = action('submit') }: ReflectCombinedProps) { + const { handleSubmit, control } = useForm(); const lens = useLens({ control }); - const firstName = lens.focus('firstName'); - const lastName = lens.focus('lastName'); - return (
- ({ name: firstNameLens, surname: lastNameLens }))} /> + ({ name: firstNameLens, surname: lens.focus('lastName') }))} />
diff --git a/examples/tests/join.test.ts b/examples/tests/join.test.ts deleted file mode 100644 index f1f64c1..0000000 --- a/examples/tests/join.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useForm } from 'react-hook-form'; -import { Lens, useLens } from '@hookform/lenses'; -import { renderHook } from '@testing-library/react'; -import { expectTypeOf } from 'vitest'; - -test('lens can merge with another lens', () => { - const { result: form1 } = renderHook(() => { - const form = useForm<{ a: string }>(); - const lens = useLens({ control: form.control }); - return lens; - }); - - const { result: form2 } = renderHook(() => { - const form = useForm<{ b: number }>(); - const lens = useLens({ control: form.control }); - return lens; - }); - - expectTypeOf(form1.current.join(form2.current, (l1, l2) => ({ a: l1.focus('a'), b: l2.focus('b') }))).toEqualTypeOf< - Lens<{ a: string; b: number }> - >(); - - expectTypeOf(form1.current.join(form2.current, (l1, l2) => ({ c: l1.focus('a'), d: l2.focus('b') }))).toEqualTypeOf< - Lens<{ c: string; d: number }> - >(); -}); diff --git a/src/LensCore.ts b/src/LensCore.ts index b18cfb8..3677208 100644 --- a/src/LensCore.ts +++ b/src/LensCore.ts @@ -103,25 +103,6 @@ export class LensCore { return newLens; } - public join(another: LensCore, merger: (original: LensCore, another: LensCore) => Record): LensCore { - const fromCache = this.cache?.get(this.settings.propPath ?? '', merger); - - if (fromCache) { - return fromCache; - } - - const focusContext = merger(this, another); - const newLens = new LensCore(this.control, this.cache, { - lensesMap: focusContext, - propPath: this.settings.propPath, - restructureSourcePath: this.settings.propPath, - }); - - this.cache?.set(newLens, this.settings.propPath ?? '', merger); - - return newLens; - } - public map( fields: Record[], mapper: (value: unknown, key: string, index: number, array: this) => R, diff --git a/src/types/lenses.ts b/src/types/lenses.ts index a868d5e..85ca032 100644 --- a/src/types/lenses.ts +++ b/src/types/lenses.ts @@ -170,56 +170,6 @@ export interface LensReflect { reflect: (getter: (original: Lens) => LensesMap) => Lens>>; } -export interface LensJoin { - /** - * This method allows you to join two lenses into a single lens. - * This can be useful when you want to create a new lens from two existing lenses. - * - * @param another - The lens to join with the original lens. - * @param merger - A function that returns an object where each field is a lens. - * - * @example - * ```tsx - * function App() { - * const { control, handleSubmit } = useForm<{ - * firstName: string; - * lastName: string; - * }>(); - * - * const lens = useLens({ control }); - * const firstName = lens.focus('firstName'); - * const lastName = lens.focus('lastName'); - * - * return ( - * - * ({ - * name: firstNameLens, - * surname: lastNameLens, - * }))} - * /> - * - * - * ); - * } - * - * function SharedComponent({ lens }: { lens: Lens<{ name: string; surname: string }> }) { - * return ( - *
- * - * - *
- * ); - * } - * - * function StringInput({ lens }: { lens: Lens }) { - * return ctrl.register(name))} />; - * } - * ``` - */ - join: (another: T2, merger: (original: Lens, another: T2) => LensesMap) => Lens>>; -} - export interface LensMap { /** * This method allows you to map an array lens. @@ -260,11 +210,13 @@ export interface LensMap { map(fields: T, mapper: (value: Lens, key: string, index: number, array: this) => R, keyName?: string): R[]; } -export interface ArrayLens extends LensMap, LensFocus, LensJoin { +export interface ArrayLens extends LensMap, LensFocus { reflect: (getter: (original: Lens) => [LensesMap]) => Lens>[]>; } -export interface ObjectLens extends LensFocus, LensReflect, LensJoin {} -export interface PrimitiveLens extends LensReflect, LensJoin {} +export interface ObjectLens extends LensFocus, LensReflect {} +// Will leave primitive lense interface for future use +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface PrimitiveLens extends LensReflect {} /** * This is a type that allows you to hold the type of a form element. From 84e5a47e4e6af83391d96b48ee14a233ebc3832d Mon Sep 17 00:00:00 2001 From: Dmitrii Pikulin Date: Wed, 19 Mar 2025 12:38:39 +0300 Subject: [PATCH 5/5] feat: pass array item to lens map callback (#10) --- README.md | 1 + examples/stories/Complex.story.tsx | 8 ++++---- examples/stories/Demo.story.tsx | 4 ++-- examples/stories/Quickstart.story.tsx | 4 ++-- examples/stories/array/Key.story.tsx | 14 +++++--------- examples/stories/array/Map.story.tsx | 4 ++-- examples/stories/restructure/Reflect.story.tsx | 4 ++-- examples/tests/map.test.ts | 11 +++++------ examples/tests/reflect.test.ts | 2 +- src/LensCore.ts | 9 ++++----- src/types/lenses.ts | 2 +- 11 files changed, 29 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 261f8e7..4283853 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ function Component({ lensA, lensB }: { lensA: Lens<{ firstName: string }>; lensB // ... } ``` + Keep in mind that is such case the passed to `reflect` function is longer pure. ##### `map` (Array Lenses) diff --git a/examples/stories/Complex.story.tsx b/examples/stories/Complex.story.tsx index c573d1d..2fc4d8e 100644 --- a/examples/stories/Complex.story.tsx +++ b/examples/stories/Complex.story.tsx @@ -68,8 +68,8 @@ function MoviesForm({ lens }: { lens: Lens }) {
- {lens.map(fields, (l, key, index) => ( -
+ {lens.map(fields, (value, l, index) => ( +
@@ -89,8 +89,8 @@ function ActorsForm({ lens }: { lens: Lens }) {
- {lens.map(fields, (l, key, index) => ( -
+ {lens.map(fields, (value, l, index) => ( +
diff --git a/examples/stories/Demo.story.tsx b/examples/stories/Demo.story.tsx index f1e305e..957fba3 100644 --- a/examples/stories/Demo.story.tsx +++ b/examples/stories/Demo.story.tsx @@ -50,8 +50,8 @@ function ChildForm({ lens }: { lens: Lens }) { return (
- {lens.map(fields, (l, key) => ( - + {lens.map(fields, (value, l) => ( + ))} - {lens.map(fields, (l, key) => ( - + {lens.map(fields, (value, l) => ( + ))} ); diff --git a/examples/stories/array/Key.story.tsx b/examples/stories/array/Key.story.tsx index 9b1c2ee..0adf908 100644 --- a/examples/stories/array/Key.story.tsx +++ b/examples/stories/array/Key.story.tsx @@ -41,15 +41,11 @@ function Items({ lens }: { lens: Lens }) { return (
- {lens.map( - fields, - (l, key) => ( -
- -
- ), - 'anotherId', - )} + {lens.map(fields, (value, l) => ( +
+ +
+ ))}
); } diff --git a/examples/stories/array/Map.story.tsx b/examples/stories/array/Map.story.tsx index 22d0b77..7857dee 100644 --- a/examples/stories/array/Map.story.tsx +++ b/examples/stories/array/Map.story.tsx @@ -40,8 +40,8 @@ function Items({ lens }: { lens: Lens }) { return (
- {lens.map(fields, (l, key) => ( -
+ {lens.map(fields, (value, l) => ( +
))} diff --git a/examples/stories/restructure/Reflect.story.tsx b/examples/stories/restructure/Reflect.story.tsx index a2303fa..3ba57a6 100644 --- a/examples/stories/restructure/Reflect.story.tsx +++ b/examples/stories/restructure/Reflect.story.tsx @@ -86,8 +86,8 @@ function Items({ lens }: { lens: Lens<{ data: string }[]> }) { return (
- {lens.map(fields, (l, key) => ( -
+ {lens.map(fields, (value, l) => ( +
))} diff --git a/examples/tests/map.test.ts b/examples/tests/map.test.ts index 95ade67..3b33bce 100644 --- a/examples/tests/map.test.ts +++ b/examples/tests/map.test.ts @@ -12,7 +12,7 @@ test('map can create a new lens', () => { expectTypeOf(result.current.lens).toEqualTypeOf>(); - const itemLenses = result.current.lens.focus('items').map([{ a: '1' }, { a: '2' }], (item) => item.focus('a')); + const itemLenses = result.current.lens.focus('items').map([{ a: '1' }, { a: '2' }], (_, item) => item.focus('a')); expectTypeOf(itemLenses).toEqualTypeOf[]>(); @@ -20,7 +20,7 @@ test('map can create a new lens', () => { expect(itemLenses[1]?.interop()).toEqual({ name: 'items.1.a', control: result.current.form.control, lens: itemLenses[1] }); }); -test('map callback accepts a keyName and index', () => { +test('map callback accepts a value and index', () => { const { result } = renderHook(() => { const form = useForm<{ items: { a: string; myId: string }[] }>(); const lens = useLens({ control: form.control }); @@ -35,19 +35,18 @@ test('map callback accepts a keyName and index', () => { { a: '1', myId: 'one' }, { a: '2', myId: 'two' }, ], - (l, keyName, index) => ({ lens: l, interop: l.interop(), keyName, index }), - 'myId', + (value, l, index) => ({ lens: l, interop: l.interop(), id: value.myId, index }), ); expect(itemLenses[0]).toMatchObject({ interop: { name: 'items.0', control: result.current.form.control, lens: itemLenses[0]?.lens }, - keyName: 'one', + id: 'one', index: 0, }); expect(itemLenses[1]).toMatchObject({ interop: { name: 'items.1', control: result.current.form.control, lens: itemLenses[1]?.lens }, - keyName: 'two', + id: 'two', index: 1, }); }); diff --git a/examples/tests/reflect.test.ts b/examples/tests/reflect.test.ts index bdfef14..0de7d4e 100644 --- a/examples/tests/reflect.test.ts +++ b/examples/tests/reflect.test.ts @@ -79,7 +79,7 @@ test('reflect can work with array', () => { expectTypeOf(result.current.lens).toEqualTypeOf>(); - const [one, two] = result.current.lens.map(result.current.arr.fields, (l) => l.focus('another').interop()); + const [one, two] = result.current.lens.map(result.current.arr.fields, (_, l) => l.focus('another').interop()); expect(one).toEqual({ name: 'items.0.value', diff --git a/src/LensCore.ts b/src/LensCore.ts index 3677208..ff7d804 100644 --- a/src/LensCore.ts +++ b/src/LensCore.ts @@ -105,16 +105,15 @@ export class LensCore { public map( fields: Record[], - mapper: (value: unknown, key: string, index: number, array: this) => R, - keyName = 'id', + mapper: (value: unknown, item: LensCore, index: number, array: unknown[], lens: this) => R, ): R[] { if (!this.settings.propPath) { throw new Error(`There is no prop name in this lens: ${this.settings.propPath}`); } - return fields.map((value, index) => { - const field = this.focus(index.toString()); - const res = mapper(field, value[keyName], index, this); + return fields.map((value, index, array) => { + const item = this.focus(index.toString()); + const res = mapper(value, item, index, array, this); return res; }); } diff --git a/src/types/lenses.ts b/src/types/lenses.ts index 85ca032..c6c510b 100644 --- a/src/types/lenses.ts +++ b/src/types/lenses.ts @@ -207,7 +207,7 @@ export interface LensMap { * } * ``` */ - map(fields: T, mapper: (value: Lens, key: string, index: number, array: this) => R, keyName?: string): R[]; + map(fields: F, mapper: (value: F[number], item: Lens, index: number, array: F, lens: this) => R): R[]; } export interface ArrayLens extends LensMap, LensFocus {