Skip to content

Commit 76df00a

Browse files
committed
feat: add keyboard shortcut for search field
1 parent 0754dab commit 76df00a

File tree

5 files changed

+154
-43
lines changed

5 files changed

+154
-43
lines changed

src/components/SearchTextField.tsx

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
1+
import { forwardRef } from "react";
2+
13
import { SearchRounded as SearchRoundedIcon } from "@mui/icons-material";
24
import { InputAdornment, TextField, type TextFieldProps } from "@mui/material";
35

6+
import { getSearchShortcut } from "../utils/platform";
7+
48
/**
5-
* MuiTextField with a search icon at the end
9+
* MuiTextField with a search icon at the end and platform-specific keyboard shortcut in label
610
*/
7-
export const SearchTextField = (TextFieldProps: TextFieldProps) => (
8-
<TextField
9-
label="Search"
10-
{...TextFieldProps}
11-
slotProps={{
12-
input: {
13-
endAdornment: (
14-
<InputAdornment position="end">
15-
<SearchRoundedIcon />
16-
</InputAdornment>
17-
),
18-
},
19-
}}
20-
/>
11+
export const SearchTextField = forwardRef<HTMLDivElement, TextFieldProps>(
12+
(TextFieldProps, ref) => (
13+
<TextField
14+
label={`Search (${getSearchShortcut()})`}
15+
ref={ref}
16+
{...TextFieldProps}
17+
slotProps={{
18+
input: {
19+
endAdornment: (
20+
<InputAdornment position="end">
21+
<SearchRoundedIcon />
22+
</InputAdornment>
23+
),
24+
},
25+
}}
26+
/>
27+
),
2128
);
29+
30+
SearchTextField.displayName = "SearchTextField";

src/hooks/useKeyboardFocus.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useEffect, useRef } from "react";
2+
3+
import { isMac } from "../utils/platform";
4+
5+
/**
6+
* Hook that provides a ref and keyboard shortcut (Ctrl+F/Cmd+F) to focus an input field
7+
*/
8+
export const useKeyboardFocus = () => {
9+
const inputRef = useRef<HTMLDivElement>(null);
10+
11+
useEffect(() => {
12+
const handleKeyDown = (event: KeyboardEvent) => {
13+
const isCorrectModifier = isMac() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
14+
15+
if (isCorrectModifier && event.key === 'f') {
16+
event.preventDefault();
17+
inputRef.current?.querySelector('input')?.focus();
18+
}
19+
};
20+
21+
document.addEventListener('keydown', handleKeyDown);
22+
return () => document.removeEventListener('keydown', handleKeyDown);
23+
}, []);
24+
25+
return inputRef;
26+
};

src/hooks/useSearchFieldFocus.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useEffect, useRef } from "react";
2+
3+
/**
4+
* Hook that provides a ref and keyboard shortcut (Ctrl+F/Cmd+F) to focus an input field
5+
*/
6+
export const useKeyboardFocus = () => {
7+
const inputRef = useRef<HTMLDivElement>(null);
8+
9+
useEffect(() => {
10+
const handleKeyDown = (event: KeyboardEvent) => {
11+
if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
12+
event.preventDefault();
13+
inputRef.current?.querySelector('input')?.focus();
14+
}
15+
};
16+
17+
document.addEventListener('keydown', handleKeyDown);
18+
return () => document.removeEventListener('keydown', handleKeyDown);
19+
}, []);
20+
21+
return inputRef;
22+
};

src/pages/run.tsx

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22

33
import { useGetApplications } from "@squonk/data-manager-client/application";
44
import { useGetJobs } from "@squonk/data-manager-client/job";
55

66
import { withPageAuthRequired as withPageAuthRequiredCSR } from "@auth0/nextjs-auth0/client";
77
import { Alert, Container, Grid2 as Grid, MenuItem, TextField } from "@mui/material";
88
import groupBy from "just-group-by";
9+
import { debounce } from "lodash-es";
910
import dynamic from "next/dynamic";
1011
import Head from "next/head";
1112

@@ -17,6 +18,7 @@ import { TEST_JOB_ID } from "../components/runCards/TestJob/jobId";
1718
import { SearchTextField } from "../components/SearchTextField";
1819
import { AS_ROLES, DM_ROLES } from "../constants/auth";
1920
import { useCurrentProject, useIsUserAdminOrEditorOfCurrentProject } from "../hooks/projectHooks";
21+
import { useKeyboardFocus } from "../hooks/useKeyboardFocus";
2022
import Layout from "../layouts/Layout";
2123
import { search } from "../utils/app/searches";
2224

@@ -30,6 +32,20 @@ const TestJobCard = dynamic(
3032
const Run = () => {
3133
const [executionTypes, setExecutionTypes] = useState(["application", "job"]);
3234
const [searchValue, setSearchValue] = useState("");
35+
const [debouncedSearchValue, setDebouncedSearchValue] = useState("");
36+
const inputRef = useKeyboardFocus();
37+
38+
// Create debounced search function
39+
const debouncedSetSearch = useMemo(
40+
() => debounce((value: string) => setDebouncedSearchValue(value), 300),
41+
[]
42+
);
43+
44+
// Update debounced value when search value changes
45+
useEffect(() => {
46+
debouncedSetSearch(searchValue);
47+
return () => debouncedSetSearch.cancel();
48+
}, [searchValue, debouncedSetSearch]);
3349

3450
const currentProject = useCurrentProject();
3551

@@ -53,27 +69,41 @@ const Run = () => {
5369
{ query: { select: (data) => data.jobs } },
5470
);
5571

72+
// Memoize filtered applications
73+
const filteredApplications = useMemo(() => {
74+
if (!applications) {return [];}
75+
return applications.filter(({ kind }) => search([kind], debouncedSearchValue));
76+
}, [applications, debouncedSearchValue]);
77+
78+
// Memoize filtered and grouped jobs
79+
const filteredAndGroupedJobs = useMemo(() => {
80+
if (!jobs) {return {};}
81+
const filteredJobs = jobs
82+
.filter(({ keywords, category, name, job, description }) =>
83+
search([keywords, category, name, job, description], debouncedSearchValue),
84+
)
85+
.filter(job => !job.replaced_by);
86+
87+
return groupBy(filteredJobs, (job) => `${job.collection}+${job.job}`);
88+
}, [jobs, debouncedSearchValue]);
89+
90+
// Memoize event handlers
91+
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
92+
setSearchValue(event.target.value);
93+
}, []);
94+
95+
const handleExecutionTypesChange = useCallback((event: any) => {
96+
setExecutionTypes(event.target.value as string[]);
97+
}, []);
98+
5699
const cards = useMemo(() => {
57-
const applicationCards =
58-
applications
59-
// Filter the apps by the search value
60-
?.filter(({ kind }) => search([kind], searchValue))
61-
// Then create a card for each
62-
.map((app) => (
63-
<Grid key={app.application_id} size={{ md: 3, sm: 6, xs: 12 }}>
64-
<ApplicationCard app={app} projectId={currentProject?.project_id} />
65-
</Grid>
66-
)) ?? [];
67-
68-
// Filter the apps by the search value
69-
const filteredJobs = (jobs ?? []).filter(({ keywords, category, name, job, description }) =>
70-
search([keywords, category, name, job, description], searchValue),
71-
).filter(job => !job.replaced_by);
72-
73-
const groupedJobObjects = groupBy(filteredJobs, (job) => `${job.collection}+${job.job}`)
74-
75-
// Then create a card for each
76-
const jobCards = Object.entries(groupedJobObjects).map(([key, jobs]) => (
100+
const applicationCards = filteredApplications.map((app) => (
101+
<Grid key={app.application_id} size={{ md: 3, sm: 6, xs: 12 }}>
102+
<ApplicationCard app={app} projectId={currentProject?.project_id} />
103+
</Grid>
104+
));
105+
106+
const jobCards = Object.entries(filteredAndGroupedJobs).map(([key, jobs]) => (
77107
<Grid key={key} size={{ md: 3, sm: 6, xs: 12 }}>
78108
<JobCard disabled={!hasPermissionToRun} job={jobs} projectId={currentProject?.project_id} />
79109
</Grid>
@@ -91,11 +121,10 @@ const Run = () => {
91121
}
92122
return jobCards;
93123
}, [
94-
applications,
124+
filteredApplications,
125+
filteredAndGroupedJobs,
95126
currentProject?.project_id,
96127
executionTypes,
97-
jobs,
98-
searchValue,
99128
hasPermissionToRun,
100129
]);
101130

@@ -118,9 +147,7 @@ const Run = () => {
118147
slotProps={{
119148
select: {
120149
multiple: true,
121-
onChange: (event) => {
122-
setExecutionTypes(event.target.value as string[]);
123-
},
150+
onChange: handleExecutionTypesChange,
124151
},
125152
}}
126153
value={executionTypes}
@@ -134,8 +161,9 @@ const Run = () => {
134161
<Grid size={{ md: 4, sm: 6, xs: 12 }} sx={{ ml: "auto" }}>
135162
<SearchTextField
136163
fullWidth
164+
ref={inputRef}
137165
value={searchValue}
138-
onChange={(event) => setSearchValue(event.target.value)}
166+
onChange={handleSearchChange}
139167
/>
140168
</Grid>
141169
</Grid>

src/utils/platform.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Detects if the current platform is macOS using modern APIs
3+
*/
4+
export const isMac = () => {
5+
if (typeof navigator === 'undefined') {
6+
return false;
7+
}
8+
9+
// Modern approach using userAgentData (Chrome 90+)
10+
if ('userAgentData' in navigator) {
11+
const userAgentData = (navigator as any).userAgentData;
12+
if (userAgentData?.platform) {
13+
return userAgentData.platform === 'macOS';
14+
}
15+
}
16+
17+
// Fallback to userAgent string parsing
18+
return (/Mac|iPhone|iPad|iPod/u).test(navigator.userAgent);
19+
};
20+
21+
/**
22+
* Returns the platform-specific keyboard shortcut text for search
23+
*/
24+
export const getSearchShortcut = () => {
25+
return isMac() ? '⌘F' : 'Ctrl+F';
26+
};

0 commit comments

Comments
 (0)