@igstack/app-catalog-frontend-core 6.5.2 → 6.7.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 (143) hide show
  1. package/dist/esm/App.d.ts +29 -3
  2. package/dist/esm/App.js +4 -2
  3. package/dist/esm/App.js.map +1 -1
  4. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.d.ts +0 -0
  5. package/dist/esm/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.d.ts +0 -0
  6. package/dist/esm/__tests__/modules/dismiss/DismissStack.test.d.ts +0 -0
  7. package/dist/esm/__tests__/modules/extensions/slots.test.d.ts +0 -0
  8. package/dist/esm/__tests__/publicEntryResolves.test.d.ts +1 -0
  9. package/dist/esm/appPropsFactory.d.ts +19 -1
  10. package/dist/esm/appPropsFactory.js +4 -3
  11. package/dist/esm/appPropsFactory.js.map +1 -1
  12. package/dist/esm/index.d.ts +15 -2
  13. package/dist/esm/index.js +5 -0
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/internal.d.ts +7 -1
  16. package/dist/esm/internal.js +0 -2
  17. package/dist/esm/internal.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/context/AppCatalogContext.d.ts +17 -0
  19. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +7 -16
  20. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +20 -11
  22. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  23. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +39 -17
  24. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  25. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +19 -3
  26. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -14
  28. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  29. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js +5 -18
  30. package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
  31. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +12 -1
  32. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +3 -1
  33. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  34. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +17 -1
  35. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +212 -79
  36. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  37. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -14
  38. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  39. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.d.ts +1 -2
  40. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +31 -23
  41. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  42. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -0
  43. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  44. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +5 -3
  45. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  46. package/dist/esm/modules/auth/AuthContext.d.ts +12 -0
  47. package/dist/esm/modules/auth/AuthContext.js +5 -0
  48. package/dist/esm/modules/auth/AuthContext.js.map +1 -1
  49. package/dist/esm/modules/auth/index.d.ts +1 -1
  50. package/dist/esm/modules/dismiss/DismissStack.d.ts +102 -0
  51. package/dist/esm/modules/dismiss/DismissStack.js +82 -0
  52. package/dist/esm/modules/dismiss/DismissStack.js.map +1 -0
  53. package/dist/esm/modules/extensions/ExtensionsContext.d.ts +17 -0
  54. package/dist/esm/modules/extensions/ExtensionsContext.js +15 -0
  55. package/dist/esm/modules/extensions/ExtensionsContext.js.map +1 -0
  56. package/dist/esm/modules/extensions/factories.d.ts +18 -0
  57. package/dist/esm/modules/extensions/factories.js +12 -0
  58. package/dist/esm/modules/extensions/factories.js.map +1 -0
  59. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +92 -0
  60. package/dist/esm/modules/extensions/features/resourceDetail.js +14 -0
  61. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -0
  62. package/dist/esm/modules/extensions/index.d.ts +79 -0
  63. package/dist/esm/modules/extensions/index.js +26 -0
  64. package/dist/esm/modules/extensions/index.js.map +1 -0
  65. package/dist/esm/modules/extensions/makeSlot.d.ts +46 -0
  66. package/dist/esm/modules/extensions/makeSlot.js +62 -0
  67. package/dist/esm/modules/extensions/makeSlot.js.map +1 -0
  68. package/dist/esm/modules/extensions/types.d.ts +46 -0
  69. package/dist/esm/modules/gallery/Gallery.js +6 -14
  70. package/dist/esm/modules/gallery/Gallery.js.map +1 -1
  71. package/dist/esm/routeTree.gen.d.ts +34 -34
  72. package/dist/esm/routeTree.gen.js +14 -14
  73. package/dist/esm/routeTree.gen.js.map +1 -1
  74. package/dist/esm/router.d.ts +23 -0
  75. package/dist/esm/router.js +5 -0
  76. package/dist/esm/router.js.map +1 -0
  77. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
  78. package/dist/esm/routes/_layout/app.$slug_.sub.$subSlug.d.ts +1 -1
  79. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +2 -2
  80. package/dist/esm/routes/_layout/index.d.ts +1 -1
  81. package/dist/esm/routes/_layout/login.d.ts +1 -1
  82. package/dist/esm/routes/_layout/mcp.d.ts +1 -1
  83. package/dist/esm/routes/_layout/service-desks.d.ts +1 -1
  84. package/dist/esm/routes/_layout.d.ts +1 -1
  85. package/dist/esm/routes/_layout.js +13 -2
  86. package/dist/esm/routes/_layout.js.map +1 -1
  87. package/dist/esm/routes/auth.callback.d.ts +1 -1
  88. package/dist/esm/ui/components/error/DefaultErrorComponent.js +9 -2
  89. package/dist/esm/ui/components/error/DefaultErrorComponent.js.map +1 -1
  90. package/dist/esm/ui/components/header/Header.js +0 -1
  91. package/dist/esm/ui/components/header/Header.js.map +1 -1
  92. package/dist/esm/ui/dialog.d.ts +1 -1
  93. package/dist/esm/ui/dialog.js +9 -0
  94. package/dist/esm/ui/dialog.js.map +1 -1
  95. package/dist/esm/ui/table.d.ts +25 -1
  96. package/dist/esm/ui/table.js +20 -3
  97. package/dist/esm/ui/table.js.map +1 -1
  98. package/dist/esm/util/createAcRouter.d.ts +1 -1
  99. package/package.json +19 -11
  100. package/src/App.tsx +34 -3
  101. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +4 -1
  102. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +1 -1
  103. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +81 -8
  104. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +115 -0
  105. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.rowMemo.test.tsx +120 -0
  106. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -5
  107. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +9 -9
  108. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +31 -5
  109. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -3
  110. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +48 -6
  111. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +1 -7
  112. package/src/__tests__/modules/dismiss/DismissStack.test.tsx +330 -0
  113. package/src/__tests__/modules/extensions/slots.test.tsx +252 -0
  114. package/src/__tests__/publicEntryResolves.test.ts +63 -0
  115. package/src/appPropsFactory.ts +23 -3
  116. package/src/index.tsx +32 -2
  117. package/src/internal.ts +7 -1
  118. package/src/modules/appCatalog/context/AppCatalogContext.tsx +21 -0
  119. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +52 -12
  120. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +59 -18
  121. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +33 -2
  122. package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +12 -20
  123. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +19 -0
  124. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +396 -111
  125. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +46 -26
  126. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +20 -0
  127. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +17 -3
  128. package/src/modules/auth/AuthContext.tsx +15 -0
  129. package/src/modules/auth/index.ts +1 -0
  130. package/src/modules/dismiss/DismissStack.tsx +224 -0
  131. package/src/modules/extensions/ExtensionsContext.tsx +36 -0
  132. package/src/modules/extensions/factories.ts +19 -0
  133. package/src/modules/extensions/features/resourceDetail.ts +74 -0
  134. package/src/modules/extensions/index.ts +121 -0
  135. package/src/modules/extensions/makeSlot.tsx +123 -0
  136. package/src/modules/extensions/types.ts +51 -0
  137. package/src/modules/gallery/Gallery.tsx +13 -17
  138. package/src/routeTree.gen.ts +31 -31
  139. package/src/router.ts +23 -0
  140. package/src/routes/_layout.tsx +51 -1
  141. package/src/ui/components/error/DefaultErrorComponent.tsx +22 -4
  142. package/src/ui/dialog.tsx +26 -0
  143. package/src/ui/table.tsx +46 -3
package/src/index.tsx CHANGED
@@ -1,4 +1,8 @@
1
1
  export { appPropsFactory } from './appPropsFactory.js'
2
+ export type { AppPropsFactoryOptions } from './appPropsFactory.js'
3
+ // What a deployment's own router factory is handed. Safe on this entry: it
4
+ // names no route tree, so it carries no `declare module` with it.
5
+ export type { AcRouterContext, AcRouterInitParams } from './types/types'
2
6
  export { App } from './App'
3
7
  export type { AppProps } from './App'
4
8
  export type { AreaIcon, AreasSettings, UiSettings } from './types/uiSettings'
@@ -6,5 +10,31 @@ export {
6
10
  DAY_TO_DAY_AREA_KEY,
7
11
  DAY_TO_DAY_TAG,
8
12
  } from './modules/appCatalog/utils/areaGrouping'
9
- export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa'
10
- export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa'
13
+ export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa/index'
14
+ export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa/index'
15
+
16
+ // ── Plugins ───────────────────────────────────────────────────────────────────
17
+ // A deployment registers these via <App extensions={[...]} />. `Resource` is
18
+ // re-exported because the slot payloads name it: without that, a consumer would
19
+ // have to depend on backend-core directly just to type a handler.
20
+ export type {
21
+ AcPlugin,
22
+ PluginSlots,
23
+ PluginWrappers,
24
+ PluginUser,
25
+ SlotSpec,
26
+ WrapperSpec,
27
+ } from './modules/extensions/index'
28
+ export type { Resource } from '@igstack/app-catalog-backend-core'
29
+ // For a deployment that owns its own routes: resolving its own path params
30
+ // against the catalog. Narrow on purpose — see the hook's own comment.
31
+ export { useCatalogResources } from './modules/appCatalog/context/AppCatalogContext'
32
+
33
+ /**
34
+ * Escape handling, for a deployment's own overlays.
35
+ *
36
+ * Call `useDismissable(close)` in any overlay and Escape dismisses whatever
37
+ * opened last, across core and deployment layers alike. Nothing needs to know
38
+ * what else is on screen.
39
+ */
40
+ export { useDismissable, useDismissStack } from './modules/dismiss/DismissStack'
package/src/internal.ts CHANGED
@@ -5,11 +5,17 @@
5
5
  *
6
6
  * No stability guarantees: this moves in lockstep with
7
7
  * `@igstack/app-catalog-test-kit`, which is its only intended consumer.
8
+ *
9
+ * Must stay free of `createAcRouter`. The test kit runs inside a DEPLOYMENT's
10
+ * test program, which declares its own `Register` augmentation for its own
11
+ * route tree — and re-exporting `createAcRouter` here would drag the core's
12
+ * competing augmentation in with it, which is a hard `TS2717`. The router
13
+ * factory is injected instead; see `setRouterFactory` in the test kit. The
14
+ * core's own router is published separately, at `/router`.
8
15
  */
9
16
  export { App } from './App'
10
17
  export type { AppProps } from './App'
11
18
  export { AcDb, dbCacheDbKeys } from './userDb/AcDb'
12
- export { createAcRouter } from './util/createAcRouter'
13
19
  export {
14
20
  SEARCH_STORAGE_KEY,
15
21
  clearSessionState,
@@ -83,3 +83,24 @@ export function useAppCatalogContext(): AppCatalogContextIface {
83
83
  }
84
84
  return context
85
85
  }
86
+
87
+ /**
88
+ * The catalog, for a deployment that owns its own routes.
89
+ *
90
+ * A deployment composing its own route tree has to resolve its own path params
91
+ * against the catalog — `/app/$slug/…/$account` means nothing until `$account`
92
+ * is matched to a resource. It needs the resources to do that, and the loading
93
+ * flag to tell "still fetching" from "no such resource", which is the
94
+ * difference between a spinner and a redirect.
95
+ *
96
+ * Deliberately narrower than `useAppCatalogContext`: persons, groups, tags and
97
+ * approval methods are the core's own rendering concerns, and publishing them
98
+ * would make every one of them part of the supported surface.
99
+ */
100
+ export function useCatalogResources(): {
101
+ resources: Resource[]
102
+ isLoading: boolean
103
+ } {
104
+ const { resources, isLoadingApps } = useAppCatalogContext()
105
+ return { resources, isLoading: isLoadingApps }
106
+ }
@@ -1,6 +1,14 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { ArrowUpRight, Search, X } from 'lucide-react'
3
- import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import {
4
+ use,
5
+ useCallback,
6
+ useDeferredValue,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
4
12
  import { cn } from '~/lib/utils'
5
13
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
6
14
  import { markdownToPlainText } from '../../utils/markdownToPlainText'
@@ -697,7 +705,29 @@ export function AppCatalogGrid({
697
705
  // on the areas most people need. Within an area, alpha (from browse).
698
706
  const browseGroups = useMemo(() => groupByArea(browse), [browse])
699
707
 
700
- const isSearching = searchValue.trim() !== ''
708
+ /**
709
+ * The query the RESULTS are computed from; the input stays on `searchValue`.
710
+ *
711
+ * Searching rebuilds the ranked result set from scratch on every keystroke —
712
+ * `searchResources` rebuilds its parent index, scores every resource across
713
+ * 15 tiers and sorts — and the first keystroke additionally unmounts the
714
+ * whole discovery spine while the last one remounts it. Doing that between
715
+ * the keypress and the caret moving is what made typing stutter. Deferring
716
+ * hands React permission to render the typed character first and the list
717
+ * after, abandoning a superseded pass instead of finishing one per
718
+ * character.
719
+ *
720
+ * `isSearching` is derived from the DEFERRED value on purpose. Driving the
721
+ * results/spine swap from the live value instead tears the frame: the spine
722
+ * unmounts immediately while the results are still a render behind, so the
723
+ * page blanks for a frame on the first keystroke. Everything the user sees
724
+ * for a given query — the swap, the rows and the highlight marks — comes off
725
+ * one value, so they can never disagree.
726
+ */
727
+ const deferredSearch = useDeferredValue(searchValue)
728
+ const isSearching = deferredSearch.trim() !== ''
729
+ // True while the list is a render behind the box, used only to dim it.
730
+ const isSearchPending = searchValue !== deferredSearch
701
731
  const searchRef = useRef<HTMLInputElement>(null)
702
732
 
703
733
  // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.
@@ -764,16 +794,26 @@ export function AppCatalogGrid({
764
794
  search box. The discovery spine below is what gets replaced — the
765
795
  shell, hero and search box stay put, so typing never shifts layout. */}
766
796
  {isSearching && (
767
- <SearchResultsList
768
- apps={allResources ?? apps}
769
- searchValue={searchValue}
770
- onAppClick={onAppClick}
771
- onSubClick={onSubClick}
772
- onLaunch={onLaunch}
773
- onClear={() => onSearchChange('')}
774
- keyboardEnabled={!detailOpen}
775
- selectedSubSlug={selectedSubSlug}
776
- />
797
+ <div
798
+ className={`transition-opacity ${
799
+ isSearchPending ? 'opacity-60' : ''
800
+ }`}
801
+ aria-busy={isSearchPending || undefined}
802
+ >
803
+ <SearchResultsList
804
+ apps={allResources ?? apps}
805
+ // Deferred, not live: `Highlight` inside marks the matched text, and
806
+ // feeding it a newer query than the rows were built from highlights
807
+ // substrings the rows do not contain.
808
+ searchValue={deferredSearch}
809
+ onAppClick={onAppClick}
810
+ onSubClick={onSubClick}
811
+ onLaunch={onLaunch}
812
+ onClear={() => onSearchChange('')}
813
+ keyboardEnabled={!detailOpen}
814
+ selectedSubSlug={selectedSubSlug}
815
+ />
816
+ </div>
777
817
  )}
778
818
 
779
819
  {/* Discovery spine: only shown when not searching */}
@@ -1,7 +1,14 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
2
  import { X } from 'lucide-react'
3
- import { useEffect, useLayoutEffect, useRef } from 'react'
3
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
4
4
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
5
+ import {
6
+ ResourceDetailAccessActions,
7
+ ResourceDetailProvider,
8
+ } from '~/modules/extensions'
9
+ import { useOptionalUser } from '~/modules/auth'
10
+ import { useDismissStack, useDismissable } from '~/modules/dismiss/DismissStack'
11
+ import { getChildResources } from '../../utils/resolveHelpers'
5
12
  import { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'
6
13
  import { AppDetails } from '../detail/AppDetails'
7
14
 
@@ -35,7 +42,20 @@ export function AppDetailPanel({
35
42
  /** Clears `?sub=` and returns to the parent's detail. */
36
43
  onBackToParent?: () => void
37
44
  }) {
38
- const { approvalMethods } = useAppCatalogContext()
45
+ const { approvalMethods, resources: allResources } = useAppCatalogContext()
46
+ const user = useOptionalUser()
47
+
48
+ // The sub-resource branch has no band of its own, so its slot payload is
49
+ // assembled here, where the catalog context already is, and handed down.
50
+ const subResourceActionsCtx = useMemo(
51
+ () => ({
52
+ resource: subResource ?? app,
53
+ parent: subResource ? app : undefined,
54
+ subResources: getChildResources(allResources, app.slug),
55
+ user: user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null,
56
+ }),
57
+ [subResource, app, allResources, user],
58
+ )
39
59
  const dialogRef = useRef<HTMLDivElement>(null)
40
60
 
41
61
  /**
@@ -92,10 +112,24 @@ export function AppDetailPanel({
92
112
  // caret would land on <body> — where the next Tab walks the catalog grid
93
113
  // behind the scrim, since the card has no focus trap.
94
114
  const shownSlug = subResource?.slug ?? app.slug
115
+ const stack = useDismissStack()
95
116
  useEffect(() => {
96
117
  const dialog = dialogRef.current
97
- if (dialog && !dialog.contains(document.activeElement)) dialog.focus()
98
- }, [shownSlug])
118
+ if (!dialog || dialog.contains(document.activeElement)) return
119
+ // Only while the card is on top. A layer opened OVER it — a deployment's
120
+ // dialog on a nested route — owns the caret, and on a cold deep link that
121
+ // layer mounts before the card does, so without this check the card took
122
+ // focus back off the dialog's first field the moment the catalog arrived.
123
+ const top = stack.topElement()
124
+ if (top && top !== dialog) return
125
+ dialog.focus()
126
+ }, [shownSlug, stack])
127
+
128
+ // Escape closes the card. Registered here rather than in AppDetails because
129
+ // this is the layer's element, and because the sub-resource view renders a
130
+ // different body in place of AppDetails — which left that view with no
131
+ // Escape at all while the registration lived in the body.
132
+ useDismissable(dialogRef, onClose)
99
133
 
100
134
  return (
101
135
  <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10">
@@ -147,20 +181,27 @@ export function AppDetailPanel({
147
181
  key={shownSlug}
148
182
  className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7"
149
183
  >
150
- {subResource ? (
151
- <SubResourceDetailPanel
152
- subResource={subResource}
153
- parent={app}
154
- approvalMethods={approvalMethods}
155
- onBack={onBackToParent ?? onClose}
156
- />
157
- ) : (
158
- <AppDetails
159
- app={app}
160
- onAppClick={onAppClick}
161
- onClosePanel={onClose}
162
- />
163
- )}
184
+ {/* Inside the key, so anything a plugin provides here is scoped to
185
+ the resource on screen and is discarded when the card navigates.
186
+ Renders its children untouched when no plugin contributes. */}
187
+ <ResourceDetailProvider
188
+ resource={subResource ?? app}
189
+ parent={subResource ? app : undefined}
190
+ >
191
+ {subResource ? (
192
+ <SubResourceDetailPanel
193
+ subResource={subResource}
194
+ parent={app}
195
+ approvalMethods={approvalMethods}
196
+ onBack={onBackToParent ?? onClose}
197
+ actions={
198
+ <ResourceDetailAccessActions {...subResourceActionsCtx} />
199
+ }
200
+ />
201
+ ) : (
202
+ <AppDetails app={app} onAppClick={onAppClick} />
203
+ )}
204
+ </ResourceDetailProvider>
164
205
  </div>
165
206
  </div>
166
207
  </div>
@@ -89,10 +89,17 @@ function RolesTable({
89
89
  <TableCell className="font-medium whitespace-nowrap">
90
90
  {role.displayName}
91
91
  </TableCell>
92
- {/* `adminNotes` is provisioning-only and never shown to
93
- the requester — only `description` belongs here. */}
92
+ {/* `adminNotes` is the provisioning detail — the directory
93
+ group, the SSO app, the manual step. It reads as a note
94
+ under the description because the person granting the role
95
+ and the person asking for it read the same page. */}
94
96
  <TableCell className="text-sm text-muted-foreground">
95
97
  {role.description || '—'}
98
+ {role.adminNotes && (
99
+ <div className="mt-1 text-xs italic text-muted-foreground/80">
100
+ Note: {role.adminNotes}
101
+ </div>
102
+ )}
96
103
  </TableCell>
97
104
  </TableRow>
98
105
  ))}
@@ -228,6 +235,22 @@ const UNDOCUMENTED_WITH_CONTACT =
228
235
  const UNDOCUMENTED_WITHOUT_CONTACT =
229
236
  'Access process not documented yet, and no owner is listed for this app.'
230
237
 
238
+ /**
239
+ * The entry's own `adminNotes`, served as `Resource.notes`: the provisioning
240
+ * route an approver follows — the pipeline to run, the group to grant. It is
241
+ * about the whole request rather than one role, so it gets its own block.
242
+ */
243
+ function AdminNotes({ notes }: { notes: string }) {
244
+ return (
245
+ <div>
246
+ <h4 className="mb-2 text-sm font-medium">Admin notes</h4>
247
+ <div className="text-sm text-muted-foreground prose prose-sm max-w-none">
248
+ <MarkdownText>{notes}</MarkdownText>
249
+ </div>
250
+ </div>
251
+ )
252
+ }
253
+
231
254
  /**
232
255
  * The frame every access state renders into, including the empty one — an
233
256
  * entry with no access data still gets the heading, because a missing box
@@ -307,6 +330,10 @@ export function AccessRequestSection({
307
330
  <p className="text-sm text-muted-foreground">
308
331
  {UNDOCUMENTED_WITHOUT_CONTACT}
309
332
  </p>
333
+ {/* No request route written down, but a provisioning note still is:
334
+ that note is then the only thing on the page that says how the
335
+ access gets granted, so it cannot hang off `accessRequest`. */}
336
+ {app.notes && <AdminNotes notes={app.notes} />}
310
337
  </AccessBox>
311
338
  )
312
339
  }
@@ -421,6 +448,10 @@ export function AccessRequestSection({
421
448
  <RolesTable roles={accessRequest.roles} />
422
449
  )}
423
450
 
451
+ {/* After the roles, because the groups and pipelines it names are what
452
+ the roles above are granted through. */}
453
+ {app.notes && <AdminNotes notes={app.notes} />}
454
+
424
455
  {/* Approvers */}
425
456
  {accessRequest.approverSlugs &&
426
457
  accessRequest.approverSlugs.length > 0 && (
@@ -1,4 +1,5 @@
1
1
  import { useMemo, useRef } from 'react'
2
+ import { useDismissable } from '~/modules/dismiss/DismissStack'
2
3
 
3
4
  import type { Resource } from '@igstack/app-catalog-backend-core'
4
5
 
@@ -24,8 +25,15 @@ export function ScreenshotGallery({
24
25
  onOpenChange,
25
26
  title,
26
27
  }: ScreenshotGalleryProps) {
27
- // Track whether Gallery is in fullscreen — if so, block Radix from closing on Escape
28
- const isFullscreenRef = useRef(false)
28
+ /**
29
+ * The dialog is dismissable through the stack, not through Radix.
30
+ *
31
+ * It no longer needs to know whether the gallery inside it is fullscreen:
32
+ * fullscreen registers above this layer while it is on, so Escape reaches it
33
+ * first and this dialog only hears Escape once fullscreen is off.
34
+ */
35
+ const contentRef = useRef<HTMLDivElement>(null)
36
+ useDismissable(contentRef, () => onOpenChange(false), { enabled: open })
29
37
 
30
38
  // Transform screenshot IDs to URLs. Capped at 2000px — plenty for on-screen
31
39
  // viewing (incl. fullscreen zoom) while avoiding multi-MB original downloads
@@ -47,33 +55,17 @@ export function ScreenshotGallery({
47
55
  return (
48
56
  <Dialog open={open} onOpenChange={onOpenChange}>
49
57
  <DialogContent
58
+ ref={contentRef}
50
59
  aria-describedby={undefined}
51
60
  className="h-[85vh] w-full max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-3rem)] md:max-w-[calc(100vw-4rem)] p-0 overflow-hidden"
52
61
  showCloseButton={true}
53
- onEscapeKeyDown={(e) => {
54
- // Stop propagation so the Esc event doesn't reach outer document listeners
55
- // (e.g. SearchResultsList's raw keydown handler, which would clear search).
56
- e.stopPropagation()
57
- // If Gallery is in fullscreen, its capture listener already handled Escape.
58
- // Prevent Radix from also closing the dialog on the same event.
59
- if (isFullscreenRef.current) {
60
- e.preventDefault()
61
- }
62
- }}
63
62
  >
64
63
  <VisuallyHidden>
65
64
  <DialogTitle>
66
65
  {title || `${app.abbreviation || app.displayName} screenshots`}
67
66
  </DialogTitle>
68
67
  </VisuallyHidden>
69
- <Gallery
70
- images={images}
71
- initialIndex={initialIndex}
72
- title={title}
73
- onFullscreenChange={(fs) => {
74
- isFullscreenRef.current = fs
75
- }}
76
- />
68
+ <Gallery images={images} initialIndex={initialIndex} title={title} />
77
69
  </DialogContent>
78
70
  </Dialog>
79
71
  )
@@ -2,6 +2,7 @@ import type {
2
2
  AppApprovalMethod,
3
3
  Resource,
4
4
  } from '@igstack/app-catalog-backend-core'
5
+ import type { ReactNode } from 'react'
5
6
  import { ArrowLeft, ExternalLink } from 'lucide-react'
6
7
  import { Button } from '~/ui/button'
7
8
  import { Badge } from '~/ui/badge'
@@ -13,6 +14,16 @@ interface SubResourceDetailPanelProps {
13
14
  parent: Resource
14
15
  approvalMethods: AppApprovalMethod[]
15
16
  onBack: () => void
17
+ /**
18
+ * Plugin actions for this sub-resource, rendered under the title.
19
+ *
20
+ * Passed in rather than rendered here on purpose. The slot's payload needs the
21
+ * catalog context and the signed-in user, and calling those hooks in this
22
+ * component would break the tests that render it bare — the same failure mode
23
+ * a `useTRPC()` call has in `AppCatalogGrid`. Optional, so those tests keep
24
+ * passing untouched.
25
+ */
26
+ actions?: ReactNode
16
27
  }
17
28
 
18
29
  function StepBadge({ step }: { step: number }) {
@@ -28,6 +39,7 @@ export function SubResourceDetailPanel({
28
39
  parent,
29
40
  approvalMethods,
30
41
  onBack,
42
+ actions,
31
43
  }: SubResourceDetailPanelProps) {
32
44
  // Mirrors AccessRequestSection's fallback: a sub-resource usually documents
33
45
  // access through top-level `approverSlugs`/`accessComments`, not through an
@@ -102,6 +114,13 @@ export function SubResourceDetailPanel({
102
114
  )}
103
115
  </div>
104
116
 
117
+ {/* ABOVE the gate below on purpose: an account with no approver, owner
118
+ or access comments renders no access region at all, and that is
119
+ exactly the case where an approver most needs the action. */}
120
+ {actions ? (
121
+ <div className="mt-4 flex items-center gap-2">{actions}</div>
122
+ ) : null}
123
+
105
124
  {/* Two-step access section */}
106
125
  {hasAnyAccess && (
107
126
  // The whole two-step region carries the anchor: the step labels naming