diff --git a/.changeset/sharp-kangaroos-retire.md b/.changeset/sharp-kangaroos-retire.md new file mode 100644 index 00000000000..7477bb87a87 --- /dev/null +++ b/.changeset/sharp-kangaroos-retire.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +In quick search you can now search for product variants with SKU. This means product variants are a new item added to the search catalogue. Catalogue items now show their media, if available. Category item display message if they don't have parents. diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 613c72bde95..f40db0fc5d6 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -603,6 +603,10 @@ "context": "expiry date section header", "string": "Expiry date" }, + "1JSu4L": { + "context": "Navigation search no parent category message", + "string": "This is root category" + }, "1LBYpE": { "context": "dialog header", "string": "Delete Menus" @@ -3930,6 +3934,10 @@ "context": "customer details, header", "string": "{fullName} Details" }, + "MtkXb6": { + "context": "Navigation search variant item type name", + "string": "Variant" + }, "MwfSVA": { "context": "input helper text", "string": "Customers can not add quantities to a single cart above the limit when value is provided." diff --git a/src/components/NavigatorSearch/NavigatorSearch.tsx b/src/components/NavigatorSearch/NavigatorSearch.tsx index d68594e456d..ada883ccb5d 100644 --- a/src/components/NavigatorSearch/NavigatorSearch.tsx +++ b/src/components/NavigatorSearch/NavigatorSearch.tsx @@ -140,10 +140,14 @@ const NavigatorSearch: React.FC = () => { return ( - + (item ? item.label : "")} + itemToString={(item: QuickSearchAction) => + // 'label' can be string for non-search results (actions) + // and ReactNode for search results + item && typeof item.label === "string" ? item.label : "" + } onSelect={(item: QuickSearchAction) => { const shouldRemainVisible = item?.onClick(); diff --git a/src/components/NavigatorSearch/NavigatorSearchSection.tsx b/src/components/NavigatorSearch/NavigatorSearchSection.tsx index 43ed8196958..033b7331732 100644 --- a/src/components/NavigatorSearch/NavigatorSearchSection.tsx +++ b/src/components/NavigatorSearch/NavigatorSearchSection.tsx @@ -2,6 +2,7 @@ import { Box, Text } from "@saleor/macaw-ui-next"; import { GetItemPropsOptions } from "downshift"; import React from "react"; +import { NavigatorThumbnail } from "./NavigatorThumbnail"; import { QuickSearchAction } from "./types"; interface NavigatorSearchSectionProps { @@ -28,6 +29,9 @@ const NavigatorSearchSection: React.FC = props => { item, }); + // 'thumbnail' is 'null' when the item is from Saleor API, 'undefined' if it's nav item + const shouldRenderThumbnail = typeof item.thumbnail !== "undefined"; + return ( = props => { active: "accent1Pressed", }} selected={highlightedIndex === index} - key={[item.label, item.type].join(":")} + key={[typeof item.label === "string" ? item.label : item.searchValue, item.type].join( + ":", + )} cursor="pointer" + display="flex" + flexDirection="row" > + {shouldRenderThumbnail && ( + + )} + - {item.symbol && ( - - {item.symbol} - - )} + + {item.symbol && ( + + {item.symbol} + + )} - {item.label} + {item.label} - {item.caption && ( - - {item.caption} - - )} - + {item.caption && ( + + {item.caption} + + )} + - + - {item.extraInfo} + {item.extraInfo} + ); })} diff --git a/src/components/NavigatorSearch/NavigatorThumbnail.tsx b/src/components/NavigatorSearch/NavigatorThumbnail.tsx new file mode 100644 index 00000000000..28859f5b835 --- /dev/null +++ b/src/components/NavigatorSearch/NavigatorThumbnail.tsx @@ -0,0 +1,70 @@ +import { Box, Skeleton } from "@saleor/macaw-ui-next"; +import React, { useState } from "react"; + +const defaultProps = { + __height: "40px", + __width: "40px", + __minWidth: "40px", +}; + +export const NavigatorThumbnail = ({ + src, + alt, +}: { + src: string | undefined; + alt: string | undefined; +}) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false || !src); + + if (error) { + return ( + + ); + } + + return ( + + {loading && ( + + )} + { + setError(true); + }} + onLoad={() => { + setLoading(false); + }} + /> + + ); +}; diff --git a/src/components/NavigatorSearch/modes/catalog.test.ts b/src/components/NavigatorSearch/modes/catalog.test.ts new file mode 100644 index 00000000000..4e12f05032d --- /dev/null +++ b/src/components/NavigatorSearch/modes/catalog.test.ts @@ -0,0 +1,88 @@ +import { SearchCatalogQuery } from "@dashboard/graphql"; +import { intlMock } from "@test/intl"; + +import { searchInCatalog } from "./catalog"; + +describe("NavigatorSearch / modes / searchInCatalog", () => { + const intl = intlMock; + const navigate = jest.fn(); + + it("should return variants if search is a sku", () => { + // Arrange + const mockCatalog: SearchCatalogQuery = { + productVariants: { + edges: [ + { + node: { + id: "1", + name: "Small", + sku: "PROD-SMALL", + product: { + id: "prod1", + name: "T-Shirt", + thumbnail: null, + category: { + name: "Apparel", + }, + }, + }, + }, + ], + }, + } as SearchCatalogQuery; + + // Act + const results = searchInCatalog("PROD-SMALL", intl, navigate, mockCatalog); + + // Assert + expect(results).toHaveLength(1); + expect(results[0].searchValue).toContain("PROD-SMALL"); + }); + + it("should return both product and its variant", () => { + // Arrange + const mockCatalog: SearchCatalogQuery = { + products: { + edges: [ + { + node: { + id: "prod1", + name: "T-Shirt", + thumbnail: null, + category: { + name: "Apparel", + }, + }, + }, + ], + }, + productVariants: { + edges: [ + { + node: { + id: "1", + name: "Small", + sku: "PROD-SMALL", + product: { + id: "prod1", + name: "T-Shirt", + thumbnail: null, + category: { + name: "Apparel", + }, + }, + }, + }, + ], + }, + } as SearchCatalogQuery; + + // Act + const results = searchInCatalog("T-Shirt", intl, navigate, mockCatalog); + + // Assert + expect(results).toHaveLength(2); + expect(results[0].label).toBe("T-Shirt"); + expect(results[1].searchValue).toContain("PROD-SMALL"); + }); +}); diff --git a/src/components/NavigatorSearch/modes/catalog.ts b/src/components/NavigatorSearch/modes/catalog.ts index 8a1171eb146..70f3d85ff4b 100644 --- a/src/components/NavigatorSearch/modes/catalog.ts +++ b/src/components/NavigatorSearch/modes/catalog.ts @@ -4,11 +4,12 @@ import { collectionUrl } from "@dashboard/collections/urls"; import { SearchCatalogQuery } from "@dashboard/graphql"; import { UseNavigatorResult } from "@dashboard/hooks/useNavigator"; import { fuzzySearch } from "@dashboard/misc"; -import { productUrl } from "@dashboard/products/urls"; +import { productUrl, productVariantEditUrl } from "@dashboard/products/urls"; import { mapEdgesToItems } from "@dashboard/utils/maps"; import { IntlShape } from "react-intl"; import { QuickSearchAction, QuickSearchActionInput } from "../types"; +import { getProductVariantLabel } from "./labels"; import messages from "./messages"; export function searchInCatalog( @@ -22,6 +23,7 @@ export function searchInCatalog( ).map(category => ({ caption: intl.formatMessage(messages.category), label: category.name, + searchValue: category.name, onClick: () => { navigate(categoryUrl(category.id)); @@ -29,12 +31,15 @@ export function searchInCatalog( }, text: category.name, type: "catalog", + thumbnail: category.backgroundImage, + extraInfo: category.level === 0 ? intl.formatMessage(messages.root) : undefined, })); const collections: QuickSearchActionInput[] = ( mapEdgesToItems(catalog?.collections) || [] ).map(collection => ({ caption: intl.formatMessage(messages.collection), label: collection.name, + searchValue: collection.name, onClick: () => { navigate(collectionUrl(collection.id)); @@ -42,6 +47,7 @@ export function searchInCatalog( }, text: collection.name, type: "catalog", + thumbnail: collection.backgroundImage, })); const products: QuickSearchActionInput[] = ( mapEdgesToItems(catalog?.products) || [] @@ -49,6 +55,7 @@ export function searchInCatalog( caption: intl.formatMessage(messages.product), extraInfo: product.category.name, label: product.name, + searchValue: product.name, onClick: () => { navigate(productUrl(product.id)); @@ -56,11 +63,29 @@ export function searchInCatalog( }, text: product.name, type: "catalog", + thumbnail: product.thumbnail, + })); + const variants: QuickSearchActionInput[] = ( + mapEdgesToItems(catalog?.productVariants) || [] + ).map(variant => ({ + caption: intl.formatMessage(messages.variant), + extraInfo: variant.product.category.name, + label: getProductVariantLabel(variant), + searchValue: `${variant.product.name} ${variant.name} ${variant.sku}`, + onClick: () => { + navigate(productVariantEditUrl(variant.product.id, variant.id)); + + return false; + }, + text: variant.name, + type: "catalog", + thumbnail: variant.product.thumbnail, })); - const searchableItems = [...categories, ...collections, ...products]; + const searchableItems = [...categories, ...collections, ...products, ...variants]; + const searchResults = fuzzySearch(searchableItems, search, ["searchValue"], 0.8); - return fuzzySearch(searchableItems, search, ["label"]); + return searchResults; } function getCatalogModeActions( diff --git a/src/components/NavigatorSearch/modes/commands/actions.ts b/src/components/NavigatorSearch/modes/commands/actions.ts index e302278add2..79634a92ecc 100644 --- a/src/components/NavigatorSearch/modes/commands/actions.ts +++ b/src/components/NavigatorSearch/modes/commands/actions.ts @@ -95,6 +95,7 @@ export function searchInCommands( return fuzzySearch(actions, search, ["label"]).map(action => ({ label: action.label, + searchValue: action.label, onClick: action.onClick, text: action.label, type: "action", diff --git a/src/components/NavigatorSearch/modes/customers.ts b/src/components/NavigatorSearch/modes/customers.ts index fb65d19228e..aa0dd6f725a 100644 --- a/src/components/NavigatorSearch/modes/customers.ts +++ b/src/components/NavigatorSearch/modes/customers.ts @@ -22,6 +22,10 @@ export function searchInCustomers( lastName: customer.lastName, }) : customer.email, + searchValue: + customer.firstName && customer.lastName + ? `${customer.firstName} ${customer.lastName}` + : customer.email, onClick: () => { navigate(customerUrl(customer.id)); diff --git a/src/components/NavigatorSearch/modes/default/views.ts b/src/components/NavigatorSearch/modes/default/views.ts index e9775815e03..0863996d46e 100644 --- a/src/components/NavigatorSearch/modes/default/views.ts +++ b/src/components/NavigatorSearch/modes/default/views.ts @@ -126,6 +126,7 @@ function searchInViews( return fuzzySearch(views, search, ["label"]).map(view => ({ label: view.label, + searchValue: view.label, onClick: () => { navigate(view.url); diff --git a/src/components/NavigatorSearch/modes/help.ts b/src/components/NavigatorSearch/modes/help.ts index aa388dc882f..c4adbf07e3c 100644 --- a/src/components/NavigatorSearch/modes/help.ts +++ b/src/components/NavigatorSearch/modes/help.ts @@ -12,6 +12,7 @@ function getHelpModeActions( return [ { label: intl.formatMessage(messages.noResults), + searchValue: query, onClick: () => true, type: "action", }, @@ -21,6 +22,7 @@ function getHelpModeActions( return [ { label: intl.formatMessage(messages.helpDefaultMode), + searchValue: "", onClick: () => { setMode("default"); @@ -31,6 +33,7 @@ function getHelpModeActions( }, { label: intl.formatMessage(messages.helpCommandsMode), + searchValue: "", onClick: () => { setMode("commands"); @@ -41,6 +44,7 @@ function getHelpModeActions( }, { label: intl.formatMessage(messages.helpOrdersMode), + searchValue: "", onClick: () => { setMode("orders"); @@ -51,6 +55,7 @@ function getHelpModeActions( }, { label: intl.formatMessage(messages.helpCustomersMode), + searchValue: "", onClick: () => { setMode("customers"); @@ -61,6 +66,7 @@ function getHelpModeActions( }, { label: intl.formatMessage(messages.helpCatalogMode), + searchValue: "", onClick: () => { setMode("catalog"); @@ -71,6 +77,7 @@ function getHelpModeActions( }, { label: intl.formatMessage(messages.helpMode), + searchValue: "", onClick: () => { setMode("help"); diff --git a/src/components/NavigatorSearch/modes/labels.tsx b/src/components/NavigatorSearch/modes/labels.tsx new file mode 100644 index 00000000000..b1f7fa6d424 --- /dev/null +++ b/src/components/NavigatorSearch/modes/labels.tsx @@ -0,0 +1,18 @@ +import { SearchCatalogQuery } from "@dashboard/graphql"; +import { Text } from "@saleor/macaw-ui-next"; +import React from "react"; + +type Variant = NonNullable["edges"][0]["node"]; + +export const getProductVariantLabel = (variant: Variant) => { + return ( + <> + {variant.product.name} / {variant.name} + {variant.sku && ( + + ({variant.sku}) + + )} + + ); +}; diff --git a/src/components/NavigatorSearch/modes/messages.ts b/src/components/NavigatorSearch/modes/messages.ts index b352d60fc70..d499149a12b 100644 --- a/src/components/NavigatorSearch/modes/messages.ts +++ b/src/components/NavigatorSearch/modes/messages.ts @@ -101,6 +101,16 @@ const messages = defineMessages({ id: "x/ZVlU", defaultMessage: "Product", }, + root: { + defaultMessage: "This is root category", + description: "Navigation search no parent category message", + id: "1JSu4L", + }, + variant: { + defaultMessage: "Variant", + description: "Navigation search variant item type name", + id: "MtkXb6", + }, }); export default messages; diff --git a/src/components/NavigatorSearch/modes/orders.ts b/src/components/NavigatorSearch/modes/orders.ts index 31e83b8e7d3..039b024037f 100644 --- a/src/components/NavigatorSearch/modes/orders.ts +++ b/src/components/NavigatorSearch/modes/orders.ts @@ -33,6 +33,7 @@ function getOrdersModeActions( label: intl.formatMessage(messages.goToOrder, { orderNumber, }), + searchValue: orderNumber, onClick: () => { navigate(orderUrl(order.id)); diff --git a/src/components/NavigatorSearch/queries/queries.ts b/src/components/NavigatorSearch/queries/queries.ts index b9fb6d1f85a..9bedbbb5382 100644 --- a/src/components/NavigatorSearch/queries/queries.ts +++ b/src/components/NavigatorSearch/queries/queries.ts @@ -21,6 +21,11 @@ export const searchCatalog = gql` node { id name + backgroundImage(size: 64) { + url + alt + } + level } } } @@ -29,6 +34,10 @@ export const searchCatalog = gql` edges { node { ...Collection + backgroundImage(size: 64) { + url + alt + } } } } @@ -42,6 +51,32 @@ export const searchCatalog = gql` name } name + thumbnail(size: 64) { + alt + url + } + } + } + } + + productVariants(first: $first, filter: { search: $query }) { + edges { + node { + id + name + sku + product { + id + name + category { + id + name + } + thumbnail(size: 64) { + alt + url + } + } } } } diff --git a/src/components/NavigatorSearch/types.ts b/src/components/NavigatorSearch/types.ts index 99dcd042f33..4ece3fd5d1c 100644 --- a/src/components/NavigatorSearch/types.ts +++ b/src/components/NavigatorSearch/types.ts @@ -1,11 +1,18 @@ +import { ReactNode } from "react"; + export type QuickSearchActionType = "action" | "catalog" | "customer" | "view"; export interface QuickSearchAction { caption?: string; extraInfo?: string; - label: string; + label: string | ReactNode; + searchValue: string; price?: number; symbol?: string; + thumbnail?: { + alt: string; + url: string; + }; type: QuickSearchActionType; onClick: () => boolean; } diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 347bf8744fd..d88806c5cde 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -7019,6 +7019,11 @@ export const SearchCatalogDocument = gql` node { id name + backgroundImage(size: 64) { + url + alt + } + level } } } @@ -7026,6 +7031,10 @@ export const SearchCatalogDocument = gql` edges { node { ...Collection + backgroundImage(size: 64) { + url + alt + } } } } @@ -7038,6 +7047,31 @@ export const SearchCatalogDocument = gql` name } name + thumbnail(size: 64) { + alt + url + } + } + } + } + productVariants(first: $first, filter: {search: $query}) { + edges { + node { + id + name + sku + product { + id + name + category { + id + name + } + thumbnail(size: 64) { + alt + url + } + } } } } diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 5145eb71a86..9bdc05cf89e 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -9588,7 +9588,7 @@ export type SearchCatalogQueryVariables = Exact<{ }>; -export type SearchCatalogQuery = { __typename: 'Query', categories: { __typename: 'CategoryCountableConnection', edges: Array<{ __typename: 'CategoryCountableEdge', node: { __typename: 'Category', id: string, name: string } }> } | null, collections: { __typename: 'CollectionCountableConnection', edges: Array<{ __typename: 'CollectionCountableEdge', node: { __typename: 'Collection', id: string, name: string, channelListings: Array<{ __typename: 'CollectionChannelListing', isPublished: boolean, publishedAt: any | null, channel: { __typename: 'Channel', id: string, name: string } }> | null } }> } | null, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, category: { __typename: 'Category', id: string, name: string } | null } }> } | null }; +export type SearchCatalogQuery = { __typename: 'Query', categories: { __typename: 'CategoryCountableConnection', edges: Array<{ __typename: 'CategoryCountableEdge', node: { __typename: 'Category', id: string, name: string, level: number, backgroundImage: { __typename: 'Image', url: string, alt: string | null } | null } }> } | null, collections: { __typename: 'CollectionCountableConnection', edges: Array<{ __typename: 'CollectionCountableEdge', node: { __typename: 'Collection', id: string, name: string, backgroundImage: { __typename: 'Image', url: string, alt: string | null } | null, channelListings: Array<{ __typename: 'CollectionChannelListing', isPublished: boolean, publishedAt: any | null, channel: { __typename: 'Channel', id: string, name: string } }> | null } }> } | null, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, category: { __typename: 'Category', id: string, name: string } | null, thumbnail: { __typename: 'Image', alt: string | null, url: string } | null } }> } | null, productVariants: { __typename: 'ProductVariantCountableConnection', edges: Array<{ __typename: 'ProductVariantCountableEdge', node: { __typename: 'ProductVariant', id: string, name: string, sku: string | null, product: { __typename: 'Product', id: string, name: string, category: { __typename: 'Category', id: string, name: string } | null, thumbnail: { __typename: 'Image', alt: string | null, url: string } | null } } }> } | null }; export type ShopInfoQueryVariables = Exact<{ [key: string]: never; }>; diff --git a/src/misc.ts b/src/misc.ts index ff4c8661a3c..bc12efde7c6 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -625,7 +625,12 @@ const getAllRemovedRowsBeforeRowIndex = (rowIndex: number, removedRowsIndexs: nu export const getDatagridRowDataIndex = (rowIndex: number, removedRowsIndexs: number[]) => rowIndex + getAllRemovedRowsBeforeRowIndex(rowIndex, removedRowsIndexs).length; -export const fuzzySearch = (array: T[], query: string | undefined, keys: string[]) => { +export const fuzzySearch = ( + array: T[], + query: string | undefined, + keys: string[], + threshold = 0.3, +) => { if (!query) { return array; } @@ -633,7 +638,7 @@ export const fuzzySearch = (array: T[], query: string | undefined, keys: stri const fuse = new Fuse(array, { keys, includeScore: true, - threshold: 0.3, + threshold, }); return fuse.search(query.toLocaleLowerCase()).map(({ item }) => item);