@igstack/app-catalog-frontend-core 0.4.0-alpha-20260727044221 → 0.4.0-alpha-20260727200037
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/tools/CatalogTools.d.ts +2 -0
- package/dist/esm/modules/appCatalog/hooks/useUrlSyncedState.js +2 -6
- package/dist/esm/modules/appCatalog/hooks/useUrlSyncedState.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +7 -2
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +6 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/routeTree.gen.d.ts +1 -0
- package/dist/esm/routes/_layout/index.d.ts +1 -0
- package/dist/esm/routes/_layout/index.js +2 -1
- package/dist/esm/routes/_layout/index.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/integration/appDeepLink.integration.test.ts +21 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +6 -1
- package/src/modules/appCatalog/hooks/useUrlSyncedState.ts +6 -11
- package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +10 -3
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +9 -2
- package/src/routes/_layout/index.tsx +1 -0
|
@@ -13,6 +13,8 @@ export declare class CatalogTools {
|
|
|
13
13
|
* Type into the search input.
|
|
14
14
|
*/
|
|
15
15
|
search(text: string): Promise<void>;
|
|
16
|
+
/** The catalog search input. */
|
|
17
|
+
getSearchInput(): HTMLInputElement;
|
|
16
18
|
/**
|
|
17
19
|
* Parse the catalog table into structured data.
|
|
18
20
|
* Skips group header rows (colspan rows).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
3
|
function useUrlSyncedState({
|
|
4
4
|
key,
|
|
5
5
|
defaultValue,
|
|
@@ -9,18 +9,14 @@ function useUrlSyncedState({
|
|
|
9
9
|
const navigate = useNavigate();
|
|
10
10
|
const router = useRouter();
|
|
11
11
|
const search = useSearch({ strict: false });
|
|
12
|
-
const isInitializedRef = useRef(false);
|
|
13
12
|
const [state, setState] = useState(() => {
|
|
14
13
|
const urlValue = search[key];
|
|
15
14
|
if (urlValue !== void 0) {
|
|
16
|
-
|
|
17
|
-
isInitializedRef.current = true;
|
|
18
|
-
return decodedValue;
|
|
15
|
+
return decode ? decode(String(urlValue)) : urlValue;
|
|
19
16
|
}
|
|
20
17
|
return defaultValue;
|
|
21
18
|
});
|
|
22
19
|
useEffect(() => {
|
|
23
|
-
if (!isInitializedRef.current) return;
|
|
24
20
|
const encodedValue = encode ? encode(state) : state;
|
|
25
21
|
if (encodedValue === search[key]) return;
|
|
26
22
|
const currentPath = router.state.location.pathname;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useUrlSyncedState.js","sources":["../../../../../src/modules/appCatalog/hooks/useUrlSyncedState.ts"],"sourcesContent":["import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'\nimport { useEffect,
|
|
1
|
+
{"version":3,"file":"useUrlSyncedState.js","sources":["../../../../../src/modules/appCatalog/hooks/useUrlSyncedState.ts"],"sourcesContent":["import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'\nimport { useEffect, useState } from 'react'\n\n/**\n * Options for useUrlSyncedState hook\n */\nexport interface UseUrlSyncedStateOptions<T> {\n /** The URL search parameter key */\n key: string\n /** Default value when URL param is not present */\n defaultValue: T\n /** Optional decoder to transform URL string to state value */\n decode?: (urlValue: string) => T\n /** Optional encoder to transform state value to URL string (return undefined to remove param) */\n encode?: (stateValue: T) => string | undefined\n}\n\n/**\n * Hook for managing state that is synced bidirectionally with URL search params.\n *\n * Key features:\n * - Initializes from URL once on mount\n * - Async sync state → URL after initialization to prevent UI sluggishness\n * - Uses replace: true to avoid polluting browser history\n *\n * @example\n * ```tsx\n * // Boolean state\n * const [recentMode, setRecentMode] = useUrlSyncedState({\n * key: 'recent',\n * defaultValue: false,\n * decode: (value) => value === '1',\n * encode: (value) => value ? '1' : undefined,\n * })\n *\n * // Object state\n * const [filters, setFilters] = useUrlSyncedState({\n * key: 'filters',\n * defaultValue: {},\n * decode: (value) => parseFilters(value),\n * encode: (value) => encodeFilters(value),\n * })\n * ```\n */\nexport function useUrlSyncedState<T>({\n key,\n defaultValue,\n decode,\n encode,\n}: UseUrlSyncedStateOptions<T>): [T, (value: T) => void] {\n const navigate = useNavigate()\n const router = useRouter()\n const search = useSearch({ strict: false })\n\n // Initialize state from URL on mount (once only)\n const [state, setState] = useState<T>(() => {\n const urlValue = (search as Record<string, unknown>)[key]\n if (urlValue !== undefined) {\n return decode ? decode(String(urlValue)) : (urlValue as T)\n }\n return defaultValue\n })\n\n // Sync state to URL (async side effect). The equality check below is what\n // prevents redundant writes / URL pollution from default values — so state\n // changes made from the default (e.g. the first keystroke in an empty search)\n // are still persisted to the URL and survive navigation/remounts.\n useEffect(() => {\n // Encode state value for URL\n const encodedValue = encode ? encode(state) : (state as string | undefined)\n\n // Check if already in sync\n if (encodedValue === (search as Record<string, unknown>)[key]) return\n\n const currentPath = router.state.location.pathname\n navigate({\n to: currentPath,\n search: {\n ...search,\n [key]: encodedValue,\n },\n replace: true, // Use replace to avoid polluting history\n })\n }, [state, key, encode, navigate, router.state.location.pathname, search])\n\n return [state, setState]\n}\n"],"names":[],"mappings":";;AA4CO,SAAS,kBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAyD;AACvD,QAAM,WAAW,YAAA;AACjB,QAAM,SAAS,UAAA;AACf,QAAM,SAAS,UAAU,EAAE,QAAQ,OAAO;AAG1C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAY,MAAM;AAC1C,UAAM,WAAY,OAAmC,GAAG;AACxD,QAAI,aAAa,QAAW;AAC1B,aAAO,SAAS,OAAO,OAAO,QAAQ,CAAC,IAAK;AAAA,IAC9C;AACA,WAAO;AAAA,EACT,CAAC;AAMD,YAAU,MAAM;AAEd,UAAM,eAAe,SAAS,OAAO,KAAK,IAAK;AAG/C,QAAI,iBAAkB,OAAmC,GAAG,EAAG;AAE/D,UAAM,cAAc,OAAO,MAAM,SAAS;AAC1C,aAAS;AAAA,MACP,IAAI;AAAA,MACJ,QAAQ;AAAA,QACN,GAAG;AAAA,QACH,CAAC,GAAG,GAAG;AAAA,MAAA;AAAA,MAET,SAAS;AAAA;AAAA,IAAA,CACV;AAAA,EACH,GAAG,CAAC,OAAO,KAAK,QAAQ,UAAU,OAAO,MAAM,SAAS,UAAU,MAAM,CAAC;AAEzE,SAAO,CAAC,OAAO,QAAQ;AACzB;"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useMemo, createContext, use } from "react";
|
|
3
3
|
import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
|
|
4
4
|
import { useUrlSyncedState } from "../../hooks/useUrlSyncedState.js";
|
|
5
5
|
import { decodeFiltersParam, encodeFiltersParam } from "../../utils/filterHelpers.js";
|
|
@@ -21,7 +21,12 @@ function AppCatalogFiltersProvider({
|
|
|
21
21
|
decode: decodeFiltersParam,
|
|
22
22
|
encode: encodeFiltersParam
|
|
23
23
|
});
|
|
24
|
-
const [searchValue, setSearchValue] =
|
|
24
|
+
const [searchValue, setSearchValue] = useUrlSyncedState({
|
|
25
|
+
key: "q",
|
|
26
|
+
defaultValue: "",
|
|
27
|
+
decode: (value) => value,
|
|
28
|
+
encode: (value) => value === "" ? void 0 : value
|
|
29
|
+
});
|
|
25
30
|
const [showDeprecated, setShowDeprecated] = useUrlSyncedState({
|
|
26
31
|
key: "deprecated",
|
|
27
32
|
defaultValue: false,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppCatalogFiltersContext.js","sources":["../../../../../../src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { createContext, use, useMemo
|
|
1
|
+
{"version":3,"file":"AppCatalogFiltersContext.js","sources":["../../../../../../src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx"],"sourcesContent":["import type { ReactNode } from 'react'\nimport { createContext, use, useMemo } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useUrlSyncedState } from '../../hooks/useUrlSyncedState'\nimport {\n decodeFiltersParam,\n encodeFiltersParam,\n} from '../../utils/filterHelpers'\n\n/**\n * Tag option for filter combobox\n */\nexport interface TagOption {\n /** Tag value (e.g., \"communication\") */\n value: string\n /** Full tag with prefix (e.g., \"category:communication\") */\n fullTag: string\n /** Display name for UI */\n displayName: string\n /** Description for UI */\n description: string\n}\n\n/**\n * Derived filter data computed from state and catalog\n */\nexport interface FilterData {\n /** Available tag options grouped by prefix */\n availableTagsByPrefix: Record<string, TagOption[]>\n /** Whether any filters are currently active */\n hasActiveFilters: boolean\n}\n\n/**\n * Filter state\n */\nexport interface AppCatalogFiltersState {\n /** Tag prefixes that can be filtered (from uiSettings) */\n filterableTagPrefixes: string[]\n /** Whether \"My Recent\" mode is active */\n recentMode: boolean\n /** Active tag filters (prefix -> value) */\n tagFilters: Record<string, string>\n /** Search query value */\n searchValue: string\n /** Whether to show deprecated apps (default: false) */\n showDeprecated: boolean\n}\n\n/**\n * Filter actions\n */\nexport interface AppCatalogFiltersActions {\n /** Enable/disable \"My Recent\" mode (clears tag filters when enabled) */\n setRecentMode: (enabled: boolean) => void\n /** Set a tag filter (clears recent mode) */\n setTagFilter: (prefix: string, value: string | undefined) => void\n /** Set search value */\n setSearchValue: (value: string) => void\n /** Set whether to show deprecated apps */\n setShowDeprecated: (show: boolean) => void\n /** Clear all filters (keeps search) */\n clearAllFilters: () => void\n}\n\nexport interface AppCatalogFiltersContextValue {\n state: AppCatalogFiltersState\n data: FilterData\n actions: AppCatalogFiltersActions\n}\n\nconst AppCatalogFiltersContext = createContext<\n AppCatalogFiltersContextValue | undefined\n>(undefined)\n\ninterface AppCatalogFiltersProviderProps {\n children: ReactNode\n /** Tag prefixes that can be filtered (from uiSettings) */\n filterableTagPrefixes: string[]\n}\n\nexport function AppCatalogFiltersProvider({\n children,\n filterableTagPrefixes,\n}: AppCatalogFiltersProviderProps) {\n const { tagsDefinitions } = useAppCatalogContext()\n\n // URL-synced state\n const [recentMode, setRecentMode] = useUrlSyncedState({\n key: 'recent',\n defaultValue: false,\n decode: (value) => value === '1',\n encode: (value) => (value ? '1' : undefined),\n })\n\n const [tagFilters, setTagFilters] = useUrlSyncedState({\n key: 'filters',\n defaultValue: {},\n decode: decodeFiltersParam,\n encode: encodeFiltersParam,\n })\n\n // Search value is synced to URL (key `q`) so it survives navigation —\n // e.g. auto-opening an app's detail page when the query narrows to one match\n // (the filters provider is mounted per-route and remounts on navigation).\n const [searchValue, setSearchValue] = useUrlSyncedState<string>({\n key: 'q',\n defaultValue: '',\n decode: (value) => value,\n encode: (value) => (value === '' ? undefined : value),\n })\n\n const [showDeprecated, setShowDeprecated] = useUrlSyncedState({\n key: 'deprecated',\n defaultValue: false,\n decode: (value) => value === '1',\n encode: (value) => (value ? '1' : undefined),\n })\n\n // Compute available tags by prefix\n const availableTagsByPrefix = useMemo(() => {\n const result: Record<string, TagOption[]> = {}\n\n filterableTagPrefixes.forEach((prefix) => {\n const definition = tagsDefinitions.find((def) => def.prefix === prefix)\n if (definition) {\n result[prefix] = definition.values.map((tagValue) => ({\n value: tagValue.value,\n fullTag: `${prefix}:${tagValue.value}`,\n displayName: tagValue.displayName,\n description: tagValue.description,\n }))\n }\n })\n\n return result\n }, [filterableTagPrefixes, tagsDefinitions])\n\n // Compute whether any filters are active\n const hasActiveFilters = useMemo(() => {\n return recentMode || Object.keys(tagFilters).length > 0\n }, [recentMode, tagFilters])\n\n // Actions\n const actions = useMemo<AppCatalogFiltersActions>(\n () => ({\n setRecentMode: (enabled: boolean) => {\n // Always clear tag filters when changing mode\n setTagFilters({})\n setRecentMode(enabled)\n },\n setTagFilter: (prefix: string, value: string | undefined) => {\n // Clear recent mode when setting tag filter\n if (value !== undefined) {\n setRecentMode(false)\n }\n\n // Update tag filters\n if (value === undefined) {\n // Remove filter\n const { [prefix]: _, ...rest } = tagFilters\n setTagFilters(rest)\n } else {\n // Add/update filter\n setTagFilters({ ...tagFilters, [prefix]: value })\n }\n },\n setSearchValue,\n setShowDeprecated,\n clearAllFilters: () => {\n setRecentMode(false)\n setTagFilters({})\n },\n }),\n [\n setRecentMode,\n setTagFilters,\n tagFilters,\n setSearchValue,\n setShowDeprecated,\n ],\n )\n\n const contextValue = useMemo<AppCatalogFiltersContextValue>(\n () => ({\n state: {\n filterableTagPrefixes,\n recentMode,\n tagFilters,\n searchValue,\n showDeprecated,\n },\n data: {\n availableTagsByPrefix,\n hasActiveFilters,\n },\n actions,\n }),\n [\n filterableTagPrefixes,\n recentMode,\n tagFilters,\n searchValue,\n showDeprecated,\n availableTagsByPrefix,\n hasActiveFilters,\n actions,\n ],\n )\n\n return (\n <AppCatalogFiltersContext value={contextValue}>\n {children}\n </AppCatalogFiltersContext>\n )\n}\n\nexport function useAppCatalogFilters(): AppCatalogFiltersContextValue {\n const context = use(AppCatalogFiltersContext)\n if (!context) {\n throw new Error(\n 'useAppCatalogFilters must be used within AppCatalogFiltersProvider',\n )\n }\n return context\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -78,12 +78,17 @@ function AppCatalogPage({
|
|
|
78
78
|
void navigate({
|
|
79
79
|
to: "/app/$slug",
|
|
80
80
|
params: { slug: filteredApps[0].slug },
|
|
81
|
+
search: (prev) => prev,
|
|
81
82
|
replace: true
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
}, [filteredApps, selectedAppSlug, navigate]);
|
|
85
86
|
const handleAppClick = (app) => {
|
|
86
|
-
void navigate({
|
|
87
|
+
void navigate({
|
|
88
|
+
to: "/app/$slug",
|
|
89
|
+
params: { slug: app.slug },
|
|
90
|
+
search: (prev) => prev
|
|
91
|
+
});
|
|
87
92
|
};
|
|
88
93
|
const handleClearFilters = () => {
|
|
89
94
|
setSearchValue("");
|
|
@@ -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 // 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 = 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] && !selectedAppSlug) {\n void navigate({\n to: '/app/$slug',\n params: { slug: filteredApps[0].slug },\n replace: true,\n })\n }\n }, [filteredApps, selectedAppSlug, navigate])\n\n const handleAppClick = (app: Resource) => {\n void navigate({ to: '/app/$slug', params: { slug: app.slug } })\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 = 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. Preserve existing search params\n // (e.g. the `q` search query) so the search input and focus survive the\n // navigation — otherwise the params are dropped and the query is lost (#10).\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) => prev,\n replace: true,\n })\n }\n }, [filteredApps, selectedAppSlug, navigate])\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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"}
|
|
@@ -8,6 +8,7 @@ import { Route as LayoutCatalogAppsIndexRouteImport } from './routes/_layout/cat
|
|
|
8
8
|
declare const LayoutRoute: import('@tanstack/router-core').Route<import('@tanstack/react-router').Register, import('@tanstack/react-router').RootRoute<import('@tanstack/react-router').Register, undefined, import('./types/types.js').AcRouterContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "", "/", "/_layout", "/_layout", undefined, import('@tanstack/router-core').ResolveParams<"">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>;
|
|
9
9
|
declare const LayoutIndexRoute: import('@tanstack/router-core').Route<import('@tanstack/react-router').Register, import('@tanstack/router-core').Route<import('@tanstack/react-router').Register, import('@tanstack/react-router').RootRoute<import('@tanstack/react-router').Register, undefined, import('./types/types.js').AcRouterContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "", "/", "/_layout", "/_layout", undefined, import('@tanstack/router-core').ResolveParams<"">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/", "/", "/_layout/", "/_layout/", import('zod').ZodObject<{
|
|
10
10
|
filterTag: import('zod').ZodOptional<import('zod').ZodString>;
|
|
11
|
+
q: import('zod').ZodOptional<import('zod').ZodString>;
|
|
11
12
|
}, import('zod/v4/core').$strip>, import('@tanstack/router-core').ResolveParams<"/">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, () => Promise<{
|
|
12
13
|
appCatalogLoader: import('./modules/appCatalog/routeLoader.js').AppCatalogLoaderReturn;
|
|
13
14
|
}>, unknown, unknown, unknown, unknown, undefined>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare const Route: import('@tanstack/router-core').Route<import('@tanstack/react-router').Register, import('@tanstack/router-core').Route<import('@tanstack/react-router').Register, import('@tanstack/react-router').RootRoute<import('@tanstack/react-router').Register, undefined, import('../../types/types.js').AcRouterContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "", "/", "/_layout", "/_layout", undefined, import('@tanstack/router-core').ResolveParams<"">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/", "/", "/_layout/", "/_layout/", z.ZodObject<{
|
|
3
3
|
filterTag: z.ZodOptional<z.ZodString>;
|
|
4
|
+
q: z.ZodOptional<z.ZodString>;
|
|
4
5
|
}, z.core.$strip>, import('@tanstack/router-core').ResolveParams<"/">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, () => Promise<{
|
|
5
6
|
appCatalogLoader: import('~/modules/appCatalog/routeLoader').AppCatalogLoaderReturn;
|
|
6
7
|
}>, unknown, unknown, unknown, unknown, undefined>;
|
|
@@ -5,7 +5,8 @@ import { AppCatalogLayout } from "../../modules/appCatalog/ui/layout/AppCatalogL
|
|
|
5
5
|
import { AppCatalogPage } from "../../modules/appCatalog/ui/pages/AppCatalogPage.js";
|
|
6
6
|
import { object, string } from "../../node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/classic/schemas.js";
|
|
7
7
|
const searchSchema = object({
|
|
8
|
-
filterTag: string().optional()
|
|
8
|
+
filterTag: string().optional(),
|
|
9
|
+
q: string().optional()
|
|
9
10
|
});
|
|
10
11
|
const Route = createFileRoute("/_layout/")({
|
|
11
12
|
component: RouteComponent,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../../../../src/routes/_layout/index.tsx"],"sourcesContent":["import { createFileRoute } from '@tanstack/react-router'\nimport { z } from 'zod'\nimport { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'\nimport { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'\nimport { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'\n\nconst searchSchema = z.object({\n filterTag: z.string().optional(),\n})\n\nexport const Route = createFileRoute('/_layout/')({\n component: RouteComponent,\n validateSearch: searchSchema,\n async loader() {\n const appCatalogLoader = await appCatalogRouteLoader()\n return {\n appCatalogLoader,\n }\n },\n})\n\nfunction RouteComponent() {\n const { queryClient, trpcClient } = Route.useRouteContext()\n\n return (\n <AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>\n <AppCatalogPage />\n </AppCatalogLayout>\n )\n}\n"],"names":["z.object","z.string"],"mappings":";;;;;;AAMA,MAAM,eAAeA,OAAS;AAAA,EAC5B,WAAWC,OAAE,EAAS,SAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/routes/_layout/index.tsx"],"sourcesContent":["import { createFileRoute } from '@tanstack/react-router'\nimport { z } from 'zod'\nimport { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'\nimport { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'\nimport { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'\n\nconst searchSchema = z.object({\n filterTag: z.string().optional(),\n q: z.string().optional(),\n})\n\nexport const Route = createFileRoute('/_layout/')({\n component: RouteComponent,\n validateSearch: searchSchema,\n async loader() {\n const appCatalogLoader = await appCatalogRouteLoader()\n return {\n appCatalogLoader,\n }\n },\n})\n\nfunction RouteComponent() {\n const { queryClient, trpcClient } = Route.useRouteContext()\n\n return (\n <AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>\n <AppCatalogPage />\n </AppCatalogLayout>\n )\n}\n"],"names":["z.object","z.string"],"mappings":";;;;;;AAMA,MAAM,eAAeA,OAAS;AAAA,EAC5B,WAAWC,OAAE,EAAS,SAAA;AAAA,EACtB,GAAGA,OAAE,EAAS,SAAA;AAChB,CAAC;AAEM,MAAM,QAAQ,gBAAgB,WAAW,EAAE;AAAA,EAChD,WAAW;AAAA,EACX,gBAAgB;AAAA,EAChB,MAAM,SAAS;AACb,UAAM,mBAAmB,MAAM,sBAAA;AAC/B,WAAO;AAAA,MACL;AAAA,IAAA;AAAA,EAEJ;AACF,CAAC;AAED,SAAS,iBAAiB;AACxB,QAAM,EAAE,aAAa,eAAe,MAAM,gBAAA;AAE1C,6BACG,kBAAA,EAAiB,aAA0B,YAC1C,UAAA,oBAAC,kBAAe,GAClB;AAEJ;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "0.4.0-alpha-
|
|
3
|
+
"version": "0.4.0-alpha-20260727200037",
|
|
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.4.0-alpha-
|
|
138
|
-
"@igstack/app-catalog-shared-core": "0.4.0-alpha-
|
|
137
|
+
"@igstack/app-catalog-backend-core": "0.4.0-alpha-20260727200037",
|
|
138
|
+
"@igstack/app-catalog-shared-core": "0.4.0-alpha-20260727200037"
|
|
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": "7aefd8038a15943bc2ae3e81e412b33fe46f632c",
|
|
148
148
|
"scripts": {
|
|
149
149
|
"build": "vite build",
|
|
150
150
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -52,4 +52,25 @@ describe('App deep-link routing', () => {
|
|
|
52
52
|
})
|
|
53
53
|
expect(ui.catalog.isDetailPanelOpen()).toBe(false)
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
it('keeps the search text and focus after auto-navigating to a single match (#10)', async () => {
|
|
57
|
+
// A search query that narrows to exactly one app auto-opens its detail
|
|
58
|
+
// page. Before the fix the search value lived in component-local state and
|
|
59
|
+
// was NOT in the URL, so the per-route remount wiped the input (and focus)
|
|
60
|
+
// on navigation. Now `q` is URL-synced and carried through the navigation.
|
|
61
|
+
const { ui, router } = await given(magazine.full(), {
|
|
62
|
+
initialRoute: '/?q=jira',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Auto-navigates to the single match, preserving the `q` query param.
|
|
66
|
+
await waitFor(() => {
|
|
67
|
+
expect(router.state.location.pathname).toBe('/app/jira')
|
|
68
|
+
})
|
|
69
|
+
expect(router.state.location.search).toMatchObject({ q: 'jira' })
|
|
70
|
+
|
|
71
|
+
// The search input still holds the query (bug: it reset to '') and keeps focus.
|
|
72
|
+
const input = ui.catalog.getSearchInput()
|
|
73
|
+
expect(input.value).toBe('jira')
|
|
74
|
+
expect(document.activeElement).toBe(input)
|
|
75
|
+
})
|
|
55
76
|
})
|
|
@@ -36,11 +36,16 @@ export class CatalogTools {
|
|
|
36
36
|
* Type into the search input.
|
|
37
37
|
*/
|
|
38
38
|
async search(text: string): Promise<void> {
|
|
39
|
-
const input =
|
|
39
|
+
const input = this.getSearchInput()
|
|
40
40
|
await this.user.clear(input)
|
|
41
41
|
await this.user.type(input, text)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The catalog search input. */
|
|
45
|
+
getSearchInput(): HTMLInputElement {
|
|
46
|
+
return screen.getByLabelText<HTMLInputElement>('Search apps')
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
/**
|
|
45
50
|
* Parse the catalog table into structured data.
|
|
46
51
|
* Skips group header rows (colspan rows).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'
|
|
2
|
-
import { useEffect,
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Options for useUrlSyncedState hook
|
|
@@ -52,25 +52,20 @@ export function useUrlSyncedState<T>({
|
|
|
52
52
|
const router = useRouter()
|
|
53
53
|
const search = useSearch({ strict: false })
|
|
54
54
|
|
|
55
|
-
// Track whether we've initialized from URL
|
|
56
|
-
const isInitializedRef = useRef(false)
|
|
57
|
-
|
|
58
55
|
// Initialize state from URL on mount (once only)
|
|
59
56
|
const [state, setState] = useState<T>(() => {
|
|
60
57
|
const urlValue = (search as Record<string, unknown>)[key]
|
|
61
58
|
if (urlValue !== undefined) {
|
|
62
|
-
|
|
63
|
-
isInitializedRef.current = true
|
|
64
|
-
return decodedValue
|
|
59
|
+
return decode ? decode(String(urlValue)) : (urlValue as T)
|
|
65
60
|
}
|
|
66
61
|
return defaultValue
|
|
67
62
|
})
|
|
68
63
|
|
|
69
|
-
// Sync state to URL (async side effect)
|
|
64
|
+
// Sync state to URL (async side effect). The equality check below is what
|
|
65
|
+
// prevents redundant writes / URL pollution from default values — so state
|
|
66
|
+
// changes made from the default (e.g. the first keystroke in an empty search)
|
|
67
|
+
// are still persisted to the URL and survive navigation/remounts.
|
|
70
68
|
useEffect(() => {
|
|
71
|
-
// Don't sync until after initialization
|
|
72
|
-
if (!isInitializedRef.current) return
|
|
73
|
-
|
|
74
69
|
// Encode state value for URL
|
|
75
70
|
const encodedValue = encode ? encode(state) : (state as string | undefined)
|
|
76
71
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
-
import { createContext, use, useMemo
|
|
2
|
+
import { createContext, use, useMemo } from 'react'
|
|
3
3
|
import { useAppCatalogContext } from '../../context/AppCatalogContext'
|
|
4
4
|
import { useUrlSyncedState } from '../../hooks/useUrlSyncedState'
|
|
5
5
|
import {
|
|
@@ -100,8 +100,15 @@ export function AppCatalogFiltersProvider({
|
|
|
100
100
|
encode: encodeFiltersParam,
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
-
// Search value is
|
|
104
|
-
|
|
103
|
+
// Search value is synced to URL (key `q`) so it survives navigation —
|
|
104
|
+
// e.g. auto-opening an app's detail page when the query narrows to one match
|
|
105
|
+
// (the filters provider is mounted per-route and remounts on navigation).
|
|
106
|
+
const [searchValue, setSearchValue] = useUrlSyncedState<string>({
|
|
107
|
+
key: 'q',
|
|
108
|
+
defaultValue: '',
|
|
109
|
+
decode: (value) => value,
|
|
110
|
+
encode: (value) => (value === '' ? undefined : value),
|
|
111
|
+
})
|
|
105
112
|
|
|
106
113
|
const [showDeprecated, setShowDeprecated] = useUrlSyncedState({
|
|
107
114
|
key: 'deprecated',
|
|
@@ -123,19 +123,26 @@ export function AppCatalogPage({
|
|
|
123
123
|
searchValue: deferredSearchValue,
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
-
// Auto-open details when only 1 result
|
|
126
|
+
// Auto-open details when only 1 result. Preserve existing search params
|
|
127
|
+
// (e.g. the `q` search query) so the search input and focus survive the
|
|
128
|
+
// navigation — otherwise the params are dropped and the query is lost (#10).
|
|
127
129
|
useEffect(() => {
|
|
128
130
|
if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {
|
|
129
131
|
void navigate({
|
|
130
132
|
to: '/app/$slug',
|
|
131
133
|
params: { slug: filteredApps[0].slug },
|
|
134
|
+
search: (prev) => prev,
|
|
132
135
|
replace: true,
|
|
133
136
|
})
|
|
134
137
|
}
|
|
135
138
|
}, [filteredApps, selectedAppSlug, navigate])
|
|
136
139
|
|
|
137
140
|
const handleAppClick = (app: Resource) => {
|
|
138
|
-
void navigate({
|
|
141
|
+
void navigate({
|
|
142
|
+
to: '/app/$slug',
|
|
143
|
+
params: { slug: app.slug },
|
|
144
|
+
search: (prev) => prev,
|
|
145
|
+
})
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
const handleClearFilters = () => {
|