@igstack/app-catalog-frontend-core 6.8.3 → 6.9.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.
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +0 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +22 -12
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +0 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/dismiss/DismissStack.d.ts +9 -0
- package/dist/esm/modules/dismiss/DismissStack.js +26 -7
- package/dist/esm/modules/dismiss/DismissStack.js.map +1 -1
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +16 -1
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
- package/dist/esm/modules/extensions/index.d.ts +19 -3
- package/dist/esm/modules/extensions/index.js +10 -4
- package/dist/esm/modules/extensions/index.js.map +1 -1
- package/dist/esm/modules/extensions/types.d.ts +1 -0
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +41 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +34 -0
- package/src/__tests__/modules/dismiss/DismissStack.test.tsx +91 -1
- package/src/index.tsx +2 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +60 -36
- package/src/modules/dismiss/DismissStack.tsx +52 -9
- package/src/modules/extensions/features/resourceDetail.ts +17 -1
- package/src/modules/extensions/index.ts +37 -2
- package/src/modules/extensions/types.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type {\n Resource,\n SourceReference,\n} from '@igstack/app-catalog-backend-core'\nimport { useSearch } from '@tanstack/react-router'\nimport { CalendarPlus, Clock, ExternalLink, Plus, Trash2 } from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport { QuickJumpBar, hasQuickJumps } from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { SourcePulse } from '../components/SourcePulse'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { ResourceDetailAccessActions } from '~/modules/extensions'\nimport { FeedbackSection } from './FeedbackSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'\nimport { AppDoor } from './AppDoor'\nimport { DetailTabs } from './DetailTabs'\nimport type { DetailTab } from './DetailTabs'\n\n/** Tab ids. Public — they land in `?tab=`, so renaming one breaks links. */\nconst TAB = {\n overview: 'overview',\n access: 'access',\n resources: 'resources',\n documentation: 'documentation',\n notes: 'notes',\n} as const\n\n/** A labelled block inside a tab. One register for all of them. */\nfunction Section({\n title,\n hint,\n first,\n children,\n}: {\n title: string\n hint?: string\n /** Drops the top margin for the first block in a panel. */\n first?: boolean\n children: React.ReactNode\n}) {\n return (\n <div className={first ? '' : 'mt-7'}>\n <h3 className=\"section-label mb-1.5\">{title}</h3>\n {hint && <p className=\"mb-2 text-xs text-muted-foreground\">{hint}</p>}\n {children}\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n /**\n * Set by the band's \"Suggest a change\", which has to do two things the button\n * cannot do alone: bring the notes tab forward, and open the composer inside it.\n * Held here because the band and the panel are siblings — neither can reach the\n * other, and routing it through the URL would put a transient intent in a\n * shareable link.\n *\n * A count, not a flag: the composer closes itself on send, so asking a second\n * time has to be distinguishable from the first ask still being set. As a\n * boolean the second click changed nothing and the button was dead for the rest\n * of the card's life.\n */\n const [composeRequests, setComposeRequests] = React.useState(0)\n\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n const { state: filterState } = useAppCatalogFilters()\n\n // Arriving with `?sub=` means the visitor picked one child out of a search —\n // so the card has to open ON that child's tab. Landing on Overview and\n // leaving them to find the tab is the bug this scenario exists to prevent.\n const search = useSearch({ strict: false })\n const subSlug = search.sub\n\n const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(':'))\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n // The list renders bare URLs (they are editable as text), so the pulse has to\n // be looked up rather than carried alongside. Absent until the autoupdate loop\n // has seen the source — a freshly added one simply gets no mark.\n const pulseByUrl = React.useMemo(() => {\n const byUrl = new Map<string, NonNullable<SourceReference['pulse']>>()\n for (const source of app.sources ?? []) {\n if (typeof source !== 'string' && source.pulse) {\n byUrl.set(source.url, source.pulse)\n }\n }\n return byUrl\n }, [app.sources])\n\n const children = React.useMemo(\n () => getChildResources(allResources, app.slug),\n [allResources, app.slug],\n )\n\n // `app` is whatever resource the card shows, a nested one included, so the\n // parent has to be looked up rather than assumed absent. A plugin uses its\n // presence to tell a sub-resource view from a top-level one.\n const parent = React.useMemo(\n () =>\n app.parentSlug\n ? allResources.find((r) => r.slug === app.parentSlug)\n : undefined,\n [allResources, app.parentSlug],\n )\n\n // What a plugin slot in the band receives. Memoised so a plugin that\n // memoises on its props is not defeated by a new object every render.\n const accessActionsCtx = React.useMemo(\n () => ({\n resource: app,\n parent,\n subResources: parent\n ? getChildResources(allResources, parent.slug)\n : children,\n user: user ? { email: user.email, isAdmin } : null,\n }),\n [app, parent, allResources, children, user, isAdmin],\n )\n\n // Enter: open screenshot gallery. Only reached on a card with no door — where\n // there is one it holds focus, and Enter there is the link's own.\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n const access = accessTabLabel(app, approvalMethods)\n\n const hasResourcesTab = children.length > 0 || (app.tiers?.length ?? 0) > 0\n const resourcesLabel = children.length\n ? (app.childrenLabel ?? 'Resources')\n : 'Environments'\n\n const tabs: DetailTab[] = [\n {\n id: TAB.overview,\n label: 'Overview',\n render: () => (\n <>\n <Section title=\"Description\" first>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </Section>\n\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <Section title={`Screenshots (${app.screenshotIds.length})`}>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </Section>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <Section title=\"Owner\" hint=\"Who is responsible for this resource\">\n <PersonBadge slug={app.ownerPersonSlug} />\n </Section>\n )}\n\n {/* Tags, minus the indexing machinery: `namespace:value` tags drive\n grouping, faceting and placement, are about half of all tag\n references, and say nothing about what an app is for. The colon is\n the test. Search still matches them — only the display drops them. */}\n {displayTags.length > 0 && (\n <Section title=\"Tags\">\n <div className=\"flex flex-wrap gap-2\">\n {displayTags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n\n {app.teams && app.teams.length > 0 && (\n <Section title=\"Teams\">\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n </>\n ),\n },\n {\n id: TAB.access,\n label: `Access — ${access.route}`,\n chip: access.chip,\n render: () => (\n <>\n {/* Two-step access: for a nested resource, show the parent-first\n prerequisite chain before this resource's own instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n </>\n ),\n },\n ...(hasResourcesTab\n ? [\n {\n id: TAB.resources,\n label: resourcesLabel,\n chip: children.length ? String(children.length) : undefined,\n render: () => (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <TierVariantsSection tiers={app.tiers} />\n )}\n {children.length > 0 && (\n <div className={app.tiers?.length ? 'mt-7' : ''}>\n {/* The active catalog search term seeds the filter, so a\n visitor who got here by searching something that matched\n a child sees that child rather than all of its siblings. */}\n <SubResourcesSection\n subResources={children}\n parentSlug={app.slug}\n parent={app}\n initialSearch={filterState.searchValue}\n childrenLabel={app.childrenLabel}\n />\n </div>\n )}\n </>\n ),\n } satisfies DetailTab,\n ]\n : []),\n {\n id: TAB.documentation,\n label: 'Documentation',\n render: () => (\n <>\n {app.links && app.links.length > 0 && (\n <Section title=\"Documentation\" first>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </Section>\n )}\n {/* Background: why the app exists at all. Deliberately quiet — a\n reader who came to get access has already passed everything they\n need, and history should not compete with the description. */}\n {app.background && (\n <Section title=\"Background\">\n <MarkdownText className=\"prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0\">\n {app.background}\n </MarkdownText>\n </Section>\n )}\n\n <Section title=\"Sources\" first={!app.background}>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n // `group` so hovering anywhere on the row fades that source's\n // track in — the mark sits in a right-hand column, and row\n // hover is what ties the two ends of a wide row together.\n <li\n key={index}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex min-w-0 items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n {pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n </li>\n ))}\n </ul>\n )}\n </Section>\n\n {/* These used to sit unlabelled in a 64px gap between two sections,\n which is what made that gap look like a mistake. Labelled, in\n their own block. \"Dates\" rather than \"Added / updated\" on purpose:\n a label that repeats the first word of every line below it adds\n nothing, and it made \"Added\"/\"Updated\" ambiguous to anything\n matching on text — including this card's own tests. */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <Section title=\"Dates\">\n <div className=\"flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check\n ran — entries predating the field have none, so fall\n back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n </Section>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-7\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"section-label mb-1\">AI Prompt</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"section-label mb-1\">AI Memory</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n </>\n ),\n },\n {\n id: TAB.notes,\n label: 'Notes & requests',\n render: () => (\n <FeedbackSection appSlug={app.slug} openComposer={composeRequests} />\n ),\n },\n ]\n\n // The hero band: identity, the way in, and the typed-id jumps. It does NOT\n // scroll away with a tab, so \"take me to the app\" is reachable from every one\n // of them.\n //\n // Deliberately holds nothing else. An owner/teams/freshness strip was drawn\n // and dropped: `ownerPersonSlug` is set on a tiny fraction of entries so the\n // owner slot would almost always be empty, teams already has its own block in\n // Overview, and on an entry with quick jumps the band had grown past 250px\n // before any content — on exactly the apps where people most want to type an\n // id and leave.\n const band = (\n <>\n <div className=\"flex flex-wrap items-center gap-x-7 gap-y-2\">\n <AppDoor app={app} onOpen={() => recordClick(app.slug)} />\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n {/* In the band, not at the foot of the notes panel.\n Down there it was invisible twice over: below the fold of a panel, and\n behind a tab nobody opens unless they already know what is in it — so\n the one affordance the feature exists for was the hardest thing on the\n card to find. Up here it is present on every tab, which is the whole\n reason the band does not scroll away.\n `mr-10` keeps it clear of the dialog's own close button at top-right. */}\n {/* Plugin actions sit beside \"Suggest a change\", sharing its reasoning:\n the band is mounted on every tab and never scrolls away. A slot in a\n tab panel would be invisible until a second click, and one inside\n SubResourceDetailPanel's `hasAnyAccess` gate would vanish for exactly\n the resources that need help most.\n Renders nothing when no plugin contributes, so the band keeps its\n current shape in the open-source build. */}\n <div className=\"ml-auto mr-10 flex items-center gap-2\">\n <ResourceDetailAccessActions {...accessActionsCtx} />\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={() => setComposeRequests((n) => n + 1)}\n >\n ✎ Suggest a change\n </Button>\n </div>\n </div>\n\n {app.nicknames && app.nicknames.length > 0 && (\n <p className=\"mt-2.5 text-xs text-muted-foreground\">\n Also known as {app.nicknames.join(', ')}\n </p>\n )}\n\n {isAdmin && (\n <div className=\"mt-3 flex flex-wrap items-center gap-x-6 gap-y-1\">\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">Slug:</span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </span>\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">URL:</span>\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n className=\"text-sm\"\n />\n </span>\n </div>\n )}\n\n {hasQuickJumps(app) && (\n <div className=\"mt-4\">\n {/* Named, where the bare control was not: \"or go straight to a\n record\" says what the field is for before you reach it. */}\n <p className=\"mb-1.5 text-xs text-muted-foreground\">\n Or go straight to a record:\n </p>\n <QuickJumpBar app={app} />\n </div>\n )}\n\n {/* Deprecation stays in the band, not in a tab: \"do not use this\"\n must not be something you have to go looking for. */}\n {app.deprecated &&\n (() => {\n const isDiscouraged = app.deprecated.type === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-4 rounded-lg border border-yellow-500/50 bg-yellow-50 p-4 dark:bg-yellow-950/20'\n : 'mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'section-label mb-1.5 text-yellow-700 dark:text-yellow-500'\n : 'section-label mb-1.5 text-destructive'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n </>\n )\n\n return (\n <>\n <div className=\"flex h-full flex-col\">\n <DetailTabs\n tabs={tabs}\n band={band}\n // Cancels the dialog's own padding so the tint reaches the card's\n // edges, then re-applies it inside.\n bandClassName=\"-mx-6 -mt-5 px-6 pt-5 sm:-mx-8 sm:-mt-7 sm:px-8 sm:pt-7\"\n // `?sub=` means a child was picked out of a search, so the card opens\n // on the tab that holds it rather than making them hunt for it.\n forcedTab={subSlug && hasResourcesTab ? TAB.resources : undefined}\n requestTab={composeRequests > 0 ? TAB.notes : undefined}\n requestNonce={composeRequests}\n />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a","_b"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type {\n Resource,\n SourceReference,\n} from '@igstack/app-catalog-backend-core'\nimport { useSearch } from '@tanstack/react-router'\nimport { CalendarPlus, Clock, ExternalLink, Plus, Trash2 } from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport { QuickJumpBar, hasQuickJumps } from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { SourcePulse } from '../components/SourcePulse'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { ResourceDetailAccessActions } from '~/modules/extensions'\nimport { FeedbackSection } from './FeedbackSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\nimport { accessTabLabel } from '~/modules/appCatalog/utils/accessTabLabel'\nimport { AppDoor } from './AppDoor'\nimport { DetailTabs } from './DetailTabs'\nimport type { DetailTab } from './DetailTabs'\n\n/** Tab ids. Public — they land in `?tab=`, so renaming one breaks links. */\nconst TAB = {\n overview: 'overview',\n access: 'access',\n resources: 'resources',\n documentation: 'documentation',\n notes: 'notes',\n} as const\n\n/** A labelled block inside a tab. One register for all of them. */\nfunction Section({\n title,\n hint,\n first,\n children,\n}: {\n title: string\n hint?: string\n /** Drops the top margin for the first block in a panel. */\n first?: boolean\n children: React.ReactNode\n}) {\n return (\n <div className={first ? '' : 'mt-7'}>\n <h3 className=\"section-label mb-1.5\">{title}</h3>\n {hint && <p className=\"mb-2 text-xs text-muted-foreground\">{hint}</p>}\n {children}\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n /**\n * Set by the band's \"Suggest a change\", which has to do two things the button\n * cannot do alone: bring the notes tab forward, and open the composer inside it.\n * Held here because the band and the panel are siblings — neither can reach the\n * other, and routing it through the URL would put a transient intent in a\n * shareable link.\n *\n * A count, not a flag: the composer closes itself on send, so asking a second\n * time has to be distinguishable from the first ask still being set. As a\n * boolean the second click changed nothing and the button was dead for the rest\n * of the card's life.\n */\n const [composeRequests, setComposeRequests] = React.useState(0)\n\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n const { state: filterState } = useAppCatalogFilters()\n\n // Arriving with `?sub=` means the visitor picked one child out of a search —\n // so the card has to open ON that child's tab. Landing on Overview and\n // leaving them to find the tab is the bug this scenario exists to prevent.\n const search = useSearch({ strict: false })\n const subSlug = search.sub\n\n const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(':'))\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n // The list renders bare URLs (they are editable as text), so the pulse has to\n // be looked up rather than carried alongside. Absent until the autoupdate loop\n // has seen the source — a freshly added one simply gets no mark.\n const pulseByUrl = React.useMemo(() => {\n const byUrl = new Map<string, NonNullable<SourceReference['pulse']>>()\n for (const source of app.sources ?? []) {\n if (typeof source !== 'string' && source.pulse) {\n byUrl.set(source.url, source.pulse)\n }\n }\n return byUrl\n }, [app.sources])\n\n const children = React.useMemo(\n () => getChildResources(allResources, app.slug),\n [allResources, app.slug],\n )\n\n // `app` is whatever resource the card shows, a nested one included, so the\n // parent has to be looked up rather than assumed absent. A plugin uses its\n // presence to tell a sub-resource view from a top-level one.\n const parent = React.useMemo(\n () =>\n app.parentSlug\n ? allResources.find((r) => r.slug === app.parentSlug)\n : undefined,\n [allResources, app.parentSlug],\n )\n\n // What a plugin slot in the band receives. Memoised so a plugin that\n // memoises on its props is not defeated by a new object every render.\n const accessActionsCtx = React.useMemo(\n () => ({\n resource: app,\n parent,\n subResources: parent\n ? getChildResources(allResources, parent.slug)\n : children,\n user: user ? { email: user.email, isAdmin } : null,\n }),\n [app, parent, allResources, children, user, isAdmin],\n )\n\n // Enter: open screenshot gallery. Only reached on a card with no door — where\n // there is one it holds focus, and Enter there is the link's own.\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n const access = accessTabLabel(app, approvalMethods)\n\n const hasResourcesTab = children.length > 0 || (app.tiers?.length ?? 0) > 0\n const resourcesLabel = children.length\n ? (app.childrenLabel ?? 'Resources')\n : 'Environments'\n\n const tabs: DetailTab[] = [\n {\n id: TAB.overview,\n label: 'Overview',\n render: () => (\n <>\n <Section title=\"Description\" first>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </Section>\n\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <Section title={`Screenshots (${app.screenshotIds.length})`}>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </Section>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <Section title=\"Owner\" hint=\"Who is responsible for this resource\">\n <PersonBadge slug={app.ownerPersonSlug} />\n </Section>\n )}\n\n {/* Tags, minus the indexing machinery: `namespace:value` tags drive\n grouping, faceting and placement, are about half of all tag\n references, and say nothing about what an app is for. The colon is\n the test. Search still matches them — only the display drops them. */}\n {displayTags.length > 0 && (\n <Section title=\"Tags\">\n <div className=\"flex flex-wrap gap-2\">\n {displayTags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n\n {app.teams && app.teams.length > 0 && (\n <Section title=\"Teams\">\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </Section>\n )}\n </>\n ),\n },\n {\n id: TAB.access,\n label: `Access — ${access.route}`,\n chip: access.chip,\n render: () => (\n <>\n {/* Two-step access: for a nested resource, show the parent-first\n prerequisite chain before this resource's own instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n </>\n ),\n },\n ...(hasResourcesTab\n ? [\n {\n id: TAB.resources,\n label: resourcesLabel,\n chip: children.length ? String(children.length) : undefined,\n render: () => (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <TierVariantsSection tiers={app.tiers} />\n )}\n {children.length > 0 && (\n <div className={app.tiers?.length ? 'mt-7' : ''}>\n {/* The active catalog search term seeds the filter, so a\n visitor who got here by searching something that matched\n a child sees that child rather than all of its siblings. */}\n <SubResourcesSection\n subResources={children}\n parentSlug={app.slug}\n parent={app}\n initialSearch={filterState.searchValue}\n childrenLabel={app.childrenLabel}\n />\n </div>\n )}\n </>\n ),\n } satisfies DetailTab,\n ]\n : []),\n {\n id: TAB.documentation,\n label: 'Documentation',\n render: () => (\n <>\n {app.links && app.links.length > 0 && (\n <Section title=\"Documentation\" first>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </Section>\n )}\n {/* Background: why the app exists at all. Deliberately quiet — a\n reader who came to get access has already passed everything they\n need, and history should not compete with the description. */}\n {app.background && (\n <Section title=\"Background\">\n <MarkdownText className=\"prose prose-sm text-muted-foreground max-w-none text-sm [&_p]:mt-0 [&_p]:mb-2 [&_p:last-child]:mb-0\">\n {app.background}\n </MarkdownText>\n </Section>\n )}\n\n <Section title=\"Sources\" first={!app.background}>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n // `group` so hovering anywhere on the row fades that source's\n // track in — the mark sits in a right-hand column, and row\n // hover is what ties the two ends of a wide row together.\n <li\n key={index}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex min-w-0 items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n {pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n </li>\n ))}\n </ul>\n )}\n </Section>\n\n {/* These used to sit unlabelled in a 64px gap between two sections,\n which is what made that gap look like a mistake. Labelled, in\n their own block. \"Dates\" rather than \"Added / updated\" on purpose:\n a label that repeats the first word of every line below it adds\n nothing, and it made \"Added\"/\"Updated\" ambiguous to anything\n matching on text — including this card's own tests. */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <Section title=\"Dates\">\n <div className=\"flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check\n ran — entries predating the field have none, so fall\n back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n </Section>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-7\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"section-label mb-1\">AI Prompt</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"section-label mb-1\">AI Memory</h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n </>\n ),\n },\n {\n id: TAB.notes,\n label: 'Notes & requests',\n render: () => (\n <FeedbackSection appSlug={app.slug} openComposer={composeRequests} />\n ),\n },\n ]\n\n // The hero band: identity, the way in, and the typed-id jumps. It does NOT\n // scroll away with a tab, so \"take me to the app\" is reachable from every one\n // of them.\n //\n // Deliberately holds nothing else. An owner/teams/freshness strip was drawn\n // and dropped: `ownerPersonSlug` is set on a tiny fraction of entries so the\n // owner slot would almost always be empty, teams already has its own block in\n // Overview, and on an entry with quick jumps the band had grown past 250px\n // before any content — on exactly the apps where people most want to type an\n // id and leave.\n const band = (\n <>\n <div className=\"flex flex-wrap items-center gap-x-7 gap-y-2\">\n <AppDoor app={app} onOpen={() => recordClick(app.slug)} />\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n {/* In the band, not at the foot of the notes panel.\n Down there it was invisible twice over: below the fold of a panel, and\n behind a tab nobody opens unless they already know what is in it — so\n the one affordance the feature exists for was the hardest thing on the\n card to find. Up here it is present on every tab, which is the whole\n reason the band does not scroll away.\n `mr-10` keeps it clear of the dialog's own close button at top-right. */}\n {/* Plugin actions sit beside \"Suggest a change\", sharing its reasoning:\n the band is mounted on every tab and never scrolls away. A slot in a\n tab panel would be invisible until a second click, and one inside\n SubResourceDetailPanel's `hasAnyAccess` gate would vanish for exactly\n the resources that need help most.\n Renders nothing when no plugin contributes, so the band keeps its\n current shape in the open-source build. */}\n <div className=\"ml-auto mr-10 flex items-center gap-2\">\n <ResourceDetailAccessActions {...accessActionsCtx} />\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n onClick={() => setComposeRequests((n) => n + 1)}\n >\n ✎ Suggest a change\n </Button>\n </div>\n </div>\n\n {app.nicknames && app.nicknames.length > 0 && (\n <p className=\"mt-2.5 text-xs text-muted-foreground\">\n Also known as {app.nicknames.join(', ')}\n </p>\n )}\n\n {isAdmin && (\n <div className=\"mt-3 flex flex-wrap items-center gap-x-6 gap-y-1\">\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">Slug:</span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </span>\n <span className=\"inline-flex items-center gap-2\">\n <span className=\"text-xs text-muted-foreground\">URL:</span>\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n className=\"text-sm\"\n />\n </span>\n </div>\n )}\n\n {hasQuickJumps(app) && (\n <div className=\"mt-4\">\n {/* Named, where the bare control was not: \"or go straight to a\n record\" says what the field is for before you reach it. */}\n <p className=\"mb-1.5 text-xs text-muted-foreground\">\n Or go straight to a record:\n </p>\n <QuickJumpBar app={app} />\n </div>\n )}\n\n {/* Deprecation stays in the band, not in a tab: \"do not use this\"\n must not be something you have to go looking for. */}\n {app.deprecated &&\n (() => {\n const isDiscouraged = app.deprecated.type === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-4 rounded-lg border border-yellow-500/50 bg-yellow-50 p-4 dark:bg-yellow-950/20'\n : 'mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'section-label mb-1.5 text-yellow-700 dark:text-yellow-500'\n : 'section-label mb-1.5 text-destructive'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n </>\n )\n\n return (\n <>\n <div className=\"flex h-full flex-col\">\n <DetailTabs\n tabs={tabs}\n band={band}\n // Cancels the dialog's own padding so the tint reaches the card's\n // edges, then re-applies it inside.\n bandClassName=\"-mx-6 -mt-5 px-6 pt-5 sm:-mx-8 sm:-mt-7 sm:px-8 sm:pt-7\"\n // `?sub=` means a child was picked out of a search, so the card opens\n // on the tab that holds it rather than making them hunt for it.\n forcedTab={subSlug && hasResourcesTab ? TAB.resources : undefined}\n requestTab={composeRequests > 0 ? TAB.notes : undefined}\n requestNonce={composeRequests}\n />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a","_b"],"mappings":"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"}
|
|
@@ -50,6 +50,15 @@ interface DismissEntry {
|
|
|
50
50
|
* popover. Skipped when choosing the top rather than unregistering.
|
|
51
51
|
*/
|
|
52
52
|
enabled: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* What had the caret before this layer opened — read on its first render,
|
|
55
|
+
* ahead of the layer's own mount focus — and the caret's place in it when
|
|
56
|
+
* it is a text field.
|
|
57
|
+
*/
|
|
58
|
+
opener?: {
|
|
59
|
+
element: Element | null;
|
|
60
|
+
selection: [number, number] | null;
|
|
61
|
+
};
|
|
53
62
|
}
|
|
54
63
|
export interface DismissStackApi {
|
|
55
64
|
/** Adds an entry and returns its unregister function. */
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import "react/jsx-runtime";
|
|
2
|
-
import { use, createContext, useRef, useEffect } from "react";
|
|
2
|
+
import { use, createContext, useRef, useState, useEffect } from "react";
|
|
3
|
+
function readOpener() {
|
|
4
|
+
const element = document.activeElement;
|
|
5
|
+
const field = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element : null;
|
|
6
|
+
const start = field == null ? void 0 : field.selectionStart;
|
|
7
|
+
const end = field == null ? void 0 : field.selectionEnd;
|
|
8
|
+
return {
|
|
9
|
+
element,
|
|
10
|
+
selection: start != null && end != null ? [start, end] : null
|
|
11
|
+
};
|
|
12
|
+
}
|
|
3
13
|
function isAfter(a, b) {
|
|
4
14
|
return (b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
|
|
5
15
|
}
|
|
@@ -40,15 +50,22 @@ function createStack() {
|
|
|
40
50
|
return () => {
|
|
41
51
|
const i = entries.indexOf(entry);
|
|
42
52
|
if (i !== -1) entries.splice(i, 1);
|
|
43
|
-
if (entries.length === 0)
|
|
53
|
+
if (entries.length === 0)
|
|
44
54
|
document.removeEventListener("keydown", onKeyDown);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
55
|
queueMicrotask(() => {
|
|
48
|
-
var _a;
|
|
56
|
+
var _a, _b;
|
|
49
57
|
if (document.activeElement && document.activeElement !== document.body)
|
|
50
58
|
return;
|
|
51
|
-
|
|
59
|
+
const onTop = topElement();
|
|
60
|
+
const opener = (_a = entry.opener) == null ? void 0 : _a.element;
|
|
61
|
+
if (opener instanceof HTMLElement && opener !== document.body && opener.isConnected && (!onTop || onTop.contains(opener))) {
|
|
62
|
+
opener.focus();
|
|
63
|
+
const selection = (_b = entry.opener) == null ? void 0 : _b.selection;
|
|
64
|
+
if (selection && (opener instanceof HTMLInputElement || opener instanceof HTMLTextAreaElement))
|
|
65
|
+
opener.setSelectionRange(...selection);
|
|
66
|
+
if (document.activeElement === opener) return;
|
|
67
|
+
}
|
|
68
|
+
onTop == null ? void 0 : onTop.focus();
|
|
52
69
|
});
|
|
53
70
|
};
|
|
54
71
|
};
|
|
@@ -64,11 +81,13 @@ function useDismissable(layer, onDismiss, { enabled = true } = {}) {
|
|
|
64
81
|
const scoped = use(DismissStackContext);
|
|
65
82
|
const stack = scoped ?? getAppStack();
|
|
66
83
|
const entryRef = useRef({ layer, onDismiss, enabled });
|
|
84
|
+
const [opener] = useState(readOpener);
|
|
67
85
|
useEffect(() => {
|
|
68
86
|
entryRef.current.layer = layer;
|
|
69
87
|
entryRef.current.onDismiss = onDismiss;
|
|
70
88
|
entryRef.current.enabled = enabled;
|
|
71
|
-
|
|
89
|
+
entryRef.current.opener = opener;
|
|
90
|
+
}, [layer, onDismiss, enabled, opener]);
|
|
72
91
|
useEffect(() => stack.register(entryRef.current), [stack]);
|
|
73
92
|
}
|
|
74
93
|
function useDismissStack() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DismissStack.js","sources":["../../../../src/modules/dismiss/DismissStack.tsx"],"sourcesContent":["import { createContext, use, useEffect, useMemo, useRef } from 'react'\n\n/**\n * One Escape key, one answer: dismiss the layer on top.\n *\n * Before this existed, Escape was handled in six independent places, and each\n * one had to know about the others to avoid double-firing — the detail card\n * bound Escape only `enabled: !isGalleryOpen`, the gallery installed its own\n * capture-phase listener specifically to beat Radix to the event, and the\n * screenshot dialog cancelled Radix's own Escape by reading the gallery's\n * fullscreen state through a ref. Every new overlay had to learn that dance,\n * and getting it wrong stayed invisible until someone pressed Escape in the\n * wrong order. It was wrong: a dialog opened over the card had no say, so\n * Escape closed the card out from under it and navigated away.\n *\n * A layer now says only \"I am dismissable, and this element is me\". The stack\n * decides who Escape belongs to, and no layer needs to know another exists.\n *\n * \"On top\" is DOCUMENT ORDER, the last enabled layer in the document. Two\n * cheaper keys were tried first and both broke:\n *\n * - Registration order. React runs a child's effects before its parent's, so\n * an overlay nested inside another registers FIRST and the container looks\n * newer: Escape in the screenshot viewer closed the card under it.\n * - Activation time. On a cold deep link the dialog's route resolves before\n * the catalog arrives, so the card it sits over mounts LATER and looks\n * newer: Escape closed the card first, and the card took focus back off the\n * dialog's first field.\n *\n * Document order has neither problem because it is what \"on top\" means on\n * screen: a portal appends to the end of `<body>`, a child route renders after\n * its parent's page, and a nested layer is a descendant — and a descendant\n * follows its ancestor in document order. None of it depends on when anything\n * mounted.\n *\n * `useDismissable` works with or without a provider above it, on purpose. A\n * hook that silently did nothing outside a provider would be the same class of\n * bug this module exists to delete — every render site, including tests and\n * stories, would have to remember a wrapper, and forgetting it would look like\n * working code with dead Escape handling. So an app-wide stack is the default\n * and `DismissStackProvider` is only for scoping.\n */\n\ninterface DismissEntry {\n /** The layer's root element; its position in the document is its rank. */\n layer: { readonly current: HTMLElement | null }\n /** Called when Escape belongs to this layer. */\n onDismiss: () => void\n /**\n * A layer can stay registered and opt out — a collapsed panel, a closed\n * popover. Skipped when choosing the top rather than unregistering.\n */\n enabled: boolean\n}\n\nexport interface DismissStackApi {\n /** Adds an entry and returns its unregister function. */\n register: (entry: DismissEntry) => () => void\n /** Dismisses the top layer; reports whether there was one. */\n dismissTop: () => boolean\n /**\n * The top layer's element, or null when nothing is open. For a layer that\n * takes focus on mount: it should only do so while it is on top, or it\n * steals the caret from whatever was opened over it.\n */\n topElement: () => HTMLElement | null\n}\n\nfunction isAfter(a: HTMLElement, b: HTMLElement): boolean {\n // A descendant reports FOLLOWING as well as CONTAINED_BY, so this one test\n // covers both \"rendered later\" and \"nested inside\".\n return (b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0\n}\n\nfunction createStack(): DismissStackApi {\n const entries: DismissEntry[] = []\n\n const top = (): DismissEntry | undefined => {\n let best: DismissEntry | undefined\n for (const entry of entries) {\n const el = entry.layer.current\n // Not in the document yet, or already gone: not something on screen.\n if (!entry.enabled || !el?.isConnected) continue\n const bestEl = best?.layer.current\n if (!bestEl || isAfter(el, bestEl)) best = entry\n }\n return best\n }\n\n const dismissTop = () => {\n const entry = top()\n if (!entry) return false\n entry.onDismiss()\n return true\n }\n\n const topElement = () => top()?.layer.current ?? null\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return\n // Something closer to the event already dealt with it — a text field\n // cancelling an edit, say. Honoured rather than overridden: the element the\n // user is typing in is more specific than any layer containing it.\n //\n // This check is the reason the listener below is on the BUBBLE phase. In\n // capture it would run before every component handler, so `defaultPrevented`\n // would always be false and a field could never keep Escape for itself.\n if (event.defaultPrevented) return\n if (!dismissTop()) return\n // Claim it, so a handler further out does not also act.\n event.preventDefault()\n event.stopPropagation()\n }\n\n const register = (entry: DismissEntry) => {\n if (entries.length === 0) {\n // Bubble, on the document: every component handler and React's own\n // delegated handlers run first, so the innermost thing that wants Escape\n // gets it. Radix is the one exception — it listens with `capture: true`,\n // so it runs BEFORE this. `DialogContent` bridges that by asking the\n // stack from inside Radix's own escape callback.\n document.addEventListener('keydown', onKeyDown)\n }\n entries.push(entry)\n return () => {\n const i = entries.indexOf(entry)\n if (i !== -1) entries.splice(i, 1)\n if (entries.length === 0) {\n document.removeEventListener('keydown', onKeyDown)\n return\n }\n // When a layer closes, the caret it held goes with it and lands on\n // <body>, where the next Tab walks whatever is behind every overlay and\n // Escape still works only because this listener is on the document.\n // Hand it to the layer that is now on top. Only from <body>: if\n // something already restored focus — Radix returns it to the trigger —\n // that choice is more specific than this one and stands.\n queueMicrotask(() => {\n if (document.activeElement && document.activeElement !== document.body)\n return\n topElement()?.focus()\n })\n }\n }\n\n return { register, dismissTop, topElement }\n}\n\n/**\n * The app-wide stack, used whenever no provider scopes one.\n *\n * Module-level so two sibling overlays coordinate even if neither sits under a\n * provider — which is the whole point. Created lazily so importing this module\n * never touches `document`, keeping it safe in a non-DOM environment.\n */\nlet appStack: DismissStackApi | null = null\nfunction getAppStack(): DismissStackApi {\n appStack ??= createStack()\n return appStack\n}\n\nconst DismissStackContext = createContext<DismissStackApi | null>(null)\n\n/**\n * Scopes a stack to a subtree.\n *\n * Only needed when overlays inside must not compete with overlays outside —\n * two independently mounted apps on one page, for instance. The app does not\n * need it for ordinary nesting; the default stack already handles that.\n */\nexport function DismissStackProvider({\n children,\n}: {\n children: React.ReactNode\n}) {\n const api = useMemo(() => createStack(), [])\n return <DismissStackContext value={api}>{children}</DismissStackContext>\n}\n\n/**\n * Declare this layer dismissable by Escape.\n *\n * ```tsx\n * const ref = useRef<HTMLDivElement>(null)\n * useDismissable(ref, onClose) // always dismissable\n * useDismissable(ref, close, { enabled: isOpen }) // only while open\n * return <div ref={ref} role=\"dialog\">…</div>\n * ```\n *\n * `ref` is the layer's root element, and it is required rather than optional\n * because it IS the ordering: the last enabled layer in the document owns\n * Escape. Call the hook once per layer, unconditionally; nothing else\n * coordinates with any other layer, and adding one above an existing layer\n * needs no change to it.\n */\nexport function useDismissable(\n layer: { readonly current: HTMLElement | null },\n onDismiss: () => void,\n { enabled = true }: { enabled?: boolean } = {},\n): void {\n const scoped = use(DismissStackContext)\n const stack = scoped ?? getAppStack()\n\n // A stable entry whose fields are refreshed in place. Handing `register` a\n // new object whenever the callback identity changed would churn the stack on\n // every render for no benefit.\n const entryRef = useRef<DismissEntry>({ layer, onDismiss, enabled })\n useEffect(() => {\n entryRef.current.layer = layer\n entryRef.current.onDismiss = onDismiss\n entryRef.current.enabled = enabled\n }, [layer, onDismiss, enabled])\n\n useEffect(() => stack.register(entryRef.current), [stack])\n}\n\n/**\n * The stack itself, for a component that owns its own key handling or focus\n * and wants stack ordering instead of inventing its own.\n */\nexport function useDismissStack(): DismissStackApi {\n const scoped = use(DismissStackContext)\n return scoped ?? getAppStack()\n}\n"],"names":[],"mappings":";;AAoEA,SAAS,QAAQ,GAAgB,GAAyB;AAGxD,UAAQ,EAAE,wBAAwB,CAAC,IAAI,KAAK,iCAAiC;AAC/E;AAEA,SAAS,cAA+B;AACtC,QAAM,UAA0B,CAAA;AAEhC,QAAM,MAAM,MAAgC;AAC1C,QAAI;AACJ,eAAW,SAAS,SAAS;AAC3B,YAAM,KAAK,MAAM,MAAM;AAEvB,UAAI,CAAC,MAAM,WAAW,EAAC,yBAAI,aAAa;AACxC,YAAM,SAAS,6BAAM,MAAM;AAC3B,UAAI,CAAC,UAAU,QAAQ,IAAI,MAAM,EAAG,QAAO;AAAA,IAC7C;AACA,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,MAAM;AACvB,UAAM,QAAQ,IAAA;AACd,QAAI,CAAC,MAAO,QAAO;AACnB,UAAM,UAAA;AACN,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,MAAA;;AAAM,sBAAA,MAAA,mBAAO,MAAM,YAAW;AAAA;AAEjD,QAAM,YAAY,CAAC,UAAyB;AAC1C,QAAI,MAAM,QAAQ,SAAU;AAQ5B,QAAI,MAAM,iBAAkB;AAC5B,QAAI,CAAC,aAAc;AAEnB,UAAM,eAAA;AACN,UAAM,gBAAA;AAAA,EACR;AAEA,QAAM,WAAW,CAAC,UAAwB;AACxC,QAAI,QAAQ,WAAW,GAAG;AAMxB,eAAS,iBAAiB,WAAW,SAAS;AAAA,IAChD;AACA,YAAQ,KAAK,KAAK;AAClB,WAAO,MAAM;AACX,YAAM,IAAI,QAAQ,QAAQ,KAAK;AAC/B,UAAI,MAAM,GAAI,SAAQ,OAAO,GAAG,CAAC;AACjC,UAAI,QAAQ,WAAW,GAAG;AACxB,iBAAS,oBAAoB,WAAW,SAAS;AACjD;AAAA,MACF;AAOA,qBAAe,MAAM;;AACnB,YAAI,SAAS,iBAAiB,SAAS,kBAAkB,SAAS;AAChE;AACF,yBAAA,MAAA,mBAAc;AAAA,MAChB,CAAC;AAAA,IACH;AAAA,EACF;AAEA,SAAO,EAAE,UAAU,YAAY,WAAA;AACjC;AASA,IAAI,WAAmC;AACvC,SAAS,cAA+B;AACtC,0BAAa,YAAA;AACb,SAAO;AACT;AAEA,MAAM,sBAAsB,cAAsC,IAAI;AAkC/D,SAAS,eACd,OACA,WACA,EAAE,UAAU,KAAA,IAAgC,IACtC;AACN,QAAM,SAAS,IAAI,mBAAmB;AACtC,QAAM,QAAQ,UAAU,YAAA;AAKxB,QAAM,WAAW,OAAqB,EAAE,OAAO,WAAW,SAAS;AACnE,YAAU,MAAM;AACd,aAAS,QAAQ,QAAQ;AACzB,aAAS,QAAQ,YAAY;AAC7B,aAAS,QAAQ,UAAU;AAAA,EAC7B,GAAG,CAAC,OAAO,WAAW,OAAO,CAAC;AAE9B,YAAU,MAAM,MAAM,SAAS,SAAS,OAAO,GAAG,CAAC,KAAK,CAAC;AAC3D;AAMO,SAAS,kBAAmC;AACjD,QAAM,SAAS,IAAI,mBAAmB;AACtC,SAAO,UAAU,YAAA;AACnB;"}
|
|
1
|
+
{"version":3,"file":"DismissStack.js","sources":["../../../../src/modules/dismiss/DismissStack.tsx"],"sourcesContent":["import { createContext, use, useEffect, useMemo, useRef, useState } from 'react'\n\n/**\n * One Escape key, one answer: dismiss the layer on top.\n *\n * Before this existed, Escape was handled in six independent places, and each\n * one had to know about the others to avoid double-firing — the detail card\n * bound Escape only `enabled: !isGalleryOpen`, the gallery installed its own\n * capture-phase listener specifically to beat Radix to the event, and the\n * screenshot dialog cancelled Radix's own Escape by reading the gallery's\n * fullscreen state through a ref. Every new overlay had to learn that dance,\n * and getting it wrong stayed invisible until someone pressed Escape in the\n * wrong order. It was wrong: a dialog opened over the card had no say, so\n * Escape closed the card out from under it and navigated away.\n *\n * A layer now says only \"I am dismissable, and this element is me\". The stack\n * decides who Escape belongs to, and no layer needs to know another exists.\n *\n * \"On top\" is DOCUMENT ORDER, the last enabled layer in the document. Two\n * cheaper keys were tried first and both broke:\n *\n * - Registration order. React runs a child's effects before its parent's, so\n * an overlay nested inside another registers FIRST and the container looks\n * newer: Escape in the screenshot viewer closed the card under it.\n * - Activation time. On a cold deep link the dialog's route resolves before\n * the catalog arrives, so the card it sits over mounts LATER and looks\n * newer: Escape closed the card first, and the card took focus back off the\n * dialog's first field.\n *\n * Document order has neither problem because it is what \"on top\" means on\n * screen: a portal appends to the end of `<body>`, a child route renders after\n * its parent's page, and a nested layer is a descendant — and a descendant\n * follows its ancestor in document order. None of it depends on when anything\n * mounted.\n *\n * `useDismissable` works with or without a provider above it, on purpose. A\n * hook that silently did nothing outside a provider would be the same class of\n * bug this module exists to delete — every render site, including tests and\n * stories, would have to remember a wrapper, and forgetting it would look like\n * working code with dead Escape handling. So an app-wide stack is the default\n * and `DismissStackProvider` is only for scoping.\n */\n\ninterface DismissEntry {\n /** The layer's root element; its position in the document is its rank. */\n layer: { readonly current: HTMLElement | null }\n /** Called when Escape belongs to this layer. */\n onDismiss: () => void\n /**\n * A layer can stay registered and opt out — a collapsed panel, a closed\n * popover. Skipped when choosing the top rather than unregistering.\n */\n enabled: boolean\n /**\n * What had the caret before this layer opened — read on its first render,\n * ahead of the layer's own mount focus — and the caret's place in it when\n * it is a text field.\n */\n opener?: { element: Element | null; selection: [number, number] | null }\n}\n\nfunction readOpener(): DismissEntry['opener'] {\n const element = document.activeElement\n const field =\n element instanceof HTMLInputElement ||\n element instanceof HTMLTextAreaElement\n ? element\n : null\n const start = field?.selectionStart\n const end = field?.selectionEnd\n return {\n element,\n selection: start != null && end != null ? [start, end] : null,\n }\n}\n\nexport interface DismissStackApi {\n /** Adds an entry and returns its unregister function. */\n register: (entry: DismissEntry) => () => void\n /** Dismisses the top layer; reports whether there was one. */\n dismissTop: () => boolean\n /**\n * The top layer's element, or null when nothing is open. For a layer that\n * takes focus on mount: it should only do so while it is on top, or it\n * steals the caret from whatever was opened over it.\n */\n topElement: () => HTMLElement | null\n}\n\nfunction isAfter(a: HTMLElement, b: HTMLElement): boolean {\n // A descendant reports FOLLOWING as well as CONTAINED_BY, so this one test\n // covers both \"rendered later\" and \"nested inside\".\n return (b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0\n}\n\nfunction createStack(): DismissStackApi {\n const entries: DismissEntry[] = []\n\n const top = (): DismissEntry | undefined => {\n let best: DismissEntry | undefined\n for (const entry of entries) {\n const el = entry.layer.current\n // Not in the document yet, or already gone: not something on screen.\n if (!entry.enabled || !el?.isConnected) continue\n const bestEl = best?.layer.current\n if (!bestEl || isAfter(el, bestEl)) best = entry\n }\n return best\n }\n\n const dismissTop = () => {\n const entry = top()\n if (!entry) return false\n entry.onDismiss()\n return true\n }\n\n const topElement = () => top()?.layer.current ?? null\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return\n // Something closer to the event already dealt with it — a text field\n // cancelling an edit, say. Honoured rather than overridden: the element the\n // user is typing in is more specific than any layer containing it.\n //\n // This check is the reason the listener below is on the BUBBLE phase. In\n // capture it would run before every component handler, so `defaultPrevented`\n // would always be false and a field could never keep Escape for itself.\n if (event.defaultPrevented) return\n if (!dismissTop()) return\n // Claim it, so a handler further out does not also act.\n event.preventDefault()\n event.stopPropagation()\n }\n\n const register = (entry: DismissEntry) => {\n if (entries.length === 0) {\n // Bubble, on the document: every component handler and React's own\n // delegated handlers run first, so the innermost thing that wants Escape\n // gets it. Radix is the one exception — it listens with `capture: true`,\n // so it runs BEFORE this. `DialogContent` bridges that by asking the\n // stack from inside Radix's own escape callback.\n document.addEventListener('keydown', onKeyDown)\n }\n entries.push(entry)\n return () => {\n const i = entries.indexOf(entry)\n if (i !== -1) entries.splice(i, 1)\n if (entries.length === 0)\n document.removeEventListener('keydown', onKeyDown)\n // When a layer closes, the caret it held goes with it and lands on\n // <body>, where the next Tab walks whatever is behind every overlay and\n // Escape still works only because this listener is on the document.\n // Hand it back to whatever opened the layer — the search field, caret\n // where it was — unless a layer still covers that; then to the layer now\n // on top. Only from <body>: if something already restored focus — Radix\n // returns it to the trigger — that choice is more specific and stands.\n queueMicrotask(() => {\n if (document.activeElement && document.activeElement !== document.body)\n return\n const onTop = topElement()\n const opener = entry.opener?.element\n if (\n opener instanceof HTMLElement &&\n opener !== document.body &&\n opener.isConnected &&\n (!onTop || onTop.contains(opener))\n ) {\n opener.focus()\n const selection = entry.opener?.selection\n if (\n selection &&\n (opener instanceof HTMLInputElement ||\n opener instanceof HTMLTextAreaElement)\n )\n opener.setSelectionRange(...selection)\n // A hidden, inert or disabled opener refuses focus; fall through.\n if (document.activeElement === opener) return\n }\n onTop?.focus()\n })\n }\n }\n\n return { register, dismissTop, topElement }\n}\n\n/**\n * The app-wide stack, used whenever no provider scopes one.\n *\n * Module-level so two sibling overlays coordinate even if neither sits under a\n * provider — which is the whole point. Created lazily so importing this module\n * never touches `document`, keeping it safe in a non-DOM environment.\n */\nlet appStack: DismissStackApi | null = null\nfunction getAppStack(): DismissStackApi {\n appStack ??= createStack()\n return appStack\n}\n\nconst DismissStackContext = createContext<DismissStackApi | null>(null)\n\n/**\n * Scopes a stack to a subtree.\n *\n * Only needed when overlays inside must not compete with overlays outside —\n * two independently mounted apps on one page, for instance. The app does not\n * need it for ordinary nesting; the default stack already handles that.\n */\nexport function DismissStackProvider({\n children,\n}: {\n children: React.ReactNode\n}) {\n const api = useMemo(() => createStack(), [])\n return <DismissStackContext value={api}>{children}</DismissStackContext>\n}\n\n/**\n * Declare this layer dismissable by Escape.\n *\n * ```tsx\n * const ref = useRef<HTMLDivElement>(null)\n * useDismissable(ref, onClose) // always dismissable\n * useDismissable(ref, close, { enabled: isOpen }) // only while open\n * return <div ref={ref} role=\"dialog\">…</div>\n * ```\n *\n * `ref` is the layer's root element, and it is required rather than optional\n * because it IS the ordering: the last enabled layer in the document owns\n * Escape. Call the hook once per layer, unconditionally; nothing else\n * coordinates with any other layer, and adding one above an existing layer\n * needs no change to it.\n */\nexport function useDismissable(\n layer: { readonly current: HTMLElement | null },\n onDismiss: () => void,\n { enabled = true }: { enabled?: boolean } = {},\n): void {\n const scoped = use(DismissStackContext)\n const stack = scoped ?? getAppStack()\n\n // A stable entry whose fields are refreshed in place. Handing `register` a\n // new object whenever the callback identity changed would churn the stack on\n // every render for no benefit.\n const entryRef = useRef<DismissEntry>({ layer, onDismiss, enabled })\n // Read during the first render, not in an effect: by the time effects run,\n // the layer has usually taken the caret for itself.\n const [opener] = useState(readOpener)\n useEffect(() => {\n entryRef.current.layer = layer\n entryRef.current.onDismiss = onDismiss\n entryRef.current.enabled = enabled\n entryRef.current.opener = opener\n }, [layer, onDismiss, enabled, opener])\n\n useEffect(() => stack.register(entryRef.current), [stack])\n}\n\n/**\n * The stack itself, for a component that owns its own key handling or focus\n * and wants stack ordering instead of inventing its own.\n */\nexport function useDismissStack(): DismissStackApi {\n const scoped = use(DismissStackContext)\n return scoped ?? getAppStack()\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -35,7 +35,9 @@ export interface ResourceDetailSlots {
|
|
|
35
35
|
* family has children (hundreds, in a large catalog) — keep the cell cheap and
|
|
36
36
|
* defer anything expensive until it is interacted with. And the column only
|
|
37
37
|
* appears when a plugin actually fills it, so a build with no plugin renders
|
|
38
|
-
* the table exactly as before.
|
|
38
|
+
* the table exactly as before. A plugin that serves only some parents says so
|
|
39
|
+
* in `appliesTo` (see `ResourceDetailSlotScopes`), so every other parent's
|
|
40
|
+
* table gets no column rather than a column of empty cells.
|
|
39
41
|
*/
|
|
40
42
|
resourceSubResourceRowActions: {
|
|
41
43
|
/** The child this row is showing. */
|
|
@@ -45,6 +47,19 @@ export interface ResourceDetailSlots {
|
|
|
45
47
|
user: PluginUser | null;
|
|
46
48
|
};
|
|
47
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* What the core knows when it decides a slot's LAYOUT, before any row renders.
|
|
52
|
+
*
|
|
53
|
+
* The input to a plugin's `appliesTo` predicate for that slot. A contribution
|
|
54
|
+
* renders as a component, so the core cannot ask it whether it would render
|
|
55
|
+
* nothing; the plugin has to say up front which scopes it serves.
|
|
56
|
+
*/
|
|
57
|
+
export interface ResourceDetailSlotScopes {
|
|
58
|
+
resourceSubResourceRowActions: {
|
|
59
|
+
/** The entry whose sub-resources table is being laid out. */
|
|
60
|
+
parent: Resource;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
48
63
|
/**
|
|
49
64
|
* Wrappers on the resource detail card.
|
|
50
65
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resourceDetail.js","sources":["../../../../../src/modules/extensions/features/resourceDetail.ts"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { slotFactory, wrapperFactory } from '../factories'\nimport type { PluginUser } from '../types'\n\n/**\n * Leaf slots on the resource detail card.\n *\n * Payloads are additive-only. Adding a field is invisible to existing plugins;\n * renaming or removing one breaks them at compile time on their next core bump,\n * which is the intended moment for that to surface.\n */\nexport interface ResourceDetailSlots {\n /**\n * Actions on the detail card's header band.\n *\n * The band and not a tab panel: tab panels mount only while active and\n * Overview is first, so a slot inside a tab is invisible until a second click.\n */\n resourceDetailAccessActions: {\n /** The resource on screen — a top-level app OR one of its sub-resources. */\n resource: Resource\n /** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */\n parent?: Resource\n /** Children of `parent ?? resource`: the family's sub-resources. */\n subResources: Resource[]\n user: PluginUser | null\n }\n\n /**\n * One cell per row of the sub-resources table, in a column of its own.\n *\n * Called once per rendered row, which is the opposite shape to\n * `resourceDetailAccessActions` — that one is called once and handed the whole\n * list. Use this when the action belongs to a single child.\n *\n * Two things to know before filling it. The table renders EVERY row with no\n * virtualisation, so a contribution here is mounted as many times as the\n * family has children (hundreds, in a large catalog) — keep the cell cheap and\n * defer anything expensive until it is interacted with. And the column only\n * appears when a plugin actually fills it, so a build with no plugin renders\n * the table exactly as before.\n */\n resourceSubResourceRowActions: {\n /** The child this row is showing. */\n resource: Resource\n /** The entry the row hangs off. Always present here, unlike the header slot. */\n parent: Resource\n user: PluginUser | null\n }\n}\n\n/**\n * Wrappers on the resource detail card.\n *\n * `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`\n * subtree, so anything a plugin provides here is scoped to the resource on\n * screen and is discarded when the card navigates. That is what lets two slots\n * share per-resource state without the plugin writing any cleanup.\n */\nexport interface ResourceDetailWrappers {\n resourceDetailProvider: {\n resource: Resource\n parent?: Resource\n }\n}\n\nconst mkSlot = slotFactory<ResourceDetailSlots>()\nconst mkWrapper = wrapperFactory<ResourceDetailWrappers>()\n\nexport const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')\nexport const ResourceSubResourceRowActions = mkSlot(\n 'resourceSubResourceRowActions',\n)\nexport const ResourceDetailProvider = mkWrapper('resourceDetailProvider')\n"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"resourceDetail.js","sources":["../../../../../src/modules/extensions/features/resourceDetail.ts"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { slotFactory, wrapperFactory } from '../factories'\nimport type { PluginUser } from '../types'\n\n/**\n * Leaf slots on the resource detail card.\n *\n * Payloads are additive-only. Adding a field is invisible to existing plugins;\n * renaming or removing one breaks them at compile time on their next core bump,\n * which is the intended moment for that to surface.\n */\nexport interface ResourceDetailSlots {\n /**\n * Actions on the detail card's header band.\n *\n * The band and not a tab panel: tab panels mount only while active and\n * Overview is first, so a slot inside a tab is invisible until a second click.\n */\n resourceDetailAccessActions: {\n /** The resource on screen — a top-level app OR one of its sub-resources. */\n resource: Resource\n /** Present only on a sub-resource view. Its absence is how a plugin tells the two apart. */\n parent?: Resource\n /** Children of `parent ?? resource`: the family's sub-resources. */\n subResources: Resource[]\n user: PluginUser | null\n }\n\n /**\n * One cell per row of the sub-resources table, in a column of its own.\n *\n * Called once per rendered row, which is the opposite shape to\n * `resourceDetailAccessActions` — that one is called once and handed the whole\n * list. Use this when the action belongs to a single child.\n *\n * Two things to know before filling it. The table renders EVERY row with no\n * virtualisation, so a contribution here is mounted as many times as the\n * family has children (hundreds, in a large catalog) — keep the cell cheap and\n * defer anything expensive until it is interacted with. And the column only\n * appears when a plugin actually fills it, so a build with no plugin renders\n * the table exactly as before. A plugin that serves only some parents says so\n * in `appliesTo` (see `ResourceDetailSlotScopes`), so every other parent's\n * table gets no column rather than a column of empty cells.\n */\n resourceSubResourceRowActions: {\n /** The child this row is showing. */\n resource: Resource\n /** The entry the row hangs off. Always present here, unlike the header slot. */\n parent: Resource\n user: PluginUser | null\n }\n}\n\n/**\n * What the core knows when it decides a slot's LAYOUT, before any row renders.\n *\n * The input to a plugin's `appliesTo` predicate for that slot. A contribution\n * renders as a component, so the core cannot ask it whether it would render\n * nothing; the plugin has to say up front which scopes it serves.\n */\nexport interface ResourceDetailSlotScopes {\n resourceSubResourceRowActions: {\n /** The entry whose sub-resources table is being laid out. */\n parent: Resource\n }\n}\n\n/**\n * Wrappers on the resource detail card.\n *\n * `resourceDetailProvider` is mounted inside the panel's `key={shownSlug}`\n * subtree, so anything a plugin provides here is scoped to the resource on\n * screen and is discarded when the card navigates. That is what lets two slots\n * share per-resource state without the plugin writing any cleanup.\n */\nexport interface ResourceDetailWrappers {\n resourceDetailProvider: {\n resource: Resource\n parent?: Resource\n }\n}\n\nconst mkSlot = slotFactory<ResourceDetailSlots>()\nconst mkWrapper = wrapperFactory<ResourceDetailWrappers>()\n\nexport const ResourceDetailAccessActions = mkSlot('resourceDetailAccessActions')\nexport const ResourceSubResourceRowActions = mkSlot(\n 'resourceSubResourceRowActions',\n)\nexport const ResourceDetailProvider = mkWrapper('resourceDetailProvider')\n"],"names":[],"mappings":";AAkFA,MAAM,SAAS,YAAA;AACf,MAAM,YAAY,eAAA;AAEX,MAAM,8BAA8B,OAAO,6BAA6B;AACxE,MAAM,gCAAgC;AAAA,EAC3C;AACF;AACO,MAAM,yBAAyB,UAAU,wBAAwB;"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
|
-
import { ResourceDetailSlots, ResourceDetailWrappers } from './features/resourceDetail.js';
|
|
2
|
+
import { ResourceDetailSlotScopes, ResourceDetailSlots, ResourceDetailWrappers } from './features/resourceDetail.js';
|
|
3
3
|
import { PluginUser } from './types.js';
|
|
4
4
|
export { ExtensionsContext, useExtensions } from './ExtensionsContext.js';
|
|
5
5
|
export { slotFactory, wrapperFactory } from './factories.js';
|
|
@@ -7,7 +7,7 @@ export { makeSlot, makeWrapper } from './makeSlot.js';
|
|
|
7
7
|
export type { PluginUser, PluginRegistration } from './types.js';
|
|
8
8
|
export type { AssertDisjoint } from './types.js';
|
|
9
9
|
export { ResourceDetailAccessActions, ResourceSubResourceRowActions, ResourceDetailProvider, } from './features/resourceDetail.js';
|
|
10
|
-
export type { ResourceDetailSlots, ResourceDetailWrappers, } from './features/resourceDetail.js';
|
|
10
|
+
export type { ResourceDetailSlotScopes, ResourceDetailSlots, ResourceDetailWrappers, } from './features/resourceDetail.js';
|
|
11
11
|
/**
|
|
12
12
|
* Every leaf slot in the core, composed from the per-feature specs.
|
|
13
13
|
*
|
|
@@ -17,6 +17,8 @@ export type { ResourceDetailSlots, ResourceDetailWrappers, } from './features/re
|
|
|
17
17
|
export type SlotSpec = ResourceDetailSlots;
|
|
18
18
|
/** Every wrapper slot in the core. */
|
|
19
19
|
export type WrapperSpec = ResourceDetailWrappers;
|
|
20
|
+
/** The layout scope of every slot whose layout depends on more than presence. */
|
|
21
|
+
export type SlotScopeSpec = ResourceDetailSlotScopes;
|
|
20
22
|
/**
|
|
21
23
|
* A plugin's leaf-slot handlers. Each receives exactly its slot's payload.
|
|
22
24
|
*
|
|
@@ -33,6 +35,15 @@ export type PluginWrappers = {
|
|
|
33
35
|
children: ReactNode;
|
|
34
36
|
}) => ReactNode;
|
|
35
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Which layout scopes a plugin's slot contribution serves. Absent = all of them.
|
|
40
|
+
*
|
|
41
|
+
* Only slots listed in `SlotScopeSpec` take a predicate: those are the slots
|
|
42
|
+
* whose layout the core decides before rendering any contribution.
|
|
43
|
+
*/
|
|
44
|
+
export type PluginAppliesTo = {
|
|
45
|
+
[N in keyof SlotScopeSpec]?: (scope: SlotScopeSpec[N]) => boolean;
|
|
46
|
+
};
|
|
36
47
|
/**
|
|
37
48
|
* One deployment-specific feature's contribution to the core's UI.
|
|
38
49
|
*
|
|
@@ -49,6 +60,7 @@ export interface AcPlugin {
|
|
|
49
60
|
name: string;
|
|
50
61
|
slots?: PluginSlots;
|
|
51
62
|
wrappers?: PluginWrappers;
|
|
63
|
+
appliesTo?: PluginAppliesTo;
|
|
52
64
|
}
|
|
53
65
|
/**
|
|
54
66
|
* Whether any registered plugin fills this slot.
|
|
@@ -62,8 +74,12 @@ export interface AcPlugin {
|
|
|
62
74
|
* Use it ONLY for layout. A slot must never be the only route to something the
|
|
63
75
|
* core itself needs, so branching behaviour on a plugin's presence is a design
|
|
64
76
|
* smell even where branching layout is not.
|
|
77
|
+
*
|
|
78
|
+
* A slot in `SlotScopeSpec` also takes the scope being laid out, and counts only
|
|
79
|
+
* plugins whose `appliesTo` accepts it. An `undefined` scope means there is
|
|
80
|
+
* nothing to decide against, so the slot reads as unfilled.
|
|
65
81
|
*/
|
|
66
|
-
export declare function useSlotFilled(slot: keyof
|
|
82
|
+
export declare function useSlotFilled<TSlot extends keyof SlotSpec>(slot: TSlot, ...scope: TSlot extends keyof SlotScopeSpec ? [scope: SlotScopeSpec[TSlot] | undefined] : []): boolean;
|
|
67
83
|
/**
|
|
68
84
|
* The signed-in user, reduced to what slots are given.
|
|
69
85
|
*
|
|
@@ -2,10 +2,16 @@ import { useMemo } from "react";
|
|
|
2
2
|
import { useOptionalUser } from "../auth/AuthContext.js";
|
|
3
3
|
import { useExtensions } from "./ExtensionsContext.js";
|
|
4
4
|
import { ExtensionsContext } from "./ExtensionsContext.js";
|
|
5
|
-
function useSlotFilled(slot) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
function useSlotFilled(slot, ...scope) {
|
|
6
|
+
const plugins = useExtensions();
|
|
7
|
+
const scoped = scope.length > 0;
|
|
8
|
+
const value = scope[0];
|
|
9
|
+
if (scoped && value === void 0) return false;
|
|
10
|
+
return plugins.some((plugin) => {
|
|
11
|
+
var _a, _b;
|
|
12
|
+
if (!((_a = plugin.slots) == null ? void 0 : _a[slot])) return false;
|
|
13
|
+
const applies = (_b = plugin.appliesTo) == null ? void 0 : _b[slot];
|
|
14
|
+
return !scoped || !applies || applies(value);
|
|
9
15
|
});
|
|
10
16
|
}
|
|
11
17
|
function usePluginUser() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../../../../src/modules/extensions/index.ts"],"sourcesContent":["import { useMemo } from 'react'\nimport type { ReactNode } from 'react'\n// Relative, not `~`: this file is on the published entry's graph and therefore\n// in every consumer's program, and `~` is the CORE's alias — a consumer only\n// resolves it if it has mapped it, which the example app has not.\nimport { useOptionalUser } from '../auth/AuthContext'\nimport type {\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\nimport type { AssertDisjoint, PluginUser } from './types'\n\nimport { useExtensions } from './ExtensionsContext'\n\nexport { ExtensionsContext, useExtensions } from './ExtensionsContext'\nexport { slotFactory, wrapperFactory } from './factories'\nexport { makeSlot, makeWrapper } from './makeSlot'\nexport type { PluginUser, PluginRegistration } from './types'\nexport type { AssertDisjoint } from './types'\nexport {\n ResourceDetailAccessActions,\n ResourceSubResourceRowActions,\n ResourceDetailProvider,\n} from './features/resourceDetail'\nexport type {\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\n\n/**\n * Every leaf slot in the core, composed from the per-feature specs.\n *\n * Adding a feature means a new `features/<name>.ts` plus one member here and one\n * line in the guard below. No existing feature file is touched.\n */\nexport type SlotSpec = ResourceDetailSlots\n\n/** Every wrapper slot in the core. */\nexport type WrapperSpec = ResourceDetailWrappers\n\n// Guard: with a second feature this becomes\n// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the\n// duplicated id spelled out in the error. With one feature it is trivially true,\n// and is kept so the next feature inherits the check rather than inventing it.\nconst _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true\nvoid _slotIdsAreDisjoint\n\n/**\n * A plugin's leaf-slot handlers. Each receives exactly its slot's payload.\n *\n * Registering an unknown slot, or reading a field the slot does not pass, is a\n * compile error — which is the whole point of composing `SlotSpec` rather than\n * typing handlers as `(props: unknown)`.\n */\nexport type PluginSlots = {\n [N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode\n}\n\n/** A plugin's wrapper handlers. `children` is injected by the core. */\nexport type PluginWrappers = {\n [N in keyof WrapperSpec]?: (\n props: WrapperSpec[N] & { children: ReactNode },\n ) => ReactNode\n}\n\n/**\n * One deployment-specific feature's contribution to the core's UI.\n *\n * Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map\n * holds one value per key, so a second plugin at the same slot would silently\n * replace the first and two wrappers around one subtree would be impossible.\n *\n * The array must be a module-level constant. The core iterates it in render\n * order, so rebuilding it per render would change hook call order inside the\n * plugins and violate the rules of hooks.\n */\nexport interface AcPlugin {\n /** Identity, used in error reports and test assertions. */\n name: string\n slots?: PluginSlots\n wrappers?: PluginWrappers\n}\n\n/**\n * Whether any registered plugin fills this slot.\n *\n * For the cases where rendering the slot is not enough and the core has to\n * change its own layout around it — a table column, say, which would otherwise\n * be an empty column with a header and no cells in every build that registers\n * nothing. Rendering an empty slot costs nothing; reserving space for one is\n * what this is for.\n *\n * Use it ONLY for layout. A slot must never be the only route to something the\n * core itself needs, so branching behaviour on a plugin's presence is a design\n * smell even where branching layout is not.\n */\nexport function useSlotFilled(slot: keyof
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/modules/extensions/index.ts"],"sourcesContent":["import { useMemo } from 'react'\nimport type { ReactNode } from 'react'\n// Relative, not `~`: this file is on the published entry's graph and therefore\n// in every consumer's program, and `~` is the CORE's alias — a consumer only\n// resolves it if it has mapped it, which the example app has not.\nimport { useOptionalUser } from '../auth/AuthContext'\nimport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\nimport type { AssertDisjoint, PluginUser } from './types'\n\nimport { useExtensions } from './ExtensionsContext'\n\nexport { ExtensionsContext, useExtensions } from './ExtensionsContext'\nexport { slotFactory, wrapperFactory } from './factories'\nexport { makeSlot, makeWrapper } from './makeSlot'\nexport type { PluginUser, PluginRegistration } from './types'\nexport type { AssertDisjoint } from './types'\nexport {\n ResourceDetailAccessActions,\n ResourceSubResourceRowActions,\n ResourceDetailProvider,\n} from './features/resourceDetail'\nexport type {\n ResourceDetailSlotScopes,\n ResourceDetailSlots,\n ResourceDetailWrappers,\n} from './features/resourceDetail'\n\n/**\n * Every leaf slot in the core, composed from the per-feature specs.\n *\n * Adding a feature means a new `features/<name>.ts` plus one member here and one\n * line in the guard below. No existing feature file is touched.\n */\nexport type SlotSpec = ResourceDetailSlots\n\n/** Every wrapper slot in the core. */\nexport type WrapperSpec = ResourceDetailWrappers\n\n/** The layout scope of every slot whose layout depends on more than presence. */\nexport type SlotScopeSpec = ResourceDetailSlotScopes\n\n// Guard: with a second feature this becomes\n// `AssertDisjoint<ResourceDetailSlots, ServiceDeskSlots>` and fails with the\n// duplicated id spelled out in the error. With one feature it is trivially true,\n// and is kept so the next feature inherits the check rather than inventing it.\nconst _slotIdsAreDisjoint: AssertDisjoint<SlotSpec, Record<never, never>> = true\nvoid _slotIdsAreDisjoint\n\n/**\n * A plugin's leaf-slot handlers. Each receives exactly its slot's payload.\n *\n * Registering an unknown slot, or reading a field the slot does not pass, is a\n * compile error — which is the whole point of composing `SlotSpec` rather than\n * typing handlers as `(props: unknown)`.\n */\nexport type PluginSlots = {\n [N in keyof SlotSpec]?: (props: SlotSpec[N]) => ReactNode\n}\n\n/** A plugin's wrapper handlers. `children` is injected by the core. */\nexport type PluginWrappers = {\n [N in keyof WrapperSpec]?: (\n props: WrapperSpec[N] & { children: ReactNode },\n ) => ReactNode\n}\n\n/**\n * Which layout scopes a plugin's slot contribution serves. Absent = all of them.\n *\n * Only slots listed in `SlotScopeSpec` take a predicate: those are the slots\n * whose layout the core decides before rendering any contribution.\n */\nexport type PluginAppliesTo = {\n [N in keyof SlotScopeSpec]?: (scope: SlotScopeSpec[N]) => boolean\n}\n\n/**\n * One deployment-specific feature's contribution to the core's UI.\n *\n * Passed to `<App extensions={[...]} />` as an array, not a keyed map: a map\n * holds one value per key, so a second plugin at the same slot would silently\n * replace the first and two wrappers around one subtree would be impossible.\n *\n * The array must be a module-level constant. The core iterates it in render\n * order, so rebuilding it per render would change hook call order inside the\n * plugins and violate the rules of hooks.\n */\nexport interface AcPlugin {\n /** Identity, used in error reports and test assertions. */\n name: string\n slots?: PluginSlots\n wrappers?: PluginWrappers\n appliesTo?: PluginAppliesTo\n}\n\n/**\n * Whether any registered plugin fills this slot.\n *\n * For the cases where rendering the slot is not enough and the core has to\n * change its own layout around it — a table column, say, which would otherwise\n * be an empty column with a header and no cells in every build that registers\n * nothing. Rendering an empty slot costs nothing; reserving space for one is\n * what this is for.\n *\n * Use it ONLY for layout. A slot must never be the only route to something the\n * core itself needs, so branching behaviour on a plugin's presence is a design\n * smell even where branching layout is not.\n *\n * A slot in `SlotScopeSpec` also takes the scope being laid out, and counts only\n * plugins whose `appliesTo` accepts it. An `undefined` scope means there is\n * nothing to decide against, so the slot reads as unfilled.\n */\nexport function useSlotFilled<TSlot extends keyof SlotSpec>(\n slot: TSlot,\n ...scope: TSlot extends keyof SlotScopeSpec\n ? [scope: SlotScopeSpec[TSlot] | undefined]\n : []\n): boolean {\n const plugins = useExtensions()\n const scoped = scope.length > 0\n const value = scope[0]\n if (scoped && value === undefined) return false\n return plugins.some((plugin) => {\n if (!plugin.slots?.[slot]) return false\n const applies = plugin.appliesTo?.[slot] as\n | ((s: typeof value) => boolean)\n | undefined\n return !scoped || !applies || applies(value)\n })\n}\n\n/**\n * The signed-in user, reduced to what slots are given.\n *\n * One place, so two slots cannot disagree about the shape — and deliberately a\n * projection rather than the real user object: the core's `User` is free to grow\n * fields, and handing a plugin the whole thing would make every one of them part\n * of the published slot contract.\n *\n * Returns `null` with no `AuthProvider` above it. The detail card renders inside\n * the catalog panel, which some suites mount outside the provider, and throwing\n * there would make a plugin's presence break unrelated tests.\n */\nexport function usePluginUser(): PluginUser | null {\n const user = useOptionalUser()\n return useMemo(\n // `?? false`, because admin is a capability: absent must read as \"not an\n // admin\", never as undefined leaking into a plugin's own check.\n () => (user ? { email: user.email, isAdmin: user.isAdmin ?? false } : null),\n [user],\n )\n}\n"],"names":[],"mappings":";;;;AAoHO,SAAS,cACd,SACG,OAGM;AACT,QAAM,UAAU,cAAA;AAChB,QAAM,SAAS,MAAM,SAAS;AAC9B,QAAM,QAAQ,MAAM,CAAC;AACrB,MAAI,UAAU,UAAU,OAAW,QAAO;AAC1C,SAAO,QAAQ,KAAK,CAAC,WAAW;;AAC9B,QAAI,GAAC,YAAO,UAAP,mBAAe,OAAO,QAAO;AAClC,UAAM,WAAU,YAAO,cAAP,mBAAmB;AAGnC,WAAO,CAAC,UAAU,CAAC,WAAW,QAAQ,KAAK;AAAA,EAC7C,CAAC;AACH;AAcO,SAAS,gBAAmC;AACjD,QAAM,OAAO,gBAAA;AACb,SAAO;AAAA;AAAA;AAAA,IAGL,MAAO,OAAO,EAAE,OAAO,KAAK,OAAO,SAAS,KAAK,WAAW,MAAA,IAAU;AAAA,IACtE,CAAC,IAAI;AAAA,EAAA;AAET;"}
|
|
@@ -31,6 +31,7 @@ export interface PluginRegistration {
|
|
|
31
31
|
name: string;
|
|
32
32
|
slots?: Record<string, OpaqueRenderer | undefined>;
|
|
33
33
|
wrappers?: Record<string, OpaqueRenderer | undefined>;
|
|
34
|
+
appliesTo?: Record<string, ((scope: never) => boolean) | undefined>;
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
36
37
|
* Compile error naming any slot id two features both claim.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.0",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"tailwindcss": "^4.1.7",
|
|
119
119
|
"tiptap-markdown": "^0.8.0",
|
|
120
120
|
"tslib": "^2.3.0",
|
|
121
|
-
"@igstack/app-catalog-shared-core": "6.
|
|
121
|
+
"@igstack/app-catalog-shared-core": "6.9.0"
|
|
122
122
|
},
|
|
123
123
|
"devDependencies": {
|
|
124
124
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"vite-plugin-static-copy": "^3.1.4",
|
|
147
147
|
"vite-plugin-svgr": "^4.2.0",
|
|
148
148
|
"vitest": "^4.1.2",
|
|
149
|
-
"@igstack/app-catalog-backend-core": "6.
|
|
149
|
+
"@igstack/app-catalog-backend-core": "6.9.0"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
152
|
"@tanstack/react-router": "1.170.41",
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
"vite": "^6.3.5",
|
|
157
157
|
"vite-plugin-svgr": "^4.2.0"
|
|
158
158
|
},
|
|
159
|
-
"gitHead": "
|
|
159
|
+
"gitHead": "6b0730c9d2d396517940db7d847080f4737df2fd",
|
|
160
160
|
"scripts": {
|
|
161
161
|
"build": "vite build",
|
|
162
162
|
"build:lenient": "vite build --mode lenient",
|