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:
Kasper Fabricius Kristensen
2024-10-11 09:38:05 +02:00
committed by GitHub
parent 49a91fd40e
commit ccd40e6548
9 changed files with 1241 additions and 66 deletions

View File

@@ -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}

View 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

View File

@@ -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}

View 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[]
}

View File

@@ -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),
}
}

View File

@@ -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"
>
) => {

View File

@@ -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",

View File

@@ -30,6 +30,7 @@
:root {
@apply bg-ui-bg-subtle text-ui-fg-base antialiased;
text-rendering: optimizeLegibility;
color-scheme: light dark;
}
}

View File

@@ -10,7 +10,7 @@ export interface AdminCustomerResponse {
}
export type AdminCustomerListResponse = PaginatedResponse<{
customers: AdminCustomer
customers: AdminCustomer[]
}>
export interface AdminCustomerAddressResponse {