@igstack/app-catalog-frontend-core 6.0.0 → 6.1.0-alpha-20260924225343
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.
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +52 -31
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.d.ts +3 -1
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js +6 -1
- package/dist/esm/modules/appCatalog/ui/components/MarkdownText.js.map +1 -1
- package/package.json +5 -6
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +74 -1
- package/src/__tests__/modules/appCatalog/ui/components/MarkdownText.test.tsx +68 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +82 -49
- package/src/modules/appCatalog/ui/components/MarkdownText.tsx +16 -3
- package/dist/esm/types/slateTypes.d.ts +0 -28
- package/src/types/slateTypes.ts +0 -22
|
@@ -19,6 +19,39 @@ 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
|
+
}
|
|
22
55
|
function useCopyToClipboard() {
|
|
23
56
|
const [copiedId, setCopiedId] = useState(null);
|
|
24
57
|
const [error, setError] = useState(null);
|
|
@@ -168,40 +201,11 @@ function AccessRequestSection({
|
|
|
168
201
|
)
|
|
169
202
|
] }),
|
|
170
203
|
accessRequest.comments && /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground prose prose-sm max-w-none", children: /* @__PURE__ */ jsx(MarkdownText, { children: accessRequest.comments }) }),
|
|
171
|
-
accessRequest.roles && accessRequest.roles.length > 0 && /* @__PURE__ */
|
|
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
|
-
] }),
|
|
204
|
+
accessRequest.roles && accessRequest.roles.length > 0 && /* @__PURE__ */ jsx(RolesTable, { roles: accessRequest.roles }),
|
|
184
205
|
accessRequest.approverSlugs && accessRequest.approverSlugs.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
|
|
185
206
|
/* @__PURE__ */ jsx("h4", { className: "mb-2 text-sm font-medium", children: "Approvers" }),
|
|
186
207
|
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: accessRequest.approverSlugs.map((slug) => /* @__PURE__ */ jsx(PersonOrGroupBadge, { slug }, slug)) })
|
|
187
208
|
] }),
|
|
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
|
-
] }),
|
|
205
209
|
accessRequest.postApprovalInstructions && (isTwoStep ? (
|
|
206
210
|
// Two-step apps: show Step 2 prominently, always expanded
|
|
207
211
|
/* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
@@ -224,7 +228,24 @@ function AccessRequestSection({
|
|
|
224
228
|
]
|
|
225
229
|
}
|
|
226
230
|
) })
|
|
227
|
-
))
|
|
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
|
+
] })
|
|
228
249
|
] });
|
|
229
250
|
}
|
|
230
251
|
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/**\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
|
+
{"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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"}
|
|
@@ -19,7 +19,9 @@ 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
|
|
22
|
+
* GitHub-flavoured markdown so links become clickable — bare URLs included
|
|
23
|
+
* (autolink literals), plus tables and strikethrough. Links use
|
|
24
|
+
* {@link MarkdownLink}; bare
|
|
23
25
|
* chat-channel mentions (`#name`) become links when the consuming app sets
|
|
24
26
|
* `UiSettings.chatChannelUrlTemplate`. Every catalog text field must render
|
|
25
27
|
* through this component so all of them link identically.
|
|
@@ -1,9 +1,14 @@
|
|
|
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 remarkGfm from "remark-gfm";
|
|
4
5
|
import { useUiSettings } from "../../../../context/UiSettingsContext.js";
|
|
5
6
|
import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
|
|
6
7
|
import { remarkChatChannels } from "./remarkChatChannels.js";
|
|
8
|
+
const GFM = [
|
|
9
|
+
remarkGfm,
|
|
10
|
+
{ singleTilde: false }
|
|
11
|
+
];
|
|
7
12
|
const INTERNAL_APP_LINK = /^\/app\/([^/?#]+)$/;
|
|
8
13
|
const MarkdownLink = ({
|
|
9
14
|
href,
|
|
@@ -56,7 +61,7 @@ function MarkdownText({
|
|
|
56
61
|
ReactMarkdown,
|
|
57
62
|
{
|
|
58
63
|
components: { a: MarkdownLink },
|
|
59
|
-
remarkPlugins: chatChannelUrlTemplate ? [[remarkChatChannels, chatChannelUrlTemplate]] : [],
|
|
64
|
+
remarkPlugins: chatChannelUrlTemplate ? [GFM, [remarkChatChannels, chatChannelUrlTemplate]] : [GFM],
|
|
60
65
|
children
|
|
61
66
|
}
|
|
62
67
|
);
|
|
@@ -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 { 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":"
|
|
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 type { Options } from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { useUiSettings } from '~/context/UiSettingsContext'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { remarkChatChannels } from './remarkChatChannels'\n\n/**\n * Single `~` stays literal: catalog text uses it for \"approximately\" (`~5 min`),\n * which default GFM would turn into strikethrough. `~~x~~` still strikes.\n */\nconst GFM: NonNullable<Options['remarkPlugins']>[number] = [\n remarkGfm,\n { singleTilde: false },\n]\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 * GitHub-flavoured markdown so links become clickable — bare URLs included\n * (autolink literals), plus tables and strikethrough. Links use\n * {@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 ? [GFM, [remarkChatChannels, chatChannelUrlTemplate]]\n : [GFM]\n }\n >\n {children}\n </ReactMarkdown>\n )\n return className ? <span className={className}>{markdown}</span> : markdown\n}\n"],"names":[],"mappings":";;;;;;;AAaA,MAAM,MAAqD;AAAA,EACzD;AAAA,EACA,EAAE,aAAa,MAAA;AACjB;AAGA,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;AAcO,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,KAAK,CAAC,oBAAoB,sBAAsB,CAAC,IAClD,CAAC,GAAG;AAAA,MAGT;AAAA,IAAA;AAAA,EAAA;AAGL,SAAO,YAAY,oBAAC,QAAA,EAAK,WAAuB,oBAAS,IAAU;AACrE;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0-alpha-20260924225343",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -105,15 +105,14 @@
|
|
|
105
105
|
"react-markdown": "^10.1.0",
|
|
106
106
|
"react-resizable-panels": "^4.6.5",
|
|
107
107
|
"react-router-dom": "7.0.2",
|
|
108
|
-
"
|
|
109
|
-
"slate-react": "^0.117.1",
|
|
108
|
+
"remark-gfm": "^4.0.1",
|
|
110
109
|
"sleep-promise": "^9.1.0",
|
|
111
110
|
"superjson": "^2.2.3",
|
|
112
111
|
"tailwind-merge": "^3.3.1",
|
|
113
112
|
"tailwindcss": "^4.1.7",
|
|
114
113
|
"tiptap-markdown": "^0.8.0",
|
|
115
114
|
"tslib": "^2.3.0",
|
|
116
|
-
"@igstack/app-catalog-shared-core": "6.
|
|
115
|
+
"@igstack/app-catalog-shared-core": "6.1.0-alpha-20260924225343"
|
|
117
116
|
},
|
|
118
117
|
"devDependencies": {
|
|
119
118
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -140,7 +139,7 @@
|
|
|
140
139
|
"vite-plugin-static-copy": "^3.1.4",
|
|
141
140
|
"vite-plugin-svgr": "^4.2.0",
|
|
142
141
|
"vitest": "^4.1.2",
|
|
143
|
-
"@igstack/app-catalog-backend-core": "6.
|
|
142
|
+
"@igstack/app-catalog-backend-core": "6.1.0-alpha-20260924225343"
|
|
144
143
|
},
|
|
145
144
|
"peerDependencies": {
|
|
146
145
|
"react": "19.1.2",
|
|
@@ -149,7 +148,7 @@
|
|
|
149
148
|
"vite": "^6.3.5",
|
|
150
149
|
"vite-plugin-svgr": "^4.2.0"
|
|
151
150
|
},
|
|
152
|
-
"gitHead": "
|
|
151
|
+
"gitHead": "f545847ceb0218aa293c717849efdc975e1ec122",
|
|
153
152
|
"scripts": {
|
|
154
153
|
"build": "vite build",
|
|
155
154
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { fireEvent, 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,6 +54,40 @@ 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
|
+
|
|
57
91
|
it('falls back to an em-dash when a role has no description', () => {
|
|
58
92
|
render(
|
|
59
93
|
<AccessRequestSection
|
|
@@ -72,6 +106,45 @@ describe('AccessRequestSection — roles table', () => {
|
|
|
72
106
|
})
|
|
73
107
|
})
|
|
74
108
|
|
|
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
|
+
|
|
75
148
|
// The channel mentions live in the access-request text fields, which this
|
|
76
149
|
// section renders itself — so they must go through the same markdown path as
|
|
77
150
|
// the description or the links never appear where the data actually is.
|
|
@@ -131,6 +131,74 @@ describe('MarkdownText — chat channel mentions', () => {
|
|
|
131
131
|
})
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
// Catalog text often pastes a URL without link syntax; it used to render as
|
|
135
|
+
// plain, unclickable text.
|
|
136
|
+
describe('MarkdownText — bare URLs', () => {
|
|
137
|
+
it('turns a bare URL into a new-tab link', () => {
|
|
138
|
+
renderMarkdown('Docs: https://a.example/x', null)
|
|
139
|
+
|
|
140
|
+
const link = screen.getByRole('link', { name: 'https://a.example/x' })
|
|
141
|
+
expect(link).toHaveAttribute('href', 'https://a.example/x')
|
|
142
|
+
expect(link).toHaveAttribute('target', '_blank')
|
|
143
|
+
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('leaves trailing sentence punctuation outside the link', () => {
|
|
147
|
+
renderMarkdown(
|
|
148
|
+
'See https://a.example/page. Or (https://a.example/q).',
|
|
149
|
+
null,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
expect(
|
|
153
|
+
screen.getByRole('link', { name: 'https://a.example/page' }),
|
|
154
|
+
).toHaveAttribute('href', 'https://a.example/page')
|
|
155
|
+
expect(
|
|
156
|
+
screen.getByRole('link', { name: 'https://a.example/q' }),
|
|
157
|
+
).toHaveAttribute('href', 'https://a.example/q')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('leaves an explicit markdown link unchanged', () => {
|
|
161
|
+
renderMarkdown('[the docs](https://a.example/d)', null)
|
|
162
|
+
|
|
163
|
+
expect(screen.getByRole('link', { name: 'the docs' })).toHaveAttribute(
|
|
164
|
+
'href',
|
|
165
|
+
'https://a.example/d',
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('keeps a URL inside inline code as code', () => {
|
|
170
|
+
renderMarkdown('run `curl https://a.example/x`', null)
|
|
171
|
+
|
|
172
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
173
|
+
expect(screen.getByText('curl https://a.example/x').tagName).toBe('CODE')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not treat a URL fragment as a chat channel', () => {
|
|
177
|
+
renderMarkdown('open https://a.example/sheet#gid and ask #team')
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
screen.getByRole('link', { name: 'https://a.example/sheet#gid' }),
|
|
181
|
+
).toHaveAttribute('href', 'https://a.example/sheet#gid')
|
|
182
|
+
expect(screen.getByRole('link', { name: '#team' })).toHaveAttribute(
|
|
183
|
+
'href',
|
|
184
|
+
'https://chat.example.com/channels/team',
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('renders GFM tables and strikethrough', () => {
|
|
189
|
+
renderMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n\n~~old~~', null)
|
|
190
|
+
|
|
191
|
+
expect(screen.getByRole('table')).toBeInTheDocument()
|
|
192
|
+
expect(screen.getByText('old').tagName).toBe('DEL')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('keeps a single tilde literal ("approximately")', () => {
|
|
196
|
+
renderMarkdown('takes ~5 min, up to ~10 min', null)
|
|
197
|
+
|
|
198
|
+
expect(screen.getByText('takes ~5 min, up to ~10 min').tagName).toBe('P')
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
134
202
|
describe('MarkdownText — wrapper element', () => {
|
|
135
203
|
it('adds a wrapper only when a className is given', () => {
|
|
136
204
|
const { container: bare } = renderMarkdown('hello')
|
|
@@ -47,6 +47,65 @@ 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
|
+
|
|
50
109
|
/**
|
|
51
110
|
* Custom hook for handling copy-to-clipboard functionality with feedback
|
|
52
111
|
* Includes proper cleanup to prevent memory leaks
|
|
@@ -282,35 +341,8 @@ export function AccessRequestSection({
|
|
|
282
341
|
</div>
|
|
283
342
|
)}
|
|
284
343
|
|
|
285
|
-
{/* Roles Table */}
|
|
286
344
|
{accessRequest.roles && accessRequest.roles.length > 0 && (
|
|
287
|
-
<
|
|
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>
|
|
345
|
+
<RolesTable roles={accessRequest.roles} />
|
|
314
346
|
)}
|
|
315
347
|
|
|
316
348
|
{/* Approvers */}
|
|
@@ -326,27 +358,6 @@ export function AccessRequestSection({
|
|
|
326
358
|
</div>
|
|
327
359
|
)}
|
|
328
360
|
|
|
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
|
-
|
|
350
361
|
{/* Post-Approval Instructions */}
|
|
351
362
|
{accessRequest.postApprovalInstructions &&
|
|
352
363
|
(isTwoStep ? (
|
|
@@ -386,6 +397,28 @@ export function AccessRequestSection({
|
|
|
386
397
|
</AccordionItem>
|
|
387
398
|
</Accordion>
|
|
388
399
|
))}
|
|
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
|
+
)}
|
|
389
422
|
</div>
|
|
390
423
|
)
|
|
391
424
|
}
|
|
@@ -1,10 +1,21 @@
|
|
|
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 type { Options } from 'react-markdown'
|
|
5
|
+
import remarkGfm from 'remark-gfm'
|
|
4
6
|
import { useUiSettings } from '~/context/UiSettingsContext'
|
|
5
7
|
import { useAppCatalogContext } from '../../context/AppCatalogContext'
|
|
6
8
|
import { remarkChatChannels } from './remarkChatChannels'
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Single `~` stays literal: catalog text uses it for "approximately" (`~5 min`),
|
|
12
|
+
* which default GFM would turn into strikethrough. `~~x~~` still strikes.
|
|
13
|
+
*/
|
|
14
|
+
const GFM: NonNullable<Options['remarkPlugins']>[number] = [
|
|
15
|
+
remarkGfm,
|
|
16
|
+
{ singleTilde: false },
|
|
17
|
+
]
|
|
18
|
+
|
|
8
19
|
/** Match a relative internal catalog link: `/app/<slug>` (slug only, no extra path). */
|
|
9
20
|
const INTERNAL_APP_LINK = /^\/app\/([^/?#]+)$/
|
|
10
21
|
|
|
@@ -70,7 +81,9 @@ export const MarkdownLink = ({
|
|
|
70
81
|
|
|
71
82
|
/**
|
|
72
83
|
* Renders user-facing catalog text (descriptions, comments, prompts) as
|
|
73
|
-
* markdown so links become clickable
|
|
84
|
+
* GitHub-flavoured markdown so links become clickable — bare URLs included
|
|
85
|
+
* (autolink literals), plus tables and strikethrough. Links use
|
|
86
|
+
* {@link MarkdownLink}; bare
|
|
74
87
|
* chat-channel mentions (`#name`) become links when the consuming app sets
|
|
75
88
|
* `UiSettings.chatChannelUrlTemplate`. Every catalog text field must render
|
|
76
89
|
* through this component so all of them link identically.
|
|
@@ -91,8 +104,8 @@ export function MarkdownText({
|
|
|
91
104
|
components={{ a: MarkdownLink }}
|
|
92
105
|
remarkPlugins={
|
|
93
106
|
chatChannelUrlTemplate
|
|
94
|
-
? [[remarkChatChannels, chatChannelUrlTemplate]]
|
|
95
|
-
: []
|
|
107
|
+
? [GFM, [remarkChatChannels, chatChannelUrlTemplate]]
|
|
108
|
+
: [GFM]
|
|
96
109
|
}
|
|
97
110
|
>
|
|
98
111
|
{children}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { BaseEditor } from 'slate';
|
|
2
|
-
import { ReactEditor } from 'slate-react';
|
|
3
|
-
type TagElement = {
|
|
4
|
-
type: 'tag';
|
|
5
|
-
children: CustomText[];
|
|
6
|
-
selected: boolean;
|
|
7
|
-
};
|
|
8
|
-
type InterTagElement = {
|
|
9
|
-
type: 'inter-tag';
|
|
10
|
-
children: CustomText[];
|
|
11
|
-
};
|
|
12
|
-
type TagContainer = {
|
|
13
|
-
type: 'tag-container';
|
|
14
|
-
children: CustomText[];
|
|
15
|
-
};
|
|
16
|
-
type CustomText = {
|
|
17
|
-
text: string;
|
|
18
|
-
};
|
|
19
|
-
export type CustomElement = TagElement | TagContainer | InterTagElement;
|
|
20
|
-
export type CustomEditor = BaseEditor & ReactEditor;
|
|
21
|
-
declare module 'slate' {
|
|
22
|
-
interface CustomTypes {
|
|
23
|
-
Editor: CustomEditor;
|
|
24
|
-
Element: CustomElement;
|
|
25
|
-
Text: CustomText;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
export {};
|
package/src/types/slateTypes.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { BaseEditor } from 'slate'
|
|
2
|
-
import type { ReactEditor } from 'slate-react'
|
|
3
|
-
|
|
4
|
-
type TagElement = {
|
|
5
|
-
type: 'tag'
|
|
6
|
-
children: CustomText[]
|
|
7
|
-
selected: boolean
|
|
8
|
-
}
|
|
9
|
-
type InterTagElement = { type: 'inter-tag'; children: CustomText[] }
|
|
10
|
-
type TagContainer = { type: 'tag-container'; children: CustomText[] }
|
|
11
|
-
type CustomText = { text: string }
|
|
12
|
-
|
|
13
|
-
export type CustomElement = TagElement | TagContainer | InterTagElement
|
|
14
|
-
export type CustomEditor = BaseEditor & ReactEditor
|
|
15
|
-
|
|
16
|
-
declare module 'slate' {
|
|
17
|
-
interface CustomTypes {
|
|
18
|
-
Editor: CustomEditor
|
|
19
|
-
Element: CustomElement
|
|
20
|
-
Text: CustomText
|
|
21
|
-
}
|
|
22
|
-
}
|