diff --git a/client/.prettierignore b/client/.prettierignore index a96d61e932a..ba27ff090d8 100644 --- a/client/.prettierignore +++ b/client/.prettierignore @@ -1,3 +1,4 @@ node_modules/ output/ src/gql/ +src/static/query/tripQuery.tsx diff --git a/client/codegen-preprocess.ts b/client/codegen-preprocess.ts new file mode 100644 index 00000000000..ec1b1dfce0d --- /dev/null +++ b/client/codegen-preprocess.ts @@ -0,0 +1,16 @@ +import type { CodegenConfig } from '@graphql-codegen/cli'; + +import * as path from 'node:path'; + +const config: CodegenConfig = { + overwrite: true, + schema: '../application/src/main/resources/org/opentripplanner/apis/transmodel/schema.graphql', + documents: 'src/**/*.{ts,tsx}', + generates: { + 'src/static/query/tripQuery.tsx': { + plugins: [path.resolve(__dirname, './src/util/generate-queries.cjs')], + }, + }, +}; + +export default config; diff --git a/client/package-lock.json b/client/package-lock.json index 8db9604c3ef..71c57f7262a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,7 +17,8 @@ "react": "19.0.0", "react-bootstrap": "2.10.7", "react-dom": "19.0.0", - "react-map-gl": "7.1.8" + "react-map-gl": "7.1.8", + "react-select": "5.9.0" }, "devDependencies": { "@eslint/compat": "1.2.5", @@ -235,7 +236,6 @@ "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -288,7 +288,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", - "dev": true, "dependencies": { "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", @@ -366,7 +365,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -447,7 +445,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -456,7 +453,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -487,7 +483,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", - "dev": true, "dependencies": { "@babel/types": "^7.26.3" }, @@ -975,7 +970,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", @@ -989,7 +983,6 @@ "version": "7.26.4", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.3", @@ -1007,7 +1000,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, "engines": { "node": ">=4" } @@ -1016,7 +1008,6 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", - "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -1150,6 +1141,114 @@ "node": ">=18" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "node_modules/@envelop/core": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.0.2.tgz", @@ -1727,6 +1826,28 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@googlemaps/polyline-codec": { "version": "1.0.28", "resolved": "https://registry.npmjs.org/@googlemaps/polyline-codec/-/polyline-codec-1.0.28.tgz", @@ -2892,7 +3013,6 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -2906,7 +3026,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -2915,7 +3034,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -2923,14 +3041,12 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3873,6 +3989,11 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", @@ -4724,6 +4845,43 @@ "node": ">= 0.4" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-plugin-syntax-trailing-function-commas": { "version": "7.0.0-beta.0", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz", @@ -4999,7 +5157,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -5476,7 +5633,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -5736,7 +5892,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -5961,7 +6116,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -6528,6 +6682,11 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -6638,7 +6797,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7096,7 +7254,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -7114,6 +7271,19 @@ "tslib": "^2.0.3" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -7211,7 +7381,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7227,7 +7396,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -7368,8 +7536,7 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "node_modules/is-async-function": { "version": "2.0.0", @@ -7433,7 +7600,6 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "dependencies": { "hasown": "^2.0.2" }, @@ -8003,7 +8169,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -8020,8 +8185,7 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -8136,8 +8300,7 @@ "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/listr2": { "version": "4.0.5", @@ -8426,6 +8589,11 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8527,8 +8695,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/murmurhash-js": { "version": "1.0.0", @@ -8936,7 +9103,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -8962,7 +9128,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -9038,8 +9203,7 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-root": { "version": "0.1.1", @@ -9088,7 +9252,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -9124,8 +9287,7 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -9432,6 +9594,26 @@ "node": ">=0.10.0" } }, + "node_modules/react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -9557,7 +9739,6 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", @@ -10077,6 +10258,14 @@ "node": ">=0.10.0" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -10359,6 +10548,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -10383,7 +10577,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -10949,6 +11142,19 @@ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", "dev": true }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/client/package.json b/client/package.json index b896e29447b..2322dcf60af 100644 --- a/client/package.json +++ b/client/package.json @@ -14,6 +14,8 @@ "preview": "vite preview", "prebuild": "npm run codegen && npm run lint && npm run check-format", "predev": "npm run codegen", + "codegen-preprocess": "graphql-codegen --config codegen-preprocess.ts", + "precodegen": "npm run codegen-preprocess", "codegen": "graphql-codegen --config codegen.ts" }, "dependencies": { @@ -26,7 +28,8 @@ "react": "19.0.0", "react-bootstrap": "2.10.7", "react-dom": "19.0.0", - "react-map-gl": "7.1.8" + "react-map-gl": "7.1.8", + "react-select": "5.9.0" }, "devDependencies": { "@eslint/compat": "1.2.5", diff --git a/client/src/components/ItineraryList/ItineraryListContainer.tsx b/client/src/components/ItineraryList/ItineraryListContainer.tsx index b474d2eb5ec..affff253388 100644 --- a/client/src/components/ItineraryList/ItineraryListContainer.tsx +++ b/client/src/components/ItineraryList/ItineraryListContainer.tsx @@ -26,39 +26,46 @@ export function ItineraryListContainer({ const timeZone = useContext(TimeZoneContext); return ( -
- - setSelectedTripPatternIndex(parseInt(eventKey as string))} - > - {tripQueryResult && - tripQueryResult.trip.tripPatterns.map((tripPattern, itineraryIndex) => ( - - - - - - - - - ))} - +
+ <> +
Itinerary results
+
+ +
+ setSelectedTripPatternIndex(parseInt(eventKey as string))} + > + {tripQueryResult && + tripQueryResult.trip.tripPatterns.map((tripPattern, itineraryIndex) => ( + + + + + + + + + ))} + + + + {/* Time Zone Info */}
All times in {timeZone}
diff --git a/client/src/components/ItineraryList/ItineraryPaginationControl.tsx b/client/src/components/ItineraryList/ItineraryPaginationControl.tsx index dc197a2451e..2e3e335cee0 100644 --- a/client/src/components/ItineraryList/ItineraryPaginationControl.tsx +++ b/client/src/components/ItineraryList/ItineraryPaginationControl.tsx @@ -12,7 +12,7 @@ export function ItineraryPaginationControl({ loading: boolean; }) { return ( -
+
+ + {/* Sidebar */} +
+ {isSidebarOpen && activeContent === 'debugLayer' && ( + + )} +
+
+ ); + } +} + +export default RightMenu; diff --git a/client/src/components/SearchBar/DepartureArrivalSelect.tsx b/client/src/components/SearchBar/DepartureArrivalSelect.tsx index b6a92cdd495..a94516dfc3b 100644 --- a/client/src/components/SearchBar/DepartureArrivalSelect.tsx +++ b/client/src/components/SearchBar/DepartureArrivalSelect.tsx @@ -24,6 +24,7 @@ export function DepartureArrivalSelect({ size="sm" onChange={(e) => (e.target.value === 'arrival' ? onChange(true) : onChange(false))} value={tripQueryVariables.arriveBy ? 'arrival' : 'departure'} + style={{ verticalAlign: 'bottom' }} > diff --git a/client/src/components/SearchBar/InputFieldsSection.tsx b/client/src/components/SearchBar/InputFieldsSection.tsx new file mode 100644 index 00000000000..234626d0c76 --- /dev/null +++ b/client/src/components/SearchBar/InputFieldsSection.tsx @@ -0,0 +1,82 @@ +import { Button, ButtonGroup, Spinner } from 'react-bootstrap'; +import { TripQueryVariables } from '../../gql/graphql.ts'; +import { LocationInputField } from './LocationInputField.tsx'; +import { SwapLocationsButton } from './SwapLocationsButton.tsx'; +import { DepartureArrivalSelect } from './DepartureArrivalSelect.tsx'; +import { DateTimeInputField } from './DateTimeInputField.tsx'; +import { SearchWindowInput } from './SearchWindowInput.tsx'; +import { AccessSelect } from './AccessSelect.tsx'; +import { EgressSelect } from './EgressSelect.tsx'; +import { DirectModeSelect } from './DirectModeSelect.tsx'; +import { TransitModeSelect } from './TransitModeSelect.tsx'; +import { NumTripPatternsInput } from './NumTripPatternsInput.tsx'; +import { ItineraryFilterDebugSelect } from './ItineraryFilterDebugSelect.tsx'; +import GraphiQLRouteButton from './GraphiQLRouteButton.tsx'; + +type InputFieldsSectionProps = { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; + onRoute: () => void; + loading: boolean; +}; + +export function InputFieldsSection({ + tripQueryVariables, + setTripQueryVariables, + onRoute, + loading, +}: InputFieldsSectionProps) { + return ( +
+
+ + + +
+
+ + +
+
+ + +
+
+ + + + +
+ + +
+ + + + +
+
+ ); +} diff --git a/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx b/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx index 6f479290947..5c781d93e7d 100644 --- a/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx +++ b/client/src/components/SearchBar/ItineraryFilterDebugSelect.tsx @@ -20,10 +20,10 @@ export function ItineraryFilterDebugSelect({ onChange={(e) => { setTripQueryVariables({ ...tripQueryVariables, - itineraryFiltersDebug: e.target.value as ItineraryFilterDebugProfile, + itineraryFilters: { debug: e.target.value as ItineraryFilterDebugProfile }, }); }} - value={tripQueryVariables.itineraryFiltersDebug || 'not_selected'} + value={tripQueryVariables.itineraryFilters?.debug || 'not_selected'} > {Object.values(ItineraryFilterDebugProfile).map((debugProfile) => ( diff --git a/client/src/components/SearchBar/LogoSection.tsx b/client/src/components/SearchBar/LogoSection.tsx new file mode 100644 index 00000000000..087263e8167 --- /dev/null +++ b/client/src/components/SearchBar/LogoSection.tsx @@ -0,0 +1,30 @@ +import { useState, useRef } from 'react'; +import Navbar from 'react-bootstrap/Navbar'; +import { ServerInfo } from '../../gql/graphql.ts'; +import { ServerInfoTooltip } from './ServerInfoTooltip.tsx'; +import logo from '../../static/img/otp-logo.svg'; + +type LogoSectionProps = { + serverInfo?: ServerInfo; +}; + +export function LogoSection({ serverInfo }: LogoSectionProps) { + const [showServerInfo, setShowServerInfo] = useState(false); + const target = useRef(null); + + return ( +
+ setShowServerInfo((v) => !v)}> +
+ + OTP Debug + {showServerInfo && } +
+
+
+
Version: {serverInfo?.version}
+
Time zone: {serverInfo?.internalTransitModelTimeZone}
+
+
+ ); +} diff --git a/client/src/components/SearchBar/NumTripPatternsInput.tsx b/client/src/components/SearchBar/NumTripPatternsInput.tsx index 360ce1c2c73..ae33e2f4e19 100644 --- a/client/src/components/SearchBar/NumTripPatternsInput.tsx +++ b/client/src/components/SearchBar/NumTripPatternsInput.tsx @@ -11,7 +11,7 @@ export function NumTripPatternsInput({ return ( - Num. results + # setTripQueryVariables({ diff --git a/client/src/components/SearchInput/ArgumentTooltip.tsx b/client/src/components/SearchInput/ArgumentTooltip.tsx new file mode 100644 index 00000000000..efb7a11dc19 --- /dev/null +++ b/client/src/components/SearchInput/ArgumentTooltip.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import infoIcon from '../../static/img/help-info-solid.svg'; +import inputIcon from '../../static/img/input.svg'; +import durationIcon from '../../static/img/lap-timer.svg'; +import { ResolvedType } from './useTripArgs.ts'; + +interface ArgumentTooltipProps { + defaultValue?: string | number | boolean | object | null | undefined; + type?: ResolvedType; +} + +const ArgumentTooltip: React.FC = ({ defaultValue, type }) => { + return ( + + {defaultValue !== undefined && defaultValue !== null && ( + + {'Info'} + + )} + {type !== undefined && type !== null && type.subtype === 'DoubleFunction' && ( + + {'Info'} + + )} + {type !== undefined && type !== null && type.subtype === 'Duration' && ( + + {'Info'} + + )} + + ); +}; + +export default ArgumentTooltip; diff --git a/client/src/components/SearchInput/ResetButton.tsx b/client/src/components/SearchInput/ResetButton.tsx new file mode 100644 index 00000000000..42e9d9e3d6b --- /dev/null +++ b/client/src/components/SearchInput/ResetButton.tsx @@ -0,0 +1,34 @@ +import { TripQueryVariables } from '../../gql/graphql.ts'; +import { excludedArguments } from './excluded-arguments.ts'; +import { getNestedValue, setNestedValue } from './nestedUtils.tsx'; +import React from 'react'; + +interface ResetButtonProps { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; +} + +const ResetButton: React.FC = ({ tripQueryVariables, setTripQueryVariables }) => { + function handleReset(): void { + // Start with an empty object (or partially typed) + let newVars: TripQueryVariables = {} as TripQueryVariables; + + // For each path in our excluded set, copy over that value (if any) + excludedArguments.forEach((excludedPath) => { + const value = getNestedValue(tripQueryVariables, excludedPath); + if (value !== undefined) { + newVars = setNestedValue(newVars, excludedPath, value) as TripQueryVariables; + } + }); + + setTripQueryVariables(newVars); + } + + return ( + + ); +}; + +export default ResetButton; diff --git a/client/src/components/SearchInput/Sidebar.tsx b/client/src/components/SearchInput/Sidebar.tsx new file mode 100644 index 00000000000..b362ad4720c --- /dev/null +++ b/client/src/components/SearchInput/Sidebar.tsx @@ -0,0 +1,53 @@ +import React, { useState, ReactNode } from 'react'; +import tripIcon from '../../static/img/route.svg'; +import filterIcon from '../../static/img/filter.svg'; +import jsonIcon from '../../static/img/json.svg'; + +interface SidebarProps { + children: ReactNode | ReactNode[]; +} + +const Sidebar: React.FC = ({ children }) => { + const [activeIndex, setActiveIndex] = useState(0); + + // Function to return the appropriate image based on the index + const getIconForIndex = (index: number) => { + switch (index) { + case 0: + return Itineray list; + case 1: + return Filters; + case 2: + return Filters; + default: + return null; + } + }; + + // Ensure children is always an array and filter out invalid children (null, undefined) + const childArray = React.Children.toArray(children).filter((child) => React.isValidElement(child)); + + return ( +
+ {/* Sidebar Navigation Buttons */} +
+ {childArray.map((_, index) => ( +
setActiveIndex(index)} + > + {getIconForIndex(index)} +
+ ))} +
+ + {/* Content Area */} +
+ {childArray.map((child, index) => (index === activeIndex ?
{child}
: null))} +
+
+ ); +}; + +export default Sidebar; diff --git a/client/src/components/SearchInput/TripArguments.ts b/client/src/components/SearchInput/TripArguments.ts new file mode 100644 index 00000000000..fbf31b5cbf8 --- /dev/null +++ b/client/src/components/SearchInput/TripArguments.ts @@ -0,0 +1,20 @@ +export interface TripArguments { + trip: { + arguments: { + [key: string]: Argument; + }; + }; +} + +export interface Argument { + type: TypeDescriptor; + defaultValue?: string; +} + +export type TypeDescriptor = ScalarType | NestedObject; + +export type ScalarType = 'ID' | 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Duration'; + +export interface NestedObject { + [key: string]: Argument | string[]; // Allows for nested objects or enum arrays +} diff --git a/client/src/components/SearchInput/TripQueryArguments.tsx b/client/src/components/SearchInput/TripQueryArguments.tsx new file mode 100644 index 00000000000..3abcc19edc2 --- /dev/null +++ b/client/src/components/SearchInput/TripQueryArguments.tsx @@ -0,0 +1,409 @@ +import React, { JSX, useEffect, useState } from 'react'; +import { useTripSchema } from './useTripSchema.ts'; +import { TripQueryVariables } from '../../gql/graphql'; +import { getNestedValue, setNestedValue } from './nestedUtils'; +import ArgumentTooltip from './ArgumentTooltip.tsx'; +import { excludedArguments } from './excluded-arguments.ts'; +import { ResolvedType } from './useTripArgs.ts'; +import ResetButton from './ResetButton.tsx'; +import { DefaultValue, extractAllArgs, formatArgumentName, ProcessedArgument } from './extractArgs.ts'; + +interface TripQueryArgumentsProps { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; +} + +const TripQueryArguments: React.FC = ({ tripQueryVariables, setTripQueryVariables }) => { + const [argumentsList, setArgumentsList] = useState([]); + const [expandedArguments, setExpandedArguments] = useState>({}); + const [searchText] = useState(''); + + const { tripArgs, loading, error } = useTripSchema(); + + useEffect(() => { + if (!tripArgs) return; // Don't run if the data isn't loaded yet + if (loading || error) return; // Optionally handle error/loading + + const extractedArgs = extractAllArgs(tripArgs.trip.arguments); + setArgumentsList(extractedArgs); + }, [tripArgs, loading, error]); + + function normalizePathForList(path: string): string { + // Replace numeric segments with `*` + return path.replace(/\.\d+/g, '.*'); + } + + function handleInputChange(path: string, value: DefaultValue | undefined): void { + const normalizedPath = normalizePathForList(path); + const argumentConfig = argumentsList.find((arg) => arg.path === normalizedPath); + + if (!argumentConfig) { + console.error(`No matching argumentConfig found for path: ${path}`); + return; + } + + // Handle comma-separated input for string arrays + if ( + argumentConfig.type.subtype != null && + ['String', 'DoubleFunction', 'ID', 'Duration'].includes(argumentConfig.type.subtype) && + argumentConfig.isList + ) { + if (typeof value === 'string') { + // Convert comma-separated string into an array + const idsArray = value.split(',').map((id) => id.trim()); + + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, path, idsArray) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, path); + setTripQueryVariables(updatedTripQueryVariables); + return; + } + } + + // Default handling for other cases + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, path, value) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, path); + setTripQueryVariables(updatedTripQueryVariables); + } + + function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + /** + * Recursively removes empty arrays/objects from `variables` based on a path. + * Returns the updated variables. + */ + function cleanUpParentIfEmpty(variables: TripQueryVariables, path: string): TripQueryVariables { + if (!path.includes('.')) { + const topValue = getNestedValue(variables, path); + + if (Array.isArray(topValue) && topValue.length === 0) { + // Create a shallow copy as a flexible object: + const copy = { ...variables } as Record; + // Remove the property: + delete copy[path]; + return copy as TripQueryVariables; + } + + // If it's a plain object and all keys are undefined/null or empty, remove it + if (isPlainObject(topValue)) { + const allKeysEmpty = Object.keys(topValue).every((key) => { + const childVal = (topValue as Record)[key]; + return childVal === undefined || childVal === null || (Array.isArray(childVal) && childVal.length === 0); + }); + + if (allKeysEmpty) { + const copy = { ...variables } as Record; + delete copy[path]; + return copy as TripQueryVariables; + } + } + + return variables; // Otherwise leave it as is + } + + // For nested paths + const pathParts = path.split('.'); + for (let i = pathParts.length - 1; i > 0; i--) { + const parentPath = pathParts.slice(0, i).join('.'); + const parentValue = getNestedValue(variables, parentPath); + + if (parentValue == null) { + // Already null or undefined, nothing to do + continue; + } + + if (Array.isArray(parentValue)) { + // If the parent array is now empty, remove it + if (parentValue.length === 0) { + variables = setNestedValue(variables, parentPath, undefined) as TripQueryVariables; + } + } else if (isPlainObject(parentValue)) { + // If all child values are null/undefined or empty, remove the parent + const allKeysEmpty = Object.keys(parentValue).every((key) => { + const childPath = `${parentPath}.${key}`; + const childValue = getNestedValue(variables, childPath); + return ( + childValue === undefined || childValue === null || (Array.isArray(childValue) && childValue.length === 0) + ); + }); + + if (allKeysEmpty) { + variables = setNestedValue(variables, parentPath, undefined) as TripQueryVariables; + } + } + } + + return variables; + } + + function toggleExpand(path: string): void { + setExpandedArguments((prev) => ({ + ...prev, + [path]: !prev[path], + })); + } + + const filteredArgumentsList = argumentsList + .filter(({ path }) => formatArgumentName(path).toLowerCase().includes(searchText.toLowerCase())) + .filter(({ path }) => !excludedArguments.has(path)); + + /** + * Renders multiple InputObjects within an array. Each item in the array + * is shown with an expand/collapse toggle and a remove button. + */ + function renderListOfInputObjects( + listPath: string, + allArgs: ProcessedArgument[], + level: number, + type: ResolvedType, + ): React.JSX.Element { + // We assume getNestedValue returns unknown; cast to an array if needed + const arrayVal = (getNestedValue(tripQueryVariables, listPath) ?? []) as unknown[]; + + // You can customize this if you have a better naming scheme + const typeName = type.name; + + return ( +
+ {arrayVal.map((_, index) => { + const itemPath = `${listPath}.${index}`; + + // Replace the `.*` placeholder with the actual index + const itemNestedArgs = allArgs + .filter((arg) => arg.path.startsWith(`${listPath}.*.`) && arg.path !== `${listPath}.*`) + .map((arg) => ({ + ...arg, + path: arg.path.replace(`${listPath}.*`, itemPath), + })); + + const immediateNestedArgs = itemNestedArgs.filter( + (arg) => arg.path.split('.').length === itemPath.split('.').length + 1, + ); + + const isExpandedItem = expandedArguments[itemPath]; + + return ( +
+ toggleExpand(itemPath)}> + {isExpandedItem ? '▼ ' : '▶ '} [#{index + 1}] + + + + {isExpandedItem && ( +
+ {renderArgumentInputs(immediateNestedArgs, level + 1, itemNestedArgs)} +
+ )} +
+ ); + })} + +
+ ); + } + + function handleAddItem(listPath: string): void { + const currentValue = (getNestedValue(tripQueryVariables, listPath) ?? []) as unknown[]; + const newValue = [...currentValue, {}]; + const updatedTripQueryVariables = setNestedValue(tripQueryVariables, listPath, newValue) as TripQueryVariables; + setTripQueryVariables(updatedTripQueryVariables); + } + + function handleRemoveItem(listPath: string, index: number): void { + const currentValue = (getNestedValue(tripQueryVariables, listPath) ?? []) as unknown[]; + const newValue = currentValue.filter((_, i) => i !== index); + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, listPath, newValue) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, listPath); + setTripQueryVariables(updatedTripQueryVariables); + } + + function handleRemoveArgument(path: string): void { + let updatedTripQueryVariables = setNestedValue(tripQueryVariables, path, undefined) as TripQueryVariables; + updatedTripQueryVariables = cleanUpParentIfEmpty(updatedTripQueryVariables, path); + setTripQueryVariables(updatedTripQueryVariables); + } + + function renderArgumentInputs(args: ProcessedArgument[], level: number, allArgs: ProcessedArgument[]): JSX.Element[] { + return args.map(({ path, type, defaultValue, enumValues, isComplex, isList }) => { + const isExpanded = expandedArguments[path]; + const currentDepth = path.split('.').length; + const nestedArgs = allArgs.filter((arg) => { + const argDepth = arg.path.split('.').length; + return arg.path.startsWith(`${path}.`) && arg.path !== path && argDepth === currentDepth + 1; + }); + + const nestedLevel = level + 1; + + // Various input renderings depending on subtype + return ( +
+ {isComplex ? ( +
+ toggleExpand(path)}> + {isExpanded ? '▼ ' : '▶ '} {formatArgumentName(path)} + + {isExpanded && isList ? ( +
{renderListOfInputObjects(path, allArgs, nestedLevel, type)}
+ ) : isExpanded ? ( + renderArgumentInputs(nestedArgs, nestedLevel, allArgs) + ) : null} +
+ ) : ( +
+ + {type.subtype === 'Boolean' && + (() => { + const currentValue = getNestedValue(tripQueryVariables, path) as boolean | undefined; + const isInUse = currentValue !== undefined; + return ( + + handleInputChange(path, e.target.checked)} + /> + {isInUse && ( + handleRemoveArgument(path)} className="remove-argument"> + x + + )} + + ); + })()} + + {type.subtype != null && + ['String', 'DoubleFunction', 'ID', 'Duration'].includes(type.subtype) && + isList && ( + { + const currentValue = getNestedValue(tripQueryVariables, path); + return Array.isArray(currentValue) ? currentValue.join(', ') : ''; + })()} + onChange={(e) => handleInputChange(path, e.target.value)} + placeholder="Comma-separated list" + /> + )} + + {type.subtype != null && + ['String', 'DoubleFunction', 'ID', 'Duration'].includes(type.subtype) && + !isList && ( + handleInputChange(path, e.target.value || undefined)} + /> + )} + + {type.subtype === 'Int' && ( + { + const val = parseInt(e.target.value, 10); + handleInputChange(path, Number.isNaN(val) ? undefined : val); + }} + /> + )} + + {type.subtype === 'Float' && ( + { + const val = parseFloat(e.target.value); + handleInputChange(path, Number.isNaN(val) ? undefined : val); + }} + /> + )} + + {type.subtype === 'DateTime' && ( + { + const newValue = e.target.value ? new Date(e.target.value).toISOString() : undefined; + handleInputChange(path, newValue); + }} + /> + )} + + {type.type === 'Enum' && enumValues && isList && ( + + )} + + {type.type === 'Enum' && enumValues && !isList && ( + + )} +
+ )} +
+ ); + }); + } + + return ( +
+
+ Filters + +
+ {filteredArgumentsList.length === 0 ? ( +

No arguments found.

+ ) : ( +
+ {renderArgumentInputs( + // Top-level arguments have a path depth of 1 + filteredArgumentsList.filter((arg) => arg.path.split('.').length === 1), + 0, + filteredArgumentsList, + )} +
+ )} +
+ ); +}; + +export default TripQueryArguments; diff --git a/client/src/components/SearchInput/TripSchemaContext.tsx b/client/src/components/SearchInput/TripSchemaContext.tsx new file mode 100644 index 00000000000..f769b33855d --- /dev/null +++ b/client/src/components/SearchInput/TripSchemaContext.tsx @@ -0,0 +1,10 @@ +import { createContext } from 'react'; +import type { TripArgsRepresentation } from './useTripArgs'; + +export interface TripSchemaContextValue { + tripArgs: TripArgsRepresentation | null; + loading: boolean; + error: string | null; +} + +export const TripSchemaContext = createContext(undefined); diff --git a/client/src/components/SearchInput/TripSchemaProvider.tsx b/client/src/components/SearchInput/TripSchemaProvider.tsx new file mode 100644 index 00000000000..a8a3f9b30a8 --- /dev/null +++ b/client/src/components/SearchInput/TripSchemaProvider.tsx @@ -0,0 +1,47 @@ +import React, { useEffect, useState } from 'react'; +import { TripSchemaContext, TripSchemaContextValue } from './TripSchemaContext'; +import { fetchTripArgs, TripArgsRepresentation } from './useTripArgs'; + +interface TripSchemaProviderProps { + endpoint: string; + children: React.ReactNode; +} + +export function TripSchemaProvider({ endpoint, children }: TripSchemaProviderProps) { + const [tripArgs, setTripArgs] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + async function loadSchema() { + setLoading(true); + setError(null); + try { + const result = await fetchTripArgs(endpoint); + if (isMounted) { + setTripArgs(result); + } + } catch (err) { + console.error('Error loading trip arguments:', err); + if (isMounted) { + setError('Failed to load trip schema'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + loadSchema(); + return () => { + isMounted = false; + }; + }, [endpoint]); + + const value: TripSchemaContextValue = { tripArgs, loading, error }; + + return {children}; +} diff --git a/client/src/components/SearchInput/ViewArgumentsRaw.tsx b/client/src/components/SearchInput/ViewArgumentsRaw.tsx new file mode 100644 index 00000000000..c08fd833a65 --- /dev/null +++ b/client/src/components/SearchInput/ViewArgumentsRaw.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { TripQueryVariables } from '../../gql/graphql.ts'; +import ResetButton from './ResetButton.tsx'; + +interface ViewArgumentsRawProps { + tripQueryVariables: TripQueryVariables; + setTripQueryVariables: (tripQueryVariables: TripQueryVariables) => void; +} + +const ViewArgumentsRaw: React.FC = ({ tripQueryVariables, setTripQueryVariables }) => { + return ( +
+
+ Arguments raw + +
+ +
{JSON.stringify(tripQueryVariables, null, 2)}
+
+ ); +}; + +export default ViewArgumentsRaw; diff --git a/client/src/components/SearchInput/excluded-arguments.ts b/client/src/components/SearchInput/excluded-arguments.ts new file mode 100644 index 00000000000..bef4f1f6075 --- /dev/null +++ b/client/src/components/SearchInput/excluded-arguments.ts @@ -0,0 +1,12 @@ +export const excludedArguments = new Set([ + 'numTripPatterns', + 'arriveBy', + 'from', + 'to', + 'dateTime', + 'searchWindow', + 'modes.accessMode', + 'modes.directMode', + 'modes.egressMode', + // Add every full path you want to exclude - top level paths will remove all children! +]); diff --git a/client/src/components/SearchInput/extractArgs.ts b/client/src/components/SearchInput/extractArgs.ts new file mode 100644 index 00000000000..9bb9b6812b0 --- /dev/null +++ b/client/src/components/SearchInput/extractArgs.ts @@ -0,0 +1,123 @@ +import { ResolvedType } from './useTripArgs.ts'; + +export type DefaultValue = string | number | boolean | object | null; + +interface ArgData { + type: ResolvedType; + name?: string; + defaultValue?: DefaultValue; + enumValues?: string[]; + isComplex?: boolean; + isList?: boolean; + args?: Record; // Recursive for nested arguments +} + +export interface ProcessedArgument { + path: string; + type: ResolvedType; + name?: string; + defaultValue?: DefaultValue; + enumValues?: string[]; + isComplex?: boolean; + isList?: boolean; +} +/** + * Returns a human-readable name from a path like "someNestedArg.subArg". + */ +export function formatArgumentName(input: string): string { + if (!input) { + return ' '; + } + const parts = input.split('.'); + const formatted = parts[parts.length - 1].replace(/([A-Z])/g, ' $1').trim(); + return formatted.replace(/\b\w/g, (char) => char.toUpperCase()) + ' '; +} +/** + * Recursively extracts a flat list of arguments (ProcessedArgument[]). + */ +export function extractAllArgs( + args: Record | undefined, + parentPath: string[] = [], +): ProcessedArgument[] { + let allArgs: ProcessedArgument[] = []; + if (!args) return []; + + Object.entries(args).forEach(([argName, argData]) => { + const currentPath = [...parentPath, argName].join('.'); + allArgs = allArgs.concat(processArgument(argName, argData, currentPath, parentPath)); + }); + + return allArgs; +} + +/** + * Converts a single ArgData into one or more ProcessedArgument entries. + * If the argData is an InputObject with nested fields, we recurse. + */ +function processArgument( + argName: string, + argData: ArgData, + currentPath: string, + parentPath: string[], +): ProcessedArgument[] { + let allArgs: ProcessedArgument[] = []; + + if (typeof argData === 'object' && argData.type) { + if (argData.type.type === 'Enum') { + const enumValues = ['Not selected', ...(argData.type.values || [])]; + const defaultValue = argData.defaultValue !== undefined ? argData.defaultValue : 'Not selected'; + + allArgs.push({ + path: currentPath, + type: { type: 'Enum' }, + defaultValue, + enumValues, + isList: argData.isList, + }); + } else if (argData.type.type === 'InputObject' && argData.isList) { + // This is a list of InputObjects + allArgs.push({ + path: currentPath, + type: { type: 'Group', name: argData.type.name }, // We'll still call this 'Group' + defaultValue: argData.defaultValue, + isComplex: true, + isList: true, + }); + + allArgs = allArgs.concat(extractAllArgs(argData.type.fields, [...parentPath, `${argName}.*`])); + } else if (argData.type.type === 'InputObject') { + // Single InputObject + allArgs.push({ + path: currentPath, + type: { type: 'Group', name: argData.type.name }, + isComplex: true, + isList: false, + }); + allArgs = allArgs.concat(extractAllArgs(argData.type.fields, [...parentPath, argName])); + } else if (argData.type.type === 'Scalar') { + allArgs.push({ + path: currentPath, + type: { type: argData.type.type, subtype: argData.type.subtype }, + defaultValue: argData.defaultValue, + isList: argData.isList, + }); + } + } else if (typeof argData === 'object' && argData.type?.fields) { + // Possibly a nested object with fields + allArgs.push({ + path: currentPath, + type: { type: 'Group' }, + isComplex: true, + }); + allArgs = allArgs.concat(extractAllArgs(argData.type.fields, [...parentPath, argName])); + } else { + // Fallback case + allArgs.push({ + path: currentPath, + type: argData.type ?? (typeof argData as unknown), // <— If argData.type is missing, fallback + defaultValue: argData.defaultValue, + }); + } + + return allArgs; +} diff --git a/client/src/components/SearchInput/nestedUtils.tsx b/client/src/components/SearchInput/nestedUtils.tsx new file mode 100644 index 00000000000..cfcfcfc232d --- /dev/null +++ b/client/src/components/SearchInput/nestedUtils.tsx @@ -0,0 +1,129 @@ +/** + * Retrieves a nested value from an object or array based on a dot-separated path. + * @param obj - The object/array to traverse (can be anything). + * @param path - The dot-separated path string (e.g. "myList.0.fieldName"). + * @returns The value at the specified path or undefined if not found. + */ +export function getNestedValue(obj: unknown, path: string): unknown { + return path.split('.').reduce((acc, key) => { + if (acc == null) { + return undefined; + } + + if (Array.isArray(acc)) { + // If the current accumulator is an array, parse key as a numeric index + const idx = Number(key); + if (Number.isNaN(idx)) return undefined; // mismatch (path wanted array index but got non-numeric) + return acc[idx]; + } else if (typeof acc === 'object') { + // treat it like a dictionary + const record = acc as Record; + return record[key]; + } + // If acc is neither object nor array, we can't go deeper + return undefined; + }, obj); +} + +/** + * Sets a nested value in an object (or array) based on a dot-separated path, + * returning a new top-level object/array to ensure immutability. + * + * This version detects numeric path segments (like "0", "1") and uses arrays + * at those levels. Non-numeric segments use objects. If there's a mismatch, + * it will convert that level to the correct type. + * + * @param obj - The original object/array (could be anything). + * @param path - The dot-separated path (e.g. "myList.0.fieldName"). + * @param value - The value to set at that path. + * @returns A new object or array with the updated value. + */ +export function setNestedValue(obj: unknown, path: string, value: unknown): unknown { + const keys = path.split('.'); + + /** + * Recursively traverse `current` based on the path segments. + * At each level, create a shallow clone of the array/object + * and update the correct child. + */ + function cloneAndSet(current: unknown, index: number): unknown { + const key = keys[index]; + const isNumeric = !isNaN(Number(key)); + + // Base case: if we're at the final segment, just return `value`. + if (index === keys.length - 1) { + // If current is an array and key is numeric, place `value` at that index + if (Array.isArray(current) && isNumeric) { + const newArray = [...current]; + newArray[Number(key)] = value; + return newArray; + } + // If current is an object (Record) and key is non-numeric, place `value` in that object + if (isObject(current) && !isNumeric) { + return { ...current, [key]: value }; + } + // Otherwise there's a type mismatch, so we convert: + if (isNumeric) { + // We expected an array + const arr = Array.isArray(current) ? [...current] : []; + arr[Number(key)] = value; + return arr; + } else { + // We expected an object + const base = isObject(current) ? current : {}; + return { + ...base, + [key]: value, + }; + } + } + + // Not at the final segment => recurse deeper + const nextIndex = index + 1; + const nextKey = keys[nextIndex]; + const nextIsNumeric = !isNaN(Number(nextKey)); + + if (Array.isArray(current) && isNumeric) { + // current is an array, and we have a numeric key + const newArray = [...current]; + const childVal = current[Number(key)]; + newArray[Number(key)] = cloneAndSet(childVal !== undefined ? childVal : nextIsNumeric ? [] : {}, nextIndex); + return newArray; + } else if (isObject(current) && !isNumeric) { + // current is an object (Record), and we have a string key + const newObj = { ...current }; + const childVal = (current as Record)[key]; + newObj[key] = cloneAndSet(childVal !== undefined ? childVal : nextIsNumeric ? [] : {}, nextIndex); + return newObj; + } else { + // There's a mismatch at this level + // e.g. current is an object but key is numeric => we want an array, or vice versa. + if (isNumeric) { + // create a new array at this level + const arr: unknown[] = []; + arr[Number(key)] = cloneAndSet(nextIsNumeric ? [] : {}, nextIndex); + return arr; + } else { + // create a new object at this level + return { + [key]: cloneAndSet(nextIsNumeric ? [] : {}, nextIndex), + }; + } + } + } + + // If the root `obj` is undefined or null, base it on the first key + if (obj == null) { + const firstKeyIsNumeric = !isNaN(Number(keys[0])); + obj = firstKeyIsNumeric ? [] : {}; + } + + return cloneAndSet(obj, 0); +} + +/** + * A small helper type-guard to check if `value` is a non-null object (but not an array). + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/client/src/components/SearchInput/useTripArgs.ts b/client/src/components/SearchInput/useTripArgs.ts new file mode 100644 index 00000000000..8f41f9f78d8 --- /dev/null +++ b/client/src/components/SearchInput/useTripArgs.ts @@ -0,0 +1,174 @@ +import { + buildClientSchema, + getIntrospectionQuery, + GraphQLSchema, + GraphQLType, + GraphQLNamedType, + isNonNullType, + isListType, + isScalarType, + isEnumType, + isInputObjectType, +} from 'graphql'; + +// +// Types +// +export interface ResolvedType { + type: 'Scalar' | 'Enum' | 'InputObject' | 'Group'; + // For scalars or fallback, e.g. "String", "Int", etc. + subtype?: string; + // For input objects + name?: string; + fields?: { + [fieldName: string]: { + type: ResolvedType; + defaultValue?: string | number | boolean | object | null; // Updated type + isList: boolean; + }; + }; + // For enums + values?: string[]; +} + +export interface ArgumentRepresentation { + [argName: string]: { + type: ResolvedType; + defaultValue?: string | number | boolean | object | null; // Updated type + isList: boolean; + }; +} + +export interface TripArgsRepresentation { + trip: { + arguments: ArgumentRepresentation; + }; +} + +/** + * Repeatedly unwraps NonNull and List wrappers until we get a named type. + */ +function getNamedType(type: GraphQLType): GraphQLNamedType { + let current: GraphQLType = type; + + while (true) { + if (isNonNullType(current)) { + current = current.ofType; + } else if (isListType(current)) { + current = current.ofType; + } else { + break; + } + } + + // At this point, current should be a GraphQLNamedType + return current as GraphQLNamedType; +} + +function resolveType(type: GraphQLType): ResolvedType { + const namedType = getNamedType(type); + + if (isScalarType(namedType)) { + return { type: 'Scalar', subtype: namedType.name }; + } + + if (isEnumType(namedType)) { + return { + type: 'Enum', + values: namedType.getValues().map((val) => val.name), + }; + } + + if (isInputObjectType(namedType)) { + const fields = namedType.getFields(); + const fieldTypes: Record< + string, + { type: ResolvedType; defaultValue?: string | number | boolean | object | null; isList: boolean } // Updated type + > = {}; + + for (const fieldName of Object.keys(fields)) { + const field = fields[fieldName]; + + // Exclude deprecated fields + if (field.deprecationReason) { + continue; + } + + const isList = isListType(field.type); + const defaultValue = field.defaultValue !== undefined ? field.defaultValue : null; + + fieldTypes[fieldName] = { + type: resolveType(field.type), + defaultValue: defaultValue, + isList, + }; + } + + return { + type: 'InputObject', + name: namedType.name, + fields: fieldTypes, + }; + } + + return { type: 'Scalar', subtype: 'String' }; +} + +function generateTripArgs(schema: GraphQLSchema): TripArgsRepresentation { + const queryType = schema.getQueryType(); + if (!queryType) { + throw new Error('No Query type found in the schema.'); + } + + const tripField = queryType.getFields()['trip']; + if (!tripField) { + throw new Error('No trip query found in the schema.'); + } + + const argsJson: ArgumentRepresentation = {}; + + tripField.args.forEach((arg) => { + if (arg.deprecationReason) { + // Skip deprecated arguments + return; + } + + const argName = arg.name; + const argType = resolveType(arg.type); + const argDefaultValue = arg.defaultValue !== null ? arg.defaultValue : null; + const isList = isListType(arg.type); + + argsJson[argName] = { + type: argType, + ...(argDefaultValue !== null && { defaultValue: argDefaultValue }), + isList, + }; + }); + + return { + trip: { + arguments: argsJson, + }, + }; +} + +//Fetch the remote GraphQL schema via introspection +export async function fetchTripArgs(graphqlEndpointUrl: string): Promise { + const introspectionQuery = getIntrospectionQuery(); + + const response = await fetch(graphqlEndpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: introspectionQuery }), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch schema. HTTP error: ${response.status}`); + } + + const { data } = await response.json(); + + const schema = buildClientSchema(data); + + return generateTripArgs(schema); +} diff --git a/client/src/components/SearchInput/useTripSchema.ts b/client/src/components/SearchInput/useTripSchema.ts new file mode 100644 index 00000000000..b7cc210026a --- /dev/null +++ b/client/src/components/SearchInput/useTripSchema.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { TripSchemaContext } from './TripSchemaContext'; + +export function useTripSchema() { + const context = useContext(TripSchemaContext); + if (!context) { + throw new Error('useTripSchema must be used within a TripSchemaProvider'); + } + return context; +} diff --git a/client/src/screens/App.tsx b/client/src/screens/App.tsx index 1b6b86b7a81..38cac431fb0 100644 --- a/client/src/screens/App.tsx +++ b/client/src/screens/App.tsx @@ -1,12 +1,17 @@ -import { Stack } from 'react-bootstrap'; import { MapView } from '../components/MapView/MapView.tsx'; -import { SearchBar } from '../components/SearchBar/SearchBar.tsx'; import { ItineraryListContainer } from '../components/ItineraryList/ItineraryListContainer.tsx'; import { useState } from 'react'; import { useTripQuery } from '../hooks/useTripQuery.ts'; import { useServerInfo } from '../hooks/useServerInfo.ts'; import { useTripQueryVariables } from '../hooks/useTripQueryVariables.ts'; import { TimeZoneContext } from '../hooks/TimeZoneContext.ts'; +import { LogoSection } from '../components/SearchBar/LogoSection.tsx'; +import { InputFieldsSection } from '../components/SearchBar/InputFieldsSection.tsx'; +import TripQueryArguments from '../components/SearchInput/TripQueryArguments.tsx'; +import Sidebar from '../components/SearchInput/Sidebar.tsx'; +import ViewArgumentsRaw from '../components/SearchInput/ViewArgumentsRaw.tsx'; +import { TripSchemaProvider } from '../components/SearchInput/TripSchemaProvider.tsx'; +import { getApiUrl } from '../util/getApiUrl.ts'; export function App() { const serverInfo = useServerInfo(); @@ -18,30 +23,49 @@ export function App() { return (
- - - - - - +
+
+ +
+
+ +
+
+ + + + + + + +
+
+ +
+
); diff --git a/client/src/static/img/code.svg b/client/src/static/img/code.svg new file mode 100644 index 00000000000..d303b8d18b5 --- /dev/null +++ b/client/src/static/img/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/data-visualization.svg b/client/src/static/img/data-visualization.svg new file mode 100644 index 00000000000..043b9ee35a4 --- /dev/null +++ b/client/src/static/img/data-visualization.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/debug-layer.svg b/client/src/static/img/debug-layer.svg new file mode 100644 index 00000000000..ac614e639dc --- /dev/null +++ b/client/src/static/img/debug-layer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/filter.svg b/client/src/static/img/filter.svg new file mode 100644 index 00000000000..cbda5f955d5 --- /dev/null +++ b/client/src/static/img/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/graph.svg b/client/src/static/img/graph.svg new file mode 100644 index 00000000000..6eef9e5100a --- /dev/null +++ b/client/src/static/img/graph.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/static/img/graphic.svg b/client/src/static/img/graphic.svg new file mode 100644 index 00000000000..344e8f9d5d5 --- /dev/null +++ b/client/src/static/img/graphic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/help-info-solid.svg b/client/src/static/img/help-info-solid.svg new file mode 100644 index 00000000000..bd87cd69731 --- /dev/null +++ b/client/src/static/img/help-info-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/info-circle.svg b/client/src/static/img/info-circle.svg new file mode 100644 index 00000000000..0689c0044ec --- /dev/null +++ b/client/src/static/img/info-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/input.svg b/client/src/static/img/input.svg new file mode 100644 index 00000000000..4ed4605b2c6 --- /dev/null +++ b/client/src/static/img/input.svg @@ -0,0 +1,8 @@ + + + diff --git a/client/src/static/img/json.svg b/client/src/static/img/json.svg new file mode 100644 index 00000000000..a92f3eec55b --- /dev/null +++ b/client/src/static/img/json.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/static/img/lap-timer.svg b/client/src/static/img/lap-timer.svg new file mode 100644 index 00000000000..1de0b3be6ce --- /dev/null +++ b/client/src/static/img/lap-timer.svg @@ -0,0 +1,8 @@ + + + diff --git a/client/src/static/img/route.svg b/client/src/static/img/route.svg new file mode 100644 index 00000000000..6699f08361b --- /dev/null +++ b/client/src/static/img/route.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/client/src/static/query/selector.fragment.graphql b/client/src/static/query/selector.fragment.graphql new file mode 100644 index 00000000000..6f3bc847ee7 --- /dev/null +++ b/client/src/static/query/selector.fragment.graphql @@ -0,0 +1,63 @@ +{ + previousPageCursor + nextPageCursor + tripPatterns { + aimedStartTime + aimedEndTime + expectedEndTime + expectedStartTime + duration + distance + legs { + id + mode + aimedStartTime + aimedEndTime + expectedEndTime + expectedStartTime + realtime + distance + duration + fromPlace { + name + quay { + id + } + } + toPlace { + name + quay { + id + } + } + toEstimatedCall { + destinationDisplay { + frontText + } + } + line { + publicCode + name + id + presentation { + colour + } + } + authority { + name + id + } + pointsOnLink { + points + } + interchangeTo { + staySeated + } + interchangeFrom { + staySeated + } + } + systemNotices { + tag + } + } \ No newline at end of file diff --git a/client/src/static/query/tripQuery.tsx b/client/src/static/query/tripQuery.tsx index f435c56e4d6..14c5ed2ec26 100644 --- a/client/src/static/query/tripQuery.tsx +++ b/client/src/static/query/tripQuery.tsx @@ -1,97 +1,155 @@ import { graphql } from '../../gql'; import { print } from 'graphql/index'; +// Generated trip query based on schema.graphql + export const query = graphql(` - query trip( - $from: Location! - $to: Location! - $arriveBy: Boolean - $dateTime: DateTime - $numTripPatterns: Int - $searchWindow: Int - $modes: Modes - $itineraryFiltersDebug: ItineraryFilterDebugProfile - $wheelchairAccessible: Boolean - $pageCursor: String - ) { - trip( - from: $from - to: $to - arriveBy: $arriveBy - dateTime: $dateTime - numTripPatterns: $numTripPatterns - searchWindow: $searchWindow - modes: $modes - itineraryFilters: { debug: $itineraryFiltersDebug } - wheelchairAccessible: $wheelchairAccessible - pageCursor: $pageCursor - ) { - previousPageCursor - nextPageCursor - tripPatterns { +query trip( + $accessEgressPenalty: [PenaltyForStreetMode!] + $alightSlackDefault: Int + $alightSlackList: [TransportModeSlack] + $arriveBy: Boolean + $banned: InputBanned + $bicycleOptimisationMethod: BicycleOptimisationMethod + $bikeSpeed: Float + $boardSlackDefault: Int + $boardSlackList: [TransportModeSlack] + $bookingTime: DateTime + $dateTime: DateTime + $filters: [TripFilterInput!] + $from: Location! + $ignoreRealtimeUpdates: Boolean + $includePlannedCancellations: Boolean + $includeRealtimeCancellations: Boolean + $itineraryFilters: ItineraryFilters + $locale: Locale + $maxAccessEgressDurationForMode: [StreetModeDurationInput!] + $maxDirectDurationForMode: [StreetModeDurationInput!] + $maximumAdditionalTransfers: Int + $maximumTransfers: Int + $modes: Modes + $numTripPatterns: Int + $pageCursor: String + $relaxTransitGroupPriority: RelaxCostInput + $searchWindow: Int + $timetableView: Boolean + $to: Location! + $transferPenalty: Int + $transferSlack: Int + $triangleFactors: TriangleFactors + $useBikeRentalAvailabilityInformation: Boolean + $via: [TripViaLocationInput!] + $waitReluctance: Float + $walkReluctance: Float + $walkSpeed: Float + $wheelchairAccessible: Boolean + $whiteListed: InputWhiteListed +) { + trip( + accessEgressPenalty: $accessEgressPenalty + alightSlackDefault: $alightSlackDefault + alightSlackList: $alightSlackList + arriveBy: $arriveBy + banned: $banned + bicycleOptimisationMethod: $bicycleOptimisationMethod + bikeSpeed: $bikeSpeed + boardSlackDefault: $boardSlackDefault + boardSlackList: $boardSlackList + bookingTime: $bookingTime + dateTime: $dateTime + filters: $filters + from: $from + ignoreRealtimeUpdates: $ignoreRealtimeUpdates + includePlannedCancellations: $includePlannedCancellations + includeRealtimeCancellations: $includeRealtimeCancellations + itineraryFilters: $itineraryFilters + locale: $locale + maxAccessEgressDurationForMode: $maxAccessEgressDurationForMode + maxDirectDurationForMode: $maxDirectDurationForMode + maximumAdditionalTransfers: $maximumAdditionalTransfers + maximumTransfers: $maximumTransfers + modes: $modes + numTripPatterns: $numTripPatterns + pageCursor: $pageCursor + relaxTransitGroupPriority: $relaxTransitGroupPriority + searchWindow: $searchWindow + timetableView: $timetableView + to: $to + transferPenalty: $transferPenalty + transferSlack: $transferSlack + triangleFactors: $triangleFactors + useBikeRentalAvailabilityInformation: $useBikeRentalAvailabilityInformation + via: $via + waitReluctance: $waitReluctance + walkReluctance: $walkReluctance + walkSpeed: $walkSpeed + wheelchairAccessible: $wheelchairAccessible + whiteListed: $whiteListed + ) + { + previousPageCursor + nextPageCursor + tripPatterns { aimedStartTime aimedEndTime expectedEndTime expectedStartTime duration distance - generalizedCost legs { - id - mode - aimedStartTime - aimedEndTime - expectedEndTime - expectedStartTime - realtime - distance - duration - generalizedCost - fromPlace { - name - quay { - id + id + mode + aimedStartTime + aimedEndTime + expectedEndTime + expectedStartTime + realtime + distance + duration + fromPlace { + name + quay { + id + } } - } - toPlace { - name - quay { - id + toPlace { + name + quay { + id + } } - } - toEstimatedCall { - destinationDisplay { - frontText + toEstimatedCall { + destinationDisplay { + frontText + } } - } - line { - publicCode - name - id - presentation { - colour + line { + publicCode + name + id + presentation { + colour + } + } + authority { + name + id + } + pointsOnLink { + points + } + interchangeTo { + staySeated + } + interchangeFrom { + staySeated } - } - authority { - name - id - } - pointsOnLink { - points - } - interchangeTo { - staySeated - } - interchangeFrom { - staySeated - } } systemNotices { - tag + tag } - } } } -`); +}`); -export const queryAsString = print(query); +export const queryAsString = print(query); \ No newline at end of file diff --git a/client/src/style.css b/client/src/style.css index eb5cbadf93b..a3f8946b3ec 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1,39 +1,43 @@ -.app { - min-width: 810px; +.layout { + display: grid; + grid-template-columns: 1fr 3fr; + grid-template-rows: 1fr 2fr; + height: 100vh; + gap: 0; } + +.box { + display: flex; + justify-content: center; + align-items: center; +} + .navbar-brand { color: #4078bc; - margin-top: 20px; - margin-right: 14px; + font-size: 2rem; } @media (min-width: 1895px) { - .top-content { - height: 75px; - } - .below-content { - height: calc(100vh - 75px); + height: calc(100vh - 175px); } } @media (max-width: 1896px) { - .top-content { - height: 150px; - } - .below-content { - height: calc(100vh - 150px); + height: calc(100vh - 175px); } } -@media (max-width: 1120px) { - .top-content { - height: 200px; +@media (max-width: 1250px) { + .below-content { + height: calc(100vh - 250px); } +} +@media (max-width: 900px) { .below-content { - height: calc(100vh - 200px); + height: calc(100vh - 325px); } } @@ -50,6 +54,10 @@ margin-right: 1rem; } +.search-bar input.input-tiny { + max-width: 50px; +} + .search-bar input.input-small { max-width: 100px; } @@ -73,16 +81,40 @@ margin: 30px 0 auto 0; } -.search-bar .swap-from-to img { +.input-family { + display: flex; + align-items: center; + gap: 2px; +} + +.swap-from-to img { width: 15px; } -.itinerary-list-container { - width: 36rem; +.logo-container { + display: flex; + flex-direction: column; +} + +.logo-container .details { + font-size: 0.8rem; + color: #666; + margin-top: 4px; + text-align: left; +} + +.logo-image { + margin-right: 2px; +} + +.left-pane-container { + font-size: 12px; + width: 100%; overflow-y: auto; + min-width: 300px; } -.itinerary-list-container .time-zone-info { +.left-pane-container .time-zone-info { margin: 10px 20px; font-size: 12px; text-align: right; @@ -207,3 +239,208 @@ .maplibregl-ctrl-group.layer-select div.layer { margin-left: 17px; } + +.right-menu-container { + position: absolute; + top: 0; + right: 0; + width: 0; + height: 100%; + background-color: #f4f4f4; + overflow-x: hidden; + transition: 0.3s; + padding-top: 60px; + box-shadow: none; +} + +.right-menu-container.open { + width: 250px; + box-shadow: -2px 0 5px rgba(0, 0, 0, 0.2); +} + +.sidebar-button.right { + position: absolute; + right: 0; /* Default position when sidebar is closed */ + background: #fff; + color: white; + border: none; + border-radius: 4px; + padding: 10px; + cursor: pointer; + transition: + right 0.3s, + background-color 0.2s; /* Smooth transitions */ +} + +.sidebar-button.right.open { + right: 270px; /* Shifted position when sidebar is open */ +} + +.sidebar-button.active { + background: #fff; +} + +.sidebar-button:hover { + background: #4078bc; /* Slightly darker when hovered */ +} + +.sidebar-button.active:hover { + background: #fff; +} + +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* For Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} + +.default-tooltip-container { + position: relative; + cursor: pointer; +} + +.pagination-controls { + margin-top: 10px; + margin-bottom: 5px; +} + +.default-tooltip-icon { + width: 10px; + height: 10px; +} +.argument-label { + padding-right: 2px; +} + +/* Sidebar Container */ +.sidebar-container { + display: flex; + width: 100%; + height: 100%; + border-top: black 1px solid; +} + +/* Sidebar Navigation */ +.sidebar { + display: flex; + flex-direction: column; + align-items: center; + width: 40px; + background-color: #f7f7f7; + border-right: 1px solid #ccc; +} + +/* Sidebar Buttons */ +.sidebar-button { + cursor: pointer; + padding: 5px; + text-align: center; + border-radius: 8px; + margin: 5px 0; + background-color: transparent; + transition: background-color 0.3s ease; +} + +.sidebar-button:hover { + background-color: #e0e0e0; +} + +.sidebar-button.active { + background-color: #ddd; + font-weight: bold; +} + +/* Content Area */ +.sidebar-content { + flex: 1; + overflow-y: auto; + margin: 5px; +} + +.panel-header { + font-size: 24px; + text-align: center; + position: relative; + margin-bottom: 10px; +} + +.argument-list { + font-size: 12px; + line-height: 1; +} + +.argument-list button { + font-size: 13px; + padding: 5px 10px; + margin: 5px 0; + background-color: #007bff; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; +} + +.argument-list button:hover { + background-color: #0056b3; /* Darker on hover */ +} + +.argument-list input[type='text'], +.argument-list input[type='number'], +.argument-list input[type='datetime-local'], +select { + font-size: 12px; + padding: 0; + margin: 0; + border: none; + border-bottom: 1px solid #ccc; /* Bottom border only */ + background: none; + box-sizing: border-box; +} + +.argument-list input[type='text'], +.argument-list input[type='number'] { + width: 50px; +} +.argument-list input[type='datetime-local'] { + width: 140px; +} + +input.comma-separated-input[type='text'], +input.comma-separated-input[type='number'] { + width: 140px; +} + +.remove-argument { + margin-left: 2px; + color: red; + cursor: pointer; +} + +.reset-button { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + /* The transform ensures the button is vertically centered + if your header has a fixed height or if text is multiline. */ +} + +.panel-header button { + font-size: 13px; + padding: 5px 10px; + margin: 5px 0; + background-color: #007bff; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; +} + +.panel-header button:hover { + background-color: #0056b3; /* Darker on hover */ +} diff --git a/client/src/util/generate-arguments.cjs b/client/src/util/generate-arguments.cjs new file mode 100644 index 00000000000..d2ff4b639b7 --- /dev/null +++ b/client/src/util/generate-arguments.cjs @@ -0,0 +1,130 @@ +const { + isScalarType, + isInputObjectType, + isNonNullType, + isListType, + isEnumType, +} = require('graphql'); + +/** + * Utility function to resolve the named type (unwrapping NonNull and List types) + */ +function getNamedType(type) { + let namedType = type; + while (isNonNullType(namedType) || isListType(namedType)) { + namedType = namedType.ofType; + } + return namedType; +} + +/** + * Recursively breaks down a GraphQL type into its primitive fields with default values + */ +function resolveType(type, schema = new Set()) { + const namedType = getNamedType(type); + + + if (isScalarType(namedType)) { + return { type: 'Scalar', subtype: namedType.name }; + } + + if (isEnumType(namedType)) { + return { type: 'Enum', values: namedType.getValues().map((val) => val.name) }; + } + + if (isInputObjectType(namedType)) { + const fields = namedType.getFields(); + const fieldTypes = {}; + + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + + // Exclude deprecated fields within input objects + if (field.deprecationReason) { + return; // Skip deprecated fields + } + + const fieldType = field.type; + const isList = isListType(fieldType); // Detect if the field is a list + const fieldDefaultValue = field.defaultValue !== undefined ? field.defaultValue : null; + + // Include defaultValue consistently, setting it to null if not defined + fieldTypes[fieldName] = { + type: resolveType(fieldType, schema), + defaultValue: fieldDefaultValue, + isList, // Explicitly indicate if it's a list + }; + }); + return { type: 'InputObject', name: namedType.name, fields: fieldTypes }; + } + + // Handle interfaces and unions if necessary + // For simplicity, treating them as strings + return { type: 'Scalar', subtype: 'String' }; +} + +/** + * Plugin to generate a JSON file with all arguments from a specified query, + * excluding deprecated arguments based on deprecationReason, + * and including their types, default values, + * and whether they support multiple selection. + */ +const generateTripArgsJsonPlugin = async (schema) => { + try { + const queryType = schema.getQueryType(); + if (!queryType) { + console.error('No Query type found in the schema.'); + return JSON.stringify({ error: 'No Query type found in the schema' }, null, 2); + } + + const tripField = queryType.getFields()['trip']; + if (!tripField) { + console.error('No trip query found in the schema.'); + return JSON.stringify({ error: 'No trip query found in the schema' }, null, 2); + } + + const args = tripField.args; + const argsJson = {}; + + args.forEach((arg) => { + if (arg.deprecationReason) { + return; // Skip deprecated arguments + } + + const argName = arg.name; + const argType = resolveType(arg.type, schema); + const argDefaultValue = arg.defaultValue !== undefined ? arg.defaultValue : null; + const isList = isListType(arg.type); // Detect if the argument is a list + + // Consistent representation for enum types + if (argDefaultValue !== null) { + argsJson[argName] = { + type: argType, + defaultValue: argDefaultValue, + isList, // Explicitly indicate if it's a list + }; + } else { + argsJson[argName] = { + type: argType, + isList, // Explicitly indicate if it's a list + }; + } + }); + + const output = { + trip: { + arguments: argsJson, + }, + }; + + // Stringify the JSON with indentation for readability + return JSON.stringify(output, null, 2); + } catch (error) { + console.error('Error generating tripArguments.json:', error); + return JSON.stringify({ error: 'Failed to generate trip arguments JSON' }, null, 2); + } +}; + +module.exports = { + plugin: generateTripArgsJsonPlugin, +}; diff --git a/client/src/util/generate-queries.cjs b/client/src/util/generate-queries.cjs new file mode 100644 index 00000000000..00366bc6a11 --- /dev/null +++ b/client/src/util/generate-queries.cjs @@ -0,0 +1,67 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Plugin to generate GraphQL queries dynamically from schema + */ +const generateQueriesPlugin = async (schema) => { + const queryType = schema.getQueryType(); + if (!queryType) { + return '// No Query type found in the schema'; + } + + // Read the content from the input file to replace "replacementContent" + const inputFilePath = path.join(__dirname, '../static/query/selector.fragment.graphql'); + let replacementContent = ''; + + try { + replacementContent = fs.readFileSync(inputFilePath, 'utf-8').trim(); + } catch (error) { + console.error(`Failed to read the input file at ${inputFilePath}`, error); + return '// Error: Failed to read the input file'; + } + + const queryFields = queryType.getFields(); + const queries = []; + + Object.keys(queryFields).forEach((fieldName) => { + if (fieldName === 'trip') { + // Only interested in the trip query + const field = queryFields[fieldName]; + + // Filter out deprecated arguments using deprecationReason - isDeprecated does not work + const validArgs = field.args.filter((arg) => !arg.deprecationReason); + + // Generate the arguments for the query with filtered arguments + const args = validArgs.map((arg) => ` $${arg.name}: ${arg.type}`).join('\n'); + + // Generate the arguments for the query variables with filtered arguments + const argsForQuery = validArgs.map((arg) => ` ${arg.name}: $${arg.name}`).join('\n'); + + const query = `import { graphql } from '../../gql'; +import { print } from 'graphql/index'; + +// Generated trip query based on schema.graphql + +export const query = graphql(\` +query ${fieldName}( +${args} +) { + ${fieldName}( +${argsForQuery} + ) + ${replacementContent} + } +}\`); + +export const queryAsString = print(query);`; + queries.push(query.trim()); // Trim unnecessary whitespace + } + }); + + return queries.join('\n\n'); // Separate queries with a blank line +}; + +module.exports = { + plugin: generateQueriesPlugin, +};