diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts index 960d81d4d..eba594816 100644 --- a/admin-ui/src/gql/types.ts +++ b/admin-ui/src/gql/types.ts @@ -924,6 +924,36 @@ export type IGeoPosition = { longitude: Scalars['Float']['output']; }; +export type IGlobalSearchResponse = { + counts: Array; + results: Array; +}; + +export type IGlobalSearchResult = + | IAssortment + | IBundleProduct + | IConfigurableProduct + | IEnrollment + | IFilter + | IOrder + | IPlanProduct + | IQuotation + | ISimpleProduct + | ITokenizedProduct + | IUser + | IWork; + +export type IGlobalSearchTypeCount = { + authorized: Scalars['Boolean']['output']; + totalCount: Scalars['Int']['output']; + type: ISearchableEntity; +}; + +export type IGlobalSearchTypeLimitInput = { + limit: Scalars['Int']['input']; + type: ISearchableEntity; +}; + export type ILanguage = { _id: Scalars['ID']['output']; isActive?: Maybe; @@ -2833,6 +2863,8 @@ export type IQuery = { filters: Array; /** Returns total number of filters */ filtersCount: Scalars['Int']['output']; + /** Search across multiple entity types in a single request */ + globalSearch: IGlobalSearchResponse; /** User impersonating currently logged in user */ impersonator?: Maybe; /** Get a specific language */ @@ -3072,6 +3104,18 @@ export type IQueryFiltersCountArgs = { queryString?: InputMaybe; }; +export type IQueryGlobalSearchArgs = { + includeCarts?: InputMaybe; + includeDraftProducts?: InputMaybe; + includeGuestUsers?: InputMaybe; + includeInactiveAssortments?: InputMaybe; + includeInactiveFilters?: InputMaybe; + limit?: InputMaybe; + query: Scalars['String']['input']; + typeLimits?: InputMaybe>; + types?: InputMaybe>; +}; + export type IQueryLanguageArgs = { languageId: Scalars['ID']['input']; }; @@ -3521,6 +3565,17 @@ export type ISearchResultProductsArgs = { offset?: InputMaybe; }; +export enum ISearchableEntity { + Assortment = 'ASSORTMENT', + Enrollment = 'ENROLLMENT', + Filter = 'FILTER', + Order = 'ORDER', + Product = 'PRODUCT', + Quotation = 'QUOTATION', + User = 'USER', + Work = 'WORK', +} + export type IShop = { _id: Scalars['ID']['output']; adminUiConfig: IAdminUiConfig; @@ -15792,6 +15847,148 @@ export type IVerifyQuotationMutation = { }; }; +type IGlobalSearchProductFragment_BundleProduct = { + __typename: 'BundleProduct'; + _id: string; + texts?: { _id: string; title?: string | null; slug?: string | null } | null; + media: Array<{ file?: { url?: string | null } | null }>; +}; + +type IGlobalSearchProductFragment_ConfigurableProduct = { + __typename: 'ConfigurableProduct'; + _id: string; + texts?: { _id: string; title?: string | null; slug?: string | null } | null; + media: Array<{ file?: { url?: string | null } | null }>; +}; + +type IGlobalSearchProductFragment_PlanProduct = { + __typename: 'PlanProduct'; + _id: string; + texts?: { _id: string; title?: string | null; slug?: string | null } | null; + media: Array<{ file?: { url?: string | null } | null }>; +}; + +type IGlobalSearchProductFragment_SimpleProduct = { + __typename: 'SimpleProduct'; + _id: string; + texts?: { _id: string; title?: string | null; slug?: string | null } | null; + media: Array<{ file?: { url?: string | null } | null }>; +}; + +type IGlobalSearchProductFragment_TokenizedProduct = { + __typename: 'TokenizedProduct'; + _id: string; + texts?: { _id: string; title?: string | null; slug?: string | null } | null; + media: Array<{ file?: { url?: string | null } | null }>; +}; + +export type IGlobalSearchProductFragment = + | IGlobalSearchProductFragment_BundleProduct + | IGlobalSearchProductFragment_ConfigurableProduct + | IGlobalSearchProductFragment_PlanProduct + | IGlobalSearchProductFragment_SimpleProduct + | IGlobalSearchProductFragment_TokenizedProduct; + +export type IGlobalSearchProductFragmentVariables = Exact<{ + [key: string]: never; +}>; + +export type IGlobalSearchQueryVariables = Exact<{ + query: Scalars['String']['input']; + types?: InputMaybe>; + limit?: InputMaybe; + includeDraftProducts?: InputMaybe; + includeInactiveAssortments?: InputMaybe; + includeInactiveFilters?: InputMaybe; + includeGuestUsers?: InputMaybe; + includeCarts?: InputMaybe; +}>; + +export type IGlobalSearchQuery = { + globalSearch: { + counts: Array<{ + type: ISearchableEntity; + totalCount: number; + authorized: boolean; + }>; + results: Array< + | { + __typename: 'Assortment'; + _id: string; + texts?: { + _id: string; + title?: string | null; + slug?: string | null; + } | null; + media: Array<{ file?: { url?: string | null } | null }>; + } + | { + __typename: 'BundleProduct'; + _id: string; + texts?: { + _id: string; + title?: string | null; + slug?: string | null; + } | null; + media: Array<{ file?: { url?: string | null } | null }>; + } + | { + __typename: 'ConfigurableProduct'; + _id: string; + texts?: { + _id: string; + title?: string | null; + slug?: string | null; + } | null; + media: Array<{ file?: { url?: string | null } | null }>; + } + | { __typename: 'Enrollment'; _id: string } + | { __typename: 'Filter'; _id: string; key?: string | null } + | { __typename: 'Order'; _id: string; orderNumber?: string | null } + | { + __typename: 'PlanProduct'; + _id: string; + texts?: { + _id: string; + title?: string | null; + slug?: string | null; + } | null; + media: Array<{ file?: { url?: string | null } | null }>; + } + | { __typename: 'Quotation'; _id: string } + | { + __typename: 'SimpleProduct'; + _id: string; + texts?: { + _id: string; + title?: string | null; + slug?: string | null; + } | null; + media: Array<{ file?: { url?: string | null } | null }>; + } + | { + __typename: 'TokenizedProduct'; + _id: string; + texts?: { + _id: string; + title?: string | null; + slug?: string | null; + } | null; + media: Array<{ file?: { url?: string | null } | null }>; + } + | { + __typename: 'User'; + _id: string; + username?: string | null; + name: string; + emails?: Array<{ address: string; verified: boolean }> | null; + avatar?: { url?: string | null } | null; + } + | { __typename: 'Work'; _id: string; type: IWorkType } + >; + }; +}; + export type IInvalidateTokenMutationVariables = Exact<{ tokenId: Scalars['ID']['input']; }>; diff --git a/admin-ui/src/i18n/de.json b/admin-ui/src/i18n/de.json index 25619923d..f552db13c 100644 --- a/admin-ui/src/i18n/de.json +++ b/admin-ui/src/i18n/de.json @@ -404,6 +404,9 @@ "go_back": "Zurück", "go_back_home": "Zurück zur Startseite", "go_to_login": "Zur Anmeldung", + "global_search_error": "Suche fehlgeschlagen. Bitte erneut versuchen.", + "global_search_hint": "Mindestens 2 Zeichen eingeben", + "global_search_placeholder": "Produkte, Bestellungen, Benutzer suchen...", "goodbye_simple": "Auf Wiedersehen\\!", "goodbye_user": "Auf Wiedersehen, {name}\\!", "gross_price": "Brutto", @@ -521,6 +524,7 @@ "no_orders_title": "Noch keine Bestellungen", "no_payload": "Payload", "no_result_found": "Kein Ergebnis gefunden", + "no_results_found": "Keine Ergebnisse gefunden", "no_tag": "Kein Tag", "no_variations_configured": "Noch keine Variationen konfiguriert. Bitte fügen Sie zuerst Variationen und deren Optionen im Variationen-Tab hinzu.", "non_taxable": "Nicht steuerpflichtig", @@ -986,6 +990,7 @@ "worker.noStatistics": "Keine Statistiken verfügbar", "worker.startCount": "Start", "worker.successCount": "Erfolgreich", + "work": "Arbeit", "workers": "Arbeiter", "ordered": "Bestellt", "registered": "Eingetragen", diff --git a/admin-ui/src/i18n/en.json b/admin-ui/src/i18n/en.json index fc13ebf64..b596d23d1 100644 --- a/admin-ui/src/i18n/en.json +++ b/admin-ui/src/i18n/en.json @@ -469,6 +469,9 @@ "go_back": "Go Back", "go_back_home": "Go back home", "go_to_login": "Go to Login", + "global_search_error": "Search failed. Please try again.", + "global_search_hint": "Type at least 2 characters to search", + "global_search_placeholder": "Search products, orders, users...", "goodbye_simple": "Goodbye!", "goodbye_user": "See you later, {name}!", "gross_price": "Gross", @@ -597,6 +600,7 @@ "no_payload": "Payload", "no_price": "N/A", "no_result_found": "No result found", + "no_results_found": "No results found", "no_retries": "No retries", "no_statistics_available": "No statistics available", "no_tag": "No Tag", @@ -1060,6 +1064,7 @@ "worker.startCount": "Start", "worker.successCount": "Success", "worker_dynamic": "{worker}", + "work": "Work", "workers": "Workers", "write_review": "Write a Review" } diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index 5b68d604b..048466fe2 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -17,7 +17,7 @@ import { FolderArrowDownIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useIntl } from 'react-intl'; import useCurrentUser from '../../accounts/hooks/useCurrentUser'; import useShopInfo from '../hooks/useShopInfo'; @@ -50,6 +50,9 @@ const resolveIcon = (iconName?: string) => { .join('') + 'Icon'; return HeroIcons[pascalCase] || null; }; +import CommandPalette from '../../search/components/CommandPalette'; +import { SearchProvider, useSearch } from '../../search/SearchContext'; +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; const QuotationIcon = ({ className }) => ( ( ); +const useIsMac = () => { + const [isMac, setIsMac] = useState(false); + useEffect(() => { + const platform = + (navigator as any).userAgentData?.platform || navigator.platform || ''; + setIsMac(platform.toUpperCase().includes('MAC')); + }, []); + return isMac; +}; + +const SearchButton = ({ narrowNav }: { narrowNav: boolean }) => { + const { open } = useSearch(); + const { formatMessage } = useIntl(); + const isMac = useIsMac(); + + return ( + + ); +}; + const Layout = ({ children, pageHeader = '', @@ -347,232 +388,110 @@ const Layout = ({ }); return ( - -
- -
-
-
-
- -
- -
- -
- -
-
- {!narrowNav && ( - <> +
+
- {currentUser?.avatar ? ( +
- ) : ( - - )} -
-
- {formatMessage({ - id: 'account', - defaultMessage: 'Account', - })} -
-
- {formatUsername(currentUser)} -
+
+ setHideNav(!hideNav)} + /> +
+
+ + {currentUser?.avatar ? ( + + ) : ( + + )} +
+
+ {formatMessage({ + id: 'account', + defaultMessage: 'Account', + })} +
+
+ {formatUsername(currentUser)} +
+
+ +
- - )} - {narrowNav && ( -
- - {currentUser?.avatar ? ( - - ) : ( - - )} - - -
- )} +
-
- {narrowNav ? ( -
+ +
+
+
+
- - - ) : ( -
- -
+
+ +
+ +
+ + +
+
+ {!narrowNav && ( + <> + + {currentUser?.avatar ? ( + + ) : ( + + )} +
+
+ {formatMessage({ + id: 'account', + defaultMessage: 'Account', + })} +
+
+ {formatUsername(currentUser)} +
+
+ + + + )} + {narrowNav && ( +
+ + {currentUser?.avatar ? ( + + ) : ( + + )} + + - +
-
- )} + )} +
+
+ {narrowNav ? ( + + ) : ( +
+ +
+ + + + + +
+
+ )} +
-
-
-
- - -
-
- + {formatMessage({ + id: 'open_sidebar', + defaultMessage: ' Open sidebar', })} - /> + + + + +
+
+ +
-
-
- - - - - +
+ + + + + + +
+
+

+ {pageHeader} +

+ {React.cloneElement(children)} +
-
-

- {pageHeader} -

- {React.cloneElement(children)} -
-
- + + ); }; diff --git a/admin-ui/src/modules/search/SearchContext.tsx b/admin-ui/src/modules/search/SearchContext.tsx new file mode 100644 index 000000000..be31acbf3 --- /dev/null +++ b/admin-ui/src/modules/search/SearchContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; + +interface SearchContextValue { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +const SearchContext = createContext({ + isOpen: false, + open: () => {}, + close: () => {}, + toggle: () => {}, +}); + +export const SearchProvider = ({ children }: { children: React.ReactNode }) => { + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + + return ( + + {children} + + ); +}; + +export const useSearch = () => useContext(SearchContext); diff --git a/admin-ui/src/modules/search/components/CommandPalette.tsx b/admin-ui/src/modules/search/components/CommandPalette.tsx new file mode 100644 index 000000000..b078c77e1 --- /dev/null +++ b/admin-ui/src/modules/search/components/CommandPalette.tsx @@ -0,0 +1,545 @@ +import React, { + useEffect, + useState, + useRef, + useMemo, + useCallback, +} from 'react'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; +import { + MagnifyingGlassIcon, + CubeIcon, + UserIcon, + InboxStackIcon, + RectangleStackIcon, + AdjustmentsHorizontalIcon, + CalendarIcon, + DocumentTextIcon, + BoltIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; +import useGlobalSearch from '../hooks/useGlobalSearch'; +import { useSearch } from '../SearchContext'; +import ImageWithFallback from '@/components/ui/ImageWithFallback'; +import generateUniqueId from '../../common/utils/getUniqueId'; +import useAuth from '../../Auth/useAuth'; +import { IRoleAction } from '../../../gql/types'; +import type { IGlobalSearchQuery } from '../../../gql/types'; + +type SearchResult = IGlobalSearchQuery['globalSearch']['results'][number]; + +const typeIcons: Record = { + SimpleProduct: CubeIcon, + ConfigurableProduct: CubeIcon, + BundleProduct: CubeIcon, + PlanProduct: CubeIcon, + TokenizedProduct: CubeIcon, + User: UserIcon, + Order: InboxStackIcon, + Assortment: RectangleStackIcon, + Filter: AdjustmentsHorizontalIcon, + Enrollment: CalendarIcon, + Quotation: DocumentTextIcon, + Work: BoltIcon, +}; + +const typeLabels: Record = { + SimpleProduct: 'Product', + ConfigurableProduct: 'Product', + BundleProduct: 'Product', + PlanProduct: 'Product', + TokenizedProduct: 'Product', + User: 'User', + Order: 'Order', + Assortment: 'Assortment', + Filter: 'Filter', + Enrollment: 'Enrollment', + Quotation: 'Quotation', + Work: 'Work', +}; + +const typeViewAllPaths: Record = { + PRODUCT: '/products', + USER: '/users', + ORDER: '/orders', + ASSORTMENT: '/assortments', + FILTER: '/filters', + ENROLLMENT: '/enrollments', + QUOTATION: '/quotations', + WORK: '/works', +}; + +const typeViewAllRoles: Record = { + PRODUCT: IRoleAction.ViewProducts, + USER: IRoleAction.ViewUsers, + ORDER: IRoleAction.ViewOrders, + ASSORTMENT: IRoleAction.ViewAssortments, + FILTER: IRoleAction.ViewFilters, + ENROLLMENT: IRoleAction.ViewEnrollments, + QUOTATION: IRoleAction.ViewQuotations, + WORK: IRoleAction.ViewWorkQueue, +}; + +function getResultTitle(result: SearchResult): string { + switch (result.__typename) { + case 'SimpleProduct': + case 'ConfigurableProduct': + case 'BundleProduct': + case 'PlanProduct': + case 'TokenizedProduct': + case 'Assortment': + return result.texts?.title || result._id; + case 'User': + return ( + result.name || + result.username || + result.emails?.[0]?.address || + result._id + ); + case 'Order': + return result.orderNumber || result._id; + case 'Filter': + return result.key || result._id; + case 'Enrollment': + case 'Quotation': + return result._id; + case 'Work': + return `${result.type} - ${result._id.slice(0, 8)}`; + } +} + +function getResultImageUrl(result: SearchResult): string | null { + switch (result.__typename) { + case 'SimpleProduct': + case 'ConfigurableProduct': + case 'BundleProduct': + case 'PlanProduct': + case 'TokenizedProduct': + case 'Assortment': + return result.media?.[0]?.file?.url || null; + case 'User': + return result.avatar?.url || null; + default: + return null; + } +} + +function getResultHref(result: SearchResult): string { + switch (result.__typename) { + case 'SimpleProduct': + case 'ConfigurableProduct': + case 'BundleProduct': + case 'PlanProduct': + case 'TokenizedProduct': + return `/products?slug=${generateUniqueId(result)}`; + case 'User': + return `/users?userId=${result._id}`; + case 'Order': + return `/orders?orderId=${result._id}`; + case 'Assortment': + return `/assortments?assortmentSlug=${generateUniqueId(result)}`; + case 'Filter': + return `/filters?filterId=${result._id}`; + case 'Enrollment': + return `/enrollments?enrollmentId=${result._id}`; + case 'Quotation': + return `/quotations?quotationId=${result._id}`; + case 'Work': + return `/works?workerId=${result._id}`; + } +} + +const LoadingSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+); + +const allQuickNavItems = [ + { + labelId: 'products', + defaultLabel: 'Products', + href: '/products', + icon: CubeIcon, + requiredRole: IRoleAction.ViewProducts, + }, + { + labelId: 'orders', + defaultLabel: 'Orders', + href: '/orders', + icon: InboxStackIcon, + requiredRole: IRoleAction.ViewOrders, + }, + { + labelId: 'users', + defaultLabel: 'Users', + href: '/users', + icon: UserIcon, + requiredRole: IRoleAction.ViewUsers, + }, + { + labelId: 'assortments', + defaultLabel: 'Assortments', + href: '/assortments', + icon: RectangleStackIcon, + requiredRole: IRoleAction.ViewAssortments, + }, +]; + +const CommandPalette = () => { + const { isOpen, close, toggle } = useSearch(); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [keyboardNav, setKeyboardNav] = useState(false); + const { search, clear, results, counts, loading, error } = useGlobalSearch(); + const router = useRouter(); + const { formatMessage } = useIntl(); + const { hasRole } = useAuth(); + const inputRef = useRef(null); + const listRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + + const quickNavItems = useMemo( + () => + allQuickNavItems + .filter((item) => hasRole(item.requiredRole)) + .map((item) => ({ + label: formatMessage({ + id: item.labelId, + defaultMessage: item.defaultLabel, + }), + href: item.href, + icon: item.icon, + })), + [formatMessage, hasRole], + ); + + const visibleCounts = useMemo( + () => + counts.filter( + (c) => c.totalCount > 0 && hasRole(typeViewAllRoles[c.type]), + ), + [counts, hasRole], + ); + + const totalSelectableItems = results.length + visibleCounts.length; + + const debouncedSearch = useCallback( + (value: string) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + if (value.trim().length >= 2) { + search(value); + } else { + clear(); + } + }, 300); + }, + [search, clear], + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + toggle(); + } + if (e.key === 'Escape' && isOpen) { + close(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, close, toggle]); + + useEffect(() => { + if (isOpen) { + setTimeout(() => inputRef.current?.focus(), 50); + } else { + setQuery(''); + setActiveIndex(0); + clear(); + } + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [isOpen, clear]); + + useEffect(() => { + const handleRouteChange = () => close(); + router.events.on('routeChangeStart', handleRouteChange); + return () => router.events.off('routeChangeStart', handleRouteChange); + }, [router, close]); + + useEffect(() => { + if (!keyboardNav) return; + const activeItem = listRef.current?.children[activeIndex] as HTMLElement; + activeItem?.scrollIntoView({ block: 'nearest' }); + }, [activeIndex, keyboardNav]); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + setActiveIndex(0); + debouncedSearch(value); + }; + + const navigateToResult = (index: number) => { + if (index < results.length) { + router.push(getResultHref(results[index])); + } else { + const countIndex = index - results.length; + const c = visibleCounts[countIndex]; + if (c) { + const path = typeViewAllPaths[c.type]; + router.push(`${path}?queryString=${encodeURIComponent(query)}`); + } + } + close(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setKeyboardNav(true); + setActiveIndex((prev) => Math.min(prev + 1, totalSelectableItems - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setKeyboardNav(true); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' && totalSelectableItems > 0) { + e.preventDefault(); + navigateToResult(activeIndex); + } + }; + + const handleResultClick = (result: SearchResult) => { + router.push(getResultHref(result)); + close(); + }; + + const handleMouseEnter = (index: number) => { + setKeyboardNav(false); + setActiveIndex(index); + }; + + if (!isOpen) return null; + + const hasQuery = query.trim().length >= 2; + const activeOptionId = + totalSelectableItems > 0 ? `search-option-${activeIndex}` : undefined; + + return ( +
+
+
+
+
+ + 0} + aria-controls="search-listbox" + aria-activedescendant={activeOptionId} + value={query} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + className="w-full px-3 py-4 bg-transparent border-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-0 text-sm" + placeholder={formatMessage({ + id: 'global_search_placeholder', + defaultMessage: 'Search products, orders, users...', + })} + /> + + ESC + +
+ +
+ {error && ( +
+ +

+ {formatMessage({ + id: 'global_search_error', + defaultMessage: 'Search failed. Please try again.', + })} +

+
+ )} + + {!error && loading && hasQuery && results.length === 0 && ( + + )} + + {!error && !loading && hasQuery && results.length === 0 && ( +
+ {formatMessage({ + id: 'no_results_found', + defaultMessage: 'No results found', + })} +
+ )} + + {!error && totalSelectableItems > 0 && ( + <> +
    + {results.map((result: SearchResult, index: number) => { + const Icon = typeIcons[result.__typename] || CubeIcon; + const label = typeLabels[result.__typename] || 'Unknown'; + const isActive = index === activeIndex; + const imageUrl = getResultImageUrl(result); + + return ( +
  • handleResultClick(result)} + onMouseEnter={() => handleMouseEnter(index)} + role="option" + aria-selected={isActive} + className={`flex items-center px-4 py-2.5 cursor-pointer ${ + isActive + ? 'bg-slate-100 dark:bg-slate-800' + : 'hover:bg-slate-50 dark:hover:bg-slate-800/50' + }`} + > + {imageUrl ? ( +
    + +
    + ) : ( + + )} +
    + + {getResultTitle(result)} + +
    + + {label} + +
  • + ); + })} + {visibleCounts.map((c, i) => { + const globalIndex = results.length + i; + const isActive = globalIndex === activeIndex; + return ( +
  • { + router.push( + `${typeViewAllPaths[c.type]}?queryString=${encodeURIComponent(query)}`, + ); + close(); + }} + onMouseEnter={() => handleMouseEnter(globalIndex)} + role="option" + aria-selected={isActive} + className={`flex items-center px-4 py-2 cursor-pointer text-xs ${ + isActive + ? 'bg-slate-100 dark:bg-slate-800' + : 'hover:bg-slate-50 dark:hover:bg-slate-800/50' + }`} + > + + {formatMessage({ + id: c.type.toLowerCase(), + defaultMessage: + c.type.charAt(0) + c.type.slice(1).toLowerCase(), + })} + : {c.totalCount} + + + {formatMessage({ + id: 'view', + defaultMessage: 'View', + })} + {' →'} + +
  • + ); + })} +
+ {loading && ( +
+
+
+ )} + + )} +
+ + {!hasQuery && !error && ( +
+

+ {formatMessage({ + id: 'global_search_hint', + defaultMessage: 'Type at least 2 characters to search', + })} +

+ {quickNavItems.length > 0 && ( +
+ {quickNavItems.map((item) => ( + + ))} +
+ )} +
+ )} +
+
+
+ ); +}; + +export default CommandPalette; diff --git a/admin-ui/src/modules/search/fragments/GlobalSearchFragment.ts b/admin-ui/src/modules/search/fragments/GlobalSearchFragment.ts new file mode 100644 index 000000000..864863a26 --- /dev/null +++ b/admin-ui/src/modules/search/fragments/GlobalSearchFragment.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +const GlobalSearchProductFragment = gql` + fragment GlobalSearchProductFragment on Product { + _id + __typename + texts { + _id + title + slug + } + media(limit: 1) { + file { + url + } + } + } +`; + +export default GlobalSearchProductFragment; diff --git a/admin-ui/src/modules/search/hooks/useGlobalSearch.ts b/admin-ui/src/modules/search/hooks/useGlobalSearch.ts new file mode 100644 index 000000000..2e54a4848 --- /dev/null +++ b/admin-ui/src/modules/search/hooks/useGlobalSearch.ts @@ -0,0 +1,168 @@ +import { gql } from '@apollo/client'; +import { useLazyQuery } from '@apollo/client/react'; +import { useCallback, useState } from 'react'; +import GlobalSearchProductFragment from '../fragments/GlobalSearchFragment'; +import type { + IGlobalSearchQuery, + IGlobalSearchQueryVariables, +} from '../../../gql/types'; + +const GLOBAL_SEARCH_QUERY = gql` + query GlobalSearch( + $query: String! + $types: [SearchableEntity!] + $limit: Int + $includeDraftProducts: Boolean + $includeInactiveAssortments: Boolean + $includeInactiveFilters: Boolean + $includeGuestUsers: Boolean + $includeCarts: Boolean + ) { + globalSearch( + query: $query + types: $types + limit: $limit + includeDraftProducts: $includeDraftProducts + includeInactiveAssortments: $includeInactiveAssortments + includeInactiveFilters: $includeInactiveFilters + includeGuestUsers: $includeGuestUsers + includeCarts: $includeCarts + ) { + counts { + type + totalCount + authorized + } + results { + ...GlobalSearchProductFragment + ... on User { + _id + __typename + username + name + emails { + address + verified + } + avatar { + url + } + } + ... on Order { + _id + __typename + orderNumber + } + ... on Assortment { + _id + __typename + texts { + _id + title + slug + } + media(limit: 1) { + file { + url + } + } + } + ... on Filter { + _id + __typename + key + } + ... on Enrollment { + _id + __typename + } + ... on Quotation { + _id + __typename + } + ... on Work { + _id + __typename + type + } + } + } + } + ${GlobalSearchProductFragment} +`; + +interface SearchOptions { + includeDraftProducts?: boolean; + includeInactiveAssortments?: boolean; + includeInactiveFilters?: boolean; + includeGuestUsers?: boolean; + includeCarts?: boolean; +} + +const useGlobalSearch = (options: SearchOptions = {}) => { + const { + includeDraftProducts = true, + includeInactiveAssortments = true, + includeInactiveFilters = true, + includeGuestUsers = false, + includeCarts = false, + } = options; + + const [cleared, setCleared] = useState(false); + const [searchFn, { data, loading, error, called }] = useLazyQuery< + IGlobalSearchQuery, + IGlobalSearchQueryVariables + >(GLOBAL_SEARCH_QUERY, { + fetchPolicy: 'cache-and-network', + notifyOnNetworkStatusChange: true, + }); + + const search = useCallback( + ( + query: string, + types?: IGlobalSearchQueryVariables['types'], + limit?: number, + ) => { + setCleared(false); + const result = searchFn({ + variables: { + query, + types, + limit, + includeDraftProducts, + includeInactiveAssortments, + includeInactiveFilters, + includeGuestUsers, + includeCarts, + }, + }); + return result; + }, + [ + searchFn, + includeDraftProducts, + includeInactiveAssortments, + includeInactiveFilters, + includeGuestUsers, + includeCarts, + ], + ); + + const clear = useCallback(() => { + setCleared(true); + }, []); + + const results = cleared ? [] : data?.globalSearch?.results || []; + const counts = cleared ? [] : data?.globalSearch?.counts || []; + + return { + search, + clear, + results, + counts, + loading: loading && called && !cleared, + error, + }; +}; + +export default useGlobalSearch; diff --git a/package-lock.json b/package-lock.json index 4ad82de9f..8725190b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,7 @@ "prettier": "^3.7.4", "typedoc": "^0.28.15", "typescript": "^5.9.3", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.60.1" }, "engines": { "node": ">=22.0.0", @@ -4154,17 +4154,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", - "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/type-utils": "8.59.0", - "@typescript-eslint/utils": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4177,7 +4177,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.0", + "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4193,16 +4193,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", - "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "engines": { @@ -4218,14 +4218,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", - "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.0", - "@typescript-eslint/types": "^8.59.0", + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "engines": { @@ -4240,14 +4240,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", - "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0" + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4258,9 +4258,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", - "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", "dev": true, "license": "MIT", "engines": { @@ -4275,15 +4275,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", - "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4300,9 +4300,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", - "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", "dev": true, "license": "MIT", "engines": { @@ -4314,16 +4314,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", - "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.0", - "@typescript-eslint/tsconfig-utils": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4342,16 +4342,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", - "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0" + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4366,13 +4366,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", - "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -16232,9 +16232,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -16667,16 +16667,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", - "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0" + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index a204b665a..63c77e6d2 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "prettier": "^3.7.4", "typedoc": "^0.28.15", "typescript": "^5.9.3", - "typescript-eslint": "^8.50.0" + "typescript-eslint": "^8.60.1" }, "trustedDependencies": [ "@mongodb-js/zstd" diff --git a/packages/api/src/resolvers/queries/index.ts b/packages/api/src/resolvers/queries/index.ts index a76d01c9c..6c4ff7615 100755 --- a/packages/api/src/resolvers/queries/index.ts +++ b/packages/api/src/resolvers/queries/index.ts @@ -45,6 +45,7 @@ import productsCount from './products/productsCount.ts'; import quotation from './quotations/quotation.ts'; import quotations from './quotations/quotations.ts'; import quotationsCount from './quotations/quotationsCount.ts'; +import globalSearch from './search/globalSearch.ts'; import searchAssortments from './filters/searchAssortments.ts'; import searchProducts from './filters/searchProducts.ts'; import shopInfo from './shopInfo.ts'; @@ -131,6 +132,7 @@ export default { quotation: acl(actions.viewQuotation)(quotation), quotations: acl(actions.viewQuotations)(quotations), quotationsCount: acl(actions.viewQuotations)(quotationsCount), + globalSearch: acl(actions.search)(globalSearch), searchProducts: acl(actions.search)(searchProducts), searchAssortments: acl(actions.search)(searchAssortments), workQueue: acl(actions.viewWorkQueue)(workQueue), diff --git a/packages/api/src/resolvers/queries/search/globalSearch.ts b/packages/api/src/resolvers/queries/search/globalSearch.ts new file mode 100644 index 000000000..071fbc94c --- /dev/null +++ b/packages/api/src/resolvers/queries/search/globalSearch.ts @@ -0,0 +1,285 @@ +import { log } from '@unchainedshop/logger'; +import { checkAction } from '../../../acl.ts'; +import { actions } from '../../../roles/index.ts'; +import type { Context } from '../../../context.ts'; +import type { Product } from '@unchainedshop/core-products'; +import type { User } from '@unchainedshop/core-users'; +import type { Order } from '@unchainedshop/core-orders'; +import type { Assortment } from '@unchainedshop/core-assortments'; +import type { Filter } from '@unchainedshop/core-filters'; +import type { Enrollment } from '@unchainedshop/core-enrollments'; +import type { Quotation } from '@unchainedshop/core-quotations'; +import type { Work } from '@unchainedshop/core-worker'; + +type SearchableEntity = + | 'PRODUCT' + | 'USER' + | 'ORDER' + | 'ASSORTMENT' + | 'FILTER' + | 'ENROLLMENT' + | 'QUOTATION' + | 'WORK'; + +interface TypeLimitInput { + type: SearchableEntity; + limit: number; +} + +type SearchResultItem = ( + | Product + | User + | Order + | Assortment + | Filter + | Enrollment + | Quotation + | Work +) & { + __typename: string; +}; + +const ALL_TYPES: SearchableEntity[] = [ + 'PRODUCT', + 'USER', + 'ORDER', + 'ASSORTMENT', + 'FILTER', + 'ENROLLMENT', + 'QUOTATION', + 'WORK', +]; + +const DEFAULT_LIMIT = 5; + +const typeActionMap: Record = { + PRODUCT: actions.viewProducts, + USER: actions.viewUsers, + ORDER: actions.viewOrders, + ASSORTMENT: actions.viewAssortments, + FILTER: actions.viewFilters, + ENROLLMENT: actions.viewEnrollments, + QUOTATION: actions.viewQuotations, + WORK: actions.viewWorkQueue, +}; + +function getLimitForType( + type: SearchableEntity, + defaultLimit: number, + typeLimits?: TypeLimitInput[], +): number { + return typeLimits?.find((tl) => tl.type === type)?.limit ?? defaultLimit; +} + +interface SearchOptions { + includeDraftProducts: boolean; + includeInactiveAssortments: boolean; + includeInactiveFilters: boolean; + includeGuestUsers: boolean; + includeCarts: boolean; +} + +const typeNameMap: Record = { + PRODUCT: 'Product', + USER: 'User', + ORDER: 'Order', + ASSORTMENT: 'Assortment', + FILTER: 'Filter', + ENROLLMENT: 'Enrollment', + QUOTATION: 'Quotation', + WORK: 'Work', +}; + +async function searchType( + type: SearchableEntity, + queryString: string, + limit: number, + modules: Context['modules'], + options: SearchOptions, +): Promise { + let results: (Product | User | Order | Assortment | Filter | Enrollment | Quotation | Work)[]; + switch (type) { + case 'PRODUCT': + results = await modules.products.findProducts({ + queryString, + limit, + offset: 0, + includeDrafts: options.includeDraftProducts, + }); + break; + case 'USER': + results = await modules.users.findUsers({ + queryString, + limit, + offset: 0, + includeGuests: options.includeGuestUsers, + }); + break; + case 'ORDER': + results = await modules.orders.findOrders({ + queryString, + limit, + offset: 0, + includeCarts: options.includeCarts, + }); + break; + case 'ASSORTMENT': + results = await modules.assortments.findAssortments({ + queryString, + limit, + offset: 0, + includeInactive: options.includeInactiveAssortments, + }); + break; + case 'FILTER': + results = await modules.filters.findFilters({ + queryString, + limit, + offset: 0, + includeInactive: options.includeInactiveFilters, + }); + break; + case 'ENROLLMENT': + results = await modules.enrollments.findEnrollments({ queryString, limit, offset: 0 }); + break; + case 'QUOTATION': + results = await modules.quotations.findQuotations({ queryString, limit, offset: 0 }); + break; + case 'WORK': + results = await modules.worker.findWorkQueue({ queryString, limit, skip: 0 }); + break; + default: + return []; + } + const __typename = typeNameMap[type]; + return results.map((r) => ({ ...r, __typename })); +} + +async function countType( + type: SearchableEntity, + queryString: string, + modules: Context['modules'], + options: SearchOptions, +): Promise { + switch (type) { + case 'PRODUCT': + return modules.products.count({ queryString, includeDrafts: options.includeDraftProducts }); + case 'USER': + return modules.users.count({ queryString, includeGuests: options.includeGuestUsers }); + case 'ORDER': + return modules.orders.count({ queryString, includeCarts: options.includeCarts }); + case 'ASSORTMENT': + return modules.assortments.count({ + queryString, + includeInactive: options.includeInactiveAssortments, + }); + case 'FILTER': + return modules.filters.count({ queryString, includeInactive: options.includeInactiveFilters }); + case 'ENROLLMENT': + return modules.enrollments.count({ queryString }); + case 'QUOTATION': + return modules.quotations.count({ queryString }); + case 'WORK': + return modules.worker.count({ queryString }); + default: + return 0; + } +} + +async function checkTypeAuthorization( + context: Context, + types: SearchableEntity[], +): Promise<{ authorized: SearchableEntity[]; unauthorized: Set }> { + const results = await Promise.allSettled( + types.map(async (type) => { + await checkAction(context, typeActionMap[type]); + return type; + }), + ); + const authorized: SearchableEntity[] = []; + const unauthorized = new Set(); + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + authorized.push(types[i]); + } else { + unauthorized.add(types[i]); + } + }); + return { authorized, unauthorized }; +} + +export default async function globalSearch( + root: never, + params: { + query: string; + types?: SearchableEntity[]; + limit?: number; + typeLimits?: TypeLimitInput[]; + includeDraftProducts?: boolean; + includeInactiveAssortments?: boolean; + includeInactiveFilters?: boolean; + includeGuestUsers?: boolean; + includeCarts?: boolean; + }, + context: Context, +) { + const { + query: queryString, + types, + limit = DEFAULT_LIMIT, + typeLimits, + includeDraftProducts = true, + includeInactiveAssortments = true, + includeInactiveFilters = true, + includeGuestUsers = false, + includeCarts = false, + } = params; + const { modules, userId } = context; + + const sanitizedQuery = queryString?.trim().slice(0, 200); + if (!sanitizedQuery) return { results: [], counts: [] }; + + log(`query globalSearch: "${sanitizedQuery}" types: ${types?.join(',') || 'all'}`, { userId }); + + const requestedTypes = types?.length ? types : ALL_TYPES; + const { authorized, unauthorized } = await checkTypeAuthorization(context, requestedTypes); + + const searchOptions: SearchOptions = { + includeDraftProducts, + includeInactiveAssortments, + includeInactiveFilters, + includeGuestUsers, + includeCarts, + }; + + const [resultsByType, countsByType] = await Promise.all([ + Promise.all( + authorized.map((type) => + searchType( + type, + sanitizedQuery, + getLimitForType(type, limit, typeLimits), + modules, + searchOptions, + ), + ), + ), + Promise.all(authorized.map((type) => countType(type, sanitizedQuery, modules, searchOptions))), + ]); + + const results = resultsByType.flat(); + const counts = [ + ...authorized.map((type, i) => ({ + type, + totalCount: countsByType[i], + authorized: true, + })), + ...[...unauthorized].map((type) => ({ + type, + totalCount: 0, + authorized: false, + })), + ]; + + return { results, counts }; +} diff --git a/packages/api/src/resolvers/type/global-search-result-types.ts b/packages/api/src/resolvers/type/global-search-result-types.ts new file mode 100644 index 000000000..5fb4632f2 --- /dev/null +++ b/packages/api/src/resolvers/type/global-search-result-types.ts @@ -0,0 +1,17 @@ +import { ProductType } from '@unchainedshop/core-products'; + +const productTypeMap: Record = { + [ProductType.CONFIGURABLE_PRODUCT]: 'ConfigurableProduct', + [ProductType.BUNDLE_PRODUCT]: 'BundleProduct', + [ProductType.PLAN_PRODUCT]: 'PlanProduct', + [ProductType.TOKENIZED_PRODUCT]: 'TokenizedProduct', +}; + +export const GlobalSearchResult = { + __resolveType(obj: Record): string { + if (obj.__typename === 'Product') { + return productTypeMap[obj.type] || 'SimpleProduct'; + } + return obj.__typename; + }, +}; diff --git a/packages/api/src/resolvers/type/index.ts b/packages/api/src/resolvers/type/index.ts index 1d66dd210..401879bde 100755 --- a/packages/api/src/resolvers/type/index.ts +++ b/packages/api/src/resolvers/type/index.ts @@ -16,6 +16,7 @@ import { EnrollmentDelivery } from './enrollment/enrollment-delivery-types.ts'; import { EnrollmentPayment } from './enrollment/enrollment-payment-types.ts'; import { EnrollmentPeriod } from './enrollment/enrollment-period-types.ts'; import { EnrollmentPlan } from './enrollment/enrollment-plan-tyes.ts'; +import { GlobalSearchResult } from './global-search-result-types.ts'; import { Filter } from './filter/filter-types.ts'; import { FilterOption } from './filter/filter-option-types.ts'; import { Language } from './language-types.ts'; @@ -92,6 +93,7 @@ const types = { EnrollmentPeriod, EnrollmentPlan, Filter, + GlobalSearchResult, FilterOption, Language, LoadedFilter, diff --git a/packages/api/src/schema/inputTypes.ts b/packages/api/src/schema/inputTypes.ts index e2215a0d8..af6700577 100644 --- a/packages/api/src/schema/inputTypes.ts +++ b/packages/api/src/schema/inputTypes.ts @@ -262,5 +262,10 @@ export default [ start: DateTime end: DateTime } + + input GlobalSearchTypeLimitInput { + type: SearchableEntity! + limit: Int! + } `, ]; diff --git a/packages/api/src/schema/query.ts b/packages/api/src/schema/query.ts index 4699fd9ed..8728acc37 100644 --- a/packages/api/src/schema/query.ts +++ b/packages/api/src/schema/query.ts @@ -415,6 +415,21 @@ export default [ """ enrollment(enrollmentId: ID!): Enrollment + """ + Search across multiple entity types in a single request + """ + globalSearch( + query: String! + types: [SearchableEntity!] + limit: Int = 5 + typeLimits: [GlobalSearchTypeLimitInput!] + includeDraftProducts: Boolean = true + includeInactiveAssortments: Boolean = true + includeInactiveFilters: Boolean = true + includeGuestUsers: Boolean = false + includeCarts: Boolean = false + ): GlobalSearchResponse! @cacheControl(maxAge: 30) + """ Search products """ diff --git a/packages/api/src/schema/types/search.ts b/packages/api/src/schema/types/search.ts index 539bd9263..af5e568f7 100644 --- a/packages/api/src/schema/types/search.ts +++ b/packages/api/src/schema/types/search.ts @@ -28,5 +28,41 @@ export default [ assortmentsCount: Int! assortments(limit: Int = 10, offset: Int = 0): [Assortment!]! } + + enum SearchableEntity { + PRODUCT + USER + ORDER + ASSORTMENT + FILTER + ENROLLMENT + QUOTATION + WORK + } + + union GlobalSearchResult = + | SimpleProduct + | ConfigurableProduct + | BundleProduct + | PlanProduct + | TokenizedProduct + | User + | Order + | Assortment + | Filter + | Enrollment + | Quotation + | Work + + type GlobalSearchTypeCount { + type: SearchableEntity! + totalCount: Int! + authorized: Boolean! + } + + type GlobalSearchResponse { + results: [GlobalSearchResult!]! + counts: [GlobalSearchTypeCount!]! + } `, ]; diff --git a/packages/core-orders/src/module/configureOrdersModule-queries.ts b/packages/core-orders/src/module/configureOrdersModule-queries.ts index 62397da9a..12c3bf873 100644 --- a/packages/core-orders/src/module/configureOrdersModule-queries.ts +++ b/packages/core-orders/src/module/configureOrdersModule-queries.ts @@ -143,6 +143,7 @@ export const configureOrdersModuleQueries = ({ Orders }: { Orders: mongodb.Colle if (queryString) { return Orders.find(selector, { + ...findOptions, ...options, projection: { score: { $meta: 'textScore' } }, sort: { score: { $meta: 'textScore' } }, diff --git a/tests/global-search.test.js b/tests/global-search.test.js new file mode 100644 index 000000000..6d075dd83 --- /dev/null +++ b/tests/global-search.test.js @@ -0,0 +1,135 @@ +import { + setupDatabase, + createLoggedInGraphqlFetch, + createAnonymousGraphqlFetch, + disconnect, +} from './helpers.js'; +import { ADMIN_TOKEN } from './seeds/users.js'; +import assert from 'node:assert'; +import test from 'node:test'; + +const GLOBAL_SEARCH_QUERY = /* GraphQL */ ` + query GlobalSearch($query: String!, $types: [SearchableEntity!], $limit: Int) { + globalSearch(query: $query, types: $types, limit: $limit) { + results { + ... on SimpleProduct { + _id + __typename + } + ... on ConfigurableProduct { + _id + __typename + } + ... on User { + _id + __typename + } + ... on Order { + _id + __typename + } + ... on Assortment { + _id + __typename + } + ... on Filter { + _id + __typename + } + } + counts { + type + totalCount + authorized + } + } + } +`; + +let graphqlFetch; +let graphqlFetchAsAnonymousUser; + +test.describe('Query.globalSearch', () => { + test.before(async () => { + await setupDatabase(); + graphqlFetch = createLoggedInGraphqlFetch(ADMIN_TOKEN); + graphqlFetchAsAnonymousUser = createAnonymousGraphqlFetch(); + }); + + test.after(async () => { + await disconnect(); + }); + + test.describe('Admin user', () => { + test('return results and counts for a broad search', async () => { + const { + data: { globalSearch }, + } = await graphqlFetch({ + query: GLOBAL_SEARCH_QUERY, + variables: { query: 'a', limit: 5 }, + }); + + assert.ok(Array.isArray(globalSearch.results)); + assert.ok(Array.isArray(globalSearch.counts)); + assert.ok(globalSearch.counts.length > 0); + + for (const count of globalSearch.counts) { + assert.ok(typeof count.type === 'string'); + assert.ok(typeof count.totalCount === 'number'); + assert.strictEqual(count.authorized, true); + } + }); + + test('filter by specific type', async () => { + const { + data: { globalSearch }, + } = await graphqlFetch({ + query: GLOBAL_SEARCH_QUERY, + variables: { query: 'a', types: ['PRODUCT'], limit: 5 }, + }); + + assert.ok(Array.isArray(globalSearch.results)); + assert.strictEqual(globalSearch.counts.length, 1); + assert.strictEqual(globalSearch.counts[0].type, 'PRODUCT'); + }); + + test('return empty results for empty query', async () => { + const { + data: { globalSearch }, + } = await graphqlFetch({ + query: GLOBAL_SEARCH_QUERY, + variables: { query: '' }, + }); + + assert.deepStrictEqual(globalSearch.results, []); + assert.deepStrictEqual(globalSearch.counts, []); + }); + + test('return empty results for whitespace-only query', async () => { + const { + data: { globalSearch }, + } = await graphqlFetch({ + query: GLOBAL_SEARCH_QUERY, + variables: { query: ' ' }, + }); + + assert.deepStrictEqual(globalSearch.results, []); + assert.deepStrictEqual(globalSearch.counts, []); + }); + }); + + test.describe('Anonymous user', () => { + test('return NoPermissionError', async () => { + const { errors } = await graphqlFetchAsAnonymousUser({ + query: GLOBAL_SEARCH_QUERY, + variables: { query: 'test' }, + }); + + assert.ok(errors?.length > 0); + assert.ok( + ['NoPermissionError', 'INTERNAL_SERVER_ERROR'].includes(errors[0].extensions?.code), + `Expected permission error, got: ${errors[0].extensions?.code}`, + ); + }); + }); +});