@igstack/app-catalog-frontend-core 0.3.1-alpha-20260403020019 → 0.3.1-alpha-20260405015231
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/dist/esm/__tests__/integration/mock-backend/MockBackendConfigurer.d.ts +4 -1
- package/dist/esm/__tests__/integration/mock-backend/MockDb.d.ts +3 -1
- package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +9 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +4 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/searchApps.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +4 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +6 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +24 -6
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +7 -57
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +5 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +68 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +148 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +156 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +47 -8
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +4 -3
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +4 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +15 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -0
- package/dist/esm/modules/appCatalog/utils/searchApps.d.ts +3 -3
- package/dist/esm/modules/appCatalog/utils/searchApps.js +24 -4
- package/dist/esm/modules/appCatalog/utils/searchApps.js.map +1 -1
- package/dist/esm/ui/select.js +138 -0
- package/dist/esm/ui/select.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/integration/appCatalog.integration.test.ts +40 -1
- package/src/__tests__/integration/harness/given.tsx +1 -1
- package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +15 -0
- package/src/__tests__/integration/mock-backend/MockDb.ts +12 -0
- package/src/__tests__/integration/mock-backend/magazines.ts +5 -9
- package/src/__tests__/integration/tools/AppDetailTools.ts +31 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +12 -2
- package/src/__tests__/modules/appCatalog/ScreenshotPreview.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +94 -0
- package/src/__tests__/modules/gallery/EscapeDismissal.integration.test.tsx +3 -0
- package/src/modules/appCatalog/context/AppCatalogContext.tsx +12 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +17 -62
- package/src/modules/appCatalog/ui/components/AppDetailModal.tsx +26 -4
- package/src/modules/appCatalog/ui/components/PersonBadge.tsx +69 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +214 -0
- package/src/modules/appCatalog/ui/components/TierVariantsSection.tsx +212 -0
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +71 -7
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +4 -2
- package/src/modules/appCatalog/utils/resolveHelpers.ts +26 -0
- package/src/modules/appCatalog/utils/searchApps.ts +45 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { AppForCatalog } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { useDeferredValue, useEffect, useMemo, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from '~/ui/empty'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { useAppCounts } from '../../hooks/useAppCounts'\nimport { useUrlSyncedState } from '../../hooks/useUrlSyncedState'\nimport { searchApps } from '../../utils/searchApps'\nimport { OnboardingCard } from '../components/OnboardingCard'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { FilterBar } from '../filters/FilterBar'\nimport { AppCatalogGrid } from '../grid/AppCatalogGrid'\n\nexport function AppCatalogPage() {\n const { apps, isLoadingApps, tagsDefinitions }
|
|
1
|
+
{"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { AppForCatalog } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { useDeferredValue, useEffect, useMemo, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Empty,\n EmptyContent,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from '~/ui/empty'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { useAppCounts } from '../../hooks/useAppCounts'\nimport { useUrlSyncedState } from '../../hooks/useUrlSyncedState'\nimport { searchApps } from '../../utils/searchApps'\nimport { OnboardingCard } from '../components/OnboardingCard'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { FilterBar } from '../filters/FilterBar'\nimport { AppCatalogGrid } from '../grid/AppCatalogGrid'\n\nexport function AppCatalogPage() {\n const { apps, isLoadingApps, tagsDefinitions, subResources } =\n useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const { getTopApps } = useAppClickHistory()\n\n // URL-synced state\n const [selectedAppSlug, setSelectedAppSlug] = useUrlSyncedState<\n string | undefined\n >({\n key: 'app',\n defaultValue: undefined,\n })\n\n // Search value from context (URL-synced in AppCatalogFiltersContext)\n const searchValue = filterState.searchValue\n const setSearchValue = actions.setSearchValue\n\n // Defer the search value used for filtering to avoid blocking the input\n const deferredSearchValue = useDeferredValue(searchValue)\n\n // State for top apps (loaded async)\n const [topAppSlugs, setTopAppSlugs] = useState<string[]>([])\n\n // Load top apps on mount to calculate recent count\n useEffect(() => {\n void getTopApps(10).then(setTopAppSlugs)\n }, [getTopApps])\n\n const filteredApps = useMemo(() => {\n let result = apps\n\n // Step 1: Filter deprecated apps (if not showing them)\n if (!filterState.showDeprecated) {\n result = result.filter((app) => !app.deprecated)\n }\n\n // Step 2: Apply recent mode or tag filters\n if (filterState.recentMode) {\n // Filter to top 10 most clicked apps\n result = result.filter((app) => topAppSlugs.includes(app.slug))\n } else if (Object.keys(filterState.tagFilters).length > 0) {\n // Apply tag filters (AND condition)\n result = result.filter((app) => {\n return Object.entries(filterState.tagFilters).every(\n ([prefix, value]) => {\n const fullTag = `${prefix}:${value}`\n return app.tags?.some(\n (tag) => tag.toLowerCase() === fullTag.toLowerCase(),\n )\n },\n )\n })\n }\n\n // Step 3: Apply search (using deferred value)\n result = searchApps(result, deferredSearchValue, subResources)\n\n return result\n }, [\n apps,\n deferredSearchValue,\n filterState.recentMode,\n filterState.tagFilters,\n filterState.showDeprecated,\n topAppSlugs,\n subResources,\n ])\n\n // Calculate counts for FilterBar\n const { allCount, recentCount, deprecatedCount } = useAppCounts({\n apps,\n topAppSlugs,\n searchValue: deferredSearchValue,\n })\n\n // Auto-open details when only 1 result\n useEffect(() => {\n if (filteredApps.length === 1 && filteredApps[0]) {\n setSelectedAppSlug(filteredApps[0].slug)\n }\n }, [filteredApps, setSelectedAppSlug])\n\n const handleAppClick = (app: AppForCatalog) => {\n setSelectedAppSlug(app.slug)\n }\n\n const handleClearFilters = () => {\n setSearchValue('')\n actions.clearAllFilters()\n setSelectedAppSlug(undefined)\n }\n\n // Calculate total apps count (respecting showDeprecated setting)\n const totalAppsCount = useMemo(() => {\n let count = apps.length\n if (!filterState.showDeprecated) {\n count = apps.filter((app) => !app.deprecated).length\n }\n return count\n }, [apps, filterState.showDeprecated])\n\n if (isLoadingApps) {\n return <div className=\"py-6 text-muted-foreground\">Loading…</div>\n }\n\n // Use first tag definition for grouping\n const groupingDefinition = tagsDefinitions[0]\n\n return (\n <div className=\"flex flex-col flex-1 min-h-0\">\n <div className=\"shrink-0\">\n <OnboardingCard />\n </div>\n\n <div className=\"shrink-0\">\n <FilterBar\n totalCount={allCount}\n recentCount={recentCount}\n deprecatedCount={deprecatedCount}\n apps={apps}\n />\n </div>\n\n <div className=\"flex-1 min-h-0\">\n {filteredApps.length === 0 ? (\n <Empty>\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <X className=\"h-6 w-6\" />\n </EmptyMedia>\n <EmptyTitle>\n No apps found{searchValue && ` for \"${searchValue}\"`}\n </EmptyTitle>\n <EmptyDescription>\n Try adjusting your search or filters\n </EmptyDescription>\n </EmptyHeader>\n <EmptyContent>\n {searchValue && (\n <Button\n variant=\"outline\"\n onClick={() => {\n setSearchValue('')\n setSelectedAppSlug(undefined)\n }}\n className=\"gap-2\"\n >\n <X className=\"h-4 w-4\" />\n Clear search\n </Button>\n )}\n </EmptyContent>\n </Empty>\n ) : (\n <AppCatalogGrid\n apps={filteredApps}\n selectedAppSlug={selectedAppSlug}\n groupingDefinition={groupingDefinition}\n onAppClick={handleAppClick}\n hasSearch={!!deferredSearchValue}\n searchQuery={deferredSearchValue}\n totalAppsCount={totalAppsCount}\n onClearFilters={handleClearFilters}\n />\n )}\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Group, Person, SubResource } from '@igstack/app-catalog-backend-core';
|
|
2
|
+
export declare function getPersonBySlug(persons: Person[], slug: string): Person | undefined;
|
|
3
|
+
export declare function getGroupBySlug(groups: Group[], slug: string): Group | undefined;
|
|
4
|
+
export declare function getSubResourcesForApp(subResources: SubResource[], appSlug: string): SubResource[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function getPersonBySlug(persons, slug) {
|
|
2
|
+
return persons.find((p) => p.slug === slug);
|
|
3
|
+
}
|
|
4
|
+
function getGroupBySlug(groups, slug) {
|
|
5
|
+
return groups.find((g) => g.slug === slug);
|
|
6
|
+
}
|
|
7
|
+
function getSubResourcesForApp(subResources, appSlug) {
|
|
8
|
+
return subResources.filter((sr) => sr.appSlug === appSlug);
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
getGroupBySlug,
|
|
12
|
+
getPersonBySlug,
|
|
13
|
+
getSubResourcesForApp
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=resolveHelpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolveHelpers.js","sources":["../../../../../src/modules/appCatalog/utils/resolveHelpers.ts"],"sourcesContent":["import type {\n Group,\n Person,\n SubResource,\n} from '@igstack/app-catalog-backend-core'\n\nexport function getPersonBySlug(\n persons: Person[],\n slug: string,\n): Person | undefined {\n return persons.find((p) => p.slug === slug)\n}\n\nexport function getGroupBySlug(\n groups: Group[],\n slug: string,\n): Group | undefined {\n return groups.find((g) => g.slug === slug)\n}\n\nexport function getSubResourcesForApp(\n subResources: SubResource[],\n appSlug: string,\n): SubResource[] {\n return subResources.filter((sr) => sr.appSlug === appSlug)\n}\n"],"names":[],"mappings":"AAMO,SAAS,gBACd,SACA,MACoB;AACpB,SAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,SAAS,IAAI;AAC5C;AAEO,SAAS,eACd,QACA,MACmB;AACnB,SAAO,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,IAAI;AAC3C;AAEO,SAAS,sBACd,cACA,SACe;AACf,SAAO,aAAa,OAAO,CAAC,OAAO,GAAG,YAAY,OAAO;AAC3D;"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { AppForCatalog } from '@igstack/app-catalog-backend-core';
|
|
1
|
+
import { AppForCatalog, SubResource } from '@igstack/app-catalog-backend-core';
|
|
2
2
|
export interface SearchMatch {
|
|
3
3
|
/** Field where the match occurred */
|
|
4
|
-
field: 'displayName' | 'abbreviation' | 'nicknames' | 'slug' | 'tags' | 'teams' | 'description';
|
|
4
|
+
field: 'displayName' | 'abbreviation' | 'nicknames' | 'slug' | 'tags' | 'teams' | 'description' | 'subResource';
|
|
5
5
|
/** Type of match */
|
|
6
6
|
type: 'exact' | 'prefix' | 'contains';
|
|
7
7
|
}
|
|
@@ -31,7 +31,7 @@ export interface SearchResult {
|
|
|
31
31
|
* @param searchQuery - Search query string
|
|
32
32
|
* @returns Filtered and sorted array of apps with search results
|
|
33
33
|
*/
|
|
34
|
-
export declare function searchApps(apps: AppForCatalog[], searchQuery: string): AppForCatalog[];
|
|
34
|
+
export declare function searchApps(apps: AppForCatalog[], searchQuery: string, subResources?: SubResource[]): AppForCatalog[];
|
|
35
35
|
/**
|
|
36
36
|
* Highlight matching text in a string
|
|
37
37
|
* @param text - Text to highlight
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
function searchApps(apps, searchQuery) {
|
|
1
|
+
function searchApps(apps, searchQuery, subResources) {
|
|
2
2
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
3
3
|
if (normalizedQuery === "") {
|
|
4
4
|
return apps;
|
|
5
5
|
}
|
|
6
|
+
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
7
|
+
const allTermsMatch = (text) => queryTerms.every((term) => text.includes(term));
|
|
8
|
+
const subResourcesByApp = /* @__PURE__ */ new Map();
|
|
9
|
+
if (subResources) {
|
|
10
|
+
for (const sr of subResources) {
|
|
11
|
+
const list = subResourcesByApp.get(sr.appSlug) ?? [];
|
|
12
|
+
list.push(sr);
|
|
13
|
+
subResourcesByApp.set(sr.appSlug, list);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
6
16
|
const scoredApps = apps.map((app) => {
|
|
7
17
|
var _a, _b, _c, _d, _e, _f, _g;
|
|
8
18
|
const name = app.displayName.toLowerCase();
|
|
@@ -47,12 +57,21 @@ function searchApps(apps, searchQuery) {
|
|
|
47
57
|
if (tags.includes(normalizedQuery)) {
|
|
48
58
|
return { app, match: { field: "tags", type: "contains" } };
|
|
49
59
|
}
|
|
50
|
-
if (teams
|
|
60
|
+
if (allTermsMatch(teams)) {
|
|
51
61
|
return { app, match: { field: "teams", type: "contains" } };
|
|
52
62
|
}
|
|
53
|
-
if (description
|
|
63
|
+
if (allTermsMatch(description)) {
|
|
54
64
|
return { app, match: { field: "description", type: "contains" } };
|
|
55
65
|
}
|
|
66
|
+
const appSubResources = subResourcesByApp.get(app.slug);
|
|
67
|
+
if (appSubResources) {
|
|
68
|
+
const subMatch = appSubResources.some(
|
|
69
|
+
(sr) => allTermsMatch(sr.displayName.toLowerCase()) || sr.aliases.some((a) => allTermsMatch(a.toLowerCase())) || (sr.description ? allTermsMatch(sr.description.toLowerCase()) : false)
|
|
70
|
+
);
|
|
71
|
+
if (subMatch) {
|
|
72
|
+
return { app, match: { field: "subResource", type: "contains" } };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
56
75
|
return null;
|
|
57
76
|
}).filter((item) => item !== null);
|
|
58
77
|
const scoreMap = /* @__PURE__ */ new Map();
|
|
@@ -76,7 +95,8 @@ function searchApps(apps, searchQuery) {
|
|
|
76
95
|
else if (match.field === "nicknames") score = 10;
|
|
77
96
|
else if (match.field === "tags") score = 11;
|
|
78
97
|
else if (match.field === "teams") score = 12;
|
|
79
|
-
else score = 13;
|
|
98
|
+
else if (match.field === "subResource") score = 13;
|
|
99
|
+
else score = 14;
|
|
80
100
|
}
|
|
81
101
|
scoreMap.set(app.id, score);
|
|
82
102
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"searchApps.js","sources":["../../../../../src/modules/appCatalog/utils/searchApps.ts"],"sourcesContent":["import type { AppForCatalog } 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 /** Type of match */\n type: 'exact' | 'prefix' | 'contains'\n}\n\nexport interface SearchResult {\n app: AppForCatalog\n match: SearchMatch\n}\n\n/**\n * Search and sort apps by relevance with highlighting support.\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 apps - Array of apps to search\n * @param searchQuery - Search query string\n * @returns Filtered and sorted array of apps with search results\n */\nexport function searchApps(\n apps: AppForCatalog[],\n searchQuery: string,\n): AppForCatalog[] {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n if (normalizedQuery === '') {\n return apps\n }\n\n // Filter and score apps\n const scoredApps = apps\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\n if (teams.includes(normalizedQuery)) {\n return { app, match: { field: 'teams', type: 'contains' } }\n }\n\n // Check description\n if (description.includes(normalizedQuery)) {\n return { app, match: { field: 'description', type: 'contains' } }\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 score = 13 // 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"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"searchApps.js","sources":["../../../../../src/modules/appCatalog/utils/searchApps.ts"],"sourcesContent":["import type {\n AppForCatalog,\n SubResource,\n} 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: AppForCatalog\n match: SearchMatch\n}\n\n/**\n * Search and sort apps by relevance with highlighting support.\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 apps - Array of apps to search\n * @param searchQuery - Search query string\n * @returns Filtered and sorted array of apps with search results\n */\nexport function searchApps(\n apps: AppForCatalog[],\n searchQuery: string,\n subResources?: SubResource[],\n): AppForCatalog[] {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n if (normalizedQuery === '') {\n return apps\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 sub-resource lookup: appSlug -> SubResource[]\n const subResourcesByApp = new Map<string, SubResource[]>()\n if (subResources) {\n for (const sr of subResources) {\n const list = subResourcesByApp.get(sr.appSlug) ?? []\n list.push(sr)\n subResourcesByApp.set(sr.appSlug, list)\n }\n }\n\n // Filter and score apps\n const scoredApps = apps\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 sub-resources (name, aliases, description) — supports multi-word queries\n const appSubResources = subResourcesByApp.get(app.slug)\n if (appSubResources) {\n const subMatch = appSubResources.some(\n (sr) =>\n allTermsMatch(sr.displayName.toLowerCase()) ||\n sr.aliases.some((a) => allTermsMatch(a.toLowerCase())) ||\n (sr.description\n ? allTermsMatch(sr.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"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
3
|
+
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
function Select({
|
|
6
|
+
...props
|
|
7
|
+
}) {
|
|
8
|
+
return /* @__PURE__ */ jsx(SelectPrimitive.Root, { "data-slot": "select", ...props });
|
|
9
|
+
}
|
|
10
|
+
function SelectValue({
|
|
11
|
+
...props
|
|
12
|
+
}) {
|
|
13
|
+
return /* @__PURE__ */ jsx(SelectPrimitive.Value, { "data-slot": "select-value", ...props });
|
|
14
|
+
}
|
|
15
|
+
function SelectTrigger({
|
|
16
|
+
className,
|
|
17
|
+
size = "default",
|
|
18
|
+
children,
|
|
19
|
+
...props
|
|
20
|
+
}) {
|
|
21
|
+
return /* @__PURE__ */ jsxs(
|
|
22
|
+
SelectPrimitive.Trigger,
|
|
23
|
+
{
|
|
24
|
+
"data-slot": "select-trigger",
|
|
25
|
+
"data-size": size,
|
|
26
|
+
className: cn(
|
|
27
|
+
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
28
|
+
className
|
|
29
|
+
),
|
|
30
|
+
...props,
|
|
31
|
+
children: [
|
|
32
|
+
children,
|
|
33
|
+
/* @__PURE__ */ jsx(SelectPrimitive.Icon, { asChild: true, children: /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 opacity-50" }) })
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
function SelectContent({
|
|
39
|
+
className,
|
|
40
|
+
children,
|
|
41
|
+
position = "popper",
|
|
42
|
+
align = "center",
|
|
43
|
+
...props
|
|
44
|
+
}) {
|
|
45
|
+
return /* @__PURE__ */ jsx(SelectPrimitive.Portal, { children: /* @__PURE__ */ jsxs(
|
|
46
|
+
SelectPrimitive.Content,
|
|
47
|
+
{
|
|
48
|
+
"data-slot": "select-content",
|
|
49
|
+
className: cn(
|
|
50
|
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
|
51
|
+
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
52
|
+
className
|
|
53
|
+
),
|
|
54
|
+
position,
|
|
55
|
+
align,
|
|
56
|
+
...props,
|
|
57
|
+
children: [
|
|
58
|
+
/* @__PURE__ */ jsx(SelectScrollUpButton, {}),
|
|
59
|
+
/* @__PURE__ */ jsx(
|
|
60
|
+
SelectPrimitive.Viewport,
|
|
61
|
+
{
|
|
62
|
+
className: cn(
|
|
63
|
+
"p-1",
|
|
64
|
+
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
|
65
|
+
),
|
|
66
|
+
children
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
/* @__PURE__ */ jsx(SelectScrollDownButton, {})
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
) });
|
|
73
|
+
}
|
|
74
|
+
function SelectItem({
|
|
75
|
+
className,
|
|
76
|
+
children,
|
|
77
|
+
...props
|
|
78
|
+
}) {
|
|
79
|
+
return /* @__PURE__ */ jsxs(
|
|
80
|
+
SelectPrimitive.Item,
|
|
81
|
+
{
|
|
82
|
+
"data-slot": "select-item",
|
|
83
|
+
className: cn(
|
|
84
|
+
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
85
|
+
className
|
|
86
|
+
),
|
|
87
|
+
...props,
|
|
88
|
+
children: [
|
|
89
|
+
/* @__PURE__ */ jsx("span", { className: "absolute right-2 flex size-3.5 items-center justify-center", children: /* @__PURE__ */ jsx(SelectPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-4" }) }) }),
|
|
90
|
+
/* @__PURE__ */ jsx(SelectPrimitive.ItemText, { children })
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
function SelectScrollUpButton({
|
|
96
|
+
className,
|
|
97
|
+
...props
|
|
98
|
+
}) {
|
|
99
|
+
return /* @__PURE__ */ jsx(
|
|
100
|
+
SelectPrimitive.ScrollUpButton,
|
|
101
|
+
{
|
|
102
|
+
"data-slot": "select-scroll-up-button",
|
|
103
|
+
className: cn(
|
|
104
|
+
"flex cursor-default items-center justify-center py-1",
|
|
105
|
+
className
|
|
106
|
+
),
|
|
107
|
+
...props,
|
|
108
|
+
children: /* @__PURE__ */ jsx(ChevronUpIcon, { className: "size-4" })
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
function SelectScrollDownButton({
|
|
113
|
+
className,
|
|
114
|
+
...props
|
|
115
|
+
}) {
|
|
116
|
+
return /* @__PURE__ */ jsx(
|
|
117
|
+
SelectPrimitive.ScrollDownButton,
|
|
118
|
+
{
|
|
119
|
+
"data-slot": "select-scroll-down-button",
|
|
120
|
+
className: cn(
|
|
121
|
+
"flex cursor-default items-center justify-center py-1",
|
|
122
|
+
className
|
|
123
|
+
),
|
|
124
|
+
...props,
|
|
125
|
+
children: /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4" })
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
export {
|
|
130
|
+
Select,
|
|
131
|
+
SelectContent,
|
|
132
|
+
SelectItem,
|
|
133
|
+
SelectScrollDownButton,
|
|
134
|
+
SelectScrollUpButton,
|
|
135
|
+
SelectTrigger,
|
|
136
|
+
SelectValue
|
|
137
|
+
};
|
|
138
|
+
//# sourceMappingURL=select.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"select.js","sources":["../../../src/ui/select.tsx"],"sourcesContent":["import * as SelectPrimitive from '@radix-ui/react-select'\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'\nimport * as React from 'react'\n\nimport { cn } from '~/lib/utils'\n\nfunction Select({\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n return <SelectPrimitive.Root data-slot=\"select\" {...props} />\n}\n\nfunction SelectGroup({\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />\n}\n\nfunction SelectValue({\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />\n}\n\nfunction SelectTrigger({\n className,\n size = 'default',\n children,\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n size?: 'sm' | 'default'\n}) {\n return (\n <SelectPrimitive.Trigger\n data-slot=\"select-trigger\"\n data-size={size}\n className={cn(\n \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n className,\n )}\n {...props}\n >\n {children}\n <SelectPrimitive.Icon asChild>\n <ChevronDownIcon className=\"size-4 opacity-50\" />\n </SelectPrimitive.Icon>\n </SelectPrimitive.Trigger>\n )\n}\n\nfunction SelectContent({\n className,\n children,\n position = 'popper',\n align = 'center',\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n return (\n <SelectPrimitive.Portal>\n <SelectPrimitive.Content\n data-slot=\"select-content\"\n className={cn(\n 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',\n position === 'popper' &&\n 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',\n className,\n )}\n position={position}\n align={align}\n {...props}\n >\n <SelectScrollUpButton />\n <SelectPrimitive.Viewport\n className={cn(\n 'p-1',\n position === 'popper' &&\n 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',\n )}\n >\n {children}\n </SelectPrimitive.Viewport>\n <SelectScrollDownButton />\n </SelectPrimitive.Content>\n </SelectPrimitive.Portal>\n )\n}\n\nfunction SelectLabel({\n className,\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n return (\n <SelectPrimitive.Label\n data-slot=\"select-label\"\n className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}\n {...props}\n />\n )\n}\n\nfunction SelectItem({\n className,\n children,\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n return (\n <SelectPrimitive.Item\n data-slot=\"select-item\"\n className={cn(\n \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n className,\n )}\n {...props}\n >\n <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n <SelectPrimitive.ItemIndicator>\n <CheckIcon className=\"size-4\" />\n </SelectPrimitive.ItemIndicator>\n </span>\n <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n </SelectPrimitive.Item>\n )\n}\n\nfunction SelectSeparator({\n className,\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n return (\n <SelectPrimitive.Separator\n data-slot=\"select-separator\"\n className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}\n {...props}\n />\n )\n}\n\nfunction SelectScrollUpButton({\n className,\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n return (\n <SelectPrimitive.ScrollUpButton\n data-slot=\"select-scroll-up-button\"\n className={cn(\n 'flex cursor-default items-center justify-center py-1',\n className,\n )}\n {...props}\n >\n <ChevronUpIcon className=\"size-4\" />\n </SelectPrimitive.ScrollUpButton>\n )\n}\n\nfunction SelectScrollDownButton({\n className,\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n return (\n <SelectPrimitive.ScrollDownButton\n data-slot=\"select-scroll-down-button\"\n className={cn(\n 'flex cursor-default items-center justify-center py-1',\n className,\n )}\n {...props}\n >\n <ChevronDownIcon className=\"size-4\" />\n </SelectPrimitive.ScrollDownButton>\n )\n}\n\nexport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectLabel,\n SelectScrollDownButton,\n SelectScrollUpButton,\n SelectSeparator,\n SelectTrigger,\n SelectValue,\n}\n"],"names":[],"mappings":";;;;AAMA,SAAS,OAAO;AAAA,EACd,GAAG;AACL,GAAsD;AACpD,6BAAQ,gBAAgB,MAAhB,EAAqB,aAAU,UAAU,GAAG,OAAO;AAC7D;AAQA,SAAS,YAAY;AAAA,EACnB,GAAG;AACL,GAAuD;AACrD,6BAAQ,gBAAgB,OAAhB,EAAsB,aAAU,gBAAgB,GAAG,OAAO;AACpE;AAEA,SAAS,cAAc;AAAA,EACrB;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,GAAG;AACL,GAEG;AACD,SACE;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC,aAAU;AAAA,MACV,aAAW;AAAA,MACX,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MAAA;AAAA,MAED,GAAG;AAAA,MAEH,UAAA;AAAA,QAAA;AAAA,QACD,oBAAC,gBAAgB,MAAhB,EAAqB,SAAO,MAC3B,UAAA,oBAAC,iBAAA,EAAgB,WAAU,oBAAA,CAAoB,EAAA,CACjD;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAGN;AAEA,SAAS,cAAc;AAAA,EACrB;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,GAAG;AACL,GAAyD;AACvD,SACE,oBAAC,gBAAgB,QAAhB,EACC,UAAA;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA,aAAa,YACX;AAAA,QACF;AAAA,MAAA;AAAA,MAEF;AAAA,MACA;AAAA,MACC,GAAG;AAAA,MAEJ,UAAA;AAAA,QAAA,oBAAC,sBAAA,EAAqB;AAAA,QACtB;AAAA,UAAC,gBAAgB;AAAA,UAAhB;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,aAAa,YACX;AAAA,YAAA;AAAA,YAGH;AAAA,UAAA;AAAA,QAAA;AAAA,4BAEF,wBAAA,CAAA,CAAuB;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA,GAE5B;AAEJ;AAeA,SAAS,WAAW;AAAA,EAClB;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAsD;AACpD,SACE;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MAAA;AAAA,MAED,GAAG;AAAA,MAEJ,UAAA;AAAA,QAAA,oBAAC,QAAA,EAAK,WAAU,8DACd,UAAA,oBAAC,gBAAgB,eAAhB,EACC,UAAA,oBAAC,WAAA,EAAU,WAAU,SAAA,CAAS,EAAA,CAChC,GACF;AAAA,QACA,oBAAC,gBAAgB,UAAhB,EAA0B,SAAA,CAAS;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAG1C;AAeA,SAAS,qBAAqB;AAAA,EAC5B;AAAA,EACA,GAAG;AACL,GAAgE;AAC9D,SACE;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MAAA;AAAA,MAED,GAAG;AAAA,MAEJ,UAAA,oBAAC,eAAA,EAAc,WAAU,SAAA,CAAS;AAAA,IAAA;AAAA,EAAA;AAGxC;AAEA,SAAS,uBAAuB;AAAA,EAC9B;AAAA,EACA,GAAG;AACL,GAAkE;AAChE,SACE;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MAAA;AAAA,MAED,GAAG;AAAA,MAEJ,UAAA,oBAAC,iBAAA,EAAgB,WAAU,SAAA,CAAS;AAAA,IAAA;AAAA,EAAA;AAG1C;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "0.3.1-alpha-
|
|
3
|
+
"version": "0.3.1-alpha-20260405015231",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -134,8 +134,8 @@
|
|
|
134
134
|
"vite-plugin-static-copy": "^3.1.4",
|
|
135
135
|
"vite-plugin-svgr": "^4.2.0",
|
|
136
136
|
"vitest": "^4.1.2",
|
|
137
|
-
"@igstack/app-catalog-backend-core": "0.3.1-alpha-
|
|
138
|
-
"@igstack/app-catalog-shared-core": "0.3.1-alpha-
|
|
137
|
+
"@igstack/app-catalog-backend-core": "0.3.1-alpha-20260405015231",
|
|
138
|
+
"@igstack/app-catalog-shared-core": "0.3.1-alpha-20260405015231"
|
|
139
139
|
},
|
|
140
140
|
"peerDependencies": {
|
|
141
141
|
"react": "19.1.2",
|
|
@@ -52,7 +52,7 @@ describe('App Catalog Integration', () => {
|
|
|
52
52
|
description: 'A test application',
|
|
53
53
|
screenshotIds: ['screenshot-1'],
|
|
54
54
|
accessRequest: {
|
|
55
|
-
|
|
55
|
+
approvalMethodSlug: approvalMethod.slug,
|
|
56
56
|
comments: 'Submit a ticket',
|
|
57
57
|
},
|
|
58
58
|
})
|
|
@@ -113,6 +113,45 @@ describe('App Catalog Integration', () => {
|
|
|
113
113
|
expect(error.element).toBeInTheDocument()
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
// Test: Sub-resources visible in side panel
|
|
117
|
+
it('shows sub-resources table in detail panel when app has sub-resources', async () => {
|
|
118
|
+
const { ui } = await given(
|
|
119
|
+
magazine.custom(({ backendCfg }) => {
|
|
120
|
+
const app = backendCfg.withApp({
|
|
121
|
+
slug: 'aws-console',
|
|
122
|
+
displayName: 'AWS Console',
|
|
123
|
+
description: 'Cloud management console',
|
|
124
|
+
})
|
|
125
|
+
backendCfg.withSubResource({
|
|
126
|
+
appSlug: app.slug,
|
|
127
|
+
displayName: 'acct-prod',
|
|
128
|
+
tierSlug: 'prod',
|
|
129
|
+
ownerPersonSlug: 'jsmith',
|
|
130
|
+
})
|
|
131
|
+
backendCfg.withSubResource({
|
|
132
|
+
appSlug: app.slug,
|
|
133
|
+
displayName: 'acct-dev',
|
|
134
|
+
tierSlug: 'dev',
|
|
135
|
+
ownerPersonSlug: 'jdoe',
|
|
136
|
+
})
|
|
137
|
+
backendCfg.withSubResource({
|
|
138
|
+
appSlug: app.slug,
|
|
139
|
+
displayName: 'acct-staging',
|
|
140
|
+
tierSlug: 'staging',
|
|
141
|
+
})
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
await ui.catalog.openApp('AWS Console')
|
|
146
|
+
const subResources = ui.app.getSubResources()
|
|
147
|
+
expect(subResources).not.toBeNull()
|
|
148
|
+
expect(subResources!.total).toBe(3)
|
|
149
|
+
expect(subResources!.visible).toBe(3)
|
|
150
|
+
expect(subResources!.names).toContain('acct-prod')
|
|
151
|
+
expect(subResources!.names).toContain('acct-dev')
|
|
152
|
+
expect(subResources!.names).toContain('acct-staging')
|
|
153
|
+
})
|
|
154
|
+
|
|
116
155
|
// Test 4: Malformed Response — HTML Instead of JSON
|
|
117
156
|
it('shows error when backend returns HTML instead of JSON', async () => {
|
|
118
157
|
suppressConsole([
|
|
@@ -165,7 +165,7 @@ export async function given(magazine: Magazine): Promise<GivenResult> {
|
|
|
165
165
|
await waitFor(
|
|
166
166
|
() => {
|
|
167
167
|
const hasAlert = screen.queryByRole('alert')
|
|
168
|
-
const hasTable = screen.
|
|
168
|
+
const hasTable = screen.queryAllByRole('table').length > 0
|
|
169
169
|
const hasSearchbox = screen.queryByLabelText('Search apps')
|
|
170
170
|
const hasTanstackError = document.body.textContent.includes(
|
|
171
171
|
'Something went wrong',
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AppApprovalMethod,
|
|
3
3
|
AppForCatalog,
|
|
4
4
|
GroupingTagDefinition,
|
|
5
|
+
SubResource,
|
|
5
6
|
} from '@igstack/app-catalog-backend-core'
|
|
6
7
|
import type { MockDb } from './MockDb'
|
|
7
8
|
import type { MockUserContext, UserConfig } from './MockUserContext'
|
|
@@ -65,6 +66,20 @@ export class MockBackendConfigurer {
|
|
|
65
66
|
return method
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
withSubResource(
|
|
70
|
+
overrides: Partial<SubResource> & { appSlug: string },
|
|
71
|
+
): SubResource {
|
|
72
|
+
const sr: SubResource = {
|
|
73
|
+
slug: overrides.slug ?? `sr-${nextId()}`,
|
|
74
|
+
displayName: overrides.displayName ?? `Sub Resource ${counter}`,
|
|
75
|
+
aliases: overrides.aliases ?? [],
|
|
76
|
+
accessMaintainerGroupSlugs: overrides.accessMaintainerGroupSlugs ?? [],
|
|
77
|
+
...overrides,
|
|
78
|
+
}
|
|
79
|
+
this.db.addSubResource(sr)
|
|
80
|
+
return sr
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
withUser(overrides: Partial<UserConfig>): void {
|
|
69
84
|
this.userContext.setUser(overrides)
|
|
70
85
|
}
|
|
@@ -3,12 +3,14 @@ import type {
|
|
|
3
3
|
AppCatalogData,
|
|
4
4
|
AppForCatalog,
|
|
5
5
|
GroupingTagDefinition,
|
|
6
|
+
SubResource,
|
|
6
7
|
} from '@igstack/app-catalog-backend-core'
|
|
7
8
|
|
|
8
9
|
export class MockDb {
|
|
9
10
|
apps: AppForCatalog[] = []
|
|
10
11
|
tagsDefinitions: GroupingTagDefinition[] = []
|
|
11
12
|
approvalMethods: AppApprovalMethod[] = []
|
|
13
|
+
subResources: SubResource[] = []
|
|
12
14
|
|
|
13
15
|
upsertApp(app: AppForCatalog): void {
|
|
14
16
|
this.apps = [...this.apps.filter((a) => a.id !== app.id), app]
|
|
@@ -36,11 +38,21 @@ export class MockDb {
|
|
|
36
38
|
this.approvalMethods = methods
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
addSubResource(sr: SubResource): void {
|
|
42
|
+
this.subResources = [
|
|
43
|
+
...this.subResources.filter((s) => s.slug !== sr.slug),
|
|
44
|
+
sr,
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
getAppCatalogData(): AppCatalogData {
|
|
40
49
|
return {
|
|
41
50
|
apps: this.apps,
|
|
42
51
|
tagsDefinitions: this.tagsDefinitions,
|
|
43
52
|
approvalMethods: this.approvalMethods,
|
|
53
|
+
persons: [],
|
|
54
|
+
groups: [],
|
|
55
|
+
subResources: this.subResources,
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
58
|
}
|
|
@@ -40,13 +40,9 @@ function fullMagazine(
|
|
|
40
40
|
})
|
|
41
41
|
const managerApproval = backendCfg.withApprovalMethod({
|
|
42
42
|
slug: 'manager-approval',
|
|
43
|
-
type: '
|
|
43
|
+
type: 'custom',
|
|
44
44
|
displayName: 'Manager Approval',
|
|
45
|
-
config: {
|
|
46
|
-
reachOutContacts: [
|
|
47
|
-
{ displayName: 'IT Team', contact: 'it-team@example.com' },
|
|
48
|
-
],
|
|
49
|
-
},
|
|
45
|
+
config: {},
|
|
50
46
|
})
|
|
51
47
|
backendCfg.withApprovalMethod({
|
|
52
48
|
slug: 'self-service',
|
|
@@ -93,7 +89,7 @@ function fullMagazine(
|
|
|
93
89
|
screenshotIds: ['ss-jira-1', 'ss-jira-2', 'ss-jira-3'],
|
|
94
90
|
appUrl: 'https://jira.example.com',
|
|
95
91
|
accessRequest: {
|
|
96
|
-
|
|
92
|
+
approvalMethodSlug: helpdesk.slug,
|
|
97
93
|
comments: 'Submit a ticket',
|
|
98
94
|
},
|
|
99
95
|
})
|
|
@@ -144,7 +140,7 @@ function fullMagazine(
|
|
|
144
140
|
tags: ['category:internal', 'team:platform'],
|
|
145
141
|
screenshotIds: ['ss-portal-1'],
|
|
146
142
|
accessRequest: {
|
|
147
|
-
|
|
143
|
+
approvalMethodSlug: managerApproval.slug,
|
|
148
144
|
comments: 'Requires manager approval',
|
|
149
145
|
},
|
|
150
146
|
})
|
|
@@ -205,7 +201,7 @@ function singleMagazine(postConfigure?: Magazine): Magazine {
|
|
|
205
201
|
screenshotIds: ['screenshot-jira-1', 'screenshot-jira-2'],
|
|
206
202
|
appUrl: 'https://jira.example.com',
|
|
207
203
|
accessRequest: {
|
|
208
|
-
|
|
204
|
+
approvalMethodSlug: method.slug,
|
|
209
205
|
comments: 'Submit a ticket to IT',
|
|
210
206
|
},
|
|
211
207
|
})
|
|
@@ -82,6 +82,37 @@ export class AppDetailTools {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Get the sub-resources section data from the detail panel.
|
|
87
|
+
* Returns null if no sub-resources section is visible.
|
|
88
|
+
*/
|
|
89
|
+
getSubResources(): {
|
|
90
|
+
total: number
|
|
91
|
+
visible: number
|
|
92
|
+
names: string[]
|
|
93
|
+
} | null {
|
|
94
|
+
const panel = this.getPanel()
|
|
95
|
+
const heading = Array.from(panel.querySelectorAll('div')).find((el) =>
|
|
96
|
+
el.textContent.match(/Sub-Resources \(\d+ of \d+\)/),
|
|
97
|
+
)
|
|
98
|
+
if (!heading) return null
|
|
99
|
+
|
|
100
|
+
const match = heading.textContent.match(/Sub-Resources \((\d+) of (\d+)\)/)
|
|
101
|
+
const visible = match?.[1] ? parseInt(match[1], 10) : 0
|
|
102
|
+
const total = match?.[2] ? parseInt(match[2], 10) : 0
|
|
103
|
+
|
|
104
|
+
const names: string[] = []
|
|
105
|
+
const rows = panel.querySelectorAll('table tbody tr')
|
|
106
|
+
rows.forEach((row) => {
|
|
107
|
+
const nameCell = row.querySelector('td .font-medium')
|
|
108
|
+
if (nameCell?.textContent) {
|
|
109
|
+
names.push(nameCell.textContent.trim())
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return { total, visible, names }
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
screenshots = {
|
|
86
117
|
/**
|
|
87
118
|
* Click the screenshot preview to open the gallery modal.
|
|
@@ -14,7 +14,8 @@ export class CatalogTools {
|
|
|
14
14
|
* Throws with list of visible apps if name not found.
|
|
15
15
|
*/
|
|
16
16
|
async openApp(name: string): Promise<void> {
|
|
17
|
-
const table =
|
|
17
|
+
const table = this.getCatalogTable()
|
|
18
|
+
if (!table) throw new Error('No catalog table found')
|
|
18
19
|
const rows = within(table).getAllByRole('row')
|
|
19
20
|
|
|
20
21
|
for (const row of rows) {
|
|
@@ -45,7 +46,7 @@ export class CatalogTools {
|
|
|
45
46
|
* Skips group header rows (colspan rows).
|
|
46
47
|
*/
|
|
47
48
|
getTableData(): TableRow[] {
|
|
48
|
-
const table =
|
|
49
|
+
const table = this.getCatalogTable()
|
|
49
50
|
if (!table) {
|
|
50
51
|
// Check if there's a global error — throw with details for debugging
|
|
51
52
|
const bodyText = document.body.textContent
|
|
@@ -93,4 +94,13 @@ export class CatalogTools {
|
|
|
93
94
|
isOnboardingVisible(): boolean {
|
|
94
95
|
return !!screen.queryByText('Welcome to App Catalog')
|
|
95
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the main catalog table (first table on page, skipping sub-resource tables in detail panel).
|
|
100
|
+
*/
|
|
101
|
+
private getCatalogTable(): HTMLElement | null {
|
|
102
|
+
const tables = screen.queryAllByRole('table')
|
|
103
|
+
// The catalog table is the first table; sub-resource tables appear later in the detail panel
|
|
104
|
+
return tables[0] ?? null
|
|
105
|
+
}
|
|
96
106
|
}
|