@igstack/app-catalog-frontend-core 5.0.2 → 6.0.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.
@@ -1,11 +1,10 @@
1
1
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { ExternalLink, Settings, Users, Bot, Check, Copy } from "lucide-react";
3
3
  import { useMemo, useCallback, useState, useRef, useEffect } from "react";
4
- import ReactMarkdown from "react-markdown";
5
4
  import { Button } from "../../../../ui/button.js";
6
5
  import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "../../../../ui/accordion.js";
7
6
  import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "../../../../ui/table.js";
8
- import { MarkdownLink } from "./MarkdownText.js";
7
+ import { MarkdownText } from "./MarkdownText.js";
9
8
  import { PersonOrGroupBadge } from "./PersonBadge.js";
10
9
  const COPY_FEEDBACK_DURATION = 2e3;
11
10
  function getApprovalMethodIcon(type) {
@@ -157,7 +156,7 @@ function AccessRequestSection({
157
156
  ] }) }),
158
157
  accessRequest.requestPrompt && /* @__PURE__ */ jsxs("div", { className: "text-sm inline-flex items-center gap-2", children: [
159
158
  /* @__PURE__ */ jsx("span", { className: "whitespace-nowrap shrink-0", children: "Request Prompt:" }),
160
- /* @__PURE__ */ jsx("span", { className: "prose prose-sm inline [&>*]:inline [&>*]:m-0", children: /* @__PURE__ */ jsx(ReactMarkdown, { components: { a: MarkdownLink }, children: accessRequest.requestPrompt }) }),
159
+ /* @__PURE__ */ jsx("span", { className: "prose prose-sm inline [&>*]:inline [&>*]:m-0", children: /* @__PURE__ */ jsx(MarkdownText, { children: accessRequest.requestPrompt }) }),
161
160
  /* @__PURE__ */ jsx(
162
161
  CopyButton,
163
162
  {
@@ -168,7 +167,7 @@ function AccessRequestSection({
168
167
  }
169
168
  )
170
169
  ] }),
171
- accessRequest.comments && /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground prose prose-sm max-w-none", children: /* @__PURE__ */ jsx(ReactMarkdown, { components: { a: MarkdownLink }, children: accessRequest.comments }) }),
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 }) }),
172
171
  accessRequest.roles && accessRequest.roles.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
173
172
  /* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Available Roles" }),
174
173
  /* @__PURE__ */ jsx("div", { className: "rounded-lg border", children: /* @__PURE__ */ jsxs(Table, { children: [
@@ -210,7 +209,7 @@ function AccessRequestSection({
210
209
  /* @__PURE__ */ jsx("span", { className: "text-xs font-bold text-primary bg-primary/10 rounded-full px-2.5 py-0.5", children: "Step 2" }),
211
210
  /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "After approval" })
212
211
  ] }),
213
- /* @__PURE__ */ jsx("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", children: /* @__PURE__ */ jsx(ReactMarkdown, { components: { a: MarkdownLink }, children: accessRequest.postApprovalInstructions }) })
212
+ /* @__PURE__ */ jsx("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", children: /* @__PURE__ */ jsx(MarkdownText, { children: accessRequest.postApprovalInstructions }) })
214
213
  ] })
215
214
  ) : (
216
215
  // Single-step apps: keep collapsible accordion (secondary info)
@@ -221,7 +220,7 @@ function AccessRequestSection({
221
220
  className: "border rounded-lg px-4",
222
221
  children: [
223
222
  /* @__PURE__ */ jsx(AccordionTrigger, { className: "text-sm hover:no-underline py-3", children: "Post-Approval Instructions" }),
224
- /* @__PURE__ */ jsx(AccordionContent, { className: "pb-3", children: /* @__PURE__ */ jsx("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", children: /* @__PURE__ */ jsx(ReactMarkdown, { components: { a: MarkdownLink }, children: accessRequest.postApprovalInstructions }) }) })
223
+ /* @__PURE__ */ jsx(AccordionContent, { className: "pb-3", children: /* @__PURE__ */ jsx("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", children: /* @__PURE__ */ jsx(MarkdownText, { children: accessRequest.postApprovalInstructions }) }) })
225
224
  ]
226
225
  }
227
226
  ) })
@@ -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 ReactMarkdown from 'react-markdown'\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 { MarkdownLink } 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 <ReactMarkdown components={{ a: MarkdownLink }}>\n {accessRequest.requestPrompt}\n </ReactMarkdown>\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 <ReactMarkdown components={{ a: MarkdownLink }}>\n {accessRequest.comments}\n </ReactMarkdown>\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 <ReactMarkdown components={{ a: MarkdownLink }}>\n {accessRequest.postApprovalInstructions}\n </ReactMarkdown>\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 <ReactMarkdown components={{ a: MarkdownLink }}>\n {accessRequest.postApprovalInstructions}\n </ReactMarkdown>\n </div>\n </AccordionContent>\n </AccordionItem>\n </Accordion>\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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"}
@@ -19,7 +19,13 @@ export declare const MarkdownLink: ({ href, children, }: {
19
19
  }) => import("react/jsx-runtime").JSX.Element;
20
20
  /**
21
21
  * Renders user-facing catalog text (descriptions, comments, prompts) as
22
- * markdown so links become clickable. Links use {@link MarkdownLink}.
22
+ * markdown so links become clickable. Links use {@link MarkdownLink}; bare
23
+ * chat-channel mentions (`#name`) become links when the consuming app sets
24
+ * `UiSettings.chatChannelUrlTemplate`. Every catalog text field must render
25
+ * through this component so all of them link identically.
26
+ *
27
+ * Without a `className` no wrapper element is emitted, so it drops into an
28
+ * existing `prose` container without changing the DOM.
23
29
  */
24
30
  export declare function MarkdownText({ children, className, }: {
25
31
  children: string;
@@ -1,7 +1,9 @@
1
1
  import { jsx, Fragment } from "react/jsx-runtime";
2
2
  import { useRouterState, Link } from "@tanstack/react-router";
3
3
  import ReactMarkdown from "react-markdown";
4
+ import { useUiSettings } from "../../../../context/UiSettingsContext.js";
4
5
  import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
6
+ import { remarkChatChannels } from "./remarkChatChannels.js";
5
7
  const INTERNAL_APP_LINK = /^\/app\/([^/?#]+)$/;
6
8
  const MarkdownLink = ({
7
9
  href,
@@ -49,7 +51,16 @@ function MarkdownText({
49
51
  children,
50
52
  className
51
53
  }) {
52
- return /* @__PURE__ */ jsx("span", { className, children: /* @__PURE__ */ jsx(ReactMarkdown, { components: { a: MarkdownLink }, children }) });
54
+ const { chatChannelUrlTemplate } = useUiSettings();
55
+ const markdown = /* @__PURE__ */ jsx(
56
+ ReactMarkdown,
57
+ {
58
+ components: { a: MarkdownLink },
59
+ remarkPlugins: chatChannelUrlTemplate ? [[remarkChatChannels, chatChannelUrlTemplate]] : [],
60
+ children
61
+ }
62
+ );
63
+ return className ? /* @__PURE__ */ jsx("span", { className, children: markdown }) : markdown;
53
64
  }
54
65
  export {
55
66
  MarkdownLink,
@@ -1 +1 @@
1
- {"version":3,"file":"MarkdownText.js","sources":["../../../../../../src/modules/appCatalog/ui/components/MarkdownText.tsx"],"sourcesContent":["import { Link, useRouterState } from '@tanstack/react-router'\nimport type React from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\n\n/** Match a relative internal catalog link: `/app/<slug>` (slug only, no extra path). */\nconst INTERNAL_APP_LINK = /^\\/app\\/([^/?#]+)$/\n\n/**\n * Renders a markdown link.\n *\n * Internal cross-references — a relative `/app/<slug>` pointing at another\n * catalog entry — navigate WITHIN the app via the TanStack router (same tab,\n * no full reload), so authors can cross-link entries with plain markdown\n * `[Name](/app/<slug>)` (#25). The slug is validated against the loaded\n * resources (canonical slug or a known alias); an unknown slug renders as\n * plain text rather than a dead link.\n *\n * Everything else (external http/https links) opens in a new tab with\n * `noopener noreferrer`, unchanged. Shared so all catalog text fields render\n * links identically.\n */\nexport const MarkdownLink = ({\n href,\n children,\n}: {\n href?: string\n children?: React.ReactNode\n}) => {\n const { resources } = useAppCatalogContext()\n const pathname = useRouterState({ select: (s) => s.location.pathname })\n\n const internalMatch = href?.match(INTERNAL_APP_LINK)\n if (internalMatch) {\n const slug = decodeURIComponent(internalMatch[1] ?? '')\n const exists = resources.some(\n (r) => r.slug === slug || r.aliases?.includes(slug),\n )\n // Unknown slug → render plain text, never a dead internal link.\n if (!exists) {\n return <>{children}</>\n }\n const isCurrent = pathname === `/app/${slug}`\n return (\n <Link\n to=\"/app/$slug\"\n params={{ slug }}\n search={(prev) => prev}\n aria-current={isCurrent ? 'page' : undefined}\n className=\"text-primary hover:underline\"\n >\n {children}\n </Link>\n )\n }\n\n return (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline\"\n >\n {children}\n </a>\n )\n}\n\n/**\n * Renders user-facing catalog text (descriptions, comments, prompts) as\n * markdown so links become clickable. Links use {@link MarkdownLink}.\n */\nexport function MarkdownText({\n children,\n className,\n}: {\n children: string\n className?: string\n}) {\n return (\n <span className={className}>\n <ReactMarkdown components={{ a: MarkdownLink }}>{children}</ReactMarkdown>\n </span>\n )\n}\n"],"names":[],"mappings":";;;;AAMA,MAAM,oBAAoB;AAgBnB,MAAM,eAAe,CAAC;AAAA,EAC3B;AAAA,EACA;AACF,MAGM;AACJ,QAAM,EAAE,UAAA,IAAc,qBAAA;AACtB,QAAM,WAAW,eAAe,EAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU;AAEtE,QAAM,gBAAgB,6BAAM,MAAM;AAClC,MAAI,eAAe;AACjB,UAAM,OAAO,mBAAmB,cAAc,CAAC,KAAK,EAAE;AACtD,UAAM,SAAS,UAAU;AAAA,MACvB,CAAC;;AAAM,iBAAE,SAAS,UAAQ,OAAE,YAAF,mBAAW,SAAS;AAAA;AAAA,IAAI;AAGpD,QAAI,CAAC,QAAQ;AACX,6CAAU,UAAS;AAAA,IACrB;AACA,UAAM,YAAY,aAAa,QAAQ,IAAI;AAC3C,WACE;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,IAAG;AAAA,QACH,QAAQ,EAAE,KAAA;AAAA,QACV,QAAQ,CAAC,SAAS;AAAA,QAClB,gBAAc,YAAY,SAAS;AAAA,QACnC,WAAU;AAAA,QAET;AAAA,MAAA;AAAA,IAAA;AAAA,EAGP;AAEA,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC;AAAA,MACA,QAAO;AAAA,MACP,KAAI;AAAA,MACJ,WAAU;AAAA,MAET;AAAA,IAAA;AAAA,EAAA;AAGP;AAMO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AACF,GAGG;AACD,SACE,oBAAC,QAAA,EAAK,WACJ,UAAA,oBAAC,eAAA,EAAc,YAAY,EAAE,GAAG,aAAA,GAAiB,SAAA,CAAS,EAAA,CAC5D;AAEJ;"}
1
+ {"version":3,"file":"MarkdownText.js","sources":["../../../../../../src/modules/appCatalog/ui/components/MarkdownText.tsx"],"sourcesContent":["import { Link, useRouterState } from '@tanstack/react-router'\nimport type React from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { remarkChatChannels } from './remarkChatChannels'\n\n/** Match a relative internal catalog link: `/app/<slug>` (slug only, no extra path). */\nconst INTERNAL_APP_LINK = /^\\/app\\/([^/?#]+)$/\n\n/**\n * Renders a markdown link.\n *\n * Internal cross-references — a relative `/app/<slug>` pointing at another\n * catalog entry — navigate WITHIN the app via the TanStack router (same tab,\n * no full reload), so authors can cross-link entries with plain markdown\n * `[Name](/app/<slug>)` (#25). The slug is validated against the loaded\n * resources (canonical slug or a known alias); an unknown slug renders as\n * plain text rather than a dead link.\n *\n * Everything else (external http/https links) opens in a new tab with\n * `noopener noreferrer`, unchanged. Shared so all catalog text fields render\n * links identically.\n */\nexport const MarkdownLink = ({\n href,\n children,\n}: {\n href?: string\n children?: React.ReactNode\n}) => {\n const { resources } = useAppCatalogContext()\n const pathname = useRouterState({ select: (s) => s.location.pathname })\n\n const internalMatch = href?.match(INTERNAL_APP_LINK)\n if (internalMatch) {\n const slug = decodeURIComponent(internalMatch[1] ?? '')\n const exists = resources.some(\n (r) => r.slug === slug || r.aliases?.includes(slug),\n )\n // Unknown slug → render plain text, never a dead internal link.\n if (!exists) {\n return <>{children}</>\n }\n const isCurrent = pathname === `/app/${slug}`\n return (\n <Link\n to=\"/app/$slug\"\n params={{ slug }}\n search={(prev) => prev}\n aria-current={isCurrent ? 'page' : undefined}\n className=\"text-primary hover:underline\"\n >\n {children}\n </Link>\n )\n }\n\n return (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline\"\n >\n {children}\n </a>\n )\n}\n\n/**\n * Renders user-facing catalog text (descriptions, comments, prompts) as\n * markdown so links become clickable. Links use {@link MarkdownLink}; bare\n * chat-channel mentions (`#name`) become links when the consuming app sets\n * `UiSettings.chatChannelUrlTemplate`. Every catalog text field must render\n * through this component so all of them link identically.\n *\n * Without a `className` no wrapper element is emitted, so it drops into an\n * existing `prose` container without changing the DOM.\n */\nexport function MarkdownText({\n children,\n className,\n}: {\n children: string\n className?: string\n}) {\n const { chatChannelUrlTemplate } = useUiSettings()\n const markdown = (\n <ReactMarkdown\n components={{ a: MarkdownLink }}\n remarkPlugins={\n chatChannelUrlTemplate\n ? [[remarkChatChannels, chatChannelUrlTemplate]]\n : []\n }\n >\n {children}\n </ReactMarkdown>\n )\n return className ? <span className={className}>{markdown}</span> : markdown\n}\n"],"names":[],"mappings":";;;;;;AAQA,MAAM,oBAAoB;AAgBnB,MAAM,eAAe,CAAC;AAAA,EAC3B;AAAA,EACA;AACF,MAGM;AACJ,QAAM,EAAE,UAAA,IAAc,qBAAA;AACtB,QAAM,WAAW,eAAe,EAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU;AAEtE,QAAM,gBAAgB,6BAAM,MAAM;AAClC,MAAI,eAAe;AACjB,UAAM,OAAO,mBAAmB,cAAc,CAAC,KAAK,EAAE;AACtD,UAAM,SAAS,UAAU;AAAA,MACvB,CAAC;;AAAM,iBAAE,SAAS,UAAQ,OAAE,YAAF,mBAAW,SAAS;AAAA;AAAA,IAAI;AAGpD,QAAI,CAAC,QAAQ;AACX,6CAAU,UAAS;AAAA,IACrB;AACA,UAAM,YAAY,aAAa,QAAQ,IAAI;AAC3C,WACE;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,IAAG;AAAA,QACH,QAAQ,EAAE,KAAA;AAAA,QACV,QAAQ,CAAC,SAAS;AAAA,QAClB,gBAAc,YAAY,SAAS;AAAA,QACnC,WAAU;AAAA,QAET;AAAA,MAAA;AAAA,IAAA;AAAA,EAGP;AAEA,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC;AAAA,MACA,QAAO;AAAA,MACP,KAAI;AAAA,MACJ,WAAU;AAAA,MAET;AAAA,IAAA;AAAA,EAAA;AAGP;AAYO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AACF,GAGG;AACD,QAAM,EAAE,uBAAA,IAA2B,cAAA;AACnC,QAAM,WACJ;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,YAAY,EAAE,GAAG,aAAA;AAAA,MACjB,eACE,yBACI,CAAC,CAAC,oBAAoB,sBAAsB,CAAC,IAC7C,CAAA;AAAA,MAGL;AAAA,IAAA;AAAA,EAAA;AAGL,SAAO,YAAY,oBAAC,QAAA,EAAK,WAAuB,oBAAS,IAAU;AACrE;"}
@@ -1,7 +1,7 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import { ExternalLinkIcon, Settings, Users, Bot } from "lucide-react";
3
3
  import { useState } from "react";
4
- import ReactMarkdown from "react-markdown";
4
+ import { MarkdownText } from "./MarkdownText.js";
5
5
  import { Badge } from "../../../../ui/badge.js";
6
6
  import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "../../../../ui/table.js";
7
7
  import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
@@ -89,7 +89,7 @@ function TierAccessDetail({
89
89
  )
90
90
  ] }),
91
91
  expanded && /* @__PURE__ */ jsxs("div", { className: "pl-5 space-y-1.5 text-xs", children: [
92
- accessRequest.comments && /* @__PURE__ */ jsx("div", { className: "text-muted-foreground prose prose-xs max-w-none", children: /* @__PURE__ */ jsx(ReactMarkdown, { children: accessRequest.comments }) }),
92
+ accessRequest.comments && /* @__PURE__ */ jsx("div", { className: "text-muted-foreground prose prose-xs max-w-none", children: /* @__PURE__ */ jsx(MarkdownText, { children: accessRequest.comments }) }),
93
93
  accessRequest.urls && accessRequest.urls.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-0.5", children: accessRequest.urls.map((urlObj, idx) => /* @__PURE__ */ jsxs(
94
94
  "a",
95
95
  {
@@ -1 +1 @@
1
- {"version":3,"file":"TierVariantsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/TierVariantsSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n TierVariant,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'\nimport { useState } from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport { Badge } from '~/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { PersonOrGroupBadge } from './PersonBadge'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\n\ninterface TierVariantsSectionProps {\n tiers: TierVariant[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nfunction getAccessIcon(type: string): React.ReactNode {\n switch (type) {\n case 'service':\n return <Bot className=\"size-4 text-primary shrink-0\" />\n case 'personTeam':\n return <Users className=\"size-4 text-primary shrink-0\" />\n default:\n return <Settings className=\"size-4 text-primary shrink-0\" />\n }\n}\n\n/** Compact inline access detail for a tier row */\nfunction TierAccessDetail({\n accessRequest,\n methods,\n}: {\n accessRequest: AppAccessRequest\n methods: AppApprovalMethod[]\n}) {\n const [expanded, setExpanded] = useState(false)\n const method = methods.find(\n (m) => m.slug === accessRequest.approvalMethodSlug,\n )\n\n const hasExtra =\n accessRequest.comments ||\n accessRequest.urls?.length ||\n accessRequest.approverSlugs?.length ||\n accessRequest.postApprovalInstructions\n\n return (\n <div className=\"space-y-1\">\n <div className=\"flex items-center gap-1.5\">\n {method && getAccessIcon(method.type)}\n {method?.type === 'service' && method.config.url ? (\n <a\n href={method.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {method.displayName}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-sm\">\n {method?.displayName ?? accessRequest.approvalMethodSlug}\n </span>\n )}\n {hasExtra && (\n <button\n type=\"button\"\n onClick={() => setExpanded(!expanded)}\n className=\"text-xs text-muted-foreground hover:text-primary ml-1\"\n >\n {expanded ? 'less' : 'more...'}\n </button>\n )}\n </div>\n {expanded && (\n <div className=\"pl-5 space-y-1.5 text-xs\">\n {accessRequest.comments && (\n <div className=\"text-muted-foreground prose prose-xs max-w-none\">\n <ReactMarkdown>{accessRequest.comments}</ReactMarkdown>\n </div>\n )}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div className=\"flex flex-col gap-0.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline inline-flex items-center gap-1\"\n >\n {urlObj.label || displayUrl(urlObj.url)}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ))}\n </div>\n )}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div className=\"flex flex-wrap gap-1\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport function TierVariantsSection({ tiers }: TierVariantsSectionProps) {\n const { approvalMethods } = useAppCatalogContext()\n\n if (tiers.length === 0) return null\n\n return (\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">Environment Tiers</div>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-[100px]\">Tier</TableHead>\n <TableHead>Name</TableHead>\n <TableHead>URL</TableHead>\n <TableHead>Access</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {tiers.map((tier) => (\n <TableRow key={tier.tierSlug}>\n <TableCell>\n <Badge\n variant={getTierBadgeVariant(tier.tierSlug)}\n className={getTierBadgeClassName(tier.tierSlug)}\n >\n {getTierDisplayLabel(tier.tierSlug)}\n </Badge>\n </TableCell>\n <TableCell className=\"font-medium\">\n {tier.displayName ?? tier.tierSlug}\n </TableCell>\n <TableCell>\n {tier.appUrl ? (\n <a\n href={tier.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {displayUrl(tier.appUrl)}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {tier.accessRequest ? (\n <TierAccessDetail\n accessRequest={tier.accessRequest}\n methods={approvalMethods}\n />\n ) : (\n <span className=\"text-muted-foreground\">Same as app</span>\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"TierVariantsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/components/TierVariantsSection.tsx"],"sourcesContent":["import type {\n AppAccessRequest,\n AppApprovalMethod,\n TierVariant,\n} from '@igstack/app-catalog-backend-core'\nimport { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'\nimport { useState } from 'react'\nimport { MarkdownText } from './MarkdownText'\nimport { Badge } from '~/ui/badge'\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '~/ui/table'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { PersonOrGroupBadge } from './PersonBadge'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\n\ninterface TierVariantsSectionProps {\n tiers: TierVariant[]\n}\n\nfunction getTierBadgeVariant(\n tierSlug: string,\n): 'default' | 'secondary' | 'destructive' | 'outline' {\n if (tierSlug === 'prod' || tierSlug === 'production') return 'destructive'\n if (tierSlug === 'dev' || tierSlug === 'staging') return 'secondary'\n if (tierSlug === 'preprod') return 'outline'\n if (tierSlug === 'sandbox') return 'outline'\n return 'outline'\n}\n\nfunction getTierBadgeClassName(tierSlug: string): string {\n if (tierSlug === 'preprod')\n return 'border-amber-400 bg-amber-100 text-amber-800 hover:bg-amber-200'\n if (tierSlug === 'sandbox')\n return 'border-gray-400 bg-gray-100 text-gray-700 hover:bg-gray-200'\n return ''\n}\n\nfunction getTierDisplayLabel(tierSlug: string): string {\n if (tierSlug === 'preprod') return 'Pre-Prod'\n if (tierSlug === 'sandbox') return 'Sandbox'\n if (tierSlug === 'prod' || tierSlug === 'production') return 'Prod'\n if (tierSlug === 'dev') return 'Dev'\n if (tierSlug === 'staging') return 'Staging'\n return tierSlug\n}\n\nfunction getAccessIcon(type: string): React.ReactNode {\n switch (type) {\n case 'service':\n return <Bot className=\"size-4 text-primary shrink-0\" />\n case 'personTeam':\n return <Users className=\"size-4 text-primary shrink-0\" />\n default:\n return <Settings className=\"size-4 text-primary shrink-0\" />\n }\n}\n\n/** Compact inline access detail for a tier row */\nfunction TierAccessDetail({\n accessRequest,\n methods,\n}: {\n accessRequest: AppAccessRequest\n methods: AppApprovalMethod[]\n}) {\n const [expanded, setExpanded] = useState(false)\n const method = methods.find(\n (m) => m.slug === accessRequest.approvalMethodSlug,\n )\n\n const hasExtra =\n accessRequest.comments ||\n accessRequest.urls?.length ||\n accessRequest.approverSlugs?.length ||\n accessRequest.postApprovalInstructions\n\n return (\n <div className=\"space-y-1\">\n <div className=\"flex items-center gap-1.5\">\n {method && getAccessIcon(method.type)}\n {method?.type === 'service' && method.config.url ? (\n <a\n href={method.config.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {method.displayName}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-sm\">\n {method?.displayName ?? accessRequest.approvalMethodSlug}\n </span>\n )}\n {hasExtra && (\n <button\n type=\"button\"\n onClick={() => setExpanded(!expanded)}\n className=\"text-xs text-muted-foreground hover:text-primary ml-1\"\n >\n {expanded ? 'less' : 'more...'}\n </button>\n )}\n </div>\n {expanded && (\n <div className=\"pl-5 space-y-1.5 text-xs\">\n {accessRequest.comments && (\n <div className=\"text-muted-foreground prose prose-xs max-w-none\">\n <MarkdownText>{accessRequest.comments}</MarkdownText>\n </div>\n )}\n {accessRequest.urls && accessRequest.urls.length > 0 && (\n <div className=\"flex flex-col gap-0.5\">\n {accessRequest.urls.map((urlObj, idx) => (\n <a\n key={`${urlObj.url}-${idx}`}\n href={urlObj.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline inline-flex items-center gap-1\"\n >\n {urlObj.label || displayUrl(urlObj.url)}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ))}\n </div>\n )}\n {accessRequest.approverSlugs &&\n accessRequest.approverSlugs.length > 0 && (\n <div className=\"flex flex-wrap gap-1\">\n {accessRequest.approverSlugs.map((slug) => (\n <PersonOrGroupBadge key={slug} slug={slug} />\n ))}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport function TierVariantsSection({ tiers }: TierVariantsSectionProps) {\n const { approvalMethods } = useAppCatalogContext()\n\n if (tiers.length === 0) return null\n\n return (\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">Environment Tiers</div>\n <div className=\"rounded-lg border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead className=\"w-[100px]\">Tier</TableHead>\n <TableHead>Name</TableHead>\n <TableHead>URL</TableHead>\n <TableHead>Access</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {tiers.map((tier) => (\n <TableRow key={tier.tierSlug}>\n <TableCell>\n <Badge\n variant={getTierBadgeVariant(tier.tierSlug)}\n className={getTierBadgeClassName(tier.tierSlug)}\n >\n {getTierDisplayLabel(tier.tierSlug)}\n </Badge>\n </TableCell>\n <TableCell className=\"font-medium\">\n {tier.displayName ?? tier.tierSlug}\n </TableCell>\n <TableCell>\n {tier.appUrl ? (\n <a\n href={tier.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n >\n {displayUrl(tier.appUrl)}\n <ExternalLinkIcon className=\"size-3\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">-</span>\n )}\n </TableCell>\n <TableCell>\n {tier.accessRequest ? (\n <TierAccessDetail\n accessRequest={tier.accessRequest}\n methods={approvalMethods}\n />\n ) : (\n <span className=\"text-muted-foreground\">Same as app</span>\n )}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Remark plugin: turns bare chat-channel mentions (`#swaggerhub`) in markdown
3
+ * text into links built from a URL template (`{name}` placeholder), so an
4
+ * author can write "ask in Slack #swaggerhub" without hand-writing the URL.
5
+ *
6
+ * Only `text` nodes are rewritten; existing links, link references and code
7
+ * are never touched. A mention is `#` preceded by start of text, whitespace or
8
+ * `(`, followed by a name in the common channel grammar (lower-case letters,
9
+ * digits, `.`, `_`, `-`; starts and ends with a letter or digit; max 80) and
10
+ * not glued to further word characters. Digits-only (`#123`, an issue number)
11
+ * and mid-word `#` (URL fragments like `?gid=0#gid=0`) are not mentions.
12
+ */
13
+ type MdNode = {
14
+ type: string;
15
+ value?: string;
16
+ url?: string;
17
+ children?: MdNode[];
18
+ };
19
+ export declare function remarkChatChannels(template: string): (node: MdNode) => void;
20
+ export {};
@@ -0,0 +1,39 @@
1
+ const MENTION = /(^|[\s(])#([a-z0-9](?:[a-z0-9._-]{0,78}[a-z0-9])?)(?![A-Za-z0-9_-])/g;
2
+ const KEEP_VERBATIM = /* @__PURE__ */ new Set(["link", "linkReference", "inlineCode", "code"]);
3
+ function remarkChatChannels(template) {
4
+ const linkTo = (name) => ({
5
+ type: "link",
6
+ url: template.replace("{name}", encodeURIComponent(name)),
7
+ children: [{ type: "text", value: `#${name}` }]
8
+ });
9
+ const splitText = (value) => {
10
+ const out = [];
11
+ let last = 0;
12
+ for (const m of value.matchAll(MENTION)) {
13
+ const [, lead = "", name = ""] = m;
14
+ if (/^\d+$/.test(name)) continue;
15
+ const start = m.index + lead.length;
16
+ out.push({ type: "text", value: value.slice(last, start) }, linkTo(name));
17
+ last = start + name.length + 1;
18
+ }
19
+ if (out.length === 0) return [{ type: "text", value }];
20
+ out.push({ type: "text", value: value.slice(last) });
21
+ return out.filter((n) => n.type !== "text" || n.value);
22
+ };
23
+ const visit = (node) => {
24
+ if (!node.children) return;
25
+ node.children = node.children.flatMap((child) => {
26
+ if (KEEP_VERBATIM.has(child.type)) return [child];
27
+ if (child.type !== "text") {
28
+ visit(child);
29
+ return [child];
30
+ }
31
+ return splitText(child.value ?? "");
32
+ });
33
+ };
34
+ return visit;
35
+ }
36
+ export {
37
+ remarkChatChannels
38
+ };
39
+ //# sourceMappingURL=remarkChatChannels.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remarkChatChannels.js","sources":["../../../../../../src/modules/appCatalog/ui/components/remarkChatChannels.ts"],"sourcesContent":["/**\n * Remark plugin: turns bare chat-channel mentions (`#swaggerhub`) in markdown\n * text into links built from a URL template (`{name}` placeholder), so an\n * author can write \"ask in Slack #swaggerhub\" without hand-writing the URL.\n *\n * Only `text` nodes are rewritten; existing links, link references and code\n * are never touched. A mention is `#` preceded by start of text, whitespace or\n * `(`, followed by a name in the common channel grammar (lower-case letters,\n * digits, `.`, `_`, `-`; starts and ends with a letter or digit; max 80) and\n * not glued to further word characters. Digits-only (`#123`, an issue number)\n * and mid-word `#` (URL fragments like `?gid=0#gid=0`) are not mentions.\n */\n\ntype MdNode = {\n type: string\n value?: string\n url?: string\n children?: MdNode[]\n}\n\nconst MENTION =\n /(^|[\\s(])#([a-z0-9](?:[a-z0-9._-]{0,78}[a-z0-9])?)(?![A-Za-z0-9_-])/g\n\n/** Node types whose text must stay verbatim. */\nconst KEEP_VERBATIM = new Set(['link', 'linkReference', 'inlineCode', 'code'])\n\nexport function remarkChatChannels(template: string) {\n const linkTo = (name: string): MdNode => ({\n type: 'link',\n url: template.replace('{name}', encodeURIComponent(name)),\n children: [{ type: 'text', value: `#${name}` }],\n })\n\n const splitText = (value: string): MdNode[] => {\n const out: MdNode[] = []\n let last = 0\n for (const m of value.matchAll(MENTION)) {\n const [, lead = '', name = ''] = m\n if (/^\\d+$/.test(name)) continue\n const start = m.index + lead.length\n out.push({ type: 'text', value: value.slice(last, start) }, linkTo(name))\n last = start + name.length + 1\n }\n if (out.length === 0) return [{ type: 'text', value }]\n out.push({ type: 'text', value: value.slice(last) })\n return out.filter((n) => n.type !== 'text' || n.value)\n }\n\n const visit = (node: MdNode): void => {\n if (!node.children) return\n node.children = node.children.flatMap((child) => {\n if (KEEP_VERBATIM.has(child.type)) return [child]\n if (child.type !== 'text') {\n visit(child)\n return [child]\n }\n return splitText(child.value ?? '')\n })\n }\n\n return visit\n}\n"],"names":[],"mappings":"AAoBA,MAAM,UACJ;AAGF,MAAM,oCAAoB,IAAI,CAAC,QAAQ,iBAAiB,cAAc,MAAM,CAAC;AAEtE,SAAS,mBAAmB,UAAkB;AACnD,QAAM,SAAS,CAAC,UAA0B;AAAA,IACxC,MAAM;AAAA,IACN,KAAK,SAAS,QAAQ,UAAU,mBAAmB,IAAI,CAAC;AAAA,IACxD,UAAU,CAAC,EAAE,MAAM,QAAQ,OAAO,IAAI,IAAI,GAAA,CAAI;AAAA,EAAA;AAGhD,QAAM,YAAY,CAAC,UAA4B;AAC7C,UAAM,MAAgB,CAAA;AACtB,QAAI,OAAO;AACX,eAAW,KAAK,MAAM,SAAS,OAAO,GAAG;AACvC,YAAM,CAAA,EAAG,OAAO,IAAI,OAAO,EAAE,IAAI;AACjC,UAAI,QAAQ,KAAK,IAAI,EAAG;AACxB,YAAM,QAAQ,EAAE,QAAQ,KAAK;AAC7B,UAAI,KAAK,EAAE,MAAM,QAAQ,OAAO,MAAM,MAAM,MAAM,KAAK,EAAA,GAAK,OAAO,IAAI,CAAC;AACxE,aAAO,QAAQ,KAAK,SAAS;AAAA,IAC/B;AACA,QAAI,IAAI,WAAW,EAAG,QAAO,CAAC,EAAE,MAAM,QAAQ,OAAO;AACrD,QAAI,KAAK,EAAE,MAAM,QAAQ,OAAO,MAAM,MAAM,IAAI,GAAG;AACnD,WAAO,IAAI,OAAO,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,KAAK;AAAA,EACvD;AAEA,QAAM,QAAQ,CAAC,SAAuB;AACpC,QAAI,CAAC,KAAK,SAAU;AACpB,SAAK,WAAW,KAAK,SAAS,QAAQ,CAAC,UAAU;AAC/C,UAAI,cAAc,IAAI,MAAM,IAAI,EAAG,QAAO,CAAC,KAAK;AAChD,UAAI,MAAM,SAAS,QAAQ;AACzB,cAAM,KAAK;AACX,eAAO,CAAC,KAAK;AAAA,MACf;AACA,aAAO,UAAU,MAAM,SAAS,EAAE;AAAA,IACpC,CAAC;AAAA,EACH;AAEA,SAAO;AACT;"}
@@ -31,6 +31,13 @@ export interface UiSettings {
31
31
  };
32
32
  /** Frontend build identifier baked at build time (e.g., pipeline ID via VITE env var) */
33
33
  frontendBuildId?: string;
34
+ /**
35
+ * URL template for bare chat-channel mentions (`#channel-name`) in catalog
36
+ * text; `{name}` is replaced by the channel name, e.g.
37
+ * `https://<workspace>.slack.com/channels/{name}`. Unset = mentions stay
38
+ * plain text. Kept generic so the core knows no vendor or workspace.
39
+ */
40
+ chatChannelUrlTemplate?: string;
34
41
  /** Area sections on the home view: icons and the day-to-day fold. */
35
42
  areas?: AreasSettings;
36
43
  /** PWA auto-update configuration (idle timeout, check interval, debug) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "5.0.2",
3
+ "version": "6.0.0",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -113,7 +113,7 @@
113
113
  "tailwindcss": "^4.1.7",
114
114
  "tiptap-markdown": "^0.8.0",
115
115
  "tslib": "^2.3.0",
116
- "@igstack/app-catalog-shared-core": "5.0.2"
116
+ "@igstack/app-catalog-shared-core": "6.0.0"
117
117
  },
118
118
  "devDependencies": {
119
119
  "@tailwindcss/vite": "^4.0.5",
@@ -140,7 +140,7 @@
140
140
  "vite-plugin-static-copy": "^3.1.4",
141
141
  "vite-plugin-svgr": "^4.2.0",
142
142
  "vitest": "^4.1.2",
143
- "@igstack/app-catalog-backend-core": "5.0.2"
143
+ "@igstack/app-catalog-backend-core": "6.0.0"
144
144
  },
145
145
  "peerDependencies": {
146
146
  "react": "19.1.2",
@@ -149,7 +149,7 @@
149
149
  "vite": "^6.3.5",
150
150
  "vite-plugin-svgr": "^4.2.0"
151
151
  },
152
- "gitHead": "d9fecee127ebca122fa2d45e53819680aebab5e4",
152
+ "gitHead": "243274faedbe508dc4418fbce9f19742864ddc59",
153
153
  "scripts": {
154
154
  "build": "vite build",
155
155
  "build:lenient": "vite build --mode lenient",
@@ -3,9 +3,14 @@ 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
+ import { UiSettingsContext } from '~/context/UiSettingsContext'
7
+ import { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
8
+ import type { AppCatalogContextIface } from '~/modules/appCatalog/context/AppCatalogContext'
9
+
6
10
  // Badges link to a person's page; no router is mounted here.
7
11
  vi.mock('@tanstack/react-router', () => ({
8
12
  useSearch: vi.fn(() => ({})),
13
+ useRouterState: vi.fn(() => '/'),
9
14
  Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
10
15
  <a {...rest}>{children}</a>
11
16
  ),
@@ -66,3 +71,52 @@ describe('AccessRequestSection — roles table', () => {
66
71
  expect(screen.queryByText(/LV 1.0 job type/)).toBeNull()
67
72
  })
68
73
  })
74
+
75
+ // The channel mentions live in the access-request text fields, which this
76
+ // section renders itself — so they must go through the same markdown path as
77
+ // the description or the links never appear where the data actually is.
78
+ describe('AccessRequestSection — chat channel mentions', () => {
79
+ it('links a #channel mention inside comments and post-approval steps', () => {
80
+ const ctx = {
81
+ resources: [],
82
+ isLoadingApps: false,
83
+ tagsDefinitions: [],
84
+ approvalMethods: [],
85
+ persons: [],
86
+ groups: [],
87
+ } satisfies AppCatalogContextIface
88
+
89
+ render(
90
+ <UiSettingsContext
91
+ value={{ chatChannelUrlTemplate: 'https://chat.example.com/c/{name}' }}
92
+ >
93
+ <AppCatalogContext value={ctx}>
94
+ <AccessRequestSection
95
+ app={
96
+ {
97
+ id: 'swaggerhub',
98
+ slug: 'swaggerhub',
99
+ displayName: 'SwaggerHub',
100
+ accessRequest: {
101
+ approvalMethodSlug: 'service',
102
+ comments: 'For questions, ask in Slack #swaggerhub channel.',
103
+ postApprovalInstructions: 'Then say hi in #onboarding.',
104
+ },
105
+ } as Resource
106
+ }
107
+ approvalMethods={[]}
108
+ />
109
+ </AppCatalogContext>
110
+ </UiSettingsContext>,
111
+ )
112
+
113
+ expect(screen.getByRole('link', { name: '#swaggerhub' })).toHaveAttribute(
114
+ 'href',
115
+ 'https://chat.example.com/c/swaggerhub',
116
+ )
117
+ expect(screen.getByRole('link', { name: '#onboarding' })).toHaveAttribute(
118
+ 'href',
119
+ 'https://chat.example.com/c/onboarding',
120
+ )
121
+ })
122
+ })
@@ -0,0 +1,147 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import '@testing-library/jest-dom/vitest'
4
+
5
+ import { UiSettingsContext } from '~/context/UiSettingsContext'
6
+ import { AppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
7
+ import type { AppCatalogContextIface } from '~/modules/appCatalog/context/AppCatalogContext'
8
+
9
+ // MarkdownLink asks the router for the current path; no router is mounted here.
10
+ vi.mock('@tanstack/react-router', () => ({
11
+ useRouterState: vi.fn(() => '/'),
12
+ Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
13
+ <a {...rest}>{children}</a>
14
+ ),
15
+ }))
16
+
17
+ const { MarkdownText } =
18
+ await import('~/modules/appCatalog/ui/components/MarkdownText')
19
+
20
+ const TEMPLATE = 'https://chat.example.com/channels/{name}'
21
+
22
+ const ctx = {
23
+ resources: [],
24
+ isLoadingApps: false,
25
+ tagsDefinitions: [],
26
+ approvalMethods: [],
27
+ persons: [],
28
+ groups: [],
29
+ } satisfies AppCatalogContextIface
30
+
31
+ // `null` = no template configured (an explicit `undefined` would hit the default).
32
+ function renderMarkdown(text: string, template: string | null = TEMPLATE) {
33
+ return render(
34
+ <UiSettingsContext
35
+ value={{ chatChannelUrlTemplate: template ?? undefined }}
36
+ >
37
+ <AppCatalogContext value={ctx}>
38
+ <MarkdownText>{text}</MarkdownText>
39
+ </AppCatalogContext>
40
+ </UiSettingsContext>,
41
+ )
42
+ }
43
+
44
+ // A bare channel mention such as "Slack #swaggerhub" used to render as plain
45
+ // text, so the reader had to search the workspace by hand.
46
+ describe('MarkdownText — chat channel mentions', () => {
47
+ it('turns a #channel mention into a new-tab link built from the template', () => {
48
+ renderMarkdown('For questions, ask in Slack #swaggerhub channel.')
49
+
50
+ const link = screen.getByRole('link', { name: '#swaggerhub' })
51
+ expect(link).toHaveAttribute(
52
+ 'href',
53
+ 'https://chat.example.com/channels/swaggerhub',
54
+ )
55
+ expect(link).toHaveAttribute('target', '_blank')
56
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
57
+ expect(screen.getByText(/For questions, ask in Slack/)).toBeInTheDocument()
58
+ })
59
+
60
+ it('accepts underscores, dots and digits inside a name', () => {
61
+ renderMarkdown('Ask #sage_mcp or #eng-ux-billing-bsp or #team.v2')
62
+
63
+ expect(screen.getByRole('link', { name: '#sage_mcp' })).toHaveAttribute(
64
+ 'href',
65
+ 'https://chat.example.com/channels/sage_mcp',
66
+ )
67
+ expect(
68
+ screen.getByRole('link', { name: '#eng-ux-billing-bsp' }),
69
+ ).toBeInTheDocument()
70
+ expect(screen.getByRole('link', { name: '#team.v2' })).toHaveAttribute(
71
+ 'href',
72
+ 'https://chat.example.com/channels/team.v2',
73
+ )
74
+ })
75
+
76
+ it('leaves the trailing sentence punctuation outside the link', () => {
77
+ renderMarkdown('Join #env-hopper.')
78
+
79
+ expect(screen.getByRole('link', { name: '#env-hopper' })).toHaveAttribute(
80
+ 'href',
81
+ 'https://chat.example.com/channels/env-hopper',
82
+ )
83
+ })
84
+
85
+ it('does not link digits-only mentions such as issue numbers', () => {
86
+ renderMarkdown('Fixed in #123 last week')
87
+
88
+ expect(screen.queryByRole('link')).toBeNull()
89
+ expect(screen.getByText('Fixed in #123 last week')).toBeInTheDocument()
90
+ })
91
+
92
+ it('does not link a # glued to the preceding word (URL fragments)', () => {
93
+ renderMarkdown('open sheet?gid=0#gid=0 and reload')
94
+
95
+ expect(screen.queryByRole('link')).toBeNull()
96
+ })
97
+
98
+ it('does not link mentions that continue into upper-case letters', () => {
99
+ renderMarkdown('see #fooBar for details')
100
+
101
+ expect(screen.queryByRole('link')).toBeNull()
102
+ })
103
+
104
+ it('leaves inline code untouched', () => {
105
+ renderMarkdown('run `#not-a-link` here')
106
+
107
+ expect(screen.queryByRole('link')).toBeNull()
108
+ expect(screen.getByText('#not-a-link').tagName).toBe('CODE')
109
+ })
110
+
111
+ it('leaves existing markdown links and their fragments untouched', () => {
112
+ renderMarkdown('[x](https://a/b#frag) and #real')
113
+
114
+ expect(screen.getByRole('link', { name: 'x' })).toHaveAttribute(
115
+ 'href',
116
+ 'https://a/b#frag',
117
+ )
118
+ expect(screen.getByRole('link', { name: '#real' })).toHaveAttribute(
119
+ 'href',
120
+ 'https://chat.example.com/channels/real',
121
+ )
122
+ })
123
+
124
+ it('renders plain text when no template is configured', () => {
125
+ renderMarkdown('ask in Slack #swaggerhub channel', null)
126
+
127
+ expect(screen.queryByRole('link')).toBeNull()
128
+ expect(
129
+ screen.getByText('ask in Slack #swaggerhub channel'),
130
+ ).toBeInTheDocument()
131
+ })
132
+ })
133
+
134
+ describe('MarkdownText — wrapper element', () => {
135
+ it('adds a wrapper only when a className is given', () => {
136
+ const { container: bare } = renderMarkdown('hello')
137
+ expect(bare.firstElementChild?.tagName).toBe('P')
138
+
139
+ const { container: wrapped } = render(
140
+ <AppCatalogContext value={ctx}>
141
+ <MarkdownText className="prose">hello</MarkdownText>
142
+ </AppCatalogContext>,
143
+ )
144
+ expect(wrapped.firstElementChild?.tagName).toBe('SPAN')
145
+ expect(wrapped.firstElementChild).toHaveClass('prose')
146
+ })
147
+ })
@@ -5,7 +5,6 @@ import type {
5
5
  } from '@igstack/app-catalog-backend-core'
6
6
  import { Bot, Check, Copy, ExternalLink, Settings, Users } from 'lucide-react'
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
- import ReactMarkdown from 'react-markdown'
9
8
  import { Button } from '~/ui/button'
10
9
  import {
11
10
  Accordion,
@@ -21,7 +20,7 @@ import {
21
20
  TableHeader,
22
21
  TableRow,
23
22
  } from '~/ui/table'
24
- import { MarkdownLink } from './MarkdownText'
23
+ import { MarkdownText } from './MarkdownText'
25
24
  import { PersonOrGroupBadge } from './PersonBadge'
26
25
 
27
26
  // Constants
@@ -265,9 +264,7 @@ export function AccessRequestSection({
265
264
  <div className="text-sm inline-flex items-center gap-2">
266
265
  <span className="whitespace-nowrap shrink-0">Request Prompt:</span>
267
266
  <span className="prose prose-sm inline [&>*]:inline [&>*]:m-0">
268
- <ReactMarkdown components={{ a: MarkdownLink }}>
269
- {accessRequest.requestPrompt}
270
- </ReactMarkdown>
267
+ <MarkdownText>{accessRequest.requestPrompt}</MarkdownText>
271
268
  </span>
272
269
  <CopyButton
273
270
  onCopy={handleCopyPrompt}
@@ -281,9 +278,7 @@ export function AccessRequestSection({
281
278
  {/* Comments */}
282
279
  {accessRequest.comments && (
283
280
  <div className="text-sm text-muted-foreground prose prose-sm max-w-none">
284
- <ReactMarkdown components={{ a: MarkdownLink }}>
285
- {accessRequest.comments}
286
- </ReactMarkdown>
281
+ <MarkdownText>{accessRequest.comments}</MarkdownText>
287
282
  </div>
288
283
  )}
289
284
 
@@ -366,9 +361,9 @@ export function AccessRequestSection({
366
361
  </span>
367
362
  </div>
368
363
  <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">
369
- <ReactMarkdown components={{ a: MarkdownLink }}>
364
+ <MarkdownText>
370
365
  {accessRequest.postApprovalInstructions}
371
- </ReactMarkdown>
366
+ </MarkdownText>
372
367
  </div>
373
368
  </div>
374
369
  ) : (
@@ -383,9 +378,9 @@ export function AccessRequestSection({
383
378
  </AccordionTrigger>
384
379
  <AccordionContent className="pb-3">
385
380
  <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">
386
- <ReactMarkdown components={{ a: MarkdownLink }}>
381
+ <MarkdownText>
387
382
  {accessRequest.postApprovalInstructions}
388
- </ReactMarkdown>
383
+ </MarkdownText>
389
384
  </div>
390
385
  </AccordionContent>
391
386
  </AccordionItem>
@@ -1,7 +1,9 @@
1
1
  import { Link, useRouterState } from '@tanstack/react-router'
2
2
  import type React from 'react'
3
3
  import ReactMarkdown from 'react-markdown'
4
+ import { useUiSettings } from '~/context/UiSettingsContext'
4
5
  import { useAppCatalogContext } from '../../context/AppCatalogContext'
6
+ import { remarkChatChannels } from './remarkChatChannels'
5
7
 
6
8
  /** Match a relative internal catalog link: `/app/<slug>` (slug only, no extra path). */
7
9
  const INTERNAL_APP_LINK = /^\/app\/([^/?#]+)$/
@@ -68,7 +70,13 @@ export const MarkdownLink = ({
68
70
 
69
71
  /**
70
72
  * Renders user-facing catalog text (descriptions, comments, prompts) as
71
- * markdown so links become clickable. Links use {@link MarkdownLink}.
73
+ * markdown so links become clickable. Links use {@link MarkdownLink}; bare
74
+ * chat-channel mentions (`#name`) become links when the consuming app sets
75
+ * `UiSettings.chatChannelUrlTemplate`. Every catalog text field must render
76
+ * through this component so all of them link identically.
77
+ *
78
+ * Without a `className` no wrapper element is emitted, so it drops into an
79
+ * existing `prose` container without changing the DOM.
72
80
  */
73
81
  export function MarkdownText({
74
82
  children,
@@ -77,9 +85,18 @@ export function MarkdownText({
77
85
  children: string
78
86
  className?: string
79
87
  }) {
80
- return (
81
- <span className={className}>
82
- <ReactMarkdown components={{ a: MarkdownLink }}>{children}</ReactMarkdown>
83
- </span>
88
+ const { chatChannelUrlTemplate } = useUiSettings()
89
+ const markdown = (
90
+ <ReactMarkdown
91
+ components={{ a: MarkdownLink }}
92
+ remarkPlugins={
93
+ chatChannelUrlTemplate
94
+ ? [[remarkChatChannels, chatChannelUrlTemplate]]
95
+ : []
96
+ }
97
+ >
98
+ {children}
99
+ </ReactMarkdown>
84
100
  )
101
+ return className ? <span className={className}>{markdown}</span> : markdown
85
102
  }
@@ -5,7 +5,7 @@ import type {
5
5
  } from '@igstack/app-catalog-backend-core'
6
6
  import { Bot, ExternalLinkIcon, Settings, Users } from 'lucide-react'
7
7
  import { useState } from 'react'
8
- import ReactMarkdown from 'react-markdown'
8
+ import { MarkdownText } from './MarkdownText'
9
9
  import { Badge } from '~/ui/badge'
10
10
  import {
11
11
  Table,
@@ -113,7 +113,7 @@ function TierAccessDetail({
113
113
  <div className="pl-5 space-y-1.5 text-xs">
114
114
  {accessRequest.comments && (
115
115
  <div className="text-muted-foreground prose prose-xs max-w-none">
116
- <ReactMarkdown>{accessRequest.comments}</ReactMarkdown>
116
+ <MarkdownText>{accessRequest.comments}</MarkdownText>
117
117
  </div>
118
118
  )}
119
119
  {accessRequest.urls && accessRequest.urls.length > 0 && (
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Remark plugin: turns bare chat-channel mentions (`#swaggerhub`) in markdown
3
+ * text into links built from a URL template (`{name}` placeholder), so an
4
+ * author can write "ask in Slack #swaggerhub" without hand-writing the URL.
5
+ *
6
+ * Only `text` nodes are rewritten; existing links, link references and code
7
+ * are never touched. A mention is `#` preceded by start of text, whitespace or
8
+ * `(`, followed by a name in the common channel grammar (lower-case letters,
9
+ * digits, `.`, `_`, `-`; starts and ends with a letter or digit; max 80) and
10
+ * not glued to further word characters. Digits-only (`#123`, an issue number)
11
+ * and mid-word `#` (URL fragments like `?gid=0#gid=0`) are not mentions.
12
+ */
13
+
14
+ type MdNode = {
15
+ type: string
16
+ value?: string
17
+ url?: string
18
+ children?: MdNode[]
19
+ }
20
+
21
+ const MENTION =
22
+ /(^|[\s(])#([a-z0-9](?:[a-z0-9._-]{0,78}[a-z0-9])?)(?![A-Za-z0-9_-])/g
23
+
24
+ /** Node types whose text must stay verbatim. */
25
+ const KEEP_VERBATIM = new Set(['link', 'linkReference', 'inlineCode', 'code'])
26
+
27
+ export function remarkChatChannels(template: string) {
28
+ const linkTo = (name: string): MdNode => ({
29
+ type: 'link',
30
+ url: template.replace('{name}', encodeURIComponent(name)),
31
+ children: [{ type: 'text', value: `#${name}` }],
32
+ })
33
+
34
+ const splitText = (value: string): MdNode[] => {
35
+ const out: MdNode[] = []
36
+ let last = 0
37
+ for (const m of value.matchAll(MENTION)) {
38
+ const [, lead = '', name = ''] = m
39
+ if (/^\d+$/.test(name)) continue
40
+ const start = m.index + lead.length
41
+ out.push({ type: 'text', value: value.slice(last, start) }, linkTo(name))
42
+ last = start + name.length + 1
43
+ }
44
+ if (out.length === 0) return [{ type: 'text', value }]
45
+ out.push({ type: 'text', value: value.slice(last) })
46
+ return out.filter((n) => n.type !== 'text' || n.value)
47
+ }
48
+
49
+ const visit = (node: MdNode): void => {
50
+ if (!node.children) return
51
+ node.children = node.children.flatMap((child) => {
52
+ if (KEEP_VERBATIM.has(child.type)) return [child]
53
+ if (child.type !== 'text') {
54
+ visit(child)
55
+ return [child]
56
+ }
57
+ return splitText(child.value ?? '')
58
+ })
59
+ }
60
+
61
+ return visit
62
+ }
@@ -34,6 +34,13 @@ export interface UiSettings {
34
34
  }
35
35
  /** Frontend build identifier baked at build time (e.g., pipeline ID via VITE env var) */
36
36
  frontendBuildId?: string
37
+ /**
38
+ * URL template for bare chat-channel mentions (`#channel-name`) in catalog
39
+ * text; `{name}` is replaced by the channel name, e.g.
40
+ * `https://<workspace>.slack.com/channels/{name}`. Unset = mentions stay
41
+ * plain text. Kept generic so the core knows no vendor or workspace.
42
+ */
43
+ chatChannelUrlTemplate?: string
37
44
  /** Area sections on the home view: icons and the day-to-day fold. */
38
45
  areas?: AreasSettings
39
46
  /** PWA auto-update configuration (idle timeout, check interval, debug) */