diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 2af6a58..3fcd2ce 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -8,6 +8,7 @@ "name": "job-ops-orchestrator", "version": "1.0.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", @@ -27,6 +28,7 @@ "express": "^4.18.2", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", + "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-transition-group": "^4.4.5", "remark-gfm": "^4.0.1", @@ -58,6 +60,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.0.2", "tailwindcss": "^4.1.18", + "tsc-alias": "^1.8.16", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.3", @@ -1420,6 +1423,18 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1470,6 +1485,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@petamoriken/float16": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", @@ -2761,6 +2814,12 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -3556,6 +3615,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3582,6 +3668,16 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3684,6 +3780,19 @@ "prebuild-install": "^7.1.1" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3743,6 +3852,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3951,6 +4073,31 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -4032,6 +4179,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -4278,6 +4435,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -4779,6 +4949,33 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4803,6 +5000,19 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -5031,6 +5241,40 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5235,6 +5479,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5292,6 +5546,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -5301,6 +5568,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5311,6 +5588,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -5320,6 +5610,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -6026,6 +6326,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -6570,6 +6880,33 @@ } ] }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -6645,6 +6982,20 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6719,6 +7070,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", @@ -6836,6 +7197,16 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6862,6 +7233,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7021,6 +7405,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7085,6 +7500,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -7277,6 +7708,32 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7372,6 +7829,17 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -7420,6 +7888,30 @@ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", "dev": true }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -7788,6 +8280,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -8089,6 +8591,19 @@ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -8150,6 +8665,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index d810575..3999cd9 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -9,7 +9,7 @@ "dev:server": "tsx watch src/server/index.ts", "dev:client": "vite --host", "build": "npm run build:client && npm run build:server", - "build:server": "tsc -p tsconfig.server.json", + "build:server": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json", "build:client": "vite build", "start": "node dist/server/index.js", "db:migrate": "tsx src/server/db/migrate.ts", @@ -20,6 +20,7 @@ "test:run": "vitest run" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", @@ -39,6 +40,7 @@ "express": "^4.18.2", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", + "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-transition-group": "^4.4.5", "remark-gfm": "^4.0.1", @@ -70,6 +72,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.0.2", "tailwindcss": "^4.1.18", + "tsc-alias": "^1.8.16", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.3", diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 7802924..d488b22 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -141,6 +141,28 @@ describe("SettingsPage", () => { expect(toast.success).toHaveBeenCalledWith("Settings saved") }) + it("shows validation error for too long model override", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + + renderPage() + + const modelTrigger = await screen.findByRole("button", { name: /model/i }) + fireEvent.click(modelTrigger) + + const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main") + const modelInput = within(modelField).getByRole("textbox") + + // Change to > 200 chars + fireEvent.change(modelInput, { target: { value: "a".repeat(201) } }) + + // Should see error message + expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument() + + // Save button should be disabled due to validation error (isValid will be false) + const saveButton = screen.getByRole("button", { name: /^save$/i }) + expect(saveButton).toBeDisabled() + }) + it("clears jobs by status and summarizes results", async () => { vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 }) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 0fbcd73..2c3571f 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -1,52 +1,101 @@ -/** - * Settings page. - */ - -import React, { useEffect, useMemo, useState } from "react" +import React, { useEffect, useState } from "react" import { Settings } from "lucide-react" import { toast } from "sonner" +import { useForm, FormProvider } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" -import { PageHeader } from "../components/layout" +import { PageHeader } from "@client/components/layout" import { Accordion } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" -import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types" -import * as api from "../api" -import { arraysEqual } from "@/lib/utils" -import { resumeProjectsEqual } from "./settings/utils" -import { DangerZoneSection } from "./settings/components/DangerZoneSection" -import { DisplaySettingsSection } from "./settings/components/DisplaySettingsSection" -import { GradcrackerSection } from "./settings/components/GradcrackerSection" -import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection" -import { JobspySection } from "./settings/components/JobspySection" -import { ModelSettingsSection } from "./settings/components/ModelSettingsSection" -import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection" -import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection" -import { SearchTermsSection } from "./settings/components/SearchTermsSection" -import { UkvisajobsSection } from "./settings/components/UkvisajobsSection" +import type { AppSettings, JobStatus } from "@shared/types" +import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema" +import * as api from "@client/api" +import { resumeProjectsEqual } from "@client/pages/settings/utils" +import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection" +import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection" +import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection" +import { JobCompleteWebhookSection } from "@client/pages/settings/components/JobCompleteWebhookSection" +import { JobspySection } from "@client/pages/settings/components/JobspySection" +import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection" +import { PipelineWebhookSection } from "@client/pages/settings/components/PipelineWebhookSection" +import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection" +import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection" +import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection" + +const DEFAULT_FORM_VALUES: UpdateSettingsInput = { + model: "", + modelScorer: "", + modelTailoring: "", + modelProjectSelection: "", + pipelineWebhookUrl: "", + jobCompleteWebhookUrl: "", + resumeProjects: null, + ukvisajobsMaxJobs: null, + gradcrackerMaxJobsPerTerm: null, + searchTerms: null, + jobspyLocation: null, + jobspyResultsWanted: null, + jobspyHoursOld: null, + jobspyCountryIndeed: null, + jobspySites: null, + jobspyLinkedinFetchDescription: null, + showSponsorInfo: null, +} + +const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { + model: null, + modelScorer: null, + modelTailoring: null, + modelProjectSelection: null, + pipelineWebhookUrl: null, + jobCompleteWebhookUrl: null, + resumeProjects: null, + ukvisajobsMaxJobs: null, + gradcrackerMaxJobsPerTerm: null, + searchTerms: null, + jobspyLocation: null, + jobspyResultsWanted: null, + jobspyHoursOld: null, + jobspyCountryIndeed: null, + jobspySites: null, + jobspyLinkedinFetchDescription: null, + showSponsorInfo: null, +} + +const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ + model: data.overrideModel ?? "", + modelScorer: data.overrideModelScorer ?? "", + modelTailoring: data.overrideModelTailoring ?? "", + modelProjectSelection: data.overrideModelProjectSelection ?? "", + pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "", + jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "", + resumeProjects: data.resumeProjects, + ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs, + gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm, + searchTerms: data.overrideSearchTerms, + jobspyLocation: data.overrideJobspyLocation, + jobspyResultsWanted: data.overrideJobspyResultsWanted, + jobspyHoursOld: data.overrideJobspyHoursOld, + jobspyCountryIndeed: data.overrideJobspyCountryIndeed, + jobspySites: data.overrideJobspySites, + jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription, + showSponsorInfo: data.overrideShowSponsorInfo, +}) export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) - const [modelDraft, setModelDraft] = useState("") - const [modelScorerDraft, setModelScorerDraft] = useState("") - const [modelTailoringDraft, setModelTailoringDraft] = useState("") - const [modelProjectSelectionDraft, setModelProjectSelectionDraft] = useState("") - const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("") - const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("") - const [resumeProjectsDraft, setResumeProjectsDraft] = useState(null) - const [ukvisajobsMaxJobsDraft, setUkvisajobsMaxJobsDraft] = useState(null) - const [gradcrackerMaxJobsPerTermDraft, setGradcrackerMaxJobsPerTermDraft] = useState(null) - const [searchTermsDraft, setSearchTermsDraft] = useState(null) - const [jobspyLocationDraft, setJobspyLocationDraft] = useState(null) - const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState(null) - const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState(null) - const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState(null) - const [jobspySitesDraft, setJobspySitesDraft] = useState(null) - const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState(null) - const [showSponsorInfoDraft, setShowSponsorInfoDraft] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) const [statusesToClear, setStatusesToClear] = useState(['discovered']) + const methods = useForm({ + resolver: zodResolver(updateSettingsSchema), + mode: "onChange", + defaultValues: DEFAULT_FORM_VALUES, + }) + + const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid } } = methods + useEffect(() => { let isMounted = true setIsLoading(true) @@ -55,23 +104,7 @@ export const SettingsPage: React.FC = () => { .then((data) => { if (!isMounted) return setSettings(data) - setModelDraft(data.overrideModel ?? "") - setModelScorerDraft(data.overrideModelScorer ?? "") - setModelTailoringDraft(data.overrideModelTailoring ?? "") - setModelProjectSelectionDraft(data.overrideModelProjectSelection ?? "") - setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "") - setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "") - setResumeProjectsDraft(data.resumeProjects) - setUkvisajobsMaxJobsDraft(data.overrideUkvisajobsMaxJobs) - setGradcrackerMaxJobsPerTermDraft(data.overrideGradcrackerMaxJobsPerTerm) - setSearchTermsDraft(data.overrideSearchTerms) - setJobspyLocationDraft(data.overrideJobspyLocation) - setJobspyResultsWantedDraft(data.overrideJobspyResultsWanted) - setJobspyHoursOldDraft(data.overrideJobspyHoursOld) - setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed) - setJobspySitesDraft(data.overrideJobspySites) - setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription) - setShowSponsorInfoDraft(data.overrideShowSponsorInfo) + reset(mapSettingsToForm(data)) }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" @@ -85,190 +118,79 @@ export const SettingsPage: React.FC = () => { return () => { isMounted = false } - }, []) + }, [reset]) const effectiveModel = settings?.model ?? "" const defaultModel = settings?.defaultModel ?? "" - const overrideModel = settings?.overrideModel const effectiveModelScorer = settings?.modelScorer ?? "" - const overrideModelScorer = settings?.overrideModelScorer const effectiveModelTailoring = settings?.modelTailoring ?? "" - const overrideModelTailoring = settings?.overrideModelTailoring const effectiveModelProjectSelection = settings?.modelProjectSelection ?? "" - const overrideModelProjectSelection = settings?.overrideModelProjectSelection const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? "" const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? "" - const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? "" const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? "" - const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl const effectiveUkvisajobsMaxJobs = settings?.ukvisajobsMaxJobs ?? 50 const defaultUkvisajobsMaxJobs = settings?.defaultUkvisajobsMaxJobs ?? 50 - const overrideUkvisajobsMaxJobs = settings?.overrideUkvisajobsMaxJobs const effectiveGradcrackerMaxJobsPerTerm = settings?.gradcrackerMaxJobsPerTerm ?? 50 const defaultGradcrackerMaxJobsPerTerm = settings?.defaultGradcrackerMaxJobsPerTerm ?? 50 - const overrideGradcrackerMaxJobsPerTerm = settings?.overrideGradcrackerMaxJobsPerTerm const effectiveSearchTerms = settings?.searchTerms ?? [] const defaultSearchTerms = settings?.defaultSearchTerms ?? [] - const overrideSearchTerms = settings?.overrideSearchTerms const effectiveJobspyLocation = settings?.jobspyLocation ?? "" const defaultJobspyLocation = settings?.defaultJobspyLocation ?? "" - const overrideJobspyLocation = settings?.overrideJobspyLocation const effectiveJobspyResultsWanted = settings?.jobspyResultsWanted ?? 200 const defaultJobspyResultsWanted = settings?.defaultJobspyResultsWanted ?? 200 - const overrideJobspyResultsWanted = settings?.overrideJobspyResultsWanted const effectiveJobspyHoursOld = settings?.jobspyHoursOld ?? 72 const defaultJobspyHoursOld = settings?.defaultJobspyHoursOld ?? 72 - const overrideJobspyHoursOld = settings?.overrideJobspyHoursOld const effectiveJobspyCountryIndeed = settings?.jobspyCountryIndeed ?? "" const defaultJobspyCountryIndeed = settings?.defaultJobspyCountryIndeed ?? "" - const overrideJobspyCountryIndeed = settings?.overrideJobspyCountryIndeed const effectiveJobspySites = settings?.jobspySites ?? ["indeed", "linkedin"] const defaultJobspySites = settings?.defaultJobspySites ?? ["indeed", "linkedin"] - const overrideJobspySites = settings?.overrideJobspySites const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true - const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription const effectiveShowSponsorInfo = settings?.showSponsorInfo ?? true const defaultShowSponsorInfo = settings?.defaultShowSponsorInfo ?? true - const overrideShowSponsorInfo = settings?.overrideShowSponsorInfo const profileProjects = settings?.profileProjects ?? [] const maxProjectsTotal = profileProjects.length - const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0 - const canSave = useMemo(() => { - if (!settings || !resumeProjectsDraft) return false - const next = modelDraft.trim() - const current = (overrideModel ?? "").trim() - const nextScorer = modelScorerDraft.trim() - const currentScorer = (overrideModelScorer ?? "").trim() - const nextTailoring = modelTailoringDraft.trim() - const currentTailoring = (overrideModelTailoring ?? "").trim() - const nextProjectSelection = modelProjectSelectionDraft.trim() - const currentProjectSelection = (overrideModelProjectSelection ?? "").trim() - const nextWebhook = pipelineWebhookUrlDraft.trim() - const currentWebhook = (overridePipelineWebhookUrl ?? "").trim() - const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim() - const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim() - const ukvisajobsChanged = ukvisajobsMaxJobsDraft !== (overrideUkvisajobsMaxJobs ?? null) - const gradcrackerChanged = gradcrackerMaxJobsPerTermDraft !== (overrideGradcrackerMaxJobsPerTerm ?? null) - const searchTermsChanged = JSON.stringify(searchTermsDraft) !== JSON.stringify(overrideSearchTerms ?? null) - return ( - next !== current || - nextScorer !== currentScorer || - nextTailoring !== currentTailoring || - nextProjectSelection !== currentProjectSelection || - nextWebhook !== currentWebhook || - nextJobCompleteWebhook !== currentJobCompleteWebhook || - !resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) || - ukvisajobsChanged || - gradcrackerChanged || - searchTermsChanged || - jobspyLocationDraft !== (overrideJobspyLocation ?? null) || - jobspyResultsWantedDraft !== (overrideJobspyResultsWanted ?? null) || - jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) || - jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) || - JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) || - jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) || - showSponsorInfoDraft !== (overrideShowSponsorInfo ?? null) - ) - }, [ - settings, - modelDraft, - modelScorerDraft, - modelTailoringDraft, - modelProjectSelectionDraft, - pipelineWebhookUrlDraft, - jobCompleteWebhookUrlDraft, - overrideModel, - overrideModelScorer, - overrideModelTailoring, - overrideModelProjectSelection, - overridePipelineWebhookUrl, - overrideJobCompleteWebhookUrl, - resumeProjectsDraft, - ukvisajobsMaxJobsDraft, - overrideUkvisajobsMaxJobs, - gradcrackerMaxJobsPerTermDraft, - overrideGradcrackerMaxJobsPerTerm, - searchTermsDraft, - overrideSearchTerms, - jobspyLocationDraft, - jobspyResultsWantedDraft, - jobspyHoursOldDraft, - jobspyCountryIndeedDraft, - jobspySitesDraft, - jobspyLinkedinFetchDescriptionDraft, - showSponsorInfoDraft, - overrideJobspyLocation, - overrideJobspyResultsWanted, - overrideJobspyHoursOld, - overrideJobspyCountryIndeed, - overrideJobspySites, - overrideJobspyLinkedinFetchDescription, - overrideShowSponsorInfo, - ]) + const watchedValues = watch() + const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0 - const handleSave = async () => { - if (!settings || !resumeProjectsDraft) return + const canSave = isDirty && isValid + + const onSave = async (data: UpdateSettingsInput) => { + if (!settings) return try { setIsSaving(true) - const trimmed = modelDraft.trim() - const trimmedScorer = modelScorerDraft.trim() - const trimmedTailoring = modelTailoringDraft.trim() - const trimmedProjectSelection = modelProjectSelectionDraft.trim() - const webhookTrimmed = pipelineWebhookUrlDraft.trim() - const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim() - const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects) + + // Prepare payload: nullify if equal to default + const resumeProjectsData = data.resumeProjects + const resumeProjectsOverride = (resumeProjectsData && settings.defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, settings.defaultResumeProjects)) ? null - : resumeProjectsDraft - const ukvisajobsMaxJobsOverride = ukvisajobsMaxJobsDraft === defaultUkvisajobsMaxJobs ? null : ukvisajobsMaxJobsDraft - const gradcrackerMaxJobsPerTermOverride = gradcrackerMaxJobsPerTermDraft === defaultGradcrackerMaxJobsPerTerm ? null : gradcrackerMaxJobsPerTermDraft - const searchTermsOverride = arraysEqual(searchTermsDraft ?? [], defaultSearchTerms) ? null : searchTermsDraft - const jobspyLocationOverride = jobspyLocationDraft === defaultJobspyLocation ? null : jobspyLocationDraft - const jobspyResultsWantedOverride = jobspyResultsWantedDraft === defaultJobspyResultsWanted ? null : jobspyResultsWantedDraft - const jobspyHoursOldOverride = jobspyHoursOldDraft === defaultJobspyHoursOld ? null : jobspyHoursOldDraft - const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft - const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft - const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft - const showSponsorInfoOverride = showSponsorInfoDraft === defaultShowSponsorInfo ? null : showSponsorInfoDraft - const updated = await api.updateSettings({ - model: trimmed.length > 0 ? trimmed : null, - modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null, - modelTailoring: trimmedTailoring.length > 0 ? trimmedTailoring : null, - modelProjectSelection: trimmedProjectSelection.length > 0 ? trimmedProjectSelection : null, - pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null, - jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null, + : resumeProjectsData + + const payload: UpdateSettingsInput = { + model: data.model?.trim() || null, + modelScorer: data.modelScorer?.trim() || null, + modelTailoring: data.modelTailoring?.trim() || null, + modelProjectSelection: data.modelProjectSelection?.trim() || null, + pipelineWebhookUrl: data.pipelineWebhookUrl?.trim() || null, + jobCompleteWebhookUrl: data.jobCompleteWebhookUrl?.trim() || null, resumeProjects: resumeProjectsOverride, - ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride, - gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride, - searchTerms: searchTermsOverride, - jobspyLocation: jobspyLocationOverride, - jobspyResultsWanted: jobspyResultsWantedOverride, - jobspyHoursOld: jobspyHoursOldOverride, - jobspyCountryIndeed: jobspyCountryIndeedOverride, - jobspySites: jobspySitesOverride, - jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride, - showSponsorInfo: showSponsorInfoOverride, - }) + ukvisajobsMaxJobs: data.ukvisajobsMaxJobs === defaultUkvisajobsMaxJobs ? null : data.ukvisajobsMaxJobs, + gradcrackerMaxJobsPerTerm: data.gradcrackerMaxJobsPerTerm === defaultGradcrackerMaxJobsPerTerm ? null : data.gradcrackerMaxJobsPerTerm, + searchTerms: JSON.stringify(data.searchTerms) === JSON.stringify(defaultSearchTerms) ? null : data.searchTerms, + jobspyLocation: data.jobspyLocation === defaultJobspyLocation ? null : data.jobspyLocation, + jobspyResultsWanted: data.jobspyResultsWanted === defaultJobspyResultsWanted ? null : data.jobspyResultsWanted, + jobspyHoursOld: data.jobspyHoursOld === defaultJobspyHoursOld ? null : data.jobspyHoursOld, + jobspyCountryIndeed: data.jobspyCountryIndeed === defaultJobspyCountryIndeed ? null : data.jobspyCountryIndeed, + jobspySites: JSON.stringify((data.jobspySites ?? []).slice().sort()) === JSON.stringify((defaultJobspySites ?? []).slice().sort()) ? null : data.jobspySites, + jobspyLinkedinFetchDescription: data.jobspyLinkedinFetchDescription === defaultJobspyLinkedinFetchDescription ? null : data.jobspyLinkedinFetchDescription, + showSponsorInfo: data.showSponsorInfo === defaultShowSponsorInfo ? null : data.showSponsorInfo, + } + + const updated = await api.updateSettings(payload) setSettings(updated) - setModelDraft(updated.overrideModel ?? "") - setModelScorerDraft(updated.overrideModelScorer ?? "") - setModelTailoringDraft(updated.overrideModelTailoring ?? "") - setModelProjectSelectionDraft(updated.overrideModelProjectSelection ?? "") - setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "") - setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "") - setResumeProjectsDraft(updated.resumeProjects) - setUkvisajobsMaxJobsDraft(updated.overrideUkvisajobsMaxJobs) - setGradcrackerMaxJobsPerTermDraft(updated.overrideGradcrackerMaxJobsPerTerm) - setSearchTermsDraft(updated.overrideSearchTerms) - setJobspyLocationDraft(updated.overrideJobspyLocation) - setJobspyResultsWantedDraft(updated.overrideJobspyResultsWanted) - setJobspyHoursOldDraft(updated.overrideJobspyHoursOld) - setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed) - setJobspySitesDraft(updated.overrideJobspySites) - setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription) - setShowSponsorInfoDraft(updated.overrideShowSponsorInfo) + reset(mapSettingsToForm(updated)) toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" @@ -277,6 +199,7 @@ export const SettingsPage: React.FC = () => { setIsSaving(false) } } + const handleClearDatabase = async () => { try { setIsSaving(true) @@ -335,43 +258,9 @@ export const SettingsPage: React.FC = () => { const handleReset = async () => { try { setIsSaving(true) - const updated = await api.updateSettings({ - model: null, - modelScorer: null, - modelTailoring: null, - modelProjectSelection: null, - pipelineWebhookUrl: null, - jobCompleteWebhookUrl: null, - resumeProjects: null, - ukvisajobsMaxJobs: null, - gradcrackerMaxJobsPerTerm: null, - searchTerms: null, - jobspyLocation: null, - jobspyResultsWanted: null, - jobspyHoursOld: null, - jobspyCountryIndeed: null, - jobspySites: null, - jobspyLinkedinFetchDescription: null, - showSponsorInfo: null, - }) + const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD) setSettings(updated) - setModelDraft("") - setModelScorerDraft("") - setModelTailoringDraft("") - setModelProjectSelectionDraft("") - setPipelineWebhookUrlDraft("") - setJobCompleteWebhookUrlDraft("") - setResumeProjectsDraft(updated.resumeProjects) - setUkvisajobsMaxJobsDraft(null) - setGradcrackerMaxJobsPerTermDraft(null) - setSearchTermsDraft(null) - setJobspyLocationDraft(null) - setJobspyResultsWantedDraft(null) - setJobspyHoursOldDraft(null) - setJobspyCountryIndeedDraft(null) - setJobspySitesDraft(null) - setJobspyLinkedinFetchDescriptionDraft(null) - setShowSponsorInfoDraft(null) + reset(mapSettingsToForm(updated)) toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" @@ -382,7 +271,7 @@ export const SettingsPage: React.FC = () => { } return ( - <> + {
{ isSaving={isSaving} /> { isSaving={isSaving} /> {
-
+ {Object.keys(errors).length > 0 && ( +
+ Please fix the errors before saving. +
+ )}
- +
) } diff --git a/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx b/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx index b23dc29..9a70589 100644 --- a/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DangerZoneSection.tsx @@ -16,7 +16,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import type { JobStatus } from "@shared/types" -import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants" +import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants" type DangerZoneSectionProps = { statusesToClear: JobStatus[] diff --git a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx index 88ba312..60c1e6d 100644 --- a/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/DisplaySettingsSection.tsx @@ -1,12 +1,12 @@ import React from "react" +import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Checkbox } from "@/components/ui/checkbox" import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" type DisplaySettingsSectionProps = { - showSponsorInfoDraft: boolean | null - setShowSponsorInfoDraft: (value: boolean | null) => void defaultShowSponsorInfo: boolean effectiveShowSponsorInfo: boolean isLoading: boolean @@ -14,14 +14,12 @@ type DisplaySettingsSectionProps = { } export const DisplaySettingsSection: React.FC = ({ - showSponsorInfoDraft, - setShowSponsorInfoDraft, defaultShowSponsorInfo, effectiveShowSponsorInfo, isLoading, isSaving, }) => { - const isChecked = showSponsorInfoDraft ?? defaultShowSponsorInfo + const { control } = useFormContext() return ( @@ -31,13 +29,19 @@ export const DisplaySettingsSection: React.FC = ({
- { - setShowSponsorInfoDraft(checked === "indeterminate" ? null : checked === true) - }} - disabled={isLoading || isSaving} + ( + { + field.onChange(checked === "indeterminate" ? null : checked === true) + }} + disabled={isLoading || isSaving} + /> + )} />