diff --git a/client/package-lock.json b/client/package-lock.json index 544d515..19a05b8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -30,7 +30,8 @@ "@vitejs/plugin-react-swc": "4.3.1", "oxlint": "^1.73.0", "typescript": "7.0.2", - "vite": "8.1.3" + "vite": "8.1.3", + "vitest": "^4.1.10" }, "engines": { "node": ">=26" @@ -67,7 +68,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -392,7 +392,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -405,7 +404,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -455,7 +453,6 @@ "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -1197,6 +1194,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.15.43", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz", @@ -1497,7 +1501,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.101.2" }, @@ -1531,7 +1534,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.17.tgz", "integrity": "sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", @@ -1719,13 +1721,37 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -1736,7 +1762,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2108,6 +2133,119 @@ "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/ansis": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", @@ -2118,6 +2256,16 @@ "node": ">=14" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -2164,7 +2312,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -2200,6 +2347,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -2302,6 +2459,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2312,6 +2476,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2744,6 +2928,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.73.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz", @@ -2881,7 +3079,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2891,7 +3088,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2925,7 +3121,6 @@ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" @@ -2975,7 +3170,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.5.tgz", "integrity": "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -2992,6 +3186,13 @@ "seroval": "^1.0" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3002,12 +3203,43 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3025,6 +3257,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3176,7 +3418,6 @@ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3249,6 +3490,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -3262,6 +3593,23 @@ "dev": true, "license": "MIT" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/client/package.json b/client/package.json index 9ce3a4a..27b7095 100644 --- a/client/package.json +++ b/client/package.json @@ -10,7 +10,8 @@ "dev": "vite --host --port=4444", "build": "tsc && vite build", "preview": "vite preview", - "lint": "oxlint" + "lint": "oxlint", + "test": "vitest run" }, "dependencies": { "@codemirror/lang-javascript": "6.2.5", @@ -35,6 +36,7 @@ "@vitejs/plugin-react-swc": "4.3.1", "oxlint": "^1.73.0", "typescript": "7.0.2", - "vite": "8.1.3" + "vite": "8.1.3", + "vitest": "4.1.10" } } diff --git a/client/src/challenges/evaluate.test.ts b/client/src/challenges/evaluate.test.ts new file mode 100644 index 0000000..15d289d --- /dev/null +++ b/client/src/challenges/evaluate.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { evaluateChecks, allPass } from "./evaluate"; +import type { CheckSpec, SandboxEvent } from "./types"; + +const base = { label: "x", failMessage: "y" }; + +const checks: CheckSpec[] = [ + { ...base, kind: "requestMade", method: "GET", urlIncludes: "/futurama/characters" }, + { ...base, kind: "responseStatus", status: 200, urlIncludes: "/futurama/characters" }, + { ...base, kind: "consoleIncludes", pattern: "bender" }, + { ...base, kind: "consoleCount", min: 1 }, + { ...base, kind: "noUncaughtError" }, +]; + +const passingEvents: SandboxEvent[] = [ + { type: "request", method: "GET", url: "http://localhost:5555/futurama/characters" }, + { + type: "response", + method: "GET", + url: "http://localhost:5555/futurama/characters", + status: 200, + }, + { type: "console", level: "log", text: '[{"name":{"first":"Bender"}}]' }, +]; + +describe("evaluateChecks", () => { + it("passes every check on a correct run", () => { + const results = evaluateChecks(checks, passingEvents); + expect(results.every((r) => r.pass)).toBe(true); + expect(allPass(results)).toBe(true); + }); + + it("fails everything on a run with zero events", () => { + const results = evaluateChecks(checks, []); + expect(results.filter((r) => r.pass).map((r) => r.spec.kind)).toEqual([ + "noUncaughtError", + ]); + expect(allPass(results)).toBe(false); + }); + + it("is order-independent: late async events still flip a check to passing", () => { + // Simulates an un-awaited .then(): console line arrives, THEN the response. + const early = passingEvents.slice(0, 1); + const midRun = evaluateChecks(checks, early); + expect(midRun.find((r) => r.spec.kind === "responseStatus")?.pass).toBe(false); + + const reordered: SandboxEvent[] = [passingEvents[2], passingEvents[0], passingEvents[1]]; + expect(allPass(evaluateChecks(checks, reordered))).toBe(true); + }); + + it("matches method and status filters exactly", () => { + const spec: CheckSpec[] = [ + { ...base, kind: "responseStatus", status: 201, method: "POST" }, + ]; + const getOnly: SandboxEvent[] = [ + { type: "response", method: "GET", url: "/futurama/characters", status: 201 }, + ]; + const posted: SandboxEvent[] = [ + { type: "response", method: "POST", url: "/futurama/characters", status: 201 }, + ]; + expect(evaluateChecks(spec, getOnly)[0].pass).toBe(false); + expect(evaluateChecks(spec, posted)[0].pass).toBe(true); + }); + + it("counts requests for minCount", () => { + const spec: CheckSpec[] = [{ ...base, kind: "requestMade", minCount: 2 }]; + const one: SandboxEvent[] = [{ type: "request", method: "GET", url: "/a" }]; + const two: SandboxEvent[] = [...one, { type: "request", method: "POST", url: "/b" }]; + expect(evaluateChecks(spec, one)[0].pass).toBe(false); + expect(evaluateChecks(spec, two)[0].pass).toBe(true); + }); + + it("consoleIncludes is case-insensitive substring match", () => { + const spec: CheckSpec[] = [{ ...base, kind: "consoleIncludes", pattern: "BENDER" }]; + const events: SandboxEvent[] = [ + { type: "console", level: "log", text: "found bender rodriguez" }, + ]; + expect(evaluateChecks(spec, events)[0].pass).toBe(true); + }); + + it("noUncaughtError fails once an uncaught event appears", () => { + const spec: CheckSpec[] = [{ ...base, kind: "noUncaughtError" }]; + expect(evaluateChecks(spec, [])[0].pass).toBe(true); + expect(evaluateChecks(spec, [{ type: "uncaught" }])[0].pass).toBe(false); + }); + + it("allPass is false for an empty check list", () => { + expect(allPass([])).toBe(false); + }); +}); diff --git a/client/src/challenges/evaluate.ts b/client/src/challenges/evaluate.ts new file mode 100644 index 0000000..50c5145 --- /dev/null +++ b/client/src/challenges/evaluate.ts @@ -0,0 +1,60 @@ +import type { CheckSpec, CheckResult, SandboxEvent } from "./types"; + +/** + * Evaluate a challenge's checks against the events a run has emitted so far. + * + * Pure function over the event array: the page re-runs it as events stream in + * (a late un-awaited .then() can still flip a check to passing), so no check may + * depend on evaluation order or timing — only on event contents. + */ +export function evaluateChecks( + checks: CheckSpec[], + events: SandboxEvent[] +): CheckResult[] { + return checks.map((spec) => ({ spec, pass: evaluateOne(spec, events) })); +} + +export function allPass(results: CheckResult[]): boolean { + return results.length > 0 && results.every((r) => r.pass); +} + +function evaluateOne(spec: CheckSpec, events: SandboxEvent[]): boolean { + switch (spec.kind) { + case "requestMade": { + const min = spec.minCount ?? 1; + const count = events.filter( + (e) => + e.type === "request" && + matchesMethod(e.method, spec.method) && + matchesUrl(e.url, spec.urlIncludes) + ).length; + return count >= min; + } + case "responseStatus": + return events.some( + (e) => + e.type === "response" && + e.status === spec.status && + matchesMethod(e.method, spec.method) && + matchesUrl(e.url, spec.urlIncludes) + ); + case "consoleIncludes": { + const needle = spec.pattern.toLowerCase(); + return events.some( + (e) => e.type === "console" && e.text.toLowerCase().includes(needle) + ); + } + case "consoleCount": + return events.filter((e) => e.type === "console").length >= spec.min; + case "noUncaughtError": + return !events.some((e) => e.type === "uncaught"); + } +} + +function matchesMethod(method: string, wanted?: string): boolean { + return !wanted || method.toUpperCase() === wanted.toUpperCase(); +} + +function matchesUrl(url: string, includes?: string): boolean { + return !includes || url.toLowerCase().includes(includes.toLowerCase()); +} diff --git a/client/src/challenges/index.ts b/client/src/challenges/index.ts new file mode 100644 index 0000000..ec11d5f --- /dev/null +++ b/client/src/challenges/index.ts @@ -0,0 +1,13 @@ +import type { Track } from "./types"; +import { restBasics } from "./rest-basics"; + +/** All available tracks, in display order. Adding a track = adding a data file + * and listing it here (same spirit as contributing a dataset). */ +export const TRACKS: Track[] = [restBasics]; + +export const findTrack = (id: string): Track | undefined => + TRACKS.find((t) => t.id === id); + +/** Tracks that run against a given API — drives the details-page banner. */ +export const tracksForApi = (apiLink: string): Track[] => + TRACKS.filter((t) => t.apiLink === apiLink); diff --git a/client/src/challenges/playgroundBridge.ts b/client/src/challenges/playgroundBridge.ts new file mode 100644 index 0000000..dac44be --- /dev/null +++ b/client/src/challenges/playgroundBridge.ts @@ -0,0 +1,38 @@ +import type { SandboxEvent } from "./types"; +import type { PlaygroundRunEvent } from "../components/Playground/types"; + +const stringifyValue = (v: unknown): string => { + if (typeof v === "string") return v; + try { + return JSON.stringify(v) ?? String(v); + } catch { + return String(v); + } +}; + +/** Map a Playground run event to the evaluator's normalized shape. */ +export const mapPlaygroundEvent = (ev: PlaygroundRunEvent): SandboxEvent | null => { + switch (ev.type) { + case "console": + return { + type: "console", + level: ev.level, + text: ev.values.map(stringifyValue).join(" "), + }; + case "net": + if (ev.event.phase === "request") + return { type: "request", method: ev.event.method, url: ev.event.url }; + if (ev.event.phase === "response") + return { + type: "response", + method: ev.event.method, + url: ev.event.url, + status: ev.event.status ?? 0, + }; + return null; + case "uncaught": + return { type: "uncaught" }; + default: + return null; + } +}; diff --git a/client/src/challenges/progress.ts b/client/src/challenges/progress.ts new file mode 100644 index 0000000..3a22a81 --- /dev/null +++ b/client/src/challenges/progress.ts @@ -0,0 +1,70 @@ +/** Track progress persistence — localStorage only, no accounts, mirroring the + * Playground's own per-URL persistence. */ + +import type { Track } from "./types"; + +interface StoredProgress { + /** Completed challenge ids with completion timestamps (ms since epoch). */ + completed: Record; +} + +const progressKey = (trackId: string) => `sampleapis:challenges:${trackId}`; +const codeKeyPrefix = (trackId: string) => `sampleapis:challenges:code:${trackId}:`; + +export function getCompleted(trackId: string): Set { + try { + const raw = localStorage.getItem(progressKey(trackId)); + if (!raw) return new Set(); + const parsed = JSON.parse(raw) as StoredProgress; + return new Set(Object.keys(parsed.completed ?? {})); + } catch { + return new Set(); + } +} + +export function markCompleted(trackId: string, challengeId: string): void { + const stored: StoredProgress = { completed: {} }; + try { + const raw = localStorage.getItem(progressKey(trackId)); + if (raw) stored.completed = (JSON.parse(raw) as StoredProgress).completed ?? {}; + } catch { + /* corrupted entry — start fresh */ + } + if (stored.completed[challengeId]) return; + stored.completed[challengeId] = Date.now(); + localStorage.setItem(progressKey(trackId), JSON.stringify(stored)); +} + +export function resetProgress(trackId: string): void { + localStorage.removeItem(progressKey(trackId)); + const prefix = codeKeyPrefix(trackId); + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key?.startsWith(prefix)) localStorage.removeItem(key); + } +} + +export interface TrackStats { + done: number; + total: number; + firstIncomplete: number; +} + +export function getTrackStats(track: Track, completed: Set): TrackStats { + const total = track.challenges.length; + const done = track.challenges.filter((c) => completed.has(c.id)).length; + const idx = track.challenges.findIndex((c) => !completed.has(c.id)); + const firstIncomplete = idx === -1 ? 1 : idx + 1; + return { done, total, firstIncomplete }; +} + +export function getCtaLabel(stats: TrackStats): string { + if (stats.done === 0) return "Start"; + if (stats.done === stats.total) return "Start over"; + return "Continue"; +} + +export function getResumeStep(stats: TrackStats): number { + if (stats.done === stats.total) return 1; + return stats.firstIncomplete; +} diff --git a/client/src/challenges/resolveChallenge.ts b/client/src/challenges/resolveChallenge.ts new file mode 100644 index 0000000..fdef8ce --- /dev/null +++ b/client/src/challenges/resolveChallenge.ts @@ -0,0 +1,29 @@ +import { findTrack } from "./index"; +import type { Challenge, Track } from "./types"; + +export interface ResolvedChallenge { + track: Track; + challenge: Challenge; + stepNum: number; + totalSteps: number; +} + +export const resolveChallenge = ( + trackId: string, + step: string +): ResolvedChallenge | undefined => { + const track = findTrack(trackId); + const stepNum = Number(step); + const validStep = + !!track && + Number.isInteger(stepNum) && + stepNum >= 1 && + stepNum <= track.challenges.length; + if (!track || !validStep) return undefined; + return { + track, + challenge: track.challenges[stepNum - 1], + stepNum, + totalSteps: track.challenges.length, + }; +}; diff --git a/client/src/challenges/rest-basics.ts b/client/src/challenges/rest-basics.ts new file mode 100644 index 0000000..f4bf293 --- /dev/null +++ b/client/src/challenges/rest-basics.ts @@ -0,0 +1,297 @@ +import type { Track } from "./types"; + +/** + * The pilot track: REST basics as a concept sequence (GET → read the response → + * query filter → handle a 404 → POST → verify the write), running on Futurama + * (decision D3). Content is written to teach; failMessages guide, never scold. + */ +export const restBasics: Track = { + id: "rest-basics", + title: "REST basics", + description: + "Learn the request/response loop every API works on: fetch a collection, read into the JSON, filter with query params, survive a 404, and write data back with POST.", + apiLink: "futurama", + endpoint: "characters", + challenges: [ + { + id: "fetch-all", + title: "Fetch all the characters", + prompt: + "Use fetch() to GET the characters collection and log what comes back.", + starter: (url) => `// 1 of 6 — Fetch all the characters, then Run (⌘/Ctrl + Enter). +// The endpoint: ${url} + +`, + checks: [ + { + kind: "requestMade", + method: "GET", + urlIncludes: "/futurama/characters", + label: "A GET request hit the characters endpoint", + failMessage: + "No GET request to the characters endpoint was seen — call fetch() with the endpoint URL from the comment.", + }, + { + kind: "responseStatus", + status: 200, + urlIncludes: "/futurama/characters", + label: "The API answered 200 OK", + failMessage: + "The request didn't come back 200 — open the Network tab and check the URL you actually requested.", + }, + { + kind: "consoleCount", + min: 1, + label: "Something was logged", + failMessage: + "Nothing hit the console — log the parsed data with console.log().", + }, + ], + hints: [ + "fetch(url) returns a promise — await it to get a Response object.", + "The Response body needs parsing: await resp.json() gives you the actual data.", + ], + solution: (url) => `const resp = await fetch("${url}"); +const data = await resp.json(); +console.log(data);`, + }, + { + id: "read-response", + title: "Read into the response", + prompt: + "Fetch the characters again, but this time log only the FIRST character's first name.", + starter: (url) => `// 2 of 6 — Log the first character's first name (just the name, not the whole object). +const resp = await fetch("${url}"); +const data = await resp.json(); + +`, + checks: [ + { + kind: "responseStatus", + status: 200, + urlIncludes: "/futurama/characters", + label: "The characters were fetched", + failMessage: + "The collection GET didn't succeed — keep the fetch from the starter code.", + }, + { + kind: "consoleIncludes", + pattern: "Philip", + label: "The first character's first name was logged", + failMessage: + "Expected to see the first character's first name in the output — it's nested: data[0], then one level deeper.", + }, + { + kind: "noUncaughtError", + label: "No uncaught errors", + failMessage: + "The run threw — check for a typo in the property path (undefined in the middle of a chain throws).", + }, + ], + hints: [ + "The response is an array — data[0] is the first character.", + "Names are nested objects here: data[0].name has first, middle, and last.", + ], + solution: (url) => `const resp = await fetch("${url}"); +const data = await resp.json(); +console.log(data[0].name.first);`, + }, + { + id: "query-filter", + title: "Filter with a query param", + prompt: + "Get ONLY Bender — but let the server do the filtering with a query parameter instead of filtering the array in JavaScript.", + note: "Server-side filtering is how real APIs avoid shipping you data you'll throw away.", + starter: (url) => `// 3 of 6 — Add a query parameter so the server returns only Bender. +// Equality filters look like: ?field=value (dot paths reach nested fields) +const resp = await fetch("${url}"); +const data = await resp.json(); +console.log(data);`, + checks: [ + { + kind: "requestMade", + method: "GET", + urlIncludes: "name.first=Bender", + label: "The request used a name.first query param", + failMessage: + "The request URL didn't include ?name.first=Bender — check the query-string spelling (dot path, capital B).", + }, + { + kind: "responseStatus", + status: 200, + urlIncludes: "name.first", + label: "The filtered request succeeded", + failMessage: + "The filtered GET didn't come back 200 — inspect the final URL in the Network tab.", + }, + { + kind: "consoleIncludes", + pattern: "bender", + label: "Bender showed up in the output", + failMessage: + "Bender wasn't in the console output — log the data the filtered request returned.", + }, + ], + hints: [ + "Query params go after a ? at the end of the URL: ?field=value.", + "Nested fields use dot paths — the field you want is name.first.", + ], + solution: (url) => `const resp = await fetch("${url}?name.first=Bender"); +const data = await resp.json(); +console.log(data);`, + }, + { + id: "handle-404", + title: "Handle the 404", + prompt: + "Request a character that doesn't exist (try id 9999) and log a friendly message instead of letting an error escape.", + note: "fetch() does NOT throw on a 404 — a missing resource is still a successful HTTP exchange. You have to check the status yourself.", + starter: (url) => `// 4 of 6 — Single items live at /characters/. Request id 9999, +// then check the response status and log a friendly message if it's missing. +const resp = await fetch("${url}/9999"); + +`, + checks: [ + { + kind: "responseStatus", + status: 404, + urlIncludes: "9999", + label: "The API answered 404 for the missing id", + failMessage: + "No 404 response was seen for id 9999 — keep the /9999 on the end of the URL.", + }, + { + kind: "consoleCount", + min: 1, + label: "A message was logged", + failMessage: + "Nothing was logged — branch on the status and console.log a message for the missing-character case.", + }, + { + kind: "noUncaughtError", + label: "No uncaught errors", + failMessage: + "The run threw — a 404 body may not be the shape your code expects; check the status BEFORE reading into the data.", + }, + ], + hints: [ + "resp.ok is true only for 2xx statuses; resp.status gives you the exact code.", + "if (!resp.ok) { console.log(...) } else { ...parse and use the data }", + ], + solution: (url) => `const resp = await fetch("${url}/9999"); +if (!resp.ok) { + console.log("No character with that id (status " + resp.status + ")"); +} else { + console.log(await resp.json()); +}`, + }, + { + id: "create-character", + title: "Create a character with POST", + prompt: + "POST a new character to the collection and log the record the API echoes back.", + note: "Writes are safe here — the data resets on a schedule, so your character vanishing later is by design.", + starter: (url) => `// 5 of 6 — POST a new character. Give it at least a name. +// A write needs three things: the method, a Content-Type header, and a JSON body. +const resp = await fetch("${url}", { + +}); +`, + checks: [ + { + kind: "requestMade", + method: "POST", + urlIncludes: "/futurama/characters", + label: "A POST hit the characters endpoint", + failMessage: + "No POST request was seen — set method: \"POST\" in fetch's second argument.", + }, + { + kind: "responseStatus", + status: 201, + method: "POST", + label: "The API answered 201 Created", + failMessage: + "The POST didn't come back 201 — make sure the body is JSON.stringify'd and the Content-Type header is application/json.", + }, + { + kind: "consoleCount", + min: 1, + label: "The created record was logged", + failMessage: + "Nothing was logged — the API echoes the created record back; parse and log it to see the id it was given.", + }, + ], + hints: [ + 'fetch\'s second argument is an options object: { method: "POST", headers: {...}, body: ... }.', + 'The body must be a string — JSON.stringify your object — and headers needs "Content-Type": "application/json".', + ], + solution: (url) => `const resp = await fetch("${url}", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: { first: "Lrrr", last: "of Omicron Persei 8" } }), +}); +console.log("Status:", resp.status); +console.log(await resp.json());`, + }, + { + id: "verify-write", + title: "Verify your write", + prompt: + "POST another character, then GET the collection again and log the last item — proof your write really landed.", + note: "Never trust a write you didn't read back. (Assert on YOUR new record, not on the collection's size — other learners write here too.)", + starter: (url) => `// 6 of 6 — Two requests in sequence: POST a character, then GET the +// collection and log the last item. +// The endpoint: ${url} + +`, + checks: [ + { + kind: "responseStatus", + status: 201, + method: "POST", + urlIncludes: "/futurama/characters", + label: "The POST created a record (201)", + failMessage: + "No successful POST was seen — challenge 5's shape works here unchanged.", + }, + { + kind: "requestMade", + method: "GET", + urlIncludes: "/futurama/characters", + label: "The collection was fetched again", + failMessage: + "No GET followed the write — fetch the collection a second time, after the POST resolves.", + }, + { + kind: "responseStatus", + status: 200, + method: "GET", + urlIncludes: "/futurama/characters", + label: "The verification GET succeeded", + failMessage: + "The follow-up GET didn't come back 200 — check the Network tab for what was actually requested.", + }, + { + kind: "noUncaughtError", + label: "No uncaught errors", + failMessage: + "The run threw — await the POST before starting the GET so the two don't race.", + }, + ], + hints: [ + "Two awaited fetches, one after the other — the await on the first guarantees the order.", + "New records are appended, so data[data.length - 1] is your character.", + ], + solution: (url) => `await fetch("${url}", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: { first: "Morbo", last: "the Annihilator" } }), +}); + +const resp = await fetch("${url}"); +const data = await resp.json(); +console.log(data[data.length - 1]);`, + }, + ], +}; diff --git a/client/src/challenges/types.ts b/client/src/challenges/types.ts new file mode 100644 index 0000000..2b4bc5e --- /dev/null +++ b/client/src/challenges/types.ts @@ -0,0 +1,83 @@ +/** + * Challenge track schema. Tracks are DATA, not code: prompts, starter templates, + * declarative checks, hints. The check vocabulary is the tagged union below — + * contributions add content against it, never executable check logic + * (decision D1 in docs/future-features/plans/guided-challenges-decisions.md). + */ + +export interface Track { + id: string; + title: string; + description: string; + /** API the track runs against, as it appears in the endpoint URL (e.g. "futurama"). */ + apiLink: string; + /** Collection endpoint within the API (e.g. "characters"). */ + endpoint: string; + challenges: Challenge[]; +} + +export interface Challenge { + id: string; + title: string; + /** Short instruction shown above the editor. Plain text. */ + prompt: string; + /** Extra context under the prompt — why this matters. Optional. */ + note?: string; + /** Starter code template. Receives the track's endpoint URL. */ + starter: (url: string) => string; + checks: CheckSpec[]; + /** Revealed one at a time; the solution unlocks after all of them (D4). */ + hints: string[]; + /** A worked example — "one way to do it", not the only accepted answer. */ + solution: (url: string) => string; +} + +interface BaseCheck { + /** What the learner sees next to the pass/fail mark. */ + label: string; + /** Shown when the check fails — written as guidance, not as an error. */ + failMessage: string; +} + +export type CheckSpec = + | (BaseCheck & { + kind: "requestMade"; + method?: string; + /** Substring the request URL must contain (case-insensitive). */ + urlIncludes?: string; + minCount?: number; + }) + | (BaseCheck & { + kind: "responseStatus"; + status: number; + method?: string; + urlIncludes?: string; + }) + | (BaseCheck & { + kind: "consoleIncludes"; + /** Substring some console line must contain (case-insensitive). */ + pattern: string; + }) + | (BaseCheck & { kind: "consoleCount"; min: number }) + | (BaseCheck & { kind: "noUncaughtError" }); + +/** Normalized view of what a run emitted — what the evaluator consumes. */ +export type SandboxEvent = + | { type: "console"; level: string; text: string } + | { + type: "request"; + method: string; + url: string; + } + | { + type: "response"; + method: string; + url: string; + status: number; + } + | { type: "uncaught" }; + +export interface CheckResult { + spec: CheckSpec; + pass: boolean; +} diff --git a/client/src/components/APICard/APICard.tsx b/client/src/components/APICard/APICard.tsx index fe8c4fe..f52eb13 100644 --- a/client/src/components/APICard/APICard.tsx +++ b/client/src/components/APICard/APICard.tsx @@ -1,18 +1,23 @@ import { faLink, faStar } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React from "react"; import { Link } from "@tanstack/react-router"; import { APIData } from "../../utils/Interfaces"; import APICategories from "../APICategories/APICategories"; +const featuredCardClassName = (featured: boolean): string => { + if (featured) return "api-card -featured"; + return "api-card"; +}; + interface Props { api: APIData; - featured?: boolean; } -const APICard: React.FC = ({ api, featured = false }) => { +const APICard = ({ api }: Props) => { + const featured = api.metaData.featured; + return ( -
+
diff --git a/client/src/components/Endpoints/Endpoints.tsx b/client/src/components/Endpoints/Endpoints.tsx index de3d9ac..112d327 100644 --- a/client/src/components/Endpoints/Endpoints.tsx +++ b/client/src/components/Endpoints/Endpoints.tsx @@ -1,25 +1,17 @@ -import React, { Dispatch, SetStateAction, useState } from "react"; +import { endpointClassName } from "./endpointClassName"; interface Props { endpoints: string[]; - onEndpointSelect: Dispatch>; + selected: string; + onSelect: (endpoint: string) => void; } -const APIEndpoints: React.FC = ({ endpoints, onEndpointSelect }) => { - // Default to the first endpoint so the initial selection matches what the - // parent (APIDetails) pre-selects for the Playground. - const [selected, setSelected] = useState(endpoints[0] ?? ""); - - const selectEndpoint = (endpoint: string) => { - setSelected(endpoint); - onEndpointSelect(endpoint); - }; - +const APIEndpoints = ({ endpoints, selected, onSelect }: Props) => { return (
    {endpoints.map((endpoint) => ( -
  • -
  • diff --git a/client/src/components/Endpoints/endpointClassName.ts b/client/src/components/Endpoints/endpointClassName.ts new file mode 100644 index 0000000..f2620b6 --- /dev/null +++ b/client/src/components/Endpoints/endpointClassName.ts @@ -0,0 +1,2 @@ +export const endpointClassName = (selected: boolean): string => + selected ? "api-endpoint -selected" : "api-endpoint"; diff --git a/client/src/components/Explore/Explore.css b/client/src/components/Explore/Explore.css new file mode 100644 index 0000000..c0c9dcf --- /dev/null +++ b/client/src/components/Explore/Explore.css @@ -0,0 +1,299 @@ +.explore { + border: 1px solid var(--color-bg-dark); + margin-bottom: 1.5rem; +} + +.explore__toggle { + display: flex; + align-items: baseline; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + color: var(--color-text-light); + text-align: left; + cursor: pointer; + + &:hover .explore__title, + &:focus-visible .explore__title { + color: var(--color-primary-base); + } +} + +.explore__caret { + color: var(--color-primary-base); + flex-shrink: 0; +} + +.explore__title { + font-size: var(--font-size-text-3); + font-weight: 700; +} + +.explore__teaser { + color: var(--color-text-base); + font-size: var(--font-size-text-4); +} + +.explore__body { + border-top: 1px solid var(--color-bg-dark); + padding: 1rem; + display: grid; + /* minmax(0, …) lets wide children (the nowrap URL bar) shrink and scroll + instead of stretching the panel past its container. */ + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.explore__tabs { + display: flex; + gap: 0.75rem; +} + +.explore__tab { + background: transparent; + border: 1px solid var(--color-bg-dark); + color: var(--color-text-base); + padding: 0.4rem 1rem; + font-size: var(--font-size-text-4); + cursor: pointer; + + &:hover, + &:focus-visible { + color: var(--color-text-light); + outline: none; + } + + &.-active { + color: var(--color-primary-base); + border-color: var(--color-primary-base); + } +} + +.explore__status { + color: var(--color-text-base); + font-size: var(--font-size-text-4); +} + +.explore__hint { + color: var(--color-text-base); + font-size: var(--font-size-text-4); +} + +/* --- Shape tab --- */ + +.shape-viewer { + display: grid; + gap: 0.75rem; +} + +.shape-viewer__meta { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.shape-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-text-4); + + & th { + text-align: left; + color: var(--color-text-base); + font-weight: 400; + padding: 0.4rem 0.75rem 0.4rem 0; + border-bottom: 1px solid var(--color-bg-dark); + } + + & td { + padding: 0.4rem 0.75rem 0.4rem 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-bg-dark) 40%, transparent); + vertical-align: baseline; + } +} + +.shape-row__field { + font-family: var(--font-family-mono, monospace); + color: var(--color-primary-base); + white-space: nowrap; +} + +.shape-row__toggle { + background: transparent; + border: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 0; +} + +.shape-row__caret { + display: inline-block; + width: 1rem; + color: var(--color-text-base); +} + +.shape-row__name { + padding-left: 1rem; +} + +.shape-row__types { + color: var(--color-secondary-base); + font-family: var(--font-family-mono, monospace); +} + +.shape-row__coverage { + color: var(--color-text-base); + white-space: nowrap; +} + +.shape-row__coverage-bar { + display: inline-block; + width: 3rem; + height: 0.4rem; + margin-right: 0.5rem; + background: var(--color-bg-dark); + vertical-align: middle; +} + +.shape-row__coverage-fill { + display: block; + height: 100%; + background: var(--color-primary-base); +} + +.shape-row__example { + color: var(--color-text-base); + font-family: var(--font-family-mono, monospace); + overflow-wrap: anywhere; +} + +/* --- Query tab --- */ + +.qb { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.qb__controls { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.qb__group { + border: 1px solid var(--color-bg-dark); + padding: 0.75rem 1rem 1rem; + display: grid; + gap: 0.75rem; + justify-items: start; + + & legend { + color: var(--color-text-base); + font-size: var(--font-size-text-4); + padding: 0 0.5rem; + } +} + +.qb-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.qb-row__control { + background: var(--color-bg-darkest); + border: 1px solid var(--color-bg-dark); + color: var(--color-text-light); + padding: 0.45rem 0.5rem; + font-size: var(--font-size-text-4); + + &:focus-visible { + outline: none; + border-color: var(--color-primary-base); + } + + &.-narrow { + width: 5rem; + } +} + +.qb-row__label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--color-text-base); + font-size: var(--font-size-text-4); + + &.-grow { + width: 100%; + + & .qb-row__control { + flex: 1; + } + } +} + +.qb-row__remove { + background: transparent; + border: 1px solid var(--color-bg-dark); + color: var(--color-text-base); + width: 2rem; + height: 2rem; + cursor: pointer; + + &:hover, + &:focus-visible { + color: var(--color-text-light); + border-color: var(--color-text-base); + outline: none; + } +} + +.qb-url { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.qb-url__text { + flex: 1; + /* A long URL scrolls inside the bar instead of widening the page. */ + min-width: 0; + background: var(--color-bg-darkest); + border: 1px solid var(--color-bg-dark); + color: var(--color-secondary-base); + padding: 0.6rem 0.75rem; + font-size: var(--font-size-text-4); + overflow-x: auto; + white-space: nowrap; +} + +.qb__actions { + display: flex; + justify-content: flex-end; +} + +.qb-preview__summary { + color: var(--color-text-base); + font-size: var(--font-size-text-4); + margin-bottom: 0.5rem; +} + +.qb-preview__spinner { + color: var(--color-primary-base); +} + +.qb-preview__tree { + max-height: 320px; + overflow: auto; + border: 1px solid var(--color-bg-dark); + padding: 0.75rem; + font-family: var(--font-family-mono, monospace); +} diff --git a/client/src/components/Explore/Explore.tsx b/client/src/components/Explore/Explore.tsx new file mode 100644 index 0000000..6e3c8d7 --- /dev/null +++ b/client/src/components/Explore/Explore.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import ShapeViewer from "./ShapeViewer"; +import QueryBuilder from "./QueryBuilder"; +import { exploreCaret, exploreTabClassName } from "./labels"; +import "./Explore.css"; + +type ExploreTab = "shape" | "query"; + +interface Props { + /** Active endpoint URL — identity for both tabs' data. */ + url: string; + /** Endpoint name; names the copied TypeScript interface. */ + endpoint: string; + /** Loads a fetch snippet for a composed URL into the Playground. */ + onSendToPlayground: (fullUrl: string) => void; +} + +/** + * "Explore this endpoint" — collapsible panel between the endpoint list and + * the Playground. Shape and Query share one sample fetch (per-URL cache), + * and nothing is requested until the panel is opened. + */ +const Explore: React.FC = ({ url, endpoint, onSendToPlayground }) => { + const [open, setOpen] = useState(false); + const [tab, setTab] = useState("shape"); + + return ( +
    + + + {open && ( +
    +
    + + +
    + {tab === "shape" && } + {tab === "query" && } +
    + )} +
    + ); +}; + +export default Explore; diff --git a/client/src/components/Explore/FilterRow.tsx b/client/src/components/Explore/FilterRow.tsx new file mode 100644 index 0000000..36de0af --- /dev/null +++ b/client/src/components/Explore/FilterRow.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { OP_LABELS, type FilterClause, type FilterOp, type QueryField } from "./queryState"; + +interface Props { + clause: FilterClause; + fields: QueryField[]; + onChange: (clause: FilterClause) => void; + onRemove: () => void; +} + +const valueInputType = (clause: FilterClause, fields: QueryField[]): string => { + const field = fields.find((f) => f.path === clause.field); + const numericOp = clause.op !== "like"; + return field?.isNumeric && numericOp ? "number" : "text"; +}; + +/** One filter clause: field × operator × value. */ +const FilterRow: React.FC = ({ clause, fields, onChange, onRemove }) => ( +
    + + + onChange({ ...clause, value: e.target.value })} + /> + +
    +); + +export default FilterRow; diff --git a/client/src/components/Explore/PaginationControls.tsx b/client/src/components/Explore/PaginationControls.tsx new file mode 100644 index 0000000..4506732 --- /dev/null +++ b/client/src/components/Explore/PaginationControls.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +interface Props { + page: string; + limit: string; + onPageChange: (page: string) => void; + onLimitChange: (limit: string) => void; +} + +/** `_page` / `_limit` inputs; blank means the param stays out of the URL. */ +const PaginationControls: React.FC = ({ page, limit, onPageChange, onLimitChange }) => ( +
    + + +
    +); + +export default PaginationControls; diff --git a/client/src/components/Explore/QueryBuilder.tsx b/client/src/components/Explore/QueryBuilder.tsx new file mode 100644 index 0000000..f9da045 --- /dev/null +++ b/client/src/components/Explore/QueryBuilder.tsx @@ -0,0 +1,108 @@ +import React, { useMemo, useState } from "react"; +import { deriveShape } from "../../utils/deriveShape"; +import { useSampleRecords } from "../../hooks/useSampleRecords"; +import FilterRow from "./FilterRow"; +import SortControl from "./SortControl"; +import PaginationControls from "./PaginationControls"; +import UrlBar from "./UrlBar"; +import QueryPreview from "./QueryPreview"; +import { + EMPTY_QUERY, + buildQueryString, + queryableFields, + type FilterClause, + type QueryState, +} from "./queryState"; + +interface Props { + /** Active endpoint URL. The host remounts this component per endpoint (key). */ + url: string; + /** Loads a fetch snippet for the composed URL into the Playground. */ + onSendToPlayground: (fullUrl: string) => void; +} + +const NEW_FILTER: FilterClause = { field: "", op: "eq", value: "" }; + +/** The Query tab: compose filter/sort/pagination visually, watch the URL follow. */ +const QueryBuilder: React.FC = ({ url, onSendToPlayground }) => { + const { data: sample, isLoading, isError } = useSampleRecords(url, true); + const fields = useMemo(() => queryableFields(deriveShape(sample?.records ?? [])), [sample]); + const [state, setState] = useState(EMPTY_QUERY); + + if (isLoading) return

    Sampling endpoint…

    ; + if (isError || !sample) return

    Could not sample this endpoint.

    ; + if (sample.isSingular) + return ( +

    + This endpoint returns a single object — filtering, sorting, and pagination apply to + collections. +

    + ); + + const fullUrl = url + buildQueryString(state); + + const updateFilter = (index: number, clause: FilterClause) => + setState((s) => ({ ...s, filters: s.filters.map((f, i) => (i === index ? clause : f)) })); + const addFilter = () => setState((s) => ({ ...s, filters: [...s.filters, NEW_FILTER] })); + const removeFilter = (index: number) => + setState((s) => ({ ...s, filters: s.filters.filter((_, i) => i !== index) })); + + return ( +
    +
    +
    + Filter + {state.filters.map((clause, i) => ( + updateFilter(i, next)} + onRemove={() => removeFilter(i)} + /> + ))} + + +
    +
    + Sort + setState((s) => ({ ...s, sort }))} + /> +
    +
    + Paginate + setState((s) => ({ ...s, page }))} + onLimitChange={(limit) => setState((s) => ({ ...s, limit }))} + /> +
    +
    + + +
    + +
    + +
    + ); +}; + +export default QueryBuilder; diff --git a/client/src/components/Explore/QueryPreview.tsx b/client/src/components/Explore/QueryPreview.tsx new file mode 100644 index 0000000..057a0e0 --- /dev/null +++ b/client/src/components/Explore/QueryPreview.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import JsonTree from "../Playground/JsonTree"; +import { useQueryPreview } from "../../hooks/useQueryPreview"; +import { resultSummary } from "./labels"; + +interface Props { + /** The composed URL to preview; fetching is debounced inside the hook. */ + url: string; +} + +/** Live results for the composed query, with the X-Total-Count summary line. */ +const QueryPreview: React.FC = ({ url }) => { + const { data: preview, isPending, isFetching, isError, error } = useQueryPreview(url, true); + + if (isPending) return

    Loading results…

    ; + if (isError) return

    {error.message}

    ; + + return ( +
    +

    + {resultSummary(preview.data, preview.total)} + {isFetching && · updating…} +

    +
    + +
    +
    + ); +}; + +export default QueryPreview; diff --git a/client/src/components/Explore/ShapeRow.tsx b/client/src/components/Explore/ShapeRow.tsx new file mode 100644 index 0000000..07403ef --- /dev/null +++ b/client/src/components/Explore/ShapeRow.tsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import type { ShapeNode } from "../../utils/deriveShape"; +import { coverageLabel, coveragePercent, exploreCaret, formatExample, formatTypes } from "./labels"; + +interface Props { + node: ShapeNode; + depth: number; + /** Hidden for singular resources, where a 1/1 denominator teaches nothing. */ + showCoverage: boolean; +} + +/** One field of the shape table; nested fields render as collapsible child rows. */ +const ShapeRow: React.FC = ({ node, depth, showCoverage }) => { + const [open, setOpen] = useState(true); + const hasChildren = node.children.length > 0; + + return ( + <> + + + {hasChildren ? ( + + ) : ( + {node.key} + )} + + {formatTypes(node)} + {showCoverage && ( + + + + + {coverageLabel(node)} + + )} + {formatExample(node.example)} + + {open && + node.children.map((child) => ( + + ))} + + ); +}; + +export default ShapeRow; diff --git a/client/src/components/Explore/ShapeViewer.tsx b/client/src/components/Explore/ShapeViewer.tsx new file mode 100644 index 0000000..b6111eb --- /dev/null +++ b/client/src/components/Explore/ShapeViewer.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from "react"; +import { deriveShape } from "../../utils/deriveShape"; +import { toTypeScript } from "../../utils/toTypeScript"; +import { useSampleRecords } from "../../hooks/useSampleRecords"; +import ShapeRow from "./ShapeRow"; +import { copyButtonLabel, sampleLabel } from "./labels"; +import { useCopied } from "./useCopied"; + +interface Props { + /** Active endpoint URL — the identity everything here derives from. */ + url: string; + /** Endpoint name; names the copied TypeScript interface. */ + endpoint: string; +} + +/** The Shape tab: what fields this endpoint's records have, honestly sampled. */ +const ShapeViewer: React.FC = ({ url, endpoint }) => { + const { data: sample, isLoading, isError } = useSampleRecords(url, true); + const shape = useMemo(() => deriveShape(sample?.records ?? []), [sample]); + const { copied, copy } = useCopied(); + + if (isLoading) return

    Sampling endpoint…

    ; + if (isError || !sample) return

    Could not sample this endpoint.

    ; + if (shape.length === 0) + return

    No fields to describe — this endpoint returns primitives.

    ; + + const showCoverage = !sample.isSingular; + + return ( +
    +
    + {sampleLabel(sample.records.length, sample.isSingular)} + +
    + + + + + + {showCoverage && } + + + + + {shape.map((node) => ( + + ))} + +
    FieldTypeCoverageExample
    +
    + ); +}; + +export default ShapeViewer; diff --git a/client/src/components/Explore/SortControl.tsx b/client/src/components/Explore/SortControl.tsx new file mode 100644 index 0000000..3edb161 --- /dev/null +++ b/client/src/components/Explore/SortControl.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import type { QueryField, QueryState, SortDir } from "./queryState"; + +interface Props { + sort: QueryState["sort"]; + fields: QueryField[]; + onChange: (sort: QueryState["sort"]) => void; +} + +/** Single-field sort: `_sort` + `_order` (Query Builder D2 keeps multi-sort out). */ +const SortControl: React.FC = ({ sort, fields, onChange }) => { + const selectField = (field: string) => + onChange(field === "" ? null : { field, dir: sort?.dir ?? "asc" }); + + const selectDir = (dir: SortDir) => { + if (sort) onChange({ ...sort, dir }); + }; + + return ( +
    + + +
    + ); +}; + +export default SortControl; diff --git a/client/src/components/Explore/UrlBar.tsx b/client/src/components/Explore/UrlBar.tsx new file mode 100644 index 0000000..ade3ffd --- /dev/null +++ b/client/src/components/Explore/UrlBar.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { copyButtonLabel } from "./labels"; +import { useCopied } from "./useCopied"; + +interface Props { + fullUrl: string; +} + +/** The live composed URL — the "what I clicked is what I'd type" lesson. */ +const UrlBar: React.FC = ({ fullUrl }) => { + const { copied, copy } = useCopied(); + + return ( +
    + {fullUrl} + +
    + ); +}; + +export default UrlBar; diff --git a/client/src/components/Explore/labels.ts b/client/src/components/Explore/labels.ts new file mode 100644 index 0000000..6f88198 --- /dev/null +++ b/client/src/components/Explore/labels.ts @@ -0,0 +1,39 @@ +import type { ShapeNode } from "../../utils/deriveShape"; + +/** Named display decisions for the Explore panel — no unnamed branches in markup. */ + +export const exploreCaret = (open: boolean): string => (open ? "▾" : "▸"); + +export const exploreTabClassName = (active: boolean): string => + active ? "explore__tab -active" : "explore__tab"; + +export const sampleLabel = (count: number, isSingular: boolean): string => { + if (isSingular) return "Based on this endpoint's single object"; + return count === 1 ? "Based on the only record" : `Based on the first ${count} records`; +}; + +export const copyButtonLabel = (copied: boolean, label: string): string => + copied ? "Copied!" : label; + +export const formatTypes = (node: ShapeNode): string => node.types.join(" | "); + +const EXAMPLE_MAX_CHARS = 40; + +export const formatExample = (example: unknown): string => { + const text = typeof example === "string" ? `"${example}"` : JSON.stringify(example) ?? ""; + return text.length > EXAMPLE_MAX_CHARS ? `${text.slice(0, EXAMPLE_MAX_CHARS)}…` : text; +}; + +export const coverageLabel = (node: ShapeNode): string => `${node.count}/${node.total}`; + +export const coveragePercent = (node: ShapeNode): number => + node.total === 0 ? 0 : Math.round((node.count / node.total) * 100); + +const recordCount = (n: number): string => (n === 1 ? "1 record" : `${n} records`); + +/** "Showing 10 of 42 records" — the X-Total-Count lesson in one line. */ +export const resultSummary = (data: unknown, total: number | null): string => { + if (!Array.isArray(data)) return "1 object"; + if (total === null || total === data.length) return recordCount(data.length); + return `Showing ${data.length} of ${total} records`; +}; diff --git a/client/src/components/Explore/queryState.test.ts b/client/src/components/Explore/queryState.test.ts new file mode 100644 index 0000000..7fa9edc --- /dev/null +++ b/client/src/components/Explore/queryState.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { deriveShape } from "../../utils/deriveShape"; +import { + EMPTY_QUERY, + buildQueryString, + hasAnyQuery, + queryableFields, + type QueryState, +} from "./queryState"; + +const query = (overrides: Partial): QueryState => ({ + ...EMPTY_QUERY, + ...overrides, +}); + +describe("buildQueryString", () => { + it("returns an empty string for the empty query", () => { + expect(buildQueryString(EMPTY_QUERY)).toBe(""); + expect(hasAnyQuery(EMPTY_QUERY)).toBe(false); + }); + + it("renders equality filters as plain field params, including dot paths", () => { + const state = query({ filters: [{ field: "name.first", op: "eq", value: "Bender" }] }); + expect(buildQueryString(state)).toBe("?name.first=Bender"); + }); + + it("renders operator filters with the _op suffix", () => { + const state = query({ + filters: [ + { field: "age", op: "gte", value: "21" }, + { field: "age", op: "lte", value: "65" }, + { field: "status", op: "ne", value: "retired" }, + { field: "name", op: "like", value: "bend" }, + ], + }); + expect(buildQueryString(state)).toBe( + "?age_gte=21&age_lte=65&status_ne=retired&name_like=bend" + ); + }); + + it("keeps repeated equality filters on the same field (OR match)", () => { + const state = query({ + filters: [ + { field: "species", op: "eq", value: "robot" }, + { field: "species", op: "eq", value: "human" }, + ], + }); + expect(buildQueryString(state)).toBe("?species=robot&species=human"); + }); + + it("skips incomplete filter rows instead of emitting broken params", () => { + const state = query({ + filters: [ + { field: "", op: "eq", value: "orphan" }, + { field: "name", op: "eq", value: " " }, + ], + }); + expect(buildQueryString(state)).toBe(""); + }); + + it("renders q, sort with explicit order, and pagination", () => { + const state = query({ + q: "bender", + sort: { field: "name.last", dir: "desc" }, + page: "2", + limit: "5", + }); + expect(buildQueryString(state)).toBe( + "?q=bender&_sort=name.last&_order=desc&_page=2&_limit=5" + ); + }); + + it("url-encodes values", () => { + const state = query({ filters: [{ field: "title", op: "like", value: "a & b" }] }); + expect(buildQueryString(state)).toBe("?title_like=a+%26+b"); + }); +}); + +describe("queryableFields", () => { + it("lists primitive dot-paths ordered by coverage, excluding array interiors", () => { + const shape = deriveShape([ + { id: 1, name: { first: "Bender" }, sayings: ["hi"], crew: [{ rank: 1 }] }, + { id: 2, name: { first: "Fry", last: "Philip" } }, + ]); + const paths = queryableFields(shape).map((f) => f.path); + expect(paths).toEqual(["id", "name.first", "name.last"]); + }); + + it("flags numeric fields for input affordances", () => { + const shape = deriveShape([{ id: 7, name: "x" }]); + const fields = queryableFields(shape); + expect(fields.find((f) => f.path === "id")?.isNumeric).toBe(true); + expect(fields.find((f) => f.path === "name")?.isNumeric).toBe(false); + }); +}); diff --git a/client/src/components/Explore/queryState.ts b/client/src/components/Explore/queryState.ts new file mode 100644 index 0000000..9150944 --- /dev/null +++ b/client/src/components/Explore/queryState.ts @@ -0,0 +1,100 @@ +import type { ShapeNode } from "../../utils/deriveShape"; + +/** + * The Query Builder's state model and its one pure output: the query string. + * Mirrors the server contract in server/utils/jsonRouter.js — the builder + * must never render a control the server doesn't honor. + */ + +export type FilterOp = "eq" | "gte" | "lte" | "ne" | "like"; + +export interface FilterClause { + field: string; + op: FilterOp; + value: string; +} + +export type SortDir = "asc" | "desc"; + +export interface QueryState { + filters: FilterClause[]; + /** Full-text search across the whole record (`q`). */ + q: string; + sort: { field: string; dir: SortDir } | null; + /** Blank inputs mean "no pagination params". */ + page: string; + limit: string; +} + +export const EMPTY_QUERY: QueryState = { + filters: [], + q: "", + sort: null, + page: "", + limit: "", +}; + +export const OP_LABELS: Record = { + eq: "equals", + gte: "≥ (gte)", + lte: "≤ (lte)", + ne: "≠ (ne)", + like: "contains (like)", +}; + +const paramName = (clause: FilterClause): string => + clause.op === "eq" ? clause.field : `${clause.field}_${clause.op}`; + +const isComplete = (clause: FilterClause): boolean => + clause.field !== "" && clause.value.trim() !== ""; + +/** `?name.first=Bender&_sort=…` — empty string when nothing is set. */ +export const buildQueryString = (state: QueryState): string => { + const params = new URLSearchParams(); + for (const clause of state.filters.filter(isComplete)) { + params.append(paramName(clause), clause.value.trim()); + } + if (state.q.trim()) params.set("q", state.q.trim()); + if (state.sort) { + params.set("_sort", state.sort.field); + params.set("_order", state.sort.dir); + } + if (state.page.trim()) params.set("_page", state.page.trim()); + if (state.limit.trim()) params.set("_limit", state.limit.trim()); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +}; + +export const hasAnyQuery = (state: QueryState): boolean => buildQueryString(state) !== ""; + +/** + * Fields the builder can filter and sort on: primitive-typed paths reachable + * by dot-path from the record root. Paths inside arrays (`crew[].name`) are + * excluded — the server's dot-path lookup cannot address array elements. + */ +export interface QueryField { + path: string; + /** Drives input affordances: number fields get numeric inputs. */ + isNumeric: boolean; + coverage: number; +} + +const PRIMITIVE_TYPES = new Set(["string", "number", "boolean"]); + +const isQueryable = (node: ShapeNode): boolean => + !node.path.includes("[]") && node.types.some((t) => PRIMITIVE_TYPES.has(t)); + +const toQueryField = (node: ShapeNode): QueryField => ({ + path: node.path, + isNumeric: node.types.includes("number"), + coverage: node.total > 0 ? node.count / node.total : 0, +}); + +export const queryableFields = (shape: ShapeNode[]): QueryField[] => { + const collect = (nodes: ShapeNode[]): QueryField[] => + nodes.flatMap((node) => [ + ...(isQueryable(node) ? [toQueryField(node)] : []), + ...collect(node.children), + ]); + return collect(shape).sort((a, b) => b.coverage - a.coverage || a.path.localeCompare(b.path)); +}; diff --git a/client/src/components/Explore/useCopied.ts b/client/src/components/Explore/useCopied.ts new file mode 100644 index 0000000..caa6404 --- /dev/null +++ b/client/src/components/Explore/useCopied.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef, useState } from "react"; + +const FEEDBACK_MS = 1500; + +/** Clipboard write with a transient "Copied!" flag for button feedback. */ +export const useCopied = () => { + const [copied, setCopied] = useState(false); + const timerRef = useRef(null); + + useEffect( + () => () => { + if (timerRef.current) window.clearTimeout(timerRef.current); + }, + [] + ); + + const copy = async (text: string) => { + await navigator.clipboard.writeText(text); + setCopied(true); + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => setCopied(false), FEEDBACK_MS); + }; + + return { copied, copy }; +}; diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index e328a0b..9745030 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -1,17 +1,14 @@ -import React, { useContext } from "react"; import { Link } from "@tanstack/react-router"; -import { GlobalContext } from "../../context/GlobalContext"; +import { useNavVisibility } from "../../hooks/useNavVisibility"; import Nav from "../Nav/Nav"; -interface Props {} - -const Header: React.FC = () => { - const { navVisible, setNavVisible } = useContext(GlobalContext); +const navToggleLabel = (visible: boolean): string => { + if (visible) return "Close navigation menu"; + return "Open navigation menu"; +}; - const toggleNav = () => { - document.body.classList.toggle("-nav-visible"); - setNavVisible(!navVisible); - }; +const Header = () => { + const { navVisible, toggle } = useNavVisibility(); return (
    @@ -19,9 +16,9 @@ const Header: React.FC = () => { Sample APIs