@open-mercato/search 0.4.7-main-1768da2e43 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agentic/standalone-guide.md +115 -0
- package/dist/lib/fallback-presenter.js +29 -9
- package/dist/lib/fallback-presenter.js.map +2 -2
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js +15 -2
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +2 -2
- package/package.json +5 -6
- package/src/__tests__/fallback-presenter.test.ts +145 -0
- package/src/lib/fallback-presenter.ts +34 -11
- package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +16 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Search Package — Standalone Developer Guide
|
|
2
|
+
|
|
3
|
+
`@open-mercato/search` provides fulltext, vector, and token-based search. Configure search for your entities via `search.ts` in your module.
|
|
4
|
+
|
|
5
|
+
## Strategy Overview
|
|
6
|
+
|
|
7
|
+
| Strategy | Backend | Use when |
|
|
8
|
+
|----------|---------|----------|
|
|
9
|
+
| **Fulltext** | Meilisearch | Fast, typo-tolerant search (names, titles, descriptions) |
|
|
10
|
+
| **Vector** | OpenAI / Ollama | Semantic, meaning-based search ("find customers interested in X") |
|
|
11
|
+
| **Tokens** | PostgreSQL | Baseline keyword search, always available, no external services |
|
|
12
|
+
|
|
13
|
+
Strategies auto-degrade when their backend is not configured.
|
|
14
|
+
|
|
15
|
+
## Adding Search to a Module
|
|
16
|
+
|
|
17
|
+
Create `src/modules/<module>/search.ts`:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import type { SearchModuleConfig, SearchBuildContext } from '@open-mercato/shared/modules/search'
|
|
21
|
+
|
|
22
|
+
export const searchConfig: SearchModuleConfig = {
|
|
23
|
+
entities: [{
|
|
24
|
+
entityId: 'my_module:my_entity', // MUST match entity registry
|
|
25
|
+
priority: 10,
|
|
26
|
+
|
|
27
|
+
// Fulltext: control field indexing
|
|
28
|
+
fieldPolicy: {
|
|
29
|
+
searchable: ['name', 'description'],
|
|
30
|
+
hashOnly: ['email', 'phone'], // exact match only
|
|
31
|
+
excluded: ['password', 'api_key'], // never indexed
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// Vector: generate text for embeddings
|
|
35
|
+
buildSource: async (ctx: SearchBuildContext) => ({
|
|
36
|
+
text: [`Name: ${ctx.record.name}`, `Description: ${ctx.record.description}`],
|
|
37
|
+
presenter: { title: ctx.record.name, subtitle: ctx.record.status, icon: 'lucide:file', badge: 'Item' },
|
|
38
|
+
links: [{ href: `/backend/my-module/${ctx.record.id}`, label: 'View', kind: 'primary' }],
|
|
39
|
+
checksumSource: { record: ctx.record, customFields: ctx.customFields },
|
|
40
|
+
}),
|
|
41
|
+
|
|
42
|
+
// Tokens: format at search time
|
|
43
|
+
formatResult: async (ctx: SearchBuildContext) => ({
|
|
44
|
+
title: ctx.record.name ?? 'Unknown',
|
|
45
|
+
subtitle: ctx.record.status,
|
|
46
|
+
icon: 'lucide:file',
|
|
47
|
+
badge: 'Item',
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
resolveUrl: async (ctx) => `/backend/my-module/${ctx.record.id}`,
|
|
51
|
+
}],
|
|
52
|
+
}
|
|
53
|
+
export default searchConfig
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Run `yarn generate` after creating the file.
|
|
57
|
+
|
|
58
|
+
## MUST Rules
|
|
59
|
+
|
|
60
|
+
1. **MUST create `search.ts`** for every module with searchable entities
|
|
61
|
+
2. **MUST define `fieldPolicy.excluded`** for sensitive fields (passwords, tokens, SSNs)
|
|
62
|
+
3. **MUST define `formatResult`** for every entity using the tokens strategy
|
|
63
|
+
4. **MUST include `checksumSource`** in every `buildSource` return value
|
|
64
|
+
5. **MUST NOT** include encrypted/sensitive fields in `buildSource` text
|
|
65
|
+
6. **MUST NOT** use raw `fetch` against search API — use `apiCall`/`apiCallOrThrow`
|
|
66
|
+
|
|
67
|
+
## Auto-Indexing
|
|
68
|
+
|
|
69
|
+
When CRUD routes have `indexer: { entityType }`, the search module automatically subscribes to entity CRUD events and indexes/removes records. No manual indexing code needed.
|
|
70
|
+
|
|
71
|
+
## CLI Commands
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
yarn mercato search status # Check strategies and connectivity
|
|
75
|
+
yarn mercato search query -q "term" --tenant <id> # Run a search
|
|
76
|
+
yarn mercato search reindex --tenant <id> # Reindex all entities
|
|
77
|
+
yarn mercato search reindex --entity my_module:my_entity --tenant <id> # Reindex specific entity
|
|
78
|
+
yarn mercato search test-meilisearch # Test Meilisearch connection
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Environment Variables
|
|
82
|
+
|
|
83
|
+
| Variable | Purpose |
|
|
84
|
+
|----------|---------|
|
|
85
|
+
| `MEILISEARCH_HOST` | Meilisearch URL (enables fulltext) |
|
|
86
|
+
| `MEILISEARCH_API_KEY` | Meilisearch auth key |
|
|
87
|
+
| `OPENAI_API_KEY` | OpenAI API key (enables vector search) |
|
|
88
|
+
| `OM_SEARCH_DEBUG` | Enable verbose debug logging |
|
|
89
|
+
|
|
90
|
+
## Programmatic Search via DI
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const searchService = container.resolve('searchService')
|
|
94
|
+
const results = await searchService.search('query', {
|
|
95
|
+
tenantId: 'tenant-123',
|
|
96
|
+
limit: 20,
|
|
97
|
+
strategies: ['fulltext', 'vector'],
|
|
98
|
+
})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## SearchBuildContext
|
|
102
|
+
|
|
103
|
+
Both `buildSource` and `formatResult` receive this context:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
interface SearchBuildContext {
|
|
107
|
+
record: Record<string, unknown> // The database record
|
|
108
|
+
customFields: Record<string, unknown> // Custom fields (without cf: prefix)
|
|
109
|
+
tenantId?: string | null
|
|
110
|
+
organizationId?: string | null
|
|
111
|
+
queryEngine?: QueryEngine // For loading related entities
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use `queryEngine` to load parent/related entities for richer presenter data.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
1
|
+
const TITLE_FIELDS_PRIMARY = [
|
|
2
2
|
"display_name",
|
|
3
3
|
"displayName",
|
|
4
4
|
"name",
|
|
@@ -10,12 +10,10 @@ const TITLE_FIELDS = [
|
|
|
10
10
|
"brandName",
|
|
11
11
|
"legal_name",
|
|
12
12
|
"legalName",
|
|
13
|
-
"first_name",
|
|
14
|
-
"firstName",
|
|
15
|
-
"last_name",
|
|
16
|
-
"lastName",
|
|
17
13
|
"preferred_name",
|
|
18
|
-
"preferredName"
|
|
14
|
+
"preferredName"
|
|
15
|
+
];
|
|
16
|
+
const TITLE_FIELDS_SECONDARY = [
|
|
19
17
|
"email",
|
|
20
18
|
"primary_email",
|
|
21
19
|
"primaryEmail",
|
|
@@ -25,6 +23,9 @@ const TITLE_FIELDS = [
|
|
|
25
23
|
"identifier",
|
|
26
24
|
"slug"
|
|
27
25
|
];
|
|
26
|
+
const FIRST_NAME_FIELDS = ["first_name", "firstName"];
|
|
27
|
+
const LAST_NAME_FIELDS = ["last_name", "lastName"];
|
|
28
|
+
const MAX_SUBTITLE_LENGTH = 120;
|
|
28
29
|
const SUBTITLE_FIELDS = [
|
|
29
30
|
"description",
|
|
30
31
|
"summary",
|
|
@@ -49,6 +50,12 @@ function findFirstValue(doc, fields) {
|
|
|
49
50
|
}
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
53
|
+
function buildNameFromParts(doc) {
|
|
54
|
+
const firstName = findFirstValue(doc, FIRST_NAME_FIELDS);
|
|
55
|
+
const lastName = findFirstValue(doc, LAST_NAME_FIELDS);
|
|
56
|
+
if (firstName && lastName) return `${firstName} ${lastName}`;
|
|
57
|
+
return firstName ?? lastName;
|
|
58
|
+
}
|
|
52
59
|
function findAnyStringValue(doc, excludeFields) {
|
|
53
60
|
const skipFields = /* @__PURE__ */ new Set([
|
|
54
61
|
"id",
|
|
@@ -77,11 +84,24 @@ function formatEntityLabel(entityId) {
|
|
|
77
84
|
const entityName = entityId.split(":")[1] ?? entityId;
|
|
78
85
|
return entityName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
79
86
|
}
|
|
87
|
+
function truncateSubtitle(value) {
|
|
88
|
+
if (value.length <= MAX_SUBTITLE_LENGTH) return value;
|
|
89
|
+
return value.slice(0, MAX_SUBTITLE_LENGTH).trimEnd();
|
|
90
|
+
}
|
|
80
91
|
function extractFallbackPresenter(doc, entityId, recordId) {
|
|
81
92
|
const entityLabel = formatEntityLabel(entityId);
|
|
82
|
-
let title = findFirstValue(doc,
|
|
93
|
+
let title = findFirstValue(doc, TITLE_FIELDS_PRIMARY);
|
|
94
|
+
if (!title) {
|
|
95
|
+
title = buildNameFromParts(doc);
|
|
96
|
+
}
|
|
97
|
+
if (!title) {
|
|
98
|
+
title = findFirstValue(doc, TITLE_FIELDS_SECONDARY);
|
|
99
|
+
}
|
|
83
100
|
if (!title) {
|
|
84
|
-
title = findAnyStringValue(
|
|
101
|
+
title = findAnyStringValue(
|
|
102
|
+
doc,
|
|
103
|
+
/* @__PURE__ */ new Set([...SUBTITLE_FIELDS, ...FIRST_NAME_FIELDS, ...LAST_NAME_FIELDS])
|
|
104
|
+
);
|
|
85
105
|
}
|
|
86
106
|
if (!title) {
|
|
87
107
|
const shortId = recordId.length > 8 ? recordId.slice(0, 8) + "..." : recordId;
|
|
@@ -97,7 +117,7 @@ function extractFallbackPresenter(doc, entityId, recordId) {
|
|
|
97
117
|
}
|
|
98
118
|
return {
|
|
99
119
|
title,
|
|
100
|
-
subtitle: subtitleParts.length > 0 ? subtitleParts.join(" \xB7 ")
|
|
120
|
+
subtitle: subtitleParts.length > 0 ? truncateSubtitle(subtitleParts.join(" \xB7 ")) : void 0,
|
|
101
121
|
badge: entityLabel
|
|
102
122
|
};
|
|
103
123
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/fallback-presenter.ts"],
|
|
4
|
-
"sourcesContent": ["import type { SearchResultPresenter } from '@open-mercato/shared/modules/search'\n\
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { SearchResultPresenter } from '@open-mercato/shared/modules/search'\n\nconst TITLE_FIELDS_PRIMARY = [\n 'display_name', 'displayName',\n 'name', 'title', 'label',\n 'full_name', 'fullName',\n 'brand_name', 'brandName',\n 'legal_name', 'legalName',\n 'preferred_name', 'preferredName',\n]\n\nconst TITLE_FIELDS_SECONDARY = [\n 'email', 'primary_email', 'primaryEmail',\n 'code', 'sku', 'reference',\n 'identifier', 'slug',\n]\n\nconst FIRST_NAME_FIELDS = ['first_name', 'firstName']\nconst LAST_NAME_FIELDS = ['last_name', 'lastName']\nconst MAX_SUBTITLE_LENGTH = 120\n\n// Fields to check for subtitle\nconst SUBTITLE_FIELDS = [\n 'description', 'summary', 'notes',\n 'email', 'primary_email', 'primaryEmail',\n 'phone', 'primary_phone', 'primaryPhone',\n 'status', 'type', 'kind', 'category',\n]\n\nfunction findFirstValue(doc: Record<string, unknown>, fields: string[]): string | null {\n for (const field of fields) {\n const value = doc[field]\n if (value != null && String(value).trim().length > 0) {\n return String(value).trim()\n }\n }\n return null\n}\n\nfunction buildNameFromParts(doc: Record<string, unknown>): string | null {\n const firstName = findFirstValue(doc, FIRST_NAME_FIELDS)\n const lastName = findFirstValue(doc, LAST_NAME_FIELDS)\n if (firstName && lastName) return `${firstName} ${lastName}`\n return firstName ?? lastName\n}\n\nfunction findAnyStringValue(doc: Record<string, unknown>, excludeFields: Set<string>): string | null {\n // Skip these fields as they're not meaningful for display\n const skipFields = new Set([\n 'id', 'tenant_id', 'tenantId', 'organization_id', 'organizationId',\n 'created_at', 'createdAt', 'updated_at', 'updatedAt', 'deleted_at', 'deletedAt',\n ...excludeFields,\n ])\n\n for (const [key, value] of Object.entries(doc)) {\n if (skipFields.has(key)) continue\n if (key.startsWith('cf:') || key.startsWith('cf_')) continue\n if (typeof value === 'string' && value.trim().length > 0 && value.length < 200) {\n return value.trim()\n }\n }\n return null\n}\n\nfunction formatEntityLabel(entityId: string): string {\n const entityName = entityId.split(':')[1] ?? entityId\n return entityName\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, (c) => c.toUpperCase())\n}\n\nfunction truncateSubtitle(value: string): string {\n if (value.length <= MAX_SUBTITLE_LENGTH) return value\n return value.slice(0, MAX_SUBTITLE_LENGTH).trimEnd()\n}\n\n/**\n * Extract a presenter from doc fields when no search.ts config exists.\n *\n * TODO: This is a basic implementation. Future improvements could include:\n * - Entity-type specific field mappings\n * - Smarter field combination (e.g., first_name + last_name)\n * - Custom field (cf:*) inspection for user-defined display fields\n * - Configuration for default presenter fields per entity type\n */\nexport function extractFallbackPresenter(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n): SearchResultPresenter {\n const entityLabel = formatEntityLabel(entityId)\n\n let title = findFirstValue(doc, TITLE_FIELDS_PRIMARY)\n\n if (!title) {\n title = buildNameFromParts(doc)\n }\n\n if (!title) {\n title = findFirstValue(doc, TITLE_FIELDS_SECONDARY)\n }\n\n if (!title) {\n title = findAnyStringValue(\n doc,\n new Set([...SUBTITLE_FIELDS, ...FIRST_NAME_FIELDS, ...LAST_NAME_FIELDS]),\n )\n }\n\n if (!title) {\n const shortId = recordId.length > 8 ? recordId.slice(0, 8) + '...' : recordId\n title = `${entityLabel} ${shortId}`\n }\n\n const subtitleParts: string[] = []\n for (const field of SUBTITLE_FIELDS) {\n const value = doc[field]\n if (value != null && String(value).trim().length > 0 && String(value) !== title) {\n subtitleParts.push(String(value).trim())\n if (subtitleParts.length >= 3) break // Limit to 3 parts\n }\n }\n\n return {\n title,\n subtitle: subtitleParts.length > 0 ? truncateSubtitle(subtitleParts.join(' \u00B7 ')) : undefined,\n badge: entityLabel,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -139,6 +139,7 @@ function GlobalSearchDialog({
|
|
|
139
139
|
const [error, setError] = React.useState(null);
|
|
140
140
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
141
141
|
const inputRef = React.useRef(null);
|
|
142
|
+
const listRef = React.useRef(null);
|
|
142
143
|
const abortRef = React.useRef(null);
|
|
143
144
|
const t = useT();
|
|
144
145
|
const [showScopeHint, setShowScopeHint] = React.useState(() => hasActiveOrganizationSelection());
|
|
@@ -263,6 +264,18 @@ function GlobalSearchDialog({
|
|
|
263
264
|
return;
|
|
264
265
|
}
|
|
265
266
|
}, [results, selectedIndex, openResult]);
|
|
267
|
+
React.useEffect(() => {
|
|
268
|
+
const container = listRef.current;
|
|
269
|
+
const active = container?.querySelector('[data-active="true"]');
|
|
270
|
+
if (!container || !active) return;
|
|
271
|
+
const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect();
|
|
272
|
+
const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect();
|
|
273
|
+
if (activeTop < containerTop) {
|
|
274
|
+
container.scrollTop -= containerTop - activeTop;
|
|
275
|
+
} else if (activeBottom > containerBottom) {
|
|
276
|
+
container.scrollTop += activeBottom - containerBottom;
|
|
277
|
+
}
|
|
278
|
+
}, [selectedIndex]);
|
|
266
279
|
const showVectorWarning = !embeddingConfigured && enabledStrategies.includes("vector") && !error;
|
|
267
280
|
const selectedResult = results[selectedIndex];
|
|
268
281
|
const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false;
|
|
@@ -308,14 +321,14 @@ function GlobalSearchDialog({
|
|
|
308
321
|
showVectorWarning ? /* @__PURE__ */ jsx("p", { className: "rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200", children: missingConfigMessage }) : null,
|
|
309
322
|
showScopeHint ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("search.scopeHint.currentOrg", "Scoped to current organization") }) : null
|
|
310
323
|
] }),
|
|
311
|
-
/* @__PURE__ */ jsxs("div", { className: "max-h-96 overflow-y-auto px-2 pb-3", children: [
|
|
324
|
+
/* @__PURE__ */ jsxs("div", { ref: listRef, className: "max-h-96 overflow-y-auto px-2 pb-3", children: [
|
|
312
325
|
results.length === 0 && !loading && !error ? /* @__PURE__ */ jsx("div", { className: "px-4 py-6 text-sm text-muted-foreground", children: query.trim().length < MIN_QUERY_LENGTH ? t("search.dialog.empty.hint") : t("search.dialog.empty.none") }) : null,
|
|
313
326
|
/* @__PURE__ */ jsx("ul", { className: "flex flex-col", children: results.map((result, index) => {
|
|
314
327
|
const presenter = result.presenter;
|
|
315
328
|
const isActive = index === selectedIndex;
|
|
316
329
|
const hasLink = pickPrimaryLink(result) !== null;
|
|
317
330
|
const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null;
|
|
318
|
-
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
|
|
331
|
+
return /* @__PURE__ */ jsx("li", { "data-active": isActive, children: /* @__PURE__ */ jsx(
|
|
319
332
|
"button",
|
|
320
333
|
{
|
|
321
334
|
type: "button",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/search/frontend/components/GlobalSearchDialog.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n Search,\n Loader2,\n Zap,\n User,\n Users,\n Building,\n StickyNote,\n Briefcase,\n CheckSquare,\n FileText,\n Mail,\n Phone,\n Calendar,\n Clock,\n Star,\n Tag,\n Flag,\n Heart,\n Bookmark,\n Package,\n Truck,\n ShoppingCart,\n CreditCard,\n DollarSign,\n Target,\n Award,\n Trophy,\n Rocket,\n Lightbulb,\n MessageSquare,\n Bell,\n Settings,\n Globe,\n MapPin,\n Link,\n Folder,\n Database,\n Activity,\n} from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { SearchResult, SearchResultLink, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport { parseSelectedOrganizationCookie } from '@open-mercato/core/modules/directory/utils/scopeCookies'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = 2\n\n/** Default strategies used when none are configured */\nconst DEFAULT_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\nfunction normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string')\n}\n\nfunction pickPrimaryLink(result: SearchResult): string | null {\n if (result.url) return result.url\n const links = normalizeLinks(result.links)\n if (!links.length) return null\n const primary = links.find((link) => link.kind === 'primary')\n return (primary ?? links[0]).href\n}\n\nfunction hasActiveOrganizationSelection(): boolean {\n const fromEvent = getCurrentOrganizationScope().organizationId\n if (typeof fromEvent === 'string' && fromEvent.trim().length > 0) return true\n\n const cookieHeader = typeof document === 'undefined' ? null : document.cookie\n const cookieValue = parseSelectedOrganizationCookie(cookieHeader)\n if (!cookieValue) return false\n return !isAllOrganizationsSelection(cookieValue);\n}\n\nfunction humanizeSegment(segment: string): string {\n return segment\n .split(/[_-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nconst ICON_MAP: Record<string, LucideIcon> = {\n bolt: Zap,\n zap: Zap,\n user: User,\n users: Users,\n building: Building,\n 'sticky-note': StickyNote,\n briefcase: Briefcase,\n 'check-square': CheckSquare,\n 'file-text': FileText,\n mail: Mail,\n phone: Phone,\n calendar: Calendar,\n clock: Clock,\n star: Star,\n tag: Tag,\n flag: Flag,\n heart: Heart,\n bookmark: Bookmark,\n package: Package,\n truck: Truck,\n 'shopping-cart': ShoppingCart,\n 'credit-card': CreditCard,\n 'dollar-sign': DollarSign,\n target: Target,\n award: Award,\n trophy: Trophy,\n rocket: Rocket,\n lightbulb: Lightbulb,\n 'message-square': MessageSquare,\n bell: Bell,\n settings: Settings,\n globe: Globe,\n 'map-pin': MapPin,\n link: Link,\n folder: Folder,\n database: Database,\n activity: Activity,\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n return ICON_MAP[name.toLowerCase()] ?? null\n}\n\nfunction formatEntityId(entityId: string): string {\n if (!entityId.includes(':')) return humanizeSegment(entityId)\n const [module, entity] = entityId.split(':')\n return `${humanizeSegment(module)} \u00B7 ${humanizeSegment(entity)}`\n}\n\nexport type GlobalSearchDialogProps = {\n /** Whether embedding provider is configured for vector search */\n embeddingConfigured: boolean\n /** Message to show when embedding is not configured */\n missingConfigMessage: string\n /** Enabled strategies from tenant configuration (optional - uses defaults if not provided) */\n enabledStrategies?: SearchStrategyId[]\n}\n\nexport function GlobalSearchDialog({\n embeddingConfigured,\n missingConfigMessage,\n enabledStrategies: propStrategies,\n}: GlobalSearchDialogProps) {\n const router = useRouter()\n const [open, setOpen] = React.useState(false)\n const [query, setQuery] = React.useState('')\n const [results, setResults] = React.useState<SearchResult[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [selectedIndex, setSelectedIndex] = React.useState(0)\n const inputRef = React.useRef<HTMLInputElement | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n const t = useT()\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n\n React.useEffect(() => {\n setShowScopeHint(hasActiveOrganizationSelection())\n return subscribeOrganizationScopeChanged((detail) => {\n setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0))\n })\n }, [])\n\n // Use configured strategies or fall back to defaults\n const enabledStrategies = React.useMemo(() => {\n if (propStrategies && propStrategies.length > 0) {\n return propStrategies\n }\n return DEFAULT_STRATEGIES\n }, [propStrategies])\n\n const resetState = React.useCallback(() => {\n setQuery('')\n setResults([])\n setError(null)\n setSelectedIndex(0)\n setLoading(false)\n }, [])\n\n React.useEffect(() => {\n if (!open) {\n resetState()\n return\n }\n const handler = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [open, resetState])\n\n React.useEffect(() => {\n const shortcut = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n setOpen((prev) => !prev)\n }\n }\n window.addEventListener('keydown', shortcut)\n return () => window.removeEventListener('keydown', shortcut)\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n const focusTimer = setTimeout(() => inputRef.current?.focus(), 50)\n return () => clearTimeout(focusTimer)\n }, [open])\n\n React.useEffect(() => {\n if (!open) return\n\n abortRef.current?.abort()\n if (query.trim().length < MIN_QUERY_LENGTH) {\n setResults([])\n setError(null)\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n const handle = setTimeout(async () => {\n try {\n const data = await fetchGlobalSearchResults(query, {\n limit: 10,\n signal: controller.signal,\n })\n setResults(data.results)\n setError(data.error ?? null)\n setSelectedIndex(0)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n const abortError = err as { name?: string }\n if (abortError?.name === 'AbortError') return\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n setResults([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 220)\n\n return () => {\n clearTimeout(handle)\n controller.abort()\n }\n }, [open, query, enabledStrategies, t])\n\n const openResult = React.useCallback((result: SearchResult | undefined) => {\n if (!result) return\n const href = pickPrimaryLink(result)\n if (!href) return\n router.push(href)\n setOpen(false)\n }, [router])\n\n const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {\n if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {\n event.preventDefault()\n openResult(results[selectedIndex])\n return\n }\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSelectedIndex((prev) => (prev + 1) % Math.max(results.length || 1, 1))\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => {\n if (!results.length) return 0\n return prev <= 0 ? results.length - 1 : prev - 1\n })\n return\n }\n if (event.key === 'Escape') {\n event.preventDefault()\n setOpen(false)\n return\n }\n if (event.key === 'Enter') {\n event.preventDefault()\n const target = results[selectedIndex]\n openResult(target)\n return\n }\n }, [results, selectedIndex, openResult])\n\n // Check if vector search is enabled but not configured\n const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error\n\n // Check if selected result has a navigable link\n const selectedResult = results[selectedIndex]\n const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false\n\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(true)} className=\"hidden sm:inline-flex items-center gap-2\">\n <Search className=\"h-4 w-4\" />\n <span>{t('search.dialog.actions.search')}</span>\n <span className=\"ml-2 rounded border px-1 text-xs text-muted-foreground\">\u2318K</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"sm:hidden\"\n onClick={() => setOpen(true)}\n aria-label={t('search.dialog.actions.openGlobalSearch')}\n >\n <Search className=\"h-4 w-4\" />\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent className=\"max-w-xl p-0\" aria-describedby=\"global-search-description\">\n <DialogTitle className=\"sr-only\">\n {t('search.dialog.title', 'Global Search')}\n </DialogTitle>\n <span id=\"global-search-description\" className=\"sr-only\">\n {t('search.dialog.instructions')}\n </span>\n <div className=\"flex flex-col gap-3 border-b px-4 pb-3 pt-12\">\n <div className=\"flex items-center gap-2 rounded border bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring\">\n <Search className=\"h-4 w-4 text-muted-foreground\" />\n <TypedInput\n ref={inputRef}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder={t('search.dialog.input.placeholder')}\n className=\"border-none px-0 shadow-none focus-visible:ring-0\"\n autoFocus\n />\n {loading && <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />}\n </div>\n\n {error ? (\n <p className=\"rounded bg-destructive/10 px-3 py-2 text-sm text-destructive\">{error}</p>\n ) : null}\n {showVectorWarning ? (\n <p className=\"rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200\">{missingConfigMessage}</p>\n ) : null}\n {showScopeHint ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </p>\n ) : null}\n </div>\n <div className=\"max-h-96 overflow-y-auto px-2 pb-3\">\n {results.length === 0 && !loading && !error ? (\n <div className=\"px-4 py-6 text-sm text-muted-foreground\">\n {query.trim().length < MIN_QUERY_LENGTH\n ? t('search.dialog.empty.hint')\n : t('search.dialog.empty.none')}\n </div>\n ) : null}\n <ul className=\"flex flex-col\">\n {results.map((result, index) => {\n const presenter = result.presenter\n const isActive = index === selectedIndex\n const hasLink = pickPrimaryLink(result) !== null\n const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null\n return (\n <li key={`${result.entityId}:${result.recordId}`}>\n <button\n type=\"button\"\n onClick={() => openResult(result)}\n onMouseEnter={() => setSelectedIndex(index)}\n className={cn(\n 'w-full rounded-lg px-4 py-3 text-left transition border',\n isActive\n ? 'border-primary bg-primary/10 text-foreground shadow-sm'\n : 'border-transparent hover:border-muted-foreground/30 hover:bg-muted/60',\n !hasLink && 'opacity-60'\n )}\n >\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className={cn('font-medium text-base whitespace-normal break-all', !hasLink && 'text-muted-foreground')}>{presenter?.title ?? result.recordId}</span>\n <span className=\"rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground\">\n {formatEntityId(result.entityId)}\n </span>\n {!hasLink && (\n <span className=\"rounded-full border border-amber-500/50 bg-amber-50 dark:bg-amber-900/20 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400\">\n {t('search.dialog.noLink')}\n </span>\n )}\n </div>\n {presenter?.subtitle ? (\n <div className=\"text-sm text-muted-foreground whitespace-normal break-words\">{presenter.subtitle}</div>\n ) : null}\n {normalizeLinks(result.links).length ? (\n <div className=\"mt-1 flex flex-wrap items-center gap-2\">\n {normalizeLinks(result.links).map((link) => (\n <span\n key={`${link.href}`}\n className={cn(\n 'rounded-full border px-2 py-0.5 text-xs',\n link.kind === 'primary'\n ? 'border-primary text-primary'\n : 'border-muted-foreground/40 text-muted-foreground'\n )}\n >\n {link.label ?? link.href}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n {Icon ? (\n <div className=\"flex flex-col items-end gap-2\">\n <Icon className=\"h-5 w-5 text-muted-foreground\" />\n </div>\n ) : null}\n </div>\n </button>\n </li>\n )\n })}\n </ul>\n </div>\n <div className=\"flex items-center justify-between border-t px-4 py-3\">\n <span className=\"text-xs text-muted-foreground\">\n {selectedResult && !selectedHasLink\n ? t('search.dialog.noLinkHint')\n : t('search.dialog.shortcuts.hint')}\n </span>\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(false)}>\n {t('search.dialog.actions.cancel')}\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={() => openResult(results[selectedIndex])}\n disabled={!results.length || !selectedHasLink}\n >\n {t('search.dialog.actions.openSelected')}\n </Button>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n\nexport default GlobalSearchDialog\nconst TypedInput = Input as React.ForwardRefExoticComponent<React.InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>>\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n Search,\n Loader2,\n Zap,\n User,\n Users,\n Building,\n StickyNote,\n Briefcase,\n CheckSquare,\n FileText,\n Mail,\n Phone,\n Calendar,\n Clock,\n Star,\n Tag,\n Flag,\n Heart,\n Bookmark,\n Package,\n Truck,\n ShoppingCart,\n CreditCard,\n DollarSign,\n Target,\n Award,\n Trophy,\n Rocket,\n Lightbulb,\n MessageSquare,\n Bell,\n Settings,\n Globe,\n MapPin,\n Link,\n Folder,\n Database,\n Activity,\n} from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { SearchResult, SearchResultLink, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport { parseSelectedOrganizationCookie } from '@open-mercato/core/modules/directory/utils/scopeCookies'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = 2\n\n/** Default strategies used when none are configured */\nconst DEFAULT_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\nfunction normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string')\n}\n\nfunction pickPrimaryLink(result: SearchResult): string | null {\n if (result.url) return result.url\n const links = normalizeLinks(result.links)\n if (!links.length) return null\n const primary = links.find((link) => link.kind === 'primary')\n return (primary ?? links[0]).href\n}\n\nfunction hasActiveOrganizationSelection(): boolean {\n const fromEvent = getCurrentOrganizationScope().organizationId\n if (typeof fromEvent === 'string' && fromEvent.trim().length > 0) return true\n\n const cookieHeader = typeof document === 'undefined' ? null : document.cookie\n const cookieValue = parseSelectedOrganizationCookie(cookieHeader)\n if (!cookieValue) return false\n return !isAllOrganizationsSelection(cookieValue);\n}\n\nfunction humanizeSegment(segment: string): string {\n return segment\n .split(/[_-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nconst ICON_MAP: Record<string, LucideIcon> = {\n bolt: Zap,\n zap: Zap,\n user: User,\n users: Users,\n building: Building,\n 'sticky-note': StickyNote,\n briefcase: Briefcase,\n 'check-square': CheckSquare,\n 'file-text': FileText,\n mail: Mail,\n phone: Phone,\n calendar: Calendar,\n clock: Clock,\n star: Star,\n tag: Tag,\n flag: Flag,\n heart: Heart,\n bookmark: Bookmark,\n package: Package,\n truck: Truck,\n 'shopping-cart': ShoppingCart,\n 'credit-card': CreditCard,\n 'dollar-sign': DollarSign,\n target: Target,\n award: Award,\n trophy: Trophy,\n rocket: Rocket,\n lightbulb: Lightbulb,\n 'message-square': MessageSquare,\n bell: Bell,\n settings: Settings,\n globe: Globe,\n 'map-pin': MapPin,\n link: Link,\n folder: Folder,\n database: Database,\n activity: Activity,\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n return ICON_MAP[name.toLowerCase()] ?? null\n}\n\nfunction formatEntityId(entityId: string): string {\n if (!entityId.includes(':')) return humanizeSegment(entityId)\n const [module, entity] = entityId.split(':')\n return `${humanizeSegment(module)} \u00B7 ${humanizeSegment(entity)}`\n}\n\nexport type GlobalSearchDialogProps = {\n /** Whether embedding provider is configured for vector search */\n embeddingConfigured: boolean\n /** Message to show when embedding is not configured */\n missingConfigMessage: string\n /** Enabled strategies from tenant configuration (optional - uses defaults if not provided) */\n enabledStrategies?: SearchStrategyId[]\n}\n\nexport function GlobalSearchDialog({\n embeddingConfigured,\n missingConfigMessage,\n enabledStrategies: propStrategies,\n}: GlobalSearchDialogProps) {\n const router = useRouter()\n const [open, setOpen] = React.useState(false)\n const [query, setQuery] = React.useState('')\n const [results, setResults] = React.useState<SearchResult[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [selectedIndex, setSelectedIndex] = React.useState(0)\n const inputRef = React.useRef<HTMLInputElement | null>(null)\n const listRef = React.useRef<HTMLDivElement | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n const t = useT()\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n\n React.useEffect(() => {\n setShowScopeHint(hasActiveOrganizationSelection())\n return subscribeOrganizationScopeChanged((detail) => {\n setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0))\n })\n }, [])\n\n // Use configured strategies or fall back to defaults\n const enabledStrategies = React.useMemo(() => {\n if (propStrategies && propStrategies.length > 0) {\n return propStrategies\n }\n return DEFAULT_STRATEGIES\n }, [propStrategies])\n\n const resetState = React.useCallback(() => {\n setQuery('')\n setResults([])\n setError(null)\n setSelectedIndex(0)\n setLoading(false)\n }, [])\n\n React.useEffect(() => {\n if (!open) {\n resetState()\n return\n }\n const handler = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [open, resetState])\n\n React.useEffect(() => {\n const shortcut = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n setOpen((prev) => !prev)\n }\n }\n window.addEventListener('keydown', shortcut)\n return () => window.removeEventListener('keydown', shortcut)\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n const focusTimer = setTimeout(() => inputRef.current?.focus(), 50)\n return () => clearTimeout(focusTimer)\n }, [open])\n\n React.useEffect(() => {\n if (!open) return\n\n abortRef.current?.abort()\n if (query.trim().length < MIN_QUERY_LENGTH) {\n setResults([])\n setError(null)\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n const handle = setTimeout(async () => {\n try {\n const data = await fetchGlobalSearchResults(query, {\n limit: 10,\n signal: controller.signal,\n })\n setResults(data.results)\n setError(data.error ?? null)\n setSelectedIndex(0)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n const abortError = err as { name?: string }\n if (abortError?.name === 'AbortError') return\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n setResults([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 220)\n\n return () => {\n clearTimeout(handle)\n controller.abort()\n }\n }, [open, query, enabledStrategies, t])\n\n const openResult = React.useCallback((result: SearchResult | undefined) => {\n if (!result) return\n const href = pickPrimaryLink(result)\n if (!href) return\n router.push(href)\n setOpen(false)\n }, [router])\n\n const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {\n if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {\n event.preventDefault()\n openResult(results[selectedIndex])\n return\n }\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSelectedIndex((prev) => (prev + 1) % Math.max(results.length || 1, 1))\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => {\n if (!results.length) return 0\n return prev <= 0 ? results.length - 1 : prev - 1\n })\n return\n }\n if (event.key === 'Escape') {\n event.preventDefault()\n setOpen(false)\n return\n }\n if (event.key === 'Enter') {\n event.preventDefault()\n const target = results[selectedIndex]\n openResult(target)\n return\n }\n }, [results, selectedIndex, openResult])\n\n React.useEffect(() => {\n const container = listRef.current\n const active = container?.querySelector<HTMLElement>('[data-active=\"true\"]')\n if (!container || !active) return\n const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect()\n const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect()\n if (activeTop < containerTop) {\n container.scrollTop -= containerTop - activeTop\n } else if (activeBottom > containerBottom) {\n container.scrollTop += activeBottom - containerBottom\n }\n }, [selectedIndex])\n\n // Check if vector search is enabled but not configured\n const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error\n\n // Check if selected result has a navigable link\n const selectedResult = results[selectedIndex]\n const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false\n\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(true)} className=\"hidden sm:inline-flex items-center gap-2\">\n <Search className=\"h-4 w-4\" />\n <span>{t('search.dialog.actions.search')}</span>\n <span className=\"ml-2 rounded border px-1 text-xs text-muted-foreground\">\u2318K</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"sm:hidden\"\n onClick={() => setOpen(true)}\n aria-label={t('search.dialog.actions.openGlobalSearch')}\n >\n <Search className=\"h-4 w-4\" />\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent className=\"max-w-xl p-0\" aria-describedby=\"global-search-description\">\n <DialogTitle className=\"sr-only\">\n {t('search.dialog.title', 'Global Search')}\n </DialogTitle>\n <span id=\"global-search-description\" className=\"sr-only\">\n {t('search.dialog.instructions')}\n </span>\n <div className=\"flex flex-col gap-3 border-b px-4 pb-3 pt-12\">\n <div className=\"flex items-center gap-2 rounded border bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring\">\n <Search className=\"h-4 w-4 text-muted-foreground\" />\n <TypedInput\n ref={inputRef}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder={t('search.dialog.input.placeholder')}\n className=\"border-none px-0 shadow-none focus-visible:ring-0\"\n autoFocus\n />\n {loading && <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />}\n </div>\n\n {error ? (\n <p className=\"rounded bg-destructive/10 px-3 py-2 text-sm text-destructive\">{error}</p>\n ) : null}\n {showVectorWarning ? (\n <p className=\"rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200\">{missingConfigMessage}</p>\n ) : null}\n {showScopeHint ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </p>\n ) : null}\n </div>\n <div ref={listRef} className=\"max-h-96 overflow-y-auto px-2 pb-3\">\n {results.length === 0 && !loading && !error ? (\n <div className=\"px-4 py-6 text-sm text-muted-foreground\">\n {query.trim().length < MIN_QUERY_LENGTH\n ? t('search.dialog.empty.hint')\n : t('search.dialog.empty.none')}\n </div>\n ) : null}\n <ul className=\"flex flex-col\">\n {results.map((result, index) => {\n const presenter = result.presenter\n const isActive = index === selectedIndex\n const hasLink = pickPrimaryLink(result) !== null\n const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null\n return (\n <li key={`${result.entityId}:${result.recordId}`} data-active={isActive}>\n <button\n type=\"button\"\n onClick={() => openResult(result)}\n onMouseEnter={() => setSelectedIndex(index)}\n className={cn(\n 'w-full rounded-lg px-4 py-3 text-left transition border',\n isActive\n ? 'border-primary bg-primary/10 text-foreground shadow-sm'\n : 'border-transparent hover:border-muted-foreground/30 hover:bg-muted/60',\n !hasLink && 'opacity-60'\n )}\n >\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className={cn('font-medium text-base whitespace-normal break-all', !hasLink && 'text-muted-foreground')}>{presenter?.title ?? result.recordId}</span>\n <span className=\"rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground\">\n {formatEntityId(result.entityId)}\n </span>\n {!hasLink && (\n <span className=\"rounded-full border border-amber-500/50 bg-amber-50 dark:bg-amber-900/20 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400\">\n {t('search.dialog.noLink')}\n </span>\n )}\n </div>\n {presenter?.subtitle ? (\n <div className=\"text-sm text-muted-foreground whitespace-normal break-words\">{presenter.subtitle}</div>\n ) : null}\n {normalizeLinks(result.links).length ? (\n <div className=\"mt-1 flex flex-wrap items-center gap-2\">\n {normalizeLinks(result.links).map((link) => (\n <span\n key={`${link.href}`}\n className={cn(\n 'rounded-full border px-2 py-0.5 text-xs',\n link.kind === 'primary'\n ? 'border-primary text-primary'\n : 'border-muted-foreground/40 text-muted-foreground'\n )}\n >\n {link.label ?? link.href}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n {Icon ? (\n <div className=\"flex flex-col items-end gap-2\">\n <Icon className=\"h-5 w-5 text-muted-foreground\" />\n </div>\n ) : null}\n </div>\n </button>\n </li>\n )\n })}\n </ul>\n </div>\n <div className=\"flex items-center justify-between border-t px-4 py-3\">\n <span className=\"text-xs text-muted-foreground\">\n {selectedResult && !selectedHasLink\n ? t('search.dialog.noLinkHint')\n : t('search.dialog.shortcuts.hint')}\n </span>\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(false)}>\n {t('search.dialog.actions.cancel')}\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={() => openResult(results[selectedIndex])}\n disabled={!results.length || !selectedHasLink}\n >\n {t('search.dialog.actions.openSelected')}\n </Button>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n\nexport default GlobalSearchDialog\nconst TypedInput = Input as React.ForwardRefExoticComponent<React.InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.4.7
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -126,9 +126,9 @@
|
|
|
126
126
|
"zod": "^4.0.0"
|
|
127
127
|
},
|
|
128
128
|
"peerDependencies": {
|
|
129
|
-
"@open-mercato/core": "0.4.7
|
|
130
|
-
"@open-mercato/queue": "0.4.7
|
|
131
|
-
"@open-mercato/shared": "0.4.7
|
|
129
|
+
"@open-mercato/core": "0.4.7",
|
|
130
|
+
"@open-mercato/queue": "0.4.7",
|
|
131
|
+
"@open-mercato/shared": "0.4.7"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@types/jest": "^30.0.0",
|
|
@@ -137,6 +137,5 @@
|
|
|
137
137
|
},
|
|
138
138
|
"publishConfig": {
|
|
139
139
|
"access": "public"
|
|
140
|
-
}
|
|
141
|
-
"stableVersion": "0.4.6"
|
|
140
|
+
}
|
|
142
141
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { extractFallbackPresenter } from '../lib/fallback-presenter'
|
|
2
|
+
|
|
3
|
+
describe('extractFallbackPresenter', () => {
|
|
4
|
+
it('prefers higher-priority title fields', () => {
|
|
5
|
+
const presenter = extractFallbackPresenter(
|
|
6
|
+
{
|
|
7
|
+
display_name: 'Display Name',
|
|
8
|
+
name: 'Name Value',
|
|
9
|
+
title: 'Title Value',
|
|
10
|
+
},
|
|
11
|
+
'customers:person',
|
|
12
|
+
'record-1',
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
expect(presenter.title).toBe('Display Name')
|
|
16
|
+
expect(presenter.badge).toBe('Person')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('builds title from first_name and last_name when no higher-priority title exists', () => {
|
|
20
|
+
const presenter = extractFallbackPresenter(
|
|
21
|
+
{
|
|
22
|
+
first_name: 'Jan',
|
|
23
|
+
last_name: 'Kowalski',
|
|
24
|
+
},
|
|
25
|
+
'customers:person',
|
|
26
|
+
'record-2',
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
expect(presenter.title).toBe('Jan Kowalski')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('builds title from firstName and lastName for camelCase records', () => {
|
|
33
|
+
const presenter = extractFallbackPresenter(
|
|
34
|
+
{
|
|
35
|
+
firstName: 'Anna',
|
|
36
|
+
lastName: 'Nowak',
|
|
37
|
+
},
|
|
38
|
+
'customers:person',
|
|
39
|
+
'record-3',
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(presenter.title).toBe('Anna Nowak')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('uses available single name part when only first_name exists', () => {
|
|
46
|
+
const presenter = extractFallbackPresenter(
|
|
47
|
+
{
|
|
48
|
+
first_name: 'Monika',
|
|
49
|
+
},
|
|
50
|
+
'customers:person',
|
|
51
|
+
'record-4',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
expect(presenter.title).toBe('Monika')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('prefers composed name from parts before email fallback', () => {
|
|
58
|
+
const presenter = extractFallbackPresenter(
|
|
59
|
+
{
|
|
60
|
+
first_name: 'Tomasz',
|
|
61
|
+
last_name: 'Brzęczyszczykiewicz',
|
|
62
|
+
email: 'tomasz@example.com',
|
|
63
|
+
},
|
|
64
|
+
'customers:person',
|
|
65
|
+
'record-5',
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
expect(presenter.title).toBe('Tomasz Brzęczyszczykiewicz')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('truncates subtitle to at most 120 characters', () => {
|
|
72
|
+
const longDescription = 'A'.repeat(90)
|
|
73
|
+
const longSummary = 'B'.repeat(90)
|
|
74
|
+
|
|
75
|
+
const presenter = extractFallbackPresenter(
|
|
76
|
+
{
|
|
77
|
+
name: 'Long Subtitle Record',
|
|
78
|
+
description: longDescription,
|
|
79
|
+
summary: longSummary,
|
|
80
|
+
},
|
|
81
|
+
'search:document',
|
|
82
|
+
'record-6',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(presenter.subtitle).toBeDefined()
|
|
86
|
+
expect((presenter.subtitle ?? '').length).toBeLessThanOrEqual(120)
|
|
87
|
+
expect(presenter.subtitle).toContain('A')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not duplicate title as subtitle part', () => {
|
|
91
|
+
const presenter = extractFallbackPresenter(
|
|
92
|
+
{
|
|
93
|
+
name: 'Acme Corp',
|
|
94
|
+
description: 'Acme Corp',
|
|
95
|
+
summary: 'Important customer',
|
|
96
|
+
},
|
|
97
|
+
'customers:company',
|
|
98
|
+
'record-7',
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(presenter.title).toBe('Acme Corp')
|
|
102
|
+
expect(presenter.subtitle).toBe('Important customer')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('falls back to entity label and short id when no string fields are available', () => {
|
|
106
|
+
const presenter = extractFallbackPresenter(
|
|
107
|
+
{
|
|
108
|
+
id: 'ignored',
|
|
109
|
+
created_at: '2026-01-01',
|
|
110
|
+
},
|
|
111
|
+
'catalog:product_variant',
|
|
112
|
+
'1234567890abcdef',
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(presenter.title).toBe('Product Variant 12345678...')
|
|
116
|
+
expect(presenter.badge).toBe('Product Variant')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('excludes technical tenant/organization/timestamp fields from generic fallback title selection', () => {
|
|
120
|
+
const presenter = extractFallbackPresenter(
|
|
121
|
+
{
|
|
122
|
+
tenant_id: 'Tenant Name Should Not Be Used',
|
|
123
|
+
organizationId: 'Org Name Should Not Be Used',
|
|
124
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
125
|
+
},
|
|
126
|
+
'customers:company',
|
|
127
|
+
'abcdef1234567890',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
expect(presenter.title).toBe('Company abcdef12...')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('does not use cf:* or cf_* values as generic fallback title', () => {
|
|
134
|
+
const presenter = extractFallbackPresenter(
|
|
135
|
+
{
|
|
136
|
+
'cf:custom_display_name': 'Custom Field Value',
|
|
137
|
+
cf_custom_name: 'Another Custom Field Value',
|
|
138
|
+
},
|
|
139
|
+
'catalog:product',
|
|
140
|
+
'fedcba9876543210',
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expect(presenter.title).toBe('Product fedcba98...')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { SearchResultPresenter } from '@open-mercato/shared/modules/search'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const TITLE_FIELDS = [
|
|
3
|
+
const TITLE_FIELDS_PRIMARY = [
|
|
5
4
|
'display_name', 'displayName',
|
|
6
5
|
'name', 'title', 'label',
|
|
7
6
|
'full_name', 'fullName',
|
|
8
7
|
'brand_name', 'brandName',
|
|
9
8
|
'legal_name', 'legalName',
|
|
10
|
-
'first_name', 'firstName',
|
|
11
|
-
'last_name', 'lastName',
|
|
12
9
|
'preferred_name', 'preferredName',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const TITLE_FIELDS_SECONDARY = [
|
|
13
13
|
'email', 'primary_email', 'primaryEmail',
|
|
14
14
|
'code', 'sku', 'reference',
|
|
15
15
|
'identifier', 'slug',
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
+
const FIRST_NAME_FIELDS = ['first_name', 'firstName']
|
|
19
|
+
const LAST_NAME_FIELDS = ['last_name', 'lastName']
|
|
20
|
+
const MAX_SUBTITLE_LENGTH = 120
|
|
21
|
+
|
|
18
22
|
// Fields to check for subtitle
|
|
19
23
|
const SUBTITLE_FIELDS = [
|
|
20
24
|
'description', 'summary', 'notes',
|
|
@@ -33,6 +37,13 @@ function findFirstValue(doc: Record<string, unknown>, fields: string[]): string
|
|
|
33
37
|
return null
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
function buildNameFromParts(doc: Record<string, unknown>): string | null {
|
|
41
|
+
const firstName = findFirstValue(doc, FIRST_NAME_FIELDS)
|
|
42
|
+
const lastName = findFirstValue(doc, LAST_NAME_FIELDS)
|
|
43
|
+
if (firstName && lastName) return `${firstName} ${lastName}`
|
|
44
|
+
return firstName ?? lastName
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
function findAnyStringValue(doc: Record<string, unknown>, excludeFields: Set<string>): string | null {
|
|
37
48
|
// Skip these fields as they're not meaningful for display
|
|
38
49
|
const skipFields = new Set([
|
|
@@ -58,6 +69,11 @@ function formatEntityLabel(entityId: string): string {
|
|
|
58
69
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
function truncateSubtitle(value: string): string {
|
|
73
|
+
if (value.length <= MAX_SUBTITLE_LENGTH) return value
|
|
74
|
+
return value.slice(0, MAX_SUBTITLE_LENGTH).trimEnd()
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
/**
|
|
62
78
|
* Extract a presenter from doc fields when no search.ts config exists.
|
|
63
79
|
*
|
|
@@ -74,21 +90,28 @@ export function extractFallbackPresenter(
|
|
|
74
90
|
): SearchResultPresenter {
|
|
75
91
|
const entityLabel = formatEntityLabel(entityId)
|
|
76
92
|
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
let title = findFirstValue(doc, TITLE_FIELDS_PRIMARY)
|
|
94
|
+
|
|
95
|
+
if (!title) {
|
|
96
|
+
title = buildNameFromParts(doc)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!title) {
|
|
100
|
+
title = findFirstValue(doc, TITLE_FIELDS_SECONDARY)
|
|
101
|
+
}
|
|
79
102
|
|
|
80
|
-
// 2. If no title found, try any string field
|
|
81
103
|
if (!title) {
|
|
82
|
-
title = findAnyStringValue(
|
|
104
|
+
title = findAnyStringValue(
|
|
105
|
+
doc,
|
|
106
|
+
new Set([...SUBTITLE_FIELDS, ...FIRST_NAME_FIELDS, ...LAST_NAME_FIELDS]),
|
|
107
|
+
)
|
|
83
108
|
}
|
|
84
109
|
|
|
85
|
-
// 3. Last resort: use entity label + truncated record ID
|
|
86
110
|
if (!title) {
|
|
87
111
|
const shortId = recordId.length > 8 ? recordId.slice(0, 8) + '...' : recordId
|
|
88
112
|
title = `${entityLabel} ${shortId}`
|
|
89
113
|
}
|
|
90
114
|
|
|
91
|
-
// Build subtitle from multiple relevant fields to show more context
|
|
92
115
|
const subtitleParts: string[] = []
|
|
93
116
|
for (const field of SUBTITLE_FIELDS) {
|
|
94
117
|
const value = doc[field]
|
|
@@ -100,7 +123,7 @@ export function extractFallbackPresenter(
|
|
|
100
123
|
|
|
101
124
|
return {
|
|
102
125
|
title,
|
|
103
|
-
subtitle: subtitleParts.length > 0 ? subtitleParts.join(' · ')
|
|
126
|
+
subtitle: subtitleParts.length > 0 ? truncateSubtitle(subtitleParts.join(' · ')) : undefined,
|
|
104
127
|
badge: entityLabel,
|
|
105
128
|
}
|
|
106
129
|
}
|
|
@@ -166,6 +166,7 @@ export function GlobalSearchDialog({
|
|
|
166
166
|
const [error, setError] = React.useState<string | null>(null)
|
|
167
167
|
const [selectedIndex, setSelectedIndex] = React.useState(0)
|
|
168
168
|
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
|
169
|
+
const listRef = React.useRef<HTMLDivElement | null>(null)
|
|
169
170
|
const abortRef = React.useRef<AbortController | null>(null)
|
|
170
171
|
const t = useT()
|
|
171
172
|
const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())
|
|
@@ -305,6 +306,19 @@ export function GlobalSearchDialog({
|
|
|
305
306
|
}
|
|
306
307
|
}, [results, selectedIndex, openResult])
|
|
307
308
|
|
|
309
|
+
React.useEffect(() => {
|
|
310
|
+
const container = listRef.current
|
|
311
|
+
const active = container?.querySelector<HTMLElement>('[data-active="true"]')
|
|
312
|
+
if (!container || !active) return
|
|
313
|
+
const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect()
|
|
314
|
+
const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect()
|
|
315
|
+
if (activeTop < containerTop) {
|
|
316
|
+
container.scrollTop -= containerTop - activeTop
|
|
317
|
+
} else if (activeBottom > containerBottom) {
|
|
318
|
+
container.scrollTop += activeBottom - containerBottom
|
|
319
|
+
}
|
|
320
|
+
}, [selectedIndex])
|
|
321
|
+
|
|
308
322
|
// Check if vector search is enabled but not configured
|
|
309
323
|
const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error
|
|
310
324
|
|
|
@@ -364,7 +378,7 @@ export function GlobalSearchDialog({
|
|
|
364
378
|
</p>
|
|
365
379
|
) : null}
|
|
366
380
|
</div>
|
|
367
|
-
<div className="max-h-96 overflow-y-auto px-2 pb-3">
|
|
381
|
+
<div ref={listRef} className="max-h-96 overflow-y-auto px-2 pb-3">
|
|
368
382
|
{results.length === 0 && !loading && !error ? (
|
|
369
383
|
<div className="px-4 py-6 text-sm text-muted-foreground">
|
|
370
384
|
{query.trim().length < MIN_QUERY_LENGTH
|
|
@@ -379,7 +393,7 @@ export function GlobalSearchDialog({
|
|
|
379
393
|
const hasLink = pickPrimaryLink(result) !== null
|
|
380
394
|
const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null
|
|
381
395
|
return (
|
|
382
|
-
<li key={`${result.entityId}:${result.recordId}`}>
|
|
396
|
+
<li key={`${result.entityId}:${result.recordId}`} data-active={isActive}>
|
|
383
397
|
<button
|
|
384
398
|
type="button"
|
|
385
399
|
onClick={() => openResult(result)}
|