diff --git a/package.json b/package.json index cc29fde71..4dd50566e 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@rjsf/utils": "5.24.10", "@rjsf/validator-ajv8": "5.24.10", "@sentry/nextjs": "8.55.0", - "@squonk/account-server-client": "4.2.0-rc.8", - "@squonk/data-manager-client": "4.0.0-rc.1", + "@squonk/account-server-client": "4.2.1", + "@squonk/data-manager-client": "4.1.0", "@squonk/mui-theme": "5.0.0", "@squonk/sdf-parser": "1.3.1", "@tanstack/match-sorter-utils": "8.19.4", @@ -67,6 +67,7 @@ "@types/prismjs": "1.26.5", "@types/react": "19.1.4", "@types/react-plotly.js": "2.6.3", + "@types/semver": "^7.7.0", "axios": "1.9.0", "dayjs": "1.11.13", "filesize": "10.1.6", @@ -93,6 +94,7 @@ "react-dropzone": "14.3.8", "react-plotly.js": "2.6.0", "react-use-websocket": "4.13.0", + "semver": "^7.7.2", "sharp": "0.34.1", "typescript": "5.8.3", "use-immer": "0.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb7e28804..6f0abec6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,11 +68,11 @@ importers: specifier: 8.55.0 version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.83.1))(react@19.1.0)(webpack@5.97.1) '@squonk/account-server-client': - specifier: 4.2.0-rc.8 - version: 4.2.0-rc.8(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0) + specifier: 4.2.1 + version: 4.2.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0) '@squonk/data-manager-client': - specifier: 4.0.0-rc.1 - version: 4.0.0-rc.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0) + specifier: 4.1.0 + version: 4.1.0(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0) '@squonk/mui-theme': specifier: 5.0.0 version: 5.0.0(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) @@ -112,6 +112,9 @@ importers: '@types/react-plotly.js': specifier: 2.6.3 version: 2.6.3 + '@types/semver': + specifier: ^7.7.0 + version: 7.7.0 axios: specifier: 1.9.0 version: 1.9.0 @@ -190,6 +193,9 @@ importers: react-use-websocket: specifier: 4.13.0 version: 4.13.0 + semver: + specifier: ^7.7.2 + version: 7.7.2 sharp: specifier: 0.34.1 version: 0.34.1 @@ -1584,14 +1590,14 @@ packages: '@sideway/pinpoint@2.0.0': resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - '@squonk/account-server-client@4.2.0-rc.8': - resolution: {integrity: sha512-CBB5chY+MzaCcAHJ6VAnHltuvA/OiP1C+DksH0qnSzlqdWL1Ey0nu/RrUnVq/iF3wLUlidT6Un/K43eI+B3UkA==} + '@squonk/account-server-client@4.2.1': + resolution: {integrity: sha512-lIqpPqrhAR95hBb05dV5H5KO3/VDb6tHZm60c101v1XqT4KuUydEsc5PUW79yYXgIclWOUVwTcOfiMGHegSeag==} peerDependencies: '@tanstack/react-query': '>=4' axios: '>=0.23' - '@squonk/data-manager-client@4.0.0-rc.1': - resolution: {integrity: sha512-Ahig16tGl8jjKlXMla91hEVthlUmspoEj6bcXZ9xvOBstPr0SAYvjCyI6a30mmaGSSNiLq181v0YKQXK2TexDg==} + '@squonk/data-manager-client@4.1.0': + resolution: {integrity: sha512-QYeKqw+UrbkHo5l44glQgL9DrU7VnnqplPRmt5rTdAQX8vFdYbH21te1ZUXpDqH/eF62zv47DoPcAPWSBLLfVQ==} peerDependencies: '@tanstack/react-query': '>=4' axios: '>=0.23' @@ -1812,6 +1818,9 @@ packages: '@types/react@19.1.4': resolution: {integrity: sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==} + '@types/semver@7.7.0': + resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} + '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} @@ -5251,8 +5260,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.1: - resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} hasBin: true @@ -6816,7 +6825,7 @@ snapshots: ini: 4.1.3 nopt: 7.2.1 proc-log: 4.2.0 - semver: 7.7.1 + semver: 7.7.2 walk-up-path: 3.0.1 transitivePeerDependencies: - bluebird @@ -6830,7 +6839,7 @@ snapshots: proc-log: 4.2.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.7.1 + semver: 7.7.2 which: 4.0.0 transitivePeerDependencies: - bluebird @@ -6852,7 +6861,7 @@ snapshots: json-parse-even-better-errors: 3.0.2 normalize-package-data: 6.0.2 proc-log: 4.2.0 - semver: 7.7.1 + semver: 7.7.2 transitivePeerDependencies: - bluebird @@ -6965,7 +6974,7 @@ snapshots: '@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.28.0 forwarded-parse: 2.1.2 - semver: 7.7.1 + semver: 7.7.2 transitivePeerDependencies: - supports-color @@ -7098,7 +7107,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.3 require-in-the-middle: 7.4.0 - semver: 7.7.1 + semver: 7.7.2 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -7110,7 +7119,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.3 require-in-the-middle: 7.4.0 - semver: 7.7.1 + semver: 7.7.2 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -7122,7 +7131,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.11.3 require-in-the-middle: 7.4.0 - semver: 7.7.1 + semver: 7.7.2 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -7573,12 +7582,12 @@ snapshots: '@sideway/pinpoint@2.0.0': {} - '@squonk/account-server-client@4.2.0-rc.8(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)': + '@squonk/account-server-client@4.2.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)': dependencies: '@tanstack/react-query': 5.76.1(react@19.1.0) axios: 1.9.0 - '@squonk/data-manager-client@4.0.0-rc.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)': + '@squonk/data-manager-client@4.1.0(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)': dependencies: '@tanstack/react-query': 5.76.1(react@19.1.0) axios: 1.9.0 @@ -7850,6 +7859,8 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/semver@7.7.0': {} + '@types/shimmer@1.2.0': {} '@types/supercluster@7.1.3': @@ -7921,7 +7932,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.1 + semver: 7.7.2 ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -8609,7 +8620,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.3) postcss-modules-values: 4.0.0(postcss@8.5.3) postcss-value-parser: 4.2.0 - semver: 7.7.1 + semver: 7.7.2 optionalDependencies: webpack: 5.97.1 @@ -9210,7 +9221,7 @@ snapshots: get-tsconfig: 4.10.0 is-glob: 4.0.3 minimatch: 10.0.1 - semver: 7.7.1 + semver: 7.7.2 stable-hash: 0.0.5 tslib: 2.8.1 unrs-resolver: 1.6.4 @@ -9285,7 +9296,7 @@ snapshots: read-package-up: 11.0.0 regexp-tree: 0.1.27 regjsparser: 0.12.0 - semver: 7.7.1 + semver: 7.7.2 strip-indent: 4.0.0 eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1): @@ -10078,7 +10089,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.1 + semver: 7.7.2 is-callable@1.2.7: {} @@ -11166,7 +11177,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.1 + semver: 7.7.2 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -11188,7 +11199,7 @@ snapshots: npm-install-checks@6.3.0: dependencies: - semver: 7.7.1 + semver: 7.7.2 npm-normalize-package-bin@3.0.1: {} @@ -11196,7 +11207,7 @@ snapshots: dependencies: hosted-git-info: 7.0.2 proc-log: 4.2.0 - semver: 7.7.1 + semver: 7.7.2 validate-npm-package-name: 5.0.1 npm-pick-manifest@9.1.0: @@ -11204,7 +11215,7 @@ snapshots: npm-install-checks: 6.3.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 11.0.3 - semver: 7.7.1 + semver: 7.7.2 npm-run-path@5.3.0: dependencies: @@ -12083,7 +12094,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.1: {} + semver@7.7.2: {} serialize-javascript@6.0.2: dependencies: @@ -12121,7 +12132,7 @@ snapshots: dependencies: color: 4.2.3 detect-libc: 2.0.3 - semver: 7.7.1 + semver: 7.7.2 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.1 '@img/sharp-darwin-x64': 0.34.1 diff --git a/src/components/BaseCard.tsx b/src/components/BaseCard.tsx index 219890968..6604802a3 100644 --- a/src/components/BaseCard.tsx +++ b/src/components/BaseCard.tsx @@ -86,14 +86,13 @@ export const BaseCard = ({ /> )} {children} - + {/* ? should this be a functionCall() or a or should this be separate props with a union and one a never type */} {typeof actions === "function" ? actions({ setExpanded }) : actions} {collapsed !== undefined && ( ({ marginLeft: "auto", transform: `rotate(${expanded ? 180 : 0}deg)`, diff --git a/src/components/SearchTextField.tsx b/src/components/SearchTextField.tsx index dafb8a717..b0b311861 100644 --- a/src/components/SearchTextField.tsx +++ b/src/components/SearchTextField.tsx @@ -1,21 +1,30 @@ +import { forwardRef } from "react"; + import { SearchRounded as SearchRoundedIcon } from "@mui/icons-material"; import { InputAdornment, TextField, type TextFieldProps } from "@mui/material"; +import { getSearchShortcut } from "../utils/platform"; + /** - * MuiTextField with a search icon at the end + * MuiTextField with a search icon at the end and platform-specific keyboard shortcut in label */ -export const SearchTextField = (TextFieldProps: TextFieldProps) => ( - - - - ), - }, - }} - /> +export const SearchTextField = forwardRef( + (TextFieldProps, ref) => ( + + + + ), + }, + }} + /> + ), ); + +SearchTextField.displayName = "SearchTextField"; diff --git a/src/components/runCards/JobCard/JobCard.tsx b/src/components/runCards/JobCard/JobCard.tsx index a662d48bb..9001cd830 100644 --- a/src/components/runCards/JobCard/JobCard.tsx +++ b/src/components/runCards/JobCard/JobCard.tsx @@ -1,13 +1,21 @@ +import { useState } from "react"; + import { type JobSummary } from "@squonk/data-manager-client"; -import { Alert, Chip, LinearProgress, Link, Typography } from "@mui/material"; +import { Alert, Chip, LinearProgress, Link, MenuItem, TextField, Typography } from "@mui/material"; import dynamic from "next/dynamic"; +import semver from "semver"; import { BaseCard } from "../../BaseCard"; import { Chips } from "../../Chips"; import { type InstancesListProps } from "../InstancesList"; import { RunJobButton, type RunJobButtonProps } from "./RunJobButton"; +const compareJobs = (a: JobSummary, b: JobSummary) => { + return -semver.compare(a.version, b.version); +}; + + const InstancesList = dynamic( () => import("../InstancesList").then((mod) => mod.InstancesList), { loading: () => }, @@ -15,9 +23,9 @@ const InstancesList = dynamic( export interface ApplicationCardProps extends Pick { /** - * the job to be instantiated + * the list of jobs (different versions) to be instantiated */ - job: JobSummary; + job: JobSummary[]; /** * Whether to disable the button */ @@ -28,16 +36,37 @@ export interface ApplicationCardProps extends Pick { +export const JobCard = ({ projectId, job: jobs, disabled = false }: ApplicationCardProps) => { + jobs.sort(compareJobs); + const [selectedJobId, setSelectedJobId] = useState(jobs[0]?.id || ""); + const job = jobs.find(j => j.id === selectedJobId) as JobSummary; + return ( ( - setExpanded(true)} - /> + <> + setSelectedJobId(e.target.value)} + > + {jobs.map((jobVersion) => ( + + {jobVersion.version} + + ))} + + setExpanded(true)} + /> + )} collapsed={ { diff --git a/src/hooks/useKeyboardFocus.ts b/src/hooks/useKeyboardFocus.ts new file mode 100644 index 000000000..c5fc55d3a --- /dev/null +++ b/src/hooks/useKeyboardFocus.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; + +import { isMac } from "../utils/platform"; + +/** + * Hook that provides a ref and keyboard shortcut (Ctrl+F/Cmd+F) to focus an input field + */ +export const useKeyboardFocus = () => { + const inputRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const isCorrectModifier = isMac() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + + if (isCorrectModifier && event.key === 'f') { + event.preventDefault(); + inputRef.current?.querySelector('input')?.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + return inputRef; +}; diff --git a/src/hooks/useSearchFieldFocus.ts b/src/hooks/useSearchFieldFocus.ts new file mode 100644 index 000000000..1c2ea71bc --- /dev/null +++ b/src/hooks/useSearchFieldFocus.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from "react"; + +/** + * Hook that provides a ref and keyboard shortcut (Ctrl+F/Cmd+F) to focus an input field + */ +export const useKeyboardFocus = () => { + const inputRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'f') { + event.preventDefault(); + inputRef.current?.querySelector('input')?.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + return inputRef; +}; diff --git a/src/pages/run.tsx b/src/pages/run.tsx index 2df910a3a..ea830443a 100644 --- a/src/pages/run.tsx +++ b/src/pages/run.tsx @@ -1,10 +1,12 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useGetApplications } from "@squonk/data-manager-client/application"; import { useGetJobs } from "@squonk/data-manager-client/job"; import { withPageAuthRequired as withPageAuthRequiredCSR } from "@auth0/nextjs-auth0/client"; import { Alert, Container, Grid2 as Grid, MenuItem, TextField } from "@mui/material"; +import groupBy from "just-group-by"; +import { debounce } from "lodash-es"; import dynamic from "next/dynamic"; import Head from "next/head"; @@ -16,6 +18,7 @@ import { TEST_JOB_ID } from "../components/runCards/TestJob/jobId"; import { SearchTextField } from "../components/SearchTextField"; import { AS_ROLES, DM_ROLES } from "../constants/auth"; import { useCurrentProject, useIsUserAdminOrEditorOfCurrentProject } from "../hooks/projectHooks"; +import { useKeyboardFocus } from "../hooks/useKeyboardFocus"; import Layout from "../layouts/Layout"; import { search } from "../utils/app/searches"; @@ -23,13 +26,26 @@ const TestJobCard = dynamic( () => import("../components/runCards/TestJob/TestJobCard").then((mod) => mod.TestJobCard), { loading: () => }, ); - /** * Page allowing the user to run jobs and applications */ const Run = () => { const [executionTypes, setExecutionTypes] = useState(["application", "job"]); const [searchValue, setSearchValue] = useState(""); + const [debouncedSearchValue, setDebouncedSearchValue] = useState(""); + const inputRef = useKeyboardFocus(); + + // Create debounced search function + const debouncedSetSearch = useMemo( + () => debounce((value: string) => setDebouncedSearchValue(value), 300), + [] + ); + + // Update debounced value when search value changes + useEffect(() => { + debouncedSetSearch(searchValue); + return () => debouncedSetSearch.cancel(); + }, [searchValue, debouncedSetSearch]); const currentProject = useCurrentProject(); @@ -44,41 +60,54 @@ const Run = () => { const applications = applicationsData?.applications; const { - data: jobsData, + data: jobs, isLoading: isJobsLoading, isError: isJobsError, error: jobsError, - } = useGetJobs({ project_id: currentProject?.project_id }); - const jobs = jobsData?.jobs; + } = useGetJobs( + { project_id: currentProject?.project_id }, + { query: { select: (data) => data.jobs } }, + ); + + // Memoize filtered applications + const filteredApplications = useMemo(() => { + if (!applications) {return [];} + return applications.filter(({ kind }) => search([kind], debouncedSearchValue)); + }, [applications, debouncedSearchValue]); + + // Memoize filtered and grouped jobs + const filteredAndGroupedJobs = useMemo(() => { + if (!jobs) {return {};} + const filteredJobs = jobs + .filter(({ keywords, category, name, job, description }) => + search([keywords, category, name, job, description], debouncedSearchValue), + ) + .filter(job => !job.replaced_by); + + return groupBy(filteredJobs, (job) => `${job.collection}+${job.job}`); + }, [jobs, debouncedSearchValue]); + + // Memoize event handlers + const handleSearchChange = useCallback((event: React.ChangeEvent) => { + setSearchValue(event.target.value); + }, []); + + const handleExecutionTypesChange = useCallback((event: any) => { + setExecutionTypes(event.target.value as string[]); + }, []); const cards = useMemo(() => { - const applicationCards = - applications - // Filter the apps by the search value - ?.filter(({ kind }) => search([kind], searchValue)) - // Then create a card for each - .map((app) => ( - - - - )) ?? []; - - // Filter the apps by the search value - const filteredJobs = jobs?.filter(({ keywords, category, name, job, description }) => - search([keywords, category, name, job, description], searchValue), - ); - - // Then create a card for each - const jobCards = - filteredJobs?.map((job) => ( - - - - )) ?? []; + const applicationCards = filteredApplications.map((app) => ( + + + + )); + + const jobCards = Object.entries(filteredAndGroupedJobs).map(([key, jobs]) => ( + + + + )); process.env.NODE_ENV === "development" && jobCards.push(); @@ -92,11 +121,10 @@ const Run = () => { } return jobCards; }, [ - applications, + filteredApplications, + filteredAndGroupedJobs, currentProject?.project_id, executionTypes, - jobs, - searchValue, hasPermissionToRun, ]); @@ -119,9 +147,7 @@ const Run = () => { slotProps={{ select: { multiple: true, - onChange: (event) => { - setExecutionTypes(event.target.value as string[]); - }, + onChange: handleExecutionTypesChange, }, }} value={executionTypes} @@ -135,8 +161,9 @@ const Run = () => { setSearchValue(event.target.value)} + onChange={handleSearchChange} /> diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 000000000..725884a0a --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,26 @@ +/** + * Detects if the current platform is macOS using modern APIs + */ +export const isMac = () => { + if (typeof navigator === 'undefined') { + return false; + } + + // Modern approach using userAgentData (Chrome 90+) + if ('userAgentData' in navigator) { + const userAgentData = (navigator as any).userAgentData; + if (userAgentData?.platform) { + return userAgentData.platform === 'macOS'; + } + } + + // Fallback to userAgent string parsing + return (/Mac|iPhone|iPad|iPod/u).test(navigator.userAgent); +}; + +/** + * Returns the platform-specific keyboard shortcut text for search + */ +export const getSearchShortcut = () => { + return isMac() ? '⌘F' : 'Ctrl+F'; +};