@igstack/app-catalog-frontend-core 0.6.3 → 0.6.4

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.
@@ -24,6 +24,12 @@ export declare class CatalogTools {
24
24
  * Whether the right detail panel is currently visible.
25
25
  */
26
26
  isDetailPanelOpen(): boolean;
27
+ /** Whether the "Show Deprecated Apps" toggle is currently checked. */
28
+ isShowDeprecatedChecked(): boolean;
29
+ /** Whether the "showing deprecated matches" fallback notice is visible. */
30
+ hasDeprecatedFallbackNotice(): boolean;
31
+ /** Whether the "No apps found" empty state is visible. */
32
+ isEmptyStateVisible(): boolean;
27
33
  /**
28
34
  * Whether the onboarding/welcome card is visible.
29
35
  */
@@ -31,32 +31,44 @@ function AppCatalogPage({
31
31
  () => resources.filter((r) => !r.parentSlug),
32
32
  [resources]
33
33
  );
34
- const filteredApps = useMemo(() => {
35
- let result = rootResources;
36
- if (!filterState.showDeprecated) {
37
- result = result.filter((app) => !app.deprecated);
38
- }
39
- if (filterState.recentMode) {
40
- result = result.filter((app) => topAppSlugs.includes(app.slug));
41
- } else if (Object.keys(filterState.tagFilters).length > 0) {
42
- result = result.filter((app) => {
43
- return Object.entries(filterState.tagFilters).every(
44
- ([prefix, value]) => {
45
- var _a;
46
- const fullTag = `${prefix}:${value}`;
47
- return (_a = app.tags) == null ? void 0 : _a.some(
48
- (tag) => tag.toLowerCase() === fullTag.toLowerCase()
49
- );
50
- }
51
- );
52
- });
53
- }
34
+ useEffect(() => {
35
+ }, [isLoadingApps, resources.length, rootResources.length]);
36
+ const { filteredApps, didDeprecatedFallback } = useMemo(() => {
54
37
  const childResources = resources.filter((r) => r.parentSlug);
55
- result = searchResources(
56
- [...result, ...childResources],
57
- deferredSearchValue
38
+ const runPipeline = (base) => {
39
+ let result2 = base;
40
+ if (filterState.recentMode) {
41
+ result2 = result2.filter((app) => topAppSlugs.includes(app.slug));
42
+ } else if (Object.keys(filterState.tagFilters).length > 0) {
43
+ result2 = result2.filter((app) => {
44
+ return Object.entries(filterState.tagFilters).every(
45
+ ([prefix, value]) => {
46
+ var _a;
47
+ const fullTag = `${prefix}:${value}`;
48
+ return (_a = app.tags) == null ? void 0 : _a.some(
49
+ (tag) => tag.toLowerCase() === fullTag.toLowerCase()
50
+ );
51
+ }
52
+ );
53
+ });
54
+ }
55
+ return searchResources(
56
+ [...result2, ...childResources],
57
+ deferredSearchValue
58
+ );
59
+ };
60
+ const activeMatches = runPipeline(
61
+ rootResources.filter((app) => !app.deprecated)
58
62
  );
59
- return result;
63
+ const hasQuery = deferredSearchValue.trim() !== "";
64
+ if (hasQuery && activeMatches.length === 0) {
65
+ const allMatches = runPipeline(rootResources);
66
+ if (allMatches.length > 0) {
67
+ return { filteredApps: allMatches, didDeprecatedFallback: true };
68
+ }
69
+ }
70
+ const result = filterState.showDeprecated ? runPipeline(rootResources) : activeMatches;
71
+ return { filteredApps: result, didDeprecatedFallback: false };
60
72
  }, [
61
73
  rootResources,
62
74
  resources,
@@ -66,6 +78,11 @@ function AppCatalogPage({
66
78
  filterState.showDeprecated,
67
79
  topAppSlugs
68
80
  ]);
81
+ useEffect(() => {
82
+ if (didDeprecatedFallback && !filterState.showDeprecated) {
83
+ actions.setShowDeprecated(true);
84
+ }
85
+ }, [didDeprecatedFallback, filterState.showDeprecated, actions]);
69
86
  const { allCount, recentCount, deprecatedCount } = useAppCounts({
70
87
  apps: rootResources,
71
88
  topAppSlugs,
@@ -118,6 +135,14 @@ function AppCatalogPage({
118
135
  apps: rootResources
119
136
  }
120
137
  ) }),
138
+ didDeprecatedFallback && /* @__PURE__ */ jsx(
139
+ "div",
140
+ {
141
+ role: "status",
142
+ className: "shrink-0 px-1 pb-2 text-sm text-muted-foreground",
143
+ children: `No active apps${searchValue ? ` for "${searchValue}"` : ""} — showing deprecated matches.`
144
+ }
145
+ ),
121
146
  /* @__PURE__ */ jsx("div", { className: "flex-1 min-h-0", children: filteredApps.length === 0 ? /* @__PURE__ */ jsxs(Empty, { children: [
122
147
  /* @__PURE__ */ jsxs(EmptyHeader, { children: [
123
148
  /* @__PURE__ */ jsx(EmptyMedia, { variant: "icon", children: /* @__PURE__ */ jsx(X, { className: "h-6 w-6" }) }),
@@ -1 +1 @@
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 { useNavigate } from '@tanstack/react-router'\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 { 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 selectedSlug,\n}: { selectedSlug?: string } = {}) {\n const { resources, isLoadingApps, tagsDefinitions } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const { getTopApps } = useAppClickHistory()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\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. Carry the *current* search value into\n // the URL (`q`) as part of this navigation so the search input and focus\n // survive the route change (#10). We must inject `searchValue` explicitly\n // rather than rely on `(prev) => prev`: this child effect runs before the\n // provider's async state→URL sync effect, so at nav time the URL does not yet\n // hold `q` and the query would otherwise be lost on remount.\n useEffect(() => {\n if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: filteredApps[0].slug },\n search: (prev) => ({\n ...prev,\n q: searchValue === '' ? undefined : searchValue,\n }),\n replace: true,\n })\n }\n }, [filteredApps, selectedAppSlug, navigate, searchValue])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleClearFilters = () => {\n setSearchValue('')\n actions.clearAllFilters()\n void navigate({ to: '/' })\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 void navigate({ to: '/' })\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 onClosePanel={() => void navigate({ to: '/' })}\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 { useNavigate } from '@tanstack/react-router'\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 { 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 selectedSlug,\n}: { selectedSlug?: string } = {}) {\n const { resources, isLoadingApps, tagsDefinitions } = useAppCatalogContext()\n const { state: filterState, actions } = useAppCatalogFilters()\n const { getTopApps } = useAppClickHistory()\n const navigate = useNavigate()\n\n // Selected app comes from the route path (/app/<slug>); undefined = nothing open\n const selectedAppSlug = selectedSlug\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 // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level\n // (rootResources === 0). That fingerprints a frontend/backend-core version skew\n // or a stale service worker — NOT a normal empty search/filter (those still have\n // rootResources). Guarded to dev so prod users never see it.\n useEffect(() => {\n if (\n import.meta.env.DEV &&\n !isLoadingApps &&\n resources.length > 0 &&\n rootResources.length === 0\n ) {\n console.warn(\n `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +\n `likely a frontend/backend-core version skew or a stale service worker. ` +\n `Check the version footer (be X / fe Y) and hard-reload.`,\n )\n }\n }, [isLoadingApps, resources.length, rootResources.length])\n\n const { filteredApps, didDeprecatedFallback } = useMemo(() => {\n const childResources = resources.filter((r) => r.parentSlug)\n\n // Apply recent mode / tag filters / search over a base set of root apps.\n const runPipeline = (base: typeof rootResources) => {\n let result = base\n\n // 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 // Apply search (using deferred value). Pass all resources so children\n // contribute to parent scoring.\n return searchResources(\n [...result, ...childResources],\n deferredSearchValue,\n )\n }\n\n // Matches among non-deprecated (\"active\") apps only.\n const activeMatches = runPipeline(\n rootResources.filter((app) => !app.deprecated),\n )\n\n // #11: derive the fallback purely from the data (not the toggle), so it\n // stays stable after the toggle auto-enables below — otherwise the notice\n // would flicker off as soon as showDeprecated flips to true.\n // Fallback applies when there's a query, no active matches, but deprecated\n // apps DO match.\n const hasQuery = deferredSearchValue.trim() !== ''\n if (hasQuery && activeMatches.length === 0) {\n const allMatches = runPipeline(rootResources)\n if (allMatches.length > 0) {\n return { filteredApps: allMatches, didDeprecatedFallback: true }\n }\n }\n\n // Normal display: include deprecated only when the toggle is on.\n const result = filterState.showDeprecated\n ? runPipeline(rootResources)\n : activeMatches\n return { filteredApps: result, didDeprecatedFallback: false }\n }, [\n rootResources,\n resources,\n deferredSearchValue,\n filterState.recentMode,\n filterState.tagFilters,\n filterState.showDeprecated,\n topAppSlugs,\n ])\n\n // #11: when the search fell back to deprecated results, enable the\n // \"Show Deprecated Apps\" toggle so the state is visible and consistent.\n // Runs as an effect (not in render) to avoid a side effect during useMemo.\n useEffect(() => {\n if (didDeprecatedFallback && !filterState.showDeprecated) {\n actions.setShowDeprecated(true)\n }\n }, [didDeprecatedFallback, filterState.showDeprecated, actions])\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. Carry the *current* search value into\n // the URL (`q`) as part of this navigation so the search input and focus\n // survive the route change (#10). We must inject `searchValue` explicitly\n // rather than rely on `(prev) => prev`: this child effect runs before the\n // provider's async state→URL sync effect, so at nav time the URL does not yet\n // hold `q` and the query would otherwise be lost on remount.\n useEffect(() => {\n if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: filteredApps[0].slug },\n search: (prev) => ({\n ...prev,\n q: searchValue === '' ? undefined : searchValue,\n }),\n replace: true,\n })\n }\n }, [filteredApps, selectedAppSlug, navigate, searchValue])\n\n const handleAppClick = (app: Resource) => {\n void navigate({\n to: '/app/$slug',\n params: { slug: app.slug },\n search: (prev) => prev,\n })\n }\n\n const handleClearFilters = () => {\n setSearchValue('')\n actions.clearAllFilters()\n void navigate({ to: '/' })\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 {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"shrink-0 px-1 pb-2 text-sm text-muted-foreground\"\n >\n {`No active apps${\n searchValue ? ` for \"${searchValue}\"` : ''\n } — showing deprecated matches.`}\n </div>\n )}\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 void navigate({ to: '/' })\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 onClosePanel={() => void navigate({ to: '/' })}\n hasSearch={!!deferredSearchValue}\n searchQuery={deferredSearchValue}\n totalAppsCount={totalAppsCount}\n onClearFilters={handleClearFilters}\n />\n )}\n </div>\n </div>\n )\n}\n"],"names":["result"],"mappings":";;;;;;;;;;;;;;AAsBO,SAAS,eAAe;AAAA,EAC7B;AACF,IAA+B,IAAI;AACjC,QAAM,EAAE,WAAW,eAAe,gBAAA,IAAoB,qBAAA;AACtD,QAAM,EAAE,OAAO,aAAa,QAAA,IAAY,qBAAA;AACxC,QAAM,EAAE,WAAA,IAAe,mBAAA;AACvB,QAAM,WAAW,YAAA;AAGjB,QAAM,kBAAkB;AAGxB,QAAM,cAAc,YAAY;AAChC,QAAM,iBAAiB,QAAQ;AAG/B,QAAM,sBAAsB,iBAAiB,WAAW;AAGxD,QAAM,CAAC,aAAa,cAAc,IAAI,SAAmB,CAAA,CAAE;AAG3D,YAAU,MAAM;AACd,SAAK,WAAW,EAAE,EAAE,KAAK,cAAc;AAAA,EACzC,GAAG,CAAC,UAAU,CAAC;AAGf,QAAM,gBAAgB;AAAA,IACpB,MAAM,UAAU,OAAO,CAAC,MAAM,CAAC,EAAE,UAAU;AAAA,IAC3C,CAAC,SAAS;AAAA,EAAA;AAOZ,YAAU,MAAM;AAAA,EAahB,GAAG,CAAC,eAAe,UAAU,QAAQ,cAAc,MAAM,CAAC;AAE1D,QAAM,EAAE,cAAc,sBAAA,IAA0B,QAAQ,MAAM;AAC5D,UAAM,iBAAiB,UAAU,OAAO,CAAC,MAAM,EAAE,UAAU;AAG3D,UAAM,cAAc,CAAC,SAA+B;AAClD,UAAIA,UAAS;AAGb,UAAI,YAAY,YAAY;AAE1BA,kBAASA,QAAO,OAAO,CAAC,QAAQ,YAAY,SAAS,IAAI,IAAI,CAAC;AAAA,MAChE,WAAW,OAAO,KAAK,YAAY,UAAU,EAAE,SAAS,GAAG;AAEzDA,kBAASA,QAAO,OAAO,CAAC,QAAQ;AAC9B,iBAAO,OAAO,QAAQ,YAAY,UAAU,EAAE;AAAA,YAC5C,CAAC,CAAC,QAAQ,KAAK,MAAM;;AACnB,oBAAM,UAAU,GAAG,MAAM,IAAI,KAAK;AAClC,sBAAO,SAAI,SAAJ,mBAAU;AAAA,gBACf,CAAC,QAAQ,IAAI,YAAA,MAAkB,QAAQ,YAAA;AAAA;AAAA,YAE3C;AAAA,UAAA;AAAA,QAEJ,CAAC;AAAA,MACH;AAIA,aAAO;AAAA,QACL,CAAC,GAAGA,SAAQ,GAAG,cAAc;AAAA,QAC7B;AAAA,MAAA;AAAA,IAEJ;AAGA,UAAM,gBAAgB;AAAA,MACpB,cAAc,OAAO,CAAC,QAAQ,CAAC,IAAI,UAAU;AAAA,IAAA;AAQ/C,UAAM,WAAW,oBAAoB,KAAA,MAAW;AAChD,QAAI,YAAY,cAAc,WAAW,GAAG;AAC1C,YAAM,aAAa,YAAY,aAAa;AAC5C,UAAI,WAAW,SAAS,GAAG;AACzB,eAAO,EAAE,cAAc,YAAY,uBAAuB,KAAA;AAAA,MAC5D;AAAA,IACF;AAGA,UAAM,SAAS,YAAY,iBACvB,YAAY,aAAa,IACzB;AACJ,WAAO,EAAE,cAAc,QAAQ,uBAAuB,MAAA;AAAA,EACxD,GAAG;AAAA,IACD;AAAA,IACA;AAAA,IACA;AAAA,IACA,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ;AAAA,EAAA,CACD;AAKD,YAAU,MAAM;AACd,QAAI,yBAAyB,CAAC,YAAY,gBAAgB;AACxD,cAAQ,kBAAkB,IAAI;AAAA,IAChC;AAAA,EACF,GAAG,CAAC,uBAAuB,YAAY,gBAAgB,OAAO,CAAC;AAG/D,QAAM,EAAE,UAAU,aAAa,gBAAA,IAAoB,aAAa;AAAA,IAC9D,MAAM;AAAA,IACN;AAAA,IACA,aAAa;AAAA,EAAA,CACd;AAQD,YAAU,MAAM;AACd,QAAI,aAAa,WAAW,KAAK,aAAa,CAAC,KAAK,CAAC,iBAAiB;AACpE,WAAK,SAAS;AAAA,QACZ,IAAI;AAAA,QACJ,QAAQ,EAAE,MAAM,aAAa,CAAC,EAAE,KAAA;AAAA,QAChC,QAAQ,CAAC,UAAU;AAAA,UACjB,GAAG;AAAA,UACH,GAAG,gBAAgB,KAAK,SAAY;AAAA,QAAA;AAAA,QAEtC,SAAS;AAAA,MAAA,CACV;AAAA,IACH;AAAA,EACF,GAAG,CAAC,cAAc,iBAAiB,UAAU,WAAW,CAAC;AAEzD,QAAM,iBAAiB,CAAC,QAAkB;AACxC,SAAK,SAAS;AAAA,MACZ,IAAI;AAAA,MACJ,QAAQ,EAAE,MAAM,IAAI,KAAA;AAAA,MACpB,QAAQ,CAAC,SAAS;AAAA,IAAA,CACnB;AAAA,EACH;AAEA,QAAM,qBAAqB,MAAM;AAC/B,mBAAe,EAAE;AACjB,YAAQ,gBAAA;AACR,SAAK,SAAS,EAAE,IAAI,KAAK;AAAA,EAC3B;AAGA,QAAM,iBAAiB,QAAQ,MAAM;AACnC,QAAI,QAAQ,cAAc;AAC1B,QAAI,CAAC,YAAY,gBAAgB;AAC/B,cAAQ,cAAc,OAAO,CAAC,QAAQ,CAAC,IAAI,UAAU,EAAE;AAAA,IACzD;AACA,WAAO;AAAA,EACT,GAAG,CAAC,eAAe,YAAY,cAAc,CAAC;AAE9C,MAAI,eAAe;AACjB,+BAAQ,OAAA,EAAI,WAAU,8BAA6B,UAAA,YAAQ;AAAA,EAC7D;AAGA,QAAM,qBAAqB,gBAAgB,CAAC;AAE5C,SACE,qBAAC,OAAA,EAAI,WAAU,gCACb,UAAA;AAAA,IAAA,oBAAC,OAAA,EAAI,WAAU,YACb,UAAA,oBAAC,kBAAe,GAClB;AAAA,IAEA,oBAAC,OAAA,EAAI,WAAU,YACb,UAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,YAAY;AAAA,QACZ;AAAA,QACA;AAAA,QACA,MAAM;AAAA,MAAA;AAAA,IAAA,GAEV;AAAA,IAEC,yBACC;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,WAAU;AAAA,QAET,UAAA,iBACC,cAAc,SAAS,WAAW,MAAM,EAC1C;AAAA,MAAA;AAAA,IAAA;AAAA,IAIJ,oBAAC,SAAI,WAAU,kBACZ,uBAAa,WAAW,yBACtB,OAAA,EACC,UAAA;AAAA,MAAA,qBAAC,aAAA,EACC,UAAA;AAAA,QAAA,oBAAC,cAAW,SAAQ,QAClB,8BAAC,GAAA,EAAE,WAAU,UAAA,CAAU,GACzB;AAAA,6BACC,YAAA,EAAW,UAAA;AAAA,UAAA;AAAA,UACI,eAAe,SAAS,WAAW;AAAA,QAAA,GACnD;AAAA,QACA,oBAAC,oBAAiB,UAAA,wCAElB;AAAA,MAAA,GACF;AAAA,MACA,oBAAC,gBACE,UAAA,eACC;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,SAAQ;AAAA,UACR,SAAS,MAAM;AACb,2BAAe,EAAE;AACjB,iBAAK,SAAS,EAAE,IAAI,KAAK;AAAA,UAC3B;AAAA,UACA,WAAU;AAAA,UAEV,UAAA;AAAA,YAAA,oBAAC,GAAA,EAAE,WAAU,WAAU;AAAA,YAAE;AAAA,UAAA;AAAA,QAAA;AAAA,MAAA,GAI/B;AAAA,IAAA,EAAA,CACF,IAEA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAM;AAAA,QACN;AAAA,QACA;AAAA,QACA,YAAY;AAAA,QACZ,cAAc,MAAM,KAAK,SAAS,EAAE,IAAI,KAAK;AAAA,QAC7C,WAAW,CAAC,CAAC;AAAA,QACb,aAAa;AAAA,QACb;AAAA,QACA,gBAAgB;AAAA,MAAA;AAAA,IAAA,GAGtB;AAAA,EAAA,GACF;AAEJ;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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.6.3",
138
- "@igstack/app-catalog-shared-core": "0.6.3"
137
+ "@igstack/app-catalog-backend-core": "0.6.4",
138
+ "@igstack/app-catalog-shared-core": "0.6.4"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -144,7 +144,7 @@
144
144
  "vite": "^6.3.5",
145
145
  "vite-plugin-svgr": "^4.2.0"
146
146
  },
147
- "gitHead": "0d35c345e9b2647c8c02b552b82f3dd9cd77682c",
147
+ "gitHead": "9eee5ee32492cb00394dfe206f41e929bd7f739d",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { waitFor } from '@testing-library/react'
3
+ import '@testing-library/jest-dom/vitest'
4
+
5
+ import { given } from './harness/given'
6
+ import { magazine } from './mock-backend/magazines'
7
+
8
+ // #11: when a search returns 0 non-deprecated results, fall back to including
9
+ // deprecated apps — show them, surface a notice, and auto-enable the
10
+ // "Show Deprecated Apps" toggle. `magazine.full()` includes a deprecated app
11
+ // "Old Tool" (slug old-tool) that is the only match for "old tool".
12
+ describe('Search fallback to deprecated apps (#11)', () => {
13
+ it('shows deprecated matches + notice + enables the toggle when 0 active results', async () => {
14
+ // Seed the query via the URL (`q`) to avoid the jsdom useDeferredValue +
15
+ // userEvent typing race. "old tool" matches ONLY the deprecated "Old Tool".
16
+ const { ui } = await given(magazine.full(), {
17
+ initialRoute: '/?q=old%20tool',
18
+ })
19
+
20
+ // The fallback surfaces the deprecated match, shows the notice, and the
21
+ // "Show Deprecated Apps" toggle is auto-enabled.
22
+ await waitFor(() => {
23
+ expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(true)
24
+ })
25
+ expect(ui.catalog.getTableData().map((r) => r.name)).toContain('Old Tool')
26
+ expect(ui.catalog.isShowDeprecatedChecked()).toBe(true)
27
+ })
28
+
29
+ it('does not force deprecated results when the query has an active match', async () => {
30
+ const { ui } = await given(magazine.full(), { initialRoute: '/?q=Jira' })
31
+
32
+ await waitFor(() => {
33
+ const names = ui.catalog.getTableData().map((r) => r.name)
34
+ expect(names).toContain('Jira')
35
+ })
36
+ // Active match exists → no fallback, no notice, toggle stays off.
37
+ expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(false)
38
+ expect(ui.catalog.isShowDeprecatedChecked()).toBe(false)
39
+ })
40
+
41
+ it('keeps the normal empty state when nothing matches at all', async () => {
42
+ const { ui } = await given(magazine.full(), {
43
+ initialRoute: '/?q=zzz-nothing-matches-zzz',
44
+ })
45
+
46
+ await waitFor(() => {
47
+ expect(ui.catalog.isEmptyStateVisible()).toBe(true)
48
+ })
49
+ expect(ui.catalog.hasDeprecatedFallbackNotice()).toBe(false)
50
+ })
51
+ })
@@ -93,6 +93,25 @@ export class CatalogTools {
93
93
  return !!screen.queryByLabelText('Close details panel')
94
94
  }
95
95
 
96
+ /** Whether the "Show Deprecated Apps" toggle is currently checked. */
97
+ isShowDeprecatedChecked(): boolean {
98
+ const cb = screen.getByRole('checkbox', { name: /Show Deprecated Apps/i })
99
+ return (
100
+ cb.getAttribute('aria-checked') === 'true' ||
101
+ cb.getAttribute('data-state') === 'checked'
102
+ )
103
+ }
104
+
105
+ /** Whether the "showing deprecated matches" fallback notice is visible. */
106
+ hasDeprecatedFallbackNotice(): boolean {
107
+ return !!screen.queryByText(/showing deprecated matches/i)
108
+ }
109
+
110
+ /** Whether the "No apps found" empty state is visible. */
111
+ isEmptyStateVisible(): boolean {
112
+ return !!screen.queryByText(/No apps found/i)
113
+ }
114
+
96
115
  /**
97
116
  * Whether the onboarding/welcome card is visible.
98
117
  */
@@ -52,41 +52,81 @@ export function AppCatalogPage({
52
52
  [resources],
53
53
  )
54
54
 
55
- const filteredApps = useMemo(() => {
56
- let result = rootResources
57
-
58
- // Step 1: Filter deprecated apps (if not showing them)
59
- if (!filterState.showDeprecated) {
60
- result = result.filter((app) => !app.deprecated)
55
+ // Dev-only skew warning: data arrived (resources > 0) but nothing is top-level
56
+ // (rootResources === 0). That fingerprints a frontend/backend-core version skew
57
+ // or a stale service worker — NOT a normal empty search/filter (those still have
58
+ // rootResources). Guarded to dev so prod users never see it.
59
+ useEffect(() => {
60
+ if (
61
+ import.meta.env.DEV &&
62
+ !isLoadingApps &&
63
+ resources.length > 0 &&
64
+ rootResources.length === 0
65
+ ) {
66
+ console.warn(
67
+ `[app-catalog] Loaded ${resources.length} resources but 0 are top-level — ` +
68
+ `likely a frontend/backend-core version skew or a stale service worker. ` +
69
+ `Check the version footer (be X / fe Y) and hard-reload.`,
70
+ )
61
71
  }
72
+ }, [isLoadingApps, resources.length, rootResources.length])
62
73
 
63
- // Step 2: Apply recent mode or tag filters
64
- if (filterState.recentMode) {
65
- // Filter to top 10 most clicked apps
66
- result = result.filter((app) => topAppSlugs.includes(app.slug))
67
- } else if (Object.keys(filterState.tagFilters).length > 0) {
68
- // Apply tag filters (AND condition)
69
- result = result.filter((app) => {
70
- return Object.entries(filterState.tagFilters).every(
71
- ([prefix, value]) => {
72
- const fullTag = `${prefix}:${value}`
73
- return app.tags?.some(
74
- (tag) => tag.toLowerCase() === fullTag.toLowerCase(),
75
- )
76
- },
77
- )
78
- })
74
+ const { filteredApps, didDeprecatedFallback } = useMemo(() => {
75
+ const childResources = resources.filter((r) => r.parentSlug)
76
+
77
+ // Apply recent mode / tag filters / search over a base set of root apps.
78
+ const runPipeline = (base: typeof rootResources) => {
79
+ let result = base
80
+
81
+ // Apply recent mode or tag filters
82
+ if (filterState.recentMode) {
83
+ // Filter to top 10 most clicked apps
84
+ result = result.filter((app) => topAppSlugs.includes(app.slug))
85
+ } else if (Object.keys(filterState.tagFilters).length > 0) {
86
+ // Apply tag filters (AND condition)
87
+ result = result.filter((app) => {
88
+ return Object.entries(filterState.tagFilters).every(
89
+ ([prefix, value]) => {
90
+ const fullTag = `${prefix}:${value}`
91
+ return app.tags?.some(
92
+ (tag) => tag.toLowerCase() === fullTag.toLowerCase(),
93
+ )
94
+ },
95
+ )
96
+ })
97
+ }
98
+
99
+ // Apply search (using deferred value). Pass all resources so children
100
+ // contribute to parent scoring.
101
+ return searchResources(
102
+ [...result, ...childResources],
103
+ deferredSearchValue,
104
+ )
79
105
  }
80
106
 
81
- // Step 3: Apply search (using deferred value)
82
- // Pass all resources so children contribute to parent scoring
83
- const childResources = resources.filter((r) => r.parentSlug)
84
- result = searchResources(
85
- [...result, ...childResources],
86
- deferredSearchValue,
107
+ // Matches among non-deprecated ("active") apps only.
108
+ const activeMatches = runPipeline(
109
+ rootResources.filter((app) => !app.deprecated),
87
110
  )
88
111
 
89
- return result
112
+ // #11: derive the fallback purely from the data (not the toggle), so it
113
+ // stays stable after the toggle auto-enables below — otherwise the notice
114
+ // would flicker off as soon as showDeprecated flips to true.
115
+ // Fallback applies when there's a query, no active matches, but deprecated
116
+ // apps DO match.
117
+ const hasQuery = deferredSearchValue.trim() !== ''
118
+ if (hasQuery && activeMatches.length === 0) {
119
+ const allMatches = runPipeline(rootResources)
120
+ if (allMatches.length > 0) {
121
+ return { filteredApps: allMatches, didDeprecatedFallback: true }
122
+ }
123
+ }
124
+
125
+ // Normal display: include deprecated only when the toggle is on.
126
+ const result = filterState.showDeprecated
127
+ ? runPipeline(rootResources)
128
+ : activeMatches
129
+ return { filteredApps: result, didDeprecatedFallback: false }
90
130
  }, [
91
131
  rootResources,
92
132
  resources,
@@ -97,6 +137,15 @@ export function AppCatalogPage({
97
137
  topAppSlugs,
98
138
  ])
99
139
 
140
+ // #11: when the search fell back to deprecated results, enable the
141
+ // "Show Deprecated Apps" toggle so the state is visible and consistent.
142
+ // Runs as an effect (not in render) to avoid a side effect during useMemo.
143
+ useEffect(() => {
144
+ if (didDeprecatedFallback && !filterState.showDeprecated) {
145
+ actions.setShowDeprecated(true)
146
+ }
147
+ }, [didDeprecatedFallback, filterState.showDeprecated, actions])
148
+
100
149
  // Calculate counts for FilterBar
101
150
  const { allCount, recentCount, deprecatedCount } = useAppCounts({
102
151
  apps: rootResources,
@@ -169,6 +218,17 @@ export function AppCatalogPage({
169
218
  />
170
219
  </div>
171
220
 
221
+ {didDeprecatedFallback && (
222
+ <div
223
+ role="status"
224
+ className="shrink-0 px-1 pb-2 text-sm text-muted-foreground"
225
+ >
226
+ {`No active apps${
227
+ searchValue ? ` for "${searchValue}"` : ''
228
+ } — showing deprecated matches.`}
229
+ </div>
230
+ )}
231
+
172
232
  <div className="flex-1 min-h-0">
173
233
  {filteredApps.length === 0 ? (
174
234
  <Empty>