@igstack/app-catalog-frontend-core 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/esm/__tests__/integration/harness/given.d.ts +1 -0
  2. package/dist/esm/__tests__/integration/skewWarning.integration.test.d.ts +0 -0
  3. package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +5 -0
  4. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +15 -1
  5. package/dist/esm/__tests__/modules/appCatalog/utils/resolveHelpers.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.d.ts +40 -0
  7. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +40 -0
  8. package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -0
  9. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +17 -0
  10. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +61 -0
  11. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -0
  12. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +9 -2
  13. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  14. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js +1 -1
  15. package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.d.ts +6 -0
  17. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +95 -31
  18. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  19. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +9 -1
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
  23. package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
  25. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +73 -18
  26. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.d.ts +9 -0
  28. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js +31 -0
  29. package/dist/esm/modules/appCatalog/ui/launcher/AttributionFooter.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +16 -0
  31. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +40 -0
  32. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
  34. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +389 -0
  35. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
  36. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
  37. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
  38. package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
  39. package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
  40. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
  41. package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
  42. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
  43. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  44. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
  45. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
  46. package/dist/esm/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
  47. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
  48. package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
  49. package/dist/esm/routeTree.gen.d.ts +1 -1
  50. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  51. package/dist/esm/routes/_layout/app._slug.js +4 -3
  52. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  53. package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
  54. package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
  55. package/dist/esm/routes/_layout/index.js +2 -1
  56. package/dist/esm/routes/_layout/index.js.map +1 -1
  57. package/dist/esm/routes/_layout/service-desks.js +2 -1
  58. package/dist/esm/routes/_layout/service-desks.js.map +1 -1
  59. package/dist/esm/types/uiSettings.d.ts +15 -0
  60. package/dist/esm/ui/components/header/Header.js +1 -1
  61. package/dist/esm/ui/components/header/Header.js.map +1 -1
  62. package/dist/index.css +73 -66
  63. package/package.json +4 -4
  64. package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
  65. package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
  66. package/src/__tests__/integration/appFreshness.integration.test.ts +3 -3
  67. package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
  68. package/src/__tests__/integration/harness/given.tsx +9 -1
  69. package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
  70. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
  71. package/src/__tests__/integration/setup/testSetup.ts +1 -0
  72. package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
  73. package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
  74. package/src/__tests__/integration/tools/CatalogTools.ts +62 -11
  75. package/src/__tests__/integration/tools/GalleryTools.ts +7 -1
  76. package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
  77. package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
  78. package/src/index.css +73 -66
  79. package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
  80. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
  81. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
  82. package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
  83. package/src/modules/appCatalog/ui/components/PersonBadge.tsx +103 -30
  84. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
  85. package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
  86. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +90 -23
  87. package/src/modules/appCatalog/ui/launcher/AttributionFooter.tsx +43 -0
  88. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +52 -0
  89. package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +547 -0
  90. package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
  91. package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
  92. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
  93. package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
  94. package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
  95. package/src/routes/_layout/app.$slug.tsx +7 -4
  96. package/src/routes/_layout/catalog.apps.index.tsx +4 -1
  97. package/src/routes/_layout/index.tsx +5 -1
  98. package/src/routes/_layout/service-desks.tsx +4 -1
  99. package/src/types/uiSettings.ts +15 -0
  100. package/src/ui/components/header/Header.tsx +3 -1
@@ -19,6 +19,8 @@ import { OnboardingCard } from '../components/OnboardingCard'
19
19
  import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
20
20
  import { FilterBar } from '../filters/FilterBar'
21
21
  import { AppCatalogGrid } from '../grid/AppCatalogGrid'
22
+ import { LauncherHome } from '../launcher/LauncherHome'
23
+ import { LauncherDetailPanel } from '../launcher/LauncherDetailPanel'
22
24
 
23
25
  export function AppCatalogPage({
24
26
  selectedSlug,
@@ -153,32 +155,37 @@ export function AppCatalogPage({
153
155
  searchValue: deferredSearchValue,
154
156
  })
155
157
 
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.
158
+ // Auto-open details when only 1 result ONLY in the legacy grid path
159
+ // (recent/tag filters active). In the launcher (#38) typing shows the
160
+ // search-morph results list; auto-navigating to /app/<slug> on a single
161
+ // match would yank the user out of the launcher into the old grid+panel
162
+ // (reported bug: searching "biom" jumped straight to an app-detail page).
163
+ // The morph already renders the single match as a keyboard-selectable row
164
+ // the user presses ↵ or clicks to open it.
165
+ const legacyGridActive =
166
+ filterState.recentMode || Object.keys(filterState.tagFilters).length > 0
162
167
  useEffect(() => {
163
- if (filteredApps.length === 1 && filteredApps[0] && !selectedAppSlug) {
168
+ if (
169
+ legacyGridActive &&
170
+ filteredApps.length === 1 &&
171
+ filteredApps[0] &&
172
+ !selectedAppSlug
173
+ ) {
164
174
  void navigate({
165
175
  to: '/app/$slug',
166
176
  params: { slug: filteredApps[0].slug },
167
- search: (prev) => ({
168
- ...prev,
169
- q: searchValue === '' ? undefined : searchValue,
170
- }),
177
+ search: (prev) => prev,
171
178
  replace: true,
172
179
  })
173
180
  }
174
- }, [filteredApps, selectedAppSlug, navigate, searchValue])
181
+ }, [legacyGridActive, filteredApps, selectedAppSlug, navigate])
175
182
 
176
183
  // #22: alias → canonical redirect. When an app is renamed its old slug is
177
184
  // kept in `aliases[]`. If the URL slug matches no canonical slug but does
178
185
  // match some app's alias, redirect (replace) to that app's canonical slug so
179
186
  // old bookmarks resolve instead of showing a blank catalog. Guard on a real
180
187
  // canonical miss so we never fight the normal detail-open path. Note: this is
181
- // a client-side SPA redirect (replace), not an HTTP 301 — see #22.
188
+ // a client-side SPA redirect (replace), not an HTTP 301 — see ig-umbrella#22.
182
189
  useEffect(() => {
183
190
  if (!selectedAppSlug || resources.length === 0) return
184
191
  const canonical = resources.some((r) => r.slug === selectedAppSlug)
@@ -202,6 +209,27 @@ export function AppCatalogPage({
202
209
  })
203
210
  }
204
211
 
212
+ const handleLaunch = (app: Resource) => {
213
+ if (app.appUrl) window.open(app.appUrl, '_blank', 'noopener,noreferrer')
214
+ }
215
+
216
+ // Adaptive-home launcher (#38): shown whenever no structural filter
217
+ // (recentMode / tagFilters) is active. A selected app no longer forces the
218
+ // old grid — instead the launcher stays as the backdrop and the app detail
219
+ // renders in a slide-over panel (#38 item B). The legacy grid+split-pane is
220
+ // reserved for the recent/tag filter views.
221
+ const showLauncherHome =
222
+ !filterState.recentMode && Object.keys(filterState.tagFilters).length === 0
223
+
224
+ // The app whose detail slide-over is open over the launcher backdrop.
225
+ const launcherSelectedApp = useMemo(
226
+ () =>
227
+ selectedAppSlug
228
+ ? (resources.find((r) => r.slug === selectedAppSlug) ?? null)
229
+ : null,
230
+ [selectedAppSlug, resources],
231
+ )
232
+
205
233
  const handleClearFilters = () => {
206
234
  setSearchValue('')
207
235
  actions.clearAllFilters()
@@ -224,6 +252,40 @@ export function AppCatalogPage({
224
252
  // Use first tag definition for grouping
225
253
  const groupingDefinition = tagsDefinitions[0]
226
254
 
255
+ // Adaptive-home launcher view (#38): the discovery spine or the search-morph
256
+ // results list (handled inside LauncherHome). Owns vertical scroll —
257
+ // MainLayout is h-screen/overflow-hidden, so the launcher must scroll
258
+ // internally to reveal the full Browse-all list as the user scrolls.
259
+ if (showLauncherHome) {
260
+ return (
261
+ <div className="flex-1 min-h-0 overflow-y-auto">
262
+ <LauncherHome
263
+ apps={rootResources.filter(
264
+ (a) => filterState.showDeprecated || !a.deprecated,
265
+ )}
266
+ // All resources incl. children — so search matches sub-resources
267
+ // (e.g. an AWS account) and surfaces their parent, preserving the
268
+ // existing cross-sub-resource search behavior.
269
+ allResources={resources}
270
+ searchValue={searchValue}
271
+ onSearchChange={setSearchValue}
272
+ onAppClick={handleAppClick}
273
+ onLaunch={handleLaunch}
274
+ totalCount={totalAppsCount}
275
+ />
276
+ {/* #38 item B: app detail as a slide-over over the launcher backdrop,
277
+ instead of dropping into the old grid + split-pane. */}
278
+ {launcherSelectedApp && (
279
+ <LauncherDetailPanel
280
+ app={launcherSelectedApp}
281
+ onClose={() => void navigate({ to: '/' })}
282
+ onAppClick={handleAppClick}
283
+ />
284
+ )}
285
+ </div>
286
+ )
287
+ }
288
+
227
289
  return (
228
290
  <div className="flex flex-col flex-1 min-h-0">
229
291
  <div className="shrink-0">
@@ -2,7 +2,7 @@
2
2
  * Reduce a short markdown string to its visible plain text.
3
3
  *
4
4
  * Descriptions may contain markdown — notably cross-reference links like
5
- * `[Example Portal](/app/example-portal)` (#25). The detail panel renders
5
+ * `[Signatera Portal](/app/signatera-portal)` (#25). The detail panel renders
6
6
  * those as interactive links via {@link MarkdownText}, but in compact / clamped
7
7
  * surfaces (grid list preview, table row, filter combobox, sub-resources) an
8
8
  * interactive link would break `line-clamp` and can't be reliably highlighted
@@ -25,5 +25,36 @@ export function getRootResources(resources: Resource[]): Resource[] {
25
25
  return resources.filter((r) => !r.parentSlug)
26
26
  }
27
27
 
28
+ /** The immediate parent of a resource (undefined for a root or if missing). */
29
+ export function getParentResource(
30
+ resources: Resource[],
31
+ resource: Pick<Resource, 'parentSlug'>,
32
+ ): Resource | undefined {
33
+ if (!resource.parentSlug) return undefined
34
+ return resources.find((r) => r.slug === resource.parentSlug)
35
+ }
36
+
37
+ /**
38
+ * The prerequisite chain for a resource: its ancestors that carry an access
39
+ * policy, ordered ROOT → nearest parent. Used to surface the two-step access
40
+ * pattern ("get <parent> access first, then this resource"), which the domain
41
+ * model computes from the tree but users can't see otherwise (#38 item D).
42
+ * Guards against cycles.
43
+ */
44
+ export function getAccessPrerequisiteChain(
45
+ resources: Resource[],
46
+ resource: Pick<Resource, 'parentSlug'>,
47
+ ): Resource[] {
48
+ const chain: Resource[] = []
49
+ const seen = new Set<string>()
50
+ let current = getParentResource(resources, resource)
51
+ while (current && !seen.has(current.slug)) {
52
+ seen.add(current.slug)
53
+ if (current.accessRequest) chain.unshift(current)
54
+ current = getParentResource(resources, current)
55
+ }
56
+ return chain
57
+ }
58
+
28
59
  /** @deprecated Use getChildResources instead */
29
60
  export const getSubResourcesForApp = getChildResources
@@ -1,23 +1,26 @@
1
- import { createFileRoute } from '@tanstack/react-router'
1
+ import { createFileRoute, stripSearchParams } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'
4
4
  import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
5
5
  import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
6
6
 
7
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).
8
+ // route so they aren't stripped on navigation. `q` (the search text) is the
9
+ // exception: as of #27 it lives in sessionStorage, not the URL. It stays
10
+ // declared here so an incoming legacy `?q=` still validates, but the
11
+ // `stripSearchParams` middleware removes it so shared app links stay clean.
10
12
  const searchSchema = z.object({
11
- q: z.string().optional(),
12
13
  filterTag: z.string().optional(),
13
14
  recent: z.string().optional(),
14
15
  filters: z.string().optional(),
15
16
  deprecated: z.string().optional(),
17
+ q: z.string().optional(),
16
18
  })
17
19
 
18
20
  export const Route = createFileRoute('/_layout/app/$slug')({
19
21
  component: RouteComponent,
20
22
  validateSearch: searchSchema,
23
+ search: { middlewares: [stripSearchParams(['q'])] },
21
24
  async loader() {
22
25
  const appCatalogLoader = await appCatalogRouteLoader()
23
26
  return { appCatalogLoader }
@@ -1,9 +1,11 @@
1
- import { createFileRoute } from '@tanstack/react-router'
1
+ import { createFileRoute, stripSearchParams } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { catalogRouteLoader } from '~/modules/appCatalog/catalogRouteLoader'
4
4
  import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
5
5
  import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
6
6
 
7
+ // #27: `q` kept declared for legacy links but stripped from the URL (search
8
+ // text lives in sessionStorage now).
7
9
  const searchSchema = z.object({
8
10
  app: z.string().optional(),
9
11
  q: z.string().optional(),
@@ -12,6 +14,7 @@ const searchSchema = z.object({
12
14
  export const Route = createFileRoute('/_layout/catalog/apps/')({
13
15
  component: RouteComponent,
14
16
  validateSearch: searchSchema,
17
+ search: { middlewares: [stripSearchParams(['q'])] },
15
18
  async loader(ctx) {
16
19
  await catalogRouteLoader(ctx)
17
20
  },
@@ -1,9 +1,12 @@
1
- import { createFileRoute } from '@tanstack/react-router'
1
+ import { createFileRoute, stripSearchParams } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'
4
4
  import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
5
5
  import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
6
6
 
7
+ // #27: `q` (search text) no longer belongs in the URL — it lives in
8
+ // sessionStorage now. Keep it declared so incoming `?q=` validates, but the
9
+ // `stripSearchParams` middleware removes it so shared/bookmarked links stay clean.
7
10
  const searchSchema = z.object({
8
11
  filterTag: z.string().optional(),
9
12
  q: z.string().optional(),
@@ -12,6 +15,7 @@ const searchSchema = z.object({
12
15
  export const Route = createFileRoute('/_layout/')({
13
16
  component: RouteComponent,
14
17
  validateSearch: searchSchema,
18
+ search: { middlewares: [stripSearchParams(['q'])] },
15
19
  async loader() {
16
20
  const appCatalogLoader = await appCatalogRouteLoader()
17
21
  return {
@@ -1,9 +1,11 @@
1
- import { createFileRoute } from '@tanstack/react-router'
1
+ import { createFileRoute, stripSearchParams } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { appCatalogRouteLoader } from '~/modules/appCatalog/routeLoader'
4
4
  import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
5
5
  import { ServiceDesksPage } from '~/modules/appCatalog/ui/pages/ServiceDesksPage'
6
6
 
7
+ // #27: `q` kept declared for legacy links but stripped from the URL (search
8
+ // text lives in sessionStorage now).
7
9
  const searchSchema = z.object({
8
10
  q: z.string().optional(),
9
11
  })
@@ -11,6 +13,7 @@ const searchSchema = z.object({
11
13
  export const Route = createFileRoute('/_layout/service-desks')({
12
14
  component: RouteComponent,
13
15
  validateSearch: searchSchema,
16
+ search: { middlewares: [stripSearchParams(['q'])] },
14
17
  async loader() {
15
18
  const appCatalogLoader = await appCatalogRouteLoader()
16
19
  return { appCatalogLoader }
@@ -13,4 +13,19 @@ export interface UiSettings {
13
13
  frontendBuildId?: string
14
14
  /** PWA auto-update configuration (idle timeout, check interval, debug) */
15
15
  pwaAutoUpdate?: PwaAutoUpdateOptions
16
+ /**
17
+ * Optional attribution shown as a subtle footer line on the home view.
18
+ * Kept generic here (no hard-coded names/links) so the OSS core stays
19
+ * vendor-neutral; the consuming app supplies its own author + repo links.
20
+ */
21
+ attribution?: {
22
+ /** e.g. "Made by <name>" — rendered verbatim. */
23
+ madeBy?: string
24
+ /** Labeled links; `kind` lets the UI hint open-source vs proprietary. */
25
+ links?: {
26
+ label: string
27
+ url: string
28
+ kind?: 'oss' | 'proprietary'
29
+ }[]
30
+ }
16
31
  }
@@ -142,7 +142,9 @@ export function Header({ middle }: HeaderProps) {
142
142
  <div className="flex items-center gap-2">
143
143
  <AppCatalogLogo className="h-16 w-16" />
144
144
  <div className="flex flex-col">
145
- <span className="text-lg font-bold">App Catalog</span>
145
+ <span className="font-serif text-lg font-semibold">
146
+ App Catalog
147
+ </span>
146
148
  {appCatalogContextMaybe?.versions && (
147
149
  <VersionDisplay versions={appCatalogContextMaybe.versions} />
148
150
  )}