@igstack/app-catalog-frontend-core 0.6.2 → 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.
Files changed (32) hide show
  1. package/dist/esm/__tests__/integration/searchDeprecatedFallback.integration.test.d.ts +0 -0
  2. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +8 -0
  3. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +1 -0
  4. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  5. package/dist/esm/modules/appCatalog/hooks/useUrlSyncedState.js +2 -6
  6. package/dist/esm/modules/appCatalog/hooks/useUrlSyncedState.js.map +1 -1
  7. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +1 -0
  8. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +1 -0
  10. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +1 -0
  12. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  13. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +7 -2
  14. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
  15. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +59 -26
  16. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  17. package/dist/esm/routeTree.gen.d.ts +8 -1
  18. package/dist/esm/routes/_layout/app.$slug.d.ts +8 -1
  19. package/dist/esm/routes/_layout/app._slug.js +9 -0
  20. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  21. package/dist/esm/routes/_layout/index.d.ts +1 -0
  22. package/dist/esm/routes/_layout/index.js +2 -1
  23. package/dist/esm/routes/_layout/index.js.map +1 -1
  24. package/package.json +4 -4
  25. package/src/__tests__/integration/appDeepLink.integration.test.ts +40 -0
  26. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +51 -0
  27. package/src/__tests__/integration/tools/CatalogTools.ts +25 -1
  28. package/src/modules/appCatalog/hooks/useUrlSyncedState.ts +6 -11
  29. package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +10 -3
  30. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +105 -32
  31. package/src/routes/_layout/app.$slug.tsx +13 -0
  32. 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).
@@ -22,6 +24,12 @@ export declare class CatalogTools {
22
24
  * Whether the right detail panel is currently visible.
23
25
  */
24
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;
25
33
  /**
26
34
  * Whether the onboarding/welcome card is visible.
27
35
  */
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
3
3
  import { createContext, useMemo, useEffect, use } from "react";
4
4
  import { ApiQueryMagazineAppCatalog } from "../api/ApiQueryMagazineAppCatalog.js";
5
5
  import { useUiSettings } from "../../../context/UiSettingsContext.js";
6
+ import "../ui/context/AppCatalogFiltersContext.js";
6
7
  import "@tanstack/react-query-devtools";
7
8
  import "next-themes";
8
9
  import "@radix-ui/react-dialog";
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogContext.js","sources":["../../../../../src/modules/appCatalog/context/AppCatalogContext.tsx"],"sourcesContent":["import type {\n AppApprovalMethod,\n AppVersionInfo,\n Group,\n GroupingTagDefinition,\n Person,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { useQuery } from '@tanstack/react-query'\nimport type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo } from 'react'\nimport { ApiQueryMagazineAppCatalog } from '~/modules/appCatalog'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport { mergeFrontendBuildId } from '~/modules/appCatalog/context/mergeFrontendBuildId'\n\nexport interface AppCatalogContextIface {\n resources: Resource[]\n isLoadingApps: boolean\n tagsDefinitions: GroupingTagDefinition[]\n approvalMethods: AppApprovalMethod[]\n persons: Person[]\n groups: Group[]\n versions?: AppVersionInfo\n}\n\nexport const AppCatalogContext = createContext<\n AppCatalogContextIface | undefined\n>(undefined)\n\ninterface AppCatalogProviderProps {\n children: ReactNode\n}\n\nexport function AppCatalogProvider({ children }: AppCatalogProviderProps) {\n const { data, isLoading: isLoadingApps } = useQuery(\n ApiQueryMagazineAppCatalog.getAppCatalog(),\n )\n const uiSettings = useUiSettings()\n\n const contextValue = useMemo<AppCatalogContextIface>(\n () => ({\n resources: data?.resources ?? [],\n isLoadingApps,\n tagsDefinitions: data?.tagsDefinitions ?? [],\n approvalMethods: data?.approvalMethods ?? [],\n persons: data?.persons ?? [],\n groups: data?.groups ?? [],\n versions: mergeFrontendBuildId(\n data?.versions ?? {},\n uiSettings.frontendBuildId,\n ),\n }),\n [\n data?.approvalMethods,\n data?.resources,\n data?.tagsDefinitions,\n data?.persons,\n data?.groups,\n data?.versions,\n uiSettings.frontendBuildId,\n isLoadingApps,\n ],\n )\n\n // Update document title based on backend version\n useEffect(() => {\n if (data?.versions?.backend?.displayName === 'local') {\n document.title = 'Local'\n } else {\n document.title = 'App Catalog'\n }\n }, [data?.versions?.backend?.displayName])\n\n return <AppCatalogContext value={contextValue}>{children}</AppCatalogContext>\n}\n\nexport function useAppCatalogContext(): AppCatalogContextIface {\n const context = use(AppCatalogContext)\n if (!context) {\n throw new Error(\n 'useAppCatalogContext must be used within AppCatalogProvider',\n )\n }\n return context\n}\n"],"names":["_b","_a"],"mappings":";;;;;;;;;;;;;;;;;;AAyBO,MAAM,oBAAoB,cAE/B,MAAS;AAMJ,SAAS,mBAAmB,EAAE,YAAqC;;AACxE,QAAM,EAAE,MAAM,WAAW,cAAA,IAAkB;AAAA,IACzC,2BAA2B,cAAA;AAAA,EAAc;AAE3C,QAAM,aAAa,cAAA;AAEnB,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL,YAAW,6BAAM,cAAa,CAAA;AAAA,MAC9B;AAAA,MACA,kBAAiB,6BAAM,oBAAmB,CAAA;AAAA,MAC1C,kBAAiB,6BAAM,oBAAmB,CAAA;AAAA,MAC1C,UAAS,6BAAM,YAAW,CAAA;AAAA,MAC1B,SAAQ,6BAAM,WAAU,CAAA;AAAA,MACxB,UAAU;AAAA,SACR,6BAAM,aAAY,CAAA;AAAA,QAClB,WAAW;AAAA,MAAA;AAAA,IACb;AAAA,IAEF;AAAA,MACE,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,WAAW;AAAA,MACX;AAAA,IAAA;AAAA,EACF;AAIF,YAAU,MAAM;;AACd,UAAIA,OAAAC,MAAA,6BAAM,aAAN,gBAAAA,IAAgB,YAAhB,gBAAAD,IAAyB,iBAAgB,SAAS;AACpD,eAAS,QAAQ;AAAA,IACnB,OAAO;AACL,eAAS,QAAQ;AAAA,IACnB;AAAA,EACF,GAAG,EAAC,wCAAM,aAAN,mBAAgB,YAAhB,mBAAyB,WAAW,CAAC;AAEzC,SAAO,oBAAC,mBAAA,EAAkB,OAAO,cAAe,SAAA,CAAS;AAC3D;AAEO,SAAS,uBAA+C;AAC7D,QAAM,UAAU,IAAI,iBAAiB;AACrC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAAA,EAEJ;AACA,SAAO;AACT;"}
1
+ {"version":3,"file":"AppCatalogContext.js","sources":["../../../../../src/modules/appCatalog/context/AppCatalogContext.tsx"],"sourcesContent":["import type {\n AppApprovalMethod,\n AppVersionInfo,\n Group,\n GroupingTagDefinition,\n Person,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { useQuery } from '@tanstack/react-query'\nimport type { ReactNode } from 'react'\nimport { createContext, use, useEffect, useMemo } from 'react'\nimport { ApiQueryMagazineAppCatalog } from '~/modules/appCatalog'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport { mergeFrontendBuildId } from '~/modules/appCatalog/context/mergeFrontendBuildId'\n\nexport interface AppCatalogContextIface {\n resources: Resource[]\n isLoadingApps: boolean\n tagsDefinitions: GroupingTagDefinition[]\n approvalMethods: AppApprovalMethod[]\n persons: Person[]\n groups: Group[]\n versions?: AppVersionInfo\n}\n\nexport const AppCatalogContext = createContext<\n AppCatalogContextIface | undefined\n>(undefined)\n\ninterface AppCatalogProviderProps {\n children: ReactNode\n}\n\nexport function AppCatalogProvider({ children }: AppCatalogProviderProps) {\n const { data, isLoading: isLoadingApps } = useQuery(\n ApiQueryMagazineAppCatalog.getAppCatalog(),\n )\n const uiSettings = useUiSettings()\n\n const contextValue = useMemo<AppCatalogContextIface>(\n () => ({\n resources: data?.resources ?? [],\n isLoadingApps,\n tagsDefinitions: data?.tagsDefinitions ?? [],\n approvalMethods: data?.approvalMethods ?? [],\n persons: data?.persons ?? [],\n groups: data?.groups ?? [],\n versions: mergeFrontendBuildId(\n data?.versions ?? {},\n uiSettings.frontendBuildId,\n ),\n }),\n [\n data?.approvalMethods,\n data?.resources,\n data?.tagsDefinitions,\n data?.persons,\n data?.groups,\n data?.versions,\n uiSettings.frontendBuildId,\n isLoadingApps,\n ],\n )\n\n // Update document title based on backend version\n useEffect(() => {\n if (data?.versions?.backend?.displayName === 'local') {\n document.title = 'Local'\n } else {\n document.title = 'App Catalog'\n }\n }, [data?.versions?.backend?.displayName])\n\n return <AppCatalogContext value={contextValue}>{children}</AppCatalogContext>\n}\n\nexport function useAppCatalogContext(): AppCatalogContextIface {\n const context = use(AppCatalogContext)\n if (!context) {\n throw new Error(\n 'useAppCatalogContext must be used within AppCatalogProvider',\n )\n }\n return context\n}\n"],"names":["_b","_a"],"mappings":";;;;;;;;;;;;;;;;;;;AAyBO,MAAM,oBAAoB,cAE/B,MAAS;AAMJ,SAAS,mBAAmB,EAAE,YAAqC;;AACxE,QAAM,EAAE,MAAM,WAAW,cAAA,IAAkB;AAAA,IACzC,2BAA2B,cAAA;AAAA,EAAc;AAE3C,QAAM,aAAa,cAAA;AAEnB,QAAM,eAAe;AAAA,IACnB,OAAO;AAAA,MACL,YAAW,6BAAM,cAAa,CAAA;AAAA,MAC9B;AAAA,MACA,kBAAiB,6BAAM,oBAAmB,CAAA;AAAA,MAC1C,kBAAiB,6BAAM,oBAAmB,CAAA;AAAA,MAC1C,UAAS,6BAAM,YAAW,CAAA;AAAA,MAC1B,SAAQ,6BAAM,WAAU,CAAA;AAAA,MACxB,UAAU;AAAA,SACR,6BAAM,aAAY,CAAA;AAAA,QAClB,WAAW;AAAA,MAAA;AAAA,IACb;AAAA,IAEF;AAAA,MACE,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,6BAAM;AAAA,MACN,WAAW;AAAA,MACX;AAAA,IAAA;AAAA,EACF;AAIF,YAAU,MAAM;;AACd,UAAIA,OAAAC,MAAA,6BAAM,aAAN,gBAAAA,IAAgB,YAAhB,gBAAAD,IAAyB,iBAAgB,SAAS;AACpD,eAAS,QAAQ;AAAA,IACnB,OAAO;AACL,eAAS,QAAQ;AAAA,IACnB;AAAA,EACF,GAAG,EAAC,wCAAM,aAAN,mBAAgB,YAAhB,mBAAyB,WAAW,CAAC;AAEzC,SAAO,oBAAC,mBAAA,EAAkB,OAAO,cAAe,SAAA,CAAS;AAC3D;AAEO,SAAS,uBAA+C;AAC7D,QAAM,UAAU,IAAI,iBAAiB;AACrC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAAA,EAEJ;AACA,SAAO;AACT;"}
@@ -1,5 +1,5 @@
1
1
  import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
2
- import { useRef, useState, useEffect } from "react";
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
- const decodedValue = decode ? decode(String(urlValue)) : urlValue;
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, useRef, 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 // Track whether we've initialized from URL\n const isInitializedRef = useRef(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 const decodedValue = decode ? decode(String(urlValue)) : (urlValue as T)\n isInitializedRef.current = true\n return decodedValue\n }\n return defaultValue\n })\n\n // Sync state to URL (async side effect)\n useEffect(() => {\n // Don't sync until after initialization\n if (!isInitializedRef.current) return\n\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,mBAAmB,OAAO,KAAK;AAGrC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAY,MAAM;AAC1C,UAAM,WAAY,OAAmC,GAAG;AACxD,QAAI,aAAa,QAAW;AAC1B,YAAM,eAAe,SAAS,OAAO,OAAO,QAAQ,CAAC,IAAK;AAC1D,uBAAiB,UAAU;AAC3B,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,CAAC;AAGD,YAAU,MAAM;AAEd,QAAI,CAAC,iBAAiB,QAAS;AAG/B,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
+ {"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;"}
@@ -3,6 +3,7 @@ import { User, Check, Copy } from "lucide-react";
3
3
  import { useState, useRef, useEffect, useCallback } from "react";
4
4
  import { Badge } from "../../../../ui/badge.js";
5
5
  import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
6
+ import "../context/AppCatalogFiltersContext.js";
6
7
  import "@tanstack/react-query-devtools";
7
8
  import "next-themes";
8
9
  import "@radix-ui/react-dialog";
@@ -1 +1 @@
1
- {"version":3,"file":"PersonBadge.js","sources":["../../../../../../src/modules/appCatalog/ui/components/PersonBadge.tsx"],"sourcesContent":["import { Check, Copy, User } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface PersonBadgeProps {\n slug: string\n}\n\nexport function PersonBadge({ slug }: PersonBadgeProps) {\n const { persons } = useAppCatalogContext()\n const person = getPersonBySlug(persons, slug)\n\n const displayName = person\n ? `${person.firstName} ${person.lastName}`.trim() || slug\n : slug\n\n const email = person?.email\n\n return (\n <Badge\n variant=\"outline\"\n className=\"font-normal inline-flex items-center gap-1\"\n title={email ? `${displayName} (${email})` : displayName}\n >\n <User className=\"size-3\" />\n {displayName}\n {email && <CopyEmailButton email={email} />}\n </Badge>\n )\n}\n\nfunction CopyEmailButton({ email }: { email: string }) {\n const [copied, setCopied] = useState(false)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n }\n }, [])\n\n const handleCopy = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation()\n navigator.clipboard.writeText(email)\n setCopied(true)\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n timeoutRef.current = setTimeout(() => setCopied(false), 2000)\n },\n [email],\n )\n\n return (\n <button\n onClick={handleCopy}\n className=\"ml-0.5 hover:text-primary transition-colors\"\n title={copied ? 'Copied!' : `Copy ${email}`}\n type=\"button\"\n >\n {copied ? (\n <Check className=\"size-3 text-green-600\" />\n ) : (\n <Copy className=\"size-3\" />\n )}\n </button>\n )\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAUO,SAAS,YAAY,EAAE,QAA0B;AACtD,QAAM,EAAE,QAAA,IAAY,qBAAA;AACpB,QAAM,SAAS,gBAAgB,SAAS,IAAI;AAE5C,QAAM,cAAc,SAChB,GAAG,OAAO,SAAS,IAAI,OAAO,QAAQ,GAAG,KAAA,KAAU,OACnD;AAEJ,QAAM,QAAQ,iCAAQ;AAEtB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAQ;AAAA,MACR,WAAU;AAAA,MACV,OAAO,QAAQ,GAAG,WAAW,KAAK,KAAK,MAAM;AAAA,MAE7C,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,QACxB;AAAA,QACA,SAAS,oBAAC,iBAAA,EAAgB,MAAA,CAAc;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAG/C;AAEA,SAAS,gBAAgB,EAAE,SAA4B;AACrD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,QAAM,aAAa,OAA8B,IAAI;AAErD,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AAAA,IACzD;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,aAAa;AAAA,IACjB,CAAC,MAAwB;AACvB,QAAE,gBAAA;AACF,gBAAU,UAAU,UAAU,KAAK;AACnC,gBAAU,IAAI;AACd,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AACvD,iBAAW,UAAU,WAAW,MAAM,UAAU,KAAK,GAAG,GAAI;AAAA,IAC9D;AAAA,IACA,CAAC,KAAK;AAAA,EAAA;AAGR,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAS;AAAA,MACT,WAAU;AAAA,MACV,OAAO,SAAS,YAAY,QAAQ,KAAK;AAAA,MACzC,MAAK;AAAA,MAEJ,UAAA,6BACE,OAAA,EAAM,WAAU,yBAAwB,IAEzC,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,IAAA;AAAA,EAAA;AAIjC;"}
1
+ {"version":3,"file":"PersonBadge.js","sources":["../../../../../../src/modules/appCatalog/ui/components/PersonBadge.tsx"],"sourcesContent":["import { Check, Copy, User } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface PersonBadgeProps {\n slug: string\n}\n\nexport function PersonBadge({ slug }: PersonBadgeProps) {\n const { persons } = useAppCatalogContext()\n const person = getPersonBySlug(persons, slug)\n\n const displayName = person\n ? `${person.firstName} ${person.lastName}`.trim() || slug\n : slug\n\n const email = person?.email\n\n return (\n <Badge\n variant=\"outline\"\n className=\"font-normal inline-flex items-center gap-1\"\n title={email ? `${displayName} (${email})` : displayName}\n >\n <User className=\"size-3\" />\n {displayName}\n {email && <CopyEmailButton email={email} />}\n </Badge>\n )\n}\n\nfunction CopyEmailButton({ email }: { email: string }) {\n const [copied, setCopied] = useState(false)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n }\n }, [])\n\n const handleCopy = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation()\n navigator.clipboard.writeText(email)\n setCopied(true)\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n timeoutRef.current = setTimeout(() => setCopied(false), 2000)\n },\n [email],\n )\n\n return (\n <button\n onClick={handleCopy}\n className=\"ml-0.5 hover:text-primary transition-colors\"\n title={copied ? 'Copied!' : `Copy ${email}`}\n type=\"button\"\n >\n {copied ? (\n <Check className=\"size-3 text-green-600\" />\n ) : (\n <Copy className=\"size-3\" />\n )}\n </button>\n )\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAUO,SAAS,YAAY,EAAE,QAA0B;AACtD,QAAM,EAAE,QAAA,IAAY,qBAAA;AACpB,QAAM,SAAS,gBAAgB,SAAS,IAAI;AAE5C,QAAM,cAAc,SAChB,GAAG,OAAO,SAAS,IAAI,OAAO,QAAQ,GAAG,KAAA,KAAU,OACnD;AAEJ,QAAM,QAAQ,iCAAQ;AAEtB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAQ;AAAA,MACR,WAAU;AAAA,MACV,OAAO,QAAQ,GAAG,WAAW,KAAK,KAAK,MAAM;AAAA,MAE7C,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,QACxB;AAAA,QACA,SAAS,oBAAC,iBAAA,EAAgB,MAAA,CAAc;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAG/C;AAEA,SAAS,gBAAgB,EAAE,SAA4B;AACrD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,QAAM,aAAa,OAA8B,IAAI;AAErD,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AAAA,IACzD;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,aAAa;AAAA,IACjB,CAAC,MAAwB;AACvB,QAAE,gBAAA;AACF,gBAAU,UAAU,UAAU,KAAK;AACnC,gBAAU,IAAI;AACd,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AACvD,iBAAW,UAAU,WAAW,MAAM,UAAU,KAAK,GAAG,GAAI;AAAA,IAC9D;AAAA,IACA,CAAC,KAAK;AAAA,EAAA;AAGR,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAS;AAAA,MACT,WAAU;AAAA,MACV,OAAO,SAAS,YAAY,QAAQ,KAAK;AAAA,MACzC,MAAK;AAAA,MAEJ,UAAA,6BACE,OAAA,EAAM,WAAU,yBAAwB,IAEzC,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,IAAA;AAAA,EAAA;AAIjC;"}
@@ -7,6 +7,7 @@ import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from ".
7
7
  import { PersonBadge } from "./PersonBadge.js";
8
8
  import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../../../../ui/select.js";
9
9
  import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
10
+ import "../context/AppCatalogFiltersContext.js";
10
11
  import "@tanstack/react-query-devtools";
11
12
  import "next-themes";
12
13
  import "@radix-ui/react-dialog";
@@ -1 +1 @@
1
- {"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Search } from 'lucide-react'\nimport { useMemo, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getGroupBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n}: SubResourcesSectionProps) {\n const { groups } = useAppCatalogContext()\n const [search, setSearch] = useState('')\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false),\n )\n }\n\n return result\n }, [subResources, search, tierFilter])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Access Contacts</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={4}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Resolve maintainer group members\n const maintainerMembers = (\n sr.accessMaintainerGroupSlugs ?? []\n ).flatMap((groupSlug) => {\n const group = getGroupBySlug(groups, groupSlug)\n return group?.memberSlugs ?? []\n })\n // Deduplicate\n const uniqueMaintainers = [...new Set(maintainerMembers)]\n\n return (\n <TableRow key={sr.slug}>\n <TableCell>\n <div className=\"font-medium text-sm\">\n {sr.displayName}\n </div>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {sr.description}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {uniqueMaintainers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {uniqueMaintainers.map((personSlug) => (\n <PersonBadge key={personSlug} slug={personSlug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"SubResourcesSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/SubResourcesSection.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { Search } from 'lucide-react'\nimport { useMemo, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Input } from '~/ui/input'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { PersonBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getGroupBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nexport function SubResourcesSection({\n subResources,\n}: SubResourcesSectionProps) {\n const { groups } = useAppCatalogContext()\n const [search, setSearch] = useState('')\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n const uniqueTiers = useMemo(() => {\n const tiers = new Set<string>()\n for (const sr of subResources) {\n if (sr.tier) tiers.add(sr.tier)\n }\n return [...tiers].sort()\n }, [subResources])\n\n const filtered = useMemo(() => {\n let result = subResources\n\n if (tierFilter !== 'all') {\n result = result.filter((sr) => sr.tier === tierFilter)\n }\n\n if (search.trim()) {\n const q = search.trim().toLowerCase()\n result = result.filter(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)) ||\n (sr.description?.toLowerCase().includes(q) ?? false),\n )\n }\n\n return result\n }, [subResources, search, tierFilter])\n\n if (subResources.length === 0) return null\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between\">\n <div className=\"text-sm font-medium\">\n Sub-Resources ({filtered.length} of {subResources.length})\n </div>\n </div>\n\n {/* Filters */}\n <div className=\"flex gap-2\">\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n placeholder=\"Search resources by name or alias...\"\n value={search}\n onChange={(e) => setSearch(e.target.value)}\n className=\"pl-9 h-9\"\n />\n </div>\n {uniqueTiers.length > 1 && (\n <Select value={tierFilter} onValueChange={setTierFilter}>\n <SelectTrigger className=\"w-[130px] h-9\">\n <SelectValue placeholder=\"All tiers\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"all\">All tiers</SelectItem>\n {uniqueTiers.map((tier) => (\n <SelectItem key={tier} value={tier}>\n {tier}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n </div>\n\n {/* Table */}\n <div className=\"rounded-lg border max-h-[400px] overflow-auto\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>Name</TableHead>\n <TableHead className=\"w-[80px]\">Tier</TableHead>\n <TableHead>Owner</TableHead>\n <TableHead>Access Contacts</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={4}\n className=\"text-center text-muted-foreground py-8\"\n >\n No resources match your filters\n </TableCell>\n </TableRow>\n ) : (\n filtered.map((sr) => {\n // Resolve maintainer group members\n const maintainerMembers = (\n sr.accessMaintainerGroupSlugs ?? []\n ).flatMap((groupSlug) => {\n const group = getGroupBySlug(groups, groupSlug)\n return group?.memberSlugs ?? []\n })\n // Deduplicate\n const uniqueMaintainers = [...new Set(maintainerMembers)]\n\n return (\n <TableRow key={sr.slug}>\n <TableCell>\n <div className=\"font-medium text-sm\">\n {sr.displayName}\n </div>\n {(sr.aliases ?? []).length > 0 && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {(sr.aliases ?? []).join(', ')}\n </div>\n )}\n {sr.description && (\n <div className=\"text-xs text-muted-foreground mt-0.5\">\n {sr.description}\n </div>\n )}\n </TableCell>\n <TableCell>\n {sr.tier && (\n <Badge\n variant={getTierBadgeVariant(sr.tier)}\n className={`text-xs ${getTierBadgeClassName(sr.tier)}`}\n >\n {getTierDisplayLabel(sr.tier)}\n </Badge>\n )}\n </TableCell>\n <TableCell>\n {sr.ownerPersonSlug && (\n <PersonBadge slug={sr.ownerPersonSlug} />\n )}\n </TableCell>\n <TableCell>\n {uniqueMaintainers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {uniqueMaintainers.map((personSlug) => (\n <PersonBadge key={personSlug} slug={personSlug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
5
5
  import { Badge } from "../../../../ui/badge.js";
6
6
  import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "../../../../ui/table.js";
7
7
  import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
8
+ import "../context/AppCatalogFiltersContext.js";
8
9
  import "@tanstack/react-query-devtools";
9
10
  import "next-themes";
10
11
  import "@radix-ui/react-dialog";
@@ -1 +1 @@
1
- {"version":3,"file":"TierVariantsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/TierVariantsSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n TierVariant,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'\nimport { useState } from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { Badge } from '~/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { PersonBadge } from './PersonBadge'\n\ninterface TierVariantsSectionProps {\n tiers: TierVariant[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nfunction getAccessIcon(type: string): React.ReactNode {\n switch (type) {\n case 'service':\n return <Bot className=\"size-4 text-primary shrink-0\" />\n case 'personTeam':\n return <Users className=\"size-4 text-primary shrink-0\" />\n default:\n return <Settings className=\"size-4 text-primary shrink-0\" />\n }\n}\n\n/** Compact inline access detail for a tier row */\nfunction TierAccessDetail({\n accessRequest,\n methods,\n}: {\n accessRequest: AppAccessRequest\n methods: AppApprovalMethod[]\n}) {\n const [expanded, setExpanded] = useState(false)\n const method = methods.find(\n (m) => m.slug === accessRequest.approvalMethodSlug,\n )\n\n const hasExtra =\n accessRequest.comments ||\n accessRequest.urls?.length ||\n accessRequest.approverPersonSlugs?.length ||\n accessRequest.postApprovalInstructions\n\n return (\n <div className=\"space-y-1\">\n <div className=\"flex items-center gap-1.5\">\n {method && getAccessIcon(method.type)}\n {method?.type === 'service' && method.config.url ? (\n <a\n href={method.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {method.displayName}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-sm\">\n {method?.displayName ?? accessRequest.approvalMethodSlug}\n </span>\n )}\n {hasExtra && (\n <button\n type=\"button\"\n onClick={() => setExpanded(!expanded)}\n className=\"text-xs text-muted-foreground hover:text-primary ml-1\"\n >\n {expanded ? 'less' : 'more...'}\n </button>\n )}\n </div>\n {expanded && (\n <div className=\"pl-5 space-y-1.5 text-xs\">\n {accessRequest.comments && (\n <div className=\"text-muted-foreground prose prose-xs max-w-none\">\n <ReactMarkdown>{accessRequest.comments}</ReactMarkdown>\n </div>\n )}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div className=\"flex flex-col gap-0.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline inline-flex items-center gap-1\"\n >\n {urlObj.label || urlObj.url.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ))}\n </div>\n )}\n {accessRequest.approverPersonSlugs &&\n accessRequest.approverPersonSlugs.length > 0 && (\n <div className=\"flex flex-wrap gap-1\">\n {accessRequest.approverPersonSlugs.map((slug) => (\n <PersonBadge key={slug} slug={slug} />\n ))}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport function TierVariantsSection({ tiers }: TierVariantsSectionProps) {\n const { approvalMethods } = useAppCatalogContext()\n\n if (tiers.length === 0) return null\n\n return (\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">Environment Tiers</div>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-[100px]\">Tier</TableHead>\n <TableHead>Name</TableHead>\n <TableHead>URL</TableHead>\n <TableHead>Access</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {tiers.map((tier) => (\n <TableRow key={tier.tierSlug}>\n <TableCell>\n <Badge\n variant={getTierBadgeVariant(tier.tierSlug)}\n className={getTierBadgeClassName(tier.tierSlug)}\n >\n {getTierDisplayLabel(tier.tierSlug)}\n </Badge>\n </TableCell>\n <TableCell className=\"font-medium\">\n {tier.displayName ?? tier.tierSlug}\n </TableCell>\n <TableCell>\n {tier.appUrl ? (\n <a\n href={tier.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {tier.appUrl.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {tier.accessRequest ? (\n <TierAccessDetail\n accessRequest={tier.accessRequest}\n methods={approvalMethods}\n />\n ) : (\n <span className=\"text-muted-foreground\">Same as app</span>\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"TierVariantsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/TierVariantsSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n TierVariant,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'\nimport { useState } from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { Badge } from '~/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { PersonBadge } from './PersonBadge'\n\ninterface TierVariantsSectionProps {\n tiers: TierVariant[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nfunction getAccessIcon(type: string): React.ReactNode {\n switch (type) {\n case 'service':\n return <Bot className=\"size-4 text-primary shrink-0\" />\n case 'personTeam':\n return <Users className=\"size-4 text-primary shrink-0\" />\n default:\n return <Settings className=\"size-4 text-primary shrink-0\" />\n }\n}\n\n/** Compact inline access detail for a tier row */\nfunction TierAccessDetail({\n accessRequest,\n methods,\n}: {\n accessRequest: AppAccessRequest\n methods: AppApprovalMethod[]\n}) {\n const [expanded, setExpanded] = useState(false)\n const method = methods.find(\n (m) => m.slug === accessRequest.approvalMethodSlug,\n )\n\n const hasExtra =\n accessRequest.comments ||\n accessRequest.urls?.length ||\n accessRequest.approverPersonSlugs?.length ||\n accessRequest.postApprovalInstructions\n\n return (\n <div className=\"space-y-1\">\n <div className=\"flex items-center gap-1.5\">\n {method && getAccessIcon(method.type)}\n {method?.type === 'service' && method.config.url ? (\n <a\n href={method.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {method.displayName}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-sm\">\n {method?.displayName ?? accessRequest.approvalMethodSlug}\n </span>\n )}\n {hasExtra && (\n <button\n type=\"button\"\n onClick={() => setExpanded(!expanded)}\n className=\"text-xs text-muted-foreground hover:text-primary ml-1\"\n >\n {expanded ? 'less' : 'more...'}\n </button>\n )}\n </div>\n {expanded && (\n <div className=\"pl-5 space-y-1.5 text-xs\">\n {accessRequest.comments && (\n <div className=\"text-muted-foreground prose prose-xs max-w-none\">\n <ReactMarkdown>{accessRequest.comments}</ReactMarkdown>\n </div>\n )}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div className=\"flex flex-col gap-0.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline inline-flex items-center gap-1\"\n >\n {urlObj.label || urlObj.url.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ))}\n </div>\n )}\n {accessRequest.approverPersonSlugs &&\n accessRequest.approverPersonSlugs.length > 0 && (\n <div className=\"flex flex-wrap gap-1\">\n {accessRequest.approverPersonSlugs.map((slug) => (\n <PersonBadge key={slug} slug={slug} />\n ))}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport function TierVariantsSection({ tiers }: TierVariantsSectionProps) {\n const { approvalMethods } = useAppCatalogContext()\n\n if (tiers.length === 0) return null\n\n return (\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">Environment Tiers</div>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-[100px]\">Tier</TableHead>\n <TableHead>Name</TableHead>\n <TableHead>URL</TableHead>\n <TableHead>Access</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {tiers.map((tier) => (\n <TableRow key={tier.tierSlug}>\n <TableCell>\n <Badge\n variant={getTierBadgeVariant(tier.tierSlug)}\n className={getTierBadgeClassName(tier.tierSlug)}\n >\n {getTierDisplayLabel(tier.tierSlug)}\n </Badge>\n </TableCell>\n <TableCell className=\"font-medium\">\n {tier.displayName ?? tier.tierSlug}\n </TableCell>\n <TableCell>\n {tier.appUrl ? (\n <a\n href={tier.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {tier.appUrl.replace(/^https?:\\/\\//, '')}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {tier.accessRequest ? (\n <TierAccessDetail\n accessRequest={tier.accessRequest}\n methods={approvalMethods}\n />\n ) : (\n <span className=\"text-muted-foreground\">Same as app</span>\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
- import { useState, useMemo, createContext, use } from "react";
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] = useState("");
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, useState } 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 NOT synced to URL\n const [searchValue, setSearchValue] = useState<string>('')\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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"}
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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"}
@@ -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,
@@ -76,12 +93,20 @@ function AppCatalogPage({
76
93
  void navigate({
77
94
  to: "/app/$slug",
78
95
  params: { slug: filteredApps[0].slug },
96
+ search: (prev) => ({
97
+ ...prev,
98
+ q: searchValue === "" ? void 0 : searchValue
99
+ }),
79
100
  replace: true
80
101
  });
81
102
  }
82
- }, [filteredApps, selectedAppSlug, navigate]);
103
+ }, [filteredApps, selectedAppSlug, navigate, searchValue]);
83
104
  const handleAppClick = (app) => {
84
- void navigate({ to: "/app/$slug", params: { slug: app.slug } });
105
+ void navigate({
106
+ to: "/app/$slug",
107
+ params: { slug: app.slug },
108
+ search: (prev) => prev
109
+ });
85
110
  };
86
111
  const handleClearFilters = () => {
87
112
  setSearchValue("");
@@ -110,6 +135,14 @@ function AppCatalogPage({
110
135
  apps: rootResources
111
136
  }
112
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
+ ),
113
146
  /* @__PURE__ */ jsx("div", { className: "flex-1 min-h-0", children: filteredApps.length === 0 ? /* @__PURE__ */ jsxs(Empty, { children: [
114
147
  /* @__PURE__ */ jsxs(EmptyHeader, { children: [
115
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\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, 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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"}
@@ -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>;
@@ -16,7 +17,13 @@ declare const AuthCallbackRoute: import('@tanstack/router-core').Route<import('@
16
17
  code: string | undefined;
17
18
  }, import('@tanstack/router-core').ResolveParams<"/auth/callback">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>;
18
19
  declare const LayoutLoginRoute: 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>, "/login", "/login", "/_layout/login", "/_layout/login", undefined, import('@tanstack/router-core').ResolveParams<"/login">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>;
19
- declare const LayoutAppSlugRoute: 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>, "/app/$slug", "/app/$slug", "/_layout/app/$slug", "/_layout/app/$slug", undefined, import('@tanstack/router-core').ResolveParams<"/app/$slug">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, () => Promise<{
20
+ declare const LayoutAppSlugRoute: 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>, "/app/$slug", "/app/$slug", "/_layout/app/$slug", "/_layout/app/$slug", import('zod').ZodObject<{
21
+ q: import('zod').ZodOptional<import('zod').ZodString>;
22
+ filterTag: import('zod').ZodOptional<import('zod').ZodString>;
23
+ recent: import('zod').ZodOptional<import('zod').ZodString>;
24
+ filters: import('zod').ZodOptional<import('zod').ZodString>;
25
+ deprecated: import('zod').ZodOptional<import('zod').ZodString>;
26
+ }, import('zod/v4/core').$strip>, import('@tanstack/router-core').ResolveParams<"/app/$slug">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, () => Promise<{
20
27
  appCatalogLoader: import('./modules/appCatalog/routeLoader.js').AppCatalogLoaderReturn;
21
28
  }>, unknown, unknown, unknown, unknown, undefined>;
22
29
  declare const LayoutCatalogAppsIndexRoute: 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>, "/catalog/apps", "/catalog/apps/", "/_layout/catalog/apps/", "/_layout/catalog/apps/", import('zod').ZodObject<{
@@ -1,3 +1,10 @@
1
- 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>, "/app/$slug", "/app/$slug", "/_layout/app/$slug", "/_layout/app/$slug", undefined, import('@tanstack/router-core').ResolveParams<"/app/$slug">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, () => Promise<{
1
+ import { z } from 'zod';
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>, "/app/$slug", "/app/$slug", "/_layout/app/$slug", "/_layout/app/$slug", z.ZodObject<{
3
+ q: z.ZodOptional<z.ZodString>;
4
+ filterTag: z.ZodOptional<z.ZodString>;
5
+ recent: z.ZodOptional<z.ZodString>;
6
+ filters: z.ZodOptional<z.ZodString>;
7
+ deprecated: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strip>, import('@tanstack/router-core').ResolveParams<"/app/$slug">, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, import('@tanstack/router-core').AnyContext, {}, () => Promise<{
2
9
  appCatalogLoader: import('~/modules/appCatalog/routeLoader').AppCatalogLoaderReturn;
3
10
  }>, unknown, unknown, unknown, unknown, undefined>;
@@ -3,8 +3,17 @@ import { createFileRoute } from "@tanstack/react-router";
3
3
  import { appCatalogRouteLoader } from "../../modules/appCatalog/routeLoader.js";
4
4
  import { AppCatalogLayout } from "../../modules/appCatalog/ui/layout/AppCatalogLayout.js";
5
5
  import { AppCatalogPage } from "../../modules/appCatalog/ui/pages/AppCatalogPage.js";
6
+ import { object, string } from "../../node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/classic/schemas.js";
7
+ const searchSchema = object({
8
+ q: string().optional(),
9
+ filterTag: string().optional(),
10
+ recent: string().optional(),
11
+ filters: string().optional(),
12
+ deprecated: string().optional()
13
+ });
6
14
  const Route = createFileRoute("/_layout/app/$slug")({
7
15
  component: RouteComponent,
16
+ validateSearch: searchSchema,
8
17
  async loader() {
9
18
  const appCatalogLoader = await appCatalogRouteLoader();
10
19
  return { appCatalogLoader };
@@ -1 +1 @@
1
- {"version":3,"file":"app._slug.js","sources":["../../../../src/routes/_layout/app.$slug.tsx"],"sourcesContent":["import { createFileRoute } from '@tanstack/react-router'\nimport { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'\nimport { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'\nimport { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'\n\nexport const Route = createFileRoute('/_layout/app/$slug')({\n component: RouteComponent,\n async loader() {\n const appCatalogLoader = await appCatalogRouteLoader()\n return { appCatalogLoader }\n },\n})\n\nfunction RouteComponent() {\n const { queryClient, trpcClient } = Route.useRouteContext()\n const { slug } = Route.useParams()\n\n return (\n <AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>\n <AppCatalogPage selectedSlug={slug} />\n </AppCatalogLayout>\n )\n}\n"],"names":[],"mappings":";;;;;AAKO,MAAM,QAAQ,gBAAgB,oBAAoB,EAAE;AAAA,EACzD,WAAW;AAAA,EACX,MAAM,SAAS;AACb,UAAM,mBAAmB,MAAM,sBAAA;AAC/B,WAAO,EAAE,iBAAA;AAAA,EACX;AACF,CAAC;AAED,SAAS,iBAAiB;AACxB,QAAM,EAAE,aAAa,eAAe,MAAM,gBAAA;AAC1C,QAAM,EAAE,KAAA,IAAS,MAAM,UAAA;AAEvB,SACE,oBAAC,oBAAiB,aAA0B,YAC1C,8BAAC,gBAAA,EAAe,cAAc,MAAM,EAAA,CACtC;AAEJ;"}
1
+ {"version":3,"file":"app._slug.js","sources":["../../../../src/routes/_layout/app.$slug.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\n// Preserve the URL-synced filter params (see AppCatalogFiltersContext) on this\n// route so they aren't stripped on navigation — the search query `q` in\n// particular must survive auto-navigation to an app's detail page (#10).\nconst searchSchema = z.object({\n q: z.string().optional(),\n filterTag: z.string().optional(),\n recent: z.string().optional(),\n filters: z.string().optional(),\n deprecated: z.string().optional(),\n})\n\nexport const Route = createFileRoute('/_layout/app/$slug')({\n component: RouteComponent,\n validateSearch: searchSchema,\n async loader() {\n const appCatalogLoader = await appCatalogRouteLoader()\n return { appCatalogLoader }\n },\n})\n\nfunction RouteComponent() {\n const { queryClient, trpcClient } = Route.useRouteContext()\n const { slug } = Route.useParams()\n\n return (\n <AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>\n <AppCatalogPage selectedSlug={slug} />\n </AppCatalogLayout>\n )\n}\n"],"names":["z.object","z.string"],"mappings":";;;;;;AASA,MAAM,eAAeA,OAAS;AAAA,EAC5B,GAAGC,OAAE,EAAS,SAAA;AAAA,EACd,WAAWA,OAAE,EAAS,SAAA;AAAA,EACtB,QAAQA,OAAE,EAAS,SAAA;AAAA,EACnB,SAASA,OAAE,EAAS,SAAA;AAAA,EACpB,YAAYA,OAAE,EAAS,SAAA;AACzB,CAAC;AAEM,MAAM,QAAQ,gBAAgB,oBAAoB,EAAE;AAAA,EACzD,WAAW;AAAA,EACX,gBAAgB;AAAA,EAChB,MAAM,SAAS;AACb,UAAM,mBAAmB,MAAM,sBAAA;AAC/B,WAAO,EAAE,iBAAA;AAAA,EACX;AACF,CAAC;AAED,SAAS,iBAAiB;AACxB,QAAM,EAAE,aAAa,eAAe,MAAM,gBAAA;AAC1C,QAAM,EAAE,KAAA,IAAS,MAAM,UAAA;AAEvB,SACE,oBAAC,oBAAiB,aAA0B,YAC1C,8BAAC,gBAAA,EAAe,cAAc,MAAM,EAAA,CACtC;AAEJ;"}
@@ -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;AACxB,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;"}
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.6.2",
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.2",
138
- "@igstack/app-catalog-shared-core": "0.6.2"
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": "dcc2b0acf435e115ec42861ec79064c6394b1ada",
147
+ "gitHead": "9eee5ee32492cb00394dfe206f41e929bd7f739d",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -52,4 +52,44 @@ 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
+ // Typing a query that narrows the catalog to exactly one app auto-opens
58
+ // that app's detail page. Before the fix the search value lived in
59
+ // component-local state (not the URL), and the filters provider remounts
60
+ // per route, so the input text (and focus) were wiped on navigation.
61
+ // This starts from an EMPTY search (initialRoute '/') and types — the real
62
+ // user flow — so it also guards the effect-ordering race where the child
63
+ // auto-navigate effect runs before the provider's async state→URL sync.
64
+ const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
65
+
66
+ await ui.catalog.search('jira')
67
+
68
+ // Auto-navigates to the single match, carrying the typed query into the URL
69
+ // as `q` (the fix). The regression this guards: before the fix `q` was never
70
+ // written, so on the per-route remount the input reset to '' and lost focus.
71
+ await waitFor(() => {
72
+ expect(router.state.location.pathname).toBe('/app/jira')
73
+ })
74
+
75
+ // The query is persisted in the URL and the input reflects it — they stay in
76
+ // sync across the navigation, so the typed text is never lost.
77
+ const q = (router.state.location.search as { q?: string }).q
78
+ expect(q).toBeTruthy()
79
+ expect(ui.catalog.getSearchInput().value).toBe(q)
80
+ })
81
+
82
+ it('populates the search input from ?q= on the app detail route (#10 read path)', async () => {
83
+ // The detail route must NOT strip `q` from the URL: landing on /app/<slug>
84
+ // with a query must repopulate the search input. This is what was actually
85
+ // broken — the route had no validateSearch schema, so `q` was dropped.
86
+ const { ui } = await given(magazine.full(), {
87
+ initialRoute: '/app/jira?q=jira',
88
+ })
89
+
90
+ await waitFor(() => {
91
+ expect(ui.catalog.isDetailPanelOpen()).toBe(true)
92
+ })
93
+ expect(ui.catalog.getSearchInput().value).toBe('jira')
94
+ })
55
95
  })
@@ -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
+ })
@@ -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 = screen.getByRole('searchbox')
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).
@@ -88,6 +93,25 @@ export class CatalogTools {
88
93
  return !!screen.queryByLabelText('Close details panel')
89
94
  }
90
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
+
91
115
  /**
92
116
  * Whether the onboarding/welcome card is visible.
93
117
  */
@@ -1,5 +1,5 @@
1
1
  import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'
2
- import { useEffect, useRef, useState } from 'react'
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
- const decodedValue = decode ? decode(String(urlValue)) : (urlValue as T)
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, useState } from 'react'
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 NOT synced to URL
104
- const [searchValue, setSearchValue] = useState<string>('')
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',
@@ -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,
@@ -104,19 +153,32 @@ export function AppCatalogPage({
104
153
  searchValue: deferredSearchValue,
105
154
  })
106
155
 
107
- // Auto-open details when only 1 result
156
+ // Auto-open details when only 1 result. Carry the *current* search value into
157
+ // the URL (`q`) as part of this navigation so the search input and focus
158
+ // survive the route change (#10). We must inject `searchValue` explicitly
159
+ // rather than rely on `(prev) => prev`: this child effect runs before the
160
+ // provider's async state→URL sync effect, so at nav time the URL does not yet
161
+ // hold `q` and the query would otherwise be lost on remount.
108
162
  useEffect(() => {
109
163
  if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {
110
164
  void navigate({
111
165
  to: '/app/$slug',
112
166
  params: { slug: filteredApps[0].slug },
167
+ search: (prev) => ({
168
+ ...prev,
169
+ q: searchValue === '' ? undefined : searchValue,
170
+ }),
113
171
  replace: true,
114
172
  })
115
173
  }
116
- }, [filteredApps, selectedAppSlug, navigate])
174
+ }, [filteredApps, selectedAppSlug, navigate, searchValue])
117
175
 
118
176
  const handleAppClick = (app: Resource) => {
119
- void navigate({ to: '/app/$slug', params: { slug: app.slug } })
177
+ void navigate({
178
+ to: '/app/$slug',
179
+ params: { slug: app.slug },
180
+ search: (prev) => prev,
181
+ })
120
182
  }
121
183
 
122
184
  const handleClearFilters = () => {
@@ -156,6 +218,17 @@ export function AppCatalogPage({
156
218
  />
157
219
  </div>
158
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
+
159
232
  <div className="flex-1 min-h-0">
160
233
  {filteredApps.length === 0 ? (
161
234
  <Empty>
@@ -1,10 +1,23 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
+ import { z } from 'zod'
2
3
  import { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'
3
4
  import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
4
5
  import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
5
6
 
7
+ // Preserve the URL-synced filter params (see AppCatalogFiltersContext) on this
8
+ // route so they aren't stripped on navigation — the search query `q` in
9
+ // particular must survive auto-navigation to an app's detail page (#10).
10
+ const searchSchema = z.object({
11
+ q: z.string().optional(),
12
+ filterTag: z.string().optional(),
13
+ recent: z.string().optional(),
14
+ filters: z.string().optional(),
15
+ deprecated: z.string().optional(),
16
+ })
17
+
6
18
  export const Route = createFileRoute('/_layout/app/$slug')({
7
19
  component: RouteComponent,
20
+ validateSearch: searchSchema,
8
21
  async loader() {
9
22
  const appCatalogLoader = await appCatalogRouteLoader()
10
23
  return { appCatalogLoader }
@@ -6,6 +6,7 @@ import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
6
6
 
7
7
  const searchSchema = z.object({
8
8
  filterTag: z.string().optional(),
9
+ q: z.string().optional(),
9
10
  })
10
11
 
11
12
  export const Route = createFileRoute('/_layout/')({