From e46527adb85748fdae7419657ed543ec4f3a4f10 Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Wed, 7 Oct 2026 09:11:01 +0200 Subject: [PATCH 1/2] refactor: Use Formik and Yup for the profile create form Replace the 21 useState calls in ProfileForm with a single Formik state object and move validation into a Yup schema with per content type rules. The schema and the request builder live in profileFormSchema.ts and are covered by vitest unit tests. Validation mirrors ui-backend and the addon-controller CRD: required fields per content type, tier as an int32 of at least 1, chartName and chartVersion for remote Helm repositories, a non-blank YAML body, the remoteURL scheme, a Go duration interval and a complete secretRef. Co-Authored-By: Claude Sonnet 5.5 --- package.json | 4 + pnpm-lock.yaml | 274 ++++++++++++ src/locales/en.json | 3 + .../profiles/profile-form/ProfileForm.tsx | 364 ++++++--------- .../profile-form/profileFormSchema.test.ts | 417 ++++++++++++++++++ .../profile-form/profileFormSchema.ts | 279 ++++++++++++ 6 files changed, 1117 insertions(+), 224 deletions(-) create mode 100644 src/modules/profiles/profile-form/profileFormSchema.test.ts create mode 100644 src/modules/profiles/profile-form/profileFormSchema.ts diff --git a/package.json b/package.json index 7f7ffc4..920bbe8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint": "npx prettier . --write && eslint --fix .", "dev": "tsc && eslint . && npx prettier . --write && vite --logLevel info -d=debug --host 0.0.0.0", "build": "vite build", + "test": "vitest run", "preview": "vite preview --logLevel info -d=debug --host 0.0.0.0" }, "dependencies": { @@ -38,6 +39,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dotenv": "^17.4.2", + "formik": "^2.4.9", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "js-yaml": "4.3.2", @@ -52,6 +54,7 @@ "sonner": "^2.0.8", "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", + "yup": "^1.7.1", "zustand": "^5.0.15" }, "devDependencies": { @@ -73,6 +76,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", "vite": "8.3.0", + "vitest": "^5.0.3", "yargs": "^18.1.0" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b05c7e..083cc00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: dotenv: specifier: ^17.4.2 version: 17.4.2 + formik: + specifier: ^2.4.9 + version: 2.4.9(@types/react@18.3.31)(react@18.3.1) i18next: specifier: ^26.4.2 version: 26.4.2(typescript@5.9.3) @@ -116,6 +119,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19) + yup: + specifier: ^1.7.1 + version: 1.7.1 zustand: specifier: ^5.0.15 version: 5.0.15(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) @@ -174,6 +180,9 @@ importers: vite: specifier: 8.3.0 version: 8.3.0(@types/node@24.13.4)(jiti@1.21.7) + vitest: + specifier: ^5.0.3 + version: 5.0.3(@types/node@24.13.4)(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7)) yargs: specifier: ^18.1.0 version: 18.1.0 @@ -271,6 +280,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -852,6 +864,9 @@ packages: peerDependencies: react: ^18 || ^19 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -870,12 +885,20 @@ packages: '@types/d3-zoom@3.0.8': resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/hoist-non-react-statics@3.3.7': + resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} + peerDependencies: + '@types/react': '*' + '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -980,6 +1003,20 @@ packages: oxc-transform-react: optional: true + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + '@xyflow/react@12.11.6': resolution: {integrity: sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ==} peerDependencies: @@ -1070,6 +1107,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1146,6 +1187,10 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1285,6 +1330,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@2.2.1: + resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==} + engines: {node: '>=0.10.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1348,6 +1397,9 @@ packages: resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1436,10 +1488,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1508,6 +1567,11 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formik@2.4.9: + resolution: {integrity: sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==} + peerDependencies: + react: '>=16.8.0' + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1617,6 +1681,9 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-parse-stringify@4.0.1: resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} @@ -1905,9 +1972,15 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1920,6 +1993,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2005,6 +2081,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + oidc-client-ts@3.5.0: resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} engines: {node: '>=18'} @@ -2129,6 +2209,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -2148,6 +2231,9 @@ packages: peerDependencies: react: ^18.3.1 + react-fast-compare@2.0.4: + resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==} + react-i18next@17.0.13: resolution: {integrity: sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==} peerDependencies: @@ -2353,6 +2439,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2425,6 +2514,20 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -2433,6 +2536,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2449,6 +2555,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -2564,6 +2674,47 @@ packages: yaml: optional: true + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2585,6 +2736,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2609,6 +2765,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -2736,6 +2895,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -3271,6 +3432,11 @@ snapshots: '@tanstack/query-core': 5.102.8 react: 18.3.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} '@types/d3-drag@3.0.7': @@ -3292,12 +3458,19 @@ snapshots: '@types/d3-interpolate': 3.0.4 '@types/d3-selection': 3.0.11 + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 + '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + hoist-non-react-statics: 3.3.2 + '@types/js-yaml@4.0.9': {} '@types/json-schema@7.0.15': {} @@ -3419,6 +3592,17 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.3.0(@types/node@24.13.4)(jiti@1.21.7) + '@vitest/mocker@5.0.3(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.0(@types/node@24.13.4)(jiti@1.21.7) + + '@vitest/spy@5.0.3': {} + '@xyflow/react@12.11.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@xyflow/system': 0.0.82 @@ -3543,6 +3727,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + async-function@1.0.0: {} asynckit@0.4.0: {} @@ -3622,6 +3808,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + chai@6.3.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3765,6 +3953,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@2.2.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -3890,6 +4080,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4018,8 +4210,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -4084,6 +4282,20 @@ snapshots: format@0.2.2: {} + formik@2.4.9(@types/react@18.3.31)(react@18.3.1): + dependencies: + '@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.31) + deepmerge: 2.2.1 + hoist-non-react-statics: 3.3.2 + lodash: 4.18.1 + lodash-es: 4.18.1 + react: 18.3.1 + react-fast-compare: 2.0.4 + tiny-warning: 1.0.3 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/react' + fraction.js@5.3.4: {} fsevents@2.3.3: @@ -4194,6 +4406,10 @@ snapshots: highlightjs-vue@1.0.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-parse-stringify@4.0.1: {} https-proxy-agent@5.0.1(supports-color@7.2.0): @@ -4457,8 +4673,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.merge@4.6.2: {} + lodash@4.18.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -4472,6 +4692,10 @@ snapshots: dependencies: react: 18.3.1 + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -4556,6 +4780,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.2.1: {} + oidc-client-ts@3.5.0: dependencies: jwt-decode: 4.0.0 @@ -4665,6 +4891,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-expr@2.0.6: {} + property-information@7.2.0: {} proxy-from-env@2.1.0: {} @@ -4679,6 +4907,8 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-fast-compare@2.0.4: {} + react-i18next@17.0.13(i18next@26.4.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.7 @@ -4925,6 +5155,8 @@ snapshots: space-separated-tokens@2.0.2: {} + std-env@4.3.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5050,6 +5282,14 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-case@1.0.3: {} + + tiny-warning@1.0.3: {} + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -5059,6 +5299,8 @@ snapshots: dependencies: is-number: 7.0.0 + toposort@2.0.2: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5071,6 +5313,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@2.19.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -5169,6 +5413,27 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 + vitest@5.0.3(@types/node@24.13.4)(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.0(@types/node@24.13.4)(jiti@1.21.7) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 24.13.4 + transitivePeerDependencies: + - msw + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5214,6 +5479,8 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.1: {} + word-wrap@1.2.5: {} wrap-ansi@9.0.2: @@ -5237,6 +5504,13 @@ snapshots: yocto-queue@0.1.0: {} + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + zustand@4.5.7(@types/react@18.3.31)(react@18.3.1): dependencies: use-sync-external-store: 1.6.0(react@18.3.1) diff --git a/src/locales/en.json b/src/locales/en.json index 012220c..9d489c8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -156,6 +156,9 @@ "value": "Value", "optional": "optional", "required": "required", + "invalid_tier": "must be a whole number between 1 and 2147483647", + "invalid_url_scheme": "must start with http://, https://, or oci://", + "invalid_duration": "must be a valid duration such as 30s, 5m or 1h30m", "content_kind": "Kind", "content_namespace": "Namespace", "content_name": "Name", diff --git a/src/modules/profiles/profile-form/ProfileForm.tsx b/src/modules/profiles/profile-form/ProfileForm.tsx index 5ba70df..da2a07e 100644 --- a/src/modules/profiles/profile-form/ProfileForm.tsx +++ b/src/modules/profiles/profile-form/ProfileForm.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { useFormik } from "formik"; import { Plus, X } from "lucide-react"; import { Button } from "@/lib/components/ui/inputs/button"; @@ -20,160 +21,80 @@ import { } from "@/lib/components/ui/inputs/toggle-group"; import TagInput from "@/lib/components/ui/data-display/TagInput"; import { useCreateProfile } from "@/modules/profiles/profile-form/hooks/useProfileMutations"; -import { CreateProfileRequest } from "@/types/profile.types"; - -type ContentType = "helm" | "yaml" | "existing" | "remoteURL"; +import { + ContentType, + ProfileFormValues, + ProfileKind, + buildCreateRequest, + createProfileFormSchema, + initialProfileFormValues, + isRemoteHelmSource, +} from "@/modules/profiles/profile-form/profileFormSchema"; -type SelectorRow = { key: string; value: string }; - -const isRemoteHelmSource = (repositoryURL: string) => { - const lower = repositoryURL.toLowerCase(); - return ( - lower.startsWith("http://") || - lower.startsWith("https://") || - lower.startsWith("oci://") - ); -}; +const FieldError = ({ message }: { message?: string }) => + message ?

{message}

: null; export const ProfileForm = () => { const { t } = useTranslation(); const navigate = useNavigate(); const createProfile = useCreateProfile(); - const [kind, setKind] = useState<"ClusterProfile" | "Profile">( - "ClusterProfile", - ); - const [namespace, setNamespace] = useState(""); - const [name, setName] = useState(""); - const [tier, setTier] = useState(""); - const [dependsOn, setDependsOn] = useState([]); - const [selectorRows, setSelectorRows] = useState([ - { key: "", value: "" }, - ]); - const [contentType, setContentType] = useState("helm"); - - const [repositoryURL, setRepositoryURL] = useState(""); - const [repositoryName, setRepositoryName] = useState(""); - const [chartName, setChartName] = useState(""); - const [chartVersion, setChartVersion] = useState(""); - const [releaseName, setReleaseName] = useState(""); - const [releaseNamespace, setReleaseNamespace] = useState(""); - const [values, setValues] = useState(""); + // Errors returned by the backend. Field errors come from the Yup schema. + const [serverError, setServerError] = useState(null); - const [yaml, setYaml] = useState(""); + const formik = useFormik({ + initialValues: initialProfileFormValues, + validationSchema: createProfileFormSchema(t), + onSubmit: (values) => { + setServerError(null); + createProfile.mutate(buildCreateRequest(values), { + onSuccess: () => navigate("/sveltos/profiles"), + onError: (mutationError) => setServerError(mutationError.message), + }); + }, + }); - const [existingKind, setExistingKind] = useState("ConfigMap"); - const [existingNamespace, setExistingNamespace] = useState(""); - const [existingName, setExistingName] = useState(""); + const { values } = formik; - const [remoteURL, setRemoteURL] = useState(""); - const [remoteURLInterval, setRemoteURLInterval] = useState(""); - const [remoteURLTemplate, setRemoteURLTemplate] = useState(false); - const [remoteURLInsecureSkipTLSVerify, setRemoteURLInsecureSkipTLSVerify] = - useState(false); - const [remoteURLPlainHTTP, setRemoteURLPlainHTTP] = useState(false); - const [remoteURLSecretNamespace, setRemoteURLSecretNamespace] = useState(""); - const [remoteURLSecretName, setRemoteURLSecretName] = useState(""); + // A field's error is shown once the user has left it or tried to submit. + const errorFor = (field: keyof ProfileFormValues) => { + const error = formik.errors[field]; + const showError = formik.touched[field] || formik.submitCount > 0; + return showError && typeof error === "string" ? error : undefined; + }; - const [error, setError] = useState(null); + // repositoryName, chartName and chartVersion are only optional for Flux sources. + const isHelmChartRemote = + values.contentType === "helm" && isRemoteHelmSource(values.repositoryURL); + const optionalUnlessRemote = isHelmChartRemote + ? "" + : ` (${t("common.optional")})`; const updateSelectorRow = ( index: number, field: "key" | "value", fieldValue: string, ) => { - setSelectorRows( - selectorRows.map((row, i) => + formik.setFieldValue( + "selectorRows", + values.selectorRows.map((row, i) => i === index ? { ...row, [field]: fieldValue } : row, ), ); }; const removeSelectorRow = (index: number) => { - setSelectorRows(selectorRows.filter((_, i) => i !== index)); + formik.setFieldValue( + "selectorRows", + values.selectorRows.filter((_, i) => i !== index), + ); }; - const handleSubmit = () => { - setError(null); - - if ( - contentType === "helm" && - isRemoteHelmSource(repositoryURL) && - !repositoryName - ) { - setError( - `${t("common.repository_name")} ${t("common.required")}: ${t("common.repository_url")} ${repositoryURL}`, - ); - return; - } - - if (contentType === "remoteURL") { - if (!remoteURL) { - setError(`${t("common.url")} ${t("common.required")}`); - return; - } - if ( - (remoteURLSecretNamespace && !remoteURLSecretName) || - (!remoteURLSecretNamespace && remoteURLSecretName) - ) { - setError( - `${t("common.remote_url_secret_ref")} ${t("common.content_namespace")} + ${t("common.content_name")}: ${t("common.required")}`, - ); - return; - } - } - - const clusterSelector: { [key: string]: string } = {}; - selectorRows.forEach((row) => { - if (row.key) { - clusterSelector[row.key] = row.value; - } - }); - - const request: CreateProfileRequest = { - kind, - namespace: kind === "Profile" ? namespace : undefined, - name, - clusterSelector, - tier: tier ? Number(tier) : undefined, - dependsOn: dependsOn.length > 0 ? dependsOn : undefined, - }; - - if (contentType === "helm") { - request.helmChart = { - repositoryURL, - repositoryName: repositoryName || undefined, - chartName: chartName || undefined, - chartVersion: chartVersion || undefined, - releaseName, - releaseNamespace, - values: values || undefined, - }; - } else if (contentType === "yaml") { - request.yaml = yaml; - } else if (contentType === "existing") { - request.existingContent = { - kind: existingKind, - namespace: existingNamespace, - name: existingName, - }; - } else { - request.remoteURL = { - url: remoteURL, - interval: remoteURLInterval || undefined, - secretRef: remoteURLSecretName - ? { namespace: remoteURLSecretNamespace, name: remoteURLSecretName } - : undefined, - template: remoteURLTemplate || undefined, - insecureSkipTLSVerify: remoteURLInsecureSkipTLSVerify || undefined, - plainHTTP: remoteURLPlainHTTP || undefined, - }; - } - - createProfile.mutate(request, { - onSuccess: () => navigate("/sveltos/profiles"), - onError: (mutationError) => setError(mutationError.message), - }); + const addSelectorRow = () => { + formik.setFieldValue("selectorRows", [ + ...values.selectorRows, + { key: "", value: "" }, + ]); }; return ( @@ -183,10 +104,10 @@ export const ProfileForm = () => {
{ if (newKind) { - setKind(newKind as "ClusterProfile" | "Profile"); + formik.setFieldValue("kind", newKind as ProfileKind); } }} className="justify-start bg-muted p-1 rounded-md inline-flex w-fit" @@ -197,7 +118,7 @@ export const ProfileForm = () => { Profile

- {kind === "Profile" + {values.kind === "Profile" ? t("common.kind_hint_profile") : t("common.kind_hint_cluster_profile")}

@@ -206,15 +127,14 @@ export const ProfileForm = () => {
- setName(e.target.value)} /> + +
- {kind === "Profile" && ( + {values.kind === "Profile" && (
- setNamespace(e.target.value)} - /> + +
)}
@@ -224,19 +144,16 @@ export const ProfileForm = () => { - setTier(e.target.value)} - /> + +
formik.setFieldValue("dependsOn", tags)} placeholder="" />
@@ -249,7 +166,7 @@ export const ProfileForm = () => { {t("common.cluster_selector")} - {selectorRows.map((row, index) => ( + {values.selectorRows.map((row, index) => (
{
))} - @@ -291,10 +202,13 @@ export const ProfileForm = () => { { if (newContentType) { - setContentType(newContentType as ContentType); + formik.setFieldValue( + "contentType", + newContentType as ContentType, + ); } }} className="justify-start bg-muted p-1 rounded-md inline-flex w-fit mb-4" @@ -313,56 +227,47 @@ export const ProfileForm = () => { - {contentType === "helm" && ( + {values.contentType === "helm" && (
- setRepositoryURL(e.target.value)} - /> + +
- setRepositoryName(e.target.value)} - /> + +
- setChartName(e.target.value)} - /> + +
- setChartVersion(e.target.value)} - /> + +
- setReleaseName(e.target.value)} - /> + +
- setReleaseNamespace(e.target.value)} - /> + +
@@ -372,14 +277,13 @@ export const ProfileForm = () => {