diff --git a/bun.lock b/bun.lock index 84a8aae..2ca1c19 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,10 @@ "": { "name": "vite-react-template", "dependencies": { + "@base-ui/react": "1.6.0", "@inlang/paraglide-js": "2.18.0", "@inlang/plugin-message-format": "4.4.0", + "@tanstack/react-form": "1.33.0", "@tanstack/react-query": "5.100.6", "@tanstack/react-router": "1.168.26", "babel-plugin-react-compiler": "1.0.0", @@ -88,6 +90,10 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], + + "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], + "@biomejs/biome": ["@biomejs/biome@2.4.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.13", "@biomejs/cli-darwin-x64": "2.4.13", "@biomejs/cli-linux-arm64": "2.4.13", "@biomejs/cli-linux-arm64-musl": "2.4.13", "@biomejs/cli-linux-x64": "2.4.13", "@biomejs/cli-linux-x64-musl": "2.4.13", "@biomejs/cli-win32-arm64": "2.4.13", "@biomejs/cli-win32-x64": "2.4.13" }, "bin": { "biome": "bin/biome" } }, "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg=="], @@ -158,6 +164,14 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.8.1", "", { "dependencies": { "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "c12": "3.3.4", "color-support": "1.1.3" } }, "sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.4.2", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.1.1" } }, "sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q=="], @@ -310,15 +324,23 @@ "@svgr/plugin-svgo": ["@svgr/plugin-svgo@8.1.0", "", { "dependencies": { "cosmiconfig": "^8.1.3", "deepmerge": "^4.3.1", "svgo": "^3.0.2" }, "peerDependencies": { "@svgr/core": "*" } }, "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.33.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-AV4Pw9Dk4orFsuPBcDssfWMJFs+yMYBae7zZ4oTqrCf4ftNGQKxvrQRZeqKHG6A4TkiLeSvf2kzIjcVkrW7E6w=="], + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.6", "", {}, "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg=="], + "@tanstack/react-form": ["@tanstack/react-form@1.33.0", "", { "dependencies": { "@tanstack/form-core": "1.33.0", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-unaee+VS4MvKo+s1dmgGUXI4902VeAhuaUbKsQbhFe3MceOpB3JpAUGCDpyzjQPXVFkFY0COKfLrUNX2XZYW4g=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.6", "", { "dependencies": { "@tanstack/query-core": "5.100.6" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA=="], "@tanstack/react-router": ["@tanstack/react-router@1.168.26", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.18", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-+MV+U5KfMUQGZIU/x8MU3FMRSujxLs678v2jhu1Y8P9ndQBKLVOBYKFY+vv/ypxBUYiyDiOsZkDxPJC8UPo/Ig=="], - "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="], "@tanstack/router-core": ["@tanstack/router-core@1.168.18", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-rheeg/+hIHSVw9IDzcc5NJlKamKtKJN/c8rPG9XEmLwHvA4C1WRN/yjMTGgoGNU0xKKjL2AzvUhYMSaBdelbEA=="], @@ -328,7 +350,7 @@ "@tanstack/router-utils": ["@tanstack/router-utils@1.161.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-VkY0u7ax/GD0qU6ZLLnfPC+UMxVzxRbvZp4yV4iUSXjgJZ/siAT5/QlLm9FEDJ9QDoC0VD9W7f00tKKreUI7Ng=="], - "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], @@ -682,6 +704,8 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -772,6 +796,8 @@ "@inlang/paraglide-js/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -792,6 +818,8 @@ "vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..01d1e60 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Documentation + +This folder contains documentation on how to use the IGNE vite-react-template. You can also add project-specific docs. diff --git a/docs/recipes/extending-base-ui.md b/docs/recipes/extending-base-ui.md new file mode 100644 index 0000000..fe7769c --- /dev/null +++ b/docs/recipes/extending-base-ui.md @@ -0,0 +1,38 @@ +# Extending base-ui + +[Base UI](https://base-ui.com) covers a bunch of generic components that we use to build our own on. + +Oftentimes you may want to implement base-ui as-is, but you need a generic way to apply generic styling or functionality to its compound components. +This doc describes a good way to do that. + +## Import and re-export + +For example a menu's Trigger should always contain an arrow: + +```tsx +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ChevronRight from "assets/icons/chevron-right.svg?react"; + +const MenuTrigger = ({children, ...rest}: BaseMenu.Trigger.Props) => + {children} + +export default { + ...BaseMenu, + Trigger: MenuTrigger, +} +``` + +This way you only have to redefine the compound components that you change. +The suffix format (in this case `Menu`Trigger), prevents issues with reserved component names (eg. Error from Field.Error). + +## If you add props, always export a type + +```tsx +//... + +export type MenuTriggerProps = BaseMenu.Trigger.Props & { icon: "chevron" | "arrow" } +const MenuTrigger = ({children, icon, ...rest}: MenuTriggerProps) => + {children}{icon ? : } + +//... +``` diff --git a/docs/working-with-forms.md b/docs/working-with-forms.md new file mode 100644 index 0000000..35a917a --- /dev/null +++ b/docs/working-with-forms.md @@ -0,0 +1,34 @@ +# Working with forms + +We use [Tanstack Form](https://tanstack.com/form/latest/docs/framework/react/guides/basic-concepts) (TSF). + +In `lib/forms/` you can define reusable form/field components. See the docs on [form composition](https://tanstack.com/form/latest/docs/framework/react/guides/form-composition) for more info. + +Form components are in `components/form/`. Tanstack Form connectors should be prefixed with `tsf-`. +If you need for example an Input component that is not connected to TSF, create a separate component. Be specific, a search component is `` etc. + +If you need something VERY specific (used only once), you may not need to make a component at all. Just use BaseUI primitives and reuse the styling classnames. + +## How to adopt + +### Error handling + +The first thing to do is to verify the **error object(s)** with Backend. +Make sure that the validation/error utils can translate the backend errors into something tanstack form understands. +See TODO's in `lib/forms/validation-helpers` & `lib/api/error-helpers.ts`. + +### Validation options + +- Use a validation schema (preferably generated from api spec) to do FE validation of the form. Connect to `onDynamic`. +- If applicable, use the provided util to handle backend validation errors with `onSubmitAsync` & `mutateAndValidate`. + Note: this is a temp solution. + +### Submission + +Prefer submitting with Tanstack Query (`useMutation`). An option object should be generated by heyapi. + + +See the form-example route to see how it all comes together. +Note: Normally you would not put everything in the route file, but this is easy to remove when you start a new project. + +💡 Remove any example files/code you don't need in your project! diff --git a/messages/en-US.json b/messages/en-US.json index 744529f..07e4f91 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "forms_optional": "Optional", + "roles_user": "User", "roles_admin": "Administrator", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 8825340..19a3ae4 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "forms_optional": "Optioneel", + "roles_user": "Gebruiker", "roles_admin": "Administrator", diff --git a/openapi.json b/openapi.json deleted file mode 100644 index 0683487..0000000 --- a/openapi.json +++ /dev/null @@ -1,203 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Example API", - "description": "An example API for Igne's vite-react-template", - "version": "2.0.0" - }, - "servers": [ - { - "url": "https://localhost.com:5173", - "description": "Vite development server with reverse proxy to circumvent CORS." - } - ], - "components": { - "securitySchemes": { - "auth-cookie": { "type": "apiKey", "in": "cookie", "name": "auth-cookie" } - }, - "schemas": { - "GenericError": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - }, - "ValidationError": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Error overview" - }, - "errors": { - "type": "object", - "description": "A detailed description of each field that failed validation.", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "LoginRequest": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "password": { - "type": "string" - } - }, - "required": ["email", "password"] - }, - "ForgotPasswordRequest": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - }, - "required": ["email"] - }, - "UserDTO": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - }, - "required": ["email"] - } - }, - "responses": { - "ValidationException": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "AuthenticationException": { - "description": "Unauthenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GenericError" - } - } - } - } - } - }, - "security": [{ "auth-cookie": [] }], - "paths": { - "/api/auth/login": { - "post": { - "responses": { - "204": { - "description": "Logs in and sets cookie on success." - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - }, - "operationId": "postApiAuthLogin", - "tags": ["Authentication"], - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - } - } - } - }, - "/api/auth/logout": { - "post": { - "operationId": "postApiAuthLogout", - "tags": ["Authentication"], - "summary": "Logs out the current user by invalidating the auth cookie.", - "responses": { - "204": { - "description": "Logs out and invalidates the auth cookie.", - "headers": { - "Set-Cookie": { - "description": "Clears the auth cookie.", - "schema": { - "type": "string", - "example": "auth-cookie=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0" - } - } - } - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, - "/api/auth/forgot-password": { - "post": { - "responses": { - "204": { - "description": "If there is a user with the given email address, a recovery email will be sent to that address." - }, - "422": { - "$ref": "#/components/responses/ValidationException" - } - }, - "operationId": "postApiAuthForgotPassword", - "tags": ["Authentication"], - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ForgotPasswordRequest" - } - } - } - } - } - }, - "/api/users/current": { - "get": { - "operationId": "getApiUsersCurrent", - "tags": ["Users"], - "summary": "Get the current authenticated user's details.", - "responses": { - "200": { - "description": "Returns the details of the current authenticated user.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserDTO" - } - } - } - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - } - } -} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..15e246f --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,215 @@ +openapi: 3.1.0 +info: + title: Example API + description: An example API for Igne's vite-react-template + version: 2.0.0 +servers: + - url: https://localhost.com:5173 + description: Vite development server with reverse proxy to circumvent CORS. +components: + securitySchemes: + auth-cookie: + type: apiKey + in: cookie + name: auth-cookie + schemas: + GenericError: + type: object + properties: + type: + type: string + description: URI to error type spec + title: + type: string + description: Error message + detail: + type: string + description: More detailed description of the error + status: + type: number + description: HTML status code + code: + type: string + description: Short code to identify the error. + required: + - type + - title + - status + - code + ValidationError: + type: object + properties: + type: + type: string + description: URI to error type spec + title: + type: string + description: Error message + detail: + type: string + description: More detailed description of the error + status: + type: number + description: HTML status code + code: + type: string + description: Short code to identify the error. + errors: + type: object + description: >- + Map of field names to the validation errors for that field. Keys are + the field names, such as "email", "phone", or a nested path like + "user.0.name". + additionalProperties: + type: array + items: + $ref: "#/components/schemas/FieldError" + required: + - type + - title + - status + - code + FieldError: + type: object + properties: + title: + type: string + properties: + type: object + properties: + value: + type: string + size: + type: string + attribute: + type: string + min: + type: string + max: + type: string + other: + type: string + values: + type: string + date: + type: string + format: + type: string + required: + - attribute + code: + type: string + required: + - title + - properties + - code + LoginRequest: + type: object + properties: + email: + type: string + format: email + password: + type: string + required: + - email + - password + ForgotPasswordRequest: + type: object + properties: + email: + type: string + format: email + required: + - email + UserDTO: + type: object + properties: + email: + type: string + format: email + required: + - email + responses: + ValidationException: + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ValidationError" + AuthenticationException: + description: Unauthenticated + content: + application/json: + schema: + $ref: "#/components/schemas/GenericError" +security: + - auth-cookie: [] +paths: + /api/auth/login: + post: + responses: + "204": + description: Logs in and sets cookie on success. + "401": + $ref: "#/components/responses/AuthenticationException" + "422": + $ref: "#/components/responses/ValidationException" + operationId: postApiAuthLogin + tags: + - Authentication + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + /api/auth/logout: + post: + operationId: postApiAuthLogout + tags: + - Authentication + summary: Logs out the current user by invalidating the auth cookie. + responses: + "204": + description: Logs out and invalidates the auth cookie. + headers: + Set-Cookie: + description: Clears the auth cookie. + schema: + type: string + example: auth-cookie=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0 + "401": + $ref: "#/components/responses/AuthenticationException" + /api/auth/forgot-password: + post: + responses: + "204": + description: If there is a user with the given email address, a recovery email + will be sent to that address. + "422": + $ref: "#/components/responses/ValidationException" + operationId: postApiAuthForgotPassword + tags: + - Authentication + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ForgotPasswordRequest" + /api/users/current: + get: + operationId: getApiUsersCurrent + tags: + - Users + summary: Get the current authenticated user's details. + responses: + "200": + description: Returns the details of the current authenticated user. + content: + application/json: + schema: + $ref: "#/components/schemas/UserDTO" + "401": + $ref: "#/components/responses/AuthenticationException" diff --git a/package.json b/package.json index 9847079..9b310be 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "fix": "bun biome check --write src && bun prettier --write 'src/**/*.{s,}css' && bun tsc --noEmit" }, "dependencies": { + "@base-ui/react": "1.6.0", "@inlang/paraglide-js": "2.18.0", "@inlang/plugin-message-format": "4.4.0", + "@tanstack/react-form": "1.33.0", "@tanstack/react-query": "5.100.6", "@tanstack/react-router": "1.168.26", "babel-plugin-react-compiler": "1.0.0", diff --git a/router-i18n.ts b/router-i18n.ts index f00659f..22302cb 100644 --- a/router-i18n.ts +++ b/router-i18n.ts @@ -56,4 +56,8 @@ export const translatedPathnames = "en-US": "/forgot-password", "nl-NL": "/wachtwoord-vergeten", }, + "/form-example": { + "en-US": "/form-example", + "nl-NL": "/formulier-voorbeeld", + }, }); diff --git a/src/components/error-text/error-text.tsx b/src/components/error-text/error-text.tsx index b2ad5ed..a25f962 100644 --- a/src/components/error-text/error-text.tsx +++ b/src/components/error-text/error-text.tsx @@ -1,34 +1,42 @@ +import clsx from "clsx"; import { Fragment, type HTMLAttributes } from "react"; import style from "./error-text.module.scss"; export type ErrorProp = string | string[]; -type Props = HTMLAttributes<"p"> & { - children?: ErrorProp; -} & ( - | { - el?: "p" | "span"; - htmlFor?: never; - } - | { - el: "label"; - htmlFor: string; - } - ); +type Props = Readonly< + Omit, "children"> & { + className?: string; + children?: ErrorProp; + } & ( + | { + el?: "p" | "span"; + htmlFor?: never; + } + | { + el: "label"; + htmlFor: string; + } + ) +>; /** * Will only render if children is a string or array of strings. */ export function ErrorText({ el = "p", + className, children, htmlFor = undefined, }: Props) { - if (!children) return null; + if (!children || children.length < 1) return null; const El = el; return ( - + {Array.isArray(children) ? children.map((e, i, all) => ( // biome-ignore lint/suspicious/noArrayIndexKey: just text, no order logic diff --git a/src/components/form/README.md b/src/components/form/README.md deleted file mode 100644 index 7e50417..0000000 --- a/src/components/form/README.md +++ /dev/null @@ -1,4 +0,0 @@ -Note that components in this folder are not ready for production, -they are just meant to give this template some content. - -TODO: Create proper reusable form elements. Possibly in a separate repo. diff --git a/src/components/form/button/button.module.scss b/src/components/form/button/button.module.scss index 4e59ce6..dba14e0 100644 --- a/src/components/form/button/button.module.scss +++ b/src/components/form/button/button.module.scss @@ -1,4 +1,4 @@ -@use "style/variables/mixins.scss"; +@use "../form.scss"; .button { cursor: pointer; @@ -10,6 +10,6 @@ color: black; &:disabled { - @include mixins.disabled; + @include form.disabled; } } diff --git a/src/components/form/checkbox/check.svg b/src/components/form/checkbox/check.svg new file mode 100644 index 0000000..a2c89c0 --- /dev/null +++ b/src/components/form/checkbox/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/form/checkbox/checkbox.module.scss b/src/components/form/checkbox/checkbox.module.scss new file mode 100644 index 0000000..6161620 --- /dev/null +++ b/src/components/form/checkbox/checkbox.module.scss @@ -0,0 +1,57 @@ +@use "../form.scss"; + +.checkboxGroup { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--unit-2); +} + +.root { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: var(--unit-4); +} + +.label { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--unit-2); + cursor: pointer; +} + +.control { + display: inline-flex; + flex: 0 0 auto; + justify-content: center; + align-items: center; + border: 2px solid currentColor; + border-radius: var(--unit-1); + background: white; + width: var(--unit-8); + height: var(--unit-8); + color: black; + + @media not (prefers-reduced-motion) { + transition: all 0.2s ease; + } + + &[aria-invalid="true"] { + @include form.invalid; + } + + &:has(~ :disabled) { + @include form.disabled; + } +} + +.indicator { + display: inline-flex; + + svg { + width: 100%; + height: 100%; + } +} diff --git a/src/components/form/checkbox/checkbox.tsx b/src/components/form/checkbox/checkbox.tsx new file mode 100644 index 0000000..7bd3d10 --- /dev/null +++ b/src/components/form/checkbox/checkbox.tsx @@ -0,0 +1,28 @@ +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import clsx from "clsx"; +import CheckIcon from "./check.svg?react"; +import style from "./checkbox.module.scss"; + +export type CheckboxProps = BaseCheckbox.Root.Props; + +/** + * This is just the Checkbox control wired into BaseUI + * Use in combination with BaseField.Label + */ +const Checkbox = ({ + className, + ...props +}: CheckboxProps) => { + return ( + + + + + + ); +}; + +export default Checkbox; diff --git a/src/components/form/checkbox/tsf-checkbox-group.tsx b/src/components/form/checkbox/tsf-checkbox-group.tsx new file mode 100644 index 0000000..1d5105e --- /dev/null +++ b/src/components/form/checkbox/tsf-checkbox-group.tsx @@ -0,0 +1,70 @@ +import { CheckboxGroup as BaseCheckboxGroup } from "@base-ui/react/checkbox-group"; +import { Field as BaseField } from "@base-ui/react/field"; +import { useFieldContext } from "lib/forms"; +import Field from "../field/field"; +import Checkbox from "./checkbox"; +import style from "./checkbox.module.scss"; + +export type CheckboxGroupItem = Readonly<{ + label: string; + value: string; +}>; + +type Props = { + fieldLabel: string; + items: CheckboxGroupItem[]; + required?: boolean; + description?: string; +}; + +/** + * A list of multiple checkboxes for tanstack form. + * All checkboxes have the same name. The value will be an array of checked checkbox strings + */ +const TSFCheckboxGroup = ({ + fieldLabel, + description, + required, + items, +}: Props) => { + const field = useFieldContext(); + + return ( + + + {fieldLabel} + + field.handleChange(value)} + > + {items.map(({ label, value }) => ( + ( + + )} + > + + + {label} + + ))} + + {field.getMeta().errors} + {description} + + ); +}; + +export default TSFCheckboxGroup; diff --git a/src/components/form/checkbox/tsf-checkbox.tsx b/src/components/form/checkbox/tsf-checkbox.tsx new file mode 100644 index 0000000..98b49f3 --- /dev/null +++ b/src/components/form/checkbox/tsf-checkbox.tsx @@ -0,0 +1,50 @@ +import { Field as BaseField } from "@base-ui/react/field"; +import { useFieldContext } from "lib/forms"; +import Field from "../field/field"; +import Checkbox, { type CheckboxProps } from "./checkbox"; +import style from "./checkbox.module.scss"; + +type Props = CheckboxProps & { + label: string; + fieldLabel?: string; + description?: string; +}; + +/** + * Single checkbox connector for tanstack form + */ +const TSFCheckbox = ({ + label, + fieldLabel, + description, + required, + className, + ...props +}: Props) => { + const field = useFieldContext(); + + return ( + + + {fieldLabel} + + + + field.handleChange(checked) + } + onBlur={field.handleBlur} + aria-invalid={!field.state.meta.isValid} + {...props} + /> + {label} + + {field.getMeta().errors} + {description} + + ); +}; + +export default TSFCheckbox; diff --git a/src/components/form/field/field.module.scss b/src/components/form/field/field.module.scss new file mode 100644 index 0000000..779ac6d --- /dev/null +++ b/src/components/form/field/field.module.scss @@ -0,0 +1,19 @@ +.field { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: var(--unit-2); + width: 100%; +} + +.label { + cursor: default; +} + +.optional { + color: lch(from currentColor calc(l + 60) c h); +} + +.description { + font-style: italic; +} diff --git a/src/components/form/field/field.tsx b/src/components/form/field/field.tsx new file mode 100644 index 0000000..c1f6203 --- /dev/null +++ b/src/components/form/field/field.tsx @@ -0,0 +1,113 @@ +import { + Field as BaseField, + type FieldRootProps as BaseFieldRootProps, +} from "@base-ui/react/field"; +import clsx from "clsx"; +import { ErrorText } from "components/error-text/error-text"; +import { normalizeFieldErrors } from "lib/forms/validation-helpers"; +import * as m from "lib/paraglide/messages"; +import style from "./field.module.scss"; + +/** + * Styling for fields, to wrap your form control with + */ +const FieldRoot = ({ + className, + ...props +}: FieldRootProps) => ( + +); +export type FieldRootProps = BaseFieldRootProps & { + className?: string; +}; + +/** + * Styled Field.Label. Won't render without children + */ +const FieldLabel = ({ + children, + required, + ...props +}: FieldLabelProps) => + children && ( + + {children}{" "} + {!required && ( + + {m.forms_optional()} + + )} + + ); +export type FieldLabelProps = BaseField.Label.Props & { + required?: boolean; +}; + +/** + * A paragraph styled like a field label + */ +const FieldLabelLike = ({ + children, + required, + ...props +}: FieldLabelLikeProps) => + children && ( +

+ {children}{" "} + {!required && ( + + {m.forms_optional()} + + )} +

+ ); +export type FieldLabelLikeProps = + React.ComponentProps<"p"> & { + required?: boolean; + }; + +/** + * Styled Field.Description + */ +const FieldDescription = ({ + children, + className, + ...props +}: BaseField.Description.Props) => + children && ( + + {children} + + ); + +/** + * Custom Field.Error + * Normalizes given errors to array of strings + */ +const FieldError = ({ + children: errors, +}: BaseField.Error.Props) => ( + ( + + {normalizeFieldErrors(errors)} + + )} + /> +); + +export default { + ...BaseField, + Root: FieldRoot, + Label: FieldLabel, + LabelLike: FieldLabelLike, + Description: FieldDescription, + Error: FieldError, +}; diff --git a/src/components/form/form.module.scss b/src/components/form/form.module.scss index 5537898..a3f856c 100644 --- a/src/components/form/form.module.scss +++ b/src/components/form/form.module.scss @@ -1,3 +1,9 @@ -.disablerFieldset { - display: contents; +// Shared classnames for all form instances, to be used in tsx + +.form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--unit-8); + max-width: 600px; } diff --git a/src/style/variables/mixins.scss b/src/components/form/form.scss similarity index 75% rename from src/style/variables/mixins.scss rename to src/components/form/form.scss index 6d6ae67..0ab9720 100644 --- a/src/style/variables/mixins.scss +++ b/src/components/form/form.scss @@ -1,10 +1,9 @@ -// Meant for recurring styling. +// Shared styles to be used in scss // // @use "style/variables/mixins.scss" // .selector { -// @include mixins.disabled; +// @include form.disabled; // } - @mixin disabled { opacity: 0.5; cursor: not-allowed; diff --git a/src/components/form/form/form.module.scss b/src/components/form/form/form.module.scss new file mode 100644 index 0000000..5537898 --- /dev/null +++ b/src/components/form/form/form.module.scss @@ -0,0 +1,3 @@ +.disablerFieldset { + display: contents; +} diff --git a/src/components/form/form.tsx b/src/components/form/form/form.tsx similarity index 100% rename from src/components/form/form.tsx rename to src/components/form/form/form.tsx diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 9d2d2b5..488bd3e 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,4 +1,7 @@ +// Tanstack Form connectors are not exported; they are only +// to be used in createFormHook export { default as Button } from "./button/button"; -export { default as Form } from "./form"; +export { default as Field } from "./field/field"; +export { default as Form } from "./form/form"; export { default as Input } from "./input/input"; export { default as Select } from "./select/select"; diff --git a/src/components/form/input/input.module.scss b/src/components/form/input/input.module.scss index feb6b45..b44efaf 100644 --- a/src/components/form/input/input.module.scss +++ b/src/components/form/input/input.module.scss @@ -1,12 +1,12 @@ -@use "style/variables/mixins.scss"; +@use "../form.scss"; .input { display: inline-flex; - flex: 1; border: 2px solid currentColor; border-radius: var(--unit-1); background: white; padding: var(--unit-4); + width: 100%; min-width: 0; color: black; @@ -15,10 +15,14 @@ } &[aria-invalid="true"] { - @include mixins.invalid; + @include form.invalid; } &:disabled { - @include mixins.disabled; + @include form.disabled; } } + +.errorLabel { + cursor: default; +} diff --git a/src/components/form/input/input.tsx b/src/components/form/input/input.tsx index 41ca7d8..2e249cb 100644 --- a/src/components/form/input/input.tsx +++ b/src/components/form/input/input.tsx @@ -1,28 +1,22 @@ +import { + Input as BaseInput, + type InputProps as BaseInputProps, +} from "@base-ui/react/input"; +import clsx from "clsx"; import style from "./input.module.scss"; -type Props = React.ComponentPropsWithoutRef<"input"> & { - name: string; - label?: string; - isInvalid?: boolean; -}; +export type InputProps = BaseInputProps; -function Input({ - label, - isInvalid, +const Input = ({ type = "text", + className, ...props -}: Props) { - return ( - <> - {label && {label}} - - - ); -} +}: InputProps) => ( + +); export default Input; diff --git a/src/components/form/input/tsf-input.tsx b/src/components/form/input/tsf-input.tsx new file mode 100644 index 0000000..a84acc3 --- /dev/null +++ b/src/components/form/input/tsf-input.tsx @@ -0,0 +1,50 @@ +import { Field as BaseField } from "@base-ui/react/field"; +import { useFieldContext } from "lib/forms"; +import Field from "../field/field"; +import Input, { type InputProps } from "./input"; +import style from "./input.module.scss"; + +type Props = InputProps & { + label?: string; + description?: string; + noError?: true; // Handy for multiple small fields in a row +}; + +const TSFInput = ({ + type = "text", + label, + description, + required, + noError, + className, + ...props +}: Props) => { + const field = useFieldContext(); + + return ( + + {label} + + field.handleChange(evt.target.value) + } + onBlur={field.handleBlur} + aria-invalid={!field.state.meta.isValid} + {...props} + /> + {!noError && ( + + + {field.getMeta().errors} + + + )} + {description} + + ); +}; + +export default TSFInput; diff --git a/src/components/form/select/select.module.scss b/src/components/form/select/select.module.scss index 0803874..9ae0c97 100644 --- a/src/components/form/select/select.module.scss +++ b/src/components/form/select/select.module.scss @@ -1,4 +1,4 @@ -@use "style/variables/mixins.scss"; +@use "../form.scss"; .select { display: inline-flex; @@ -15,10 +15,10 @@ } &[aria-invalid="true"] { - @include mixins.invalid; + @include form.invalid; } &:disabled { - @include mixins.disabled; + @include form.disabled; } } diff --git a/src/components/header/app-header.tsx b/src/components/header/app-header.tsx index c8007df..f6a4853 100644 --- a/src/components/header/app-header.tsx +++ b/src/components/header/app-header.tsx @@ -19,6 +19,11 @@ const links = linkOptions([ icon: , label: m.home_title, }, + { + to: "/form-example", + icon: "📋", + label: () => "Form", + }, ]); const AppHeader = () => { diff --git a/src/lib/api/error-helpers.ts b/src/lib/api/error-helpers.ts new file mode 100644 index 0000000..bf24006 --- /dev/null +++ b/src/lib/api/error-helpers.ts @@ -0,0 +1,21 @@ +/** + * Try to get an error message from an unknown value + * Useful for catch situations or poorly typed api's + * TODO: Update this to accomodate (changes in) the project + */ +export const parseErrorString = ( + value: unknown, + fallback?: string, +): string => { + const unknownError = fallback || "Onbekende fout."; + if (!value) return unknownError; + if (typeof value === "string") return value; + if ( + typeof value === "object" && + "message" in value && + value.message + ) { + return String(value.message); + } + return unknownError; +}; diff --git a/src/lib/forms/index.tsx b/src/lib/forms/index.tsx new file mode 100644 index 0000000..35dba14 --- /dev/null +++ b/src/lib/forms/index.tsx @@ -0,0 +1,33 @@ +import { + createFormHook, + createFormHookContexts, +} from "@tanstack/react-form"; +import Checkbox from "components/form/checkbox/tsf-checkbox"; +import CheckboxGroup from "components/form/checkbox/tsf-checkbox-group"; +import Input from "components/form/input/tsf-input"; + +export const { + fieldContext, + formContext, + useFieldContext, +} = createFormHookContexts(); + +/** + * Connecting a form element to tanstack involves passing a lot of properties. + * In order to make it easier, we can define reusable form controls. This hook + * is meant to connect them via `fieldComponents` and `formComponents`. + * + * There usually is only 1 useAppForm in the project. So any field should + * be defined here. + */ +export const { useAppForm, withForm } = createFormHook({ + fieldContext, + formContext, + + fieldComponents: { + Checkbox, + CheckboxGroup, + Input, + }, + formComponents: {}, +}); diff --git a/src/lib/forms/validation-helpers.ts b/src/lib/forms/validation-helpers.ts new file mode 100644 index 0000000..e648715 --- /dev/null +++ b/src/lib/forms/validation-helpers.ts @@ -0,0 +1,104 @@ +import { parseErrorString } from "lib/api/error-helpers"; +import { zValidationError } from "lib/heyapi/zod.gen"; + +/** + * Field errors can be of multiple types. This transforms to an array of strings. + * TODO: make sure this can handle the error types for your project + */ +export const normalizeFieldErrors = ( + errors: unknown, +): string[] => { + const errorArray = Array.isArray(errors) + ? errors + : [errors]; + return errorArray.flatMap((error) => { + if (typeof error === "string") return [error]; + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) + return [error.message]; + return []; + }); +}; + +/** + * Returns a flattened array of error from given fields + */ +export const getFieldErrors = < + TState extends { + fieldMeta: Record< + string, + { errors: unknown } | undefined + >; + }, + TField extends keyof TState["fieldMeta"], +>( + state: TState, + fields: TField[], +): string[] => + fields.flatMap((field) => + normalizeFieldErrors(state.fieldMeta[field]?.errors), + ); + +/** + * Backend uses snake_case, this converts to camelCase + */ +const snakeCaseToCamelCase = (string: string) => { + return string.replace(/_([a-z])/g, (_, letter) => + letter.toUpperCase(), + ); +}; + +/** + * Transform api error to form error. + * TODO: check api error type for your project and adjust as necessary + * the template assumes rfc9457: https://datatracker.ietf.org/doc/html/rfc9457#name-the-problem-details-json-ob + */ +export const apiErrorToFormErrors = (error: unknown) => { + const parsed = zValidationError.safeParse(error); + if (parsed.success) { + return { + form: parsed.data.title, + fields: Object.fromEntries( + Object.entries(parsed.data.errors ?? {}).map( + ([field, fieldErrors]) => [ + snakeCaseToCamelCase(field), + fieldErrors.map(({ title }) => title), + ], + ), + ), + }; + } + return { form: parseErrorString(error) }; +}; + +// Instead of using `UseMutationResult` we use this custom type, so it +// can be used by other means than react-query and is easier to mock. +type Mutatable = { + mutateAsync: (variables: TVariables) => Promise; +}; + +/** + * Submit form and handle possible errors. + * This is a temporary solution until TSF supports it out of the box: + * https://github.com/TanStack/form/issues/2188 + * + * @example + * validators: { + * onSubmitAsync: async ({ value }) => + * mutateAndValidate(mutation, { body: value }), + * } + */ +export const mutateAndValidate = async ( + mutation: Mutatable, + variables: TVariables, +) => { + try { + await mutation.mutateAsync(variables); + } catch (error) { + return apiErrorToFormErrors(error); + } +}; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 4fd1922..f942bd2 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,5 +1,6 @@ import { createRootRouteWithContext, + ErrorComponent, HeadContent, Outlet, redirect, @@ -56,5 +57,8 @@ export const Route = ), + errorComponent: (error) => ( + + ), notFoundComponent: NotFoundPage, }); diff --git a/src/routes/_app/form-example.module.scss b/src/routes/_app/form-example.module.scss new file mode 100644 index 0000000..7803800 --- /dev/null +++ b/src/routes/_app/form-example.module.scss @@ -0,0 +1,31 @@ +.page { + grid-column: main; +} + +.address { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: var(--unit-4); +} + +.addressError { + grid-column: span 3; +} + +.devMessage { + margin: var(--unit-4) 0; + color: grey; + + .code { + font-weight: 700; + font-family: "Courier New", Courier, monospace; + } +} + +.success { + margin: var(--unit-4) 0; + background-color: chartreuse; + color: green; + font-weight: 700; + text-align: center; +} diff --git a/src/routes/_app/form-example.tsx b/src/routes/_app/form-example.tsx new file mode 100644 index 0000000..b22cdf3 --- /dev/null +++ b/src/routes/_app/form-example.tsx @@ -0,0 +1,230 @@ +import { revalidateLogic } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { ErrorText } from "components/error-text/error-text"; +import { Button, Form } from "components/form"; +import formStyle from "components/form/form.module.scss"; +import { H1 } from "components/heading/heading"; +import { useAppForm } from "lib/forms"; +import { + getFieldErrors, + mutateAndValidate, +} from "lib/forms/validation-helpers"; +import type { ValidationError } from "lib/heyapi"; +import { makePageTitle } from "lib/title"; +import { useState } from "react"; +import z from "zod"; +import style from "./form-example.module.scss"; + +// Note: this whole file is an example, you should always prefer to use generated schema's +// and messages/labels translated through paraglide. +// The main takeaway of this page is to show how to use tanstack form AppFields and showing errors! +const validationSchema = z.object({ + email: z.email(), + postalCode: z.string().regex(/\d{4}\s?[a-zA-Z]{2}/, { + error: "Vul een geldige postcode in", + }), + houseNumber: z.string().regex(/\d+/), + houseNumberAdd: z.string(), + agree: z.literal(true), + options: z + .array(z.string()) + .min(1, "Kies minimaal één optie"), +}); +type ValidationType = z.infer; + +// biome-ignore lint/suspicious/noExplicitAny: whatever man +const fakeSubmit = async (_value: any, ok = true) => + new Promise((resolve, reject) => + setTimeout(() => { + if (ok) { + resolve({ message: "Success" }); + } else { + // Note: this error object mimics the agreed upon format with BE, but the actual + // implementation may be slightly different + reject({ + type: "ValidationError", + code: "invalid_form", + status: 422, + title: "There was an issue with your input", + errors: { + // -- Try out errors on these fields + email: [ + { + code: "exists", + title: "This email already exists", + properties: { attribute: "unique" }, + }, + { + code: "unimaginative", + title: + "Your emailaddress is unimaginative 🤪", + properties: { attribute: "unimaginative" }, + }, + ], + postal_code: [ + { + code: "not_found", + title: + "Could not find an address with the data you supplied", + properties: { attribute: "not_found" }, + }, + ], + }, + } satisfies ValidationError); + } + }, 500), + ); + +// --- + +export const Route = createFileRoute("/_app/form-example")({ + head: () => ({ + meta: [{ title: makePageTitle("Form test") }], + }), + component: FormTest, + loader: () => ({ + // Suppose options are set by cms + allOptions: [ + { label: "I like apples!", value: "apples" }, + { + label: "I like puppies very much!", + value: "puppies", + }, + { label: "I eat squirrels!", value: "squirrels" }, + ], + }), +}); + +function FormTest() { + const [disabled, setDisabled] = useState(false); + const { allOptions } = Route.useLoaderData(); + + const mutation = useMutation({ + mutationFn: ({ body }: { body: ValidationType }) => { + // biome-ignore lint/suspicious/noConsole: DEV -show what is submitted + console.log("Will submit data:", body); + return fakeSubmit(body, false); // CHANGE this to false to test erros + }, + }); + + // Note: Has values so it's easier to test/play the form + const defaultValues: ValidationType = { + email: "test@test.nl", + postalCode: "1234AZ", + houseNumber: "123", + houseNumberAdd: "", + agree: false, + options: [], + }; + + const form = useAppForm({ + defaultValues, + validationLogic: revalidateLogic(), + validators: { + onDynamic: validationSchema, + onSubmitAsync: ({ value }) => + mutateAndValidate(mutation, { body: value }), + }, + }); + + return ( +
+

Form example

+ +
{ + evt.preventDefault(); + form.handleSubmit(); + }} + disabled={mutation.isPending || disabled} + > + + {(field) => ( + + )} + + + {/* + Good candidate to use with `withFieldGroup`: + https://tanstack.com/form/latest/docs/framework/solid/guides/form-composition#reusing-groups-of-fields-in-multiple-forms + */} +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + getFieldErrors(state, [ + "postalCode", + "houseNumber", + "houseNumberAdd", + ]) + } + > + {(errors) => + errors.length > 0 ? ( + + {errors} + + ) : null + } + +
+ + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + +
+ + {!mutation.isSuccess && ( +

+ DEV: to successfully submit, update the call to{" "} + fakeSubmit in + the mutation. +

+ )} + {mutation.isSuccess && ( +

Success!

+ )} +
+ ); +} diff --git a/src/routes/_auth/login.tsx b/src/routes/_auth/login.tsx index ed45106..aa5348e 100644 --- a/src/routes/_auth/login.tsx +++ b/src/routes/_auth/login.tsx @@ -1,3 +1,5 @@ +import { revalidateLogic } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; import { createFileRoute, Link, @@ -5,15 +7,18 @@ import { } from "@tanstack/react-router"; import clsx from "clsx"; import { ErrorText } from "components/error-text/error-text"; -import { Button, Form, Input } from "components/form"; +import { Button, Form } from "components/form"; import { H1 } from "components/heading/heading"; +import { useAppForm } from "lib/forms"; import { - postApiAuthLogin, - type ValidationError, -} from "lib/heyapi"; + mutateAndValidate, + normalizeFieldErrors, +} from "lib/forms/validation-helpers"; +import type { LoginRequest } from "lib/heyapi"; +import { postApiAuthLoginMutation } from "lib/heyapi/@tanstack/react-query.gen"; +import { zLoginRequest } from "lib/heyapi/zod.gen"; import * as m from "lib/paraglide/messages"; import { makePageTitle } from "lib/title"; -import { useState } from "react"; import z from "zod"; import style from "./login.module.scss"; @@ -34,30 +39,25 @@ export const Route = createFileRoute("/_auth/login")({ function LoginPage() { const { redirect } = Route.useSearch(); const navigate = useNavigate(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(); - const [isPending, setIsPending] = useState(false); - const handleSubmit = async ( - evt: React.FormEvent, - ) => { - evt.preventDefault(); - setIsPending(true); + const mutation = useMutation({ + ...postApiAuthLoginMutation(), + gcTime: 0, + onSuccess: () => navigate({ to: redirect || "/" }), + }); - // Auth endpoints do not use TanStack Query - const result = await postApiAuthLogin({ - body: { email, password }, - }); - - if (result.error) { - setError(result.error); - setIsPending(false); - return; - } - - navigate({ to: redirect || "/" }); - }; + const form = useAppForm({ + defaultValues: { + email: "", + password: "", + } satisfies LoginRequest, + validationLogic: revalidateLogic(), + validators: { + onDynamic: zLoginRequest, + onSubmitAsync: ({ value }) => + mutateAndValidate(mutation, { body: value }), + }, + }); return ( <> @@ -65,36 +65,31 @@ function LoginPage() { {m.login_title()}
{ + evt.preventDefault(); + form.handleSubmit(); + }} className={style.form} - disabled={isPending} + disabled={mutation.isPending} > - -
- + )} + +
+ + {(field) => ( + + )} +
- {error?.message} + + { + const errBag = state.errorMap.onSubmit; + return typeof errBag === "string" + ? errBag + : errBag?.form; + }} + > + {(formError) => ( + + {normalizeFieldErrors(formError)} + + )} + diff --git a/vite.config.ts b/vite.config.ts index d2cf666..a9940e3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig(({ mode }) => { }), heyApiPlugin({ config: { - input: "./openapi.json", + input: "./openapi.yaml", output: "src/lib/heyapi", plugins: [ "@hey-api/typescript",