feat(dashboard): Add global search (#9504)
**What**
- Adds the ability to do global searches from cmd + k in the admin.
- The solution is temporary, until we have a proper search API.
**Note**
I have deviated a bit from the design, due to the constraints of this temporary solution:
- We don't have nested items, such as showing variants under a product (don't think having a proper search API will make this any easier, and not entirely sure how we would handle this for cases where a query returns multiple products, which is the only case that is designed)
- I have added a "Load {{count}} more" button instead of doing infinite scrolling, I am assuming the later is the intended behaviour based on the design file, but with 20+ sources of data changing so often it was resulting in some weird behaviours, so settled for the simpler approach for this temporary solution.
- Removed the "Details" label on search results as it seemed a bit repetitive
- I haven't added icons for the different types of search results, as there are only a couple of examples in the design doc, and I wasn't sure what to pick for all the different types of results. If we want to add icons, then I think it's something we can add when we revisit this later, but think its fine to omit, as each group of results is labeled, so they are easy to tell apart.
Resolves CC-574
This commit is contained in:
committed by
GitHub
parent
49a91fd40e
commit
ccd40e6548
@@ -1,13 +1,23 @@
|
||||
import { Photo } from "@medusajs/icons"
|
||||
import { clx } from "@medusajs/ui"
|
||||
|
||||
type ThumbnailProps = {
|
||||
src?: string | null
|
||||
alt?: string
|
||||
size?: "small" | "base"
|
||||
}
|
||||
|
||||
export const Thumbnail = ({ src, alt }: ThumbnailProps) => {
|
||||
export const Thumbnail = ({ src, alt, size = "base" }: ThumbnailProps) => {
|
||||
return (
|
||||
<div className="bg-ui-bg-component flex h-8 w-6 items-center justify-center overflow-hidden rounded-[4px]">
|
||||
<div
|
||||
className={clx(
|
||||
"bg-ui-bg-component flex items-center justify-center overflow-hidden rounded-[4px]",
|
||||
{
|
||||
"h-8 w-6": size === "base",
|
||||
"h-5 w-4": size === "small",
|
||||
}
|
||||
)}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
|
||||
30
packages/admin/dashboard/src/components/search/constants.ts
Normal file
30
packages/admin/dashboard/src/components/search/constants.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export const SEARCH_AREAS = [
|
||||
"all",
|
||||
"order",
|
||||
"product",
|
||||
"productVariant",
|
||||
"collection",
|
||||
"category",
|
||||
"inventory",
|
||||
"customer",
|
||||
"customerGroup",
|
||||
"promotion",
|
||||
"campaign",
|
||||
"priceList",
|
||||
"user",
|
||||
"region",
|
||||
"taxRegion",
|
||||
"returnReason",
|
||||
"salesChannel",
|
||||
"productType",
|
||||
"productTag",
|
||||
"location",
|
||||
"shippingProfile",
|
||||
"publishableApiKey",
|
||||
"secretApiKey",
|
||||
"command",
|
||||
"navigation",
|
||||
] as const
|
||||
|
||||
export const DEFAULT_SEARCH_LIMIT = 3
|
||||
export const SEARCH_LIMIT_INCREMENT = 20
|
||||
@@ -1,66 +1,232 @@
|
||||
import { Badge, Kbd, Text, clx } from "@medusajs/ui"
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
clx,
|
||||
DropdownMenu,
|
||||
IconButton,
|
||||
Kbd,
|
||||
Text,
|
||||
} from "@medusajs/ui"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { Command } from "cmdk"
|
||||
import {
|
||||
Children,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
forwardRef,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { Shortcut, ShortcutType } from "../../providers/keybind-provider"
|
||||
import { useGlobalShortcuts } from "../../providers/keybind-provider/hooks"
|
||||
|
||||
import {
|
||||
ArrowUturnLeft,
|
||||
MagnifyingGlass,
|
||||
Plus,
|
||||
Spinner,
|
||||
TriangleDownMini,
|
||||
} from "@medusajs/icons"
|
||||
import { matchSorter } from "match-sorter"
|
||||
|
||||
import { useSearch } from "../../providers/search-provider"
|
||||
import { Skeleton } from "../common/skeleton"
|
||||
import { Thumbnail } from "../common/thumbnail"
|
||||
import {
|
||||
DEFAULT_SEARCH_LIMIT,
|
||||
SEARCH_AREAS,
|
||||
SEARCH_LIMIT_INCREMENT,
|
||||
} from "./constants"
|
||||
import { SearchArea } from "./types"
|
||||
import { useSearchResults } from "./use-search-results"
|
||||
|
||||
export const Search = () => {
|
||||
const [area, setArea] = useState<SearchArea>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [limit, setLimit] = useState(DEFAULT_SEARCH_LIMIT)
|
||||
const { open, onOpenChange } = useSearch()
|
||||
const globalCommands = useGlobalShortcuts()
|
||||
const location = useLocation()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { staticResults, dynamicResults, isFetching } = useSearchResults({
|
||||
area,
|
||||
limit,
|
||||
q: search,
|
||||
})
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setArea("all")
|
||||
setSearch("")
|
||||
setLimit(DEFAULT_SEARCH_LIMIT)
|
||||
}, [setLimit])
|
||||
|
||||
const handleBack = () => {
|
||||
handleReset()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
handleReset()
|
||||
}
|
||||
|
||||
onOpenChange(open)
|
||||
},
|
||||
[onOpenChange, handleReset]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChange(false)
|
||||
}, [location.pathname, onOpenChange])
|
||||
handleOpenChange(false)
|
||||
}, [location.pathname, handleOpenChange])
|
||||
|
||||
const links = useMemo(() => {
|
||||
const groups = new Map<ShortcutType, Shortcut[]>()
|
||||
const handleSelect = (item: { to?: string; callback?: () => void }) => {
|
||||
handleOpenChange(false)
|
||||
|
||||
globalCommands.forEach((command) => {
|
||||
const group = groups.get(command.type) || []
|
||||
group.push(command)
|
||||
groups.set(command.type, group)
|
||||
})
|
||||
|
||||
return Array.from(groups).map(([title, items]) => ({
|
||||
title,
|
||||
items,
|
||||
}))
|
||||
}, [globalCommands])
|
||||
|
||||
const handleSelect = (shortcut: Shortcut) => {
|
||||
onOpenChange(false)
|
||||
|
||||
if (shortcut.to) {
|
||||
navigate(shortcut.to)
|
||||
if (item.to) {
|
||||
navigate(item.to)
|
||||
return
|
||||
}
|
||||
|
||||
if (shortcut.callback) {
|
||||
shortcut.callback()
|
||||
if (item.callback) {
|
||||
item.callback()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowMore = (area: SearchArea) => {
|
||||
if (area === "all") {
|
||||
setLimit(DEFAULT_SEARCH_LIMIT)
|
||||
} else {
|
||||
setLimit(SEARCH_LIMIT_INCREMENT)
|
||||
}
|
||||
setArea(area)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setLimit((l) => l + SEARCH_LIMIT_INCREMENT)
|
||||
}
|
||||
|
||||
const filteredStaticResults = useMemo(() => {
|
||||
const filteredResults: typeof staticResults = []
|
||||
|
||||
staticResults.forEach((group) => {
|
||||
const filteredItems = matchSorter(group.items, search, {
|
||||
keys: ["label"],
|
||||
})
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
filteredResults.push({
|
||||
...group,
|
||||
items: filteredItems,
|
||||
})
|
||||
})
|
||||
|
||||
return filteredResults
|
||||
}, [staticResults, search])
|
||||
|
||||
const handleSearch = (q: string) => {
|
||||
setSearch(q)
|
||||
listRef.current?.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
const showLoading = useMemo(() => {
|
||||
return isFetching && !dynamicResults.length && !filteredStaticResults.length
|
||||
}, [isFetching, dynamicResults, filteredStaticResults])
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder={t("app.search.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("general.noResultsTitle")}</CommandEmpty>
|
||||
{links.map((group) => {
|
||||
<CommandDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<CommandInput
|
||||
isFetching={isFetching}
|
||||
ref={inputRef}
|
||||
area={area}
|
||||
setArea={setArea}
|
||||
value={search}
|
||||
onValueChange={handleSearch}
|
||||
onBack={area !== "all" ? handleBack : undefined}
|
||||
placeholder={t("app.search.placeholder")}
|
||||
/>
|
||||
<CommandList ref={listRef}>
|
||||
{showLoading && <CommandLoading />}
|
||||
{dynamicResults.map((group) => {
|
||||
return (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
{group.items.map((item) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={item.value}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
{item.thumbnail && (
|
||||
<Thumbnail
|
||||
alt={item.title}
|
||||
src={item.thumbnail}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
{item.subtitle && (
|
||||
<span className="text-ui-fg-muted">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{group.hasMore && area === "all" && (
|
||||
<CommandItem
|
||||
onSelect={() => handleShowMore(group.area)}
|
||||
hidden={true}
|
||||
value={`${group.title}:show:more`} // Prevent the "Show more" buttons across groups from sharing the same value/state
|
||||
>
|
||||
<div className="text-ui-fg-muted flex items-center gap-x-3">
|
||||
<Plus />
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("app.search.showMore")}
|
||||
</Text>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
{group.hasMore && area === group.area && (
|
||||
<CommandItem
|
||||
onSelect={handleLoadMore}
|
||||
hidden={true}
|
||||
value={`${group.title}:load:more`}
|
||||
>
|
||||
<div className="text-ui-fg-muted flex items-center gap-x-3">
|
||||
<Plus />
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("app.search.loadMore", {
|
||||
count: Math.min(
|
||||
SEARCH_LIMIT_INCREMENT,
|
||||
group.count - limit
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
{filteredStaticResults.map((group) => {
|
||||
return (
|
||||
<CommandGroup
|
||||
key={group.title}
|
||||
@@ -97,6 +263,7 @@ export const Search = () => {
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
{!showLoading && <CommandEmpty q={search} />}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
@@ -107,6 +274,7 @@ const CommandPalette = forwardRef<
|
||||
ComponentPropsWithoutRef<typeof Command>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
@@ -117,17 +285,36 @@ const CommandPalette = forwardRef<
|
||||
))
|
||||
CommandPalette.displayName = Command.displayName
|
||||
|
||||
interface CommandDialogProps extends Dialog.DialogProps {}
|
||||
interface CommandDialogProps extends Dialog.DialogProps {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const preserveHeight = useMemo(() => {
|
||||
return props.isLoading && Children.count(children) === 0
|
||||
}, [props.isLoading, children])
|
||||
|
||||
return (
|
||||
<Dialog.Root {...props}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
|
||||
<Dialog.Content className="bg-ui-bg-base shadow-elevation-modal fixed left-[50%] top-[50%] flex max-h-[calc(100%-16px)] w-[calc(100%-16px)] min-w-0 max-w-2xl translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl p-0">
|
||||
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground flex h-full flex-col overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input]]:h-[52px]">
|
||||
<Dialog.Content
|
||||
className={clx(
|
||||
"bg-ui-bg-base shadow-elevation-modal fixed left-[50%] top-[50%] flex max-h-[calc(100%-16px)] w-[calc(100%-16px)] min-w-0 max-w-2xl translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-xl p-0",
|
||||
{
|
||||
"h-[300px]": preserveHeight, // Prevents the dialog from collapsing when loading async results and before the no results message is displayed
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Dialog.Title className="sr-only">
|
||||
{t("app.search.title")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="sr-only">
|
||||
{t("app.search.description")}
|
||||
</Dialog.Description>
|
||||
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground flex h-full flex-col overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0">
|
||||
{children}
|
||||
</CommandPalette>
|
||||
<div className="bg-ui-bg-field text-ui-fg-subtle flex items-center justify-end border-t px-4 py-3">
|
||||
@@ -158,27 +345,117 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
|
||||
const CommandInput = forwardRef<
|
||||
ElementRef<typeof Command.Input>,
|
||||
ComponentPropsWithoutRef<typeof Command.Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
ComponentPropsWithoutRef<typeof Command.Input> & {
|
||||
area: SearchArea
|
||||
setArea: (area: SearchArea) => void
|
||||
isFetching: boolean
|
||||
onBack?: () => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
value,
|
||||
onValueChange,
|
||||
area,
|
||||
setArea,
|
||||
isFetching,
|
||||
onBack,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const innerRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b">
|
||||
<div className="px-4 pt-4">
|
||||
{/* TODO: Add filter once we have search engine */}
|
||||
<Badge size="2xsmall">{t("app.search.allAreas")}</Badge>
|
||||
useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
|
||||
ref,
|
||||
() => innerRef.current
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b">
|
||||
<div className="px-4 pt-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Badge
|
||||
size="2xsmall"
|
||||
className="hover:bg-ui-bg-base-pressed transition-fg cursor-pointer"
|
||||
>
|
||||
{t(`app.search.groups.${area}`)}
|
||||
<TriangleDownMini className="text-ui-fg-muted" />
|
||||
</Badge>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align="start"
|
||||
className="h-full max-h-[360px] overflow-auto"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
innerRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.RadioGroup
|
||||
value={area}
|
||||
onValueChange={(v) => setArea(v as SearchArea)}
|
||||
>
|
||||
{SEARCH_AREAS.map((area) => (
|
||||
<Fragment key={area}>
|
||||
{area === "command" && <DropdownMenu.Separator />}
|
||||
<DropdownMenu.RadioItem value={area}>
|
||||
{t(`app.search.groups.${area}`)}
|
||||
</DropdownMenu.RadioItem>
|
||||
{area === "all" && <DropdownMenu.Separator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="relative flex items-center gap-x-2 px-4 py-3">
|
||||
{onBack && (
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="transparent"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowUturnLeft className="text-ui-fg-muted" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Command.Input
|
||||
ref={innerRef}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className={clx(
|
||||
"placeholder:text-ui-fg-muted flex !h-6 w-full rounded-md bg-transparent text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div className="absolute right-4 top-1/2 flex -translate-y-1/2 items-center justify-end gap-x-2">
|
||||
{isFetching && (
|
||||
<Spinner className="text-ui-fg-muted animate-spin" />
|
||||
)}
|
||||
{value && (
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="small"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onValueChange?.("")
|
||||
innerRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Command.Input
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"placeholder:text-ui-fg-muted flex h-10 w-full rounded-md bg-transparent p-4 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
CommandInput.displayName = Command.Input.displayName
|
||||
|
||||
@@ -200,13 +477,58 @@ CommandList.displayName = Command.List.displayName
|
||||
|
||||
const CommandEmpty = forwardRef<
|
||||
ElementRef<typeof Command.Empty>,
|
||||
ComponentPropsWithoutRef<typeof Command.Empty>
|
||||
>((props, ref) => (
|
||||
<Command.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
))
|
||||
Omit<ComponentPropsWithoutRef<typeof Command.Empty>, "children"> & {
|
||||
q?: string
|
||||
}
|
||||
>((props, ref) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Command.Empty ref={ref} className="py-6 text-center text-sm" {...props}>
|
||||
<div className="text-ui-fg-subtle flex min-h-[236px] flex-col items-center justify-center gap-y-3">
|
||||
<MagnifyingGlass className="text-ui-fg-subtle" />
|
||||
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{props.q
|
||||
? t("app.search.noResultsTitle")
|
||||
: t("app.search.emptySearchTitle")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-muted">
|
||||
{props.q
|
||||
? t("app.search.noResultsMessage")
|
||||
: t("app.search.emptySearchMessage")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Command.Empty>
|
||||
)
|
||||
})
|
||||
|
||||
CommandEmpty.displayName = Command.Empty.displayName
|
||||
|
||||
const CommandLoading = forwardRef<
|
||||
ElementRef<typeof Command.Loading>,
|
||||
ComponentPropsWithoutRef<typeof Command.Loading>
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<Command.Loading
|
||||
ref={ref}
|
||||
{...props}
|
||||
className="bg-ui-bg-base flex flex-col"
|
||||
>
|
||||
<div className="w-full px-2 pb-1 pt-3">
|
||||
<Skeleton className="h-5 w-10" />
|
||||
</div>
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={index} className="w-full p-2">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</Command.Loading>
|
||||
)
|
||||
})
|
||||
CommandLoading.displayName = Command.Loading.displayName
|
||||
|
||||
const CommandGroup = forwardRef<
|
||||
ElementRef<typeof Command.Group>,
|
||||
ComponentPropsWithoutRef<typeof Command.Group>
|
||||
@@ -214,7 +536,7 @@ const CommandGroup = forwardRef<
|
||||
<Command.Group
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-4 [&_[cmdk-item]]:py-2",
|
||||
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-item]]:py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
20
packages/admin/dashboard/src/components/search/types.ts
Normal file
20
packages/admin/dashboard/src/components/search/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SEARCH_AREAS } from "./constants"
|
||||
|
||||
export type SearchArea = (typeof SEARCH_AREAS)[number]
|
||||
|
||||
export type DynamicSearchResultItem = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
to: string
|
||||
thumbnail?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type DynamicSearchResult = {
|
||||
area: SearchArea
|
||||
title: string
|
||||
hasMore: boolean
|
||||
count: number
|
||||
items: DynamicSearchResultItem[]
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { TFunction } from "i18next"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
useApiKeys,
|
||||
useCampaigns,
|
||||
useCollections,
|
||||
useCustomerGroups,
|
||||
useCustomers,
|
||||
useInventoryItems,
|
||||
useOrders,
|
||||
usePriceLists,
|
||||
useProductCategories,
|
||||
useProducts,
|
||||
useProductTags,
|
||||
useProductTypes,
|
||||
usePromotions,
|
||||
useRegions,
|
||||
useSalesChannels,
|
||||
useShippingProfiles,
|
||||
useStockLocations,
|
||||
useTaxRegions,
|
||||
useUsers,
|
||||
useVariants,
|
||||
} from "../../hooks/api"
|
||||
import { useReturnReasons } from "../../hooks/api/return-reasons"
|
||||
import { Shortcut, ShortcutType } from "../../providers/keybind-provider"
|
||||
import { useGlobalShortcuts } from "../../providers/keybind-provider/hooks"
|
||||
import { DynamicSearchResult, SearchArea } from "./types"
|
||||
|
||||
type UseSearchProps = {
|
||||
q?: string
|
||||
limit: number
|
||||
area?: SearchArea
|
||||
}
|
||||
|
||||
export const useSearchResults = ({
|
||||
q,
|
||||
limit,
|
||||
area = "all",
|
||||
}: UseSearchProps) => {
|
||||
const staticResults = useStaticSearchResults(area)
|
||||
const { dynamicResults, isFetching } = useDynamicSearchResults(area, limit, q)
|
||||
|
||||
return {
|
||||
staticResults,
|
||||
dynamicResults,
|
||||
isFetching,
|
||||
}
|
||||
}
|
||||
|
||||
const useStaticSearchResults = (currentArea: SearchArea) => {
|
||||
const globalCommands = useGlobalShortcuts()
|
||||
|
||||
const results = useMemo(() => {
|
||||
const groups = new Map<ShortcutType, Shortcut[]>()
|
||||
|
||||
globalCommands.forEach((command) => {
|
||||
const group = groups.get(command.type) || []
|
||||
group.push(command)
|
||||
groups.set(command.type, group)
|
||||
})
|
||||
|
||||
let filteredGroups: [ShortcutType, Shortcut[]][]
|
||||
|
||||
switch (currentArea) {
|
||||
case "all":
|
||||
filteredGroups = Array.from(groups)
|
||||
break
|
||||
case "navigation":
|
||||
filteredGroups = Array.from(groups).filter(
|
||||
([type]) => type === "pageShortcut" || type === "settingShortcut"
|
||||
)
|
||||
break
|
||||
case "command":
|
||||
filteredGroups = Array.from(groups).filter(
|
||||
([type]) => type === "commandShortcut"
|
||||
)
|
||||
break
|
||||
default:
|
||||
filteredGroups = []
|
||||
}
|
||||
|
||||
return filteredGroups.map(([title, items]) => ({
|
||||
title,
|
||||
items,
|
||||
}))
|
||||
}, [globalCommands, currentArea])
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
const useDynamicSearchResults = (
|
||||
currentArea: SearchArea,
|
||||
limit: number,
|
||||
q?: string
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const debouncedSearch = useDebouncedSearch(q, 300)
|
||||
|
||||
const orderResponse = useOrders(
|
||||
{
|
||||
q: debouncedSearch?.replace(/^#/, ""), // Since we display the ID with a # prefix, it's natural for the user to include it in the search. This will however cause no results to be returned, so we remove the # prefix from the search query.
|
||||
limit,
|
||||
fields: "id,display_id,email",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "order"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const productResponse = useProducts(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,title,thumbnail",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "product"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const productVariantResponse = useVariants(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,title,sku",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "productVariant"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const categoryResponse = useProductCategories(
|
||||
{
|
||||
// TODO: Remove the OR condition once the list endpoint does not throw when q equals an empty string
|
||||
q: debouncedSearch || undefined,
|
||||
limit,
|
||||
fields: "id,name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "category"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const collectionResponse = useCollections(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,title",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "collection"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const customerResponse = useCustomers(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,email,first_name,last_name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "customer"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const customerGroupResponse = useCustomerGroups(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "customerGroup"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const inventoryResponse = useInventoryItems(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,title,sku",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "inventory"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const promotionResponse = usePromotions(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,code",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "promotion"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const campaignResponse = useCampaigns(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "campaign"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const priceListResponse = usePriceLists(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,title",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "priceList"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const userResponse = useUsers(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,email,first_name,last_name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "user"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const regionResponse = useRegions(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "region"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const taxRegionResponse = useTaxRegions(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,country_code,province_code",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "taxRegion"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const returnReasonResponse = useReturnReasons(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,label,value",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "returnReason"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const salesChannelResponse = useSalesChannels(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "salesChannel"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const productTypeResponse = useProductTypes(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,value",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "productType"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const productTagResponse = useProductTags(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,value",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "productTag"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const locationResponse = useStockLocations(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "location"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const shippingProfileResponse = useShippingProfiles(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,name",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "shippingProfile"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const publishableApiKeyResponse = useApiKeys(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,title,redacted",
|
||||
type: "publishable",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "publishableApiKey"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const secretApiKeyResponse = useApiKeys(
|
||||
{
|
||||
q: debouncedSearch,
|
||||
limit,
|
||||
fields: "id,title,redacted",
|
||||
type: "secret",
|
||||
},
|
||||
{
|
||||
enabled: isAreaEnabled(currentArea, "secretApiKey"),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const responseMap = useMemo(
|
||||
() => ({
|
||||
order: orderResponse,
|
||||
product: productResponse,
|
||||
productVariant: productVariantResponse,
|
||||
collection: collectionResponse,
|
||||
category: categoryResponse,
|
||||
inventory: inventoryResponse,
|
||||
customer: customerResponse,
|
||||
customerGroup: customerGroupResponse,
|
||||
promotion: promotionResponse,
|
||||
campaign: campaignResponse,
|
||||
priceList: priceListResponse,
|
||||
user: userResponse,
|
||||
region: regionResponse,
|
||||
taxRegion: taxRegionResponse,
|
||||
returnReason: returnReasonResponse,
|
||||
salesChannel: salesChannelResponse,
|
||||
productType: productTypeResponse,
|
||||
productTag: productTagResponse,
|
||||
location: locationResponse,
|
||||
shippingProfile: shippingProfileResponse,
|
||||
publishableApiKey: publishableApiKeyResponse,
|
||||
secretApiKey: secretApiKeyResponse,
|
||||
}),
|
||||
[
|
||||
orderResponse,
|
||||
productResponse,
|
||||
productVariantResponse,
|
||||
inventoryResponse,
|
||||
categoryResponse,
|
||||
collectionResponse,
|
||||
customerResponse,
|
||||
customerGroupResponse,
|
||||
promotionResponse,
|
||||
campaignResponse,
|
||||
priceListResponse,
|
||||
userResponse,
|
||||
regionResponse,
|
||||
taxRegionResponse,
|
||||
returnReasonResponse,
|
||||
salesChannelResponse,
|
||||
productTypeResponse,
|
||||
productTagResponse,
|
||||
locationResponse,
|
||||
shippingProfileResponse,
|
||||
publishableApiKeyResponse,
|
||||
secretApiKeyResponse,
|
||||
]
|
||||
)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const groups = Object.entries(responseMap)
|
||||
.map(([key, response]) => {
|
||||
const area = key as SearchArea
|
||||
if (isAreaEnabled(currentArea, area) || currentArea === "all") {
|
||||
return transformDynamicSearchResults(area, limit, t, response)
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) // Remove null values
|
||||
|
||||
return groups
|
||||
}, [responseMap, currentArea, limit, t])
|
||||
|
||||
const isAreaFetching = useCallback(
|
||||
(area: SearchArea): boolean => {
|
||||
if (area === "all") {
|
||||
return Object.values(responseMap).some(
|
||||
(response) => response.isFetching
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
isAreaEnabled(currentArea, area) &&
|
||||
responseMap[area as keyof typeof responseMap]?.isFetching
|
||||
)
|
||||
},
|
||||
[currentArea, responseMap]
|
||||
)
|
||||
|
||||
const isFetching = useMemo(() => {
|
||||
return isAreaFetching(currentArea)
|
||||
}, [currentArea, isAreaFetching])
|
||||
|
||||
const dynamicResults = q
|
||||
? (results.filter(
|
||||
(group) => !!group && group.items.length > 0
|
||||
) as DynamicSearchResult[])
|
||||
: []
|
||||
|
||||
return {
|
||||
dynamicResults,
|
||||
isFetching,
|
||||
}
|
||||
}
|
||||
|
||||
const useDebouncedSearch = (value: string | undefined, delay: number) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
function isAreaEnabled(area: SearchArea, currentArea: SearchArea) {
|
||||
if (area === "all") {
|
||||
return true
|
||||
}
|
||||
if (area === currentArea) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type TransformMap = {
|
||||
[K in SearchArea]?: {
|
||||
dataKey: string
|
||||
transform: (item: any) => {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
to: string
|
||||
value: string
|
||||
thumbnail?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transformMap: TransformMap = {
|
||||
order: {
|
||||
dataKey: "orders",
|
||||
transform: (order: HttpTypes.AdminOrder) => ({
|
||||
id: order.id,
|
||||
title: `#${order.display_id}`,
|
||||
subtitle: order.email ?? undefined,
|
||||
to: `/orders/${order.id}`,
|
||||
value: `order:${order.id}`,
|
||||
}),
|
||||
},
|
||||
product: {
|
||||
dataKey: "products",
|
||||
transform: (product: HttpTypes.AdminProduct) => ({
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
to: `/products/${product.id}`,
|
||||
thumbnail: product.thumbnail ?? undefined,
|
||||
value: `product:${product.id}`,
|
||||
}),
|
||||
},
|
||||
productVariant: {
|
||||
dataKey: "variants",
|
||||
transform: (variant: HttpTypes.AdminProductVariant) => ({
|
||||
id: variant.id,
|
||||
title: variant.title!,
|
||||
subtitle: variant.sku ?? undefined,
|
||||
to: `/products/${variant.product_id}/variants/${variant.id}`,
|
||||
value: `variant:${variant.id}`,
|
||||
}),
|
||||
},
|
||||
category: {
|
||||
dataKey: "product_categories",
|
||||
transform: (category: HttpTypes.AdminProductCategory) => ({
|
||||
id: category.id,
|
||||
title: category.name,
|
||||
to: `/categories/${category.id}`,
|
||||
value: `category:${category.id}`,
|
||||
}),
|
||||
},
|
||||
inventory: {
|
||||
dataKey: "inventory_items",
|
||||
transform: (inventory: HttpTypes.AdminInventoryItem) => ({
|
||||
id: inventory.id,
|
||||
title: inventory.title ?? "",
|
||||
subtitle: inventory.sku ?? undefined,
|
||||
to: `/inventory/${inventory.id}`,
|
||||
value: `inventory:${inventory.id}`,
|
||||
}),
|
||||
},
|
||||
customer: {
|
||||
dataKey: "customers",
|
||||
transform: (customer: HttpTypes.AdminCustomer) => {
|
||||
const name = [customer.first_name, customer.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return {
|
||||
id: customer.id,
|
||||
title: name || customer.email,
|
||||
subtitle: name ? customer.email : undefined,
|
||||
to: `/customers/${customer.id}`,
|
||||
value: `customer:${customer.id}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
customerGroup: {
|
||||
dataKey: "customer_groups",
|
||||
transform: (customerGroup: HttpTypes.AdminCustomerGroup) => ({
|
||||
id: customerGroup.id,
|
||||
title: customerGroup.name!,
|
||||
to: `/customer-groups/${customerGroup.id}`,
|
||||
value: `customerGroup:${customerGroup.id}`,
|
||||
}),
|
||||
},
|
||||
collection: {
|
||||
dataKey: "collections",
|
||||
transform: (collection: HttpTypes.AdminCollection) => ({
|
||||
id: collection.id,
|
||||
title: collection.title,
|
||||
to: `/collections/${collection.id}`,
|
||||
value: `collection:${collection.id}`,
|
||||
}),
|
||||
},
|
||||
promotion: {
|
||||
dataKey: "promotions",
|
||||
transform: (promotion: HttpTypes.AdminPromotion) => ({
|
||||
id: promotion.id,
|
||||
title: promotion.code!,
|
||||
to: `/promotions/${promotion.id}`,
|
||||
value: `promotion:${promotion.id}`,
|
||||
}),
|
||||
},
|
||||
campaign: {
|
||||
dataKey: "campaigns",
|
||||
transform: (campaign: HttpTypes.AdminCampaign) => ({
|
||||
id: campaign.id,
|
||||
title: campaign.name,
|
||||
to: `/campaigns/${campaign.id}`,
|
||||
value: `campaign:${campaign.id}`,
|
||||
}),
|
||||
},
|
||||
priceList: {
|
||||
dataKey: "price_lists",
|
||||
transform: (priceList: HttpTypes.AdminPriceList) => ({
|
||||
id: priceList.id,
|
||||
title: priceList.title,
|
||||
to: `/price-lists/${priceList.id}`,
|
||||
value: `priceList:${priceList.id}`,
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
dataKey: "users",
|
||||
transform: (user: HttpTypes.AdminUser) => ({
|
||||
id: user.id,
|
||||
title: `${user.first_name} ${user.last_name}`,
|
||||
subtitle: user.email,
|
||||
to: `/users/${user.id}`,
|
||||
value: `user:${user.id}`,
|
||||
}),
|
||||
},
|
||||
region: {
|
||||
dataKey: "regions",
|
||||
transform: (region: HttpTypes.AdminRegion) => ({
|
||||
id: region.id,
|
||||
title: region.name,
|
||||
to: `/regions/${region.id}`,
|
||||
value: `region:${region.id}`,
|
||||
}),
|
||||
},
|
||||
taxRegion: {
|
||||
dataKey: "tax_regions",
|
||||
transform: (taxRegion: HttpTypes.AdminTaxRegion) => ({
|
||||
id: taxRegion.id,
|
||||
title:
|
||||
taxRegion.province_code?.toUpperCase() ??
|
||||
taxRegion.country_code!.toUpperCase(),
|
||||
subtitle: taxRegion.province_code ? taxRegion.country_code! : undefined,
|
||||
to: `/tax-regions/${taxRegion.id}`,
|
||||
value: `taxRegion:${taxRegion.id}`,
|
||||
}),
|
||||
},
|
||||
returnReason: {
|
||||
dataKey: "return_reasons",
|
||||
transform: (returnReason: HttpTypes.AdminReturnReason) => ({
|
||||
id: returnReason.id,
|
||||
title: returnReason.label,
|
||||
subtitle: returnReason.value,
|
||||
to: `/return-reasons/${returnReason.id}/edit`,
|
||||
value: `returnReason:${returnReason.id}`,
|
||||
}),
|
||||
},
|
||||
salesChannel: {
|
||||
dataKey: "sales_channels",
|
||||
transform: (salesChannel: HttpTypes.AdminSalesChannel) => ({
|
||||
id: salesChannel.id,
|
||||
title: salesChannel.name,
|
||||
to: `/sales-channels/${salesChannel.id}`,
|
||||
value: `salesChannel:${salesChannel.id}`,
|
||||
}),
|
||||
},
|
||||
productType: {
|
||||
dataKey: "product_types",
|
||||
transform: (productType: HttpTypes.AdminProductType) => ({
|
||||
id: productType.id,
|
||||
title: productType.value,
|
||||
to: `/product-types/${productType.id}`,
|
||||
value: `productType:${productType.id}`,
|
||||
}),
|
||||
},
|
||||
productTag: {
|
||||
dataKey: "product_tags",
|
||||
transform: (productTag: HttpTypes.AdminProductTag) => ({
|
||||
id: productTag.id,
|
||||
title: productTag.value,
|
||||
to: `/product-tags/${productTag.id}`,
|
||||
value: `productTag:${productTag.id}`,
|
||||
}),
|
||||
},
|
||||
location: {
|
||||
dataKey: "stock_locations",
|
||||
transform: (location: HttpTypes.AdminStockLocation) => ({
|
||||
id: location.id,
|
||||
title: location.name,
|
||||
to: `/locations/${location.id}`,
|
||||
value: `location:${location.id}`,
|
||||
}),
|
||||
},
|
||||
shippingProfile: {
|
||||
dataKey: "shipping_profiles",
|
||||
transform: (shippingProfile: HttpTypes.AdminShippingProfile) => ({
|
||||
id: shippingProfile.id,
|
||||
title: shippingProfile.name,
|
||||
to: `/shipping-profiles/${shippingProfile.id}`,
|
||||
value: `shippingProfile:${shippingProfile.id}`,
|
||||
}),
|
||||
},
|
||||
publishableApiKey: {
|
||||
dataKey: "api_keys",
|
||||
transform: (apiKey: HttpTypes.AdminApiKeyResponse["api_key"]) => ({
|
||||
id: apiKey.id,
|
||||
title: apiKey.title,
|
||||
subtitle: apiKey.redacted,
|
||||
to: `/publishable-api-keys/${apiKey.id}`,
|
||||
value: `publishableApiKey:${apiKey.id}`,
|
||||
}),
|
||||
},
|
||||
secretApiKey: {
|
||||
dataKey: "api_keys",
|
||||
transform: (apiKey: HttpTypes.AdminApiKeyResponse["api_key"]) => ({
|
||||
id: apiKey.id,
|
||||
title: apiKey.title,
|
||||
subtitle: apiKey.redacted,
|
||||
to: `/secret-api-keys/${apiKey.id}`,
|
||||
value: `secretApiKey:${apiKey.id}`,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
function transformDynamicSearchResults<T extends { count: number }>(
|
||||
type: SearchArea,
|
||||
limit: number,
|
||||
t: TFunction,
|
||||
response?: T
|
||||
): DynamicSearchResult | undefined {
|
||||
if (!response || !transformMap[type]) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { dataKey, transform } = transformMap[type]!
|
||||
const data = response[dataKey as keyof T]
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
title: t(`app.search.groups.${type}`),
|
||||
area: type,
|
||||
hasMore: response.count > limit,
|
||||
count: response.count,
|
||||
items: data.map(transform),
|
||||
}
|
||||
}
|
||||
@@ -71,9 +71,14 @@ export const useOrderPreview = (
|
||||
}
|
||||
|
||||
export const useOrders = (
|
||||
query?: Record<string, any>,
|
||||
query?: HttpTypes.AdminOrderFilters,
|
||||
options?: Omit<
|
||||
UseQueryOptions<any, FetchError, any, QueryKey>,
|
||||
UseQueryOptions<
|
||||
HttpTypes.AdminOrderListResponse,
|
||||
FetchError,
|
||||
HttpTypes.AdminOrderListResponse,
|
||||
QueryKey
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>
|
||||
) => {
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"edit": "Edit",
|
||||
"addItems": "Add items",
|
||||
"download": "Download",
|
||||
"clear": "Clear",
|
||||
"clearAll": "Clear all",
|
||||
"apply": "Apply",
|
||||
"add": "Add",
|
||||
@@ -140,10 +141,46 @@
|
||||
"app": {
|
||||
"search": {
|
||||
"label": "Search",
|
||||
"title": "Search",
|
||||
"description": "Search your entire store, including orders, products, customers, and more.",
|
||||
"allAreas": "All areas",
|
||||
"navigation": "Navigation",
|
||||
"openResult": "Open result",
|
||||
"placeholder": "Jump to or find anything..."
|
||||
"showMore": "Show more",
|
||||
"placeholder": "Jump to or find anything...",
|
||||
"noResultsTitle": "No results found",
|
||||
"noResultsMessage": "We couldn't find anything that matched your search.",
|
||||
"emptySearchTitle": "Type to search",
|
||||
"emptySearchMessage": "Enter a keyword or phrase to explore.",
|
||||
"loadMore": "Load {{count}} more",
|
||||
"groups": {
|
||||
"all": "All areas",
|
||||
"customer": "Customers",
|
||||
"customerGroup": "Customer Groups",
|
||||
"product": "Products",
|
||||
"productVariant": "Product Variants",
|
||||
"inventory": "Inventory",
|
||||
"reservation": "Reservations",
|
||||
"category": "Categories",
|
||||
"collection": "Collections",
|
||||
"order": "Orders",
|
||||
"promotion": "Promotions",
|
||||
"campaign": "Campaigns",
|
||||
"priceList": "Price Lists",
|
||||
"user": "Users",
|
||||
"region": "Regions",
|
||||
"taxRegion": "Tax Regions",
|
||||
"returnReason": "Return Reasons",
|
||||
"salesChannel": "Sales Channels",
|
||||
"productType": "Product Types",
|
||||
"productTag": "Product Tags",
|
||||
"location": "Locations",
|
||||
"shippingProfile": "Shipping Profiles",
|
||||
"publishableApiKey": "Publishable API Keys",
|
||||
"secretApiKey": "Secret API Keys",
|
||||
"command": "Commands",
|
||||
"navigation": "Navigation"
|
||||
}
|
||||
},
|
||||
"keyboardShortcuts": {
|
||||
"pageShortcut": "Jump to",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
:root {
|
||||
@apply bg-ui-bg-subtle text-ui-fg-base antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface AdminCustomerResponse {
|
||||
}
|
||||
|
||||
export type AdminCustomerListResponse = PaginatedResponse<{
|
||||
customers: AdminCustomer
|
||||
customers: AdminCustomer[]
|
||||
}>
|
||||
|
||||
export interface AdminCustomerAddressResponse {
|
||||
|
||||
Reference in New Issue
Block a user