@open-mercato/search 0.6.7-develop.6785.1.1dd7cfac55 → 0.6.7-develop.6795.1.8a3f27921c
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/lib/presenter-enricher.js +12 -12
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js +2 -9
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +2 -2
- package/dist/modules/search/frontend/components/HybridSearchTable.js +2 -11
- package/dist/modules/search/frontend/components/HybridSearchTable.js.map +2 -2
- package/dist/modules/search/frontend/components/TopbarSearchInline.js +2 -9
- package/dist/modules/search/frontend/components/TopbarSearchInline.js.map +2 -2
- package/dist/modules/search/frontend/lib/entityTypeLabel.js +19 -0
- package/dist/modules/search/frontend/lib/entityTypeLabel.js.map +7 -0
- package/dist/modules/search/i18n/de.json +47 -0
- package/dist/modules/search/i18n/en.json +47 -0
- package/dist/modules/search/i18n/es.json +47 -0
- package/dist/modules/search/i18n/pl.json +47 -0
- package/dist/service.js +2 -5
- package/dist/service.js.map +2 -2
- package/package.json +9 -9
- package/src/__tests__/presenter-enricher.test.ts +48 -0
- package/src/__tests__/presenter-locale.test.ts +76 -0
- package/src/lib/presenter-enricher.ts +14 -14
- package/src/modules/search/api/__tests__/global-search.routes.test.ts +167 -0
- package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +2 -15
- package/src/modules/search/frontend/components/HybridSearchTable.tsx +2 -17
- package/src/modules/search/frontend/components/TopbarSearchInline.tsx +2 -15
- package/src/modules/search/frontend/lib/__tests__/entityTypeLabel.test.ts +25 -0
- package/src/modules/search/frontend/lib/entityTypeLabel.ts +22 -0
- package/src/modules/search/i18n/de.json +47 -0
- package/src/modules/search/i18n/en.json +47 -0
- package/src/modules/search/i18n/es.json +47 -0
- package/src/modules/search/i18n/pl.json +47 -0
- package/src/service.ts +2 -7
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:search] found
|
|
1
|
+
[build:search] found 97 entry points
|
|
2
2
|
[build:search] built successfully
|
package/AGENTS.md
CHANGED
|
@@ -542,6 +542,7 @@ curl "https://your-app.com/api/search?q=john%20doe&limit=20" \
|
|
|
542
542
|
| `OM_SEARCH_HASH_ALGO` | When choosing the token hash algorithm | Default: `sha256` (accepts `sha1`, `md5`). **Token strategy only** |
|
|
543
543
|
| `OM_SEARCH_STORE_RAW_TOKENS` | Almost never — debugging tokenization only | Default: `false`. Stores plaintext token alongside the hash — **security-sensitive**, retains plaintext of otherwise-hashed values. **Token strategy only** |
|
|
544
544
|
| `OM_SEARCH_FIELD_BLOCKLIST` | When extra fields must never be tokenized | Comma-separated field-name substrings, merged with built-in `password,token,secret,hash`; prefix an entry with `entityType@` to scope it to one entity. Applies to per-field tokens and aggregate `search_text`; reindex affected entities after changes. **Token strategy only** |
|
|
545
|
+
| `OM_SEARCH_TOKEN_PRESENCE_CACHE_MS` | When tuning the query engines' "does this entity have search tokens?" probe | Default: `30000` (ms); `0` disables. Process-level TTL cache in `@open-mercato/shared/lib/search/availability` — bounds how long a token purge or first-index is invisible to like/ilike routing. **Token strategy only** |
|
|
545
546
|
| `SEARCH_EXCLUDE_ENCRYPTED_FIELDS` | When you need to keep encrypted fields out of fulltext | Set to `true` to exclude encrypted fields from fulltext index |
|
|
546
547
|
| `OM_LOG_LEVEL` | When debugging presenter enrichment | Set to `debug` to surface presenter enricher diagnostics (replaces the former `DEBUG_SEARCH_ENRICHER` flag) |
|
|
547
548
|
|
|
@@ -66,20 +66,20 @@ async function computePresenterAndLinks(doc, entityId, recordId, config, tenantI
|
|
|
66
66
|
queryEngine
|
|
67
67
|
};
|
|
68
68
|
if (config?.formatResult || config?.buildSource) {
|
|
69
|
-
if (config.
|
|
69
|
+
if (config.formatResult) {
|
|
70
70
|
try {
|
|
71
|
-
|
|
72
|
-
if (source?.presenter) presenter = source.presenter;
|
|
73
|
-
if (source?.links) links = source.links;
|
|
71
|
+
presenter = await config.formatResult(buildContext) ?? null;
|
|
74
72
|
} catch (err) {
|
|
75
|
-
logWarning("
|
|
73
|
+
logWarning("formatResult failed", { entityId, recordId, err });
|
|
76
74
|
}
|
|
77
75
|
}
|
|
78
|
-
if (!presenter && config.
|
|
76
|
+
if (!presenter && config.buildSource) {
|
|
79
77
|
try {
|
|
80
|
-
|
|
78
|
+
const source = await config.buildSource(buildContext);
|
|
79
|
+
if (source?.presenter) presenter = source.presenter;
|
|
80
|
+
if (source?.links) links = source.links;
|
|
81
81
|
} catch (err) {
|
|
82
|
-
logWarning("
|
|
82
|
+
logWarning("buildSource failed", { entityId, recordId, err });
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
}
|
|
@@ -102,7 +102,8 @@ async function computePresenterAndLinks(doc, entityId, recordId, config, tenantI
|
|
|
102
102
|
}
|
|
103
103
|
function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService) {
|
|
104
104
|
return async (results, tenantId, organizationId) => {
|
|
105
|
-
const
|
|
105
|
+
const shouldEnrich = (result) => needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId);
|
|
106
|
+
const missingResults = results.filter(shouldEnrich);
|
|
106
107
|
if (missingResults.length === 0) return results;
|
|
107
108
|
const byEntityType = /* @__PURE__ */ new Map();
|
|
108
109
|
for (const result of missingResults) {
|
|
@@ -161,16 +162,15 @@ function createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionSer
|
|
|
161
162
|
enrichmentMap.set(key, { presenter, url, links });
|
|
162
163
|
}
|
|
163
164
|
return results.map((result) => {
|
|
164
|
-
if (!
|
|
165
|
+
if (!shouldEnrich(result)) return result;
|
|
165
166
|
const key = `${result.entityId}:${result.recordId}`;
|
|
166
167
|
const enriched = enrichmentMap.get(key);
|
|
167
168
|
if (!enriched) return result;
|
|
168
|
-
const hasExistingLinks = Array.isArray(result.links) && result.links.length > 0;
|
|
169
169
|
return {
|
|
170
170
|
...result,
|
|
171
171
|
presenter: enriched.presenter ?? result.presenter,
|
|
172
172
|
url: result.url ?? enriched.url,
|
|
173
|
-
links:
|
|
173
|
+
links: enriched.links ?? result.links
|
|
174
174
|
};
|
|
175
175
|
});
|
|
176
176
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/presenter-enricher.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { extractFallbackPresenter } from './fallback-presenter'\nimport { needsSearchResultEnrichment } from './search-result-enrichment'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */\nconst enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n enricherLogger.debug(message, context)\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n db: Kysely<any>,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where('tenant_id' as any, '=', tenantId)\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n // If search.ts config exists, use formatResult/buildSource for presenter\n if (config?.formatResult || config?.buildSource) {\n if (config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning('buildSource failed', { entityId, recordId, err })\n }\n }\n\n if (!presenter && config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning('formatResult failed', { entityId, recordId, err })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(doc, entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n db: Kysely<any>,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n // Find results missing presenter OR with encrypted presenter\n const missingResults = results.filter(needsSearchResultEnrichment)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // Single batch query for all docs across all entity types\n const rawDocs = await fetchDocsBatch(db, byEntityType, tenantId, organizationId)\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning('Failed to decrypt doc', { entityId: row.entity_type, recordId: row.entity_id, err })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning('Doc not found in entity_indexes', { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n return results.map((result) => {\n if (!needsSearchResultEnrichment(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n const hasExistingLinks = Array.isArray(result.links) && result.links.length > 0\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: hasExistingLinks ? result.links : (enriched.links ?? result.links),\n }\n })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAYA,SAAS,gCAAgC;AACzC,SAAS,oBAAoB;AAC7B,SAAS,gCAAgC;AACzC,SAAS,mCAAmC;AAG5C,MAAM,aAAa;AAGnB,MAAM,iBAAiB,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,qBAAqB,CAAC;AACvF,MAAM,aAAa,CAAC,SAAiB,YAAsC;AACzE,iBAAe,MAAM,SAAS,OAAO;AACvC;AAKA,SAAS,MAAS,OAAY,MAAqB;AACjD,QAAM,SAAgB,CAAC;AACvB,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,MAAM;AAC3C,WAAO,KAAK,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC;AAAA,EACtC;AACA,SAAO;AACT;AAMA,eAAe,eACb,IACA,cACA,UACA,gBAC0F;AAC1F,QAAM,UAA2F,CAAC;AAGlG,QAAM,WAA4D,CAAC;AACnE,aAAW,CAAC,YAAY,OAAO,KAAK,cAAc;AAChD,eAAW,UAAU,SAAS;AAC5B,eAAS,KAAK,EAAE,YAAY,UAAU,OAAO,SAAS,CAAC;AAAA,IACzD;AAAA,EACF;AAEA,MAAI,SAAS,WAAW,EAAG,QAAO;AAGlC,QAAM,SAAS,MAAM,UAAU,UAAU;AAEzC,aAAW,aAAa,QAAQ;AAE9B,UAAM,cAAc,oBAAI,IAAsB;AAC9C,eAAW,EAAE,YAAY,SAAS,KAAK,WAAW;AAChD,YAAM,MAAM,YAAY,IAAI,UAAU,KAAK,CAAC;AAC5C,UAAI,KAAK,QAAQ;AACjB,kBAAY,IAAI,YAAY,GAAG;AAAA,IACjC;AAGA,QAAI,QAAQ,GACT,WAAW,gBAAuB,EAClC,OAAO,CAAC,eAAsB,aAAoB,KAAY,CAAC,EAC/D,MAAM,aAAoB,KAAK,QAAQ,EACvC,MAAM,cAAqB,MAAM,IAAI,EACrC,MAAM,CAAC,OAAY,GAAG;AAAA,MACrB,MAAM,KAAK,YAAY,QAAQ,CAAC,EAAE,IAAI,CAAC,CAAC,YAAY,SAAS,MAAM,GAAG,IAAI;AAAA,QACxE,GAAG,eAAsB,KAAK,UAAU;AAAA,QACxC,GAAG,aAAoB,MAAM,SAAS;AAAA,MACxC,CAAC,CAAC;AAAA,IACJ,CAAC;AAGH,QAAI,gBAAgB;AAClB,cAAQ,MAAM,MAAM,CAAC,OAAY,GAAG,GAAG;AAAA,QACrC,GAAG,mBAA0B,KAAK,cAAc;AAAA,QAChD,GAAG,mBAA0B,MAAM,IAAI;AAAA,MACzC,CAAC,CAAC;AAAA,IACJ;AAEA,UAAM,OAAO,MAAM,MAAM,QAAQ;AACjC,YAAQ,KAAK,GAAG,IAAI;AAAA,EACtB;AAEA,SAAO;AACT;AAaA,eAAe,yBACb,KACA,UACA,UACA,QACA,UACA,gBACA,aAC2B;AAC3B,MAAI,YAA0C;AAC9C,MAAI;AACJ,MAAI;AAGJ,QAAM,eAAwC,CAAC;AAC/C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,GAAG,GAAG;AAC9C,QAAI,IAAI,WAAW,KAAK,KAAK,IAAI,WAAW,KAAK,GAAG;AAClD,mBAAa,IAAI,MAAM,CAAC,CAAC,IAAI;AAAA,IAC/B;AAAA,EACF;AAEA,QAAM,eAAmC;AAAA,IACvC,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;
|
|
4
|
+
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { extractFallbackPresenter } from './fallback-presenter'\nimport { needsSearchResultEnrichment } from './search-result-enrichment'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Diagnostic logger - surfaces issues without breaking flow, gated by OM_LOG_LEVEL=debug */\nconst enricherLogger = createLogger('search').child({ component: 'presenter-enricher' })\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n enricherLogger.debug(message, context)\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n db: Kysely<any>,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n let query = db\n .selectFrom('entity_indexes' as any)\n .select(['entity_type' as any, 'entity_id' as any, 'doc' as any])\n .where('tenant_id' as any, '=', tenantId)\n .where('deleted_at' as any, 'is', null)\n .where((eb: any) => eb.or(\n Array.from(chunkByType.entries()).map(([entityType, recordIds]) => eb.and([\n eb('entity_type' as any, '=', entityType),\n eb('entity_id' as any, 'in', recordIds),\n ])),\n ))\n\n // Add organization filter if provided\n if (organizationId) {\n query = query.where((eb: any) => eb.or([\n eb('organization_id' as any, '=', organizationId),\n eb('organization_id' as any, 'is', null),\n ]))\n }\n\n const rows = await query.execute() as Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>\n allDocs.push(...rows)\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n if (config?.formatResult || config?.buildSource) {\n if (config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning('formatResult failed', { entityId, recordId, err })\n }\n }\n\n if (!presenter && config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning('buildSource failed', { entityId, recordId, err })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(doc, entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n db: Kysely<any>,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n const shouldEnrich = (result: SearchResult): boolean =>\n needsSearchResultEnrichment(result) || entityConfigMap.has(result.entityId as EntityId)\n\n const missingResults = results.filter(shouldEnrich)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // Single batch query for all docs across all entity types\n const rawDocs = await fetchDocsBatch(db, byEntityType, tenantId, organizationId)\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning('Failed to decrypt doc', { entityId: row.entity_type, recordId: row.entity_id, err })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning('Doc not found in entity_indexes', { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n return results.map((result) => {\n if (!shouldEnrich(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: enriched.links ?? result.links,\n }\n })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -47,6 +47,7 @@ import { Input } from "@open-mercato/ui/primitives/input";
|
|
|
47
47
|
import { Button } from "@open-mercato/ui/primitives/button";
|
|
48
48
|
import { cn } from "@open-mercato/shared/lib/utils";
|
|
49
49
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
50
|
+
import { resolveEntityTypeLabel } from "../lib/entityTypeLabel.js";
|
|
50
51
|
import {
|
|
51
52
|
getCurrentOrganizationScope,
|
|
52
53
|
subscribeOrganizationScopeChanged
|
|
@@ -77,9 +78,6 @@ function hasActiveOrganizationSelection() {
|
|
|
77
78
|
if (!cookieValue) return false;
|
|
78
79
|
return !isAllOrganizationsSelection(cookieValue);
|
|
79
80
|
}
|
|
80
|
-
function humanizeSegment(segment) {
|
|
81
|
-
return segment.split(/[_-]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
82
|
-
}
|
|
83
81
|
const ICON_MAP = {
|
|
84
82
|
bolt: Zap,
|
|
85
83
|
zap: Zap,
|
|
@@ -123,11 +121,6 @@ function resolveIcon(name) {
|
|
|
123
121
|
if (!name) return null;
|
|
124
122
|
return ICON_MAP[name.toLowerCase()] ?? null;
|
|
125
123
|
}
|
|
126
|
-
function formatEntityId(entityId) {
|
|
127
|
-
if (!entityId.includes(":")) return humanizeSegment(entityId);
|
|
128
|
-
const [module, entity] = entityId.split(":");
|
|
129
|
-
return `${humanizeSegment(module)} \xB7 ${humanizeSegment(entity)}`;
|
|
130
|
-
}
|
|
131
124
|
function GlobalSearchDialog({
|
|
132
125
|
embeddingConfigured,
|
|
133
126
|
missingConfigMessage,
|
|
@@ -359,7 +352,7 @@ function GlobalSearchDialog({
|
|
|
359
352
|
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
|
|
360
353
|
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
361
354
|
/* @__PURE__ */ jsx("span", { className: cn("font-medium text-base whitespace-normal break-all", !hasLink && "text-muted-foreground"), children: presenter?.title ?? result.recordId }),
|
|
362
|
-
/* @__PURE__ */ jsx("span", { className: "rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground", children:
|
|
355
|
+
/* @__PURE__ */ jsx("span", { className: "rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground", children: resolveEntityTypeLabel(t, result.entityId) }),
|
|
363
356
|
!hasLink && /* @__PURE__ */ jsx("span", { className: "rounded-full border border-status-warning-border bg-status-warning-bg px-2 py-0.5 text-xs text-status-warning-text", children: t("search.dialog.noLink") })
|
|
364
357
|
] }),
|
|
365
358
|
presenter?.subtitle ? /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground whitespace-normal break-words", children: presenter.subtitle }) : null,
|
|
@@ -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 { ForbiddenError } from '@open-mercato/ui/backend/utils/api'\nimport { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = resolveSearchMinTokenLength()\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 if (err instanceof ForbiddenError) {\n setError(t('search.dialog.errors.noPermission'))\n } else {\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n }\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\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setOpen(true)}\n className=\"hidden sm:inline-flex items-center gap-2 text-foreground\"\n >\n <Search className=\"h-4 w-4 text-foreground\" />\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 text-foreground\" />\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 border-border bg-background px-3 py-2 transition-colors focus-within:border-primary\">\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 focus-visible:ring-offset-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-status-warning-bg px-3 py-2 text-sm text-status-warning-text\">{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', { count: MIN_QUERY_LENGTH })\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/50',\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-status-warning-border bg-status-warning-bg px-2 py-0.5 text-xs text-status-warning-text\">\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-accent-indigo text-foreground'\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 { resolveEntityTypeLabel } from '../lib/entityTypeLabel'\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 { ForbiddenError } from '@open-mercato/ui/backend/utils/api'\nimport { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = resolveSearchMinTokenLength()\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\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\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 if (err instanceof ForbiddenError) {\n setError(t('search.dialog.errors.noPermission'))\n } else {\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n }\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\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setOpen(true)}\n className=\"hidden sm:inline-flex items-center gap-2 text-foreground\"\n >\n <Search className=\"h-4 w-4 text-foreground\" />\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 text-foreground\" />\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 border-border bg-background px-3 py-2 transition-colors focus-within:border-primary\">\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 focus-visible:ring-offset-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-status-warning-bg px-3 py-2 text-sm text-status-warning-text\">{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', { count: MIN_QUERY_LENGTH })\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/50',\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 {resolveEntityTypeLabel(t, result.entityId)}\n </span>\n {!hasLink && (\n <span className=\"rounded-full border border-status-warning-border bg-status-warning-bg px-2 py-0.5 text-xs text-status-warning-text\">\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-accent-indigo text-foreground'\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
|
}
|
|
@@ -8,6 +8,7 @@ import { RowActions } from "@open-mercato/ui/backend/RowActions";
|
|
|
8
8
|
import { cn } from "@open-mercato/shared/lib/utils";
|
|
9
9
|
import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
10
10
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
11
|
+
import { resolveEntityTypeLabel } from "../lib/entityTypeLabel.js";
|
|
11
12
|
import {
|
|
12
13
|
getCurrentOrganizationScope,
|
|
13
14
|
subscribeOrganizationScopeChanged
|
|
@@ -36,7 +37,7 @@ function createColumns(t) {
|
|
|
36
37
|
const title = resolveRowTitle(item);
|
|
37
38
|
const iconName = item.presenter?.icon;
|
|
38
39
|
const Icon = iconName ? resolveIcon(iconName) : null;
|
|
39
|
-
const typeLabel =
|
|
40
|
+
const typeLabel = resolveEntityTypeLabel(t, item.entityId);
|
|
40
41
|
const snapshot = item.presenter?.subtitle ?? extractSnapshot(item.metadata);
|
|
41
42
|
const links = normalizeLinks(item.links);
|
|
42
43
|
return /* @__PURE__ */ jsx("div", { className: "flex flex-col", children: /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3", children: [
|
|
@@ -109,16 +110,6 @@ function resolveIcon(name) {
|
|
|
109
110
|
}
|
|
110
111
|
return null;
|
|
111
112
|
}
|
|
112
|
-
function humanizeSegment(segment) {
|
|
113
|
-
return segment.split(/[_-]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
114
|
-
}
|
|
115
|
-
function formatEntityId(entityId) {
|
|
116
|
-
if (!entityId.includes(":")) return humanizeSegment(entityId);
|
|
117
|
-
const [module, entity] = entityId.split(":");
|
|
118
|
-
const moduleLabel = humanizeSegment(module);
|
|
119
|
-
const entityLabel = humanizeSegment(entity);
|
|
120
|
-
return `${moduleLabel} \xB7 ${entityLabel}`;
|
|
121
|
-
}
|
|
122
113
|
function resolveRowTitle(row) {
|
|
123
114
|
const presenterTitle = row.presenter?.title;
|
|
124
115
|
if (typeof presenterTitle === "string") {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/search/frontend/components/HybridSearchTable.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport * as LucideIcons from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport type { SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\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 { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config'\nimport { fetchHybridSearchResults } from '../utils'\n\ntype Row = {\n entityId: string\n recordId: string\n source: string\n score: number | null\n url: string | null\n presenter: SearchResult['presenter'] | null\n links: SearchResult['links'] | null\n metadata: Record<string, unknown> | null\n}\n\nconst MIN_QUERY_LENGTH = resolveSearchMinTokenLength()\nconst ALL_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\ntype Translator = (\n key: string,\n fallbackOrParams?: string | Record<string, string | number>,\n params?: Record<string, string | number>\n) => string\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 createColumns(t: Translator): ColumnDef<Row>[] {\n return [\n {\n id: 'title',\n header: () => t('search.table.columns.result', 'Result'),\n cell: ({ row }) => {\n const item = row.original\n const title = resolveRowTitle(item)\n const iconName = item.presenter?.icon\n const Icon = iconName ? resolveIcon(iconName) : null\n const typeLabel = formatEntityId(item.entityId)\n const snapshot = item.presenter?.subtitle ?? extractSnapshot(item.metadata)\n const links = normalizeLinks(item.links)\n return (\n <div className=\"flex flex-col\">\n <div className=\"flex items-start gap-3\">\n {Icon ? <Icon className=\"mt-0.5 h-5 w-5 text-muted-foreground\" /> : null}\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"font-medium whitespace-normal break-all\">{title}</span>\n <span className=\"rounded border border-muted-foreground/40 px-2 py-0.5 text-xs text-muted-foreground\">\n {typeLabel}\n </span>\n </div>\n {snapshot ? (\n <span className=\"text-sm text-muted-foreground whitespace-normal break-words\">{snapshot}</span>\n ) : null}\n {links.length ? (\n <div className=\"mt-1 flex flex-wrap items-center gap-2\">\n {links.map((link) => (\n <span\n key={`${item.entityId}:${item.recordId}:${link.href}`}\n className={cn(\n 'rounded-full border px-2 py-0.5 text-xs',\n link.kind === 'primary'\n ? 'border-accent-indigo text-foreground'\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 </div>\n </div>\n )\n },\n meta: { priority: 1 },\n },\n {\n id: 'source',\n header: () => t('search.table.columns.source', 'Source'),\n cell: ({ row }) => {\n const source = row.original.source\n const colorClass = getStrategyColorClass(source)\n return (\n <span className={cn('rounded px-2 py-0.5 text-xs font-medium', colorClass)}>\n {source}\n </span>\n )\n },\n meta: { priority: 2 },\n },\n {\n id: 'score',\n header: () => t('search.table.columns.score', 'Score'),\n cell: ({ row }) => <span>{row.original.score != null ? row.original.score.toFixed(2) : '\u2014'}</span>,\n meta: { priority: 2 },\n },\n ]\n}\n\nfunction getStrategyColorClass(strategy: string): string {\n switch (strategy) {\n case 'fulltext':\n return 'bg-status-info-bg text-status-info-text'\n case 'vector':\n return 'bg-brand-violet/10 text-brand-violet'\n case 'tokens':\n return 'bg-status-success-bg text-status-success-text'\n default:\n return 'bg-status-neutral-bg text-status-neutral-text'\n }\n}\n\nfunction normalizeLinks(links?: Row['links']): { href: string; label?: string; kind?: string }[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string') as Array<{ href: string; label?: string; kind?: string }>\n}\n\nfunction toPascalCase(input: string): string {\n return input\n .split(/[-_ ]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('')\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n const key = toPascalCase(name)\n const candidate = (LucideIcons as Record<string, unknown>)[key]\n if (typeof candidate === 'function') {\n return candidate as LucideIcon\n }\n return null\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\nfunction formatEntityId(entityId: string): string {\n if (!entityId.includes(':')) return humanizeSegment(entityId)\n const [module, entity] = entityId.split(':')\n const moduleLabel = humanizeSegment(module)\n const entityLabel = humanizeSegment(entity)\n return `${moduleLabel} \u00B7 ${entityLabel}`\n}\n\nfunction resolveRowTitle(row: Row): string {\n const presenterTitle = row.presenter?.title\n if (typeof presenterTitle === 'string') {\n const trimmed = presenterTitle.trim()\n if (trimmed.length) return trimmed\n }\n return row.recordId\n}\n\nfunction extractSnapshot(metadata: Record<string, unknown> | null): string | null {\n if (!metadata) return null\n const candidateKeys = ['snapshot', 'summary', 'description', 'body', 'content', 'note']\n for (const key of candidateKeys) {\n const value = metadata[key]\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (trimmed.length) return trimmed\n }\n }\n return null\n}\n\nfunction pickPrimaryLink(row: Row): string | null {\n if (row.url) return row.url\n const links = normalizeLinks(row.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 normalizeErrorMessage(input: unknown, fallback?: string): string | null {\n const fallbackMessage = typeof fallback === 'string' && fallback.trim().length ? fallback.trim() : null\n let message: string | null = null\n if (typeof input === 'string') {\n message = input\n } else if (input instanceof Error && typeof input.message === 'string') {\n message = input.message\n }\n if (message) {\n const trimmed = message.trim()\n if (trimmed.length) {\n const sanitized = trimmed.replace(/^\\[[^\\]]+\\]\\s*/, '').trim()\n if (sanitized.length) return sanitized\n }\n }\n return fallbackMessage\n}\n\ntype HybridSearchTableProps = {\n /** Show strategy selector checkboxes (default: false - hidden from regular users) */\n showStrategySelector?: boolean\n /** Show source column in results (default: false - hidden from regular users) */\n showSourceColumn?: boolean\n}\n\nexport function HybridSearchTable({\n showStrategySelector = false,\n showSourceColumn = false,\n}: HybridSearchTableProps = {}) {\n const router = useRouter()\n const t = useT()\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n const [searchValue, setSearchValue] = React.useState('')\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [timing, setTiming] = React.useState<number | null>(null)\n const [strategiesUsed, setStrategiesUsed] = React.useState<string[]>([])\n const [enabledStrategies, setEnabledStrategies] = React.useState<Set<SearchStrategyId>>(\n new Set(ALL_STRATEGIES)\n )\n const debounceRef = React.useRef<number | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n const columns = React.useMemo(() => {\n const allColumns = createColumns(t)\n if (!showSourceColumn) {\n return allColumns.filter((col) => col.id !== 'source')\n }\n return allColumns\n }, [t, showSourceColumn])\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 const toggleStrategy = React.useCallback((strategy: SearchStrategyId) => {\n setEnabledStrategies((prev) => {\n const next = new Set(prev)\n if (next.has(strategy)) {\n next.delete(strategy)\n } else {\n next.add(strategy)\n }\n return next\n })\n }, [])\n\n const openRow = React.useCallback(\n (row: Row) => {\n const href = pickPrimaryLink(row)\n if (!href) return\n router.push(href)\n },\n [router]\n )\n\n React.useEffect(() => {\n const trimmed = searchValue.trim()\n abortRef.current?.abort()\n if (debounceRef.current) {\n window.clearTimeout(debounceRef.current)\n debounceRef.current = null\n }\n\n if (trimmed.length < MIN_QUERY_LENGTH) {\n setRows([])\n setTiming(null)\n setStrategiesUsed([])\n setError(null)\n setLoading(false)\n return\n }\n\n if (enabledStrategies.size === 0) {\n setRows([])\n setTiming(null)\n setStrategiesUsed([])\n setError(t('search.table.errors.noSources', 'Select at least one search source'))\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n debounceRef.current = window.setTimeout(async () => {\n try {\n const data = await fetchHybridSearchResults(trimmed, {\n limit: 50,\n strategies: Array.from(enabledStrategies),\n signal: controller.signal,\n })\n const mapped = data.results.map<Row>((item) => ({\n entityId: item.entityId,\n recordId: item.recordId,\n source: item.source,\n score: typeof item.score === 'number' ? item.score : null,\n url: item.url ?? null,\n presenter: item.presenter ?? null,\n links: item.links ?? null,\n metadata: (item.metadata as Record<string, unknown> | null) ?? null,\n }))\n setRows(mapped)\n setTiming(data.timing)\n setStrategiesUsed(data.strategiesUsed)\n const message = data.error ? normalizeErrorMessage(data.error, t('search.table.errors.searchFailed', 'Search failed')) : null\n setError(message ?? null)\n setPage(1)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n if ((err as { name?: string })?.name === 'AbortError') return\n setError(normalizeErrorMessage(err, t('search.table.errors.searchFailed', 'Search failed')))\n setRows([])\n setTiming(null)\n setStrategiesUsed([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 250)\n\n return () => {\n controller.abort()\n if (debounceRef.current) window.clearTimeout(debounceRef.current)\n }\n }, [searchValue, enabledStrategies, t])\n\n React.useEffect(() => {\n if (!error) return\n flash(error, 'error')\n }, [error])\n\n return (\n <div className=\"flex w-full flex-col gap-4\">\n {/* Source Selector - only shown when showStrategySelector is true */}\n {showStrategySelector && (\n <div className=\"flex flex-wrap items-center gap-4 rounded-lg border bg-muted/50 p-3\">\n <span className=\"text-sm font-medium text-muted-foreground\">\n {t('search.table.sources', 'Sources:')}\n </span>\n {ALL_STRATEGIES.map((strategy) => (\n <label key={strategy} className=\"flex cursor-pointer items-center gap-2\">\n <input\n type=\"checkbox\"\n className=\"size-4 rounded border-input\"\n checked={enabledStrategies.has(strategy)}\n onChange={() => toggleStrategy(strategy)}\n />\n <span\n className={cn(\n 'rounded px-2 py-0.5 text-xs font-medium',\n getStrategyColorClass(strategy)\n )}\n >\n {strategy}\n </span>\n </label>\n ))}\n </div>\n )}\n\n {/* Stats Bar */}\n {timing !== null && rows.length > 0 && (\n <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\n <span>{rows.length} {t('search.table.stats.results', 'results')}</span>\n <span>{timing}ms</span>\n {/* Only show sources when strategy selector is visible */}\n {showStrategySelector && strategiesUsed.length > 0 && (\n <span>\n {t('search.table.stats.sources', 'Sources:')} {strategiesUsed.join(', ')}\n </span>\n )}\n </div>\n )}\n\n {/* Error Alert */}\n {error ? (\n <div\n role=\"alert\"\n className=\"w-full rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive\"\n >\n {error}\n </div>\n ) : null}\n\n {showScopeHint ? (\n <div className=\"text-xs text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </div>\n ) : null}\n\n {/* Data Table */}\n <DataTable<Row>\n title={t('search.table.title', 'Search')}\n columns={columns}\n data={rows}\n searchValue={searchValue}\n onSearchChange={(value) => {\n setSearchValue(value)\n setPage(1)\n }}\n searchPlaceholder={t('search.table.searchPlaceholder', 'Search across all strategies...')}\n isLoading={loading}\n pagination={{ page, pageSize: rows.length || 1, total: rows.length, totalPages: 1, onPageChange: setPage }}\n onRowClick={(row) => openRow(row)}\n rowActions={(row) => {\n const primaryHref = pickPrimaryLink(row)\n if (!primaryHref) return null\n return <RowActions items={[{ id: 'open', label: t('search.table.actions.open', 'Open'), href: primaryHref }]} />\n }}\n embedded\n />\n </div>\n )\n}\n\nexport default HybridSearchTable\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport * as LucideIcons from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport type { SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { resolveEntityTypeLabel } from '../lib/entityTypeLabel'\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 { resolveSearchMinTokenLength } from '@open-mercato/shared/lib/search/config'\nimport { fetchHybridSearchResults } from '../utils'\n\ntype Row = {\n entityId: string\n recordId: string\n source: string\n score: number | null\n url: string | null\n presenter: SearchResult['presenter'] | null\n links: SearchResult['links'] | null\n metadata: Record<string, unknown> | null\n}\n\nconst MIN_QUERY_LENGTH = resolveSearchMinTokenLength()\nconst ALL_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\ntype Translator = (\n key: string,\n fallbackOrParams?: string | Record<string, string | number>,\n params?: Record<string, string | number>\n) => string\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 createColumns(t: Translator): ColumnDef<Row>[] {\n return [\n {\n id: 'title',\n header: () => t('search.table.columns.result', 'Result'),\n cell: ({ row }) => {\n const item = row.original\n const title = resolveRowTitle(item)\n const iconName = item.presenter?.icon\n const Icon = iconName ? resolveIcon(iconName) : null\n const typeLabel = resolveEntityTypeLabel(t, item.entityId)\n const snapshot = item.presenter?.subtitle ?? extractSnapshot(item.metadata)\n const links = normalizeLinks(item.links)\n return (\n <div className=\"flex flex-col\">\n <div className=\"flex items-start gap-3\">\n {Icon ? <Icon className=\"mt-0.5 h-5 w-5 text-muted-foreground\" /> : null}\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"font-medium whitespace-normal break-all\">{title}</span>\n <span className=\"rounded border border-muted-foreground/40 px-2 py-0.5 text-xs text-muted-foreground\">\n {typeLabel}\n </span>\n </div>\n {snapshot ? (\n <span className=\"text-sm text-muted-foreground whitespace-normal break-words\">{snapshot}</span>\n ) : null}\n {links.length ? (\n <div className=\"mt-1 flex flex-wrap items-center gap-2\">\n {links.map((link) => (\n <span\n key={`${item.entityId}:${item.recordId}:${link.href}`}\n className={cn(\n 'rounded-full border px-2 py-0.5 text-xs',\n link.kind === 'primary'\n ? 'border-accent-indigo text-foreground'\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 </div>\n </div>\n )\n },\n meta: { priority: 1 },\n },\n {\n id: 'source',\n header: () => t('search.table.columns.source', 'Source'),\n cell: ({ row }) => {\n const source = row.original.source\n const colorClass = getStrategyColorClass(source)\n return (\n <span className={cn('rounded px-2 py-0.5 text-xs font-medium', colorClass)}>\n {source}\n </span>\n )\n },\n meta: { priority: 2 },\n },\n {\n id: 'score',\n header: () => t('search.table.columns.score', 'Score'),\n cell: ({ row }) => <span>{row.original.score != null ? row.original.score.toFixed(2) : '\u2014'}</span>,\n meta: { priority: 2 },\n },\n ]\n}\n\nfunction getStrategyColorClass(strategy: string): string {\n switch (strategy) {\n case 'fulltext':\n return 'bg-status-info-bg text-status-info-text'\n case 'vector':\n return 'bg-brand-violet/10 text-brand-violet'\n case 'tokens':\n return 'bg-status-success-bg text-status-success-text'\n default:\n return 'bg-status-neutral-bg text-status-neutral-text'\n }\n}\n\nfunction normalizeLinks(links?: Row['links']): { href: string; label?: string; kind?: string }[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string') as Array<{ href: string; label?: string; kind?: string }>\n}\n\nfunction toPascalCase(input: string): string {\n return input\n .split(/[-_ ]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('')\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n const key = toPascalCase(name)\n const candidate = (LucideIcons as Record<string, unknown>)[key]\n if (typeof candidate === 'function') {\n return candidate as LucideIcon\n }\n return null\n}\n\nfunction resolveRowTitle(row: Row): string {\n const presenterTitle = row.presenter?.title\n if (typeof presenterTitle === 'string') {\n const trimmed = presenterTitle.trim()\n if (trimmed.length) return trimmed\n }\n return row.recordId\n}\n\nfunction extractSnapshot(metadata: Record<string, unknown> | null): string | null {\n if (!metadata) return null\n const candidateKeys = ['snapshot', 'summary', 'description', 'body', 'content', 'note']\n for (const key of candidateKeys) {\n const value = metadata[key]\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (trimmed.length) return trimmed\n }\n }\n return null\n}\n\nfunction pickPrimaryLink(row: Row): string | null {\n if (row.url) return row.url\n const links = normalizeLinks(row.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 normalizeErrorMessage(input: unknown, fallback?: string): string | null {\n const fallbackMessage = typeof fallback === 'string' && fallback.trim().length ? fallback.trim() : null\n let message: string | null = null\n if (typeof input === 'string') {\n message = input\n } else if (input instanceof Error && typeof input.message === 'string') {\n message = input.message\n }\n if (message) {\n const trimmed = message.trim()\n if (trimmed.length) {\n const sanitized = trimmed.replace(/^\\[[^\\]]+\\]\\s*/, '').trim()\n if (sanitized.length) return sanitized\n }\n }\n return fallbackMessage\n}\n\ntype HybridSearchTableProps = {\n /** Show strategy selector checkboxes (default: false - hidden from regular users) */\n showStrategySelector?: boolean\n /** Show source column in results (default: false - hidden from regular users) */\n showSourceColumn?: boolean\n}\n\nexport function HybridSearchTable({\n showStrategySelector = false,\n showSourceColumn = false,\n}: HybridSearchTableProps = {}) {\n const router = useRouter()\n const t = useT()\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n const [searchValue, setSearchValue] = React.useState('')\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [timing, setTiming] = React.useState<number | null>(null)\n const [strategiesUsed, setStrategiesUsed] = React.useState<string[]>([])\n const [enabledStrategies, setEnabledStrategies] = React.useState<Set<SearchStrategyId>>(\n new Set(ALL_STRATEGIES)\n )\n const debounceRef = React.useRef<number | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n const columns = React.useMemo(() => {\n const allColumns = createColumns(t)\n if (!showSourceColumn) {\n return allColumns.filter((col) => col.id !== 'source')\n }\n return allColumns\n }, [t, showSourceColumn])\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 const toggleStrategy = React.useCallback((strategy: SearchStrategyId) => {\n setEnabledStrategies((prev) => {\n const next = new Set(prev)\n if (next.has(strategy)) {\n next.delete(strategy)\n } else {\n next.add(strategy)\n }\n return next\n })\n }, [])\n\n const openRow = React.useCallback(\n (row: Row) => {\n const href = pickPrimaryLink(row)\n if (!href) return\n router.push(href)\n },\n [router]\n )\n\n React.useEffect(() => {\n const trimmed = searchValue.trim()\n abortRef.current?.abort()\n if (debounceRef.current) {\n window.clearTimeout(debounceRef.current)\n debounceRef.current = null\n }\n\n if (trimmed.length < MIN_QUERY_LENGTH) {\n setRows([])\n setTiming(null)\n setStrategiesUsed([])\n setError(null)\n setLoading(false)\n return\n }\n\n if (enabledStrategies.size === 0) {\n setRows([])\n setTiming(null)\n setStrategiesUsed([])\n setError(t('search.table.errors.noSources', 'Select at least one search source'))\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n debounceRef.current = window.setTimeout(async () => {\n try {\n const data = await fetchHybridSearchResults(trimmed, {\n limit: 50,\n strategies: Array.from(enabledStrategies),\n signal: controller.signal,\n })\n const mapped = data.results.map<Row>((item) => ({\n entityId: item.entityId,\n recordId: item.recordId,\n source: item.source,\n score: typeof item.score === 'number' ? item.score : null,\n url: item.url ?? null,\n presenter: item.presenter ?? null,\n links: item.links ?? null,\n metadata: (item.metadata as Record<string, unknown> | null) ?? null,\n }))\n setRows(mapped)\n setTiming(data.timing)\n setStrategiesUsed(data.strategiesUsed)\n const message = data.error ? normalizeErrorMessage(data.error, t('search.table.errors.searchFailed', 'Search failed')) : null\n setError(message ?? null)\n setPage(1)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n if ((err as { name?: string })?.name === 'AbortError') return\n setError(normalizeErrorMessage(err, t('search.table.errors.searchFailed', 'Search failed')))\n setRows([])\n setTiming(null)\n setStrategiesUsed([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 250)\n\n return () => {\n controller.abort()\n if (debounceRef.current) window.clearTimeout(debounceRef.current)\n }\n }, [searchValue, enabledStrategies, t])\n\n React.useEffect(() => {\n if (!error) return\n flash(error, 'error')\n }, [error])\n\n return (\n <div className=\"flex w-full flex-col gap-4\">\n {/* Source Selector - only shown when showStrategySelector is true */}\n {showStrategySelector && (\n <div className=\"flex flex-wrap items-center gap-4 rounded-lg border bg-muted/50 p-3\">\n <span className=\"text-sm font-medium text-muted-foreground\">\n {t('search.table.sources', 'Sources:')}\n </span>\n {ALL_STRATEGIES.map((strategy) => (\n <label key={strategy} className=\"flex cursor-pointer items-center gap-2\">\n <input\n type=\"checkbox\"\n className=\"size-4 rounded border-input\"\n checked={enabledStrategies.has(strategy)}\n onChange={() => toggleStrategy(strategy)}\n />\n <span\n className={cn(\n 'rounded px-2 py-0.5 text-xs font-medium',\n getStrategyColorClass(strategy)\n )}\n >\n {strategy}\n </span>\n </label>\n ))}\n </div>\n )}\n\n {/* Stats Bar */}\n {timing !== null && rows.length > 0 && (\n <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\n <span>{rows.length} {t('search.table.stats.results', 'results')}</span>\n <span>{timing}ms</span>\n {/* Only show sources when strategy selector is visible */}\n {showStrategySelector && strategiesUsed.length > 0 && (\n <span>\n {t('search.table.stats.sources', 'Sources:')} {strategiesUsed.join(', ')}\n </span>\n )}\n </div>\n )}\n\n {/* Error Alert */}\n {error ? (\n <div\n role=\"alert\"\n className=\"w-full rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive\"\n >\n {error}\n </div>\n ) : null}\n\n {showScopeHint ? (\n <div className=\"text-xs text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </div>\n ) : null}\n\n {/* Data Table */}\n <DataTable<Row>\n title={t('search.table.title', 'Search')}\n columns={columns}\n data={rows}\n searchValue={searchValue}\n onSearchChange={(value) => {\n setSearchValue(value)\n setPage(1)\n }}\n searchPlaceholder={t('search.table.searchPlaceholder', 'Search across all strategies...')}\n isLoading={loading}\n pagination={{ page, pageSize: rows.length || 1, total: rows.length, totalPages: 1, onPageChange: setPage }}\n onRowClick={(row) => openRow(row)}\n rowActions={(row) => {\n const primaryHref = pickPrimaryLink(row)\n if (!primaryHref) return null\n return <RowActions items={[{ id: 'open', label: t('search.table.actions.open', 'Open'), href: primaryHref }]} />\n }}\n embedded\n />\n </div>\n )\n}\n\nexport default HybridSearchTable\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
} from "lucide-react";
|
|
48
48
|
import { cn } from "@open-mercato/shared/lib/utils";
|
|
49
49
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
50
|
+
import { resolveEntityTypeLabel } from "../lib/entityTypeLabel.js";
|
|
50
51
|
import {
|
|
51
52
|
getCurrentOrganizationScope,
|
|
52
53
|
subscribeOrganizationScopeChanged
|
|
@@ -77,9 +78,6 @@ function hasActiveOrganizationSelection() {
|
|
|
77
78
|
if (!cookieValue) return false;
|
|
78
79
|
return !isAllOrganizationsSelection(cookieValue);
|
|
79
80
|
}
|
|
80
|
-
function humanizeSegment(segment) {
|
|
81
|
-
return segment.split(/[_-]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
82
|
-
}
|
|
83
81
|
const ICON_MAP = {
|
|
84
82
|
bolt: Zap,
|
|
85
83
|
zap: Zap,
|
|
@@ -123,11 +121,6 @@ function resolveIcon(name) {
|
|
|
123
121
|
if (!name) return null;
|
|
124
122
|
return ICON_MAP[name.toLowerCase()] ?? null;
|
|
125
123
|
}
|
|
126
|
-
function formatEntityId(entityId) {
|
|
127
|
-
if (!entityId.includes(":")) return humanizeSegment(entityId);
|
|
128
|
-
const [module, entity] = entityId.split(":");
|
|
129
|
-
return `${humanizeSegment(module)} \xB7 ${humanizeSegment(entity)}`;
|
|
130
|
-
}
|
|
131
124
|
function TopbarSearchInline({
|
|
132
125
|
embeddingConfigured,
|
|
133
126
|
missingConfigMessage
|
|
@@ -450,7 +443,7 @@ function TopbarSearchInline({
|
|
|
450
443
|
}
|
|
451
444
|
) }),
|
|
452
445
|
/* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 text-overline text-muted-foreground", children: [
|
|
453
|
-
/* @__PURE__ */ jsx("span", { className: "truncate", children:
|
|
446
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: resolveEntityTypeLabel(t, result.entityId) }),
|
|
454
447
|
presenter?.subtitle ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
455
448
|
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }),
|
|
456
449
|
/* @__PURE__ */ jsx("span", { className: "truncate", children: presenter.subtitle })
|