From c341e60fe5fe5023bfbc65bf387504da2ff49f17 Mon Sep 17 00:00:00 2001 From: trevor Date: Tue, 28 Jul 2026 11:50:45 -0600 Subject: [PATCH 1/5] feat(frontend): onboarding use-case selection on the Overview page The Overview onboarding now opens with a use-case and language picker, derives the quickstart scaffold and trigger commands from that selection, and completes on a run created after the selection was confirmed. Workers and runs from an earlier selection never satisfy the current one; the confirmation timestamp that scopes both persists for each tenant in localStorage, while completion itself is always re-derived from the runs API. Skip and Finish both hide onboarding behind a confirmation dialog, and the tenant General settings page offers Restart onboarding while it is hidden. useLocalStorageState now reloads when its key changes, so a tenant switch cannot carry another tenant's stored state across. jsdom was added as a dev dependency to test that hook under node:test. --- frontend/app/package.json | 2 + frontend/app/pnpm-lock.yaml | 343 ++++++++++++++++++ .../app/src/components/v1/ui/radio-group.tsx | 25 +- .../src/hooks/use-local-storage-state.test.ts | 136 +++++++ .../app/src/hooks/use-local-storage-state.tsx | 17 +- .../components/finish-onboarding-dialog.tsx | 42 +++ .../components/learn-workflow-section.tsx | 330 +++++++++++------ .../overview/components/onboarding-options.ts | 31 ++ .../components/onboarding-state.test.ts | 279 ++++++++++++++ .../overview/components/onboarding-state.ts | 206 +++++++++++ .../components/skip-onboarding-dialog.tsx | 50 +++ .../components/use-case-options.test.ts | 66 ++++ .../overview/components/use-case-options.ts | 77 ++++ .../app/src/pages/main/v1/overview/index.tsx | 245 ++++++++++--- .../v1/tenant-settings/overview/index.tsx | 48 +++ 15 files changed, 1737 insertions(+), 160 deletions(-) create mode 100644 frontend/app/src/hooks/use-local-storage-state.test.ts create mode 100644 frontend/app/src/pages/main/v1/overview/components/finish-onboarding-dialog.tsx create mode 100644 frontend/app/src/pages/main/v1/overview/components/onboarding-options.ts create mode 100644 frontend/app/src/pages/main/v1/overview/components/onboarding-state.test.ts create mode 100644 frontend/app/src/pages/main/v1/overview/components/onboarding-state.ts create mode 100644 frontend/app/src/pages/main/v1/overview/components/skip-onboarding-dialog.tsx create mode 100644 frontend/app/src/pages/main/v1/overview/components/use-case-options.test.ts create mode 100644 frontend/app/src/pages/main/v1/overview/components/use-case-options.ts diff --git a/frontend/app/package.json b/frontend/app/package.json index 9a5b832dc2..1677527f50 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -94,6 +94,7 @@ "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/dagre": "^0.7.54", + "@types/jsdom": "^28.0.3", "@types/node": "^20.19.43", "@types/qs": "^6.15.1", "@types/react": "^18.3.20", @@ -115,6 +116,7 @@ "eslint-plugin-unused-imports": "^3.2.0", "postcss": "^8.5.22", "prettier": "^3.9.6", + "jsdom": "^29.1.1", "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^3.4.19", "tsx": "^4.23.1", diff --git a/frontend/app/pnpm-lock.yaml b/frontend/app/pnpm-lock.yaml index 731f0cc732..33ae813005 100644 --- a/frontend/app/pnpm-lock.yaml +++ b/frontend/app/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: '@types/dagre': specifier: ^0.7.54 version: 0.7.54 + '@types/jsdom': + specifier: ^28.0.3 + version: 28.0.3 '@types/node': specifier: ^20.19.43 version: 20.19.43 @@ -297,6 +300,9 @@ importers: eslint-plugin-unused-imports: specifier: ^3.2.0 version: 3.2.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 postcss: specifier: ^8.5.18 version: 8.5.23 @@ -325,6 +331,21 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -449,10 +470,50 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@cypress/request@3.0.9': resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==} engines: {node: '>= 6'} @@ -790,6 +851,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -2075,6 +2145,9 @@ packages: '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + '@types/jsdom@28.0.3': + resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2113,6 +2186,9 @@ packages: '@types/sizzle@2.3.10': resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2346,6 +2422,9 @@ packages: resolution: {integrity: sha512-zYW024hfcLp+6RupEfvYBVGiY9q43OzUFUbucyMY+cm7KPCgonPbG3v5TEOhwneJGHODe1aftYRNB9bZ0TNytA==} engines: {node: '>=14.0.0'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2523,6 +2602,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2616,6 +2699,10 @@ packages: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2663,6 +2750,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2729,6 +2819,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -3185,6 +3279,10 @@ packages: hotkeys-js@4.0.3: resolution: {integrity: sha512-adK3E0KGXiIm8rcRhn2JHEvCVzgg54UtHtufxHVWM0I95hakyxn80aX6ez0nxVEdbzzgrb9g2lMJIThFSyTSFQ==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-signature@1.4.0: resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} engines: {node: '>=0.10'} @@ -3330,6 +3428,9 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3432,6 +3533,15 @@ packages: jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3539,6 +3649,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3560,6 +3674,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3729,6 +3846,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4101,6 +4221,10 @@ packages: request-progress@3.0.0: resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4159,6 +4283,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4322,6 +4450,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4376,10 +4507,17 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -4392,9 +4530,17 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -4465,6 +4611,13 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.29.0: + resolution: {integrity: sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -4594,12 +4747,20 @@ packages: yaml: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-vitals@5.2.0: resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -4607,6 +4768,14 @@ packages: webpack-virtual-modules@0.5.0: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4646,6 +4815,13 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4687,6 +4863,26 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4848,9 +5044,37 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@colors/colors@1.5.0': optional: true + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@cypress/request@3.0.9': dependencies: aws-sign2: 0.7.0 @@ -5058,6 +5282,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -6429,6 +6655,13 @@ snapshots: dependencies: '@types/unist': 2.0.11 + '@types/jsdom@28.0.3': + dependencies: + '@types/node': 20.19.43 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 7.29.0 + '@types/json-schema@7.0.15': optional: true @@ -6463,6 +6696,8 @@ snapshots: '@types/sizzle@2.3.10': {} + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -6747,6 +6982,10 @@ snapshots: dependencies: key-master: 4.0.0 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} blob-util@2.0.2: {} @@ -6910,6 +7149,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -7039,6 +7283,13 @@ snapshots: dependencies: assert-plus: 1.0.0 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7081,6 +7332,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -7150,6 +7403,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -7785,6 +8040,12 @@ snapshots: hotkeys-js@4.0.3: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + http-signature@1.4.0: dependencies: assert-plus: 1.0.0 @@ -7924,6 +8185,8 @@ snapshots: is-path-inside@3.0.3: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8007,6 +8270,32 @@ snapshots: jsbn@0.1.1: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -8112,6 +8401,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -8128,6 +8419,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -8298,6 +8591,10 @@ snapshots: parse-statements@1.0.11: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -8629,6 +8926,8 @@ snapshots: dependencies: throttleit: 1.0.1 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve@1.22.12: @@ -8719,6 +9018,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -8922,6 +9225,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 @@ -8992,10 +9297,16 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.4.9: {} + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + tmp@0.2.7: {} to-regex-range@5.0.1: @@ -9006,8 +9317,16 @@ snapshots: dependencies: tldts: 6.1.86 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-api-utils@1.4.3(typescript@5.9.3): @@ -9089,6 +9408,10 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.29.0: {} + + undici@7.29.0: {} + universalify@2.0.1: {} unplugin@1.0.1: @@ -9183,14 +9506,30 @@ snapshots: tsx: 4.23.1 yaml: 2.8.3 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-vitals@5.2.0: {} webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} webpack-virtual-modules@0.5.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -9257,6 +9596,10 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} yallist@3.1.1: {} diff --git a/frontend/app/src/components/v1/ui/radio-group.tsx b/frontend/app/src/components/v1/ui/radio-group.tsx index df76295033..a52bbd5470 100644 --- a/frontend/app/src/components/v1/ui/radio-group.tsx +++ b/frontend/app/src/components/v1/ui/radio-group.tsx @@ -38,4 +38,27 @@ const RadioGroupItem = React.forwardRef< }); RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; -export { RadioGroup, RadioGroupItem }; +// A radio item rendered as a selectable card. Unlike RadioGroupItem it +// renders its children and marks selection on the card itself instead of a +// dot indicator. Selection is a border and fill; keyboard focus is an +// offset ring, so the two states stay distinguishable when combined. +const RadioGroupCardItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + ); +}); +RadioGroupCardItem.displayName = 'RadioGroupCardItem'; + +export { RadioGroup, RadioGroupItem, RadioGroupCardItem }; diff --git a/frontend/app/src/hooks/use-local-storage-state.test.ts b/frontend/app/src/hooks/use-local-storage-state.test.ts new file mode 100644 index 0000000000..4de2230a2d --- /dev/null +++ b/frontend/app/src/hooks/use-local-storage-state.test.ts @@ -0,0 +1,136 @@ +// Renders the real hook under jsdom with React's own act, so no test +// framework beyond node:test is involved. The globals must exist before +// react-dom loads, so every import below is dynamic. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +// eslint-disable-next-line import/no-extraneous-dependencies -- test-only DOM; never bundled +const { JSDOM } = await import('jsdom'); + +const dom = new JSDOM('', { + url: 'http://localhost/', +}); + +// Newer Node versions expose some of these (navigator) as getter-only +// globals that assignment cannot replace, so they are installed with +// defineProperty. The event classes must be jsdom's, since Node's own are +// not accepted by jsdom's EventTarget. +for (const [name, value] of Object.entries({ + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + localStorage: dom.window.localStorage, + Event: dom.window.Event, + CustomEvent: dom.window.CustomEvent, + StorageEvent: dom.window.StorageEvent, + IS_REACT_ACT_ENVIRONMENT: true, +})) { + Object.defineProperty(globalThis, name, { value, configurable: true }); +} + +const React = (await import('react')).default; +const { act } = await import('react'); +const { createRoot } = await import('react-dom/client'); +const { useLocalStorageState } = await import('./use-local-storage-state'); + +const KEY_A = 'hatchet:onboarding:tenant-a'; +const KEY_B = 'hatchet:onboarding:tenant-b'; + +type Marker = { marker: string } | null; + +let latest: { + value: Marker; + setValue: (value: Marker) => void; +}; +const committedValues: Marker[] = []; + +function Probe({ storageKey }: { storageKey: string }) { + const [value, setValue] = useLocalStorageState(storageKey, null); + latest = { value, setValue }; + React.useEffect(() => { + committedValues.push(value); + }); + return null; +} + +test('the hook follows its key across tenants without leaking state', () => { + window.localStorage.setItem(KEY_A, JSON.stringify({ marker: 'a' })); + window.localStorage.setItem(KEY_B, JSON.stringify({ marker: 'b' })); + + const container = document.createElement('div'); + const root = createRoot(container); + + act(() => { + root.render(React.createElement(Probe, { storageKey: KEY_A })); + }); + assert.deepEqual(latest.value, { marker: 'a' }); + + // committedValues proves no frame of tenant A's state commits under + // tenant B's key. + committedValues.length = 0; + act(() => { + root.render(React.createElement(Probe, { storageKey: KEY_B })); + }); + assert.deepEqual(latest.value, { marker: 'b' }); + assert.deepEqual(committedValues, [{ marker: 'b' }]); + + act(() => { + latest.setValue({ marker: 'b2' }); + }); + assert.deepEqual(latest.value, { marker: 'b2' }); + assert.deepEqual(JSON.parse(window.localStorage.getItem(KEY_B) ?? ''), { + marker: 'b2', + }); + assert.deepEqual(JSON.parse(window.localStorage.getItem(KEY_A) ?? ''), { + marker: 'a', + }); + + act(() => { + root.render(React.createElement(Probe, { storageKey: KEY_A })); + }); + assert.deepEqual(latest.value, { marker: 'a' }); + + act(() => { + window.dispatchEvent( + new dom.window.StorageEvent('storage', { + key: KEY_B, + newValue: JSON.stringify({ marker: 'b3' }), + }), + ); + }); + assert.deepEqual(latest.value, { marker: 'a' }); + + act(() => { + window.dispatchEvent( + new dom.window.StorageEvent('storage', { + key: KEY_A, + newValue: JSON.stringify({ marker: 'a2' }), + }), + ); + }); + assert.deepEqual(latest.value, { marker: 'a2' }); + + // hatchet:local-storage is the hook's same-tab change event, dispatched + // by other hook instances in this document. + act(() => { + window.dispatchEvent( + new dom.window.CustomEvent('hatchet:local-storage', { + detail: { key: KEY_B, value: { marker: 'b4' } }, + }), + ); + }); + assert.deepEqual(latest.value, { marker: 'a2' }); + + act(() => { + window.dispatchEvent( + new dom.window.CustomEvent('hatchet:local-storage', { + detail: { key: KEY_A, value: { marker: 'a3' } }, + }), + ); + }); + assert.deepEqual(latest.value, { marker: 'a3' }); + + act(() => { + root.unmount(); + }); +}); diff --git a/frontend/app/src/hooks/use-local-storage-state.tsx b/frontend/app/src/hooks/use-local-storage-state.tsx index 7c65eb7613..e78e1d1801 100644 --- a/frontend/app/src/hooks/use-local-storage-state.tsx +++ b/frontend/app/src/hooks/use-local-storage-state.tsx @@ -8,14 +8,27 @@ export function useLocalStorageState( key: string, defaultValue: T, ): [T, (value: SetStateValue) => void] { - const [state, setState] = useState(() => { + const readStoredValue = (): T => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : defaultValue; } catch (error) { return defaultValue; } - }); + }; + + const [state, setState] = useState(readStoredValue); + + // The initializer runs once, so a caller whose key changes (for example + // a tenant-scoped key after a tenant switch) must reload the new key's + // value. Setting state during render makes React re-render before + // committing, so no frame of the previous key's state is ever shown or + // written under the new key. + const [renderedKey, setRenderedKey] = useState(key); + if (renderedKey !== key) { + setRenderedKey(key); + setState(readStoredValue()); + } const setValue = (value: SetStateValue) => { try { diff --git a/frontend/app/src/pages/main/v1/overview/components/finish-onboarding-dialog.tsx b/frontend/app/src/pages/main/v1/overview/components/finish-onboarding-dialog.tsx new file mode 100644 index 0000000000..c5f76e175a --- /dev/null +++ b/frontend/app/src/pages/main/v1/overview/components/finish-onboarding-dialog.tsx @@ -0,0 +1,42 @@ +import { Button } from '@/components/v1/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/v1/ui/dialog'; + +// Confirming through OK is what hides onboarding and navigates; closing +// the dialog any other way leaves onboarding visible on the Finish tab. +export function FinishOnboardingDialog({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + + + Onboarding complete + +

+ Onboarding has been removed from Overview. You can run it again from + Settings → General by selecting Restart onboarding. +

+ + + +
+
+ ); +} diff --git a/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx b/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx index cd71a28162..8d84458bab 100644 --- a/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx +++ b/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx @@ -1,7 +1,24 @@ +import { + installMethodOptions, + workflowLanguageOptions, + workflowStepOptions, + type InstallMethod, + type WorkflowLanguageKey, + type WorkflowStepKey, +} from './onboarding-options'; import { SectionHeader } from './section-header'; +import { + availableUseCases, + isLanguageSupported, + scaffoldCommand, + triggerCommand, + workerDevCommand, + type AvailableUseCaseKey, +} from './use-case-options'; import { Button } from '@/components/v1/ui/button'; import { CodeHighlighter } from '@/components/v1/ui/code-highlighter'; import { Spinner } from '@/components/v1/ui/loading'; +import { RadioGroup, RadioGroupCardItem } from '@/components/v1/ui/radio-group'; import { Tabs, TabsContent, @@ -10,43 +27,38 @@ import { } from '@/components/v1/ui/tabs'; import { TriggerWorkflowForm } from '@/pages/main/v1/workflows/$workflow/components/trigger-workflow-form'; import { CheckIcon, ChevronRightIcon } from '@radix-ui/react-icons'; -import { useEffect, useState, type ReactNode } from 'react'; - -export const workflowStepOptions = { - install: { value: 'install', label: 'Install the CLI' }, - profile: { value: 'profile', label: 'Set your profile' }, - quickstart: { value: 'quickstart', label: 'Project quickstart' }, - runTask: { value: 'runTask', label: 'Run a task' }, - aiDocs: { value: 'aiDocs', label: 'Install Docs MCP (optional)' }, -} as const; - -export const workflowLanguageOptions = { - python: { value: 'python', label: 'Python' }, - typescript: { value: 'typescript', label: 'TypeScript' }, - go: { value: 'go', label: 'Go' }, -} as const; +import { useState, type ReactNode } from 'react'; -export const installMethodOptions = { - native: { value: 'native', label: 'Native (Recommended)' }, - homebrew: { value: 'homebrew', label: 'Homebrew' }, -} as const; - -export type WorkflowStepKey = keyof typeof workflowStepOptions; -export type WorkflowLanguageKey = - (typeof workflowLanguageOptions)[keyof typeof workflowLanguageOptions]['value']; -export type InstallMethod = - (typeof installMethodOptions)[keyof typeof installMethodOptions]['value']; +// Re-exported so existing importers keep working after the catalogs moved +// to onboarding-options.ts. +export { + installMethodOptions, + workflowLanguageOptions, + workflowStepOptions, + type InstallMethod, + type WorkflowLanguageKey, + type WorkflowStepKey, +} from './onboarding-options'; export function LearnWorkflowSection({ tenantName, selectedTab, onSelectedTabChange, + useCase, + onUseCaseChange, + language, + onLanguageChange, profileToken, isGeneratingProfileToken, profileTokenError, onGenerateProfileToken, - hasActiveWorker, + hasConnectedWorker, + hasQualifiedRun, + onViewRuns, + onSkip, onTabChangeEvent, + onLanguageSelectedEvent, + onUseCaseSelectedEvent, onFinish, installMethod, onInstallMethodChange, @@ -56,18 +68,29 @@ export function LearnWorkflowSection({ tenantName?: string; selectedTab: WorkflowStepKey; onSelectedTabChange: (tab: WorkflowStepKey) => void; + useCase: AvailableUseCaseKey; + onUseCaseChange: (useCase: AvailableUseCaseKey) => void; language: WorkflowLanguageKey; onLanguageChange: (language: WorkflowLanguageKey) => void; profileToken?: string; isGeneratingProfileToken: boolean; profileTokenError?: string; onGenerateProfileToken: () => void; - hasActiveWorker: boolean; + // An ACTIVE worker registered after the confirmed selection exists. + hasConnectedWorker: boolean; + // A completed run created after the confirmed selection exists. + hasQualifiedRun: boolean; + onViewRuns: () => void; + onSkip: () => void; onTabChangeEvent?: (tab: WorkflowStepKey, tabLabel: string) => void; onLanguageSelectedEvent?: ( language: WorkflowLanguageKey, label: string, ) => void; + onUseCaseSelectedEvent?: ( + useCase: AvailableUseCaseKey, + label: string, + ) => void; onFinish: () => void; installMethod: InstallMethod; onInstallMethodChange: (installMethod: InstallMethod) => void; @@ -83,17 +106,92 @@ export function LearnWorkflowSection({ .replace(/`/g, '\\`'); const [showTriggerWorkflow, setShowTriggerWorkflow] = useState(false); - const [hasCopiedProfileToken, setHasCopiedProfileToken] = useState(false); - useEffect(() => { - setHasCopiedProfileToken(false); - }, [profileToken]); + // The shared Button removes the native focus outline without a + // replacement, so every focusable onboarding control carries an explicit + // ring here rather than changing the shared primitives app-wide. + const focusRing = + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background'; const steps: Array<{ value: WorkflowStepKey; label: string; content: ReactNode; }> = [ + { + ...workflowStepOptions.chooseUseCase, + content: ( + <> +

Preferred language

+ { + const nextLanguage = value as WorkflowLanguageKey; + onLanguageChange(nextLanguage); + onLanguageSelectedEvent?.( + nextLanguage, + workflowLanguageOptions[nextLanguage].label, + ); + }} + className="w-full" + > + + {Object.values(workflowLanguageOptions).map((option) => ( + + {option.label} + + ))} + + +

Use case

+ { + const nextUseCase = value as AvailableUseCaseKey; + onUseCaseChange(nextUseCase); + onUseCaseSelectedEvent?.( + nextUseCase, + availableUseCases[nextUseCase].label, + ); + }} + className="grid-cols-1 gap-3 lg:grid-cols-2" + > + {Object.values(availableUseCases).map((option) => ( + + + {option.label} + + + {option.description} + + + ))} + + {useCase === availableUseCases.scheduled.value && ( +

+ The scheduled template is currently Go only, so the language is + set to Go. +

+ )} + + + ), + }, { ...workflowStepOptions.install, content: ( @@ -111,7 +209,7 @@ export function LearnWorkflowSection({ {value.label} @@ -120,7 +218,7 @@ export function LearnWorkflowSection({

MacOS, Linux, WSL @@ -135,7 +233,7 @@ export function LearnWorkflowSection({

MacOS @@ -158,7 +256,7 @@ export function LearnWorkflowSection({ - - +

+ Already have a Hatchet CLI profile? Continue to the next step. +

+ ), }, @@ -277,12 +356,13 @@ export function LearnWorkflowSection({ content: ( <>

- Run the quickstart command to clone an example project repository - and follow the instructions to cd into the project directory.. + Run the quickstart command to generate an example project for your + selected use case. The CLI asks for the package manager, project + name, and directory.

@@ -293,13 +373,13 @@ export function LearnWorkflowSection({

- {hasActiveWorker ? ( + {hasConnectedWorker ? ( <> Worker is connected @@ -316,7 +396,7 @@ export function LearnWorkflowSection({ +
+ {hasQualifiedRun ? ( + <> + + Run completed + + ) : ( + <> + + + Waiting for a completed run... + + + )} +
+ +
+ + +
), }, @@ -368,8 +475,8 @@ export function LearnWorkflowSection({ content: ( <>

- Get Hatchet documentation directly in your AI coding assistant - (Cursor, Claude Code, Claude Desktop, and more). + Optional next step: get Hatchet documentation directly in your AI + coding assistant (Cursor, Claude Code, Claude Desktop, and more).

{' '} for manual configuration options.

+ {!hasQualifiedRun && ( +

+ Waiting for a completed run before finishing. See the Run a task + step. +

+ )} +
{ @@ -427,9 +551,7 @@ export function LearnWorkflowSection({
{index + 1} {step.label} @@ -441,7 +563,7 @@ export function LearnWorkflowSection({ {step.content} diff --git a/frontend/app/src/pages/main/v1/overview/components/onboarding-options.ts b/frontend/app/src/pages/main/v1/overview/components/onboarding-options.ts new file mode 100644 index 0000000000..031d9f5147 --- /dev/null +++ b/frontend/app/src/pages/main/v1/overview/components/onboarding-options.ts @@ -0,0 +1,31 @@ +// Option catalogs for the Overview onboarding. These live outside the +// component file so the pure catalog, command, and persistence modules can +// import them without pulling React or UI imports into unit tests. + +export const workflowStepOptions = { + chooseUseCase: { value: 'chooseUseCase', label: 'Choose use case' }, + install: { value: 'install', label: 'Install the CLI' }, + profile: { value: 'profile', label: 'Set your profile' }, + quickstart: { value: 'quickstart', label: 'Project quickstart' }, + runTask: { value: 'runTask', label: 'Run a task' }, + // The key predates the label. This tab used to be the Docs MCP step, + // and persisted tab state may still reference the old key. + aiDocs: { value: 'aiDocs', label: 'Finish' }, +} as const; + +export const workflowLanguageOptions = { + python: { value: 'python', label: 'Python' }, + typescript: { value: 'typescript', label: 'TypeScript' }, + go: { value: 'go', label: 'Go' }, +} as const; + +export const installMethodOptions = { + native: { value: 'native', label: 'Native (Recommended)' }, + homebrew: { value: 'homebrew', label: 'Homebrew' }, +} as const; + +export type WorkflowStepKey = keyof typeof workflowStepOptions; +export type WorkflowLanguageKey = + (typeof workflowLanguageOptions)[keyof typeof workflowLanguageOptions]['value']; +export type InstallMethod = + (typeof installMethodOptions)[keyof typeof installMethodOptions]['value']; diff --git a/frontend/app/src/pages/main/v1/overview/components/onboarding-state.test.ts b/frontend/app/src/pages/main/v1/overview/components/onboarding-state.test.ts new file mode 100644 index 0000000000..b4d65474ad --- /dev/null +++ b/frontend/app/src/pages/main/v1/overview/components/onboarding-state.test.ts @@ -0,0 +1,279 @@ +import { WorkerStatus } from '../../../../../lib/api/generated/data-contracts'; +import { + applyLanguageChange, + applyTabChange, + applyUseCaseChange, + defaultOnboardingState, + hasQualifiedWorker, + normalizeOnboardingState, + onboardingStorageKey, + qualifiedRunQueryParams, + type OnboardingPersistedState, +} from './onboarding-state'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const CONFIRMED_AT = '2026-07-26T18:00:00.000Z'; + +const storedState = ( + overrides: Partial = {}, +): OnboardingPersistedState => ({ + useCase: 'simple', + language: 'python', + tab: 'runTask', + hidden: false, + selectionConfirmedAt: CONFIRMED_AT, + ...overrides, +}); + +test('storage keys are tenant-scoped', () => { + const keyA = onboardingStorageKey('tenant-a'); + const keyB = onboardingStorageKey('tenant-b'); + + assert.notEqual(keyA, keyB); + assert.equal(keyA, 'hatchet:onboarding:tenant-a'); +}); + +test('restart writes the complete default state', () => { + // The Settings restart control persists exactly this object. + assert.deepEqual(defaultOnboardingState(), { + useCase: 'simple', + language: 'python', + tab: 'chooseUseCase', + hidden: false, + selectionConfirmedAt: null, + }); +}); + +test('malformed state falls back to the defaults', () => { + const fallback = defaultOnboardingState(); + + assert.deepEqual(normalizeOnboardingState(null), fallback); + assert.deepEqual(normalizeOnboardingState('not-an-object'), fallback); + assert.deepEqual(normalizeOnboardingState(42), fallback); + assert.deepEqual(normalizeOnboardingState({}), fallback); +}); + +test('a valid stored state survives normalization unchanged', () => { + const state = normalizeOnboardingState(storedState()); + + assert.deepEqual(state, storedState()); +}); + +test('a stored use case no longer in the catalog resets the selection', () => { + const state = normalizeOnboardingState( + storedState({ useCase: 'pdf' as never, language: 'go' }), + ); + + assert.equal(state.useCase, 'simple'); + assert.equal(state.tab, 'chooseUseCase'); + assert.equal(state.selectionConfirmedAt, null); +}); + +test('an unknown stored language resets the selection', () => { + const state = normalizeOnboardingState( + storedState({ language: 'rust' as never, hidden: true }), + ); + + // The fallback language is compatible with the use case, so the reset + // comes from the unknown value itself. + assert.equal(state.language, 'python'); + assert.equal(state.tab, 'chooseUseCase'); + assert.equal(state.selectionConfirmedAt, null); + assert.equal(state.hidden, true); +}); + +test('a stored language the use case does not support resets the selection', () => { + const state = normalizeOnboardingState( + storedState({ useCase: 'scheduled', language: 'python' }), + ); + + assert.equal(state.useCase, 'scheduled'); + assert.equal(state.language, 'go'); + assert.equal(state.tab, 'chooseUseCase'); + assert.equal(state.selectionConfirmedAt, null); +}); + +test('an unknown tab falls back to Choose use case', () => { + const state = normalizeOnboardingState( + storedState({ tab: 'removedTab' as never }), + ); + + assert.equal(state.tab, 'chooseUseCase'); +}); + +test('the confirmation timestamp survives only as a valid date-time', () => { + assert.equal( + normalizeOnboardingState(storedState()).selectionConfirmedAt, + CONFIRMED_AT, + ); + assert.equal( + normalizeOnboardingState( + storedState({ selectionConfirmedAt: 'not-a-date' }), + ).selectionConfirmedAt, + null, + ); +}); + +test('completion is never part of the persisted state', () => { + const state = normalizeOnboardingState({ + ...storedState(), + completed: true, + }); + + assert.deepEqual(Object.keys(state).sort(), [ + 'hidden', + 'language', + 'selectionConfirmedAt', + 'tab', + 'useCase', + ]); +}); + +test('changing use case clears the confirmation timestamp and resolves the language', () => { + const next = applyUseCaseChange(storedState(), 'scheduled'); + + assert.equal(next.useCase, 'scheduled'); + assert.equal(next.language, 'go'); + assert.equal(next.selectionConfirmedAt, null); + // The user is re-choosing, so the current tab stays where it is. + assert.equal(next.tab, 'runTask'); +}); + +test('re-selecting the current use case changes nothing', () => { + const state = storedState(); + + assert.equal(applyUseCaseChange(state, 'simple'), state); +}); + +test('changing language clears the confirmation timestamp', () => { + const next = applyLanguageChange(storedState(), 'go'); + + assert.equal(next.language, 'go'); + assert.equal(next.selectionConfirmedAt, null); +}); + +test('a language selection that resolves to the current language changes nothing', () => { + const state = storedState(); + assert.equal(applyLanguageChange(state, 'python'), state); + + // Unsupported languages resolve to the use case's only language, so a + // scheduled selection keeps its timestamp when the resolution is a no-op. + const scheduled = storedState({ useCase: 'scheduled', language: 'go' }); + assert.equal(applyLanguageChange(scheduled, 'typescript'), scheduled); +}); + +test('leaving Choose use case records the confirmation timestamp when none exists', () => { + const now = '2026-07-27T09:00:00.000Z'; + const choosing = storedState({ + tab: 'chooseUseCase', + selectionConfirmedAt: null, + }); + + const toInstall = applyTabChange(choosing, 'install', now); + assert.equal(toInstall.tab, 'install'); + assert.equal(toInstall.selectionConfirmedAt, now); + + const toQuickstart = applyTabChange(choosing, 'quickstart', now); + assert.equal(toQuickstart.tab, 'quickstart'); + assert.equal(toQuickstart.selectionConfirmedAt, now); +}); + +test('tab navigation preserves an existing confirmation timestamp', () => { + const now = '2026-07-27T09:00:00.000Z'; + + const later = applyTabChange( + storedState({ tab: 'quickstart' }), + 'runTask', + now, + ); + assert.equal(later.selectionConfirmedAt, CONFIRMED_AT); + + // Only a use-case or language change clears the timestamp. + const back = applyTabChange(storedState(), 'chooseUseCase', now); + assert.equal(back.tab, 'chooseUseCase'); + assert.equal(back.selectionConfirmedAt, CONFIRMED_AT); +}); + +test('re-selecting the current tab changes nothing', () => { + const state = storedState(); + + assert.equal( + applyTabChange(state, 'runTask', '2026-07-27T09:00:00.000Z'), + state, + ); +}); + +test('the run query filters to completed runs after the confirmed selection', () => { + const params = qualifiedRunQueryParams(CONFIRMED_AT); + + assert.equal(params.since, CONFIRMED_AT); + assert.deepEqual(params.statuses, ['COMPLETED']); + assert.equal(params.limit, 1); + assert.equal(params.only_tasks, false); +}); + +const worker = (status: WorkerStatus, createdAt: string) => ({ + status, + metadata: { id: 'w', createdAt, updatedAt: createdAt }, +}); + +const BEFORE_CONFIRMATION = '2026-07-26T17:00:00.000Z'; +const AFTER_CONFIRMATION = '2026-07-26T19:00:00.000Z'; + +test('a worker registered before the confirmed selection does not qualify', () => { + // The previous selection's worker is still listed and ACTIVE; + // registration time is what excludes it. + assert.equal( + hasQualifiedWorker( + [worker(WorkerStatus.ACTIVE, BEFORE_CONFIRMATION)], + CONFIRMED_AT, + ), + false, + ); +}); + +test('an active worker registered after the confirmed selection qualifies', () => { + assert.equal( + hasQualifiedWorker( + [worker(WorkerStatus.ACTIVE, AFTER_CONFIRMATION)], + CONFIRMED_AT, + ), + true, + ); + + // Registration exactly at the confirmation timestamp counts. + assert.equal( + hasQualifiedWorker( + [worker(WorkerStatus.ACTIVE, CONFIRMED_AT)], + CONFIRMED_AT, + ), + true, + ); +}); + +test('a disconnected or paused worker does not qualify', () => { + assert.equal( + hasQualifiedWorker( + [worker(WorkerStatus.INACTIVE, AFTER_CONFIRMATION)], + CONFIRMED_AT, + ), + false, + ); + assert.equal( + hasQualifiedWorker( + [worker(WorkerStatus.PAUSED, AFTER_CONFIRMATION)], + CONFIRMED_AT, + ), + false, + ); +}); + +test('no confirmed selection means no worker qualifies', () => { + // Selection changes clear the timestamp (the transition tests above), + // so this rule is what resets worker qualification with them. + assert.equal( + hasQualifiedWorker([worker(WorkerStatus.ACTIVE, AFTER_CONFIRMATION)], null), + false, + ); +}); diff --git a/frontend/app/src/pages/main/v1/overview/components/onboarding-state.ts b/frontend/app/src/pages/main/v1/overview/components/onboarding-state.ts new file mode 100644 index 0000000000..c10e784f28 --- /dev/null +++ b/frontend/app/src/pages/main/v1/overview/components/onboarding-state.ts @@ -0,0 +1,206 @@ +import { + V1TaskStatus, + WorkerStatus, + type Worker, +} from '../../../../../lib/api/generated/data-contracts'; +import { + workflowLanguageOptions, + workflowStepOptions, + type WorkflowLanguageKey, + type WorkflowStepKey, +} from './onboarding-options'; +import { + availableUseCases, + resolveLanguage, + type AvailableUseCaseKey, +} from './use-case-options'; + +// Onboarding state for one tenant, persisted to localStorage. Completion +// is deliberately absent. It is derived from the runs query, so clearing +// storage can never fabricate or lose it. +export type OnboardingPersistedState = { + useCase: AvailableUseCaseKey; + language: WorkflowLanguageKey; + tab: WorkflowStepKey; + // True after Skip or Finish. The Overview then renders no onboarding; + // recovery is Restart onboarding on the tenant General settings page. + hidden: boolean; + // ISO date-time recorded when the use-case and language selection is + // confirmed by navigating past Choose use case. Workers and runs qualify + // only from this moment on, so nothing left over from a previously + // selected use case or language can satisfy the current one. + selectionConfirmedAt: string | null; +}; + +// Onboarding state is a property of the tenant being onboarded, so the key +// embeds the tenant id. +export function onboardingStorageKey(tenantId: string): string { + return `hatchet:onboarding:${tenantId}`; +} + +export function defaultOnboardingState(): OnboardingPersistedState { + return { + useCase: 'simple', + language: workflowLanguageOptions.python.value, + tab: workflowStepOptions.chooseUseCase.value, + hidden: false, + selectionConfirmedAt: null, + }; +} + +// Validates a value read from storage, falling back field by field for +// anything unknown or malformed. A stored use case that is no longer +// selectable, or a stored language its use case does not support, +// invalidates the whole selection. The tab returns to Choose use case and +// selectionConfirmedAt clears, because downstream progress belonged to a +// selection that no longer exists. The hidden flag survives repairs; +// hiding is not selection-dependent. +export function normalizeOnboardingState( + value: unknown, +): OnboardingPersistedState { + const fallback = defaultOnboardingState(); + + if (typeof value !== 'object' || value === null) { + return fallback; + } + + const raw = value as Record; + + const useCaseValid = + typeof raw.useCase === 'string' && raw.useCase in availableUseCases; + const useCase = useCaseValid + ? (raw.useCase as AvailableUseCaseKey) + : fallback.useCase; + + const languageKnown = + typeof raw.language === 'string' && raw.language in workflowLanguageOptions; + const languageCandidate = languageKnown + ? (raw.language as WorkflowLanguageKey) + : fallback.language; + const language = resolveLanguage(useCase, languageCandidate); + // languageKnown is checked separately so an unknown stored language + // invalidates the selection even when the fallback is compatible. + const selectionValid = + useCaseValid && languageKnown && language === languageCandidate; + + const tab = + selectionValid && + typeof raw.tab === 'string' && + raw.tab in workflowStepOptions + ? (raw.tab as WorkflowStepKey) + : fallback.tab; + + const selectionConfirmedAt = + selectionValid && + typeof raw.selectionConfirmedAt === 'string' && + !Number.isNaN(Date.parse(raw.selectionConfirmedAt)) + ? raw.selectionConfirmedAt + : null; + + return { + useCase, + language, + tab, + hidden: raw.hidden === true, + selectionConfirmedAt, + }; +} + +export function applyUseCaseChange( + state: OnboardingPersistedState, + nextUseCase: AvailableUseCaseKey, +): OnboardingPersistedState { + if (nextUseCase === state.useCase) { + return state; + } + + return { + ...state, + useCase: nextUseCase, + language: resolveLanguage(nextUseCase, state.language), + selectionConfirmedAt: null, + }; +} + +export function applyLanguageChange( + state: OnboardingPersistedState, + nextLanguage: WorkflowLanguageKey, +): OnboardingPersistedState { + const language = resolveLanguage(state.useCase, nextLanguage); + if (language === state.language) { + return state; + } + + return { + ...state, + language, + selectionConfirmedAt: null, + }; +} + +// Every tab is directly clickable, so moving to any tab past Choose use +// case confirms the selection when no timestamp exists yet; otherwise a +// direct click on a later tab would leave worker and run detection +// disabled. An existing timestamp survives all navigation, including +// returning to Choose use case. The timestamp is a parameter so the +// transition stays deterministic. +export function applyTabChange( + state: OnboardingPersistedState, + nextTab: WorkflowStepKey, + confirmationTimestamp: string, +): OnboardingPersistedState { + if (nextTab === state.tab) { + return state; + } + + const confirmsSelection = + state.selectionConfirmedAt === null && + nextTab !== workflowStepOptions.chooseUseCase.value; + + return { + ...state, + tab: nextTab, + selectionConfirmedAt: confirmsSelection + ? confirmationTimestamp + : state.selectionConfirmedAt, + }; +} + +// A worker qualifies when it is currently ACTIVE and it registered at or +// after the confirmed selection, so a worker left over from a previously +// selected use case or language cannot satisfy the current one. The +// workers list returns every worker with a heartbeat in the last 24 +// hours, and the API computes status from heartbeat staleness with a 5 +// second threshold, so a killed worker can read ACTIVE for up to that +// long plus one poll interval. +export function hasQualifiedWorker( + workers: Array>, + selectionConfirmedAt: string | null, +): boolean { + if (!selectionConfirmedAt) { + return false; + } + + const confirmedAtMs = Date.parse(selectionConfirmedAt); + + return workers.some( + (worker) => + worker.status === WorkerStatus.ACTIVE && + Date.parse(worker.metadata.createdAt) >= confirmedAtMs, + ); +} + +// Query parameters for detecting the onboarding run. The server compares +// against `since`, so a run created before the confirmed selection can +// never qualify, and one row is enough to answer whether onboarding is +// complete. +export function qualifiedRunQueryParams(selectionConfirmedAt: string) { + return { + offset: 0, + limit: 1, + statuses: [V1TaskStatus.COMPLETED], + since: selectionConfirmedAt, + only_tasks: false, + include_payloads: false, + }; +} diff --git a/frontend/app/src/pages/main/v1/overview/components/skip-onboarding-dialog.tsx b/frontend/app/src/pages/main/v1/overview/components/skip-onboarding-dialog.tsx new file mode 100644 index 0000000000..5b2a2e232c --- /dev/null +++ b/frontend/app/src/pages/main/v1/overview/components/skip-onboarding-dialog.tsx @@ -0,0 +1,50 @@ +import { Button } from '@/components/v1/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/v1/ui/dialog'; + +// Confirming is what hides onboarding; Cancel, Escape, or clicking outside +// leaves it visible and persists nothing. Unlike the Finish dialog, a +// confirmed skip captures no completion event and stays on Overview. +export function SkipOnboardingDialog({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + const focusRing = + 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background'; + + return ( + + + + Skip onboarding? + +

+ Onboarding will be removed from Overview. You can run it again from + Settings → General by selecting Restart onboarding. +

+ + + + +
+
+ ); +} diff --git a/frontend/app/src/pages/main/v1/overview/components/use-case-options.test.ts b/frontend/app/src/pages/main/v1/overview/components/use-case-options.test.ts new file mode 100644 index 0000000000..1a4d1a9860 --- /dev/null +++ b/frontend/app/src/pages/main/v1/overview/components/use-case-options.test.ts @@ -0,0 +1,66 @@ +import { + availableUseCases, + isLanguageSupported, + resolveLanguage, + scaffoldCommand, + triggerCommand, +} from './use-case-options'; +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +test('scaffoldCommand derives the CLI command from the selection', () => { + assert.equal( + scaffoldCommand({ useCase: 'simple', language: 'python' }), + 'hatchet quickstart --use-case simple --language python', + ); + assert.equal( + scaffoldCommand({ useCase: 'simple', language: 'typescript' }), + 'hatchet quickstart --use-case simple --language typescript', + ); + assert.equal( + scaffoldCommand({ useCase: 'simple', language: 'go' }), + 'hatchet quickstart --use-case simple --language go', + ); + assert.equal( + scaffoldCommand({ useCase: 'scheduled', language: 'go' }), + 'hatchet quickstart --use-case scheduled --language go', + ); +}); + +test('scaffoldCommand resolves an unsupported language to a supported one', () => { + assert.equal( + scaffoldCommand({ useCase: 'scheduled', language: 'python' }), + 'hatchet quickstart --use-case scheduled --language go', + ); +}); + +test('triggerCommand uses the trigger name registered by each template', () => { + assert.equal(triggerCommand('simple'), 'hatchet trigger simple'); + assert.equal(triggerCommand('scheduled'), 'hatchet trigger manual-run'); +}); + +test('language compatibility matches the published templates', () => { + assert.equal(isLanguageSupported('simple', 'python'), true); + assert.equal(isLanguageSupported('simple', 'typescript'), true); + assert.equal(isLanguageSupported('simple', 'go'), true); + assert.equal(isLanguageSupported('scheduled', 'go'), true); + assert.equal(isLanguageSupported('scheduled', 'python'), false); + assert.equal(isLanguageSupported('scheduled', 'typescript'), false); + + assert.equal(resolveLanguage('simple', 'typescript'), 'typescript'); + assert.equal(resolveLanguage('scheduled', 'typescript'), 'go'); +}); + +test('only the shippable use cases are selectable', () => { + assert.deepEqual(Object.keys(availableUseCases), ['simple', 'scheduled']); + + // The @ts-expect-error assertions are checked by tsc; the wrapper is + // never executed. + const rejectedByTypes = () => { + // @ts-expect-error roadmap use cases are not selectable + triggerCommand('pdf'); + // @ts-expect-error roadmap use cases are not selectable + scaffoldCommand({ useCase: 'claudeAgent', language: 'go' }); + }; + void rejectedByTypes; +}); diff --git a/frontend/app/src/pages/main/v1/overview/components/use-case-options.ts b/frontend/app/src/pages/main/v1/overview/components/use-case-options.ts new file mode 100644 index 0000000000..acfb089360 --- /dev/null +++ b/frontend/app/src/pages/main/v1/overview/components/use-case-options.ts @@ -0,0 +1,77 @@ +import { + workflowLanguageOptions, + type WorkflowLanguageKey, +} from './onboarding-options'; + +// Selectable quickstart use cases. `value` is the CLI --use-case token and +// `trigger` is the workflow name the generated template registers, so both +// feed directly into the printed commands. Roadmap use cases without a +// template belong in a separate display-only list, never here. The helpers +// below accept only AvailableUseCaseKey, so a display-only entry cannot +// produce a command. +export const availableUseCases = { + simple: { + value: 'simple', + label: 'Simple task', + description: 'A minimal task that confirms your worker runs end to end.', + languages: [ + workflowLanguageOptions.python.value, + workflowLanguageOptions.typescript.value, + workflowLanguageOptions.go.value, + ], + trigger: 'simple', + }, + scheduled: { + value: 'scheduled', + label: 'Scheduled CRON job', + description: + 'A workflow on a cron schedule that can also be run on demand.', + languages: [workflowLanguageOptions.go.value], + trigger: 'manual-run', + }, +} as const; + +export type AvailableUseCaseKey = keyof typeof availableUseCases; + +export type QuickstartSelection = { + useCase: AvailableUseCaseKey; + language: WorkflowLanguageKey; +}; + +// The CLI prints this after scaffolding for every language and package +// manager, so onboarding shows the same command everywhere. +export const workerDevCommand = 'hatchet worker dev'; + +export function isLanguageSupported( + useCase: AvailableUseCaseKey, + language: WorkflowLanguageKey, +): boolean { + return ( + availableUseCases[useCase].languages as readonly WorkflowLanguageKey[] + ).includes(language); +} + +export function resolveLanguage( + useCase: AvailableUseCaseKey, + language: WorkflowLanguageKey, +): WorkflowLanguageKey { + return isLanguageSupported(useCase, language) + ? language + : availableUseCases[useCase].languages[0]; +} + +// The CLI prompts for the package manager, project name, and directory, +// so the command deliberately omits those flags. +export function scaffoldCommand({ + useCase, + language, +}: QuickstartSelection): string { + return `hatchet quickstart --use-case ${useCase} --language ${resolveLanguage( + useCase, + language, + )}`; +} + +export function triggerCommand(useCase: AvailableUseCaseKey): string { + return `hatchet trigger ${availableUseCases[useCase].trigger}`; +} diff --git a/frontend/app/src/pages/main/v1/overview/index.tsx b/frontend/app/src/pages/main/v1/overview/index.tsx index 2381ee17cd..07fc55f479 100644 --- a/frontend/app/src/pages/main/v1/overview/index.tsx +++ b/frontend/app/src/pages/main/v1/overview/index.tsx @@ -1,18 +1,31 @@ import { CreateApiTokenSection } from './components/create-api-token-section'; +import { FinishOnboardingDialog } from './components/finish-onboarding-dialog'; import { LearnWorkflowSection, type WorkflowLanguageKey, type WorkflowStepKey, type InstallMethod, - workflowLanguageOptions, installMethodOptions, workflowStepOptions, } from './components/learn-workflow-section'; +import { + applyLanguageChange, + applyTabChange, + applyUseCaseChange, + hasQualifiedWorker, + normalizeOnboardingState, + onboardingStorageKey, + qualifiedRunQueryParams, + type OnboardingPersistedState, +} from './components/onboarding-state'; +import { SkipOnboardingDialog } from './components/skip-onboarding-dialog'; import { SupportSection } from './components/support-section'; import { TokenSuccessDialog } from './components/token-success-dialog'; +import { type AvailableUseCaseKey } from './components/use-case-options'; import { useAnalytics } from '@/hooks/use-analytics'; import useAuthDisabled from '@/hooks/use-auth-disabled'; import { useCurrentUser } from '@/hooks/use-current-user'; +import { useLocalStorageState } from '@/hooks/use-local-storage-state'; import { useTenantDetails } from '@/hooks/use-tenant'; import api, { CreateAPITokenRequest, queries } from '@/lib/api'; import { useApiError } from '@/lib/hooks'; @@ -41,22 +54,50 @@ export default function Overview() { const [expiresIn, setExpiresIn] = useState(EXPIRES_IN_OPTIONS['100 years']); const [generatedToken, setGeneratedToken] = useState(); const [showTokenDialog, setShowTokenDialog] = useState(false); + const [showFinishDialog, setShowFinishDialog] = useState(false); + const [showSkipDialog, setShowSkipDialog] = useState(false); const [profileToken, setProfileToken] = useState(); const [profileTokenError, setProfileTokenError] = useState< string | undefined >(); const [fieldErrors, setFieldErrors] = useState>({}); - const [selectedTab, setSelectedTab] = useState( - workflowStepOptions.install.value, - ); - const [language, setLanguage] = useState( - workflowLanguageOptions.python.value, - ); const [installMethod, setInstallMethod] = useState( installMethodOptions.native.value, ); const hasTrackedWorkerConnection = useRef(false); + // The raw stored value is normalized on every read, so malformed or + // stale entries fall back to the defaults. + const [storedOnboarding, setStoredOnboarding] = useLocalStorageState( + onboardingStorageKey(tenantId ?? 'unknown'), + null, + ); + const onboarding = useMemo( + () => normalizeOnboardingState(storedOnboarding), + [storedOnboarding], + ); + const updateOnboarding = (patch: Partial) => { + setStoredOnboarding((prev: unknown) => ({ + ...normalizeOnboardingState(prev), + ...patch, + })); + }; + + const selectedTab: WorkflowStepKey = onboarding.tab; + // All tab navigation, including direct tab clicks and Continue buttons, + // goes through applyTabChange so leaving Choose use case always confirms + // the selection. + const setSelectedTab = (tab: WorkflowStepKey) => + setStoredOnboarding((prev: unknown) => + applyTabChange( + normalizeOnboardingState(prev), + tab, + new Date().toISOString(), + ), + ); + const language: WorkflowLanguageKey = onboarding.language; + const useCase: AvailableUseCaseKey = onboarding.useCase; + const defaultTokenName = useMemo(() => { const name = currentUser?.name?.trim(); if (!name) { @@ -159,25 +200,72 @@ export default function Overview() { }); }; - // Poll for workers when on the "Run worker" tab + const selectionConfirmedAt = onboarding.selectionConfirmedAt; + + // Poll for workers while "Project quickstart" is visible and onboarding + // is shown. Polling keeps running there even after a worker qualifies, + // because the indicator must also flip back when a worker disconnects. + // hasQualifiedWorker decides the connected state; the rows alone + // include workers that are stale or from a previous selection. const workersQuery = useQuery({ ...queries.workers.list(tenantId!), - enabled: selectedTab === workflowStepOptions.quickstart.value, + enabled: + !!tenantId && + !onboarding.hidden && + selectedTab === workflowStepOptions.quickstart.value, refetchInterval: 2000, // Poll every 2 seconds }); - const hasActiveWorker = (workersQuery.data?.rows?.length ?? 0) > 0; + const hasConnectedWorker = hasQualifiedWorker( + workersQuery.data?.rows ?? [], + selectionConfirmedAt, + ); + + // Detect a completed run created after the confirmed selection. Worker + // qualification above is current state and reverts on disconnect; a + // completed run is historical product state and stays complete while + // the confirmation timestamp stands. Both reset together when a + // selection change or Restart onboarding clears the timestamp. The + // query runs on any tab whenever a timestamp exists, so completion + // survives a reload onto Finish, and it polls only while the "Run a + // task" tab is waiting for a result. The timestamp is part of the query + // key, so a reset selection never reuses stale completion data. Runs + // are tenant-scoped, so a teammate's completed run after the + // confirmation also satisfies this check. + const qualifiedRunQuery = useQuery({ + ...queries.v1WorkflowRuns.list( + tenantId!, + qualifiedRunQueryParams( + selectionConfirmedAt ?? new Date(0).toISOString(), + ), + ), + enabled: !!tenantId && !!selectionConfirmedAt && !onboarding.hidden, + refetchInterval: (query) => + selectedTab === workflowStepOptions.runTask.value && + (query.state.data?.rows?.length ?? 0) === 0 + ? 2000 + : false, + }); + + const hasQualifiedRun = + !!selectionConfirmedAt && (qualifiedRunQuery.data?.rows?.length ?? 0) > 0; + + // The connection event fires once for each tenant, so a tenant switch + // without a remount must re-arm it. + useEffect(() => { + hasTrackedWorkerConnection.current = false; + }, [tenantId]); // Track worker connection (only once) useEffect(() => { - if (hasActiveWorker && !hasTrackedWorkerConnection.current) { + if (hasConnectedWorker && !hasTrackedWorkerConnection.current) { capture('onboarding_worker_connected', { tenant_id: tenantId, user_email: currentUser?.email, }); hasTrackedWorkerConnection.current = true; } - }, [hasActiveWorker, capture, tenantId, currentUser?.email]); + }, [hasConnectedWorker, capture, tenantId, currentUser?.email]); return (
@@ -187,48 +275,70 @@ export default function Overview() {
- { - capture('onboarding_tab_changed', { - tenant_id: tenantId, - user_email: currentUser?.email, - tab: tabLabel, - }); - }} - onLanguageSelectedEvent={(_language, languageLabel) => { - capture('onboarding_language_selected', { - tenant_id: tenantId, - user_email: currentUser?.email, - language: languageLabel, - }); - }} - onFinish={() => { - capture('onboarding_completed', { - tenant_id: tenantId, - user_email: currentUser?.email, - }); - navigate({ - to: '/tenants/$tenant/runs', - params: { tenant: tenantId! }, - }); - }} - /> + {/* Hidden onboarding leaves no trace on Overview, whether hidden by + Skip or by Finish; recovery is Restart onboarding on the tenant + General settings page. */} + {!onboarding.hidden && ( + { + setStoredOnboarding((prev: unknown) => + applyUseCaseChange(normalizeOnboardingState(prev), nextUseCase), + ); + }} + language={language} + onLanguageChange={(nextLanguage) => { + setStoredOnboarding((prev: unknown) => + applyLanguageChange(normalizeOnboardingState(prev), nextLanguage), + ); + }} + installMethod={installMethod} + onInstallMethodChange={setInstallMethod} + authDisabled={authDisabled} + authDisabledToken={authDisabledToken} + profileToken={profileToken} + isGeneratingProfileToken={createProfileTokenMutation.isPending} + profileTokenError={profileTokenError} + onGenerateProfileToken={handleGenerateProfileToken} + hasConnectedWorker={hasConnectedWorker} + hasQualifiedRun={hasQualifiedRun} + onViewRuns={() => { + navigate({ + to: '/tenants/$tenant/runs', + params: { tenant: tenantId! }, + }); + }} + onSkip={() => setShowSkipDialog(true)} + onTabChangeEvent={(_tab, tabLabel) => { + capture('onboarding_tab_changed', { + tenant_id: tenantId, + user_email: currentUser?.email, + tab: tabLabel, + }); + }} + onLanguageSelectedEvent={(_language, languageLabel) => { + capture('onboarding_language_selected', { + tenant_id: tenantId, + user_email: currentUser?.email, + language: languageLabel, + }); + }} + onUseCaseSelectedEvent={(useCaseKey, useCaseLabel) => { + capture('onboarding_use_case_selected', { + tenant_id: tenantId, + user_email: currentUser?.email, + use_case: useCaseKey, + use_case_label: useCaseLabel, + }); + }} + onFinish={() => setShowFinishDialog(true)} + /> + )} - {!authDisabled && ( + {!authDisabled && !onboarding.hidden && ( { @@ -254,6 +364,35 @@ export default function Overview() { + { + updateOnboarding({ hidden: true }); + setShowSkipDialog(false); + }} + /> + + { + // The confirmed OK is the completion action, so the event fires + // exactly once here; opening or dismissing the dialog captures + // nothing. + capture('onboarding_completed', { + tenant_id: tenantId, + user_email: currentUser?.email, + }); + updateOnboarding({ hidden: true }); + setShowFinishDialog(false); + navigate({ + to: '/tenants/$tenant/runs', + params: { tenant: tenantId! }, + }); + }} + /> + + @@ -277,3 +285,43 @@ const AnalyticsOptOut: React.FC = () => { ); }; + +// A visible onboarding needs no recovery control, and after a restart the +// row removes itself because hidden returns to false. +const OnboardingSettingRow: React.FC = () => { + const { tenantId } = useCurrentTenantId(); + const navigate = useNavigate(); + // The same tenant-scoped key and defaults the Overview onboarding reads, + // so restarting here is indistinguishable from a first visit there. + const [storedOnboarding, setStoredOnboarding] = useLocalStorageState( + onboardingStorageKey(tenantId), + null, + ); + + const onboarding = normalizeOnboardingState(storedOnboarding); + + if (!onboarding.hidden) { + return null; + } + + return ( + + + + ); +}; From 02d79db51895568956f7dc7efe54feaf0067f270 Mon Sep 17 00:00:00 2001 From: trevor Date: Mon, 27 Jul 2026 06:42:07 -0600 Subject: [PATCH 2/5] feat: add Python and TypeScript to the scheduled quickstart The onboarding picker now lists all three languages for scheduled. The quickstarts dependency is temporarily pinned to the head commit of the hatchet-quickstarts PR that adds these templates. Replace it with the v0.3.0 tag once that PR merges and is tagged, then this PR can be taken out of draft. --- .../cli/internal/templater/selection_test.go | 23 +++++++++++++-- cmd/hatchet-cli/cli/quickstart_e2e_test.go | 21 ++++++++++++-- .../components/learn-workflow-section.tsx | 6 ---- .../components/onboarding-state.test.ts | 23 +++------------ .../components/use-case-options.test.ts | 28 +++++++++---------- .../overview/components/use-case-options.ts | 6 +++- go.mod | 2 +- go.sum | 4 +-- 8 files changed, 64 insertions(+), 49 deletions(-) diff --git a/cmd/hatchet-cli/cli/internal/templater/selection_test.go b/cmd/hatchet-cli/cli/internal/templater/selection_test.go index 5c64d47975..b8d7fdd7f4 100644 --- a/cmd/hatchet-cli/cli/internal/templater/selection_test.go +++ b/cmd/hatchet-cli/cli/internal/templater/selection_test.go @@ -6,6 +6,7 @@ import ( "reflect" "strings" "testing" + "testing/fstest" quickstarts "github.com/hatchet-dev/hatchet-quickstarts" ) @@ -49,8 +50,8 @@ func TestLanguagesFor(t *testing.T) { t.Fatalf("LanguagesFor(scheduled) returned an error: %v", err) } - if !reflect.DeepEqual(scheduled, []string{"go"}) { - t.Errorf("expected only go for scheduled, got %v", scheduled) + if !reflect.DeepEqual(scheduled, []string{"python", "typescript", "go"}) { + t.Errorf("expected all three languages for scheduled, got %v", scheduled) } } @@ -60,6 +61,8 @@ func TestValidate(t *testing.T) { valid := []Selection{ {UseCase: "simple", Language: "python", PackageManager: "poetry"}, {UseCase: "", Language: "typescript", PackageManager: "pnpm"}, + {UseCase: "scheduled", Language: "python", PackageManager: "pip"}, + {UseCase: "scheduled", Language: "typescript", PackageManager: "npm"}, {UseCase: "scheduled", Language: "go", PackageManager: "go"}, } @@ -74,7 +77,6 @@ func TestValidate(t *testing.T) { wantErr string }{ {Selection{UseCase: "nonexistent", Language: "go", PackageManager: "go"}, "unknown use case"}, - {Selection{UseCase: "scheduled", Language: "python", PackageManager: "poetry"}, "does not support language"}, {Selection{UseCase: "simple", Language: "rust", PackageManager: "cargo"}, "invalid language"}, {Selection{UseCase: "simple", Language: "go", PackageManager: "npm"}, "invalid package manager"}, } @@ -92,6 +94,21 @@ func TestValidate(t *testing.T) { } } +// TestValidateUnsupportedLanguageForUseCase uses a synthetic tree because +// every real use case currently supports every language; the branch stays +// covered for the next use case that constrains its languages. +func TestValidateUnsupportedLanguageForUseCase(t *testing.T) { + fsys := fstest.MapFS{ + "templates/go/README.md": {Data: []byte("simple")}, + "templates/use-cases/partial/go/README.md": {Data: []byte("go only")}, + } + + err := Validate(fsys, Selection{UseCase: "partial", Language: "python", PackageManager: "poetry"}) + if err == nil || !strings.Contains(err.Error(), "does not support language") { + t.Errorf("expected a does-not-support error, got: %v", err) + } +} + func TestProcessMultiSourceScheduledGo(t *testing.T) { dstDir := filepath.Join(t.TempDir(), "project") sel := Selection{UseCase: "scheduled", Language: "go", PackageManager: "go"} diff --git a/cmd/hatchet-cli/cli/quickstart_e2e_test.go b/cmd/hatchet-cli/cli/quickstart_e2e_test.go index e114dbf0bc..20b8f794de 100644 --- a/cmd/hatchet-cli/cli/quickstart_e2e_test.go +++ b/cmd/hatchet-cli/cli/quickstart_e2e_test.go @@ -43,6 +43,13 @@ var templateTests = []templateTestCase{ {"simple", "go", "go", "simple"}, // Use cases + {"scheduled", "python", "poetry", "manual-run"}, + {"scheduled", "python", "uv", "manual-run"}, + {"scheduled", "python", "pip", "manual-run"}, + {"scheduled", "typescript", "npm", "manual-run"}, + {"scheduled", "typescript", "pnpm", "manual-run"}, + {"scheduled", "typescript", "yarn", "manual-run"}, + {"scheduled", "typescript", "bun", "manual-run"}, {"scheduled", "go", "go", "manual-run"}, } @@ -232,11 +239,16 @@ func verifyProjectStructure(t *testing.T, projectDir string, tt templateTestCase // Language-specific files switch tt.language { case "python": + pythonWorkflowFile := "src/workflows/first_workflow.py" + if tt.useCase == "scheduled" { + pythonWorkflowFile = "src/workflows/scheduled_workflow.py" + } + pythonFiles := []string{ "src/hatchet_client.py", "src/run.py", "src/worker.py", - "src/workflows/first_workflow.py", + pythonWorkflowFile, } for _, file := range pythonFiles { path := filepath.Join(projectDir, file) @@ -262,11 +274,16 @@ func verifyProjectStructure(t *testing.T, projectDir string, tt templateTestCase } case "typescript": + tsWorkflowFile := "src/workflows/first-workflow.ts" + if tt.useCase == "scheduled" { + tsWorkflowFile = "src/workflows/scheduled-workflow.ts" + } + tsFiles := []string{ "src/hatchet-client.ts", "src/run.ts", "src/worker.ts", - "src/workflows/first-workflow.ts", + tsWorkflowFile, "tsconfig.json", "package.json", } diff --git a/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx b/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx index 8d84458bab..ad65f578e6 100644 --- a/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx +++ b/frontend/app/src/pages/main/v1/overview/components/learn-workflow-section.tsx @@ -172,12 +172,6 @@ export function LearnWorkflowSection({ ))} - {useCase === availableUseCases.scheduled.value && ( -

- The scheduled template is currently Go only, so the language is - set to Go. -

- )}