@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.
- package/dist/esm/__tests__/integration/searchDeprecatedFallback.integration.test.d.ts +0 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +49 -24
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +51 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +19 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +89 -29
|
File without changes
|
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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":"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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.6.
|
|
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.
|
|
138
|
-
"@igstack/app-catalog-shared-core": "0.6.
|
|
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": "
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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>
|