diff --git a/config/lattice.php b/config/lattice.php index a6b68000..ae23d3b3 100644 --- a/config/lattice.php +++ b/config/lattice.php @@ -41,6 +41,11 @@ 'middleware' => ['web', 'auth'], ], + 'search' => [ + 'endpoint' => 'lattice/search', + 'middleware' => ['web', 'auth'], + ], + 'actions' => [ 'endpoint' => 'lattice/actions/{action}', 'middleware' => ['web', 'auth'], diff --git a/lang/de/lattice.php b/lang/de/lattice.php index 28c34805..676b8507 100644 --- a/lang/de/lattice.php +++ b/lang/de/lattice.php @@ -110,6 +110,17 @@ 'send' => 'Senden', 'input-label' => 'Nachrichteneingabe', ], + 'search' => [ + 'placeholder' => 'Suchen…', + 'title' => 'Suche', + 'results' => 'Ergebnisse', + 'recent' => 'Zuletzt', + 'open' => 'Öffnen', + 'empty' => 'Keine Ergebnisse gefunden.', + 'loading' => 'Suche läuft…', + 'error' => 'Etwas ist schiefgelaufen.', + 'previewEmpty' => 'Ergebnis auswählen, um eine Vorschau anzuzeigen.', + ], 'remote' => [ 'data-list' => [ 'loading' => 'Lädt...', diff --git a/lang/en/lattice.php b/lang/en/lattice.php index 1cb8698a..ccf023f8 100644 --- a/lang/en/lattice.php +++ b/lang/en/lattice.php @@ -110,6 +110,17 @@ 'send' => 'Send', 'input-label' => 'Message input', ], + 'search' => [ + 'placeholder' => 'Search…', + 'title' => 'Search', + 'results' => 'Results', + 'recent' => 'Recent', + 'open' => 'Open', + 'empty' => 'No results found.', + 'loading' => 'Searching…', + 'error' => 'Something went wrong.', + 'previewEmpty' => 'Select a result to preview.', + ], 'remote' => [ 'data-list' => [ 'loading' => 'Loading...', diff --git a/package-lock.json b/package-lock.json index ec2c76f2..661a1b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lattice-php/lattice", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lattice-php/lattice", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@lattice-php/vite-svg-sprite": "^0.2.1", @@ -279,9 +279,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -299,9 +296,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -319,9 +313,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -339,9 +330,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2484,9 +2472,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2504,9 +2489,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2524,9 +2506,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2544,9 +2523,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2564,9 +2540,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2584,9 +2557,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2604,9 +2574,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2624,9 +2591,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2644,9 +2608,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2670,9 +2631,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2696,9 +2654,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2722,9 +2677,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2748,9 +2700,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2774,9 +2723,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2800,9 +2746,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2826,9 +2769,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3615,9 +3555,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3635,9 +3572,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3655,9 +3589,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3675,9 +3606,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3695,9 +3623,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3715,9 +3640,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3735,9 +3657,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3755,9 +3674,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3981,9 +3897,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3998,9 +3911,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4015,9 +3925,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4032,9 +3939,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4049,9 +3953,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4066,9 +3967,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4083,9 +3981,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4100,9 +3995,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4308,9 +4200,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4328,9 +4217,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4348,9 +4234,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4368,9 +4251,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4388,9 +4268,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4408,9 +4285,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4428,9 +4302,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4448,9 +4319,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4655,9 +4523,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4675,9 +4540,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4695,9 +4557,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4715,9 +4574,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4735,9 +4591,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4755,9 +4608,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4775,9 +4625,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4795,9 +4642,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5781,9 +5625,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5801,9 +5642,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5821,9 +5659,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5841,9 +5676,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5861,9 +5693,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5881,9 +5710,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6103,9 +5929,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6120,9 +5943,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6137,9 +5957,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6154,9 +5971,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6171,9 +5985,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6188,9 +5999,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6205,9 +6013,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6222,9 +6027,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6239,9 +6041,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6256,9 +6055,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6273,9 +6069,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6290,9 +6083,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6307,9 +6097,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6883,9 +6670,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6903,9 +6687,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6923,9 +6704,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6943,9 +6721,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11586,9 +11361,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11610,9 +11382,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11634,9 +11403,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11658,9 +11424,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14244,9 +14007,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14264,9 +14024,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -14284,9 +14041,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14304,9 +14058,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14324,9 +14075,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -14344,9 +14092,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14364,9 +14109,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -14384,9 +14126,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 0b46245e..476a6702 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,14 @@ "types": "./dist/chat/*.d.ts", "import": "./dist/chat/*.js" }, + "./search": { + "types": "./dist/search/index.d.ts", + "import": "./dist/search/index.js" + }, + "./search/*": { + "types": "./dist/search/*.d.ts", + "import": "./dist/search/*.js" + }, "./clipboard": { "types": "./dist/clipboard/index.d.ts", "import": "./dist/clipboard/index.js" @@ -186,7 +194,7 @@ "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "type-coverage": "type-coverage --project tsconfig.json --at-least 99.5", - "check": "npm run lint:fix && npm run format && npm run typecheck && npm run type-coverage && npm run test && npm run build:lib", + "check": "npm run lint:fix && npm run format && npm run typecheck && npm run type-coverage && npm run test && npm run build && npm run build:lib", "docs:typecheck": "tsc --noEmit -p docs/tsconfig.json", "lint": "oxlint resources/js workbench/resources/js", "lint:fix": "oxlint resources/js workbench/resources/js --fix", diff --git a/resources/icons/package.svg b/resources/icons/package.svg new file mode 100644 index 00000000..e1d12d86 --- /dev/null +++ b/resources/icons/package.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/resources/icons/receipt.svg b/resources/icons/receipt.svg new file mode 100644 index 00000000..04b424df --- /dev/null +++ b/resources/icons/receipt.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/resources/icons/users.svg b/resources/icons/users.svg new file mode 100644 index 00000000..b201db65 --- /dev/null +++ b/resources/icons/users.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/resources/js/index.ts b/resources/js/index.ts index 309d2235..48616840 100644 --- a/resources/js/index.ts +++ b/resources/js/index.ts @@ -1,6 +1,23 @@ export { registry } from "./registry"; export { useChat } from "./chat/use-chat"; export { chatPlugin } from "./chat"; +export { useSearch } from "./search/use-search"; +export { + SearchBox, + SearchInput, + SearchCategories, + SearchResults, + SearchRecent, + SearchPreview, + searchComponents, +} from "./search"; +export type { + SearchResult, + SearchCategory, + SearchPagination, + UseSearchOptions, + UseSearchReturn, +} from "./search/types"; export { getActionEffects, isActionEffect, diff --git a/resources/js/registry.ts b/resources/js/registry.ts index b24c5e7d..c1c9e01d 100644 --- a/resources/js/registry.ts +++ b/resources/js/registry.ts @@ -3,6 +3,7 @@ import { actionComponents } from "./action"; import { chatPlugin } from "./chat"; import { coreComponents } from "./core/components"; import { formComponents } from "./form"; +import { searchComponents } from "./search"; import { remoteComponents } from "./remote"; import { layoutComponents } from "./layout/components"; import { tableComponents } from "./table"; @@ -14,5 +15,6 @@ export const registry = createRegistry( layoutComponents, tableComponents, chatPlugin, + searchComponents, remoteComponents, ); diff --git a/resources/js/search/components/categories.tsx b/resources/js/search/components/categories.tsx new file mode 100644 index 00000000..ddd0b713 --- /dev/null +++ b/resources/js/search/components/categories.tsx @@ -0,0 +1,39 @@ +import { Icon } from "@lattice-php/lattice/icons"; +import { cn } from "@lattice-php/lattice/lib/utils"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useSearchContext } from "../context"; + +const SearchCategories: RendererComponent<"search.categories"> = () => { + const { categories, activeCategory, setCategory } = useSearchContext(); + + return ( +
+ {categories.map((category) => ( + + ))} +
+ ); +}; + +export default SearchCategories; diff --git a/resources/js/search/components/input.tsx b/resources/js/search/components/input.tsx new file mode 100644 index 00000000..639ad0aa --- /dev/null +++ b/resources/js/search/components/input.tsx @@ -0,0 +1,26 @@ +import { Icon } from "@lattice-php/lattice/icons"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useSearchContext } from "../context"; + +const SearchInput: RendererComponent<"search.input"> = () => { + const { query, setQuery } = useSearchContext(); + const { t } = useT("lattice"); + + return ( +
+
+ ); +}; + +export default SearchInput; diff --git a/resources/js/search/components/preview.tsx b/resources/js/search/components/preview.tsx new file mode 100644 index 00000000..29d359bb --- /dev/null +++ b/resources/js/search/components/preview.tsx @@ -0,0 +1,40 @@ +import { Button } from "@lattice-php/lattice/core/components/button"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useSearchContext } from "../context"; + +const SearchPreview: RendererComponent<"search.preview"> = () => { + const { results, recent, focusedId, openResult } = useSearchContext(); + const { t } = useT("lattice"); + + const focused = [...results, ...recent].find((result) => result.item.id === focusedId) ?? null; + + if (focused === null) { + return ( +
+ {t("search.previewEmpty", "Select a result to preview.")} +
+ ); + } + + const detail = [focused.item.subtitle, focused.item.additionalInfo].filter(Boolean).join(" · "); + + return ( +
+
+ {focused.item.title} + {detail !== "" ? {detail} : null} + {focused.item.badge ? ( + + {focused.item.badge} + + ) : null} +
+ +
+ ); +}; + +export default SearchPreview; diff --git a/resources/js/search/components/recent.tsx b/resources/js/search/components/recent.tsx new file mode 100644 index 00000000..e2dfe8b4 --- /dev/null +++ b/resources/js/search/components/recent.tsx @@ -0,0 +1,32 @@ +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useSearchContext } from "../context"; +import { ResultRow } from "./result-row"; + +const SearchRecent: RendererComponent<"search.recent"> = () => { + const { query, recent, focusedId, setFocusedId, openResult } = useSearchContext(); + const { t } = useT("lattice"); + + if (query.trim() !== "" || recent.length === 0) { + return null; + } + + return ( +
+ + {t("search.recent", "Recent")} + + {recent.map((result) => ( + setFocusedId(result.item.id)} + onOpen={() => openResult(result)} + result={result} + /> + ))} +
+ ); +}; + +export default SearchRecent; diff --git a/resources/js/search/components/result-row.tsx b/resources/js/search/components/result-row.tsx new file mode 100644 index 00000000..5d87e12b --- /dev/null +++ b/resources/js/search/components/result-row.tsx @@ -0,0 +1,58 @@ +import { Icon } from "@lattice-php/lattice/icons"; +import { cn } from "@lattice-php/lattice/lib/utils"; +import type { SearchResult } from "../types"; + +function secondary(item: SearchResult["item"]): string { + return [item.subtitle, item.additionalInfo] + .filter((value): value is string => Boolean(value)) + .join(" · "); +} + +export function ResultRow({ + result, + focused, + onOpen, + onFocus, +}: { + result: SearchResult; + focused: boolean; + onOpen: () => void; + onFocus: () => void; +}) { + const detail = secondary(result.item); + + return ( + + ); +} diff --git a/resources/js/search/components/results.test.tsx b/resources/js/search/components/results.test.tsx new file mode 100644 index 00000000..c74752d6 --- /dev/null +++ b/resources/js/search/components/results.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SearchProvider } from "../context"; +import type { SearchResult, UseSearchReturn } from "../types"; +import SearchResults from "./results"; + +function row(id: string, title: string): SearchResult { + return { + category: { name: "products" }, + item: { + id, + title, + subtitle: "SKU-" + id, + additionalInfo: null, + link: "/products/" + id, + badge: null, + }, + }; +} + +function harness(overrides: Partial = {}) { + const setFocusedId = vi.fn<(id: string | null) => void>(); + const openResult = vi.fn<(result: SearchResult) => void>(); + const loadMore = vi.fn<() => void>(); + const value: UseSearchReturn = { + query: "wid", + setQuery: vi.fn<(value: string) => void>(), + categories: [], + activeCategory: "products", + setCategory: vi.fn<(name: string | null) => void>(), + results: [row("1", "Widget 1"), row("2", "Widget 2")], + recent: [], + pagination: { page: 1, perPage: 20, total: 2, hasMore: false, nextPage: null }, + status: "success", + error: null, + focusedId: "1", + setFocusedId, + openResult, + loadMore, + refreshRecent: vi.fn<() => void>(), + ...overrides, + }; + + render( + + {null} + , + ); + + return { setFocusedId, openResult, loadMore }; +} + +describe("SearchResults", () => { + it("renders rows with title and joined subtitle", () => { + harness(); + expect(screen.getByText("Widget 1")).toBeInTheDocument(); + expect(screen.getByText("SKU-1")).toBeInTheDocument(); + }); + + it("moves focus with arrow keys and opens on Enter", () => { + const { setFocusedId, openResult } = harness(); + const list = screen.getByRole("listbox"); + + fireEvent.keyDown(list, { key: "ArrowDown" }); + expect(setFocusedId).toHaveBeenCalledWith("2"); + + fireEvent.keyDown(list, { key: "Enter" }); + expect(openResult).toHaveBeenCalledWith( + expect.objectContaining({ item: expect.objectContaining({ id: "1" }) }), + ); + }); + + it("shows the empty state when there are no results", () => { + harness({ results: [], status: "success" }); + expect(screen.getByText(/no results/i)).toBeInTheDocument(); + }); +}); diff --git a/resources/js/search/components/results.tsx b/resources/js/search/components/results.tsx new file mode 100644 index 00000000..47fba436 --- /dev/null +++ b/resources/js/search/components/results.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, type KeyboardEvent } from "react"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { useSearchContext } from "../context"; +import { ResultRow } from "./result-row"; + +const SearchResults: RendererComponent<"search.results"> = () => { + const { results, focusedId, setFocusedId, openResult, loadMore, status, pagination } = + useSearchContext(); + const { t } = useT("lattice"); + const sentinelRef = useRef(null); + + useEffect(() => { + const node = sentinelRef.current; + if (!node || pagination?.hasMore !== true) { + return; + } + + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + loadMore(); + } + }); + observer.observe(node); + + return () => observer.disconnect(); + }, [loadMore, pagination?.hasMore]); + + function onKeyDown(event: KeyboardEvent): void { + if (results.length === 0) { + return; + } + + const index = Math.max( + 0, + results.findIndex((result) => result.item.id === focusedId), + ); + + if (event.key === "ArrowDown") { + event.preventDefault(); + setFocusedId(results[Math.min(results.length - 1, index + 1)]?.item.id ?? null); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setFocusedId(results[Math.max(0, index - 1)]?.item.id ?? null); + } else if (event.key === "Enter") { + event.preventDefault(); + const focused = results[index]; + if (focused) { + openResult(focused); + } + } + } + + if (status === "error") { + return ( +
{t("search.error", "Something went wrong.")}
+ ); + } + + if (status === "loading" && results.length === 0) { + return
{t("search.loading", "Searching…")}
; + } + + if (results.length === 0) { + return ( +
{t("search.empty", "No results found.")}
+ ); + } + + return ( +
+ {results.map((result) => ( + setFocusedId(result.item.id)} + onOpen={() => openResult(result)} + result={result} + /> + ))} + + ); +}; + +export default SearchResults; diff --git a/resources/js/search/components/search-box.test.tsx b/resources/js/search/components/search-box.test.tsx new file mode 100644 index 00000000..8a93e566 --- /dev/null +++ b/resources/js/search/components/search-box.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import SearchBox from "./search-box"; + +afterEach(() => vi.unstubAllGlobals()); + +const emptyPayload = JSON.stringify({ + data: [], + categories: [], + pagination: { page: 1, perPage: 20, total: 0, hasMore: false, nextPage: null }, + state: { query: "", category: null, perPage: 20, countsIncluded: true }, +}); + +function renderRoot() { + const node = { + type: "search.box", + props: { + endpoint: "/lattice/search", + placeholder: "Search…", + title: "Search", + shortcut: true, + perPage: 20, + }, + } as never; + + return render({null}); +} + +describe("SearchBox root", () => { + it("opens on Cmd+K and shows the default composition", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(emptyPayload, { status: 200 })), + ); + renderRoot(); + + fireEvent.keyDown(window, { key: "k", metaKey: true }); + + await waitFor(() => expect(screen.getByRole("searchbox")).toBeInTheDocument()); + }); + + it("fires the recent=1 request exactly once when opened (no render loop)", async () => { + vi.useRealTimers(); + + const fetchMock = vi.fn(async () => new Response(emptyPayload, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + renderRoot(); + + fireEvent.keyDown(window, { key: "k", metaKey: true }); + + // Wait for the dialog to appear and the initial fetch to settle. + await waitFor(() => expect(screen.getByRole("searchbox")).toBeInTheDocument()); + + // Give any potential loop iterations time to run. + await new Promise((resolve) => setTimeout(resolve, 100)); + + const recentCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes("recent=1")); + + expect(recentCalls).toHaveLength(1); + }); + + it("ignores the shortcut while typing in an input", () => { + renderRoot(); + const external = document.createElement("input"); + document.body.appendChild(external); + external.focus(); + + fireEvent.keyDown(external, { key: "k", metaKey: true }); + + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + external.remove(); + }); +}); diff --git a/resources/js/search/components/search-box.tsx b/resources/js/search/components/search-box.tsx new file mode 100644 index 00000000..d616c841 --- /dev/null +++ b/resources/js/search/components/search-box.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Dialog, DialogContent } from "@lattice-php/lattice/core/components/dialog"; +import { Icon } from "@lattice-php/lattice/icons"; +import type { RendererComponent } from "@lattice-php/lattice/core/types"; +import { useT } from "@lattice-php/lattice/i18n"; +import { SearchProvider } from "../context"; +import { useSearch } from "../use-search"; +import SearchCategories from "./categories"; +import SearchInput from "./input"; +import SearchPreview from "./preview"; +import SearchRecent from "./recent"; +import SearchResults from "./results"; + +function isEditingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + const tag = target.tagName; + + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; +} + +function DefaultComposition() { + const passthrough = { type: "search.slot", props: {} } as never; + + return ( +
+ {null} + {null} +
+
+ {null} +
+
+ {null} +
+
+ {null} +
+
+
+ ); +} + +const SearchBox: RendererComponent<"search.box"> = ({ node, children }) => { + const { endpoint, placeholder, title, shortcut, perPage } = node.props; + const { t } = useT("lattice"); + const [open, setOpen] = useState(false); + const search = useSearch({ + endpoint: endpoint ?? "/lattice/search", + perPage: perPage ?? 20, + }); + + useEffect(() => { + if (shortcut === false) { + return; + } + + function onKeyDown(event: KeyboardEvent): void { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + if (isEditingTarget(event.target)) { + return; + } + + event.preventDefault(); + setOpen((value) => !value); + } + } + + window.addEventListener("keydown", onKeyDown); + + return () => window.removeEventListener("keydown", onKeyDown); + }, [shortcut]); + + const { refreshRecent } = search; + useEffect(() => { + if (open) { + refreshRecent(); + } + }, [open, refreshRecent]); + + const composed: ReactNode = node.schema?.length ? children : (children ?? ); + + return ( + <> + + + + {composed} + + + + ); +}; + +export default SearchBox; diff --git a/resources/js/search/components/slots.test.tsx b/resources/js/search/components/slots.test.tsx new file mode 100644 index 00000000..a4069ffe --- /dev/null +++ b/resources/js/search/components/slots.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SearchProvider } from "../context"; +import type { SearchResult, UseSearchReturn } from "../types"; +import SearchCategories from "./categories"; +import SearchInput from "./input"; +import SearchRecent from "./recent"; + +function row(id: string): SearchResult { + return { + category: { name: "products" }, + item: { + id, + title: "Widget " + id, + subtitle: null, + additionalInfo: null, + link: "/p/" + id, + badge: null, + }, + }; +} + +function value(overrides: Partial = {}): UseSearchReturn { + return { + query: "", + setQuery: vi.fn<(value: string) => void>(), + categories: [{ name: "products", label: "Products", icon: "package", count: 3 }], + activeCategory: "products", + setCategory: vi.fn<(name: string | null) => void>(), + results: [], + recent: [], + pagination: null, + status: "idle", + error: null, + focusedId: null, + setFocusedId: vi.fn<(id: string | null) => void>(), + loadMore: vi.fn<() => void>(), + openResult: vi.fn<(result: SearchResult) => void>(), + refreshRecent: vi.fn<() => void>(), + ...overrides, + }; +} + +function node(type: string) { + return { type, props: {} } as never; +} + +describe("search slots", () => { + it("input forwards typing to setQuery", () => { + const v = value(); + render( + + {null} + , + ); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "wid" } }); + expect(v.setQuery).toHaveBeenCalledWith("wid"); + }); + + it("categories render label + count and select on click", () => { + const v = value(); + render( + + {null} + , + ); + fireEvent.click(screen.getByRole("button", { name: /Products/ })); + expect(v.setCategory).toHaveBeenCalledWith("products"); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("recent hides while a query is active", () => { + const { container } = render( + + {null} + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/resources/js/search/context.tsx b/resources/js/search/context.tsx new file mode 100644 index 00000000..4a767e40 --- /dev/null +++ b/resources/js/search/context.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { UseSearchReturn } from "./types"; + +const SearchContext = createContext(null); + +export function SearchProvider({ + value, + children, +}: { + value: UseSearchReturn; + children: ReactNode; +}) { + return {children}; +} + +export function useSearchContext(): UseSearchReturn { + const context = useContext(SearchContext); + + if (context === null) { + throw new Error("useSearchContext must be used within a root."); + } + + return context; +} diff --git a/resources/js/search/index.test.ts b/resources/js/search/index.test.ts new file mode 100644 index 00000000..60832ab7 --- /dev/null +++ b/resources/js/search/index.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { searchComponents } from "./index"; + +describe("searchComponents", () => { + it("registers every search wire type", () => { + expect(Object.keys(searchComponents.components ?? {})).toEqual( + expect.arrayContaining([ + "search.box", + "search.input", + "search.categories", + "search.results", + "search.recent", + "search.preview", + ]), + ); + }); +}); diff --git a/resources/js/search/index.ts b/resources/js/search/index.ts new file mode 100644 index 00000000..dd6805ba --- /dev/null +++ b/resources/js/search/index.ts @@ -0,0 +1,38 @@ +import { createPlugin, eagerComponent } from "@lattice-php/lattice/core/registry"; +import SearchBox from "./components/search-box"; +import SearchCategories from "./components/categories"; +import SearchInput from "./components/input"; +import SearchPreview from "./components/preview"; +import SearchRecent from "./components/recent"; +import SearchResults from "./components/results"; + +export const searchComponents = createPlugin({ + name: "lattice/search", + components: { + "search.box": eagerComponent(SearchBox), + "search.input": eagerComponent(SearchInput), + "search.categories": eagerComponent(SearchCategories), + "search.results": eagerComponent(SearchResults), + "search.recent": eagerComponent(SearchRecent), + "search.preview": eagerComponent(SearchPreview), + }, +}); + +export { default as SearchBox } from "./components/search-box"; +export { default as SearchInput } from "./components/input"; +export { default as SearchCategories } from "./components/categories"; +export { default as SearchResults } from "./components/results"; +export { default as SearchRecent } from "./components/recent"; +export { default as SearchPreview } from "./components/preview"; +export { SearchProvider, useSearchContext } from "./context"; +export { useSearch } from "./use-search"; +export type { + SearchResult, + SearchCategory, + SearchPagination, + SearchResponse, + RecordResponse, + SearchStatus, + UseSearchOptions, + UseSearchReturn, +} from "./types"; diff --git a/resources/js/search/types.ts b/resources/js/search/types.ts new file mode 100644 index 00000000..247bc7c0 --- /dev/null +++ b/resources/js/search/types.ts @@ -0,0 +1,63 @@ +export const SEARCH_DEBOUNCE_MS = 250; + +export type SearchResult = { + category: { name: string }; + item: { + id: string; + title: string; + subtitle: string | null; + additionalInfo: string | null; + link: string; + badge: string | null; + }; +}; + +export type SearchCategory = { + name: string; + label: string; + icon: string | null; + count: number | null; +}; +export type SearchPagination = { + page: number; + perPage: number; + total: number; + hasMore: boolean; + nextPage: number | null; +}; + +export type SearchState = { + query: string; + category: string | null; + perPage: number; + countsIncluded: boolean; + mode: string; +}; +export type SearchResponse = { + data: SearchResult[]; + categories: SearchCategory[]; + pagination: SearchPagination; + state: SearchState; +}; +export type RecordResponse = { data: SearchResult | null; state: { recorded: boolean } }; + +export type SearchStatus = "idle" | "loading" | "success" | "error"; +export type UseSearchOptions = { endpoint: string; perPage?: number }; + +export type UseSearchReturn = { + query: string; + setQuery: (value: string) => void; + categories: SearchCategory[]; + activeCategory: string | null; + setCategory: (name: string | null) => void; + results: SearchResult[]; + recent: SearchResult[]; + pagination: SearchPagination | null; + status: SearchStatus; + error: string | null; + focusedId: string | null; + setFocusedId: (id: string | null) => void; + loadMore: () => void; + openResult: (result: SearchResult) => void; + refreshRecent: () => void; +}; diff --git a/resources/js/search/use-search.test.ts b/resources/js/search/use-search.test.ts new file mode 100644 index 00000000..0ec0ecbe --- /dev/null +++ b/resources/js/search/use-search.test.ts @@ -0,0 +1,154 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { router } from "@inertiajs/react"; +import { SEARCH_DEBOUNCE_MS } from "./types"; +import { useSearch } from "./use-search"; + +vi.mock("@inertiajs/react", () => ({ router: { visit: vi.fn<(href: string) => void>() } })); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +const page1 = { + data: [ + { + category: { name: "products" }, + item: { + id: "1", + title: "Widget 1", + subtitle: null, + additionalInfo: null, + link: "/products/1", + badge: null, + }, + }, + ], + categories: [{ name: "products", label: "Products", icon: "package", count: null }], + pagination: { page: 1, perPage: 1, total: 2, hasMore: true, nextPage: 2 }, + state: { query: "wid", category: "products", perPage: 1, countsIncluded: false }, +}; +const page2 = { + data: [ + { + category: { name: "products" }, + item: { + id: "2", + title: "Widget 2", + subtitle: null, + additionalInfo: null, + link: "/products/2", + badge: null, + }, + }, + ], + categories: page1.categories, + pagination: { page: 2, perPage: 1, total: 2, hasMore: false, nextPage: null }, + state: { query: "wid", category: "products", perPage: 1, countsIncluded: false }, +}; + +describe("useSearch", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("debounces the query then fetches results", async () => { + const fetchMock = vi.fn(async () => jsonResponse(page1)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 })); + act(() => result.current.setQuery("wid")); + + expect(fetchMock).not.toHaveBeenCalled(); // still within debounce window + await act(async () => { + await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); + }); + + await waitFor(() => expect(result.current.results).toHaveLength(1)); + expect(result.current.results[0]?.item.id).toBe("1"); + }); + + it("appends the next page on loadMore", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(page2)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 })); + act(() => result.current.setQuery("wid")); + await act(async () => { + await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); + }); + await waitFor(() => expect(result.current.results).toHaveLength(1)); + + act(() => result.current.loadMore()); + await waitFor(() => expect(result.current.results).toHaveLength(2)); + expect(result.current.results[1]?.item.id).toBe("2"); + }); + + it("ignores a stale response whose echoed query no longer matches", async () => { + const fetchMock = vi.fn(async (_url, _init) => { + const url = String(_url); + // The first (stale) request resolves last but echoes the old query. + return jsonResponse(url.includes("query=old") ? page1 : page2); + }); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 })); + act(() => result.current.setQuery("old")); + await act(async () => { + await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); + }); + act(() => result.current.setQuery("new")); + await act(async () => { + await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); + }); + + await waitFor(() => expect(result.current.results[0]?.item.id).toBe("2")); + }); + + it("records the selection over POST then navigates to the re-resolved link", async () => { + vi.useRealTimers(); + const recordResponse = { + data: { + category: { name: "products" }, + item: { + id: "9", + title: "Canonical", + subtitle: null, + additionalInfo: null, + link: "/products/9", + badge: null, + }, + }, + state: { recorded: false }, + }; + const fetchMock = vi.fn(async () => jsonResponse(recordResponse)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useSearch({ endpoint: "/lattice/search", perPage: 1 })); + // The client-supplied link is deliberately wrong; navigation must use the re-resolved row. + const clicked = { + category: { name: "products" }, + item: { + id: "9", + title: "Client Title", + subtitle: null, + additionalInfo: null, + link: "/client/9", + badge: null, + }, + }; + + result.current.openResult(clicked); + + await waitFor(() => expect(router.visit).toHaveBeenCalledWith("/products/9")); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + }); +}); diff --git a/resources/js/search/use-search.ts b/resources/js/search/use-search.ts new file mode 100644 index 00000000..85c39837 --- /dev/null +++ b/resources/js/search/use-search.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { router } from "@inertiajs/react"; +import { apiJson } from "@lattice-php/lattice/core/api"; +import { + SEARCH_DEBOUNCE_MS, + type RecordResponse, + type SearchCategory, + type SearchPagination, + type SearchResponse, + type SearchResult, + type UseSearchOptions, + type UseSearchReturn, +} from "./types"; + +function buildUrl(endpoint: string, params: Record): string { + const search = new URLSearchParams(params).toString(); + + return search === "" ? endpoint : `${endpoint}?${search}`; +} + +export function useSearch({ endpoint, perPage = 20 }: UseSearchOptions): UseSearchReturn { + const [query, setQueryState] = useState(""); + const [categories, setCategories] = useState([]); + const [activeCategory, setActiveCategory] = useState(null); + const [results, setResults] = useState([]); + const [recent, setRecent] = useState([]); + const [pagination, setPagination] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [focusedId, setFocusedId] = useState(null); + + const abortRef = useRef(null); + const requestIdRef = useRef(0); + const debounceRef = useRef | null>(null); + + const run = useCallback( + async ( + nextQuery: string, + category: string | null, + page: number, + append: boolean, + ): Promise => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const requestId = (requestIdRef.current += 1); + + setStatus("loading"); + setError(null); + + try { + const params: Record = { + query: nextQuery, + page: String(page), + per_page: String(perPage), + counts: "1", + }; + if (category !== null) { + params.category = category; + } + + const payload = await apiJson(buildUrl(endpoint, params), { + signal: controller.signal, + }); + + // Stale-response guard: drop anything superseded by a newer request. + if (requestId !== requestIdRef.current) { + return; + } + + setCategories(payload.categories); + setActiveCategory(payload.state.category); + setPagination(payload.pagination); + setResults((current) => (append ? [...current, ...payload.data] : payload.data)); + setFocusedId((current) => current ?? payload.data[0]?.item.id ?? null); + setStatus("success"); + } catch (caught) { + if (caught instanceof DOMException && caught.name === "AbortError") { + return; + } + + setError(caught instanceof Error ? caught.message : String(caught)); + setStatus("error"); + } + }, + [endpoint, perPage], + ); + + const setQuery = useCallback( + (value: string): void => { + setQueryState(value); + setFocusedId(null); + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(async () => { + await run(value, activeCategory, 1, false); + }, SEARCH_DEBOUNCE_MS); + }, + [activeCategory, run], + ); + + const setCategory = useCallback( + (name: string | null): void => { + setActiveCategory(name); + setFocusedId(null); + void run(query, name, 1, false); + }, + [query, run], + ); + + const loadMore = useCallback((): void => { + if (pagination?.nextPage == null || status === "loading") { + return; + } + + void run(query, activeCategory, pagination.nextPage, true); + }, [activeCategory, pagination, query, run, status]); + + const refreshRecent = useCallback(async (): Promise => { + try { + const payload = await apiJson( + buildUrl(endpoint, { recent: "1", per_page: String(perPage) }), + ); + setRecent(payload.data); + } catch { + setRecent([]); + } + }, [endpoint, perPage]); + + const openResult = useCallback( + async (result: SearchResult): Promise => { + let target = result; + + try { + const payload = await apiJson(endpoint, { + method: "POST", + body: JSON.stringify({ category: result.category.name, id: result.item.id }), + throwOnError: false, + }); + if (payload.data) { + target = payload.data; + } + } catch { + // Navigation should still proceed with the row we already have. + } + + router.visit(target.item.link); + }, + [endpoint], + ); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, []); + + const handleOpenResult = useCallback( + (result: SearchResult): void => { + void openResult(result); + }, + [openResult], + ); + + const handleRefreshRecent = useCallback((): void => { + void refreshRecent(); + }, [refreshRecent]); + + return { + query, + setQuery, + categories, + activeCategory, + setCategory, + results, + recent, + pagination, + status, + error, + focusedId, + setFocusedId, + loadMore, + openResult: handleOpenResult, + refreshRecent: handleRefreshRecent, + }; +} diff --git a/resources/js/test/setup.ts b/resources/js/test/setup.ts index 6be848fa..1b5127de 100644 --- a/resources/js/test/setup.ts +++ b/resources/js/test/setup.ts @@ -1,6 +1,13 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, configure } from "@testing-library/react"; -import { afterEach } from "vitest"; +import { afterEach, vi } from "vitest"; + +// Allow @testing-library/dom's `jestFakeTimersAreEnabled()` to detect vitest +// fake timers. Without this, `waitFor` uses a fake `setTimeout(resolve, 0)` +// inside its async wrapper that never fires when fake timers are active. +// See: https://github.com/testing-library/dom-testing-library/blob/main/src/helpers.ts +// @ts-expect-error — jest is not declared as a global in this project +globalThis.jest = vi; configure({ testIdAttribute: "data-test" }); diff --git a/resources/js/types/generated.ts b/resources/js/types/generated.ts index 38fc480f..d42cb26e 100644 --- a/resources/js/types/generated.ts +++ b/resources/js/types/generated.ts @@ -361,6 +361,37 @@ export type CoreNode = key?: string; props: RawBlock; } + | { + type: "search.box"; + key?: string; + props: SearchBox; + schema?: Node[]; + } + | { + type: "search.categories"; + key?: string; + props: SearchCategories; + } + | { + type: "search.input"; + key?: string; + props: SearchInput; + } + | { + type: "search.preview"; + key?: string; + props: SearchPreview; + } + | { + type: "search.recent"; + key?: string; + props: SearchRecent; + } + | { + type: "search.results"; + key?: string; + props: SearchResults; + } | { type: "section"; key?: string; @@ -1013,6 +1044,18 @@ export type RowAction = { }; export type RowActionType = "duplicate" | "remove"; export type RowLayout = "stack" | "table"; +export type SearchBox = { + endpoint: string | null; + perPage: number; + placeholder: string | null; + shortcut: boolean; + title: string | null; +}; +export type SearchCategories = Record; +export type SearchInput = Record; +export type SearchPreview = Record; +export type SearchRecent = Record; +export type SearchResults = Record; export type Section = { collapsed: boolean; collapsible: boolean; diff --git a/routes/web.php b/routes/web.php index bd43e5bd..30b573a0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use Lattice\Lattice\Http\Controllers\FormController; use Lattice\Lattice\Http\Controllers\FragmentController; use Lattice\Lattice\Http\Controllers\RemoteSourceTokenController; +use Lattice\Lattice\Http\Controllers\SearchController; use Lattice\Lattice\Http\Controllers\TableController; Route::middleware(config('lattice.forms.middleware', ['web', 'auth'])) @@ -38,3 +39,11 @@ ->match(['post', 'put', 'patch', 'delete'], config('lattice.bulk-actions.endpoint', 'lattice/bulk-actions/{bulkAction}'), BulkActionController::class) ->where('bulkAction', '.*') ->name('lattice.bulk-actions.handle'); + +Route::middleware(config('lattice.search.middleware', ['web', 'auth'])) + ->get(config('lattice.search.endpoint', 'lattice/search'), [SearchController::class, 'search']) + ->name('lattice.search.query'); + +Route::middleware(config('lattice.search.middleware', ['web', 'auth'])) + ->post(config('lattice.search.endpoint', 'lattice/search'), [SearchController::class, 'record']) + ->name('lattice.search.record'); diff --git a/src/Attributes/AsSearchProvider.php b/src/Attributes/AsSearchProvider.php new file mode 100644 index 00000000..9b4517c7 --- /dev/null +++ b/src/Attributes/AsSearchProvider.php @@ -0,0 +1,7 @@ +endpoint = $endpoint; + + return $this; + } + + public function placeholder(string $placeholder): static + { + $this->placeholder = $placeholder; + + return $this; + } + + public function title(string $title): static + { + $this->title = $title; + + return $this; + } + + public function shortcut(bool $shortcut = true): static + { + $this->shortcut = $shortcut; + + return $this; + } + + public function perPage(int $perPage): static + { + $this->perPage = $perPage; + + return $this; + } +} diff --git a/src/Core/Components/SearchCategories.php b/src/Core/Components/SearchCategories.php new file mode 100644 index 00000000..dc3adc9f --- /dev/null +++ b/src/Core/Components/SearchCategories.php @@ -0,0 +1,15 @@ + AsFragment::class, 'remote-sources' => AsRemoteSource::class, 'layouts' => AsLayout::class, + 'search' => AsSearchProvider::class, ]; public const string PAGE_ATTRIBUTE = AsPage::class; diff --git a/src/Core/Enums/Icon.php b/src/Core/Enums/Icon.php index e2f63f3b..4838337c 100644 --- a/src/Core/Enums/Icon.php +++ b/src/Core/Enums/Icon.php @@ -42,11 +42,13 @@ enum Icon: string case Loader2 = 'loader-2'; case Minus = 'minus'; case MoreHorizontal = 'more-horizontal'; + case Package = 'package'; case PanelLeft = 'panel-left'; case Pencil = 'pencil'; case PencilLine = 'pencil-line'; case Plus = 'plus'; case Quote = 'quote'; + case Receipt = 'receipt'; case RotateCcw = 'rotate-ccw'; case Rows3 = 'rows-3'; case Search = 'search'; @@ -57,5 +59,6 @@ enum Icon: string case Table = 'table'; case Trash2 = 'trash-2'; case Underline = 'underline'; + case Users = 'users'; case X = 'x'; } diff --git a/src/Http/Controllers/SearchController.php b/src/Http/Controllers/SearchController.php new file mode 100644 index 00000000..7fa3a221 --- /dev/null +++ b/src/Http/Controllers/SearchController.php @@ -0,0 +1,125 @@ +providers->authorized($request); + + if ($request->wantsRecent()) { + return response()->json([ + 'data' => $this->history->recent($request, $request->perPage()), + 'categories' => $this->categories($request, $authorized), + 'pagination' => SearchPagination::forPage(1, $request->perPage(), 0), + 'state' => $this->state($request, null, 'recent'), + ]); + } + + $categories = $this->categories($request, $authorized); + $active = $this->activeCategory($request, $categories); + $provider = $active !== null ? ($authorized[$active] ?? null) : null; + + $results = $provider instanceof SearchResultProvider + ? $provider->search(new SearchQuery($request->queryString(), $active, $request->page(), $request->perPage(), app()->getLocale())) + : new SearchResults([], 0); + + return response()->json([ + 'data' => $results->rows, + 'categories' => $categories, + 'pagination' => SearchPagination::forPage($request->page(), $request->perPage(), $results->total), + 'state' => $this->state($request, $active), + ]); + } + + public function record(RecordSelectionRequest $request): JsonResponse + { + /** @var string $category */ + $category = $request->validated('category'); + /** @var string $id */ + $id = $request->validated('id'); + + $provider = $this->providers->forCategory($category); + + abort_if($provider === null, 404); + abort_unless($provider->authorize($request), 403); + + $result = $provider->resolve($id, $request); + $recorded = $result !== null && $this->history->record($request, $result); + + return response()->json([ + 'data' => $result, + 'state' => ['recorded' => $recorded], + ]); + } + + /** + * @param array $authorized + * @return array + */ + private function categories(SearchRequest $request, array $authorized): array + { + return array_values(array_map(function (SearchResultProvider $provider) use ($request): SearchCategory { + $category = $provider->category(); + + if (! $request->wantsCounts()) { + return $category; + } + + return $category->withCount($provider->count( + new SearchQuery($request->queryString(), $category->name, 1, $request->perPage(), app()->getLocale()), + )); + }, $authorized)); + } + + /** + * @param array $categories + */ + private function activeCategory(SearchRequest $request, array $categories): ?string + { + $names = array_map(fn (SearchCategory $category): string => $category->name, $categories); + + $requested = $request->category(); + if ($requested !== null && in_array($requested, $names, true)) { + return $requested; + } + + if ($request->wantsCounts() && $categories !== []) { + usort($categories, fn (SearchCategory $a, SearchCategory $b): int => ($b->count ?? 0) <=> ($a->count ?? 0)); + + return $categories[0]->name; + } + + return $names[0] ?? null; + } + + /** @return array{query:string,category:?string,perPage:int,countsIncluded:bool,mode:string} */ + private function state(SearchRequest $request, ?string $active, string $mode = 'results'): array + { + return [ + 'query' => $request->queryString(), + 'category' => $active, + 'perPage' => $request->perPage(), + 'countsIncluded' => $request->wantsCounts(), + 'mode' => $mode, + ]; + } +} diff --git a/src/Http/Requests/RecordSelectionRequest.php b/src/Http/Requests/RecordSelectionRequest.php new file mode 100644 index 00000000..5d799373 --- /dev/null +++ b/src/Http/Requests/RecordSelectionRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'category' => ['required', 'string', 'max:255'], + 'id' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/src/Http/Requests/SearchRequest.php b/src/Http/Requests/SearchRequest.php new file mode 100644 index 00000000..5d379570 --- /dev/null +++ b/src/Http/Requests/SearchRequest.php @@ -0,0 +1,59 @@ + */ + public function rules(): array + { + return [ + 'query' => ['nullable', 'string', 'max:255'], + 'category' => ['nullable', 'string', 'max:255'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'counts' => ['nullable', 'boolean'], + 'recent' => ['nullable', 'boolean'], + ]; + } + + public function queryString(): string + { + return (string) $this->input('query', ''); + } + + public function page(): int + { + return (int) $this->input('page', 1); + } + + public function perPage(): int + { + return (int) $this->input('per_page', 20); + } + + public function wantsCounts(): bool + { + return $this->boolean('counts'); + } + + public function wantsRecent(): bool + { + return $this->boolean('recent'); + } + + public function category(): ?string + { + $category = $this->input('category'); + + return is_string($category) && $category !== '' ? $category : null; + } +} diff --git a/src/LatticeRegistry.php b/src/LatticeRegistry.php index 3fe8a9a0..1a8db6e0 100644 --- a/src/LatticeRegistry.php +++ b/src/LatticeRegistry.php @@ -18,6 +18,8 @@ use Lattice\Lattice\Layouts\LayoutRegistry; use Lattice\Lattice\Remote\RemoteSourceDefinition; use Lattice\Lattice\Remote\RemoteSourceRegistry; +use Lattice\Lattice\Search\Contracts\SearchResultProvider; +use Lattice\Lattice\Search\SearchResultProviderRegistry; use Lattice\Lattice\Tables\TableDefinition; use Lattice\Lattice\Tables\TableRegistry; @@ -32,6 +34,7 @@ public function __construct( private PageRegistry $pages, private TableRegistry $tables, private RemoteSourceRegistry $remoteSources, + private SearchResultProviderRegistry $searchProviders, ) {} /** @@ -120,4 +123,17 @@ public function remoteSourceRegistry(): RemoteSourceRegistry { return $this->remoteSources; } + + /** + * @param class-string|array> $providers + */ + public function searchProviders(string|array $providers): void + { + $this->searchProviders->register($providers); + } + + public function searchProviderRegistry(): SearchResultProviderRegistry + { + return $this->searchProviders; + } } diff --git a/src/LatticeServiceProvider.php b/src/LatticeServiceProvider.php index e3144d4a..232c3ea8 100644 --- a/src/LatticeServiceProvider.php +++ b/src/LatticeServiceProvider.php @@ -31,6 +31,9 @@ use Lattice\Lattice\Http\PageRegistry; use Lattice\Lattice\Layouts\LayoutRegistry; use Lattice\Lattice\Remote\RemoteSourceRegistry; +use Lattice\Lattice\Search\Contracts\SearchHistoryRecorder; +use Lattice\Lattice\Search\NullSearchHistoryRecorder; +use Lattice\Lattice\Search\SearchResultProviderRegistry; use Lattice\Lattice\Support\Evaluation\Evaluator; use Lattice\Lattice\Support\TypeScript\AugmentProfile; use Lattice\Lattice\Support\TypeScript\TypeScriptProfile; @@ -68,6 +71,8 @@ public function packageRegistered(): void $this->app->singleton(BulkActionRegistry::class); $this->app->singleton(PageRegistry::class); $this->app->singleton(RemoteSourceRegistry::class); + $this->app->singleton(SearchResultProviderRegistry::class); + $this->app->bind(SearchHistoryRecorder::class, NullSearchHistoryRecorder::class); $this->app->singleton(ComponentReferenceSigner::class); $this->app->alias(ComponentReferenceSigner::class, SignsComponentReferences::class); $this->app->singleton(LatticeRegistry::class); diff --git a/src/Search/Contracts/SearchHistoryRecorder.php b/src/Search/Contracts/SearchHistoryRecorder.php new file mode 100644 index 00000000..8bc51f09 --- /dev/null +++ b/src/Search/Contracts/SearchHistoryRecorder.php @@ -0,0 +1,16 @@ + */ + public function recent(Request $request, int $limit): array; +} diff --git a/src/Search/Contracts/SearchResultProvider.php b/src/Search/Contracts/SearchResultProvider.php new file mode 100644 index 00000000..a9d13809 --- /dev/null +++ b/src/Search/Contracts/SearchResultProvider.php @@ -0,0 +1,23 @@ +name, $this->label, $this->icon, $count); + } + + /** @return array{name:string,label:string,icon:?string,count:?int} */ + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'label' => $this->label, + 'icon' => $this->icon, + 'count' => $this->count, + ]; + } +} diff --git a/src/Search/SearchPagination.php b/src/Search/SearchPagination.php new file mode 100644 index 00000000..f2d1acc2 --- /dev/null +++ b/src/Search/SearchPagination.php @@ -0,0 +1,36 @@ + $this->page, + 'perPage' => $this->perPage, + 'total' => $this->total, + 'hasMore' => $this->hasMore, + 'nextPage' => $this->nextPage, + ]; + } +} diff --git a/src/Search/SearchQuery.php b/src/Search/SearchQuery.php new file mode 100644 index 00000000..29c5daaa --- /dev/null +++ b/src/Search/SearchQuery.php @@ -0,0 +1,15 @@ +} */ + public function jsonSerialize(): array + { + return [ + 'category' => ['name' => $this->category], + 'item' => $this->item->jsonSerialize(), + ]; + } +} diff --git a/src/Search/SearchResultItem.php b/src/Search/SearchResultItem.php new file mode 100644 index 00000000..39dd0135 --- /dev/null +++ b/src/Search/SearchResultItem.php @@ -0,0 +1,31 @@ + $this->id, + 'title' => $this->title, + 'subtitle' => $this->subtitle, + 'additionalInfo' => $this->additionalInfo, + 'link' => $this->link, + 'badge' => $this->badge, + ]; + } +} diff --git a/src/Search/SearchResultProviderRegistry.php b/src/Search/SearchResultProviderRegistry.php new file mode 100644 index 00000000..2e7afaef --- /dev/null +++ b/src/Search/SearchResultProviderRegistry.php @@ -0,0 +1,77 @@ +> */ + private array $registered = []; + + public function __construct( + private readonly Container $container, + private readonly DiscoveryManifest $manifest, + ) {} + + /** + * @param class-string|array> $providers + */ + public function register(string|array $providers): void + { + foreach ((array) $providers as $provider) { + $this->registered[$this->keyFor($provider)] = $provider; + } + } + + /** @return array */ + public function all(): array + { + /** @var array> $discovered */ + $discovered = $this->manifest->forGroup('search'); + + return array_map( + fn (string $class): SearchResultProvider => $this->container->make($class), + array_merge($discovered, $this->registered), + ); + } + + public function forCategory(string $name): ?SearchResultProvider + { + return $this->all()[$name] ?? null; + } + + /** @return array */ + public function authorized(Request $request): array + { + return array_filter( + $this->all(), + fn (SearchResultProvider $provider): bool => $provider->authorize($request), + ); + } + + /** + * @param class-string $provider + */ + private function keyFor(string $provider): string + { + if (! is_subclass_of($provider, SearchResultProvider::class)) { + throw new InvalidArgumentException("[{$provider}] must implement ".SearchResultProvider::class.'.'); + } + + $attribute = Attributes::get($provider, AsSearchProvider::class); + + if (! $attribute instanceof AsSearchProvider) { + throw new InvalidArgumentException("[{$provider}] is missing the #[AsSearchProvider] attribute."); + } + + return $attribute->key; + } +} diff --git a/src/Search/SearchResults.php b/src/Search/SearchResults.php new file mode 100644 index 00000000..0b92a161 --- /dev/null +++ b/src/Search/SearchResults.php @@ -0,0 +1,13 @@ + $rows */ + public function __construct( + public array $rows, + public int $total, + ) {} +} diff --git a/tests/Feature/Http/PageTest.php b/tests/Feature/Http/PageTest.php index 7967a8e9..e5b6167e 100644 --- a/tests/Feature/Http/PageTest.php +++ b/tests/Feature/Http/PageTest.php @@ -120,8 +120,9 @@ ->where('lattice.layout.schema.0.schema.0.schema.0.schema.0.schema.1.schema.1.schema.2.props.href', '/builder-table') ->where('lattice.layout.schema.0.schema.0.schema.0.schema.1.schema.0.type', 'menu-item') ->where('lattice.layout.schema.0.schema.0.schema.0.schema.1.schema.0.props.label', 'Log out') - ->where('lattice.layout.schema.0.schema.1.schema.0.type', 'breadcrumbs') - ->where('lattice.layout.schema.0.schema.1.schema.1.type', 'outlet') + ->where('lattice.layout.schema.0.schema.1.schema.0.type', 'search.box') + ->where('lattice.layout.schema.0.schema.1.schema.1.type', 'breadcrumbs') + ->where('lattice.layout.schema.0.schema.1.schema.2.type', 'outlet') ->where('lattice.layout.schema.1.type', 'floating-panel') ->where('lattice.layout.schema.1.key', 'locale-switcher-panel') ->where('lattice.layout.schema.1.props.label', 'Language') diff --git a/tests/Feature/Search/DiscoveryTest.php b/tests/Feature/Search/DiscoveryTest.php new file mode 100644 index 00000000..5049690d --- /dev/null +++ b/tests/Feature/Search/DiscoveryTest.php @@ -0,0 +1,14 @@ +forGroup('search'); + + expect($group)->toHaveKey('products'); + expect($group['products'])->toBe(DiscoveredProductsSearchProvider::class); +}); diff --git a/tests/Feature/Search/FacadeTest.php b/tests/Feature/Search/FacadeTest.php new file mode 100644 index 00000000..7ae3a0c3 --- /dev/null +++ b/tests/Feature/Search/FacadeTest.php @@ -0,0 +1,17 @@ + []]); + app(SearchResultProviderRegistry::class); // ensure singleton built before manifest clear + app(DiscoveryManifest::class)->clear(); + + Lattice::searchProviders([DiscoveredProductsSearchProvider::class]); + + expect(Lattice::searchProviderRegistry()->forCategory('products'))->not->toBeNull(); +}); diff --git a/tests/Feature/Search/RecordEndpointTest.php b/tests/Feature/Search/RecordEndpointTest.php new file mode 100644 index 00000000..7157fae6 --- /dev/null +++ b/tests/Feature/Search/RecordEndpointTest.php @@ -0,0 +1,49 @@ + '7', 'title' => 'Canonical Widget']]; + discoverFixtures(); + app()->bind(SearchHistoryRecorder::class, NullSearchHistoryRecorder::class); + actingAs(UserFactory::new()->create()); +}); + +test('record re-resolves through the provider and ignores client payload', function () { + $response = postJson('/lattice/search', [ + 'category' => 'products', + 'id' => '7', + 'item' => ['title' => 'TAMPERED', 'link' => 'https://evil.example/x'], + ]); + + $response->assertOk(); + expect($response->json('data.item.title'))->toBe('Canonical Widget') + ->and($response->json('data.item.link'))->toBe('/products/7') + ->and($response->json('state.recorded'))->toBeFalse(); +}); + +test('record validates the payload', function () { + postJson('/lattice/search', [])->assertStatus(422); +}); + +test('record 404s an unknown category and 403s an unauthorized one', function () { + postJson('/lattice/search', ['category' => 'missing', 'id' => '7'])->assertStatus(404); + + DiscoveredProductsSearchProvider::$authorized = false; + postJson('/lattice/search', ['category' => 'products', 'id' => '7'])->assertStatus(403); +}); + +test('recent returns an empty list with the default recorder', function () { + actingAs(UserFactory::new()->create()); + $response = \Pest\Laravel\getJson('/lattice/search?recent=1'); + $response->assertOk(); + expect($response->json('data'))->toBe([]); +}); diff --git a/tests/Feature/Search/RegistryTest.php b/tests/Feature/Search/RegistryTest.php new file mode 100644 index 00000000..be688f0a --- /dev/null +++ b/tests/Feature/Search/RegistryTest.php @@ -0,0 +1,28 @@ +forCategory('products'))->toBeInstanceOf(SearchResultProvider::class); + expect($registry->forCategory('missing'))->toBeNull(); + expect(array_keys($registry->all()))->toContain('products'); +}); + +test('the registry filters unauthorized providers', function () { + DiscoveredProductsSearchProvider::$authorized = false; + + $authorized = app(SearchResultProviderRegistry::class)->authorized(new Request); + + expect($authorized)->not->toHaveKey('products'); +}); diff --git a/tests/Feature/Search/SearchEndpointTest.php b/tests/Feature/Search/SearchEndpointTest.php new file mode 100644 index 00000000..7cfa9a37 --- /dev/null +++ b/tests/Feature/Search/SearchEndpointTest.php @@ -0,0 +1,62 @@ + ['id' => (string) $n, 'title' => "Widget {$n}"], + range(1, 25), + ); + discoverFixtures(); + actingAs(UserFactory::new()->create()); +}); + +test('config exposes the default endpoint and middleware', function () { + expect(config('lattice.search.endpoint'))->toBe('lattice/search'); + expect(config('lattice.search.middleware'))->toBe(['web', 'auth']); +}); + +test('search returns the closed response envelope', function () { + $response = getJson('/lattice/search?query=Widget&category=products&per_page=10'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [['category' => ['name'], 'item' => ['id', 'title', 'subtitle', 'additionalInfo', 'link', 'badge']]], + 'categories' => [['name', 'label', 'icon', 'count']], + 'pagination' => ['page', 'perPage', 'total', 'hasMore', 'nextPage'], + 'state' => ['query', 'category', 'perPage', 'countsIncluded'], + ]); + + expect($response->json('pagination.total'))->toBe(25) + ->and($response->json('pagination.hasMore'))->toBeTrue() + ->and($response->json('pagination.nextPage'))->toBe(2) + ->and($response->json('data'))->toHaveCount(10) + ->and($response->json('data.0.category.name'))->toBe('products') + ->and($response->json('state.query'))->toBe('Widget'); +}); + +test('counts are present only when requested', function () { + expect(getJson('/lattice/search?query=Widget&category=products')->json('categories.0.count'))->toBeNull(); + expect(getJson('/lattice/search?query=Widget&category=products&counts=1')->json('categories.0.count'))->toBe(25); + expect(getJson('/lattice/search?query=Widget&category=products')->json('state.countsIncluded'))->toBeFalse(); +}); + +test('unauthorized providers are excluded', function () { + DiscoveredProductsSearchProvider::$authorized = false; + + $response = getJson('/lattice/search?query=Widget'); + + expect($response->json('categories'))->toBe([]) + ->and($response->json('data'))->toBe([]); +}); + +test('search validates pagination parameters', function () { + getJson('/lattice/search?per_page=999')->assertStatus(422); + getJson('/lattice/search?page=0')->assertStatus(422); +}); diff --git a/tests/Feature/Search/SessionRecorderTest.php b/tests/Feature/Search/SessionRecorderTest.php new file mode 100644 index 00000000..9c6b9b07 --- /dev/null +++ b/tests/Feature/Search/SessionRecorderTest.php @@ -0,0 +1,41 @@ +setLaravelSession(new Store('test', new ArraySessionHandler(120))); + + return $request; +} + +test('records a selection and reads it back', function () { + $request = sessionRecorderRequest(); + $recorder = new SessionSearchHistoryRecorder; + $result = new SearchResult('products', new SearchResultItem(id: '1', title: 'Widget', link: '/products/1')); + + expect($recorder->record($request, $result))->toBeTrue(); + + $recent = $recorder->recent($request, 10); + expect($recent)->toHaveCount(1); + expect($recent[0]->item->id)->toBe('1') + ->and($recent[0]->category)->toBe('products'); +}); + +test('recording the same selection twice dedupes to one entry', function () { + $request = sessionRecorderRequest(); + $recorder = new SessionSearchHistoryRecorder; + $result = new SearchResult('products', new SearchResultItem(id: '1', title: 'Widget', link: '/products/1')); + + $recorder->record($request, $result); + $recorder->record($request, $result); + + expect($recorder->recent($request, 10))->toHaveCount(1); +}); diff --git a/tests/Feature/Search/WorkbenchProviderTest.php b/tests/Feature/Search/WorkbenchProviderTest.php new file mode 100644 index 00000000..2b90e624 --- /dev/null +++ b/tests/Feature/Search/WorkbenchProviderTest.php @@ -0,0 +1,22 @@ +create(['name' => 'Aurora Lamp', 'sku' => 'LAMP-1', 'status' => 'active']); + Product::factory()->create(['name' => 'Borealis Chair', 'sku' => 'CHAIR-1', 'status' => 'active']); + + actingAs(UserFactory::new()->create()); + + $response = getJson('/lattice/search?query=Aurora&category=products&counts=1'); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1) + ->and($response->json('data.0.item.title'))->toContain('Aurora') + ->and($response->json('data.0.category.name'))->toBe('products'); +}); diff --git a/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php b/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php new file mode 100644 index 00000000..fafdd9f5 --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoveredProductsSearchProvider.php @@ -0,0 +1,79 @@ + */ + public static array $rows = []; + + public static bool $authorized = true; + + public function authorize(Request $request): bool + { + return self::$authorized; + } + + public function category(): SearchCategory + { + return new SearchCategory('products', 'Products', 'package'); + } + + public function count(SearchQuery $query): int + { + return count($this->matching($query->query)); + } + + public function search(SearchQuery $query): SearchResults + { + $matching = $this->matching($query->query); + $offset = ($query->page - 1) * $query->perPage; + $slice = array_slice($matching, $offset, $query->perPage); + + $rows = array_map( + fn (array $row): SearchResult => new SearchResult('products', new SearchResultItem( + id: $row['id'], title: $row['title'], link: '/products/'.$row['id'], + )), + $slice, + ); + + return new SearchResults($rows, count($matching)); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + foreach (self::$rows as $row) { + if ($row['id'] === $id) { + return new SearchResult('products', new SearchResultItem( + id: $row['id'], title: $row['title'], link: '/products/'.$row['id'], + )); + } + } + + return null; + } + + /** @return array */ + private function matching(string $query): array + { + if ($query === '') { + return self::$rows; + } + + return array_values(array_filter( + self::$rows, + fn (array $row): bool => str_contains(strtolower($row['title']), strtolower($query)), + )); + } +} diff --git a/tests/Unit/Search/ComponentWireShapeTest.php b/tests/Unit/Search/ComponentWireShapeTest.php new file mode 100644 index 00000000..7e9696ee --- /dev/null +++ b/tests/Unit/Search/ComponentWireShapeTest.php @@ -0,0 +1,33 @@ +endpoint('/lattice/search') + ->placeholder('Search…') + ->title('Search') + ->schema([ + SearchInput::make(), + SearchResults::make(), + ]), + ); + + expect($node)->toMatchArray([ + 'type' => 'search.box', + 'props' => [ + 'endpoint' => '/lattice/search', + 'placeholder' => 'Search…', + 'title' => 'Search', + 'shortcut' => true, + 'perPage' => 20, + ], + ]); + + expect($node['schema'][0]['type'])->toBe('search.input'); + expect($node['schema'][1]['type'])->toBe('search.results'); +}); diff --git a/tests/Unit/Search/NullSearchHistoryRecorderTest.php b/tests/Unit/Search/NullSearchHistoryRecorderTest.php new file mode 100644 index 00000000..e1388888 --- /dev/null +++ b/tests/Unit/Search/NullSearchHistoryRecorderTest.php @@ -0,0 +1,15 @@ +record(new Request, $result))->toBeFalse(); + expect($recorder->recent(new Request, 10))->toBe([]); +}); diff --git a/tests/Unit/Search/WireShapeTest.php b/tests/Unit/Search/WireShapeTest.php new file mode 100644 index 00000000..7f56fb86 --- /dev/null +++ b/tests/Unit/Search/WireShapeTest.php @@ -0,0 +1,50 @@ +jsonSerialize())->toBe([ + 'category' => ['name' => 'products'], + 'item' => [ + 'id' => '42', 'title' => 'Widget X', 'subtitle' => 'SKU-42', + 'additionalInfo' => '€19.99', 'link' => '/products/42', 'badge' => 'new', + ], + ]); +}); + +test('category count is null until stamped', function () { + $category = new SearchCategory('products', 'Products', 'package'); + expect($category->jsonSerialize()['count'])->toBeNull(); + expect($category->withCount(96)->jsonSerialize())->toBe([ + 'name' => 'products', 'label' => 'Products', 'icon' => 'package', 'count' => 96, + ]); +}); + +test('pagination reports the next page when more remain', function () { + expect((new SearchPagination(1, 20, 96, true, 2))->jsonSerialize())->toBe([ + 'page' => 1, 'perPage' => 20, 'total' => 96, 'hasMore' => true, 'nextPage' => 2, + ]); +}); + +test('SearchPagination::forPage calculates pagination with items remaining', function () { + $pagination = SearchPagination::forPage(1, 20, 96); + expect($pagination->jsonSerialize())->toBe([ + 'page' => 1, 'perPage' => 20, 'total' => 96, 'hasMore' => true, 'nextPage' => 2, + ]); +}); + +test('SearchPagination::forPage handles exact last page boundary', function () { + $pagination = SearchPagination::forPage(5, 20, 100); + expect($pagination->jsonSerialize())->toBe([ + 'page' => 5, 'perPage' => 20, 'total' => 100, 'hasMore' => false, 'nextPage' => null, + ]); +}); diff --git a/vite.config.ts b/vite.config.ts index c52c69db..751325b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,8 @@ const latticeIcons = [ "align-center", "align-justify", "align-left", "align-right", "bold", "code", "columns-3", "heading-1", "heading-2", "heading-3", "highlighter", "italic", "list", "list-ordered", "quote", "rows-3", "smile", "strikethrough", "table", "underline", + // Global-search category icons + "package", "receipt", "users", ]; function libraryEntries(): string[] { diff --git a/workbench/app/Layouts/AppLayout.php b/workbench/app/Layouts/AppLayout.php index cd696846..11870bdf 100644 --- a/workbench/app/Layouts/AppLayout.php +++ b/workbench/app/Layouts/AppLayout.php @@ -11,6 +11,7 @@ use Lattice\Lattice\Core\Components\ChatBox; use Lattice\Lattice\Core\Components\FloatingPanel; use Lattice\Lattice\Core\Components\RawBlock; +use Lattice\Lattice\Core\Components\SearchBox; use Lattice\Lattice\Core\Components\Stack; use Lattice\Lattice\Core\Enums\Align; use Lattice\Lattice\Core\Enums\ButtonVariant; @@ -59,6 +60,9 @@ public function schema(PageSchema $schema, Request $request): PageSchema Stack::make('app-main') ->width(Width::Fill) ->schema([ + SearchBox::make('search') + ->placeholder(__('workbench.search.placeholder')) + ->title(__('workbench.search.title')), Breadcrumbs::make(), Outlet::make(), RawBlock::make('chat-scroll-clearance')->html(''), diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index f5bfaa81..3541c4c7 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -14,8 +14,10 @@ use Laravel\Boost\Install\SkillComposer; use Laravel\Boost\Support\Config; use Laravel\Roster\Roster; +use Lattice\Lattice\Search\Contracts\SearchHistoryRecorder; use Lattice\Lattice\Support\TypeScript\TypeScriptProfile; use Workbench\App\Models\User; +use Workbench\App\Search\SessionSearchHistoryRecorder; use Workbench\App\Support\BoostConfig; use Workbench\App\Support\BoostGuidelineComposer; use Workbench\App\Support\BoostSkillComposer; @@ -40,6 +42,10 @@ public function register(): void // Rebind so lattice:typescript regenerates the package's own built-in types. $this->app->bind(TypeScriptProfile::class, BaseProfile::class); + $this->app->bind( + SearchHistoryRecorder::class, + SessionSearchHistoryRecorder::class, + ); $this->useWorkbenchDatabase(); $this->readBoostConfigFromPackageRoot(); $this->serveLatticeTranslations(); diff --git a/workbench/app/Search/BusinessPartnerSearchProvider.php b/workbench/app/Search/BusinessPartnerSearchProvider.php new file mode 100644 index 00000000..e4a60776 --- /dev/null +++ b/workbench/app/Search/BusinessPartnerSearchProvider.php @@ -0,0 +1,75 @@ +query($query->query)->count(); + } + + public function search(SearchQuery $query): SearchResults + { + $builder = $this->query($query->query); + $total = $builder->count(); + + $rows = $builder + ->forPage($query->page, $query->perPage) + ->get() + ->map(fn (BusinessPartner $partner): SearchResult => $this->toResult($partner)) + ->all(); + + return new SearchResults($rows, $total); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + $partner = BusinessPartner::query()->find($id); + + return $partner === null ? null : $this->toResult($partner); + } + + /** @return Builder */ + private function query(string $term): Builder + { + return BusinessPartner::query() + ->when($term !== '', function (Builder $builder) use ($term): void { + $builder->where('name', 'like', "%{$term}%")->orWhere('email', 'like', "%{$term}%"); + }) + ->orderBy('name'); + } + + private function toResult(BusinessPartner $partner): SearchResult + { + return new SearchResult('business-partners', new SearchResultItem( + id: (string) $partner->getKey(), + title: $partner->name, + link: '/business-partners/'.$partner->getKey(), + subtitle: $partner->email, + )); + } +} diff --git a/workbench/app/Search/ProductSearchProvider.php b/workbench/app/Search/ProductSearchProvider.php new file mode 100644 index 00000000..27663e20 --- /dev/null +++ b/workbench/app/Search/ProductSearchProvider.php @@ -0,0 +1,77 @@ +query($query->query)->count(); + } + + public function search(SearchQuery $query): SearchResults + { + $builder = $this->query($query->query); + $total = $builder->count(); + + $products = $builder + ->forPage($query->page, $query->perPage) + ->get(); + + $rows = $products->map(fn (Product $product): SearchResult => $this->toResult($product))->all(); + + return new SearchResults($rows, $total); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + $product = Product::query()->find($id); + + return $product === null ? null : $this->toResult($product); + } + + /** @return Builder */ + private function query(string $term): Builder + { + return Product::query() + ->when($term !== '', function ($builder) use ($term): void { + $builder->where('name', 'like', "%{$term}%")->orWhere('sku', 'like', "%{$term}%"); + }) + ->orderBy('name'); + } + + private function toResult(Product $product): SearchResult + { + return new SearchResult('products', new SearchResultItem( + id: (string) $product->getKey(), + title: $product->name, + link: '/products/'.$product->getKey(), + subtitle: $product->sku, + additionalInfo: null, + badge: $product->status === 'active' ? null : $product->status, + )); + } +} diff --git a/workbench/app/Search/SalesOrderSearchProvider.php b/workbench/app/Search/SalesOrderSearchProvider.php new file mode 100644 index 00000000..f0c7bb25 --- /dev/null +++ b/workbench/app/Search/SalesOrderSearchProvider.php @@ -0,0 +1,75 @@ +query($query->query)->count(); + } + + public function search(SearchQuery $query): SearchResults + { + $builder = $this->query($query->query); + $total = $builder->count(); + + $rows = $builder + ->with('businessPartner') + ->forPage($query->page, $query->perPage) + ->get() + ->map(fn (SalesOrder $order): SearchResult => $this->toResult($order)) + ->all(); + + return new SearchResults($rows, $total); + } + + public function resolve(string $id, Request $request): ?SearchResult + { + $order = SalesOrder::query()->with('businessPartner')->find($id); + + return $order === null ? null : $this->toResult($order); + } + + /** @return Builder */ + private function query(string $term): Builder + { + return SalesOrder::query() + ->when($term !== '', fn (Builder $builder) => $builder->where('number', 'like', "%{$term}%")) + ->orderByDesc('id'); + } + + private function toResult(SalesOrder $order): SearchResult + { + return new SearchResult('sales-orders', new SearchResultItem( + id: (string) $order->getKey(), + title: $order->number, + link: '/sales-orders/'.$order->getKey(), + subtitle: $order->businessPartner?->name, + badge: $order->status->value, + )); + } +} diff --git a/workbench/app/Search/SessionSearchHistoryRecorder.php b/workbench/app/Search/SessionSearchHistoryRecorder.php new file mode 100644 index 00000000..47430b9f --- /dev/null +++ b/workbench/app/Search/SessionSearchHistoryRecorder.php @@ -0,0 +1,44 @@ + $stored */ + $stored = $request->session()->get(self::KEY, []); + $recent = collect($stored) + ->reject(fn (array $row): bool => $row['category']['name'] === $result->category && $row['item']['id'] === $result->item->id) + ->prepend($result->jsonSerialize()) + ->take(10) + ->values() + ->all(); + + $request->session()->put(self::KEY, $recent); + + return true; + } + + public function recent(Request $request, int $limit): array + { + /** @var list $stored */ + $stored = $request->session()->get(self::KEY, []); + + return collect($stored) + ->take($limit) + ->map(fn (array $row): SearchResult => new SearchResult($row['category']['name'], new SearchResultItem( + id: $row['item']['id'], title: $row['item']['title'], link: $row['item']['link'], + subtitle: $row['item']['subtitle'] ?? null, additionalInfo: $row['item']['additionalInfo'] ?? null, badge: $row['item']['badge'] ?? null, + ))) + ->all(); + } +} diff --git a/workbench/lang/de/workbench.php b/workbench/lang/de/workbench.php index 42710674..019697b0 100644 --- a/workbench/lang/de/workbench.php +++ b/workbench/lang/de/workbench.php @@ -2,6 +2,13 @@ declare(strict_types=1); return [ + 'search' => [ + 'placeholder' => 'Produkte, Partner, Aufträge suchen…', + 'title' => 'Suche', + 'products' => 'Produkte', + 'business-partners' => 'Geschäftspartner', + 'sales-orders' => 'Aufträge', + ], 'assistant' => [ 'placeholder' => 'Frag den Assistenten…', 'title' => 'Assistent', diff --git a/workbench/lang/en/workbench.php b/workbench/lang/en/workbench.php index ba804e05..95924ad6 100644 --- a/workbench/lang/en/workbench.php +++ b/workbench/lang/en/workbench.php @@ -2,6 +2,13 @@ declare(strict_types=1); return [ + 'search' => [ + 'placeholder' => 'Search products, partners, orders…', + 'title' => 'Search', + 'products' => 'Products', + 'business-partners' => 'Business partners', + 'sales-orders' => 'Sales orders', + ], 'assistant' => [ 'placeholder' => 'Ask the assistant…', 'title' => 'Assistant', diff --git a/workbench/resources/js/sprite-icons.ts b/workbench/resources/js/sprite-icons.ts index 06f254d3..75913ab3 100644 --- a/workbench/resources/js/sprite-icons.ts +++ b/workbench/resources/js/sprite-icons.ts @@ -38,11 +38,13 @@ export const iconNames = [ "loader-2", "minus", "more-horizontal", + "package", "panel-left", "pencil", "pencil-line", "plus", "quote", + "receipt", "rotate-ccw", "rows-3", "search", @@ -54,6 +56,7 @@ export const iconNames = [ "table", "trash-2", "underline", + "users", "x", ] as const; @@ -98,11 +101,13 @@ declare module "@lattice-php/lattice" { "loader-2": true; minus: true; "more-horizontal": true; + package: true; "panel-left": true; pencil: true; "pencil-line": true; plus: true; quote: true; + receipt: true; "rotate-ccw": true; "rows-3": true; search: true; @@ -114,6 +119,7 @@ declare module "@lattice-php/lattice" { table: true; "trash-2": true; underline: true; + users: true; x: true; } }