@elytracms/studio 0.0.98 → 0.0.99
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/convex/ingest.test.ts +84 -7
- package/convex/ingest.ts +110 -31
- package/convex/path-segment-refusal.test.ts +201 -0
- package/convex/records-column-transform.convex.test.ts +147 -0
- package/convex/schema.ts +1 -1
- package/dist/addon.d.ts +5 -3
- package/dist/addon.js +4 -3
- package/dist/addon.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +10 -3
- package/dist/components/app-views/default-modules.js +5 -1
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +15 -8
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +29 -1
- package/dist/components/builder/reuse-context.js +58 -12
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +3 -2
- package/dist/components/cms/cms-store.js +8 -2
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.js +15 -5
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +4 -1
- package/dist/components/cms/use-document-draft.js +4 -1
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +7 -0
- package/dist/components/cms/use-publish-controller.js +17 -3
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/versions-pane.js +8 -5
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +34 -16
- package/dist/components/delivery-health-banner.js +59 -27
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/issue-severity.d.ts +3 -2
- package/dist/components/issue-severity.js.map +1 -1
- package/dist/components/nav-issues-registry.d.ts +39 -24
- package/dist/components/nav-issues-registry.js +50 -21
- package/dist/components/nav-issues-registry.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -50
- package/dist/components/nav-issues.js +31 -59
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/notifications/notice-center.d.ts +61 -0
- package/dist/components/notifications/notice-center.js +119 -0
- package/dist/components/notifications/notice-center.js.map +1 -0
- package/dist/components/notifications/notice-model.d.ts +137 -0
- package/dist/components/notifications/notice-model.js +107 -0
- package/dist/components/notifications/notice-model.js.map +1 -0
- package/dist/components/notifications/outcome-notice.d.ts +16 -0
- package/dist/components/notifications/outcome-notice.js +34 -0
- package/dist/components/notifications/outcome-notice.js.map +1 -0
- package/dist/components/notifications/shell-findings.d.ts +36 -0
- package/dist/components/notifications/shell-findings.js +140 -0
- package/dist/components/notifications/shell-findings.js.map +1 -0
- package/dist/components/rail-view-source.js +5 -0
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/records/record-rail.d.ts +10 -2
- package/dist/components/records/record-rail.js +74 -4
- package/dist/components/records/record-rail.js.map +1 -1
- package/dist/components/section-nav.d.ts +15 -3
- package/dist/components/section-nav.js +39 -147
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +8 -3
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/use-narrow-viewport.d.ts +11 -0
- package/dist/components/use-narrow-viewport.js +25 -0
- package/dist/components/use-narrow-viewport.js.map +1 -0
- package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
- package/dist/components/users/role-policy-drift-issues.js +17 -8
- package/dist/components/users/role-policy-drift-issues.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +21 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +19 -2
- package/dist/lib/i18n/messages.en.js +24 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/notices/toast-store.d.ts +61 -0
- package/dist/lib/notices/toast-store.js +116 -0
- package/dist/lib/notices/toast-store.js.map +1 -0
- package/dist/lib/operations/cms-persistence.d.ts +24 -4
- package/dist/lib/operations/cms-persistence.js +417 -80
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +61 -30
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/ui/toast.d.ts +24 -16
- package/dist/ui/toast.js +23 -32
- package/dist/ui/toast.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useSyncExternalStore } from 'react';
|
|
3
|
+
import { BellDot, CircleCheck, X } from 'lucide-react';
|
|
4
|
+
import { Link, STUDIO_PROJECT_ROUTE } from '@elytracms/studio/router';
|
|
5
|
+
import { Button, Popover, PopoverContent, PopoverTrigger, Stack, Text } from '@elytracms/studio/ui';
|
|
6
|
+
import { cn } from '../../lib/utils.js';
|
|
7
|
+
import { translate } from '../../lib/i18n/index.js';
|
|
8
|
+
import { PROJECT_CONFIG_FILE } from '../../lib/workspace/index.js';
|
|
9
|
+
import { studioToasts } from '../../lib/notices/toast-store.js';
|
|
10
|
+
import { issueSeverityIcon, issueSeverityStyle } from '../issue-severity.js';
|
|
11
|
+
import { useStudioNav } from '../app-views/sidebar.js';
|
|
12
|
+
import { countBySeverity, goToTarget } from './notice-model.js';
|
|
13
|
+
import { useShellFindings, ViewIssuesProbes } from './shell-findings.js';
|
|
14
|
+
/**
|
|
15
|
+
* EC-690 — the studio's ONE notification-and-issue surface, in the shell header: a badge
|
|
16
|
+
* counting what needs attention by severity (EC-507's treatment), opening a tray that lists
|
|
17
|
+
* every live notice and every persistent finding with where it is ("Go to" a rail row or a
|
|
18
|
+
* document; the file or setting for code). Owner ruling (a), 2026-10-28: ONE badge, in the
|
|
19
|
+
* header — the rail foot shows none (and no finding text at all any more).
|
|
20
|
+
*
|
|
21
|
+
* The tray is a Radix popover bounded to the content area (`boundary` = `<main>`), so it can
|
|
22
|
+
* never cover the section rail at any width. Notices also PEEK in the content area's
|
|
23
|
+
* bottom-right ({@link NoticePeek}) when they are raised.
|
|
24
|
+
*/
|
|
25
|
+
export function NoticeCenter({ project, boundary }) {
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
const findings = useShellFindings(project);
|
|
28
|
+
const notices = useNotices();
|
|
29
|
+
const nav = useStudioNav(project);
|
|
30
|
+
const views = nav.registry.views;
|
|
31
|
+
const counts = noticeCounts(findings, notices);
|
|
32
|
+
const summary = noticeSummary(counts);
|
|
33
|
+
return (_jsxs(_Fragment, { children: [_jsx(ViewIssuesProbes, { project: project }), _jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-haspopup": "dialog", "aria-label": translate('shell.notices.open', { summary }), title: summary, "data-testid": "notice-badge", "data-notice-errors": counts.error, "data-notice-warnings": counts.warning, className: "flex h-7 shrink-0 items-center gap-1.5 rounded-md px-1.5 text-xs font-medium transition-colors hover:bg-muted", children: _jsx(NoticeBadgeContent, { counts: counts }) }) }), _jsx(PopoverContent, { align: "end", sideOffset: 8, collisionBoundary: boundary ?? undefined, collisionPadding: 8, "aria-label": translate('shell.notices.title'), "data-testid": "notice-tray", className: "flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-[min(26rem,var(--radix-popover-content-available-width))] flex-col gap-0 overflow-hidden p-0", children: _jsx(NoticeTray, { projectId: project.id, findings: findings, notices: notices, target: (finding) => (finding.location ? goToTarget(finding.location, views) : null), onNavigate: () => setOpen(false) }) })] })] }));
|
|
34
|
+
}
|
|
35
|
+
/** The live transient notices (the toast channel). */
|
|
36
|
+
function useNotices() {
|
|
37
|
+
return useSyncExternalStore(studioToasts.subscribe, studioToasts.getSnapshot, studioToasts.getSnapshot);
|
|
38
|
+
}
|
|
39
|
+
/** What the badge counts: every finding, plus every live error notice (an error stays until dismissed). */
|
|
40
|
+
export function noticeCounts(findings, notices) {
|
|
41
|
+
return countBySeverity([
|
|
42
|
+
...findings,
|
|
43
|
+
...notices.filter((notice) => notice.variant === 'error').map(() => ({ severity: 'error' })),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
/** "2 errors, 1 warning" — or "No issues". */
|
|
47
|
+
export function noticeSummary(counts) {
|
|
48
|
+
const parts = [];
|
|
49
|
+
if (counts.error > 0)
|
|
50
|
+
parts.push(translate(counts.error === 1 ? 'shell.notices.errors.one' : 'shell.notices.errors.other', { count: counts.error }));
|
|
51
|
+
if (counts.warning > 0) {
|
|
52
|
+
parts.push(translate(counts.warning === 1 ? 'shell.notices.warnings.one' : 'shell.notices.warnings.other', { count: counts.warning }));
|
|
53
|
+
}
|
|
54
|
+
return parts.length > 0 ? parts.join(', ') : translate('shell.notices.none');
|
|
55
|
+
}
|
|
56
|
+
/** The badge's face: one count per severity present, or a muted "all clear". */
|
|
57
|
+
export function NoticeBadgeContent({ counts }) {
|
|
58
|
+
if (counts.error === 0 && counts.warning === 0) {
|
|
59
|
+
return _jsx(BellDot, { className: "size-4 text-muted-foreground/60", "aria-hidden": true });
|
|
60
|
+
}
|
|
61
|
+
return (_jsx(_Fragment, { children: ['error', 'warning'].map((severity) => {
|
|
62
|
+
if (counts[severity] === 0)
|
|
63
|
+
return null;
|
|
64
|
+
const Icon = issueSeverityIcon(severity);
|
|
65
|
+
return (_jsxs("span", { className: cn('flex items-center gap-0.5', issueSeverityStyle(severity).text), "data-notice-count-severity": severity, children: [_jsx(Icon, { className: "size-4", "aria-hidden": true }), counts[severity]] }, severity));
|
|
66
|
+
}) }));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The tray's body: live notices first (newest first), then the findings in the one order.
|
|
70
|
+
* Pure over its inputs — `target` resolves a finding's location, so a test renders it without
|
|
71
|
+
* a router (a finding with no link target renders no `Link`).
|
|
72
|
+
*/
|
|
73
|
+
export function NoticeTray({ projectId, findings, notices, target, onNavigate, }) {
|
|
74
|
+
const liveNotices = [...notices].reverse();
|
|
75
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "border-b border-border px-3 py-2", children: _jsx(Text, { size: "sm", weight: "semibold", children: translate('shell.notices.title') }) }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-2", children: liveNotices.length === 0 && findings.length === 0 ? (_jsx(Text, { size: "sm", tone: "muted", className: "px-1 py-3", "data-testid": "notice-tray-empty", children: translate('shell.notices.empty') })) : (_jsxs("ul", { "aria-label": translate('shell.notices.title'), className: "flex flex-col gap-1", children: [liveNotices.map((notice) => (_jsx("li", { children: _jsx(NoticeRow, { notice: notice }) }, `notice:${notice.id}`))), findings.map((finding) => {
|
|
76
|
+
const goTo = target(finding);
|
|
77
|
+
return (_jsx("li", { children: _jsx(FindingRow, { finding: finding, action: goTo?.kind === 'link' ? (_jsx(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: goTo.splat }, onClick: onNavigate, className: "text-xs font-medium text-primary underline-offset-2 hover:underline", "data-testid": "finding-go-to", children: translate('shell.notices.goTo') })) : goTo?.kind === 'named' ? (_jsx(NamedLocation, { target: goTo })) : null }) }, finding.id));
|
|
78
|
+
})] })) })] }));
|
|
79
|
+
}
|
|
80
|
+
/** Where a code finding is: the file, or the deployment settings — named, never linked. */
|
|
81
|
+
function NamedLocation({ target }) {
|
|
82
|
+
const label = target.settings
|
|
83
|
+
? translate('shell.notices.deploymentSettings', { settings: target.settings.join(', ') })
|
|
84
|
+
: target.declaredIn === 'studio'
|
|
85
|
+
? translate('shell.notices.declaredInStudio')
|
|
86
|
+
: translate('shell.notices.declaredInProject', { file: PROJECT_CONFIG_FILE });
|
|
87
|
+
return (_jsx("code", { className: "max-w-full rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-muted-foreground [overflow-wrap:anywhere]", "data-testid": "finding-named-location", children: target.entry ? `${label} · ${target.entry}` : label }));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One persistent finding: EC-507's card + glyph for its severity, a bold title, the message
|
|
91
|
+
* (clamped, full text on hover; long unbreakable paths wrap — `overflow-wrap:anywhere`), and
|
|
92
|
+
* its location action. No dismiss and no collapse (owner ruling (e)): it leaves when it stops
|
|
93
|
+
* being true.
|
|
94
|
+
*/
|
|
95
|
+
export function FindingRow({ finding, action }) {
|
|
96
|
+
const style = issueSeverityStyle(finding.severity);
|
|
97
|
+
const Icon = issueSeverityIcon(finding.severity);
|
|
98
|
+
return (_jsxs("div", { className: cn('flex items-start gap-2 rounded-md border px-2.5 py-2', style.card), "data-notice-severity": finding.severity, "data-finding-source": finding.source, children: [_jsx(Icon, { className: cn('mt-0.5 size-3.5 shrink-0', style.text), "aria-hidden": true }), _jsxs(Stack, { gap: "1", className: "min-w-0 flex-1", children: [finding.title ? (_jsx(Text, { size: "xs", weight: "semibold", className: style.text, children: finding.title })) : null, _jsx(Text, { size: "xs", className: "line-clamp-4 leading-snug [overflow-wrap:anywhere]", title: finding.message, children: finding.message }), action || finding.action ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [action, finding.action ? (_jsx(Button, { size: "sm", variant: "outline", className: "h-6 px-2 text-xs", onClick: finding.action.run, children: finding.action.label })) : null] })) : null] })] }));
|
|
99
|
+
}
|
|
100
|
+
/** One transient notice: EC-507's palette for an error, the success tone otherwise; always dismissable. */
|
|
101
|
+
export function NoticeRow({ notice }) {
|
|
102
|
+
const error = notice.variant === 'error';
|
|
103
|
+
const style = issueSeverityStyle('error');
|
|
104
|
+
const Icon = error ? issueSeverityIcon('error') : CircleCheck;
|
|
105
|
+
return (_jsxs("div", { role: error ? 'alert' : 'status', className: cn('flex items-start gap-2 rounded-md border px-2.5 py-2', error ? style.card : 'border-success/30 bg-success/10'), "data-notice-variant": notice.variant, children: [_jsx(Icon, { className: cn('mt-0.5 size-3.5 shrink-0', error ? style.text : 'text-success'), "aria-hidden": true }), _jsx(Text, { size: "xs", className: "min-w-0 flex-1 leading-snug [overflow-wrap:anywhere]", children: notice.message }), _jsx("button", { type: "button", onClick: () => studioToasts.dismiss(notice.id), "aria-label": translate('shell.notices.dismiss'), className: "shrink-0 rounded text-muted-foreground transition-colors hover:text-foreground", "data-testid": "notice-dismiss", children: _jsx(X, { className: "size-3.5", "aria-hidden": true }) })] }));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* New notices, peeking for 5 s in the content area's bottom-right — inside `<main>`, so they
|
|
109
|
+
* never cover the section rail (the old `Toaster` was fixed to the viewport and did, at
|
|
110
|
+
* narrow widths), and not at its top-right, where the editor's Publish button and save status
|
|
111
|
+
* sit (critic point 8). An error leaves the peek but stays in the tray until dismissed.
|
|
112
|
+
*/
|
|
113
|
+
export function NoticePeek() {
|
|
114
|
+
const notices = useNotices().filter((notice) => notice.peeking);
|
|
115
|
+
if (notices.length === 0)
|
|
116
|
+
return null;
|
|
117
|
+
return (_jsx("div", { className: "pointer-events-none absolute bottom-3 right-3 z-40 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-2", "data-testid": "notice-peek", children: notices.map((notice) => (_jsx("div", { className: "pointer-events-auto rounded-md bg-background shadow-md", children: _jsx(NoticeRow, { notice: notice }) }, notice.id))) }));
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=notice-center.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notice-center.js","sourceRoot":"","sources":["../../../src/components/notifications/notice-center.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useState, useSyncExternalStore } from 'react'\nimport type { ReactNode } from 'react'\nimport { BellDot, CircleCheck, X } from 'lucide-react'\nimport { Link, STUDIO_PROJECT_ROUTE } from '@elytracms/studio/router'\nimport type { Project } from '@elytracms/core/studio-core'\nimport { Button, Popover, PopoverContent, PopoverTrigger, Stack, Text } from '@elytracms/studio/ui'\nimport { cn } from '../../lib/utils'\nimport { translate } from '../../lib/i18n'\nimport { PROJECT_CONFIG_FILE } from '../../lib/workspace'\nimport { studioToasts } from '../../lib/notices/toast-store'\nimport type { ToastItem } from '../../lib/notices/toast-store'\nimport { issueSeverityIcon, issueSeverityStyle } from '../issue-severity'\nimport type { IssueSeverity } from '../issue-severity'\nimport { useStudioNav } from '../app-views/sidebar'\nimport { countBySeverity, goToTarget } from './notice-model'\nimport type { Finding, GoToTarget } from './notice-model'\nimport { useShellFindings, ViewIssuesProbes } from './shell-findings'\n\n/**\n * EC-690 — the studio's ONE notification-and-issue surface, in the shell header: a badge\n * counting what needs attention by severity (EC-507's treatment), opening a tray that lists\n * every live notice and every persistent finding with where it is (\"Go to\" a rail row or a\n * document; the file or setting for code). Owner ruling (a), 2026-10-28: ONE badge, in the\n * header — the rail foot shows none (and no finding text at all any more).\n *\n * The tray is a Radix popover bounded to the content area (`boundary` = `<main>`), so it can\n * never cover the section rail at any width. Notices also PEEK in the content area's\n * bottom-right ({@link NoticePeek}) when they are raised.\n */\nexport function NoticeCenter({ project, boundary }: { project: Project; boundary: Element | null }) {\n const [open, setOpen] = useState(false)\n const findings = useShellFindings(project)\n const notices = useNotices()\n const nav = useStudioNav(project)\n const views = nav.registry.views\n const counts = noticeCounts(findings, notices)\n const summary = noticeSummary(counts)\n\n return (\n <>\n <ViewIssuesProbes project={project} />\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-label={translate('shell.notices.open', { summary })}\n title={summary}\n data-testid=\"notice-badge\"\n data-notice-errors={counts.error}\n data-notice-warnings={counts.warning}\n className=\"flex h-7 shrink-0 items-center gap-1.5 rounded-md px-1.5 text-xs font-medium transition-colors hover:bg-muted\"\n >\n <NoticeBadgeContent counts={counts} />\n </button>\n </PopoverTrigger>\n <PopoverContent\n align=\"end\"\n sideOffset={8}\n collisionBoundary={boundary ?? undefined}\n collisionPadding={8}\n aria-label={translate('shell.notices.title')}\n data-testid=\"notice-tray\"\n className=\"flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-[min(26rem,var(--radix-popover-content-available-width))] flex-col gap-0 overflow-hidden p-0\"\n >\n <NoticeTray\n projectId={project.id}\n findings={findings}\n notices={notices}\n target={(finding) => (finding.location ? goToTarget(finding.location, views) : null)}\n onNavigate={() => setOpen(false)}\n />\n </PopoverContent>\n </Popover>\n </>\n )\n}\n\n/** The live transient notices (the toast channel). */\nfunction useNotices(): ToastItem[] {\n return useSyncExternalStore(studioToasts.subscribe, studioToasts.getSnapshot, studioToasts.getSnapshot)\n}\n\n/** What the badge counts: every finding, plus every live error notice (an error stays until dismissed). */\nexport function noticeCounts(findings: readonly Finding[], notices: readonly ToastItem[]): Record<IssueSeverity, number> {\n return countBySeverity([\n ...findings,\n ...notices.filter((notice) => notice.variant === 'error').map(() => ({ severity: 'error' as const })),\n ])\n}\n\n/** \"2 errors, 1 warning\" — or \"No issues\". */\nexport function noticeSummary(counts: Record<IssueSeverity, number>): string {\n const parts: string[] = []\n if (counts.error > 0) parts.push(translate(counts.error === 1 ? 'shell.notices.errors.one' : 'shell.notices.errors.other', { count: counts.error }))\n if (counts.warning > 0) {\n parts.push(translate(counts.warning === 1 ? 'shell.notices.warnings.one' : 'shell.notices.warnings.other', { count: counts.warning }))\n }\n return parts.length > 0 ? parts.join(', ') : translate('shell.notices.none')\n}\n\n/** The badge's face: one count per severity present, or a muted \"all clear\". */\nexport function NoticeBadgeContent({ counts }: { counts: Record<IssueSeverity, number> }) {\n if (counts.error === 0 && counts.warning === 0) {\n return <BellDot className=\"size-4 text-muted-foreground/60\" aria-hidden />\n }\n return (\n <>\n {(['error', 'warning'] as const).map((severity) => {\n if (counts[severity] === 0) return null\n const Icon = issueSeverityIcon(severity)\n return (\n <span\n key={severity}\n className={cn('flex items-center gap-0.5', issueSeverityStyle(severity).text)}\n data-notice-count-severity={severity}\n >\n <Icon className=\"size-4\" aria-hidden />\n {counts[severity]}\n </span>\n )\n })}\n </>\n )\n}\n\n/**\n * The tray's body: live notices first (newest first), then the findings in the one order.\n * Pure over its inputs — `target` resolves a finding's location, so a test renders it without\n * a router (a finding with no link target renders no `Link`).\n */\nexport function NoticeTray({\n projectId,\n findings,\n notices,\n target,\n onNavigate,\n}: {\n projectId: string\n findings: readonly Finding[]\n notices: readonly ToastItem[]\n target: (finding: Finding) => GoToTarget | null\n onNavigate?: () => void\n}) {\n const liveNotices = [...notices].reverse()\n return (\n <>\n <div className=\"border-b border-border px-3 py-2\">\n <Text size=\"sm\" weight=\"semibold\">\n {translate('shell.notices.title')}\n </Text>\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto p-2\">\n {liveNotices.length === 0 && findings.length === 0 ? (\n <Text size=\"sm\" tone=\"muted\" className=\"px-1 py-3\" data-testid=\"notice-tray-empty\">\n {translate('shell.notices.empty')}\n </Text>\n ) : (\n <ul aria-label={translate('shell.notices.title')} className=\"flex flex-col gap-1\">\n {liveNotices.map((notice) => (\n <li key={`notice:${notice.id}`}>\n <NoticeRow notice={notice} />\n </li>\n ))}\n {findings.map((finding) => {\n const goTo = target(finding)\n return (\n <li key={finding.id}>\n <FindingRow\n finding={finding}\n action={\n goTo?.kind === 'link' ? (\n <Link\n to={STUDIO_PROJECT_ROUTE}\n params={{ projectId, _splat: goTo.splat }}\n onClick={onNavigate}\n className=\"text-xs font-medium text-primary underline-offset-2 hover:underline\"\n data-testid=\"finding-go-to\"\n >\n {translate('shell.notices.goTo')}\n </Link>\n ) : goTo?.kind === 'named' ? (\n <NamedLocation target={goTo} />\n ) : null\n }\n />\n </li>\n )\n })}\n </ul>\n )}\n </div>\n </>\n )\n}\n\n/** Where a code finding is: the file, or the deployment settings — named, never linked. */\nfunction NamedLocation({ target }: { target: Extract<GoToTarget, { kind: 'named' }> }) {\n const label = target.settings\n ? translate('shell.notices.deploymentSettings', { settings: target.settings.join(', ') })\n : target.declaredIn === 'studio'\n ? translate('shell.notices.declaredInStudio')\n : translate('shell.notices.declaredInProject', { file: PROJECT_CONFIG_FILE })\n return (\n <code\n className=\"max-w-full rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-muted-foreground [overflow-wrap:anywhere]\"\n data-testid=\"finding-named-location\"\n >\n {target.entry ? `${label} · ${target.entry}` : label}\n </code>\n )\n}\n\n/**\n * One persistent finding: EC-507's card + glyph for its severity, a bold title, the message\n * (clamped, full text on hover; long unbreakable paths wrap — `overflow-wrap:anywhere`), and\n * its location action. No dismiss and no collapse (owner ruling (e)): it leaves when it stops\n * being true.\n */\nexport function FindingRow({ finding, action }: { finding: Finding; action?: ReactNode }) {\n const style = issueSeverityStyle(finding.severity)\n const Icon = issueSeverityIcon(finding.severity)\n return (\n <div\n className={cn('flex items-start gap-2 rounded-md border px-2.5 py-2', style.card)}\n data-notice-severity={finding.severity}\n data-finding-source={finding.source}\n >\n <Icon className={cn('mt-0.5 size-3.5 shrink-0', style.text)} aria-hidden />\n <Stack gap=\"1\" className=\"min-w-0 flex-1\">\n {finding.title ? (\n <Text size=\"xs\" weight=\"semibold\" className={style.text}>\n {finding.title}\n </Text>\n ) : null}\n <Text size=\"xs\" className=\"line-clamp-4 leading-snug [overflow-wrap:anywhere]\" title={finding.message}>\n {finding.message}\n </Text>\n {action || finding.action ? (\n <div className=\"flex flex-wrap items-center gap-2\">\n {action}\n {finding.action ? (\n <Button size=\"sm\" variant=\"outline\" className=\"h-6 px-2 text-xs\" onClick={finding.action.run}>\n {finding.action.label}\n </Button>\n ) : null}\n </div>\n ) : null}\n </Stack>\n </div>\n )\n}\n\n/** One transient notice: EC-507's palette for an error, the success tone otherwise; always dismissable. */\nexport function NoticeRow({ notice }: { notice: ToastItem }) {\n const error = notice.variant === 'error'\n const style = issueSeverityStyle('error')\n const Icon = error ? issueSeverityIcon('error') : CircleCheck\n return (\n <div\n role={error ? 'alert' : 'status'}\n className={cn(\n 'flex items-start gap-2 rounded-md border px-2.5 py-2',\n error ? style.card : 'border-success/30 bg-success/10',\n )}\n data-notice-variant={notice.variant}\n >\n <Icon className={cn('mt-0.5 size-3.5 shrink-0', error ? style.text : 'text-success')} aria-hidden />\n <Text size=\"xs\" className=\"min-w-0 flex-1 leading-snug [overflow-wrap:anywhere]\">\n {notice.message}\n </Text>\n <button\n type=\"button\"\n onClick={() => studioToasts.dismiss(notice.id)}\n aria-label={translate('shell.notices.dismiss')}\n className=\"shrink-0 rounded text-muted-foreground transition-colors hover:text-foreground\"\n data-testid=\"notice-dismiss\"\n >\n <X className=\"size-3.5\" aria-hidden />\n </button>\n </div>\n )\n}\n\n/**\n * New notices, peeking for 5 s in the content area's bottom-right — inside `<main>`, so they\n * never cover the section rail (the old `Toaster` was fixed to the viewport and did, at\n * narrow widths), and not at its top-right, where the editor's Publish button and save status\n * sit (critic point 8). An error leaves the peek but stays in the tray until dismissed.\n */\nexport function NoticePeek() {\n const notices = useNotices().filter((notice) => notice.peeking)\n if (notices.length === 0) return null\n return (\n <div\n className=\"pointer-events-none absolute bottom-3 right-3 z-40 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-2\"\n data-testid=\"notice-peek\"\n >\n {notices.map((notice) => (\n <div key={notice.id} className=\"pointer-events-auto rounded-md bg-background shadow-md\">\n <NoticeRow notice={notice} />\n </div>\n ))}\n </div>\n )\n}\n"]}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { CmsValidationIssue } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { IssueSeverity } from '../issue-severity.js';
|
|
3
|
+
/**
|
|
4
|
+
* EC-690 — the model of the studio's ONE notification-and-issue surface (design note and
|
|
5
|
+
* owner ruling in `tasks/EC-690-one-notification-and-issue-surface.md`).
|
|
6
|
+
*
|
|
7
|
+
* Two kinds of entry, two TYPES — never a `dismissable` flag:
|
|
8
|
+
* - a {@link Finding} is PERSISTENT: it is listed while its producer publishes it, and no
|
|
9
|
+
* reader can dismiss it (no API exists for it);
|
|
10
|
+
* - a transient notice (`lib/notices/toast-store.ts` `ToastItem`) is an action's outcome:
|
|
11
|
+
* always dismissable; a success expires, an error stays until dismissed.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* WHERE a finding is — what the tray's "Go to" opens, or the file it names. Exported for
|
|
15
|
+
* addons (`@elytracms/studio/addon`), so a contributed finding can be located too.
|
|
16
|
+
*
|
|
17
|
+
* - `record` — a row of a record rail, by RECORD KIND (`redirect`, `submission`): a
|
|
18
|
+
* consumer names the rail's view (`nav.rail(kind, { id })`), the kind is what the data is.
|
|
19
|
+
* No `id` → the rail's list.
|
|
20
|
+
* - `document` — a CMS document.
|
|
21
|
+
* - `view` — a studio view's home, by view id.
|
|
22
|
+
* - `config` — code, so it is NAMED, never linked: `project` = `elytra.config.ts`, `studio` =
|
|
23
|
+
* the developer's `defineStudio({…})` (its file path is theirs, AGENTS §4). `entry` names the
|
|
24
|
+
* declaration (a route id, a view id).
|
|
25
|
+
* - `deployment` — a setting of the Convex deployment (env vars), named, never linked.
|
|
26
|
+
*/
|
|
27
|
+
export type NoticeLocation = {
|
|
28
|
+
kind: 'record';
|
|
29
|
+
recordKind: string;
|
|
30
|
+
id?: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'document';
|
|
33
|
+
collection: string;
|
|
34
|
+
id: string;
|
|
35
|
+
} | {
|
|
36
|
+
kind: 'view';
|
|
37
|
+
view: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'config';
|
|
40
|
+
declaredIn: 'project' | 'studio';
|
|
41
|
+
entry?: string;
|
|
42
|
+
} | {
|
|
43
|
+
kind: 'deployment';
|
|
44
|
+
settings: readonly string[];
|
|
45
|
+
};
|
|
46
|
+
/** An action a finding's row offers besides "Go to" (an engine finding's Retry). */
|
|
47
|
+
export interface FindingAction {
|
|
48
|
+
label: string;
|
|
49
|
+
run(): void;
|
|
50
|
+
}
|
|
51
|
+
/** One persistent finding, as the tray reads it. */
|
|
52
|
+
export interface Finding {
|
|
53
|
+
/** Unique across the tray (its publisher's identity + its key). */
|
|
54
|
+
id: string;
|
|
55
|
+
/** Who reported it — an engine source (`routes`, `config`, …) or a contributor's name. */
|
|
56
|
+
source: string;
|
|
57
|
+
/** Stable within its source. */
|
|
58
|
+
key: string;
|
|
59
|
+
severity: IssueSeverity;
|
|
60
|
+
/** The localized message — the detail. */
|
|
61
|
+
message: string;
|
|
62
|
+
/** A short localized title ("Invalid redirect"); absent → the row leads with the message. */
|
|
63
|
+
title?: string;
|
|
64
|
+
location?: NoticeLocation;
|
|
65
|
+
action?: FindingAction;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The engine's own finding sources, in the order the tray lists them (critic point 2: an
|
|
69
|
+
* EXPLICIT rank, never "registration order" — a source that clears and comes back must not
|
|
70
|
+
* move behind the addons). Reserved: an addon cannot publish under one of these names, or
|
|
71
|
+
* under the `engine:` prefix (it is listed, and reported — never silently merged).
|
|
72
|
+
*/
|
|
73
|
+
export declare const ENGINE_FINDING_SOURCES: readonly ["config", "customization", "routes", "orphans", "delivery"];
|
|
74
|
+
export type EngineFindingSource = (typeof ENGINE_FINDING_SOURCES)[number];
|
|
75
|
+
export declare function isReservedFindingSource(source: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The tray's ONE order: source rank, then severity (errors first), then source name, then key
|
|
78
|
+
* — total and deterministic, whatever order the publishers mounted in.
|
|
79
|
+
*/
|
|
80
|
+
export declare function orderFindings(findings: readonly Finding[]): Finding[];
|
|
81
|
+
/**
|
|
82
|
+
* Where a URL-map issue (EC-469/590/628) is: core addresses a redirect as
|
|
83
|
+
* `['redirects', <record id>]` (an unreadable row without an id: its index), and a route as
|
|
84
|
+
* `['routes', <route id>]` — routes are code, so a route issue is named in `elytra.config.ts`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function routeIssueLocation(issue: Pick<CmsValidationIssue, 'path'>): NoticeLocation | undefined;
|
|
87
|
+
/** A studio view as the location resolver needs it. */
|
|
88
|
+
export interface LocatableView {
|
|
89
|
+
id: string;
|
|
90
|
+
/** The record kind a record rail lists (`railView`), absent on every other view. */
|
|
91
|
+
recordKind?: string;
|
|
92
|
+
}
|
|
93
|
+
/** What a finding's row offers for its location. */
|
|
94
|
+
export type GoToTarget = {
|
|
95
|
+
kind: 'link';
|
|
96
|
+
splat: string;
|
|
97
|
+
}
|
|
98
|
+
/** `config` / `deployment`: what to name (the file, the settings) — never a link. */
|
|
99
|
+
| {
|
|
100
|
+
kind: 'named';
|
|
101
|
+
declaredIn?: 'project' | 'studio';
|
|
102
|
+
entry?: string;
|
|
103
|
+
settings?: readonly string[];
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Where "Go to" leads, or what the row names. `null` when the location's view is not in this
|
|
107
|
+
* studio (a record kind no rail lists, a view the consumer did not mount): the message still
|
|
108
|
+
* says what is wrong, there is just nowhere in this studio to open.
|
|
109
|
+
*/
|
|
110
|
+
export declare function goToTarget(location: NoticeLocation, views: readonly LocatableView[]): GoToTarget | null;
|
|
111
|
+
/** Who is reading the tray. */
|
|
112
|
+
export interface FindingAudience {
|
|
113
|
+
/** The role grants `project.administer` — the one who can change config and the deployment. */
|
|
114
|
+
isAdmin: boolean;
|
|
115
|
+
/** The role grants `content.read`. */
|
|
116
|
+
canReadContent: boolean;
|
|
117
|
+
/** The views this role sees in the rail (`isNavViewVisible`), with the record kind a rail lists. */
|
|
118
|
+
views: readonly (LocatableView & {
|
|
119
|
+
visible: boolean;
|
|
120
|
+
})[];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Owner ruling (b), 2026-10-28: config and deployment findings go to admins; a finding
|
|
124
|
+
* located in content goes to anyone who can open that content; nothing is hidden from the
|
|
125
|
+
* role that can act on it. So:
|
|
126
|
+
* - `config` / `deployment` → admins;
|
|
127
|
+
* - `record` → whoever sees the rail that lists the kind; no such rail → admins (nobody else
|
|
128
|
+
* can act on it from this studio, and it is still a real problem);
|
|
129
|
+
* - `view` → whoever sees that view (absent view → admins);
|
|
130
|
+
* - `document` → whoever can read content;
|
|
131
|
+
* - no location → everyone (nothing to gate on, and hiding it could hide it from its fixer).
|
|
132
|
+
*/
|
|
133
|
+
export declare function canSeeFinding(finding: Pick<Finding, 'location'>, audience: FindingAudience): boolean;
|
|
134
|
+
/** Per-severity counts, for the header badge. */
|
|
135
|
+
export declare function countBySeverity(entries: readonly {
|
|
136
|
+
severity: IssueSeverity;
|
|
137
|
+
}[]): Record<IssueSeverity, number>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { redirectRecordKind } from '@elytracms/core/persistence';
|
|
2
|
+
import { studioPath } from '@elytracms/studio/router';
|
|
3
|
+
/**
|
|
4
|
+
* The engine's own finding sources, in the order the tray lists them (critic point 2: an
|
|
5
|
+
* EXPLICIT rank, never "registration order" — a source that clears and comes back must not
|
|
6
|
+
* move behind the addons). Reserved: an addon cannot publish under one of these names, or
|
|
7
|
+
* under the `engine:` prefix (it is listed, and reported — never silently merged).
|
|
8
|
+
*/
|
|
9
|
+
export const ENGINE_FINDING_SOURCES = ['config', 'customization', 'routes', 'orphans', 'delivery'];
|
|
10
|
+
/** Where every contributed source ranks: after the engine's, in source-name order. */
|
|
11
|
+
const CONTRIBUTED_RANK = ENGINE_FINDING_SOURCES.length + 1;
|
|
12
|
+
export function isReservedFindingSource(source) {
|
|
13
|
+
return ENGINE_FINDING_SOURCES.includes(source) || source.startsWith('engine:');
|
|
14
|
+
}
|
|
15
|
+
function sourceRank(source) {
|
|
16
|
+
const index = ENGINE_FINDING_SOURCES.indexOf(source);
|
|
17
|
+
return index === -1 ? CONTRIBUTED_RANK : index;
|
|
18
|
+
}
|
|
19
|
+
const SEVERITY_RANK = { error: 0, warning: 1 };
|
|
20
|
+
/**
|
|
21
|
+
* The tray's ONE order: source rank, then severity (errors first), then source name, then key
|
|
22
|
+
* — total and deterministic, whatever order the publishers mounted in.
|
|
23
|
+
*/
|
|
24
|
+
export function orderFindings(findings) {
|
|
25
|
+
return [...findings].sort((a, b) => sourceRank(a.source) - sourceRank(b.source) ||
|
|
26
|
+
SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
|
|
27
|
+
(a.source < b.source ? -1 : a.source > b.source ? 1 : 0) ||
|
|
28
|
+
(a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Where a URL-map issue (EC-469/590/628) is: core addresses a redirect as
|
|
32
|
+
* `['redirects', <record id>]` (an unreadable row without an id: its index), and a route as
|
|
33
|
+
* `['routes', <route id>]` — routes are code, so a route issue is named in `elytra.config.ts`.
|
|
34
|
+
*/
|
|
35
|
+
export function routeIssueLocation(issue) {
|
|
36
|
+
const [domain, id] = issue.path;
|
|
37
|
+
if (domain === 'redirects') {
|
|
38
|
+
return typeof id === 'string'
|
|
39
|
+
? { kind: 'record', recordKind: redirectRecordKind.kind, id }
|
|
40
|
+
: { kind: 'record', recordKind: redirectRecordKind.kind };
|
|
41
|
+
}
|
|
42
|
+
if (domain === 'routes' && typeof id === 'string')
|
|
43
|
+
return { kind: 'config', declaredIn: 'project', entry: id };
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Where "Go to" leads, or what the row names. `null` when the location's view is not in this
|
|
48
|
+
* studio (a record kind no rail lists, a view the consumer did not mount): the message still
|
|
49
|
+
* says what is wrong, there is just nowhere in this studio to open.
|
|
50
|
+
*/
|
|
51
|
+
export function goToTarget(location, views) {
|
|
52
|
+
switch (location.kind) {
|
|
53
|
+
case 'record': {
|
|
54
|
+
const view = views.find((candidate) => candidate.recordKind === location.recordKind);
|
|
55
|
+
if (!view)
|
|
56
|
+
return null;
|
|
57
|
+
return { kind: 'link', splat: location.id === undefined ? view.id : `${view.id}/${location.id}` };
|
|
58
|
+
}
|
|
59
|
+
case 'document':
|
|
60
|
+
return { kind: 'link', splat: studioPath.cmsDocument(location.collection, location.id) };
|
|
61
|
+
case 'view':
|
|
62
|
+
return views.some((candidate) => candidate.id === location.view) ? { kind: 'link', splat: location.view } : null;
|
|
63
|
+
case 'config':
|
|
64
|
+
return { kind: 'named', declaredIn: location.declaredIn, ...(location.entry !== undefined ? { entry: location.entry } : {}) };
|
|
65
|
+
case 'deployment':
|
|
66
|
+
return { kind: 'named', settings: location.settings };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Owner ruling (b), 2026-10-28: config and deployment findings go to admins; a finding
|
|
71
|
+
* located in content goes to anyone who can open that content; nothing is hidden from the
|
|
72
|
+
* role that can act on it. So:
|
|
73
|
+
* - `config` / `deployment` → admins;
|
|
74
|
+
* - `record` → whoever sees the rail that lists the kind; no such rail → admins (nobody else
|
|
75
|
+
* can act on it from this studio, and it is still a real problem);
|
|
76
|
+
* - `view` → whoever sees that view (absent view → admins);
|
|
77
|
+
* - `document` → whoever can read content;
|
|
78
|
+
* - no location → everyone (nothing to gate on, and hiding it could hide it from its fixer).
|
|
79
|
+
*/
|
|
80
|
+
export function canSeeFinding(finding, audience) {
|
|
81
|
+
const location = finding.location;
|
|
82
|
+
if (!location)
|
|
83
|
+
return true;
|
|
84
|
+
switch (location.kind) {
|
|
85
|
+
case 'config':
|
|
86
|
+
case 'deployment':
|
|
87
|
+
return audience.isAdmin;
|
|
88
|
+
case 'record': {
|
|
89
|
+
const view = audience.views.find((candidate) => candidate.recordKind === location.recordKind);
|
|
90
|
+
return view ? view.visible : audience.isAdmin;
|
|
91
|
+
}
|
|
92
|
+
case 'view': {
|
|
93
|
+
const view = audience.views.find((candidate) => candidate.id === location.view);
|
|
94
|
+
return view ? view.visible : audience.isAdmin;
|
|
95
|
+
}
|
|
96
|
+
case 'document':
|
|
97
|
+
return audience.canReadContent;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Per-severity counts, for the header badge. */
|
|
101
|
+
export function countBySeverity(entries) {
|
|
102
|
+
const counts = { error: 0, warning: 0 };
|
|
103
|
+
for (const entry of entries)
|
|
104
|
+
counts[entry.severity] += 1;
|
|
105
|
+
return counts;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=notice-model.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notice-model.js","sourceRoot":"","sources":["../../../src/components/notifications/notice-model.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CmsValidationIssue } from '@elytracms/core/cms-core'\nimport { redirectRecordKind } from '@elytracms/core/persistence'\nimport { studioPath } from '@elytracms/studio/router'\nimport type { IssueSeverity } from '../issue-severity'\n\n/**\n * EC-690 — the model of the studio's ONE notification-and-issue surface (design note and\n * owner ruling in `tasks/EC-690-one-notification-and-issue-surface.md`).\n *\n * Two kinds of entry, two TYPES — never a `dismissable` flag:\n * - a {@link Finding} is PERSISTENT: it is listed while its producer publishes it, and no\n * reader can dismiss it (no API exists for it);\n * - a transient notice (`lib/notices/toast-store.ts` `ToastItem`) is an action's outcome:\n * always dismissable; a success expires, an error stays until dismissed.\n */\n\n/**\n * WHERE a finding is — what the tray's \"Go to\" opens, or the file it names. Exported for\n * addons (`@elytracms/studio/addon`), so a contributed finding can be located too.\n *\n * - `record` — a row of a record rail, by RECORD KIND (`redirect`, `submission`): a\n * consumer names the rail's view (`nav.rail(kind, { id })`), the kind is what the data is.\n * No `id` → the rail's list.\n * - `document` — a CMS document.\n * - `view` — a studio view's home, by view id.\n * - `config` — code, so it is NAMED, never linked: `project` = `elytra.config.ts`, `studio` =\n * the developer's `defineStudio({…})` (its file path is theirs, AGENTS §4). `entry` names the\n * declaration (a route id, a view id).\n * - `deployment` — a setting of the Convex deployment (env vars), named, never linked.\n */\nexport type NoticeLocation =\n | { kind: 'record'; recordKind: string; id?: string }\n | { kind: 'document'; collection: string; id: string }\n | { kind: 'view'; view: string }\n | { kind: 'config'; declaredIn: 'project' | 'studio'; entry?: string }\n | { kind: 'deployment'; settings: readonly string[] }\n\n/** An action a finding's row offers besides \"Go to\" (an engine finding's Retry). */\nexport interface FindingAction {\n label: string\n run(): void\n}\n\n/** One persistent finding, as the tray reads it. */\nexport interface Finding {\n /** Unique across the tray (its publisher's identity + its key). */\n id: string\n /** Who reported it — an engine source (`routes`, `config`, …) or a contributor's name. */\n source: string\n /** Stable within its source. */\n key: string\n severity: IssueSeverity\n /** The localized message — the detail. */\n message: string\n /** A short localized title (\"Invalid redirect\"); absent → the row leads with the message. */\n title?: string\n location?: NoticeLocation\n action?: FindingAction\n}\n\n/**\n * The engine's own finding sources, in the order the tray lists them (critic point 2: an\n * EXPLICIT rank, never \"registration order\" — a source that clears and comes back must not\n * move behind the addons). Reserved: an addon cannot publish under one of these names, or\n * under the `engine:` prefix (it is listed, and reported — never silently merged).\n */\nexport const ENGINE_FINDING_SOURCES = ['config', 'customization', 'routes', 'orphans', 'delivery'] as const\nexport type EngineFindingSource = (typeof ENGINE_FINDING_SOURCES)[number]\n\n/** Where every contributed source ranks: after the engine's, in source-name order. */\nconst CONTRIBUTED_RANK = ENGINE_FINDING_SOURCES.length + 1\n\nexport function isReservedFindingSource(source: string): boolean {\n return (ENGINE_FINDING_SOURCES as readonly string[]).includes(source) || source.startsWith('engine:')\n}\n\nfunction sourceRank(source: string): number {\n const index = (ENGINE_FINDING_SOURCES as readonly string[]).indexOf(source)\n return index === -1 ? CONTRIBUTED_RANK : index\n}\n\nconst SEVERITY_RANK: Record<IssueSeverity, number> = { error: 0, warning: 1 }\n\n/**\n * The tray's ONE order: source rank, then severity (errors first), then source name, then key\n * — total and deterministic, whatever order the publishers mounted in.\n */\nexport function orderFindings(findings: readonly Finding[]): Finding[] {\n return [...findings].sort(\n (a, b) =>\n sourceRank(a.source) - sourceRank(b.source) ||\n SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||\n (a.source < b.source ? -1 : a.source > b.source ? 1 : 0) ||\n (a.key < b.key ? -1 : a.key > b.key ? 1 : 0),\n )\n}\n\n/**\n * Where a URL-map issue (EC-469/590/628) is: core addresses a redirect as\n * `['redirects', <record id>]` (an unreadable row without an id: its index), and a route as\n * `['routes', <route id>]` — routes are code, so a route issue is named in `elytra.config.ts`.\n */\nexport function routeIssueLocation(issue: Pick<CmsValidationIssue, 'path'>): NoticeLocation | undefined {\n const [domain, id] = issue.path\n if (domain === 'redirects') {\n return typeof id === 'string'\n ? { kind: 'record', recordKind: redirectRecordKind.kind, id }\n : { kind: 'record', recordKind: redirectRecordKind.kind }\n }\n if (domain === 'routes' && typeof id === 'string') return { kind: 'config', declaredIn: 'project', entry: id }\n return undefined\n}\n\n/** A studio view as the location resolver needs it. */\nexport interface LocatableView {\n id: string\n /** The record kind a record rail lists (`railView`), absent on every other view. */\n recordKind?: string\n}\n\n/** What a finding's row offers for its location. */\nexport type GoToTarget =\n | { kind: 'link'; splat: string }\n /** `config` / `deployment`: what to name (the file, the settings) — never a link. */\n | { kind: 'named'; declaredIn?: 'project' | 'studio'; entry?: string; settings?: readonly string[] }\n\n/**\n * Where \"Go to\" leads, or what the row names. `null` when the location's view is not in this\n * studio (a record kind no rail lists, a view the consumer did not mount): the message still\n * says what is wrong, there is just nowhere in this studio to open.\n */\nexport function goToTarget(location: NoticeLocation, views: readonly LocatableView[]): GoToTarget | null {\n switch (location.kind) {\n case 'record': {\n const view = views.find((candidate) => candidate.recordKind === location.recordKind)\n if (!view) return null\n return { kind: 'link', splat: location.id === undefined ? view.id : `${view.id}/${location.id}` }\n }\n case 'document':\n return { kind: 'link', splat: studioPath.cmsDocument(location.collection, location.id) }\n case 'view':\n return views.some((candidate) => candidate.id === location.view) ? { kind: 'link', splat: location.view } : null\n case 'config':\n return { kind: 'named', declaredIn: location.declaredIn, ...(location.entry !== undefined ? { entry: location.entry } : {}) }\n case 'deployment':\n return { kind: 'named', settings: location.settings }\n }\n}\n\n/** Who is reading the tray. */\nexport interface FindingAudience {\n /** The role grants `project.administer` — the one who can change config and the deployment. */\n isAdmin: boolean\n /** The role grants `content.read`. */\n canReadContent: boolean\n /** The views this role sees in the rail (`isNavViewVisible`), with the record kind a rail lists. */\n views: readonly (LocatableView & { visible: boolean })[]\n}\n\n/**\n * Owner ruling (b), 2026-10-28: config and deployment findings go to admins; a finding\n * located in content goes to anyone who can open that content; nothing is hidden from the\n * role that can act on it. So:\n * - `config` / `deployment` → admins;\n * - `record` → whoever sees the rail that lists the kind; no such rail → admins (nobody else\n * can act on it from this studio, and it is still a real problem);\n * - `view` → whoever sees that view (absent view → admins);\n * - `document` → whoever can read content;\n * - no location → everyone (nothing to gate on, and hiding it could hide it from its fixer).\n */\nexport function canSeeFinding(finding: Pick<Finding, 'location'>, audience: FindingAudience): boolean {\n const location = finding.location\n if (!location) return true\n switch (location.kind) {\n case 'config':\n case 'deployment':\n return audience.isAdmin\n case 'record': {\n const view = audience.views.find((candidate) => candidate.recordKind === location.recordKind)\n return view ? view.visible : audience.isAdmin\n }\n case 'view': {\n const view = audience.views.find((candidate) => candidate.id === location.view)\n return view ? view.visible : audience.isAdmin\n }\n case 'document':\n return audience.canReadContent\n }\n}\n\n/** Per-severity counts, for the header badge. */\nexport function countBySeverity(entries: readonly { severity: IssueSeverity }[]): Record<IssueSeverity, number> {\n const counts: Record<IssueSeverity, number> = { error: 0, warning: 0 }\n for (const entry of entries) counts[entry.severity] += 1\n return counts\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { OperationError } from '../../lib/operations/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The tray text of a failed operation an owner holds: its localized label and message — or
|
|
4
|
+
* `null` when there is nothing to show. A session expiry is never a notice: it is routed to
|
|
5
|
+
* the blocking re-auth overlay (EC-403), and a tray entry would only duplicate it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function outcomeNoticeMessage(error: OperationError | null): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* EC-690 (critic point 4) — a failed operation its OWNER holds (`persistError`, a rail's
|
|
10
|
+
* `lastError`) as an error notice of the one issues tray, for as long as the owner holds it:
|
|
11
|
+
* raised when it is set, retracted when the owner clears it (the next save, the inline
|
|
12
|
+
* banner's dismiss), and dismissing it in the tray calls `clear` — one state, two views. It
|
|
13
|
+
* stays until dismissed (owner ruling (d)). Registered ONCE, by the hook's caller, which must
|
|
14
|
+
* be the error's real owner — never an edit shell that merely renders it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useOutcomeNotice(error: OperationError | null, clear: () => void): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useId } from 'react';
|
|
2
|
+
import { isSessionExpiredError } from '../../lib/auth-errors.js';
|
|
3
|
+
import { operationErrorLabel, operationErrorMessage } from '../../lib/persona/labels.js';
|
|
4
|
+
import { studioToasts } from '../../lib/notices/toast-store.js';
|
|
5
|
+
/**
|
|
6
|
+
* The tray text of a failed operation an owner holds: its localized label and message — or
|
|
7
|
+
* `null` when there is nothing to show. A session expiry is never a notice: it is routed to
|
|
8
|
+
* the blocking re-auth overlay (EC-403), and a tray entry would only duplicate it.
|
|
9
|
+
*/
|
|
10
|
+
export function outcomeNoticeMessage(error) {
|
|
11
|
+
if (!error || isSessionExpiredError(error))
|
|
12
|
+
return null;
|
|
13
|
+
return `${operationErrorLabel(error.code)} — ${operationErrorMessage(error)}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* EC-690 (critic point 4) — a failed operation its OWNER holds (`persistError`, a rail's
|
|
17
|
+
* `lastError`) as an error notice of the one issues tray, for as long as the owner holds it:
|
|
18
|
+
* raised when it is set, retracted when the owner clears it (the next save, the inline
|
|
19
|
+
* banner's dismiss), and dismissing it in the tray calls `clear` — one state, two views. It
|
|
20
|
+
* stays until dismissed (owner ruling (d)). Registered ONCE, by the hook's caller, which must
|
|
21
|
+
* be the error's real owner — never an edit shell that merely renders it.
|
|
22
|
+
*/
|
|
23
|
+
export function useOutcomeNotice(error, clear) {
|
|
24
|
+
const key = useId();
|
|
25
|
+
const message = outcomeNoticeMessage(error);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (message === null)
|
|
28
|
+
studioToasts.retract(key);
|
|
29
|
+
else
|
|
30
|
+
studioToasts.raise(key, { message, variant: 'error', onDismiss: clear });
|
|
31
|
+
}, [key, message, clear]);
|
|
32
|
+
useEffect(() => () => studioToasts.retract(key), [key]);
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=outcome-notice.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outcome-notice.js","sourceRoot":"","sources":["../../../src/components/notifications/outcome-notice.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,OAAO,CAAA;AAExC,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAA;AACrF,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAA;AAE5D;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAA4B;IAC/D,IAAI,CAAC,KAAK,IAAI,qBAAqB,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACvD,OAAO,GAAG,mBAAmB,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,qBAAqB,CAAC,KAAK,CAAC,EAAE,CAAA;AAC/E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAA4B,EAAE,KAAiB;IAC9E,MAAM,GAAG,GAAG,KAAK,EAAE,CAAA;IACnB,MAAM,OAAO,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAA;IAC3C,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO,KAAK,IAAI;YAAE,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;;YAC1C,YAAY,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAA;IAC/E,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;IACzB,SAAS,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;AACzD,CAAC","sourcesContent":["import { useEffect, useId } from 'react'\nimport type { OperationError } from '../../lib/operations'\nimport { isSessionExpiredError } from '../../lib/auth-errors'\nimport { operationErrorLabel, operationErrorMessage } from '../../lib/persona/labels'\nimport { studioToasts } from '../../lib/notices/toast-store'\n\n/**\n * The tray text of a failed operation an owner holds: its localized label and message — or\n * `null` when there is nothing to show. A session expiry is never a notice: it is routed to\n * the blocking re-auth overlay (EC-403), and a tray entry would only duplicate it.\n */\nexport function outcomeNoticeMessage(error: OperationError | null): string | null {\n if (!error || isSessionExpiredError(error)) return null\n return `${operationErrorLabel(error.code)} — ${operationErrorMessage(error)}`\n}\n\n/**\n * EC-690 (critic point 4) — a failed operation its OWNER holds (`persistError`, a rail's\n * `lastError`) as an error notice of the one issues tray, for as long as the owner holds it:\n * raised when it is set, retracted when the owner clears it (the next save, the inline\n * banner's dismiss), and dismissing it in the tray calls `clear` — one state, two views. It\n * stays until dismissed (owner ruling (d)). Registered ONCE, by the hook's caller, which must\n * be the error's real owner — never an edit shell that merely renders it.\n */\nexport function useOutcomeNotice(error: OperationError | null, clear: () => void): void {\n const key = useId()\n const message = outcomeNoticeMessage(error)\n useEffect(() => {\n if (message === null) studioToasts.retract(key)\n else studioToasts.raise(key, { message, variant: 'error', onDismiss: clear })\n }, [key, message, clear])\n useEffect(() => () => studioToasts.retract(key), [key])\n}\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Project } from '@elytracms/core/studio-core';
|
|
2
|
+
import type { Finding, FindingAudience } from './notice-model.js';
|
|
3
|
+
/**
|
|
4
|
+
* EC-690 — every persistent finding of the studio shell, for the ONE issues tray.
|
|
5
|
+
*
|
|
6
|
+
* The ENGINE's own sources are computed here, in render — config (EC-479), customization
|
|
7
|
+
* (EC-236/467/478), the URL map (EC-469/590/628), orphaned collections (EC-606) and
|
|
8
|
+
* instant-publish health (EC-268) — so they are there on the first paint (no effect has to
|
|
9
|
+
* run before the badge can count them) and no contributor can overwrite them: they are not
|
|
10
|
+
* in the registry's namespace at all. Contributions (`useNavIssues`, a view's `useIssues`)
|
|
11
|
+
* come from the registry. Then the viewer's audience filter (owner ruling (b)) and the ONE
|
|
12
|
+
* order ({@link orderFindings}).
|
|
13
|
+
*
|
|
14
|
+
* The findings hooks moved here from `SectionNav`, which renders none of them any more (the
|
|
15
|
+
* rail foot is only its collapse toggle — owner ruling (a)).
|
|
16
|
+
*/
|
|
17
|
+
export declare function useShellFindings(project: Project): Finding[];
|
|
18
|
+
/**
|
|
19
|
+
* Who is reading (owner ruling (b)): an admin (`project.administer`) sees config and
|
|
20
|
+
* deployment findings; a located finding shows to whoever can open its location. While the
|
|
21
|
+
* role is still loading nobody is an admin and no view is visible, so a gated finding
|
|
22
|
+
* appears once the role is known, never to the wrong reader first.
|
|
23
|
+
*/
|
|
24
|
+
export declare function useFindingAudience(project: Project): FindingAudience;
|
|
25
|
+
/**
|
|
26
|
+
* EC-616 → EC-690: runs every view's always-on findings hook (`useIssues`) — whether or not
|
|
27
|
+
* the view is open — and publishes what it returns. One component per view (each hook keeps
|
|
28
|
+
* its own hook order), each its OWN publisher with the source `view:<id>`: before EC-690 every
|
|
29
|
+
* probe published under the one source `view:`, so two such views overwrote each other and
|
|
30
|
+
* one unmount retracted both (critic point 1).
|
|
31
|
+
*/
|
|
32
|
+
export declare function ViewIssuesProbes({ project }: {
|
|
33
|
+
project: Project;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
35
|
+
/** The source a view's always-on findings publish under. */
|
|
36
|
+
export declare function viewIssuesSource(viewId: string): string;
|