From ccd40e65489266e710efe1668c8ed076c1914321 Mon Sep 17 00:00:00 2001
From: Kasper Fabricius Kristensen
<45367945+kasperkristensen@users.noreply.github.com>
Date: Fri, 11 Oct 2024 09:38:05 +0200
Subject: [PATCH] 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
---
.../components/common/thumbnail/thumbnail.tsx | 14 +-
.../src/components/search/constants.ts | 30 +
.../src/components/search/search.tsx | 442 +++++++++--
.../dashboard/src/components/search/types.ts | 20 +
.../components/search/use-search-results.tsx | 750 ++++++++++++++++++
.../admin/dashboard/src/hooks/api/orders.tsx | 9 +-
.../dashboard/src/i18n/translations/en.json | 39 +-
packages/admin/dashboard/src/index.css | 1 +
.../src/http/customer/admin/responses.ts | 2 +-
9 files changed, 1241 insertions(+), 66 deletions(-)
create mode 100644 packages/admin/dashboard/src/components/search/constants.ts
create mode 100644 packages/admin/dashboard/src/components/search/types.ts
create mode 100644 packages/admin/dashboard/src/components/search/use-search-results.tsx
diff --git a/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx b/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx
index f439a2cd38..ffd0002f12 100644
--- a/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx
+++ b/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx
@@ -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 (
-
+
{src ? (
{
+ const [area, setArea] = useState
("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(null)
+ const listRef = useRef(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()
+ 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 (
-
-
-
- {t("general.noResultsTitle")}
- {links.map((group) => {
+
+
+
+ {showLoading && }
+ {dynamicResults.map((group) => {
+ return (
+
+ {group.items.map((item) => {
+ return (
+ handleSelect(item)}
+ value={item.value}
+ className="flex items-center justify-between"
+ >
+
+ {item.thumbnail && (
+
+ )}
+ {item.title}
+ {item.subtitle && (
+
+ {item.subtitle}
+
+ )}
+
+
+ )
+ })}
+ {group.hasMore && area === "all" && (
+ handleShowMore(group.area)}
+ hidden={true}
+ value={`${group.title}:show:more`} // Prevent the "Show more" buttons across groups from sharing the same value/state
+ >
+
+
+
+ {t("app.search.showMore")}
+
+
+
+ )}
+ {group.hasMore && area === group.area && (
+
+
+
+
+ {t("app.search.loadMore", {
+ count: Math.min(
+ SEARCH_LIMIT_INCREMENT,
+ group.count - limit
+ ),
+ })}
+
+
+
+ )}
+
+ )
+ })}
+ {filteredStaticResults.map((group) => {
return (
{
)
})}
+ {!showLoading && }
)
@@ -107,6 +274,7 @@ const CommandPalette = forwardRef<
ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
{
const { t } = useTranslation()
+ const preserveHeight = useMemo(() => {
+ return props.isLoading && Children.count(children) === 0
+ }, [props.isLoading, children])
+
return (
-
-
+
+
+ {t("app.search.title")}
+
+
+ {t("app.search.description")}
+
+
{children}
@@ -158,27 +345,117 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
const CommandInput = forwardRef<
ElementRef
,
- ComponentPropsWithoutRef
->(({ className, ...props }, ref) => {
- const { t } = useTranslation()
+ ComponentPropsWithoutRef & {
+ 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(null)
- return (
-
-
- {/* TODO: Add filter once we have search engine */}
-
{t("app.search.allAreas")}
+ useImperativeHandle
(
+ ref,
+ () => innerRef.current
+ )
+
+ return (
+
+
+
+
+
+ {t(`app.search.groups.${area}`)}
+
+
+
+ {
+ e.preventDefault()
+ innerRef.current?.focus()
+ }}
+ >
+ setArea(v as SearchArea)}
+ >
+ {SEARCH_AREAS.map((area) => (
+
+ {area === "command" && }
+
+ {t(`app.search.groups.${area}`)}
+
+ {area === "all" && }
+
+ ))}
+
+
+
+
+
+ {onBack && (
+
+
+
+ )}
+
+
+ {isFetching && (
+
+ )}
+ {value && (
+ {
+ onValueChange?.("")
+ innerRef.current?.focus()
+ }}
+ >
+ {t("actions.clear")}
+
+ )}
+
+
-
-
- )
-})
+ )
+ }
+)
CommandInput.displayName = Command.Input.displayName
@@ -200,13 +477,58 @@ CommandList.displayName = Command.List.displayName
const CommandEmpty = forwardRef<
ElementRef
,
- ComponentPropsWithoutRef
->((props, ref) => (
-
-))
+ Omit, "children"> & {
+ q?: string
+ }
+>((props, ref) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+ {props.q
+ ? t("app.search.noResultsTitle")
+ : t("app.search.emptySearchTitle")}
+
+
+ {props.q
+ ? t("app.search.noResultsMessage")
+ : t("app.search.emptySearchMessage")}
+
+
+
+
+ )
+})
CommandEmpty.displayName = Command.Empty.displayName
+const CommandLoading = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>((props, ref) => {
+ return (
+
+
+
+
+ {Array.from({ length: 7 }).map((_, index) => (
+
+
+
+ ))}
+
+ )
+})
+CommandLoading.displayName = Command.Loading.displayName
+
const CommandGroup = forwardRef<
ElementRef,
ComponentPropsWithoutRef
@@ -214,7 +536,7 @@ const CommandGroup = forwardRef<
{
+ 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()
+
+ 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(
+ 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),
+ }
+}
diff --git a/packages/admin/dashboard/src/hooks/api/orders.tsx b/packages/admin/dashboard/src/hooks/api/orders.tsx
index 9dd92cef17..b76c0af3fa 100644
--- a/packages/admin/dashboard/src/hooks/api/orders.tsx
+++ b/packages/admin/dashboard/src/hooks/api/orders.tsx
@@ -71,9 +71,14 @@ export const useOrderPreview = (
}
export const useOrders = (
- query?: Record,
+ query?: HttpTypes.AdminOrderFilters,
options?: Omit<
- UseQueryOptions,
+ UseQueryOptions<
+ HttpTypes.AdminOrderListResponse,
+ FetchError,
+ HttpTypes.AdminOrderListResponse,
+ QueryKey
+ >,
"queryFn" | "queryKey"
>
) => {
diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json
index bf926aa1b4..103515072d 100644
--- a/packages/admin/dashboard/src/i18n/translations/en.json
+++ b/packages/admin/dashboard/src/i18n/translations/en.json
@@ -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",
diff --git a/packages/admin/dashboard/src/index.css b/packages/admin/dashboard/src/index.css
index bc87c9f0be..e73e75abb6 100644
--- a/packages/admin/dashboard/src/index.css
+++ b/packages/admin/dashboard/src/index.css
@@ -30,6 +30,7 @@
:root {
@apply bg-ui-bg-subtle text-ui-fg-base antialiased;
text-rendering: optimizeLegibility;
+ color-scheme: light dark;
}
}
diff --git a/packages/core/types/src/http/customer/admin/responses.ts b/packages/core/types/src/http/customer/admin/responses.ts
index 253dc6b81c..2844a27c7c 100644
--- a/packages/core/types/src/http/customer/admin/responses.ts
+++ b/packages/core/types/src/http/customer/admin/responses.ts
@@ -10,7 +10,7 @@ export interface AdminCustomerResponse {
}
export type AdminCustomerListResponse = PaginatedResponse<{
- customers: AdminCustomer
+ customers: AdminCustomer[]
}>
export interface AdminCustomerAddressResponse {