@igstack/app-catalog-frontend-core 1.0.0-alpha-20260913172344 → 1.0.0

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.
Files changed (37) hide show
  1. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +1 -1
  2. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  3. package/dist/esm/modules/appCatalog/ui/components/Highlight.js +1 -1
  4. package/dist/esm/modules/appCatalog/ui/components/Highlight.js.map +1 -1
  5. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.d.ts +1 -1
  6. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js +11 -0
  7. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/pages/McpPage.d.ts +11 -0
  9. package/dist/esm/modules/appCatalog/ui/pages/McpPage.js +227 -0
  10. package/dist/esm/modules/appCatalog/ui/pages/McpPage.js.map +1 -0
  11. package/dist/esm/modules/appCatalog/utils/fetchMcpTools.d.ts +32 -0
  12. package/dist/esm/modules/appCatalog/utils/fetchMcpTools.js +79 -0
  13. package/dist/esm/modules/appCatalog/utils/fetchMcpTools.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/utils/highlightText.d.ts +15 -0
  15. package/dist/esm/modules/appCatalog/utils/highlightText.js +30 -0
  16. package/dist/esm/modules/appCatalog/utils/highlightText.js.map +1 -0
  17. package/dist/esm/routeTree.gen.d.ts +18 -3
  18. package/dist/esm/routeTree.gen.js +9 -2
  19. package/dist/esm/routeTree.gen.js.map +1 -1
  20. package/dist/esm/routes/_layout/mcp.d.ts +3 -0
  21. package/dist/esm/routes/_layout/mcp.js +20 -0
  22. package/dist/esm/routes/_layout/mcp.js.map +1 -0
  23. package/package.json +5 -5
  24. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +1 -1
  25. package/src/modules/appCatalog/ui/components/Highlight.tsx +1 -1
  26. package/src/modules/appCatalog/ui/components/ViewToggle.tsx +10 -1
  27. package/src/modules/appCatalog/ui/pages/McpPage.tsx +275 -0
  28. package/src/modules/appCatalog/utils/fetchMcpTools.ts +140 -0
  29. package/src/modules/appCatalog/utils/highlightText.ts +51 -0
  30. package/src/routeTree.gen.ts +21 -0
  31. package/src/routes/_layout/mcp.tsx +22 -0
  32. package/dist/esm/__tests__/modules/appCatalog/utils/searchApps.test.d.ts +0 -1
  33. package/dist/esm/modules/appCatalog/utils/searchApps.d.ts +0 -49
  34. package/dist/esm/modules/appCatalog/utils/searchApps.js +0 -144
  35. package/dist/esm/modules/appCatalog/utils/searchApps.js.map +0 -1
  36. package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +0 -96
  37. package/src/modules/appCatalog/utils/searchApps.ts +0 -266
@@ -1,144 +0,0 @@
1
- function searchResources(resources, searchQuery) {
2
- const normalizedQuery = searchQuery.trim().toLowerCase();
3
- const rootResources = resources.filter((r) => !r.parentSlug);
4
- if (normalizedQuery === "") {
5
- return rootResources;
6
- }
7
- const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
8
- const allTermsMatch = (text) => queryTerms.every((term) => text.includes(term));
9
- const childrenByParent = /* @__PURE__ */ new Map();
10
- for (const r of resources) {
11
- if (r.parentSlug) {
12
- const list = childrenByParent.get(r.parentSlug) ?? [];
13
- list.push(r);
14
- childrenByParent.set(r.parentSlug, list);
15
- }
16
- }
17
- const scoredApps = rootResources.map((app) => {
18
- var _a, _b, _c, _d, _e, _f, _g;
19
- const name = app.displayName.toLowerCase();
20
- const abbreviation = ((_a = app.abbreviation) == null ? void 0 : _a.toLowerCase()) || "";
21
- const nicknames = ((_b = app.nicknames) == null ? void 0 : _b.map((n) => n.toLowerCase())) || [];
22
- const description = ((_c = app.description) == null ? void 0 : _c.toLowerCase()) || "";
23
- const tags = ((_d = app.tags) == null ? void 0 : _d.join(" ").toLowerCase()) || "";
24
- const teams = ((_e = app.teams) == null ? void 0 : _e.join(" ").toLowerCase()) || "";
25
- if (abbreviation && abbreviation === normalizedQuery) {
26
- return { app, match: { field: "abbreviation", type: "exact" } };
27
- }
28
- if (name === normalizedQuery) {
29
- return { app, match: { field: "displayName", type: "exact" } };
30
- }
31
- if (nicknames.some((n) => n === normalizedQuery)) {
32
- return { app, match: { field: "nicknames", type: "exact" } };
33
- }
34
- if (abbreviation && abbreviation.startsWith(normalizedQuery)) {
35
- return { app, match: { field: "abbreviation", type: "prefix" } };
36
- }
37
- if (name.startsWith(normalizedQuery)) {
38
- return { app, match: { field: "displayName", type: "prefix" } };
39
- }
40
- if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
41
- return { app, match: { field: "nicknames", type: "prefix" } };
42
- }
43
- if ((_f = app.tags) == null ? void 0 : _f.some((tag) => tag.toLowerCase() === normalizedQuery)) {
44
- return { app, match: { field: "tags", type: "exact" } };
45
- }
46
- if ((_g = app.tags) == null ? void 0 : _g.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))) {
47
- return { app, match: { field: "tags", type: "prefix" } };
48
- }
49
- if (abbreviation && abbreviation.includes(normalizedQuery)) {
50
- return { app, match: { field: "abbreviation", type: "contains" } };
51
- }
52
- if (name.includes(normalizedQuery)) {
53
- return { app, match: { field: "displayName", type: "contains" } };
54
- }
55
- if (nicknames.some((n) => n.includes(normalizedQuery))) {
56
- return { app, match: { field: "nicknames", type: "contains" } };
57
- }
58
- if (tags.includes(normalizedQuery)) {
59
- return { app, match: { field: "tags", type: "contains" } };
60
- }
61
- if (allTermsMatch(teams)) {
62
- return { app, match: { field: "teams", type: "contains" } };
63
- }
64
- if (allTermsMatch(description)) {
65
- return { app, match: { field: "description", type: "contains" } };
66
- }
67
- const children = childrenByParent.get(app.slug);
68
- if (children) {
69
- const subMatch = children.some(
70
- (r) => allTermsMatch(r.displayName.toLowerCase()) || (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) || (r.description ? allTermsMatch(r.description.toLowerCase()) : false)
71
- );
72
- if (subMatch) {
73
- return { app, match: { field: "subResource", type: "contains" } };
74
- }
75
- }
76
- return null;
77
- }).filter((item) => item !== null);
78
- const scoreMap = /* @__PURE__ */ new Map();
79
- scoredApps.forEach(({ app, match }) => {
80
- let score = 0;
81
- if (match.type === "exact") {
82
- if (match.field === "abbreviation") score = 0;
83
- else if (match.field === "displayName") score = 1;
84
- else if (match.field === "nicknames") score = 2;
85
- else if (match.field === "tags") score = 6;
86
- else score = 999;
87
- } else if (match.type === "prefix") {
88
- if (match.field === "abbreviation") score = 3;
89
- else if (match.field === "displayName") score = 4;
90
- else if (match.field === "nicknames") score = 5;
91
- else if (match.field === "tags") score = 7;
92
- else score = 999;
93
- } else {
94
- if (match.field === "abbreviation") score = 8;
95
- else if (match.field === "displayName") score = 9;
96
- else if (match.field === "nicknames") score = 10;
97
- else if (match.field === "tags") score = 11;
98
- else if (match.field === "teams") score = 12;
99
- else if (match.field === "subResource") score = 13;
100
- else score = 14;
101
- }
102
- scoreMap.set(app.id, score);
103
- });
104
- scoredApps.sort((a, b) => {
105
- const scoreA = scoreMap.get(a.app.id) ?? 999;
106
- const scoreB = scoreMap.get(b.app.id) ?? 999;
107
- if (scoreA !== scoreB) {
108
- return scoreA - scoreB;
109
- }
110
- return a.app.displayName.localeCompare(b.app.displayName);
111
- });
112
- return scoredApps.map((item) => item.app);
113
- }
114
- function highlightText(text, query) {
115
- if (!query.trim()) {
116
- return [{ text, highlight: false }];
117
- }
118
- const normalizedQuery = query.trim().toLowerCase();
119
- const lowerText = text.toLowerCase();
120
- const index = lowerText.indexOf(normalizedQuery);
121
- if (index === -1) {
122
- return [{ text, highlight: false }];
123
- }
124
- const segments = [];
125
- if (index > 0) {
126
- segments.push({ text: text.slice(0, index), highlight: false });
127
- }
128
- segments.push({
129
- text: text.slice(index, index + normalizedQuery.length),
130
- highlight: true
131
- });
132
- if (index + normalizedQuery.length < text.length) {
133
- segments.push({
134
- text: text.slice(index + normalizedQuery.length),
135
- highlight: false
136
- });
137
- }
138
- return segments;
139
- }
140
- export {
141
- highlightText,
142
- searchResources
143
- };
144
- //# sourceMappingURL=searchApps.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"searchApps.js","sources":["../../../../../src/modules/appCatalog/utils/searchApps.ts"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\n\nexport interface SearchMatch {\n /** Field where the match occurred */\n field:\n | 'displayName'\n | 'abbreviation'\n | 'nicknames'\n | 'slug'\n | 'tags'\n | 'teams'\n | 'description'\n | 'subResource'\n /** Type of match */\n type: 'exact' | 'prefix' | 'contains'\n}\n\nexport interface SearchResult {\n app: Resource\n match: SearchMatch\n}\n\n/**\n * Search and sort resources by relevance with highlighting support.\n * Only root resources (no parentSlug) are scored and returned.\n * Child resources (with parentSlug) contribute to their parent's score.\n *\n * Priority order:\n * 0. Exact match in abbreviation\n * 1. Exact match in displayName\n * 2. Exact match in nickname\n * 3. Prefix match in abbreviation\n * 4. Prefix match in displayName\n * 5. Prefix match in nickname\n * 6. Exact match in tags\n * 7. Prefix match in tags\n * 8. Contains match in abbreviation\n * 9. Contains match in displayName\n * 10. Contains match in nickname\n * 11. Contains match in tags\n * 12. Teams\n * 13. Description\n *\n * @param resources - Array of all resources (root + children)\n * @param searchQuery - Search query string\n * @returns Filtered and sorted array of root resources\n */\nexport function searchResources(\n resources: Resource[],\n searchQuery: string,\n): Resource[] {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n // Separate root resources from children\n const rootResources = resources.filter((r) => !r.parentSlug)\n\n if (normalizedQuery === '') {\n return rootResources\n }\n\n // Split query into terms for multi-word matching (AND logic)\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n\n // Helper: all terms appear in the text (order-independent)\n const allTermsMatch = (text: string): boolean =>\n queryTerms.every((term) => text.includes(term))\n\n // Build children lookup: parentSlug -> Resource[]\n const childrenByParent = new Map<string, Resource[]>()\n for (const r of resources) {\n if (r.parentSlug) {\n const list = childrenByParent.get(r.parentSlug) ?? []\n list.push(r)\n childrenByParent.set(r.parentSlug, list)\n }\n }\n\n // Filter and score root resources\n const scoredApps = rootResources\n .map((app): SearchResult | null => {\n const name = app.displayName.toLowerCase()\n const abbreviation = app.abbreviation?.toLowerCase() || ''\n const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []\n const description = app.description?.toLowerCase() || ''\n const tags = app.tags?.join(' ').toLowerCase() || ''\n const teams = app.teams?.join(' ').toLowerCase() || ''\n\n // Check exact matches first - prioritize abbreviation over displayName\n if (abbreviation && abbreviation === normalizedQuery) {\n return { app, match: { field: 'abbreviation', type: 'exact' } }\n }\n if (name === normalizedQuery) {\n return { app, match: { field: 'displayName', type: 'exact' } }\n }\n if (nicknames.some((n) => n === normalizedQuery)) {\n return { app, match: { field: 'nicknames', type: 'exact' } }\n }\n\n // Check prefix matches\n if (abbreviation && abbreviation.startsWith(normalizedQuery)) {\n return { app, match: { field: 'abbreviation', type: 'prefix' } }\n }\n if (name.startsWith(normalizedQuery)) {\n return { app, match: { field: 'displayName', type: 'prefix' } }\n }\n if (nicknames.some((n) => n.startsWith(normalizedQuery))) {\n return { app, match: { field: 'nicknames', type: 'prefix' } }\n }\n\n // Check exact match in tags (any tag exactly matches query)\n if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {\n return { app, match: { field: 'tags', type: 'exact' } }\n }\n\n // Check tags - prefix match (any tag starts with query)\n if (\n app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))\n ) {\n return { app, match: { field: 'tags', type: 'prefix' } }\n }\n\n // Check contains matches - prioritize abbreviation over displayName\n if (abbreviation && abbreviation.includes(normalizedQuery)) {\n return { app, match: { field: 'abbreviation', type: 'contains' } }\n }\n if (name.includes(normalizedQuery)) {\n return { app, match: { field: 'displayName', type: 'contains' } }\n }\n if (nicknames.some((n) => n.includes(normalizedQuery))) {\n return { app, match: { field: 'nicknames', type: 'contains' } }\n }\n\n // Check tags - contains match\n if (tags.includes(normalizedQuery)) {\n return { app, match: { field: 'tags', type: 'contains' } }\n }\n\n // Check teams (multi-word)\n if (allTermsMatch(teams)) {\n return { app, match: { field: 'teams', type: 'contains' } }\n }\n\n // Check description (multi-word)\n if (allTermsMatch(description)) {\n return { app, match: { field: 'description', type: 'contains' } }\n }\n\n // Check child resources (name, aliases, description) — supports multi-word queries\n const children = childrenByParent.get(app.slug)\n if (children) {\n const subMatch = children.some(\n (r) =>\n allTermsMatch(r.displayName.toLowerCase()) ||\n (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||\n (r.description\n ? allTermsMatch(r.description.toLowerCase())\n : false),\n )\n if (subMatch) {\n return { app, match: { field: 'subResource', type: 'contains' } }\n }\n }\n\n // No match found\n return null\n })\n .filter((item): item is SearchResult => item !== null)\n\n // Calculate numeric scores for sorting\n const scoreMap = new Map<string, number>()\n scoredApps.forEach(({ app, match }) => {\n let score = 0\n\n // Exact matches: 0-2 (abbreviation, displayName, nicknames)\n if (match.type === 'exact') {\n if (match.field === 'abbreviation') score = 0\n else if (match.field === 'displayName') score = 1\n else if (match.field === 'nicknames') score = 2\n else if (match.field === 'tags') score = 6\n else score = 999\n }\n // Prefix matches: 3-5 (abbreviation, displayName, nicknames), 7 (tags)\n else if (match.type === 'prefix') {\n if (match.field === 'abbreviation') score = 3\n else if (match.field === 'displayName') score = 4\n else if (match.field === 'nicknames') score = 5\n else if (match.field === 'tags') score = 7\n else score = 999\n }\n // Contains matches\n else {\n if (match.field === 'abbreviation') score = 8\n else if (match.field === 'displayName') score = 9\n else if (match.field === 'nicknames') score = 10\n else if (match.field === 'tags') score = 11\n else if (match.field === 'teams') score = 12\n else if (match.field === 'subResource') score = 13\n else score = 14 // description\n }\n\n scoreMap.set(app.id, score)\n })\n\n // Sort by score (ascending - lower score = higher priority)\n scoredApps.sort((a, b) => {\n const scoreA = scoreMap.get(a.app.id) ?? 999\n const scoreB = scoreMap.get(b.app.id) ?? 999\n\n if (scoreA !== scoreB) {\n return scoreA - scoreB\n }\n // If same score, sort alphabetically by display name\n return a.app.displayName.localeCompare(b.app.displayName)\n })\n\n return scoredApps.map((item) => item.app)\n}\n\n/**\n * Highlight matching text in a string\n * @param text - Text to highlight\n * @param query - Search query\n * @returns Array of text segments with highlight flags\n */\nexport function highlightText(\n text: string,\n query: string,\n): { text: string; highlight: boolean }[] {\n if (!query.trim()) {\n return [{ text, highlight: false }]\n }\n\n const normalizedQuery = query.trim().toLowerCase()\n const lowerText = text.toLowerCase()\n const index = lowerText.indexOf(normalizedQuery)\n\n if (index === -1) {\n return [{ text, highlight: false }]\n }\n\n const segments: { text: string; highlight: boolean }[] = []\n\n // Text before match\n if (index > 0) {\n segments.push({ text: text.slice(0, index), highlight: false })\n }\n\n // Matched text\n segments.push({\n text: text.slice(index, index + normalizedQuery.length),\n highlight: true,\n })\n\n // Text after match\n if (index + normalizedQuery.length < text.length) {\n segments.push({\n text: text.slice(index + normalizedQuery.length),\n highlight: false,\n })\n }\n\n return segments\n}\n\n/** @deprecated Use searchResources instead */\nexport const searchApps = searchResources\n"],"names":[],"mappings":"AA+CO,SAAS,gBACd,WACA,aACY;AACZ,QAAM,kBAAkB,YAAY,KAAA,EAAO,YAAA;AAG3C,QAAM,gBAAgB,UAAU,OAAO,CAAC,MAAM,CAAC,EAAE,UAAU;AAE3D,MAAI,oBAAoB,IAAI;AAC1B,WAAO;AAAA,EACT;AAGA,QAAM,aAAa,gBAAgB,MAAM,KAAK,EAAE,OAAO,OAAO;AAG9D,QAAM,gBAAgB,CAAC,SACrB,WAAW,MAAM,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC;AAGhD,QAAM,uCAAuB,IAAA;AAC7B,aAAW,KAAK,WAAW;AACzB,QAAI,EAAE,YAAY;AAChB,YAAM,OAAO,iBAAiB,IAAI,EAAE,UAAU,KAAK,CAAA;AACnD,WAAK,KAAK,CAAC;AACX,uBAAiB,IAAI,EAAE,YAAY,IAAI;AAAA,IACzC;AAAA,EACF;AAGA,QAAM,aAAa,cAChB,IAAI,CAAC,QAA6B;AAhChC;AAiCD,UAAM,OAAO,IAAI,YAAY,YAAA;AAC7B,UAAM,iBAAe,SAAI,iBAAJ,mBAAkB,kBAAiB;AACxD,UAAM,cAAY,SAAI,cAAJ,mBAAe,IAAI,CAAC,MAAM,EAAE,YAAA,OAAkB,CAAA;AAChE,UAAM,gBAAc,SAAI,gBAAJ,mBAAiB,kBAAiB;AACtD,UAAM,SAAO,SAAI,SAAJ,mBAAU,KAAK,KAAK,kBAAiB;AAClD,UAAM,UAAQ,SAAI,UAAJ,mBAAW,KAAK,KAAK,kBAAiB;AAGpD,QAAI,gBAAgB,iBAAiB,iBAAiB;AACpD,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,gBAAgB,MAAM,UAAQ;AAAA,IAC9D;AACA,QAAI,SAAS,iBAAiB;AAC5B,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,eAAe,MAAM,UAAQ;AAAA,IAC7D;AACA,QAAI,UAAU,KAAK,CAAC,MAAM,MAAM,eAAe,GAAG;AAChD,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,aAAa,MAAM,UAAQ;AAAA,IAC3D;AAGA,QAAI,gBAAgB,aAAa,WAAW,eAAe,GAAG;AAC5D,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,gBAAgB,MAAM,WAAS;AAAA,IAC/D;AACA,QAAI,KAAK,WAAW,eAAe,GAAG;AACpC,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,eAAe,MAAM,WAAS;AAAA,IAC9D;AACA,QAAI,UAAU,KAAK,CAAC,MAAM,EAAE,WAAW,eAAe,CAAC,GAAG;AACxD,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,aAAa,MAAM,WAAS;AAAA,IAC5D;AAGA,SAAI,SAAI,SAAJ,mBAAU,KAAK,CAAC,QAAQ,IAAI,kBAAkB,kBAAkB;AAClE,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,QAAQ,MAAM,UAAQ;AAAA,IACtD;AAGA,SACE,SAAI,SAAJ,mBAAU,KAAK,CAAC,QAAQ,IAAI,cAAc,WAAW,eAAe,IACpE;AACA,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,QAAQ,MAAM,WAAS;AAAA,IACvD;AAGA,QAAI,gBAAgB,aAAa,SAAS,eAAe,GAAG;AAC1D,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,gBAAgB,MAAM,aAAW;AAAA,IACjE;AACA,QAAI,KAAK,SAAS,eAAe,GAAG;AAClC,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,eAAe,MAAM,aAAW;AAAA,IAChE;AACA,QAAI,UAAU,KAAK,CAAC,MAAM,EAAE,SAAS,eAAe,CAAC,GAAG;AACtD,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,aAAa,MAAM,aAAW;AAAA,IAC9D;AAGA,QAAI,KAAK,SAAS,eAAe,GAAG;AAClC,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,QAAQ,MAAM,aAAW;AAAA,IACzD;AAGA,QAAI,cAAc,KAAK,GAAG;AACxB,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,SAAS,MAAM,aAAW;AAAA,IAC1D;AAGA,QAAI,cAAc,WAAW,GAAG;AAC9B,aAAO,EAAE,KAAK,OAAO,EAAE,OAAO,eAAe,MAAM,aAAW;AAAA,IAChE;AAGA,UAAM,WAAW,iBAAiB,IAAI,IAAI,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,WAAW,SAAS;AAAA,QACxB,CAAC,MACC,cAAc,EAAE,YAAY,YAAA,CAAa,MACxC,EAAE,WAAW,CAAA,GAAI,KAAK,CAAC,MAAM,cAAc,EAAE,aAAa,CAAC,MAC3D,EAAE,cACC,cAAc,EAAE,YAAY,YAAA,CAAa,IACzC;AAAA,MAAA;AAER,UAAI,UAAU;AACZ,eAAO,EAAE,KAAK,OAAO,EAAE,OAAO,eAAe,MAAM,aAAW;AAAA,MAChE;AAAA,IACF;AAGA,WAAO;AAAA,EACT,CAAC,EACA,OAAO,CAAC,SAA+B,SAAS,IAAI;AAGvD,QAAM,+BAAe,IAAA;AACrB,aAAW,QAAQ,CAAC,EAAE,KAAK,YAAY;AACrC,QAAI,QAAQ;AAGZ,QAAI,MAAM,SAAS,SAAS;AAC1B,UAAI,MAAM,UAAU,eAAgB,SAAQ;AAAA,eACnC,MAAM,UAAU,cAAe,SAAQ;AAAA,eACvC,MAAM,UAAU,YAAa,SAAQ;AAAA,eACrC,MAAM,UAAU,OAAQ,SAAQ;AAAA,UACpC,SAAQ;AAAA,IACf,WAES,MAAM,SAAS,UAAU;AAChC,UAAI,MAAM,UAAU,eAAgB,SAAQ;AAAA,eACnC,MAAM,UAAU,cAAe,SAAQ;AAAA,eACvC,MAAM,UAAU,YAAa,SAAQ;AAAA,eACrC,MAAM,UAAU,OAAQ,SAAQ;AAAA,UACpC,SAAQ;AAAA,IACf,OAEK;AACH,UAAI,MAAM,UAAU,eAAgB,SAAQ;AAAA,eACnC,MAAM,UAAU,cAAe,SAAQ;AAAA,eACvC,MAAM,UAAU,YAAa,SAAQ;AAAA,eACrC,MAAM,UAAU,OAAQ,SAAQ;AAAA,eAChC,MAAM,UAAU,QAAS,SAAQ;AAAA,eACjC,MAAM,UAAU,cAAe,SAAQ;AAAA,UAC3C,SAAQ;AAAA,IACf;AAEA,aAAS,IAAI,IAAI,IAAI,KAAK;AAAA,EAC5B,CAAC;AAGD,aAAW,KAAK,CAAC,GAAG,MAAM;AACxB,UAAM,SAAS,SAAS,IAAI,EAAE,IAAI,EAAE,KAAK;AACzC,UAAM,SAAS,SAAS,IAAI,EAAE,IAAI,EAAE,KAAK;AAEzC,QAAI,WAAW,QAAQ;AACrB,aAAO,SAAS;AAAA,IAClB;AAEA,WAAO,EAAE,IAAI,YAAY,cAAc,EAAE,IAAI,WAAW;AAAA,EAC1D,CAAC;AAED,SAAO,WAAW,IAAI,CAAC,SAAS,KAAK,GAAG;AAC1C;AAQO,SAAS,cACd,MACA,OACwC;AACxC,MAAI,CAAC,MAAM,QAAQ;AACjB,WAAO,CAAC,EAAE,MAAM,WAAW,OAAO;AAAA,EACpC;AAEA,QAAM,kBAAkB,MAAM,KAAA,EAAO,YAAA;AACrC,QAAM,YAAY,KAAK,YAAA;AACvB,QAAM,QAAQ,UAAU,QAAQ,eAAe;AAE/C,MAAI,UAAU,IAAI;AAChB,WAAO,CAAC,EAAE,MAAM,WAAW,OAAO;AAAA,EACpC;AAEA,QAAM,WAAmD,CAAA;AAGzD,MAAI,QAAQ,GAAG;AACb,aAAS,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,KAAK,GAAG,WAAW,OAAO;AAAA,EAChE;AAGA,WAAS,KAAK;AAAA,IACZ,MAAM,KAAK,MAAM,OAAO,QAAQ,gBAAgB,MAAM;AAAA,IACtD,WAAW;AAAA,EAAA,CACZ;AAGD,MAAI,QAAQ,gBAAgB,SAAS,KAAK,QAAQ;AAChD,aAAS,KAAK;AAAA,MACZ,MAAM,KAAK,MAAM,QAAQ,gBAAgB,MAAM;AAAA,MAC/C,WAAW;AAAA,IAAA,CACZ;AAAA,EACH;AAEA,SAAO;AACT;"}
@@ -1,96 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import type { Resource } from '@igstack/app-catalog-backend-core'
3
- import { searchResources } from '~/modules/appCatalog/utils/searchApps'
4
-
5
- function makeApp(overrides: Partial<Resource> & { slug: string }): Resource {
6
- return {
7
- id: overrides.slug,
8
- displayName: overrides.slug,
9
- ...overrides,
10
- }
11
- }
12
-
13
- function makeChildResource(
14
- overrides: Partial<Resource> & { slug: string; parentSlug: string },
15
- ): Resource {
16
- return {
17
- id: overrides.slug,
18
- displayName: overrides.slug,
19
- aliases: [],
20
- approverSlugs: [],
21
- ...overrides,
22
- }
23
- }
24
-
25
- describe('searchResources', () => {
26
- const apps: Resource[] = [
27
- makeApp({
28
- slug: 'taskflow',
29
- displayName: 'TaskFlow',
30
- description: 'Issue tracker',
31
- }),
32
- makeApp({
33
- slug: 'aws-console',
34
- displayName: 'AWS Console',
35
- description: 'Cloud management',
36
- }),
37
- makeApp({
38
- slug: 'teamchat',
39
- displayName: 'TeamChat',
40
- description: 'Messaging',
41
- }),
42
- ]
43
-
44
- it('returns all root apps when query is empty', () => {
45
- expect(searchResources(apps, '')).toHaveLength(3)
46
- })
47
-
48
- it('finds app by displayName', () => {
49
- const results = searchResources(apps, 'taskflow')
50
- expect(results).toHaveLength(1)
51
- expect(results[0]!.slug).toBe('taskflow')
52
- })
53
-
54
- describe('child resource search', () => {
55
- const childResources: Resource[] = [
56
- makeChildResource({
57
- slug: 'aws-acme-pipelines-dev',
58
- displayName: 'acme-pipelines-data-analytics-dev',
59
- parentSlug: 'aws-console',
60
- aliases: ['000000000001'],
61
- }),
62
- makeChildResource({
63
- slug: 'aws-acme-infosec-dev',
64
- displayName: 'acme-infosec-dev',
65
- parentSlug: 'aws-console',
66
- aliases: [],
67
- }),
68
- ]
69
-
70
- const allResources = [...apps, ...childResources]
71
-
72
- it('finds app by child resource displayName', () => {
73
- const results = searchResources(allResources, 'pipelines data-analytics')
74
- expect(results).toHaveLength(1)
75
- expect(results[0]!.slug).toBe('aws-console')
76
- })
77
-
78
- it('finds app by child resource alias (account ID)', () => {
79
- const results = searchResources(allResources, '000000000001')
80
- expect(results).toHaveLength(1)
81
- expect(results[0]!.slug).toBe('aws-console')
82
- })
83
-
84
- it('does not match child resources when none provided', () => {
85
- const results = searchResources(apps, '000000000001')
86
- expect(results).toHaveLength(0)
87
- })
88
-
89
- it('direct app match ranks higher than child resource match', () => {
90
- const results = searchResources(allResources, 'aws')
91
- // 'aws-console' matches by displayName, should appear
92
- expect(results.length).toBeGreaterThanOrEqual(1)
93
- expect(results[0]!.slug).toBe('aws-console')
94
- })
95
- })
96
- })
@@ -1,266 +0,0 @@
1
- import type { Resource } from '@igstack/app-catalog-backend-core'
2
-
3
- export interface SearchMatch {
4
- /** Field where the match occurred */
5
- field:
6
- | 'displayName'
7
- | 'abbreviation'
8
- | 'nicknames'
9
- | 'slug'
10
- | 'tags'
11
- | 'teams'
12
- | 'description'
13
- | 'subResource'
14
- /** Type of match */
15
- type: 'exact' | 'prefix' | 'contains'
16
- }
17
-
18
- export interface SearchResult {
19
- app: Resource
20
- match: SearchMatch
21
- }
22
-
23
- /**
24
- * Search and sort resources by relevance with highlighting support.
25
- * Only root resources (no parentSlug) are scored and returned.
26
- * Child resources (with parentSlug) contribute to their parent's score.
27
- *
28
- * Priority order:
29
- * 0. Exact match in abbreviation
30
- * 1. Exact match in displayName
31
- * 2. Exact match in nickname
32
- * 3. Prefix match in abbreviation
33
- * 4. Prefix match in displayName
34
- * 5. Prefix match in nickname
35
- * 6. Exact match in tags
36
- * 7. Prefix match in tags
37
- * 8. Contains match in abbreviation
38
- * 9. Contains match in displayName
39
- * 10. Contains match in nickname
40
- * 11. Contains match in tags
41
- * 12. Teams
42
- * 13. Description
43
- *
44
- * @param resources - Array of all resources (root + children)
45
- * @param searchQuery - Search query string
46
- * @returns Filtered and sorted array of root resources
47
- */
48
- export function searchResources(
49
- resources: Resource[],
50
- searchQuery: string,
51
- ): Resource[] {
52
- const normalizedQuery = searchQuery.trim().toLowerCase()
53
-
54
- // Separate root resources from children
55
- const rootResources = resources.filter((r) => !r.parentSlug)
56
-
57
- if (normalizedQuery === '') {
58
- return rootResources
59
- }
60
-
61
- // Split query into terms for multi-word matching (AND logic)
62
- const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
63
-
64
- // Helper: all terms appear in the text (order-independent)
65
- const allTermsMatch = (text: string): boolean =>
66
- queryTerms.every((term) => text.includes(term))
67
-
68
- // Build children lookup: parentSlug -> Resource[]
69
- const childrenByParent = new Map<string, Resource[]>()
70
- for (const r of resources) {
71
- if (r.parentSlug) {
72
- const list = childrenByParent.get(r.parentSlug) ?? []
73
- list.push(r)
74
- childrenByParent.set(r.parentSlug, list)
75
- }
76
- }
77
-
78
- // Filter and score root resources
79
- const scoredApps = rootResources
80
- .map((app): SearchResult | null => {
81
- const name = app.displayName.toLowerCase()
82
- const abbreviation = app.abbreviation?.toLowerCase() || ''
83
- const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []
84
- const description = app.description?.toLowerCase() || ''
85
- const tags = app.tags?.join(' ').toLowerCase() || ''
86
- const teams = app.teams?.join(' ').toLowerCase() || ''
87
-
88
- // Check exact matches first - prioritize abbreviation over displayName
89
- if (abbreviation && abbreviation === normalizedQuery) {
90
- return { app, match: { field: 'abbreviation', type: 'exact' } }
91
- }
92
- if (name === normalizedQuery) {
93
- return { app, match: { field: 'displayName', type: 'exact' } }
94
- }
95
- if (nicknames.some((n) => n === normalizedQuery)) {
96
- return { app, match: { field: 'nicknames', type: 'exact' } }
97
- }
98
-
99
- // Check prefix matches
100
- if (abbreviation && abbreviation.startsWith(normalizedQuery)) {
101
- return { app, match: { field: 'abbreviation', type: 'prefix' } }
102
- }
103
- if (name.startsWith(normalizedQuery)) {
104
- return { app, match: { field: 'displayName', type: 'prefix' } }
105
- }
106
- if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
107
- return { app, match: { field: 'nicknames', type: 'prefix' } }
108
- }
109
-
110
- // Check exact match in tags (any tag exactly matches query)
111
- if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {
112
- return { app, match: { field: 'tags', type: 'exact' } }
113
- }
114
-
115
- // Check tags - prefix match (any tag starts with query)
116
- if (
117
- app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))
118
- ) {
119
- return { app, match: { field: 'tags', type: 'prefix' } }
120
- }
121
-
122
- // Check contains matches - prioritize abbreviation over displayName
123
- if (abbreviation && abbreviation.includes(normalizedQuery)) {
124
- return { app, match: { field: 'abbreviation', type: 'contains' } }
125
- }
126
- if (name.includes(normalizedQuery)) {
127
- return { app, match: { field: 'displayName', type: 'contains' } }
128
- }
129
- if (nicknames.some((n) => n.includes(normalizedQuery))) {
130
- return { app, match: { field: 'nicknames', type: 'contains' } }
131
- }
132
-
133
- // Check tags - contains match
134
- if (tags.includes(normalizedQuery)) {
135
- return { app, match: { field: 'tags', type: 'contains' } }
136
- }
137
-
138
- // Check teams (multi-word)
139
- if (allTermsMatch(teams)) {
140
- return { app, match: { field: 'teams', type: 'contains' } }
141
- }
142
-
143
- // Check description (multi-word)
144
- if (allTermsMatch(description)) {
145
- return { app, match: { field: 'description', type: 'contains' } }
146
- }
147
-
148
- // Check child resources (name, aliases, description) — supports multi-word queries
149
- const children = childrenByParent.get(app.slug)
150
- if (children) {
151
- const subMatch = children.some(
152
- (r) =>
153
- allTermsMatch(r.displayName.toLowerCase()) ||
154
- (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||
155
- (r.description
156
- ? allTermsMatch(r.description.toLowerCase())
157
- : false),
158
- )
159
- if (subMatch) {
160
- return { app, match: { field: 'subResource', type: 'contains' } }
161
- }
162
- }
163
-
164
- // No match found
165
- return null
166
- })
167
- .filter((item): item is SearchResult => item !== null)
168
-
169
- // Calculate numeric scores for sorting
170
- const scoreMap = new Map<string, number>()
171
- scoredApps.forEach(({ app, match }) => {
172
- let score = 0
173
-
174
- // Exact matches: 0-2 (abbreviation, displayName, nicknames)
175
- if (match.type === 'exact') {
176
- if (match.field === 'abbreviation') score = 0
177
- else if (match.field === 'displayName') score = 1
178
- else if (match.field === 'nicknames') score = 2
179
- else if (match.field === 'tags') score = 6
180
- else score = 999
181
- }
182
- // Prefix matches: 3-5 (abbreviation, displayName, nicknames), 7 (tags)
183
- else if (match.type === 'prefix') {
184
- if (match.field === 'abbreviation') score = 3
185
- else if (match.field === 'displayName') score = 4
186
- else if (match.field === 'nicknames') score = 5
187
- else if (match.field === 'tags') score = 7
188
- else score = 999
189
- }
190
- // Contains matches
191
- else {
192
- if (match.field === 'abbreviation') score = 8
193
- else if (match.field === 'displayName') score = 9
194
- else if (match.field === 'nicknames') score = 10
195
- else if (match.field === 'tags') score = 11
196
- else if (match.field === 'teams') score = 12
197
- else if (match.field === 'subResource') score = 13
198
- else score = 14 // description
199
- }
200
-
201
- scoreMap.set(app.id, score)
202
- })
203
-
204
- // Sort by score (ascending - lower score = higher priority)
205
- scoredApps.sort((a, b) => {
206
- const scoreA = scoreMap.get(a.app.id) ?? 999
207
- const scoreB = scoreMap.get(b.app.id) ?? 999
208
-
209
- if (scoreA !== scoreB) {
210
- return scoreA - scoreB
211
- }
212
- // If same score, sort alphabetically by display name
213
- return a.app.displayName.localeCompare(b.app.displayName)
214
- })
215
-
216
- return scoredApps.map((item) => item.app)
217
- }
218
-
219
- /**
220
- * Highlight matching text in a string
221
- * @param text - Text to highlight
222
- * @param query - Search query
223
- * @returns Array of text segments with highlight flags
224
- */
225
- export function highlightText(
226
- text: string,
227
- query: string,
228
- ): { text: string; highlight: boolean }[] {
229
- if (!query.trim()) {
230
- return [{ text, highlight: false }]
231
- }
232
-
233
- const normalizedQuery = query.trim().toLowerCase()
234
- const lowerText = text.toLowerCase()
235
- const index = lowerText.indexOf(normalizedQuery)
236
-
237
- if (index === -1) {
238
- return [{ text, highlight: false }]
239
- }
240
-
241
- const segments: { text: string; highlight: boolean }[] = []
242
-
243
- // Text before match
244
- if (index > 0) {
245
- segments.push({ text: text.slice(0, index), highlight: false })
246
- }
247
-
248
- // Matched text
249
- segments.push({
250
- text: text.slice(index, index + normalizedQuery.length),
251
- highlight: true,
252
- })
253
-
254
- // Text after match
255
- if (index + normalizedQuery.length < text.length) {
256
- segments.push({
257
- text: text.slice(index + normalizedQuery.length),
258
- highlight: false,
259
- })
260
- }
261
-
262
- return segments
263
- }
264
-
265
- /** @deprecated Use searchResources instead */
266
- export const searchApps = searchResources