@igstack/app-catalog-frontend-core 0.3.1-alpha-20260405015231 → 0.4.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 (65) hide show
  1. package/dist/esm/__tests__/integration/harness/MockBackendVerifier.d.ts +3 -3
  2. package/dist/esm/__tests__/integration/mock-backend/MockBackendConfigurer.d.ts +4 -4
  3. package/dist/esm/__tests__/integration/mock-backend/MockDb.d.ts +11 -7
  4. package/dist/esm/api/infra/trpc.d.ts +3 -3
  5. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +2 -3
  6. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +3 -4
  7. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/hooks/useAppCounts.d.ts +2 -2
  9. package/dist/esm/modules/appCatalog/hooks/useAppCounts.js +3 -3
  10. package/dist/esm/modules/appCatalog/hooks/useAppCounts.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +9 -0
  12. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.d.ts +2 -2
  13. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/AppDetailModal.d.ts +2 -2
  15. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +1 -0
  16. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  17. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.d.ts +2 -2
  18. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +2 -2
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +13 -14
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.d.ts +2 -2
  23. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +1 -0
  24. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/ui/filters/FilterBar.d.ts +2 -2
  26. package/dist/esm/modules/appCatalog/ui/filters/FilterBar.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +3 -3
  28. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +19 -19
  29. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  30. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogTable.d.ts +2 -2
  31. package/dist/esm/modules/appCatalog/ui/grid/appCatalogUtils.d.ts +2 -2
  32. package/dist/esm/modules/appCatalog/ui/hooks/useKeyboardNavigation.d.ts +3 -3
  33. package/dist/esm/modules/appCatalog/ui/hooks/useKeyboardNavigation.js.map +1 -1
  34. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +20 -12
  35. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  36. package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +5 -2
  37. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +4 -4
  38. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
  39. package/dist/esm/modules/appCatalog/utils/searchApps.d.ts +11 -6
  40. package/dist/esm/modules/appCatalog/utils/searchApps.js +15 -14
  41. package/dist/esm/modules/appCatalog/utils/searchApps.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/__tests__/integration/appCatalog.integration.test.ts +3 -3
  44. package/src/__tests__/integration/harness/MockBackendVerifier.ts +5 -5
  45. package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +16 -12
  46. package/src/__tests__/integration/mock-backend/MockDb.ts +30 -22
  47. package/src/__tests__/modules/appCatalog/ScreenshotPreview.test.tsx +4 -5
  48. package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +28 -30
  49. package/src/__tests__/modules/gallery/EscapeDismissal.integration.test.tsx +3 -4
  50. package/src/modules/appCatalog/context/AppCatalogContext.tsx +4 -8
  51. package/src/modules/appCatalog/hooks/useAppCounts.ts +5 -5
  52. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +2 -2
  53. package/src/modules/appCatalog/ui/components/AppDetailModal.tsx +10 -10
  54. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +2 -2
  55. package/src/modules/appCatalog/ui/components/SearchAndFilterHeader.tsx +6 -2
  56. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +17 -17
  57. package/src/modules/appCatalog/ui/components/TierVariantsSection.tsx +2 -2
  58. package/src/modules/appCatalog/ui/filters/FilterBar.tsx +2 -2
  59. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +34 -37
  60. package/src/modules/appCatalog/ui/grid/AppCatalogTable.tsx +3 -3
  61. package/src/modules/appCatalog/ui/grid/appCatalogUtils.ts +2 -2
  62. package/src/modules/appCatalog/ui/hooks/useKeyboardNavigation.ts +3 -3
  63. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +24 -14
  64. package/src/modules/appCatalog/utils/resolveHelpers.ts +13 -10
  65. package/src/modules/appCatalog/utils/searchApps.ts +36 -31
@@ -7,13 +7,13 @@ import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
7
7
  import { useAppClickHistory } from "../../hooks/useAppClickHistory.js";
8
8
  import { useAppCounts } from "../../hooks/useAppCounts.js";
9
9
  import { useUrlSyncedState } from "../../hooks/useUrlSyncedState.js";
10
- import { searchApps } from "../../utils/searchApps.js";
10
+ import { searchResources } from "../../utils/searchApps.js";
11
11
  import { OnboardingCard } from "../components/OnboardingCard.js";
12
12
  import { useAppCatalogFilters } from "../context/AppCatalogFiltersContext.js";
13
13
  import { FilterBar } from "../filters/FilterBar.js";
14
14
  import { AppCatalogGrid } from "../grid/AppCatalogGrid.js";
15
15
  function AppCatalogPage() {
16
- const { apps, isLoadingApps, tagsDefinitions, subResources } = useAppCatalogContext();
16
+ const { resources, isLoadingApps, tagsDefinitions } = useAppCatalogContext();
17
17
  const { state: filterState, actions } = useAppCatalogFilters();
18
18
  const { getTopApps } = useAppClickHistory();
19
19
  const [selectedAppSlug, setSelectedAppSlug] = useUrlSyncedState({
@@ -27,8 +27,12 @@ function AppCatalogPage() {
27
27
  useEffect(() => {
28
28
  void getTopApps(10).then(setTopAppSlugs);
29
29
  }, [getTopApps]);
30
+ const rootResources = useMemo(
31
+ () => resources.filter((r) => !r.parentSlug),
32
+ [resources]
33
+ );
30
34
  const filteredApps = useMemo(() => {
31
- let result = apps;
35
+ let result = rootResources;
32
36
  if (!filterState.showDeprecated) {
33
37
  result = result.filter((app) => !app.deprecated);
34
38
  }
@@ -47,19 +51,23 @@ function AppCatalogPage() {
47
51
  );
48
52
  });
49
53
  }
50
- result = searchApps(result, deferredSearchValue, subResources);
54
+ const childResources = resources.filter((r) => r.parentSlug);
55
+ result = searchResources(
56
+ [...result, ...childResources],
57
+ deferredSearchValue
58
+ );
51
59
  return result;
52
60
  }, [
53
- apps,
61
+ rootResources,
62
+ resources,
54
63
  deferredSearchValue,
55
64
  filterState.recentMode,
56
65
  filterState.tagFilters,
57
66
  filterState.showDeprecated,
58
- topAppSlugs,
59
- subResources
67
+ topAppSlugs
60
68
  ]);
61
69
  const { allCount, recentCount, deprecatedCount } = useAppCounts({
62
- apps,
70
+ apps: rootResources,
63
71
  topAppSlugs,
64
72
  searchValue: deferredSearchValue
65
73
  });
@@ -77,12 +85,12 @@ function AppCatalogPage() {
77
85
  setSelectedAppSlug(void 0);
78
86
  };
79
87
  const totalAppsCount = useMemo(() => {
80
- let count = apps.length;
88
+ let count = rootResources.length;
81
89
  if (!filterState.showDeprecated) {
82
- count = apps.filter((app) => !app.deprecated).length;
90
+ count = rootResources.filter((app) => !app.deprecated).length;
83
91
  }
84
92
  return count;
85
- }, [apps, filterState.showDeprecated]);
93
+ }, [rootResources, filterState.showDeprecated]);
86
94
  if (isLoadingApps) {
87
95
  return /* @__PURE__ */ jsx("div", { className: "py-6 text-muted-foreground", children: "Loading…" });
88
96
  }
@@ -95,7 +103,7 @@ function AppCatalogPage() {
95
103
  totalCount: allCount,
96
104
  recentCount,
97
105
  deprecatedCount,
98
- apps
106
+ apps: rootResources
99
107
  }
100
108
  ) }),
101
109
  /* @__PURE__ */ jsx("div", { className: "flex-1 min-h-0", children: filteredApps.length === 0 ? /* @__PURE__ */ jsxs(Empty, { children: [
@@ -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, 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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"}
1
+ {"version":3,"file":"AppCatalogPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/AppCatalogPage.tsx"],"sourcesContent":["import type { Resource } 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 { searchResources } 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 { resources, isLoadingApps, tagsDefinitions } = 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 // Get root resources for filtering (children handled internally by searchResources)\n const rootResources = useMemo(\n () => resources.filter((r) => !r.parentSlug),\n [resources],\n )\n\n const filteredApps = useMemo(() => {\n let result = rootResources\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 // Pass all resources so children contribute to parent scoring\n const childResources = resources.filter((r) => r.parentSlug)\n result = searchResources(\n [...result, ...childResources],\n deferredSearchValue,\n )\n\n return result\n }, [\n rootResources,\n resources,\n deferredSearchValue,\n filterState.recentMode,\n filterState.tagFilters,\n filterState.showDeprecated,\n topAppSlugs,\n ])\n\n // Calculate counts for FilterBar\n const { allCount, recentCount, deprecatedCount } = useAppCounts({\n apps: rootResources,\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: Resource) => {\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 = rootResources.length\n if (!filterState.showDeprecated) {\n count = rootResources.filter((app) => !app.deprecated).length\n }\n return count\n }, [rootResources, 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={rootResources}\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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"}
@@ -1,4 +1,7 @@
1
- import { Group, Person, SubResource } from '@igstack/app-catalog-backend-core';
1
+ import { Group, Person, Resource } from '@igstack/app-catalog-backend-core';
2
2
  export declare function getPersonBySlug(persons: Person[], slug: string): Person | undefined;
3
3
  export declare function getGroupBySlug(groups: Group[], slug: string): Group | undefined;
4
- export declare function getSubResourcesForApp(subResources: SubResource[], appSlug: string): SubResource[];
4
+ export declare function getChildResources(resources: Resource[], parentSlug: string): Resource[];
5
+ export declare function getRootResources(resources: Resource[]): Resource[];
6
+ /** @deprecated Use getChildResources instead */
7
+ export declare const getSubResourcesForApp: typeof getChildResources;
@@ -4,12 +4,12 @@ function getPersonBySlug(persons, slug) {
4
4
  function getGroupBySlug(groups, slug) {
5
5
  return groups.find((g) => g.slug === slug);
6
6
  }
7
- function getSubResourcesForApp(subResources, appSlug) {
8
- return subResources.filter((sr) => sr.appSlug === appSlug);
7
+ function getChildResources(resources, parentSlug) {
8
+ return resources.filter((r) => r.parentSlug === parentSlug);
9
9
  }
10
10
  export {
11
+ getChildResources,
11
12
  getGroupBySlug,
12
- getPersonBySlug,
13
- getSubResourcesForApp
13
+ getPersonBySlug
14
14
  };
15
15
  //# sourceMappingURL=resolveHelpers.js.map
@@ -1 +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
+ {"version":3,"file":"resolveHelpers.js","sources":["../../../../../src/modules/appCatalog/utils/resolveHelpers.ts"],"sourcesContent":["import type { Group, Person, Resource } 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 getChildResources(\n resources: Resource[],\n parentSlug: string,\n): Resource[] {\n return resources.filter((r) => r.parentSlug === parentSlug)\n}\n\nexport function getRootResources(resources: Resource[]): Resource[] {\n return resources.filter((r) => !r.parentSlug)\n}\n\n/** @deprecated Use getChildResources instead */\nexport const getSubResourcesForApp = getChildResources\n"],"names":[],"mappings":"AAEO,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,kBACd,WACA,YACY;AACZ,SAAO,UAAU,OAAO,CAAC,MAAM,EAAE,eAAe,UAAU;AAC5D;"}
@@ -1,4 +1,4 @@
1
- import { AppForCatalog, SubResource } from '@igstack/app-catalog-backend-core';
1
+ import { Resource } from '@igstack/app-catalog-backend-core';
2
2
  export interface SearchMatch {
3
3
  /** Field where the match occurred */
4
4
  field: 'displayName' | 'abbreviation' | 'nicknames' | 'slug' | 'tags' | 'teams' | 'description' | 'subResource';
@@ -6,11 +6,14 @@ export interface SearchMatch {
6
6
  type: 'exact' | 'prefix' | 'contains';
7
7
  }
8
8
  export interface SearchResult {
9
- app: AppForCatalog;
9
+ app: Resource;
10
10
  match: SearchMatch;
11
11
  }
12
12
  /**
13
- * Search and sort apps by relevance with highlighting support.
13
+ * Search and sort resources by relevance with highlighting support.
14
+ * Only root resources (no parentSlug) are scored and returned.
15
+ * Child resources (with parentSlug) contribute to their parent's score.
16
+ *
14
17
  * Priority order:
15
18
  * 0. Exact match in abbreviation
16
19
  * 1. Exact match in displayName
@@ -27,11 +30,11 @@ export interface SearchResult {
27
30
  * 12. Teams
28
31
  * 13. Description
29
32
  *
30
- * @param apps - Array of apps to search
33
+ * @param resources - Array of all resources (root + children)
31
34
  * @param searchQuery - Search query string
32
- * @returns Filtered and sorted array of apps with search results
35
+ * @returns Filtered and sorted array of root resources
33
36
  */
34
- export declare function searchApps(apps: AppForCatalog[], searchQuery: string, subResources?: SubResource[]): AppForCatalog[];
37
+ export declare function searchResources(resources: Resource[], searchQuery: string): Resource[];
35
38
  /**
36
39
  * Highlight matching text in a string
37
40
  * @param text - Text to highlight
@@ -42,3 +45,5 @@ export declare function highlightText(text: string, query: string): {
42
45
  text: string;
43
46
  highlight: boolean;
44
47
  }[];
48
+ /** @deprecated Use searchResources instead */
49
+ export declare const searchApps: typeof searchResources;
@@ -1,19 +1,20 @@
1
- function searchApps(apps, searchQuery, subResources) {
1
+ function searchResources(resources, searchQuery) {
2
2
  const normalizedQuery = searchQuery.trim().toLowerCase();
3
+ const rootResources = resources.filter((r) => !r.parentSlug);
3
4
  if (normalizedQuery === "") {
4
- return apps;
5
+ return rootResources;
5
6
  }
6
7
  const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
7
8
  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);
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);
14
15
  }
15
16
  }
16
- const scoredApps = apps.map((app) => {
17
+ const scoredApps = rootResources.map((app) => {
17
18
  var _a, _b, _c, _d, _e, _f, _g;
18
19
  const name = app.displayName.toLowerCase();
19
20
  const abbreviation = ((_a = app.abbreviation) == null ? void 0 : _a.toLowerCase()) || "";
@@ -63,10 +64,10 @@ function searchApps(apps, searchQuery, subResources) {
63
64
  if (allTermsMatch(description)) {
64
65
  return { app, match: { field: "description", type: "contains" } };
65
66
  }
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)
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)
70
71
  );
71
72
  if (subMatch) {
72
73
  return { app, match: { field: "subResource", type: "contains" } };
@@ -138,6 +139,6 @@ function highlightText(text, query) {
138
139
  }
139
140
  export {
140
141
  highlightText,
141
- searchApps
142
+ searchResources
142
143
  };
143
144
  //# sourceMappingURL=searchApps.js.map
@@ -1 +1 @@
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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"}
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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.3.1-alpha-20260405015231",
3
+ "version": "0.4.0",
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-20260405015231",
138
- "@igstack/app-catalog-shared-core": "0.3.1-alpha-20260405015231"
137
+ "@igstack/app-catalog-backend-core": "0.4.0",
138
+ "@igstack/app-catalog-shared-core": "0.4.0"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -125,19 +125,19 @@ describe('App Catalog Integration', () => {
125
125
  backendCfg.withSubResource({
126
126
  appSlug: app.slug,
127
127
  displayName: 'acct-prod',
128
- tierSlug: 'prod',
128
+ tier: 'prod',
129
129
  ownerPersonSlug: 'jsmith',
130
130
  })
131
131
  backendCfg.withSubResource({
132
132
  appSlug: app.slug,
133
133
  displayName: 'acct-dev',
134
- tierSlug: 'dev',
134
+ tier: 'dev',
135
135
  ownerPersonSlug: 'jdoe',
136
136
  })
137
137
  backendCfg.withSubResource({
138
138
  appSlug: app.slug,
139
139
  displayName: 'acct-staging',
140
- tierSlug: 'staging',
140
+ tier: 'staging',
141
141
  })
142
142
  }),
143
143
  )
@@ -1,14 +1,14 @@
1
- import type { AppForCatalog } from '@igstack/app-catalog-backend-core'
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import type { MockDb } from '../mock-backend/MockDb'
3
3
 
4
4
  export class MockBackendVerifier {
5
5
  constructor(readonly db: MockDb) {}
6
6
 
7
- apps(): AppForCatalog[] {
8
- return this.db.getApps()
7
+ apps(): Resource[] {
8
+ return this.db.getResources()
9
9
  }
10
10
 
11
- getApp(slug: string): AppForCatalog {
12
- return this.db.getApp(slug)
11
+ getApp(slug: string): Resource {
12
+ return this.db.getResource(slug)
13
13
  }
14
14
  }
@@ -1,8 +1,7 @@
1
1
  import type {
2
2
  AppApprovalMethod,
3
- AppForCatalog,
4
3
  GroupingTagDefinition,
5
- SubResource,
4
+ Resource,
6
5
  } from '@igstack/app-catalog-backend-core'
7
6
  import type { MockDb } from './MockDb'
8
7
  import type { MockUserContext, UserConfig } from './MockUserContext'
@@ -24,9 +23,9 @@ export class MockBackendConfigurer {
24
23
  readonly userContext: MockUserContext,
25
24
  ) {}
26
25
 
27
- withApp(overrides?: Partial<AppForCatalog>): AppForCatalog {
26
+ withApp(overrides?: Partial<Resource>): Resource {
28
27
  const id = overrides?.id ?? nextId()
29
- const app: AppForCatalog = {
28
+ const app: Resource = {
30
29
  id,
31
30
  slug: overrides?.slug ?? nextSlug(),
32
31
  displayName: overrides?.displayName ?? `App ${counter}`,
@@ -35,7 +34,7 @@ export class MockBackendConfigurer {
35
34
  screenshotIds: overrides?.screenshotIds ?? [],
36
35
  ...overrides,
37
36
  }
38
- this.db.upsertApp(app)
37
+ this.db.upsertResource(app)
39
38
  return app
40
39
  }
41
40
 
@@ -67,17 +66,22 @@ export class MockBackendConfigurer {
67
66
  }
68
67
 
69
68
  withSubResource(
70
- overrides: Partial<SubResource> & { appSlug: string },
71
- ): SubResource {
72
- const sr: SubResource = {
73
- slug: overrides.slug ?? `sr-${nextId()}`,
69
+ overrides: Partial<Resource> & { appSlug: string },
70
+ ): Resource {
71
+ const appSlug = overrides.appSlug
72
+ const id = overrides.id ?? nextId()
73
+ const resource: Resource = {
74
+ id,
75
+ slug: overrides.slug ?? `sr-${id}`,
74
76
  displayName: overrides.displayName ?? `Sub Resource ${counter}`,
75
77
  aliases: overrides.aliases ?? [],
76
78
  accessMaintainerGroupSlugs: overrides.accessMaintainerGroupSlugs ?? [],
77
- ...overrides,
79
+ parentSlug: appSlug,
80
+ tier: overrides.tier,
81
+ ownerPersonSlug: overrides.ownerPersonSlug,
78
82
  }
79
- this.db.addSubResource(sr)
80
- return sr
83
+ this.db.upsertResource(resource)
84
+ return resource
81
85
  }
82
86
 
83
87
  withUser(overrides: Partial<UserConfig>): void {
@@ -1,33 +1,49 @@
1
1
  import type {
2
2
  AppApprovalMethod,
3
3
  AppCatalogData,
4
- AppForCatalog,
5
4
  GroupingTagDefinition,
6
- SubResource,
5
+ Resource,
7
6
  } from '@igstack/app-catalog-backend-core'
8
7
 
9
8
  export class MockDb {
10
- apps: AppForCatalog[] = []
9
+ resources: Resource[] = []
11
10
  tagsDefinitions: GroupingTagDefinition[] = []
12
11
  approvalMethods: AppApprovalMethod[] = []
13
- subResources: SubResource[] = []
14
12
 
15
- upsertApp(app: AppForCatalog): void {
16
- this.apps = [...this.apps.filter((a) => a.id !== app.id), app]
13
+ upsertResource(resource: Resource): void {
14
+ this.resources = [
15
+ ...this.resources.filter((r) => r.id !== resource.id),
16
+ resource,
17
+ ]
18
+ }
19
+
20
+ /** @deprecated Use upsertResource */
21
+ upsertApp(app: Resource): void {
22
+ this.upsertResource(app)
23
+ }
24
+
25
+ getResources(): Resource[] {
26
+ return this.resources
17
27
  }
18
28
 
19
- getApps(): AppForCatalog[] {
20
- return this.apps
29
+ /** @deprecated Use getResources */
30
+ getApps(): Resource[] {
31
+ return this.getResources()
21
32
  }
22
33
 
23
- getApp(slug: string): AppForCatalog {
24
- const app = this.apps.find((a) => a.slug === slug)
25
- if (!app) {
34
+ getResource(slug: string): Resource {
35
+ const resource = this.resources.find((r) => r.slug === slug)
36
+ if (!resource) {
26
37
  throw new Error(
27
- `MockDb: app with slug "${slug}" not found. Available: ${this.apps.map((a) => a.slug).join(', ')}`,
38
+ `MockDb: resource with slug "${slug}" not found. Available: ${this.resources.map((r) => r.slug).join(', ')}`,
28
39
  )
29
40
  }
30
- return app
41
+ return resource
42
+ }
43
+
44
+ /** @deprecated Use getResource */
45
+ getApp(slug: string): Resource {
46
+ return this.getResource(slug)
31
47
  }
32
48
 
33
49
  setTagDefinitions(defs: GroupingTagDefinition[]): void {
@@ -38,21 +54,13 @@ export class MockDb {
38
54
  this.approvalMethods = methods
39
55
  }
40
56
 
41
- addSubResource(sr: SubResource): void {
42
- this.subResources = [
43
- ...this.subResources.filter((s) => s.slug !== sr.slug),
44
- sr,
45
- ]
46
- }
47
-
48
57
  getAppCatalogData(): AppCatalogData {
49
58
  return {
50
- apps: this.apps,
59
+ resources: this.resources,
51
60
  tagsDefinitions: this.tagsDefinitions,
52
61
  approvalMethods: this.approvalMethods,
53
62
  persons: [],
54
63
  groups: [],
55
- subResources: this.subResources,
56
64
  }
57
65
  }
58
66
  }
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
2
2
  import { describe, expect, it } from 'vitest'
3
3
  import '@testing-library/jest-dom/vitest'
4
4
 
5
- import type { AppForCatalog } from '@igstack/app-catalog-backend-core'
5
+ import type { Resource } from '@igstack/app-catalog-backend-core'
6
6
  import { AppDetailModal } from '~/modules/appCatalog/ui/components/AppDetailModal'
7
7
  import { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
8
8
  import type { AppCatalogContextIface } from '~/modules/appCatalog/context/AppCatalogContext'
@@ -10,16 +10,15 @@ import type { AppCatalogContextIface } from '~/modules/appCatalog/context/AppCat
10
10
  // Minimal context value — ScreenshotPreview doesn't use context directly,
11
11
  // but AccessSection (sibling in AppDetailModal) does.
12
12
  const minimalContext: AppCatalogContextIface = {
13
- apps: [],
13
+ resources: [],
14
14
  isLoadingApps: false,
15
15
  tagsDefinitions: [],
16
16
  approvalMethods: [],
17
17
  persons: [],
18
18
  groups: [],
19
- subResources: [],
20
19
  }
21
20
 
22
- function renderWithContext(app: AppForCatalog) {
21
+ function renderWithContext(app: Resource) {
23
22
  return render(
24
23
  <AppCatalogContext value={minimalContext}>
25
24
  <AppDetailModal app={app} isOpen={true} onClose={() => {}} />
@@ -27,7 +26,7 @@ function renderWithContext(app: AppForCatalog) {
27
26
  )
28
27
  }
29
28
 
30
- function makeApp(screenshotIds: string[]): AppForCatalog {
29
+ function makeApp(screenshotIds: string[]): Resource {
31
30
  return {
32
31
  id: 'app-1',
33
32
  slug: 'test-app',