@igstack/app-catalog-frontend-core 6.1.1 → 7.0.0-alpha-20260924220457

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.
@@ -19,39 +19,6 @@ function getApprovalMethodIcon(type) {
19
19
  return /* @__PURE__ */ jsx(Settings, { className: "size-5 text-primary" });
20
20
  }
21
21
  }
22
- const VISIBLE_ROLES = 5;
23
- function RolesTable({
24
- roles
25
- }) {
26
- const [expanded, setExpanded] = useState(false);
27
- const collapsible = roles.length > VISIBLE_ROLES;
28
- const shown = collapsible && !expanded ? roles.slice(0, VISIBLE_ROLES) : roles;
29
- return /* @__PURE__ */ jsxs("div", { children: [
30
- /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Available Roles" }),
31
- /* @__PURE__ */ jsx("div", { className: "rounded-lg border", children: /* @__PURE__ */ jsxs(Table, { children: [
32
- /* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
33
- /* @__PURE__ */ jsx(TableHead, { className: "whitespace-nowrap", children: "Role" }),
34
- /* @__PURE__ */ jsx(TableHead, { children: "Description" })
35
- ] }) }),
36
- /* @__PURE__ */ jsx(TableBody, { children: shown.map((role, idx) => /* @__PURE__ */ jsxs(TableRow, { children: [
37
- /* @__PURE__ */ jsx(TableCell, { className: "font-medium whitespace-nowrap", children: role.displayName }),
38
- /* @__PURE__ */ jsx(TableCell, { className: "text-sm text-muted-foreground", children: role.description || "—" })
39
- ] }, `${role.displayName}-${idx}`)) })
40
- ] }) }),
41
- collapsible && /* @__PURE__ */ jsx(
42
- Button,
43
- {
44
- type: "button",
45
- variant: "link",
46
- size: "sm",
47
- className: "mt-1 h-auto px-0",
48
- "aria-expanded": expanded,
49
- onClick: () => setExpanded((v) => !v),
50
- children: expanded ? "Show fewer roles" : `Show all ${roles.length} roles`
51
- }
52
- )
53
- ] });
54
- }
55
22
  function useCopyToClipboard() {
56
23
  const [copiedId, setCopiedId] = useState(null);
57
24
  const [error, setError] = useState(null);
@@ -201,11 +168,40 @@ function AccessRequestSection({
201
168
  )
202
169
  ] }),
203
170
  accessRequest.comments && /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground prose prose-sm max-w-none", children: /* @__PURE__ */ jsx(MarkdownText, { children: accessRequest.comments }) }),
204
- accessRequest.roles && accessRequest.roles.length > 0 && /* @__PURE__ */ jsx(RolesTable, { roles: accessRequest.roles }),
171
+ accessRequest.roles && accessRequest.roles.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
172
+ /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Available Roles" }),
173
+ /* @__PURE__ */ jsx("div", { className: "rounded-lg border", children: /* @__PURE__ */ jsxs(Table, { children: [
174
+ /* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
175
+ /* @__PURE__ */ jsx(TableHead, { className: "whitespace-nowrap", children: "Role" }),
176
+ /* @__PURE__ */ jsx(TableHead, { children: "Description" })
177
+ ] }) }),
178
+ /* @__PURE__ */ jsx(TableBody, { children: accessRequest.roles.map((role, idx) => /* @__PURE__ */ jsxs(TableRow, { children: [
179
+ /* @__PURE__ */ jsx(TableCell, { className: "font-medium whitespace-nowrap", children: role.displayName }),
180
+ /* @__PURE__ */ jsx(TableCell, { className: "text-sm text-muted-foreground", children: role.description || "—" })
181
+ ] }, `${role.displayName}-${idx}`)) })
182
+ ] }) })
183
+ ] }),
205
184
  accessRequest.approverSlugs && accessRequest.approverSlugs.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
206
185
  /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Approvers" }),
207
186
  /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: accessRequest.approverSlugs.map((slug) => /* @__PURE__ */ jsx(PersonOrGroupBadge, { slug }, slug)) })
208
187
  ] }),
188
+ accessRequest.urls && accessRequest.urls.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
189
+ /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Documentation" }),
190
+ /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5", children: accessRequest.urls.map((urlObj, idx) => /* @__PURE__ */ jsxs(
191
+ "a",
192
+ {
193
+ href: urlObj.url,
194
+ target: "_blank",
195
+ rel: "noopener noreferrer",
196
+ className: "text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5",
197
+ children: [
198
+ urlObj.label || urlObj.url,
199
+ /* @__PURE__ */ jsx(ExternalLink, { className: "size-3" })
200
+ ]
201
+ },
202
+ `${urlObj.url}-${idx}`
203
+ )) })
204
+ ] }),
209
205
  accessRequest.postApprovalInstructions && (isTwoStep ? (
210
206
  // Two-step apps: show Step 2 prominently, always expanded
211
207
  /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
@@ -228,24 +224,7 @@ function AccessRequestSection({
228
224
  ]
229
225
  }
230
226
  ) })
231
- )),
232
- accessRequest.urls && accessRequest.urls.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
233
- /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Documentation" }),
234
- /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5", children: accessRequest.urls.map((urlObj, idx) => /* @__PURE__ */ jsxs(
235
- "a",
236
- {
237
- href: urlObj.url,
238
- target: "_blank",
239
- rel: "noopener noreferrer",
240
- className: "text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5",
241
- children: [
242
- urlObj.label || urlObj.url,
243
- /* @__PURE__ */ jsx(ExternalLink, { className: "size-3" })
244
- ]
245
- },
246
- `${urlObj.url}-${idx}`
247
- )) })
248
- ] })
227
+ ))
249
228
  ] });
250
229
  }
251
230
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"AccessRequestSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessRequestSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, Check, Copy, ExternalLink, Settings, Users } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { MarkdownText } from './MarkdownText'\nimport { PersonOrGroupBadge } from './PersonBadge'\n\n// Constants\nconst COPY_FEEDBACK_DURATION = 2000\n\ninterface AccessRequestSectionProps {\n app: Resource\n approvalMethods: AppApprovalMethod[]\n}\n\n// Helper function for approval method icons\nfunction getApprovalMethodIcon(\n type: 'service' | 'personTeam' | 'custom' | 'noAccessRequired' | 'unknown',\n) {\n switch (type) {\n case 'service':\n return <Bot className=\"size-5 text-primary\" />\n case 'personTeam':\n return <Users className=\"size-5 text-primary\" />\n case 'custom':\n case 'noAccessRequired':\n case 'unknown':\n return <Settings className=\"size-5 text-primary\" />\n }\n}\n\n/** Roles shown before the rest are folded behind \"Show all\". */\nconst VISIBLE_ROLES = 5\n\n/**\n * The roles table. A long list (dozens of roles) pushed the approval steps off\n * screen, so beyond {@link VISIBLE_ROLES} the rest sit behind a toggle.\n */\nfunction RolesTable({\n roles,\n}: {\n roles: NonNullable<AppAccessRequest['roles']>\n}) {\n const [expanded, setExpanded] = useState(false)\n const collapsible = roles.length > VISIBLE_ROLES\n const shown = collapsible && !expanded ? roles.slice(0, VISIBLE_ROLES) : roles\n\n return (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Available Roles</h4>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"whitespace-nowrap\">Role</TableHead>\n <TableHead>Description</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {shown.map((role, idx) => (\n <TableRow key={`${role.displayName}-${idx}`}>\n <TableCell className=\"font-medium whitespace-nowrap\">\n {role.displayName}\n </TableCell>\n {/* `adminNotes` is provisioning-only and never shown to\n the requester — only `description` belongs here. */}\n <TableCell className=\"text-sm text-muted-foreground\">\n {role.description || '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n {collapsible && (\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n className=\"mt-1 h-auto px-0\"\n aria-expanded={expanded}\n onClick={() => setExpanded((v) => !v)}\n >\n {expanded ? 'Show fewer roles' : `Show all ${roles.length} roles`}\n </Button>\n )}\n </div>\n )\n}\n\n/**\n * Custom hook for handling copy-to-clipboard functionality with feedback\n * Includes proper cleanup to prevent memory leaks\n */\nfunction useCopyToClipboard() {\n const [copiedId, setCopiedId] = useState<string | null>(null)\n const [error, setError] = useState<string | null>(null)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n // Cleanup timeout on unmount\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n }\n }, [])\n\n const copyToClipboard = useCallback(async (text: string, id: string) => {\n // Clear existing timeout\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n\n try {\n await navigator.clipboard.writeText(text)\n setCopiedId(id)\n setError(null)\n\n // Set new timeout with cleanup\n timeoutRef.current = setTimeout(() => {\n setCopiedId(null)\n }, COPY_FEEDBACK_DURATION)\n } catch (err) {\n console.error('Failed to copy to clipboard:', err)\n setError('Failed to copy')\n\n // Clear error after duration\n timeoutRef.current = setTimeout(() => {\n setError(null)\n }, COPY_FEEDBACK_DURATION)\n }\n }, [])\n\n return { copiedId, error, copyToClipboard }\n}\n\n/**\n * Reusable copy button component with accessibility\n */\ninterface CopyButtonProps {\n onCopy: () => void\n isCopied: boolean\n ariaLabel: string\n className?: string\n}\n\nfunction CopyButton({\n onCopy,\n isCopied,\n ariaLabel,\n className,\n}: CopyButtonProps) {\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onCopy}\n className={className}\n aria-label={ariaLabel}\n title={isCopied ? 'Copied!' : 'Copy to clipboard'}\n >\n {isCopied ? (\n <>\n <Check className=\"h-3.5 w-3.5 text-green-600\" />\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n Copied to clipboard\n </span>\n </>\n ) : (\n <Copy className=\"h-3.5 w-3.5\" />\n )}\n </Button>\n )\n}\n\nexport function AccessRequestSection({\n app,\n approvalMethods,\n}: AccessRequestSectionProps) {\n const { copiedId, copyToClipboard } = useCopyToClipboard()\n\n // Sub-resources carry their access data as top-level fields (`approverSlugs`,\n // `accessComments`, `ownerPersonSlug`) rather than in an `accessRequest`.\n // Without this fallback the whole section rendered nothing for them: the\n // person was told a request is needed and then shown no way to make one.\n const accessRequest = useMemo((): AppAccessRequest | undefined => {\n if (app.accessRequest) return app.accessRequest\n const approverSlugs = [\n ...new Set([\n ...(app.approverSlugs ?? []),\n ...(app.ownerPersonSlug ? [app.ownerPersonSlug] : []),\n ]),\n ]\n if (approverSlugs.length === 0 && !app.accessComments) return undefined\n // No approval method to name — the approvers below ARE the route.\n return {\n approvalMethodSlug: '',\n approverSlugs,\n comments: app.accessComments,\n }\n }, [\n app.accessRequest,\n app.approverSlugs,\n app.ownerPersonSlug,\n app.accessComments,\n ])\n\n const approvalMethod = approvalMethods.find(\n (m) => m.slug === accessRequest?.approvalMethodSlug,\n )\n\n const handleCopyPrompt = useCallback(() => {\n if (accessRequest?.requestPrompt) {\n copyToClipboard(accessRequest.requestPrompt, 'prompt')\n }\n }, [accessRequest?.requestPrompt, copyToClipboard])\n\n // Early return if no access request\n if (!accessRequest) return null\n\n // Access UX (#31): some methods carry no clickable target — `noAccessRequired`\n // (open), `unknown` (undocumented), and bare `custom` with no comments. The\n // old UI rendered nothing for `custom`, leaving a blank/dead section. Instead\n // surface an explicit, actionable line so the user is never stranded:\n // - open → \"No request needed — just open it.\"\n // - unknown / undocumented custom → route them to the owner (shown below).\n const isOpen = approvalMethod?.type === 'noAccessRequired'\n const hasWrittenSteps = Boolean(\n accessRequest.requestPrompt || accessRequest.comments,\n )\n const isUndocumented =\n approvalMethod?.type === 'unknown' ||\n (approvalMethod?.type === 'custom' && !hasWrittenSteps)\n // Two-step: has a primary request action AND follow-up post-approval steps\n const isTwoStep = Boolean(\n hasWrittenSteps && accessRequest.postApprovalInstructions,\n )\n\n return (\n <div className=\"mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4 space-y-4\">\n <h3 className=\"text-sm font-semibold text-foreground\">\n How to get access\n </h3>\n\n {isOpen && (\n <p className=\"text-sm font-medium text-primary\">\n ✓ Open to everyone — no request needed. Just open it.\n </p>\n )}\n\n {isUndocumented && (\n <p className=\"text-sm text-muted-foreground\">\n Access process not documented yet — contact the owner below to find\n out.\n </p>\n )}\n\n {isTwoStep && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 1\n </span>\n <span className=\"text-xs text-muted-foreground\">Request access</span>\n </div>\n )}\n\n {/* Approval Method */}\n {approvalMethod && approvalMethod.type !== 'custom' && (\n <div className=\"flex items-center gap-2\">\n {approvalMethod.type === 'service' && approvalMethod.config.url ? (\n <>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 hover:text-primary transition-colors\"\n title={approvalMethod.config.url}\n >\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </a>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n title={approvalMethod.config.url}\n >\n <ExternalLink className=\"size-4\" />\n </a>\n </>\n ) : (\n <>\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </>\n )}\n </div>\n )}\n\n {/* Request Prompt - Inline */}\n {accessRequest.requestPrompt && (\n <div className=\"text-sm inline-flex items-center gap-2\">\n <span className=\"whitespace-nowrap shrink-0\">Request Prompt:</span>\n <span className=\"prose prose-sm inline [&>*]:inline [&>*]:m-0\">\n <MarkdownText>{accessRequest.requestPrompt}</MarkdownText>\n </span>\n <CopyButton\n onCopy={handleCopyPrompt}\n isCopied={copiedId === 'prompt'}\n ariaLabel=\"Copy request prompt\"\n className=\"h-6 w-6 p-0 shrink-0\"\n />\n </div>\n )}\n\n {/* Comments */}\n {accessRequest.comments && (\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none\">\n <MarkdownText>{accessRequest.comments}</MarkdownText>\n </div>\n )}\n\n {accessRequest.roles && accessRequest.roles.length > 0 && (\n <RolesTable roles={accessRequest.roles} />\n )}\n\n {/* Approvers */}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Approvers</h4>\n <div className=\"flex flex-wrap gap-2\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n </div>\n )}\n\n {/* Post-Approval Instructions */}\n {accessRequest.postApprovalInstructions &&\n (isTwoStep ? (\n // Two-step apps: show Step 2 prominently, always expanded\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 2\n </span>\n <span className=\"text-xs text-muted-foreground\">\n After approval\n </span>\n </div>\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </div>\n ) : (\n // Single-step apps: keep collapsible accordion (secondary info)\n <Accordion type=\"single\" collapsible>\n <AccordionItem\n value=\"post-approval\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Post-Approval Instructions\n </AccordionTrigger>\n <AccordionContent className=\"pb-3\">\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n ))}\n\n {/* Documentation URLs — box level: they cover the whole process,\n not one step, so they come after Step 2 */}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Documentation</h4>\n <div className=\"flex flex-col gap-1.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-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5\"\n >\n {urlObj.label || urlObj.url}\n <ExternalLink className=\"size-3\" />\n </a>\n ))}\n </div>\n </div>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AccessRequestSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/AccessRequestSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n Resource,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, Check, Copy, ExternalLink, Settings, Users } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { MarkdownText } from './MarkdownText'\nimport { PersonOrGroupBadge } from './PersonBadge'\n\n// Constants\nconst COPY_FEEDBACK_DURATION = 2000\n\ninterface AccessRequestSectionProps {\n app: Resource\n approvalMethods: AppApprovalMethod[]\n}\n\n// Helper function for approval method icons\nfunction getApprovalMethodIcon(\n type: 'service' | 'personTeam' | 'custom' | 'noAccessRequired' | 'unknown',\n) {\n switch (type) {\n case 'service':\n return <Bot className=\"size-5 text-primary\" />\n case 'personTeam':\n return <Users className=\"size-5 text-primary\" />\n case 'custom':\n case 'noAccessRequired':\n case 'unknown':\n return <Settings className=\"size-5 text-primary\" />\n }\n}\n\n/**\n * Custom hook for handling copy-to-clipboard functionality with feedback\n * Includes proper cleanup to prevent memory leaks\n */\nfunction useCopyToClipboard() {\n const [copiedId, setCopiedId] = useState<string | null>(null)\n const [error, setError] = useState<string | null>(null)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n // Cleanup timeout on unmount\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n }\n }, [])\n\n const copyToClipboard = useCallback(async (text: string, id: string) => {\n // Clear existing timeout\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current)\n }\n\n try {\n await navigator.clipboard.writeText(text)\n setCopiedId(id)\n setError(null)\n\n // Set new timeout with cleanup\n timeoutRef.current = setTimeout(() => {\n setCopiedId(null)\n }, COPY_FEEDBACK_DURATION)\n } catch (err) {\n console.error('Failed to copy to clipboard:', err)\n setError('Failed to copy')\n\n // Clear error after duration\n timeoutRef.current = setTimeout(() => {\n setError(null)\n }, COPY_FEEDBACK_DURATION)\n }\n }, [])\n\n return { copiedId, error, copyToClipboard }\n}\n\n/**\n * Reusable copy button component with accessibility\n */\ninterface CopyButtonProps {\n onCopy: () => void\n isCopied: boolean\n ariaLabel: string\n className?: string\n}\n\nfunction CopyButton({\n onCopy,\n isCopied,\n ariaLabel,\n className,\n}: CopyButtonProps) {\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onCopy}\n className={className}\n aria-label={ariaLabel}\n title={isCopied ? 'Copied!' : 'Copy to clipboard'}\n >\n {isCopied ? (\n <>\n <Check className=\"h-3.5 w-3.5 text-green-600\" />\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n Copied to clipboard\n </span>\n </>\n ) : (\n <Copy className=\"h-3.5 w-3.5\" />\n )}\n </Button>\n )\n}\n\nexport function AccessRequestSection({\n app,\n approvalMethods,\n}: AccessRequestSectionProps) {\n const { copiedId, copyToClipboard } = useCopyToClipboard()\n\n // Sub-resources carry their access data as top-level fields (`approverSlugs`,\n // `accessComments`, `ownerPersonSlug`) rather than in an `accessRequest`.\n // Without this fallback the whole section rendered nothing for them: the\n // person was told a request is needed and then shown no way to make one.\n const accessRequest = useMemo((): AppAccessRequest | undefined => {\n if (app.accessRequest) return app.accessRequest\n const approverSlugs = [\n ...new Set([\n ...(app.approverSlugs ?? []),\n ...(app.ownerPersonSlug ? [app.ownerPersonSlug] : []),\n ]),\n ]\n if (approverSlugs.length === 0 && !app.accessComments) return undefined\n // No approval method to name — the approvers below ARE the route.\n return {\n approvalMethodSlug: '',\n approverSlugs,\n comments: app.accessComments,\n }\n }, [\n app.accessRequest,\n app.approverSlugs,\n app.ownerPersonSlug,\n app.accessComments,\n ])\n\n const approvalMethod = approvalMethods.find(\n (m) => m.slug === accessRequest?.approvalMethodSlug,\n )\n\n const handleCopyPrompt = useCallback(() => {\n if (accessRequest?.requestPrompt) {\n copyToClipboard(accessRequest.requestPrompt, 'prompt')\n }\n }, [accessRequest?.requestPrompt, copyToClipboard])\n\n // Early return if no access request\n if (!accessRequest) return null\n\n // Access UX (#31): some methods carry no clickable target — `noAccessRequired`\n // (open), `unknown` (undocumented), and bare `custom` with no comments. The\n // old UI rendered nothing for `custom`, leaving a blank/dead section. Instead\n // surface an explicit, actionable line so the user is never stranded:\n // - open → \"No request needed — just open it.\"\n // - unknown / undocumented custom → route them to the owner (shown below).\n const isOpen = approvalMethod?.type === 'noAccessRequired'\n const hasWrittenSteps = Boolean(\n accessRequest.requestPrompt || accessRequest.comments,\n )\n const isUndocumented =\n approvalMethod?.type === 'unknown' ||\n (approvalMethod?.type === 'custom' && !hasWrittenSteps)\n // Two-step: has a primary request action AND follow-up post-approval steps\n const isTwoStep = Boolean(\n hasWrittenSteps && accessRequest.postApprovalInstructions,\n )\n\n return (\n <div className=\"mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4 space-y-4\">\n <h3 className=\"text-sm font-semibold text-foreground\">\n How to get access\n </h3>\n\n {isOpen && (\n <p className=\"text-sm font-medium text-primary\">\n ✓ Open to everyone — no request needed. Just open it.\n </p>\n )}\n\n {isUndocumented && (\n <p className=\"text-sm text-muted-foreground\">\n Access process not documented yet — contact the owner below to find\n out.\n </p>\n )}\n\n {isTwoStep && (\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 1\n </span>\n <span className=\"text-xs text-muted-foreground\">Request access</span>\n </div>\n )}\n\n {/* Approval Method */}\n {approvalMethod && approvalMethod.type !== 'custom' && (\n <div className=\"flex items-center gap-2\">\n {approvalMethod.type === 'service' && approvalMethod.config.url ? (\n <>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 hover:text-primary transition-colors\"\n title={approvalMethod.config.url}\n >\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </a>\n <a\n href={approvalMethod.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-muted-foreground hover:text-primary\"\n title={approvalMethod.config.url}\n >\n <ExternalLink className=\"size-4\" />\n </a>\n </>\n ) : (\n <>\n <div>{getApprovalMethodIcon(approvalMethod.type)}</div>\n <div className=\"font-medium\">{approvalMethod.displayName}</div>\n </>\n )}\n </div>\n )}\n\n {/* Request Prompt - Inline */}\n {accessRequest.requestPrompt && (\n <div className=\"text-sm inline-flex items-center gap-2\">\n <span className=\"whitespace-nowrap shrink-0\">Request Prompt:</span>\n <span className=\"prose prose-sm inline [&>*]:inline [&>*]:m-0\">\n <MarkdownText>{accessRequest.requestPrompt}</MarkdownText>\n </span>\n <CopyButton\n onCopy={handleCopyPrompt}\n isCopied={copiedId === 'prompt'}\n ariaLabel=\"Copy request prompt\"\n className=\"h-6 w-6 p-0 shrink-0\"\n />\n </div>\n )}\n\n {/* Comments */}\n {accessRequest.comments && (\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none\">\n <MarkdownText>{accessRequest.comments}</MarkdownText>\n </div>\n )}\n\n {/* Roles Table */}\n {accessRequest.roles && accessRequest.roles.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Available Roles</h4>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"whitespace-nowrap\">Role</TableHead>\n <TableHead>Description</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {accessRequest.roles.map((role, idx) => (\n <TableRow key={`${role.displayName}-${idx}`}>\n <TableCell className=\"font-medium whitespace-nowrap\">\n {role.displayName}\n </TableCell>\n {/* `adminNotes` is provisioning-only and never shown to\n the requester — only `description` belongs here. */}\n <TableCell className=\"text-sm text-muted-foreground\">\n {role.description || '—'}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </div>\n )}\n\n {/* Approvers */}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Approvers</h4>\n <div className=\"flex flex-wrap gap-2\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n </div>\n )}\n\n {/* Documentation URLs */}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div>\n <h4 className=\"mb-2 text-sm font-medium\">Documentation</h4>\n <div className=\"flex flex-col gap-1.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-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5\"\n >\n {urlObj.label || urlObj.url}\n <ExternalLink className=\"size-3\" />\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Post-Approval Instructions */}\n {accessRequest.postApprovalInstructions &&\n (isTwoStep ? (\n // Two-step apps: show Step 2 prominently, always expanded\n <div className=\"space-y-2\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5\">\n Step 2\n </span>\n <span className=\"text-xs text-muted-foreground\">\n After approval\n </span>\n </div>\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </div>\n ) : (\n // Single-step apps: keep collapsible accordion (secondary info)\n <Accordion type=\"single\" collapsible>\n <AccordionItem\n value=\"post-approval\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Post-Approval Instructions\n </AccordionTrigger>\n <AccordionContent className=\"pb-3\">\n <div className=\"text-sm text-muted-foreground prose prose-sm max-w-none [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5\">\n <MarkdownText>\n {accessRequest.postApprovalInstructions}\n </MarkdownText>\n </div>\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n ))}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
- import { useSearch, useNavigate, Link } from "@tanstack/react-router";
3
- import { X, Search } from "lucide-react";
4
- import { useMemo, useState, useEffect, useRef } from "react";
2
+ import { useSearch, Link } from "@tanstack/react-router";
3
+ import { Search } from "lucide-react";
4
+ import { useMemo, useState, useEffect } from "react";
5
5
  import { Badge } from "../../../../ui/badge.js";
6
6
  import { Input } from "../../../../ui/input.js";
7
7
  import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "../../../../ui/table.js";
@@ -41,27 +41,6 @@ function SubResourcesSection({
41
41
  () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,
42
42
  [subResources, selectedSubSlug]
43
43
  );
44
- const navigate = useNavigate();
45
- const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug);
46
- useEffect(() => {
47
- if (selectedSubSlug) setHighlightSlug(selectedSubSlug);
48
- }, [selectedSubSlug]);
49
- const searchInputRef = useRef(null);
50
- const focusSearchAfterClear = useRef(false);
51
- useEffect(() => {
52
- var _a;
53
- if (!selectedSub && focusSearchAfterClear.current) {
54
- focusSearchAfterClear.current = false;
55
- (_a = searchInputRef.current) == null ? void 0 : _a.focus();
56
- }
57
- }, [selectedSub]);
58
- const clearSelection = () => {
59
- focusSearchAfterClear.current = true;
60
- void navigate({
61
- to: ".",
62
- search: (prev) => ({ ...prev, sub: void 0 })
63
- });
64
- };
65
44
  const seededSearch = useMemo(() => {
66
45
  const q = initialSearch == null ? void 0 : initialSearch.trim().toLowerCase();
67
46
  if (!q) return "";
@@ -107,29 +86,11 @@ function SubResourcesSection({
107
86
  ")"
108
87
  ] }) }),
109
88
  /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
110
- selectedSub ? (
111
- // `?sub=` wins over the search box, so show it where the box would be
112
- // and make it dismissible — otherwise the only way out is the URL.
113
- /* @__PURE__ */ jsx("div", { className: "flex flex-1 items-center", children: /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "h-9 gap-1 pl-3 pr-1 text-sm", children: [
114
- "Showing: ",
115
- selectedSub.displayName,
116
- /* @__PURE__ */ jsx(
117
- "button",
118
- {
119
- type: "button",
120
- "aria-label": "Clear sub-resource filter",
121
- onClick: clearSelection,
122
- className: "rounded-sm p-1 hover:bg-muted-foreground/20",
123
- children: /* @__PURE__ */ jsx(X, { className: "size-4" })
124
- }
125
- )
126
- ] }) })
127
- ) : /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
89
+ /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
128
90
  /* @__PURE__ */ jsx(Search, { className: "absolute left-2.5 top-2.5 size-4 text-muted-foreground" }),
129
91
  /* @__PURE__ */ jsx(
130
92
  Input,
131
93
  {
132
- ref: searchInputRef,
133
94
  placeholder: "Search resources by name or alias...",
134
95
  value: search,
135
96
  onChange: (e) => setSearch(e.target.value),
@@ -165,8 +126,8 @@ function SubResourcesSection({
165
126
  return /* @__PURE__ */ jsxs(
166
127
  TableRow,
167
128
  {
168
- "aria-current": sr.slug === highlightSlug ? "true" : void 0,
169
- className: sr.slug === highlightSlug ? "bg-primary/[0.06]" : "",
129
+ "aria-current": sr.slug === selectedSubSlug ? "true" : void 0,
130
+ className: sr.slug === selectedSubSlug ? "bg-primary/[0.06]" : "",
170
131
  children: [
171
132
  /* @__PURE__ */ jsxs(TableCell, { children: [
172
133
  /* @__PURE__ */ jsx(
@@ -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 { Link, useNavigate, useSearch } from '@tanstack/react-router'\nimport { Search, X } from 'lucide-react'\nimport { useEffect, useMemo, useRef, 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, PersonOrGroupBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainText'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * Initial filter text (#38 item C). When the user reached this app by\n * searching a term that matched a sub-resource, seed the sub-resource filter\n * with that term so the matched child is revealed instead of buried in a\n * long list (e.g. searching \"data\" → open Cloud Console → the matching\n * account is pre-filtered). Only applied when it actually matches a child.\n */\n initialSearch?: string\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 parentSlug,\n initialSearch,\n}: SubResourcesSectionProps) {\n // `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row\n // in the search results. It wins over the query-seeded filter below: a query\n // like \"project\" matches every child, so seeding with it would bury the row\n // that was actually clicked.\n const search_ = useSearch({ strict: false })\n const selectedSubSlug = search_.sub\n const selectedSub = useMemo(\n () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,\n [subResources, selectedSubSlug],\n )\n const navigate = useNavigate()\n\n // The row to mark as \"this one\". It outlives `?sub=` so clearing the filter\n // brings the siblings back without losing the user's place.\n const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug)\n useEffect(() => {\n if (selectedSubSlug) setHighlightSlug(selectedSubSlug)\n }, [selectedSubSlug])\n\n // The search input only mounts once the selection is gone, so focus it then.\n const searchInputRef = useRef<HTMLInputElement>(null)\n const focusSearchAfterClear = useRef(false)\n useEffect(() => {\n if (!selectedSub && focusSearchAfterClear.current) {\n focusSearchAfterClear.current = false\n searchInputRef.current?.focus()\n }\n }, [selectedSub])\n\n const clearSelection = () => {\n focusSearchAfterClear.current = true\n // A navigation, not a replace, so Back restores the single-row view.\n void navigate({\n to: '.',\n search: (prev) => ({ ...prev, sub: undefined }),\n })\n }\n\n // Seed the filter with the incoming query ONLY if it matches a child, so we\n // reveal the matched sub-resource without hiding everything on a non-match.\n const seededSearch = useMemo(() => {\n const q = initialSearch?.trim().toLowerCase()\n if (!q) return ''\n const hit = subResources.some(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),\n )\n return hit ? initialSearch!.trim() : ''\n }, [initialSearch, subResources])\n const [search, setSearch] = useState(seededSearch)\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n // useState only reads its argument on the first render, so without this the\n // filter kept the query from whenever this panel first mounted — searching\n // again with the panel already open left the old term in the box.\n useEffect(() => setSearch(seededSearch), [seededSearch])\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 if (selectedSub) return [selectedSub]\n\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 (sr.extra as Record<string, unknown>).awsAccountId as\n | string\n | undefined\n )?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, search, tierFilter, selectedSub])\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 {selectedSub ? (\n // `?sub=` wins over the search box, so show it where the box would be\n // and make it dismissible — otherwise the only way out is the URL.\n <div className=\"flex flex-1 items-center\">\n <Badge variant=\"secondary\" className=\"h-9 gap-1 pl-3 pr-1 text-sm\">\n Showing: {selectedSub.displayName}\n <button\n type=\"button\"\n aria-label=\"Clear sub-resource filter\"\n onClick={clearSelection}\n className=\"rounded-sm p-1 hover:bg-muted-foreground/20\"\n >\n <X className=\"size-4\" />\n </button>\n </Badge>\n </div>\n ) : (\n <div className=\"relative flex-1\">\n <Search className=\"absolute left-2.5 top-2.5 size-4 text-muted-foreground\" />\n <Input\n ref={searchInputRef}\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 )}\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>Approvers</TableHead>\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={5}\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 // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n\n return (\n <TableRow\n key={sr.slug}\n // The row the user asked for, so it reads as \"this one\" even\n // once the filter is cleared and its siblings come back.\n aria-current={\n sr.slug === highlightSlug ? 'true' : undefined\n }\n className={\n sr.slug === highlightSlug ? 'bg-primary/[0.06]' : ''\n }\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a\n table of resources, each of which has its own page. */}\n <Link\n to=\"/app/$slug/sub/$subSlug\"\n params={{ slug: parentSlug, subSlug: sr.slug }}\n className=\"font-medium text-sm hover:text-primary hover:underline\"\n >\n {sr.displayName}\n </Link>\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 {markdownToPlainText(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 {approvers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {approvers.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {(() => {\n const accountId = (\n sr.extra as Record<string, unknown> | null | undefined\n )?.awsAccountId as string | undefined\n if (!accountId)\n return (\n <span className=\"text-muted-foreground\">—</span>\n )\n // The account id is what people copy; when the resource\n // also carries its own `appUrl` that is the console\n // deep link for THAT account, so the id doubles as the\n // launch affordance.\n return sr.appUrl ? (\n <a\n href={sr.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text\"\n >\n {accountId}\n </a>\n ) : (\n <span className=\"font-mono text-xs text-muted-foreground select-text\">\n {accountId}\n </span>\n )\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 { Link, useSearch } from '@tanstack/react-router'\nimport { Search } from 'lucide-react'\nimport { useEffect, 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, PersonOrGroupBadge } from './PersonBadge'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '~/ui/select'\nimport { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainText'\n\ninterface SubResourcesSectionProps {\n subResources: Resource[]\n /** Slug of the parent, for linking to a child's own page. */\n parentSlug: string\n /**\n * Initial filter text (#38 item C). When the user reached this app by\n * searching a term that matched a sub-resource, seed the sub-resource filter\n * with that term so the matched child is revealed instead of buried in a\n * long list (e.g. searching \"data\" → open Cloud Console → the matching\n * account is pre-filtered). Only applied when it actually matches a child.\n */\n initialSearch?: string\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 parentSlug,\n initialSearch,\n}: SubResourcesSectionProps) {\n // `?sub=<slug>` — the one sub-resource the user asked for, by clicking its row\n // in the search results. It wins over the query-seeded filter below: a query\n // like \"project\" matches every child, so seeding with it would bury the row\n // that was actually clicked.\n const search_ = useSearch({ strict: false })\n const selectedSubSlug = search_.sub\n const selectedSub = useMemo(\n () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,\n [subResources, selectedSubSlug],\n )\n\n // Seed the filter with the incoming query ONLY if it matches a child, so we\n // reveal the matched sub-resource without hiding everything on a non-match.\n const seededSearch = useMemo(() => {\n const q = initialSearch?.trim().toLowerCase()\n if (!q) return ''\n const hit = subResources.some(\n (sr) =>\n sr.displayName.toLowerCase().includes(q) ||\n (sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),\n )\n return hit ? initialSearch!.trim() : ''\n }, [initialSearch, subResources])\n const [search, setSearch] = useState(seededSearch)\n const [tierFilter, setTierFilter] = useState<string>('all')\n\n // useState only reads its argument on the first render, so without this the\n // filter kept the query from whenever this panel first mounted — searching\n // again with the panel already open left the old term in the box.\n useEffect(() => setSearch(seededSearch), [seededSearch])\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 if (selectedSub) return [selectedSub]\n\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 (sr.extra as Record<string, unknown>).awsAccountId as\n | string\n | undefined\n )?.includes(q) === true,\n )\n }\n\n return result\n }, [subResources, search, tierFilter, selectedSub])\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>Approvers</TableHead>\n <TableHead className=\"w-[140px]\">AWS Account</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {filtered.length === 0 ? (\n <TableRow>\n <TableCell\n colSpan={5}\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 // Approvers are Person OR Group slugs; the badge resolves both.\n const approvers = [...new Set(sr.approverSlugs ?? [])]\n\n return (\n <TableRow\n key={sr.slug}\n // The row the user asked for, so it reads as \"this one\" even\n // once the filter is cleared and its siblings come back.\n aria-current={\n sr.slug === selectedSubSlug ? 'true' : undefined\n }\n className={\n sr.slug === selectedSubSlug ? 'bg-primary/[0.06]' : ''\n }\n >\n <TableCell>\n {/* A real link, not a click handler on the row: this is a\n table of resources, each of which has its own page. */}\n <Link\n to=\"/app/$slug/sub/$subSlug\"\n params={{ slug: parentSlug, subSlug: sr.slug }}\n className=\"font-medium text-sm hover:text-primary hover:underline\"\n >\n {sr.displayName}\n </Link>\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 {markdownToPlainText(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 {approvers.length > 0 ? (\n <div className=\"flex flex-wrap gap-1\">\n {approvers.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {(() => {\n const accountId = (\n sr.extra as Record<string, unknown> | null | undefined\n )?.awsAccountId as string | undefined\n if (!accountId)\n return (\n <span className=\"text-muted-foreground\">—</span>\n )\n // The account id is what people copy; when the resource\n // also carries its own `appUrl` that is the console\n // deep link for THAT account, so the id doubles as the\n // launch affordance.\n return sr.appUrl ? (\n <a\n href={sr.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"font-mono text-xs text-muted-foreground hover:text-primary hover:underline select-text\"\n >\n {accountId}\n </a>\n ) : (\n <span className=\"font-mono text-xs text-muted-foreground select-text\">\n {accountId}\n </span>\n )\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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "6.1.1",
3
+ "version": "7.0.0-alpha-20260924220457",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -112,7 +112,7 @@
112
112
  "tailwindcss": "^4.1.7",
113
113
  "tiptap-markdown": "^0.8.0",
114
114
  "tslib": "^2.3.0",
115
- "@igstack/app-catalog-shared-core": "6.1.1"
115
+ "@igstack/app-catalog-shared-core": "7.0.0-alpha-20260924220457"
116
116
  },
117
117
  "devDependencies": {
118
118
  "@tailwindcss/vite": "^4.0.5",
@@ -139,7 +139,7 @@
139
139
  "vite-plugin-static-copy": "^3.1.4",
140
140
  "vite-plugin-svgr": "^4.2.0",
141
141
  "vitest": "^4.1.2",
142
- "@igstack/app-catalog-backend-core": "6.1.1"
142
+ "@igstack/app-catalog-backend-core": "7.0.0-alpha-20260924220457"
143
143
  },
144
144
  "peerDependencies": {
145
145
  "react": "19.1.2",
@@ -148,7 +148,7 @@
148
148
  "vite": "^6.3.5",
149
149
  "vite-plugin-svgr": "^4.2.0"
150
150
  },
151
- "gitHead": "dc60c949f96e7263f289e52060055d6aa05b80f4",
151
+ "gitHead": "21630f1f7af88ac2a67ba20a5917a461b708386a",
152
152
  "scripts": {
153
153
  "build": "vite build",
154
154
  "build:lenient": "vite build --mode lenient",
@@ -1,5 +1,5 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
- import { fireEvent, render, screen } from '@testing-library/react'
2
+ import { render, screen } from '@testing-library/react'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
  import '@testing-library/jest-dom/vitest'
5
5
 
@@ -54,40 +54,6 @@ describe('AccessRequestSection — roles table', () => {
54
54
  expect(screen.queryByText(/^Note:/)).toBeNull()
55
55
  })
56
56
 
57
- // A long role list (one real entry has 47) pushed the approval steps below
58
- // the fold; show the first few and let the reader expand the rest.
59
- it('collapses a long role list behind a "Show all" toggle', () => {
60
- const roles = Array.from({ length: 8 }, (_, i) => ({
61
- displayName: `Role ${i + 1}`,
62
- }))
63
- render(
64
- <AccessRequestSection app={appWithRoles(roles)} approvalMethods={[]} />,
65
- )
66
-
67
- expect(screen.getByText('Role 5')).toBeInTheDocument()
68
- expect(screen.queryByText('Role 6')).toBeNull()
69
- const toggle = screen.getByRole('button', { name: 'Show all 8 roles' })
70
- expect(toggle).toHaveAttribute('aria-expanded', 'false')
71
-
72
- fireEvent.click(toggle)
73
- expect(screen.getByText('Role 8')).toBeInTheDocument()
74
- expect(
75
- screen.getByRole('button', { name: 'Show fewer roles' }),
76
- ).toHaveAttribute('aria-expanded', 'true')
77
- })
78
-
79
- it('shows five or fewer roles without a toggle', () => {
80
- const roles = Array.from({ length: 5 }, (_, i) => ({
81
- displayName: `Role ${i + 1}`,
82
- }))
83
- render(
84
- <AccessRequestSection app={appWithRoles(roles)} approvalMethods={[]} />,
85
- )
86
-
87
- expect(screen.getByText('Role 5')).toBeInTheDocument()
88
- expect(screen.queryByRole('button', { name: /Show all/ })).toBeNull()
89
- })
90
-
91
57
  it('falls back to an em-dash when a role has no description', () => {
92
58
  render(
93
59
  <AccessRequestSection
@@ -106,45 +72,6 @@ describe('AccessRequestSection — roles table', () => {
106
72
  })
107
73
  })
108
74
 
109
- // Documentation links describe the whole access process, not the request
110
- // step, so in a two-step entry they sat visually inside Step 1 and read as
111
- // "docs for requesting" — a reader looking for the CLI guide after approval
112
- // never scrolled back up to find it.
113
- describe('AccessRequestSection — documentation placement', () => {
114
- it('renders Documentation after Step 2, not inside Step 1', () => {
115
- render(
116
- <AccessRequestSection
117
- app={
118
- {
119
- id: 'cloud',
120
- slug: 'cloud',
121
- displayName: 'Cloud',
122
- accessRequest: {
123
- approvalMethodSlug: 'service',
124
- comments: 'Request it from the help desk.',
125
- postApprovalInstructions: 'Ask the account maintainers.',
126
- urls: [
127
- { label: 'CLI guide', url: 'https://docs.example.com/cli' },
128
- ],
129
- },
130
- } as Resource
131
- }
132
- approvalMethods={[]}
133
- />,
134
- )
135
-
136
- const step2 = screen.getByText('Step 2')
137
- const docs = screen.getByRole('heading', { name: 'Documentation' })
138
- expect(
139
- step2.compareDocumentPosition(docs) & Node.DOCUMENT_POSITION_FOLLOWING,
140
- ).toBeTruthy()
141
- expect(screen.getByRole('link', { name: /CLI guide/ })).toHaveAttribute(
142
- 'href',
143
- 'https://docs.example.com/cli',
144
- )
145
- })
146
- })
147
-
148
75
  // The channel mentions live in the access-request text fields, which this
149
76
  // section renders itself — so they must go through the same markdown path as
150
77
  // the description or the links never appear where the data actually is.
@@ -1,15 +1,12 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
- import { fireEvent, render, screen } from '@testing-library/react'
2
+ import { render, screen } from '@testing-library/react'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
  import '@testing-library/jest-dom/vitest'
5
5
 
6
6
  // The component reads `?sub=` and links rows to a child's own page; neither is
7
7
  // under test here, so stub the router rather than mounting one.
8
- const routerSearch: { sub?: string } = {}
9
- const navigate = vi.fn()
10
8
  vi.mock('@tanstack/react-router', () => ({
11
- useSearch: vi.fn(() => routerSearch),
12
- useNavigate: vi.fn(() => navigate),
9
+ useSearch: vi.fn(() => ({})),
13
10
  Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
14
11
  <a {...rest}>{children}</a>
15
12
  ),
@@ -95,46 +92,3 @@ describe('SubResourcesSection — account id as deep link', () => {
95
92
  )
96
93
  })
97
94
  })
98
-
99
- // A `?sub=` deep link singles out one row; the table must say so and offer a
100
- // way back to the full list without editing the URL.
101
- describe('SubResourcesSection — clearing the ?sub= selection', () => {
102
- const rows = [sub('a', {}), sub('b', {}), sub('c', {})]
103
-
104
- it('shows the selection as a dismissible filter that restores every row', () => {
105
- routerSearch.sub = 'b'
106
- navigate.mockClear()
107
- const { rerender } = render(
108
- <SubResourcesSection parentSlug="p" subResources={rows} />,
109
- )
110
- expect(screen.getByText('Sub-Resources (1 of 3)')).toBeInTheDocument()
111
-
112
- fireEvent.click(
113
- screen.getByRole('button', { name: 'Clear sub-resource filter' }),
114
- )
115
- const { search } = navigate.mock.calls[0]![0] as {
116
- search: (prev: object) => object
117
- }
118
- expect(search({ sub: 'b', q: 'x' })).toEqual({ sub: undefined, q: 'x' })
119
-
120
- // The router drops `sub` from the URL, which re-renders the section.
121
- routerSearch.sub = undefined
122
- rerender(<SubResourcesSection parentSlug="p" subResources={rows} />)
123
- expect(screen.getByText('Sub-Resources (3 of 3)')).toBeInTheDocument()
124
- expect(
125
- screen.getByPlaceholderText('Search resources by name or alias...'),
126
- ).toHaveFocus()
127
- // The user keeps their place: the row they came for is still marked.
128
- expect(
129
- screen.getByText('b', { selector: 'a' }).closest('tr'),
130
- ).toHaveAttribute('aria-current', 'true')
131
- })
132
-
133
- it('shows no clear control without a selection', () => {
134
- routerSearch.sub = undefined
135
- render(<SubResourcesSection parentSlug="p" subResources={rows} />)
136
- expect(
137
- screen.queryByRole('button', { name: 'Clear sub-resource filter' }),
138
- ).not.toBeInTheDocument()
139
- })
140
- })
@@ -47,65 +47,6 @@ function getApprovalMethodIcon(
47
47
  }
48
48
  }
49
49
 
50
- /** Roles shown before the rest are folded behind "Show all". */
51
- const VISIBLE_ROLES = 5
52
-
53
- /**
54
- * The roles table. A long list (dozens of roles) pushed the approval steps off
55
- * screen, so beyond {@link VISIBLE_ROLES} the rest sit behind a toggle.
56
- */
57
- function RolesTable({
58
- roles,
59
- }: {
60
- roles: NonNullable<AppAccessRequest['roles']>
61
- }) {
62
- const [expanded, setExpanded] = useState(false)
63
- const collapsible = roles.length > VISIBLE_ROLES
64
- const shown = collapsible && !expanded ? roles.slice(0, VISIBLE_ROLES) : roles
65
-
66
- return (
67
- <div>
68
- <h4 className="mb-2 text-sm font-medium">Available Roles</h4>
69
- <div className="rounded-lg border">
70
- <Table>
71
- <TableHeader>
72
- <TableRow>
73
- <TableHead className="whitespace-nowrap">Role</TableHead>
74
- <TableHead>Description</TableHead>
75
- </TableRow>
76
- </TableHeader>
77
- <TableBody>
78
- {shown.map((role, idx) => (
79
- <TableRow key={`${role.displayName}-${idx}`}>
80
- <TableCell className="font-medium whitespace-nowrap">
81
- {role.displayName}
82
- </TableCell>
83
- {/* `adminNotes` is provisioning-only and never shown to
84
- the requester — only `description` belongs here. */}
85
- <TableCell className="text-sm text-muted-foreground">
86
- {role.description || '—'}
87
- </TableCell>
88
- </TableRow>
89
- ))}
90
- </TableBody>
91
- </Table>
92
- </div>
93
- {collapsible && (
94
- <Button
95
- type="button"
96
- variant="link"
97
- size="sm"
98
- className="mt-1 h-auto px-0"
99
- aria-expanded={expanded}
100
- onClick={() => setExpanded((v) => !v)}
101
- >
102
- {expanded ? 'Show fewer roles' : `Show all ${roles.length} roles`}
103
- </Button>
104
- )}
105
- </div>
106
- )
107
- }
108
-
109
50
  /**
110
51
  * Custom hook for handling copy-to-clipboard functionality with feedback
111
52
  * Includes proper cleanup to prevent memory leaks
@@ -341,8 +282,35 @@ export function AccessRequestSection({
341
282
  </div>
342
283
  )}
343
284
 
285
+ {/* Roles Table */}
344
286
  {accessRequest.roles && accessRequest.roles.length > 0 && (
345
- <RolesTable roles={accessRequest.roles} />
287
+ <div>
288
+ <h4 className="mb-2 text-sm font-medium">Available Roles</h4>
289
+ <div className="rounded-lg border">
290
+ <Table>
291
+ <TableHeader>
292
+ <TableRow>
293
+ <TableHead className="whitespace-nowrap">Role</TableHead>
294
+ <TableHead>Description</TableHead>
295
+ </TableRow>
296
+ </TableHeader>
297
+ <TableBody>
298
+ {accessRequest.roles.map((role, idx) => (
299
+ <TableRow key={`${role.displayName}-${idx}`}>
300
+ <TableCell className="font-medium whitespace-nowrap">
301
+ {role.displayName}
302
+ </TableCell>
303
+ {/* `adminNotes` is provisioning-only and never shown to
304
+ the requester — only `description` belongs here. */}
305
+ <TableCell className="text-sm text-muted-foreground">
306
+ {role.description || '—'}
307
+ </TableCell>
308
+ </TableRow>
309
+ ))}
310
+ </TableBody>
311
+ </Table>
312
+ </div>
313
+ </div>
346
314
  )}
347
315
 
348
316
  {/* Approvers */}
@@ -358,6 +326,27 @@ export function AccessRequestSection({
358
326
  </div>
359
327
  )}
360
328
 
329
+ {/* Documentation URLs */}
330
+ {accessRequest.urls && accessRequest.urls.length > 0 && (
331
+ <div>
332
+ <h4 className="mb-2 text-sm font-medium">Documentation</h4>
333
+ <div className="flex flex-col gap-1.5">
334
+ {accessRequest.urls.map((urlObj, idx) => (
335
+ <a
336
+ key={`${urlObj.url}-${idx}`}
337
+ href={urlObj.url}
338
+ target="_blank"
339
+ rel="noopener noreferrer"
340
+ className="text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5"
341
+ >
342
+ {urlObj.label || urlObj.url}
343
+ <ExternalLink className="size-3" />
344
+ </a>
345
+ ))}
346
+ </div>
347
+ </div>
348
+ )}
349
+
361
350
  {/* Post-Approval Instructions */}
362
351
  {accessRequest.postApprovalInstructions &&
363
352
  (isTwoStep ? (
@@ -397,28 +386,6 @@ export function AccessRequestSection({
397
386
  </AccordionItem>
398
387
  </Accordion>
399
388
  ))}
400
-
401
- {/* Documentation URLs — box level: they cover the whole process,
402
- not one step, so they come after Step 2 */}
403
- {accessRequest.urls && accessRequest.urls.length > 0 && (
404
- <div>
405
- <h4 className="mb-2 text-sm font-medium">Documentation</h4>
406
- <div className="flex flex-col gap-1.5">
407
- {accessRequest.urls.map((urlObj, idx) => (
408
- <a
409
- key={`${urlObj.url}-${idx}`}
410
- href={urlObj.url}
411
- target="_blank"
412
- rel="noopener noreferrer"
413
- className="text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1.5"
414
- >
415
- {urlObj.label || urlObj.url}
416
- <ExternalLink className="size-3" />
417
- </a>
418
- ))}
419
- </div>
420
- </div>
421
- )}
422
389
  </div>
423
390
  )
424
391
  }
@@ -1,7 +1,7 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
- import { Link, useNavigate, useSearch } from '@tanstack/react-router'
3
- import { Search, X } from 'lucide-react'
4
- import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Link, useSearch } from '@tanstack/react-router'
3
+ import { Search } from 'lucide-react'
4
+ import { useEffect, useMemo, useState } from 'react'
5
5
  import { Badge } from '~/ui/badge'
6
6
  import { Input } from '~/ui/input'
7
7
  import {
@@ -78,33 +78,6 @@ export function SubResourcesSection({
78
78
  () => subResources.find((sr) => sr.slug === selectedSubSlug) ?? null,
79
79
  [subResources, selectedSubSlug],
80
80
  )
81
- const navigate = useNavigate()
82
-
83
- // The row to mark as "this one". It outlives `?sub=` so clearing the filter
84
- // brings the siblings back without losing the user's place.
85
- const [highlightSlug, setHighlightSlug] = useState(selectedSubSlug)
86
- useEffect(() => {
87
- if (selectedSubSlug) setHighlightSlug(selectedSubSlug)
88
- }, [selectedSubSlug])
89
-
90
- // The search input only mounts once the selection is gone, so focus it then.
91
- const searchInputRef = useRef<HTMLInputElement>(null)
92
- const focusSearchAfterClear = useRef(false)
93
- useEffect(() => {
94
- if (!selectedSub && focusSearchAfterClear.current) {
95
- focusSearchAfterClear.current = false
96
- searchInputRef.current?.focus()
97
- }
98
- }, [selectedSub])
99
-
100
- const clearSelection = () => {
101
- focusSearchAfterClear.current = true
102
- // A navigation, not a replace, so Back restores the single-row view.
103
- void navigate({
104
- to: '.',
105
- search: (prev) => ({ ...prev, sub: undefined }),
106
- })
107
- }
108
81
 
109
82
  // Seed the filter with the incoming query ONLY if it matches a child, so we
110
83
  // reveal the matched sub-resource without hiding everything on a non-match.
@@ -173,34 +146,15 @@ export function SubResourcesSection({
173
146
 
174
147
  {/* Filters */}
175
148
  <div className="flex gap-2">
176
- {selectedSub ? (
177
- // `?sub=` wins over the search box, so show it where the box would be
178
- // and make it dismissible — otherwise the only way out is the URL.
179
- <div className="flex flex-1 items-center">
180
- <Badge variant="secondary" className="h-9 gap-1 pl-3 pr-1 text-sm">
181
- Showing: {selectedSub.displayName}
182
- <button
183
- type="button"
184
- aria-label="Clear sub-resource filter"
185
- onClick={clearSelection}
186
- className="rounded-sm p-1 hover:bg-muted-foreground/20"
187
- >
188
- <X className="size-4" />
189
- </button>
190
- </Badge>
191
- </div>
192
- ) : (
193
- <div className="relative flex-1">
194
- <Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
195
- <Input
196
- ref={searchInputRef}
197
- placeholder="Search resources by name or alias..."
198
- value={search}
199
- onChange={(e) => setSearch(e.target.value)}
200
- className="pl-9 h-9"
201
- />
202
- </div>
203
- )}
149
+ <div className="relative flex-1">
150
+ <Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
151
+ <Input
152
+ placeholder="Search resources by name or alias..."
153
+ value={search}
154
+ onChange={(e) => setSearch(e.target.value)}
155
+ className="pl-9 h-9"
156
+ />
157
+ </div>
204
158
  {uniqueTiers.length > 1 && (
205
159
  <Select value={tierFilter} onValueChange={setTierFilter}>
206
160
  <SelectTrigger className="w-[130px] h-9">
@@ -251,10 +205,10 @@ export function SubResourcesSection({
251
205
  // The row the user asked for, so it reads as "this one" even
252
206
  // once the filter is cleared and its siblings come back.
253
207
  aria-current={
254
- sr.slug === highlightSlug ? 'true' : undefined
208
+ sr.slug === selectedSubSlug ? 'true' : undefined
255
209
  }
256
210
  className={
257
- sr.slug === highlightSlug ? 'bg-primary/[0.06]' : ''
211
+ sr.slug === selectedSubSlug ? 'bg-primary/[0.06]' : ''
258
212
  }
259
213
  >
260
214
  <TableCell>