Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 59 additions & 35 deletions dashboard/src/components/Dialog/CustomDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ export type ActionLink = ActionLinkWithIntl | ActionLinkWithLabel;

export interface CustomDialogProps {
trigger: ReactNode;
titleIntlId: MessagesKey;
descriptionIntlId: MessagesKey;
titleIntlId?: MessagesKey;
descriptionIntlId?: MessagesKey;
content?: ReactNode;
contentClassName?: string;
showOverlay?: boolean;
showCloseButton?: boolean;
footerClassName?: string;
actionLinks?: ActionLink[];
showCancel?: boolean;
Expand All @@ -47,47 +51,67 @@ export const CustomDialog = ({
trigger,
titleIntlId,
descriptionIntlId,
content,
contentClassName,
showOverlay = true,
showCloseButton = true,
footerClassName,
actionLinks = [],
showCancel = true,
}: CustomDialogProps): JSX.Element => {
const hasHeader = Boolean(titleIntlId || descriptionIntlId);
const hasFooter = actionLinks.length > 0 || showCancel;

return (
<Dialog>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="leading-normal tracking-normal">
<FormattedMessage id={titleIntlId} />
</DialogTitle>
<DialogDescription>
<FormattedMessage id={descriptionIntlId} />
</DialogDescription>
</DialogHeader>
<DialogFooter className={footerClassName}>
{actionLinks.map((actionLink, index) => (
<Button key={index} asChild>
<a
href={actionLink.href}
target={actionLink.target ?? '_blank'}
rel={actionLink.rel ?? 'noreferrer'}
>
{actionLink.intlId ? (
<FormattedMessage id={actionLink.intlId} />
) : (
actionLink.label
)}
{actionLink.icon}
</a>
</Button>
))}
{showCancel && (
<DialogClose asChild>
<Button variant={'outline'}>
<FormattedMessage id="global.cancel" />
<DialogContent
className={contentClassName}
showOverlay={showOverlay}
showCloseButton={showCloseButton}
>
{hasHeader && (
<DialogHeader>
{titleIntlId && (
<DialogTitle className="leading-normal tracking-normal">
<FormattedMessage id={titleIntlId} />
</DialogTitle>
)}
{descriptionIntlId && (
<DialogDescription>
<FormattedMessage id={descriptionIntlId} />
</DialogDescription>
)}
</DialogHeader>
)}
{content}
{hasFooter && (
<DialogFooter className={footerClassName}>
{actionLinks.map((actionLink, index) => (
<Button key={index} asChild>
<a
href={actionLink.href}
target={actionLink.target ?? '_blank'}
rel={actionLink.rel ?? 'noreferrer'}
>
{actionLink.intlId ? (
<FormattedMessage id={actionLink.intlId} />
) : (
actionLink.label
)}
{actionLink.icon}
</a>
</Button>
</DialogClose>
)}
</DialogFooter>
))}
{showCancel && (
<DialogClose asChild>
<Button variant={'outline'}>
<FormattedMessage id="global.cancel" />
</Button>
</DialogClose>
)}
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
Expand Down
143 changes: 143 additions & 0 deletions dashboard/src/components/SearchBoxNavigate/SearchBoxNavigate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useMatches, useNavigate, useSearch } from '@tanstack/react-router';
import type { ChangeEvent, JSX } from 'react';
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { HiSearch } from 'react-icons/hi';

import DebounceInput from '@/components/DebounceInput/DebounceInput';
import { CustomDialog } from '@/components/Dialog/CustomDialog';

// Relates the type of listing to the corresponding search key
const forwardFields: Record<string, string> = {
tree: 'treeSearch',
hardware: 'hardwareSearch',
issue: 'issueSearch',
};

interface ISearchData {
currentSearch?: string;
searchPlaceholder: string;
navigateTarget: string;
}

export const SearchBoxNavigate = (): JSX.Element => {
const matches = useMatches();
const routeInfo = useMemo(() => {
const lastMatch = matches[matches.length - 1];
const cleanFullPath = lastMatch?.fullPath.replace(/\//g, '') ?? '';

if (['tree', 'treev1', 'treev2'].includes(cleanFullPath)) {
return 'tree';
}

if (['hardware', 'hardwarev1'].includes(cleanFullPath)) {
return 'hardware';
}

if (cleanFullPath === 'issues') {
return 'issue';
}

return 'unknown';
}, [matches]);
const { formatMessage } = useIntl();

const { treeSearch, hardwareSearch, issueSearch } = useSearch({
strict: false,
});
const searchData = useMemo((): ISearchData => {
switch (routeInfo) {
case 'tree':
return {
currentSearch: treeSearch,
searchPlaceholder: formatMessage({ id: 'tree.searchPlaceholder' }),
navigateTarget: 'treeSearch',
};
case 'hardware':
return {
currentSearch: hardwareSearch,
searchPlaceholder: formatMessage({
id: 'hardware.searchPlaceholder',
}),
navigateTarget: 'hardwareSearch',
};
case 'issue':
return {
currentSearch: issueSearch,
searchPlaceholder: formatMessage({
id: 'issue.searchPlaceholder',
}),
navigateTarget: 'issueSearch',
};
default:
return {
currentSearch: '',
searchPlaceholder: '',
navigateTarget: '',
};
}
}, [routeInfo, treeSearch, formatMessage, hardwareSearch, issueSearch]);

const navigate = useNavigate();

const onInputSearchTextChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// using routeInfo as a dependency instead of searchData so that we don't depend on useSearch
const forwardSearch = { [forwardFields[routeInfo]]: value };

navigate({
to: '.',
search: previousSearch => ({
...previousSearch,
...forwardSearch,
}),
});
},
[navigate, routeInfo],
);

const sharedInput = useMemo(() => {
return (
<DebounceInput
key={`${routeInfo}`}
debouncedSideEffect={onInputSearchTextChange}
type="text"
autoFocus
startingValue={searchData.currentSearch}
placeholder={searchData.searchPlaceholder}
/>
);
}, [
onInputSearchTextChange,
routeInfo,
searchData.currentSearch,
searchData.searchPlaceholder,
]);

if (routeInfo === 'unknown') {
console.error('SearchBoxNavigate shown on an invalid route.');
console.error('Route is ', matches);
return <></>;
}

return (
<div className="flex w-full max-w-3xl items-center">
{/* Mobile: icon button */}
<CustomDialog
trigger={
<button className="min-[475px]:hidden">
<HiSearch className="size-6" />
</button>
}
content={sharedInput}
contentClassName="w-9/10 top-10 border-0 bg-transparent p-0 shadow-none min-[475px]:hidden"
showCloseButton={false}
showCancel={false}
/>

{/* Desktop: inline input */}
<div className="hidden w-full min-[475px]:block">{sharedInput}</div>
</div>
);
};
3 changes: 3 additions & 0 deletions dashboard/src/components/SearchBoxNavigate/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SearchBoxNavigate } from './SearchBoxNavigate';

export { SearchBoxNavigate };
20 changes: 15 additions & 5 deletions dashboard/src/components/TopBar/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { useOrigins } from '@/api/origin';
import { Button } from '@/components/ui/button';
import MobileSideMenu from '@/components/SideMenu/MobileSideMenu';

import { SearchBoxNavigate } from '@/components/SearchBoxNavigate';

const OriginSelect = ({
isHardwarePath,
}: {
Expand Down Expand Up @@ -75,7 +77,7 @@ const OriginSelect = ({

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

return {
firstUrlLocation,
isTreeListing: ['tree', 'treev1', 'treev2'].includes(cleanFullPath),
isTreeListing: isTreeListing,
isHardwarePage: cleanFullPath.includes('hardware'),
isListingPage: isListingPage,
};
}, [matches]);

const basePath = redirectStateFrom ?? routeInfo.firstUrlLocation;

return (
<>
<div className="fixed top-0 z-10 flex h-20 w-full bg-white px-6 md:px-16">
<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">
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-row items-center gap-4">
<div className="flex w-full flex-row items-center gap-4">
<Button
variant="ghost"
size="icon"
Expand All @@ -144,12 +151,15 @@ const TopBar = (): JSX.Element => {
>
<HiMenu className="size-6" />
</Button>
<span className="mr-10 text-2xl">
<span className="mr-2 text-2xl sm:mr-10">
<TitleName basePath={basePath} />
</span>
{(routeInfo.isTreeListing || routeInfo.isHardwarePage) && (
<OriginSelect isHardwarePath={routeInfo.isHardwarePage} />
)}
<span className="ml-0 flex w-full px-6 lg:ml-14">
{routeInfo.isListingPage && <SearchBoxNavigate />}
</span>
</div>
</div>
</div>
Expand Down
35 changes: 27 additions & 8 deletions dashboard/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,28 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
showOverlay?: boolean;
showCloseButton?: boolean;
}

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
DialogContentProps
>(
(
{
className,
children,
showOverlay = true,
showCloseButton = true,
...props
},
ref,
) => (
<DialogPortal>
<DialogOverlay />
{showOverlay && <DialogOverlay />}
<DialogPrimitive.Content
ref={ref}
className={cn(
Expand All @@ -44,13 +60,16 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton && (
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
),
);
DialogContent.displayName = DialogPrimitive.Content.displayName;

const DialogHeader = ({
Expand Down
Loading