Skip to content

Commit c6f527b

Browse files
feat: add responsivity to listing search (#1741)
- Moves control of the searchbox from each page to the topbar; - Improves custom dialog to be more custom; - Improves searchbox with responsivity showing a small button and a dialog on small screens Closes #1416
1 parent a5fecbf commit c6f527b

10 files changed

Lines changed: 257 additions & 220 deletions

File tree

dashboard/src/components/Dialog/CustomDialog.tsx

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ export type ActionLink = ActionLinkWithIntl | ActionLinkWithLabel;
3636

3737
export interface CustomDialogProps {
3838
trigger: ReactNode;
39-
titleIntlId: MessagesKey;
40-
descriptionIntlId: MessagesKey;
39+
titleIntlId?: MessagesKey;
40+
descriptionIntlId?: MessagesKey;
41+
content?: ReactNode;
42+
contentClassName?: string;
43+
showOverlay?: boolean;
44+
showCloseButton?: boolean;
4145
footerClassName?: string;
4246
actionLinks?: ActionLink[];
4347
showCancel?: boolean;
@@ -47,47 +51,67 @@ export const CustomDialog = ({
4751
trigger,
4852
titleIntlId,
4953
descriptionIntlId,
54+
content,
55+
contentClassName,
56+
showOverlay = true,
57+
showCloseButton = true,
5058
footerClassName,
5159
actionLinks = [],
5260
showCancel = true,
5361
}: CustomDialogProps): JSX.Element => {
62+
const hasHeader = Boolean(titleIntlId || descriptionIntlId);
63+
const hasFooter = actionLinks.length > 0 || showCancel;
64+
5465
return (
5566
<Dialog>
5667
<DialogTrigger asChild>{trigger}</DialogTrigger>
57-
<DialogContent>
58-
<DialogHeader>
59-
<DialogTitle className="leading-normal tracking-normal">
60-
<FormattedMessage id={titleIntlId} />
61-
</DialogTitle>
62-
<DialogDescription>
63-
<FormattedMessage id={descriptionIntlId} />
64-
</DialogDescription>
65-
</DialogHeader>
66-
<DialogFooter className={footerClassName}>
67-
{actionLinks.map((actionLink, index) => (
68-
<Button key={index} asChild>
69-
<a
70-
href={actionLink.href}
71-
target={actionLink.target ?? '_blank'}
72-
rel={actionLink.rel ?? 'noreferrer'}
73-
>
74-
{actionLink.intlId ? (
75-
<FormattedMessage id={actionLink.intlId} />
76-
) : (
77-
actionLink.label
78-
)}
79-
{actionLink.icon}
80-
</a>
81-
</Button>
82-
))}
83-
{showCancel && (
84-
<DialogClose asChild>
85-
<Button variant={'outline'}>
86-
<FormattedMessage id="global.cancel" />
68+
<DialogContent
69+
className={contentClassName}
70+
showOverlay={showOverlay}
71+
showCloseButton={showCloseButton}
72+
>
73+
{hasHeader && (
74+
<DialogHeader>
75+
{titleIntlId && (
76+
<DialogTitle className="leading-normal tracking-normal">
77+
<FormattedMessage id={titleIntlId} />
78+
</DialogTitle>
79+
)}
80+
{descriptionIntlId && (
81+
<DialogDescription>
82+
<FormattedMessage id={descriptionIntlId} />
83+
</DialogDescription>
84+
)}
85+
</DialogHeader>
86+
)}
87+
{content}
88+
{hasFooter && (
89+
<DialogFooter className={footerClassName}>
90+
{actionLinks.map((actionLink, index) => (
91+
<Button key={index} asChild>
92+
<a
93+
href={actionLink.href}
94+
target={actionLink.target ?? '_blank'}
95+
rel={actionLink.rel ?? 'noreferrer'}
96+
>
97+
{actionLink.intlId ? (
98+
<FormattedMessage id={actionLink.intlId} />
99+
) : (
100+
actionLink.label
101+
)}
102+
{actionLink.icon}
103+
</a>
87104
</Button>
88-
</DialogClose>
89-
)}
90-
</DialogFooter>
105+
))}
106+
{showCancel && (
107+
<DialogClose asChild>
108+
<Button variant={'outline'}>
109+
<FormattedMessage id="global.cancel" />
110+
</Button>
111+
</DialogClose>
112+
)}
113+
</DialogFooter>
114+
)}
91115
</DialogContent>
92116
</Dialog>
93117
);
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { useMatches, useNavigate, useSearch } from '@tanstack/react-router';
2+
import type { ChangeEvent, JSX } from 'react';
3+
import { useCallback, useMemo } from 'react';
4+
import { useIntl } from 'react-intl';
5+
import { HiSearch } from 'react-icons/hi';
6+
7+
import DebounceInput from '@/components/DebounceInput/DebounceInput';
8+
import { CustomDialog } from '@/components/Dialog/CustomDialog';
9+
10+
// Relates the type of listing to the corresponding search key
11+
const forwardFields: Record<string, string> = {
12+
tree: 'treeSearch',
13+
hardware: 'hardwareSearch',
14+
issue: 'issueSearch',
15+
};
16+
17+
interface ISearchData {
18+
currentSearch?: string;
19+
searchPlaceholder: string;
20+
navigateTarget: string;
21+
}
22+
23+
export const SearchBoxNavigate = (): JSX.Element => {
24+
const matches = useMatches();
25+
const routeInfo = useMemo(() => {
26+
const lastMatch = matches[matches.length - 1];
27+
const cleanFullPath = lastMatch?.fullPath.replace(/\//g, '') ?? '';
28+
29+
if (['tree', 'treev1', 'treev2'].includes(cleanFullPath)) {
30+
return 'tree';
31+
}
32+
33+
if (['hardware', 'hardwarev1'].includes(cleanFullPath)) {
34+
return 'hardware';
35+
}
36+
37+
if (cleanFullPath === 'issues') {
38+
return 'issue';
39+
}
40+
41+
return 'unknown';
42+
}, [matches]);
43+
const { formatMessage } = useIntl();
44+
45+
const { treeSearch, hardwareSearch, issueSearch } = useSearch({
46+
strict: false,
47+
});
48+
const searchData = useMemo((): ISearchData => {
49+
switch (routeInfo) {
50+
case 'tree':
51+
return {
52+
currentSearch: treeSearch,
53+
searchPlaceholder: formatMessage({ id: 'tree.searchPlaceholder' }),
54+
navigateTarget: 'treeSearch',
55+
};
56+
case 'hardware':
57+
return {
58+
currentSearch: hardwareSearch,
59+
searchPlaceholder: formatMessage({
60+
id: 'hardware.searchPlaceholder',
61+
}),
62+
navigateTarget: 'hardwareSearch',
63+
};
64+
case 'issue':
65+
return {
66+
currentSearch: issueSearch,
67+
searchPlaceholder: formatMessage({
68+
id: 'issue.searchPlaceholder',
69+
}),
70+
navigateTarget: 'issueSearch',
71+
};
72+
default:
73+
return {
74+
currentSearch: '',
75+
searchPlaceholder: '',
76+
navigateTarget: '',
77+
};
78+
}
79+
}, [routeInfo, treeSearch, formatMessage, hardwareSearch, issueSearch]);
80+
81+
const navigate = useNavigate();
82+
83+
const onInputSearchTextChange = useCallback(
84+
(e: ChangeEvent<HTMLInputElement>) => {
85+
const value = e.target.value;
86+
// using routeInfo as a dependency instead of searchData so that we don't depend on useSearch
87+
const forwardSearch = { [forwardFields[routeInfo]]: value };
88+
89+
navigate({
90+
to: '.',
91+
search: previousSearch => ({
92+
...previousSearch,
93+
...forwardSearch,
94+
}),
95+
});
96+
},
97+
[navigate, routeInfo],
98+
);
99+
100+
const sharedInput = useMemo(() => {
101+
return (
102+
<DebounceInput
103+
key={`${routeInfo}`}
104+
debouncedSideEffect={onInputSearchTextChange}
105+
type="text"
106+
autoFocus
107+
startingValue={searchData.currentSearch}
108+
placeholder={searchData.searchPlaceholder}
109+
/>
110+
);
111+
}, [
112+
onInputSearchTextChange,
113+
routeInfo,
114+
searchData.currentSearch,
115+
searchData.searchPlaceholder,
116+
]);
117+
118+
if (routeInfo === 'unknown') {
119+
console.error('SearchBoxNavigate shown on an invalid route.');
120+
console.error('Route is ', matches);
121+
return <></>;
122+
}
123+
124+
return (
125+
<div className="flex w-full max-w-3xl items-center">
126+
{/* Mobile: icon button */}
127+
<CustomDialog
128+
trigger={
129+
<button className="min-[475px]:hidden">
130+
<HiSearch className="size-6" />
131+
</button>
132+
}
133+
content={sharedInput}
134+
contentClassName="w-9/10 top-10 border-0 bg-transparent p-0 shadow-none min-[475px]:hidden"
135+
showCloseButton={false}
136+
showCancel={false}
137+
/>
138+
139+
{/* Desktop: inline input */}
140+
<div className="hidden w-full min-[475px]:block">{sharedInput}</div>
141+
</div>
142+
);
143+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { SearchBoxNavigate } from './SearchBoxNavigate';
2+
3+
export { SearchBoxNavigate };

dashboard/src/components/TopBar/TopBar.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { useOrigins } from '@/api/origin';
1717
import { Button } from '@/components/ui/button';
1818
import MobileSideMenu from '@/components/SideMenu/MobileSideMenu';
1919

20+
import { SearchBoxNavigate } from '@/components/SearchBoxNavigate';
21+
2022
const OriginSelect = ({
2123
isHardwarePath,
2224
}: {
@@ -75,7 +77,7 @@ const OriginSelect = ({
7577

7678
return (
7779
<div className="flex items-center">
78-
<span className="text-dim-gray mr-4 text-base font-medium">
80+
<span className="text-dim-gray mr-4 hidden text-base font-medium sm:block">
7981
<FormattedMessage id="global.origin" />
8082
</span>
8183
<Select
@@ -120,21 +122,26 @@ const TopBar = (): JSX.Element => {
120122
const lastMatch = matches[matches.length - 1];
121123
const firstUrlLocation = lastMatch?.pathname.split('/')[1] ?? '';
122124
const cleanFullPath = lastMatch?.fullPath.replace(/\//g, '') ?? '';
125+
const isTreeListing = ['tree', 'treev1', 'treev2'].includes(cleanFullPath);
126+
const isListingPage =
127+
isTreeListing ||
128+
['hardware', 'hardwarev1', 'issues'].includes(cleanFullPath);
123129

124130
return {
125131
firstUrlLocation,
126-
isTreeListing: ['tree', 'treev1', 'treev2'].includes(cleanFullPath),
132+
isTreeListing: isTreeListing,
127133
isHardwarePage: cleanFullPath.includes('hardware'),
134+
isListingPage: isListingPage,
128135
};
129136
}, [matches]);
130137

131138
const basePath = redirectStateFrom ?? routeInfo.firstUrlLocation;
132139

133140
return (
134141
<>
135-
<div className="fixed top-0 z-10 flex h-20 w-full bg-white px-6 md:px-16">
142+
<div className="fixed top-0 z-10 flex h-20 w-full max-w-full bg-white px-6 md:max-w-[calc(100%-14rem)] md:px-16">
136143
<div className="flex w-full flex-row items-center justify-between">
137-
<div className="flex flex-row items-center gap-4">
144+
<div className="flex w-full flex-row items-center gap-4">
138145
<Button
139146
variant="ghost"
140147
size="icon"
@@ -144,12 +151,15 @@ const TopBar = (): JSX.Element => {
144151
>
145152
<HiMenu className="size-6" />
146153
</Button>
147-
<span className="mr-10 text-2xl">
154+
<span className="mr-2 text-2xl sm:mr-10">
148155
<TitleName basePath={basePath} />
149156
</span>
150157
{(routeInfo.isTreeListing || routeInfo.isHardwarePage) && (
151158
<OriginSelect isHardwarePath={routeInfo.isHardwarePage} />
152159
)}
160+
<span className="ml-0 flex w-full px-6 lg:ml-14">
161+
{routeInfo.isListingPage && <SearchBoxNavigate />}
162+
</span>
153163
</div>
154164
</div>
155165
</div>

dashboard/src/components/ui/dialog.tsx

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,28 @@ const DialogOverlay = React.forwardRef<
2929
));
3030
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
3131

32+
interface DialogContentProps
33+
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
34+
showOverlay?: boolean;
35+
showCloseButton?: boolean;
36+
}
37+
3238
const DialogContent = React.forwardRef<
3339
React.ElementRef<typeof DialogPrimitive.Content>,
34-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35-
>(({ className, children, ...props }, ref) => (
40+
DialogContentProps
41+
>(
42+
(
43+
{
44+
className,
45+
children,
46+
showOverlay = true,
47+
showCloseButton = true,
48+
...props
49+
},
50+
ref,
51+
) => (
3652
<DialogPortal>
37-
<DialogOverlay />
53+
{showOverlay && <DialogOverlay />}
3854
<DialogPrimitive.Content
3955
ref={ref}
4056
className={cn(
@@ -44,13 +60,16 @@ const DialogContent = React.forwardRef<
4460
{...props}
4561
>
4662
{children}
47-
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-400">
48-
<X className="h-4 w-4" />
49-
<span className="sr-only">Close</span>
50-
</DialogPrimitive.Close>
63+
{showCloseButton && (
64+
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-400">
65+
<X className="h-4 w-4" />
66+
<span className="sr-only">Close</span>
67+
</DialogPrimitive.Close>
68+
)}
5169
</DialogPrimitive.Content>
5270
</DialogPortal>
53-
));
71+
),
72+
);
5473
DialogContent.displayName = DialogPrimitive.Content.displayName;
5574

5675
const DialogHeader = ({

0 commit comments

Comments
 (0)