canopycms 0.11.1 → 0.12.1
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/CHANGELOG.md +174 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-changes.d.ts +1 -0
- package/dist/api/branch-changes.js +1 -1
- package/dist/api/branch-entry-changes.d.ts +92 -7
- package/dist/api/branch-entry-changes.js +272 -38
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +17 -2
- package/dist/api/client.js +26 -0
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9713 -1493
- package/dist/cli/generate-ai-content.js +415 -244
- package/dist/cli/init.js +6 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +65 -3
- package/dist/content-store.js +121 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +549 -53
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
- package/dist/editor/components/EntryChangesDrawer.js +164 -162
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +4 -6
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +90 -0
- package/dist/editor/conflict-cards.js +279 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +20 -0
- package/dist/editor/copy.js +20 -0
- package/dist/editor/entry-diff.d.ts +18 -2
- package/dist/editor/entry-diff.js +26 -32
- package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +52 -0
- package/dist/editor/undo-change.js +241 -0
- package/dist/git-manager.d.ts +5 -0
- package/dist/git-manager.js +30 -0
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- package/package.json +1 -1
|
@@ -6,16 +6,19 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
6
6
|
* and jsdiff stay out of the editor's first chunk.
|
|
7
7
|
*/
|
|
8
8
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
-
import { Alert, Badge, Box, Button, Group,
|
|
9
|
+
import { Alert, Badge, Box, Button, Group, Paper, SimpleGrid, Stack, Text, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
10
10
|
import { useMediaQuery } from '@mantine/hooks';
|
|
11
|
-
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil,
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { formatRelativeTime } from '../relative-time.js';
|
|
11
|
+
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil, IconPlus, } from '@tabler/icons-react';
|
|
12
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
13
|
+
import { computeEntryDiff } from '../entry-diff.js';
|
|
14
|
+
import { buildEntryConflicts } from '../conflict-cards.js';
|
|
15
|
+
import { findBodyFieldName } from '../../utils/body-field.js';
|
|
17
16
|
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
17
|
+
import { movedThreadsNote } from '../undo-removal.js';
|
|
18
|
+
import { canSetBack, canopyPathOf, formValueOfSide, holdsAs, setAllBack, setChangeBack, sideOfFormValue, } from '../undo-change.js';
|
|
18
19
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
20
|
+
import { DateValue, MarkedText, ReasonedButton, TEXT_STYLE, Value, ordinal, plural, } from './change-parts.js';
|
|
21
|
+
import { ConflictsGroup } from './ConflictsGroup.js';
|
|
19
22
|
import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
|
|
20
23
|
const KIND_BADGES = {
|
|
21
24
|
added: { label: 'Added', icon: _jsx(IconPlus, { size: 10, "aria-hidden": true }) },
|
|
@@ -23,98 +26,6 @@ const KIND_BADGES = {
|
|
|
23
26
|
changed: { label: 'Changed', icon: _jsx(IconPencil, { size: 10, "aria-hidden": true }) },
|
|
24
27
|
moved: { label: 'Moved', icon: _jsx(IconArrowsMove, { size: 10, "aria-hidden": true }) },
|
|
25
28
|
};
|
|
26
|
-
/**
|
|
27
|
-
* Struck on a tint at least 1.3:1 from the card in either scheme, with text kept at 4.5:1 on it:
|
|
28
|
-
* `red.2` in light, `red.6` at 30% over the dark card.
|
|
29
|
-
*/
|
|
30
|
-
const REMOVED_STYLE = {
|
|
31
|
-
background: 'light-dark(var(--mantine-color-red-2), color-mix(in srgb, var(--mantine-color-red-6) 30%, transparent))',
|
|
32
|
-
color: 'inherit',
|
|
33
|
-
textDecoration: 'line-through',
|
|
34
|
-
textDecorationThickness: 2,
|
|
35
|
-
};
|
|
36
|
-
/** Underlined on a tint, measured as REMOVED_STYLE's: `green.3`, or `green.6` at 30%. */
|
|
37
|
-
const ADDED_STYLE = {
|
|
38
|
-
background: 'light-dark(var(--mantine-color-green-3), color-mix(in srgb, var(--mantine-color-green-6) 30%, transparent))',
|
|
39
|
-
color: 'inherit',
|
|
40
|
-
textDecoration: 'underline',
|
|
41
|
-
textDecorationThickness: 2,
|
|
42
|
-
};
|
|
43
|
-
const TEXT_STYLE = { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
|
|
44
|
-
/** Long text on one side only is cut to this many lines once it has more than LONG_TEXT_LINES. */
|
|
45
|
-
const COLLAPSED_LINES = 50;
|
|
46
|
-
const LONG_TEXT_LINES = 200;
|
|
47
|
-
/** Above this share of a line's text changed, one prefix names the whole line instead of each run. */
|
|
48
|
-
const WHOLE_LINE_SHARE = 0.6;
|
|
49
|
-
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
50
|
-
const ordinal = (n) => {
|
|
51
|
-
const tens = n % 100;
|
|
52
|
-
const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
|
|
53
|
-
return `${n}${suffix}`;
|
|
54
|
-
};
|
|
55
|
-
/**
|
|
56
|
-
* `side`'s runs of `parts`, a marked run joined to the next of its mark across whitespace alone,
|
|
57
|
-
* so a screen reader hears one removal, not one per word.
|
|
58
|
-
*/
|
|
59
|
-
const sideRuns = (parts, side) => {
|
|
60
|
-
const runs = [];
|
|
61
|
-
for (const part of parts) {
|
|
62
|
-
if (side === 'before' ? part.added : part.removed)
|
|
63
|
-
continue;
|
|
64
|
-
const mark = part.removed ? 'removed' : part.added ? 'added' : undefined;
|
|
65
|
-
const last = runs[runs.length - 1];
|
|
66
|
-
const gap = runs[runs.length - 2];
|
|
67
|
-
if (mark && last && last.mark === mark) {
|
|
68
|
-
last.value += part.value;
|
|
69
|
-
}
|
|
70
|
-
else if (mark && last && !last.mark && /^\s+$/.test(last.value) && gap?.mark === mark) {
|
|
71
|
-
runs.pop();
|
|
72
|
-
gap.value += last.value + part.value;
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
runs.push({ value: part.value, ...(mark && { mark }) });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return runs;
|
|
79
|
-
};
|
|
80
|
-
const WHITESPACE_MARKS = {
|
|
81
|
-
' ': { shown: '·', said: 'space' },
|
|
82
|
-
'\t': { shown: '→', said: 'tab' },
|
|
83
|
-
'\n': { shown: '⏎', said: 'line break' },
|
|
84
|
-
};
|
|
85
|
-
/** A run's text; whitespace alone shows a mark for each character, said in words. */
|
|
86
|
-
const RunText = ({ value }) => {
|
|
87
|
-
if (value === '')
|
|
88
|
-
return _jsx("em", { children: "(blank line)" });
|
|
89
|
-
if (!/^\s+$/.test(value))
|
|
90
|
-
return _jsx(_Fragment, { children: value });
|
|
91
|
-
const chars = [...value];
|
|
92
|
-
return (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: chars.map((c) => WHITESPACE_MARKS[c]?.shown ?? c).join('') }), _jsx(VisuallyHidden, { component: "span", children: chars.map((c) => WHITESPACE_MARKS[c]?.said ?? 'space').join(', ') })] }));
|
|
93
|
-
};
|
|
94
|
-
const MARK_WORDS = {
|
|
95
|
-
removed: { start: 'Removed: ', end: ', end of removal', line: 'Removed line: ' },
|
|
96
|
-
added: { start: 'Added: ', end: ', end of addition', line: 'Added line: ' },
|
|
97
|
-
};
|
|
98
|
-
/**
|
|
99
|
-
* One side's text with its runs marked by a strike or an underline and by words, never colour
|
|
100
|
-
* alone. A line mostly changed says so once, up front, instead of run by run.
|
|
101
|
-
*/
|
|
102
|
-
const MarkedText = ({ parts, side, unit, }) => {
|
|
103
|
-
const runs = sideRuns(parts, side);
|
|
104
|
-
const total = runs.reduce((n, run) => n + run.value.length, 0);
|
|
105
|
-
const marked = runs.reduce((n, run) => n + (run.mark ? run.value.length : 0), 0);
|
|
106
|
-
const mark = side === 'before' ? 'removed' : 'added';
|
|
107
|
-
const whole = runs.some((run) => run.mark) && (total === 0 || marked / total > WHOLE_LINE_SHARE);
|
|
108
|
-
return (_jsxs(_Fragment, { children: [whole && (_jsx(VisuallyHidden, { component: "span", children: unit === 'line' ? MARK_WORDS[mark].line : MARK_WORDS[mark].start })), runs.map((run, i) => {
|
|
109
|
-
if (!run.mark)
|
|
110
|
-
return _jsx(React.Fragment, { children: run.value }, i);
|
|
111
|
-
const Tag = run.mark === 'removed' ? 'del' : 'ins';
|
|
112
|
-
const style = run.mark === 'removed' ? REMOVED_STYLE : ADDED_STYLE;
|
|
113
|
-
// Whitespace alone, or a blank line's label, has nothing for a line to cross.
|
|
114
|
-
const shown = /^\s*$/.test(run.value) ? { ...style, textDecoration: 'none' } : style;
|
|
115
|
-
return (_jsxs(Tag, { style: shown, children: [!whole && (_jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].start })), _jsx(RunText, { value: run.value }), !whole && _jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].end })] }, i));
|
|
116
|
-
})] }));
|
|
117
|
-
};
|
|
118
29
|
const Gutter = ({ sign }) => (_jsx(Text, { size: "sm", c: "dimmed", "aria-hidden": true, ta: "center", style: { userSelect: 'none' }, children: sign ?? '' }));
|
|
119
30
|
const Skipped = ({ count, style }) => (_jsx(Text, { size: "xs", c: "dimmed", py: 2, style: style, children: plural(count, 'unchanged line', 'unchanged lines') }));
|
|
120
31
|
const LINE_GRID = {
|
|
@@ -212,77 +123,28 @@ const LineRows = ({ rows }) => {
|
|
|
212
123
|
return (_jsxs(Box, { style: narrow ? { ...LINE_GRID, gridTemplateColumns: '1rem minmax(0, 1fr)' } : LINE_GRID, "data-testid": narrow ? 'entry-change-line-list' : 'entry-change-line-grid', children: [!narrow && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", c: "dimmed", style: { gridColumn: '1 / span 2' }, children: "Before" }), _jsx(Text, { size: "xs", c: "dimmed", style: { gridColumn: '3 / span 2' }, children: "After" })] })), segments.map((segment, i) => segment.long ? (_jsx(LongRun, { rows: segment.rows, side: segment.long, renderRows: renderRows }, i)) : (_jsx(React.Fragment, { children: renderRows(segment.rows) }, i)))] }));
|
|
213
124
|
};
|
|
214
125
|
const WordsChange = ({ text, side, }) => (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: _jsx(MarkedText, { parts: text.parts, side: side, unit: "text" }) }));
|
|
215
|
-
const THUMBNAIL_SIZE = EDITOR_THUMBNAIL_WIDTHS.listRow / 2;
|
|
216
|
-
/** An image's thumbnail beside its name, which says what it is; an icon if it won't load. */
|
|
217
|
-
const Thumbnail = ({ src }) => {
|
|
218
|
-
const { baseUrl } = useAssetContext();
|
|
219
|
-
const [failed, setFailed] = useState(false);
|
|
220
|
-
if (failed) {
|
|
221
|
-
return (_jsx(IconPhotoOff, { size: THUMBNAIL_SIZE, stroke: 1.5, "aria-hidden": true, style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
|
|
222
|
-
}
|
|
223
|
-
return (_jsx("img", { src: editorImageSrc(src, baseUrl, { width: EDITOR_THUMBNAIL_WIDTHS.listRow }), alt: "", width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE, onError: () => setFailed(true), style: { objectFit: 'cover', borderRadius: 4, flexShrink: 0 } }));
|
|
224
|
-
};
|
|
225
|
-
/** Text on one side only; more than LONG_TEXT_LINES lines show COLLAPSED_LINES until asked. */
|
|
226
|
-
const LongText = ({ text }) => {
|
|
227
|
-
const [expanded, setExpanded] = useState(false);
|
|
228
|
-
const firstNew = useRef(null);
|
|
229
|
-
useEffect(() => {
|
|
230
|
-
if (expanded)
|
|
231
|
-
firstNew.current?.focus();
|
|
232
|
-
}, [expanded]);
|
|
233
|
-
const lines = text.split('\n');
|
|
234
|
-
const long = lines.length > LONG_TEXT_LINES;
|
|
235
|
-
const collapsed = !expanded && long;
|
|
236
|
-
return (_jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: !long ? (text) : (_jsxs(_Fragment, { children: [lines.slice(0, COLLAPSED_LINES).join('\n'), expanded && (_jsxs(_Fragment, { children: ['\n', _jsx("span", { ref: firstNew, tabIndex: -1, children: lines.slice(COLLAPSED_LINES).join('\n') })] }))] })) }), collapsed && (_jsxs(Button, { size: "compact-sm", variant: "subtle", onClick: () => setExpanded(true), children: ["Show all ", lines.length, " lines"] }))] }));
|
|
237
|
-
};
|
|
238
|
-
/** A value as the form would show it: titles for references, file names for images. */
|
|
239
|
-
const Value = ({ value, alt, }) => {
|
|
240
|
-
switch (value.kind) {
|
|
241
|
-
case 'empty':
|
|
242
|
-
return (_jsx(Text, { size: "sm", c: "dimmed", children: "Not set" }));
|
|
243
|
-
case 'text':
|
|
244
|
-
return _jsx(LongText, { text: value.text });
|
|
245
|
-
case 'number':
|
|
246
|
-
return _jsx(Text, { size: "sm", children: value.value.toLocaleString() });
|
|
247
|
-
case 'boolean':
|
|
248
|
-
return _jsx(Text, { size: "sm", children: value.value ? 'Yes' : 'No' });
|
|
249
|
-
case 'date':
|
|
250
|
-
return _jsx(DateValue, { value: value });
|
|
251
|
-
case 'select':
|
|
252
|
-
return _jsx(Text, { size: "sm", children: value.label });
|
|
253
|
-
case 'reference':
|
|
254
|
-
return value.title !== undefined ? (_jsx(Text, { size: "sm", children: value.title })) : (_jsx(Text, { size: "sm", c: "dimmed", children: "Entry unavailable" }));
|
|
255
|
-
case 'image':
|
|
256
|
-
return (_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(Thumbnail, { src: value.src }, value.src), _jsxs(Stack, { gap: 0, miw: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: value.name }), value.alt && (_jsxs(Text, { size: "xs", style: TEXT_STYLE, children: [_jsxs(Text, { span: true, size: "xs", c: "dimmed", children: ["Alt text:", ' '] }), alt?.text.mode === 'words' ? (_jsx(MarkedText, { parts: alt.text.parts, side: alt.side, unit: "text" })) : (value.alt)] }))] })] }));
|
|
257
|
-
case 'position':
|
|
258
|
-
return (_jsxs(Text, { size: "sm", children: [ordinal(value.position), " of ", value.of] }));
|
|
259
|
-
case 'list':
|
|
260
|
-
return (_jsx(List, { size: "sm", spacing: 4, children: value.items.map((item, i) => (_jsx(List.Item, { children: _jsx(Value, { value: item }) }, i))) }));
|
|
261
|
-
case 'fields':
|
|
262
|
-
return (_jsx(Stack, { gap: 6, children: value.fields.map((field, i) => (_jsxs("div", { children: [_jsx(Text, { size: "xs", fw: 500, children: field.label }), _jsx(Value, { value: field.value })] }, i))) }));
|
|
263
|
-
case 'raw':
|
|
264
|
-
return (_jsx(Text, { size: "xs", ff: "monospace", style: TEXT_STYLE, children: value.text }));
|
|
265
|
-
}
|
|
266
|
-
};
|
|
267
|
-
/**
|
|
268
|
-
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
269
|
-
* place the editor shows a date absolute first and relative after. A day has no relative form: it
|
|
270
|
-
* may be a future event, and a past one is not hours ago.
|
|
271
|
-
*/
|
|
272
|
-
const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), !value.dateOnly && (_jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.iso) }))] }));
|
|
273
126
|
const SideColumn = ({ label, change, side, }) => {
|
|
274
127
|
const value = change[side] ?? { kind: 'empty' };
|
|
275
128
|
const words = change.text?.mode === 'words' ? change.text : undefined;
|
|
276
129
|
return (_jsxs(Stack, { gap: 4, miw: 0, children: [_jsx(Text, { size: "xs", c: "dimmed", children: label }), value.kind === 'date' && words ? (_jsx(DateValue, { value: value, text: _jsx(MarkedText, { parts: words.parts, side: side, unit: "text" }) })) : words ? (_jsx(WordsChange, { text: words, side: side })) : (_jsx(Value, { value: value, alt: change.altText && { text: change.altText, side } }))] }));
|
|
277
130
|
};
|
|
131
|
+
const UNDONE_IN_FORM = 'Undone in the form. Save to keep it.';
|
|
132
|
+
const ALL_UNDONE_IN_FORM = 'All changes undone in the form. Save to keep them.';
|
|
133
|
+
// Undo and redo replace what the form holds at a change with the base's or the saved value, so
|
|
134
|
+
// neither acts on a change the form has edited since: the edit would be lost.
|
|
135
|
+
const UNSAVED_EDIT_REASON = 'This change has unsaved edits. Save them, or choose "Discard changes" in the entry\'s menu, on its name, first.';
|
|
136
|
+
const UNSAVED_EDITS_REASON = 'Some of these changes have unsaved edits. Save them, or choose "Discard changes" in the entry\'s menu, on its name, first.';
|
|
137
|
+
/** Beside the undo controls, where an undo makes the entry unsaved, in place of a new notice. */
|
|
138
|
+
const UNSAVED_IN_FORM = 'Unsaved edits, undos included, apply when you save.';
|
|
139
|
+
const NOT_IN_FORM_REASON = 'The form no longer has the item this change is in, so it can’t be undone here.';
|
|
278
140
|
/** One change: its labelled breadcrumb, its kind in a word, and the values on each side. */
|
|
279
|
-
const ChangeCard = ({ change, wholeEntry, }) => {
|
|
141
|
+
const ChangeCard = ({ change, wholeEntry, undo }) => {
|
|
280
142
|
const moved = change.kind === 'moved' &&
|
|
281
143
|
change.before?.kind === 'position' &&
|
|
282
144
|
change.after?.kind === 'position'
|
|
283
145
|
? { from: change.before.position, to: change.after.position, of: change.after.of }
|
|
284
146
|
: undefined;
|
|
285
|
-
return (_jsxs(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", "data-testid": "entry-change-card", children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", mb: "xs", align: "flex-start", children: [_jsx(Text, { component: "h3", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: change.path.join(' › ') }), _jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: KIND_BADGES[change.kind].icon, style: { flexShrink: 0 }, children: KIND_BADGES[change.kind].label })] }), moved ? (_jsxs(Text, { size: "sm", children: ["Moved from ", ordinal(moved.from), " to ", ordinal(moved.to), " of ", moved.of] })) : wholeEntry ? (_jsx(Value, { value: change[wholeEntry] ?? { kind: 'empty' } })) : change.text?.mode === 'lines' ? (_jsx(LineRows, { rows: change.text.rows })) : change.text?.mode === 'endLineBreak' ? (_jsx(Text, { size: "sm", children: change.text.added ? 'Line break added at the end' : 'Line break removed at the end' })) : (_jsxs(SimpleGrid, { cols: { base: 1, md: 2 }, spacing: "sm", children: [_jsx(SideColumn, { label: "Before", change: change, side: "before" }), _jsx(SideColumn, { label: "After", change: change, side: "after" })] })), change.tooLargeToMark && (_jsx(Text, { size: "xs", c: "dimmed", mt: "xs", "data-testid": "entry-change-unmarked", children: "Too many changes to highlight." }))] }));
|
|
147
|
+
return (_jsxs(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", "data-testid": "entry-change-card", children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", mb: "xs", align: "flex-start", children: [_jsx(Text, { component: "h3", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: change.path.join(' › ') }), _jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: KIND_BADGES[change.kind].icon, style: { flexShrink: 0 }, children: KIND_BADGES[change.kind].label })] }), moved ? (_jsxs(Text, { size: "sm", children: ["Moved from ", ordinal(moved.from), " to ", ordinal(moved.to), " of ", moved.of] })) : wholeEntry ? (_jsx(Value, { value: change[wholeEntry] ?? { kind: 'empty' } })) : change.text?.mode === 'lines' ? (_jsx(LineRows, { rows: change.text.rows })) : change.text?.mode === 'endLineBreak' ? (_jsx(Text, { size: "sm", children: change.text.added ? 'Line break added at the end' : 'Line break removed at the end' })) : (_jsxs(SimpleGrid, { cols: { base: 1, md: 2 }, spacing: "sm", children: [_jsx(SideColumn, { label: "Before", change: change, side: "before" }), _jsx(SideColumn, { label: "After", change: change, side: "after" })] })), change.tooLargeToMark && (_jsx(Text, { size: "xs", c: "dimmed", mt: "xs", "data-testid": "entry-change-unmarked", children: "Too many changes to highlight." })), undo && (_jsxs(Group, { gap: "xs", mt: "xs", wrap: "wrap", align: "center", children: [_jsx(ReasonedButton, { reason: undo.reason, onClick: undo.onToggle, "aria-label": `${undo.undone ? EDITOR_ACTIONS.redoChange : EDITOR_ACTIONS.undoChange} to ${change.path.join(' › ')}`, "data-testid": "entry-change-undo", children: undo.undone ? EDITOR_ACTIONS.redoChange : EDITOR_ACTIONS.undoChange }), _jsx(Text, { size: "xs", "data-testid": "entry-change-undone", children: undo.undone ? [UNDONE_IN_FORM, undo.note].filter(Boolean).join(' ') : '' })] }))] }));
|
|
286
148
|
};
|
|
287
149
|
/**
|
|
288
150
|
* The summary's place, a status region from the first render on, so a screen reader hears
|
|
@@ -311,8 +173,33 @@ const changeSummary = (changes, base) => {
|
|
|
311
173
|
];
|
|
312
174
|
return `${parts.join(', ')} since ${base}`;
|
|
313
175
|
};
|
|
314
|
-
/** The
|
|
315
|
-
|
|
176
|
+
/** The conflicts' place: loading, their failure with Retry, or the group; nothing for none. */
|
|
177
|
+
const ConflictsSection = ({ conflicts, subject, referenceTitle, undo, noEditReason, lockedReason }) => {
|
|
178
|
+
const { data, error, isValidating, reload } = conflicts.state;
|
|
179
|
+
const bodyField = useMemo(() => {
|
|
180
|
+
const side = data && Object.values(data.versions).find((v) => 'body' in v && v.body !== undefined);
|
|
181
|
+
return side && 'data' in side
|
|
182
|
+
? (side.bodyFieldName ?? findBodyFieldName(subject.fields))
|
|
183
|
+
: undefined;
|
|
184
|
+
}, [data, subject.fields]);
|
|
185
|
+
// Regions apply only to the text your saved value holds, so the cards are built against it.
|
|
186
|
+
const saved = noEditReason ? undefined : undo?.saved;
|
|
187
|
+
const built = useMemo(() => data && buildEntryConflicts(data, subject.fields, bodyField, referenceTitle, saved), [data, subject.fields, bodyField, referenceTitle, saved]);
|
|
188
|
+
if (data === null)
|
|
189
|
+
return null;
|
|
190
|
+
if (error && !data) {
|
|
191
|
+
return (_jsx(Alert, { variant: "light", color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), "data-testid": "conflicts-failed", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: "Couldn\u2019t load this entry\u2019s conflicts." }), _jsx(Button, { size: "compact-sm", variant: "subtle", loading: isValidating, onClick: () => void reload(), children: "Retry" })] }) }));
|
|
192
|
+
}
|
|
193
|
+
if (!data || !built)
|
|
194
|
+
return _jsx(LoadingCards, {});
|
|
195
|
+
return (_jsx(ConflictsGroup, { data: data, conflicts: built, adminBaseBranch: conflicts.adminBaseBranch, choices: conflicts.choices, form: !noEditReason && undo?.saved
|
|
196
|
+
? { value: undo.value, saved: undo.saved, update: undo.update, bodyField }
|
|
197
|
+
: undefined, onChoose: conflicts.onChoose, onEdit: conflicts.onEdit, onResolveEntry: conflicts.onResolveEntry, noEditReason: noEditReason, lockedReason: lockedReason, unsavedEdits: conflicts.unsavedEdits ?? subject.unsavedEdits, entryDeleted: subject.status === 'deleted' }));
|
|
198
|
+
};
|
|
199
|
+
/** The drawer's body: the entry's conflicts, if any, above its changes. */
|
|
200
|
+
export const EntryChangesView = (props) => props.conflicts ? (_jsxs(Stack, { gap: "lg", children: [_jsx(ConflictsSection, { conflicts: props.conflicts, subject: props.subject, referenceTitle: props.referenceTitle, undo: props.undo, noEditReason: props.noEditReason, lockedReason: props.lockedReason }), _jsx(EntryChangesBody, { ...props })] })) : (_jsx(EntryChangesBody, { ...props }));
|
|
201
|
+
/** The changes: their states, then the summary and one card per change. */
|
|
202
|
+
const EntryChangesBody = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl, undo, noEditReason, lockedReason, onDeleteEntry, onRestoreEntry, }) => {
|
|
316
203
|
const { data, error } = state;
|
|
317
204
|
const diff = useMemo(() => data &&
|
|
318
205
|
computeEntryDiff({
|
|
@@ -321,6 +208,33 @@ export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenE
|
|
|
321
208
|
after: data.after,
|
|
322
209
|
referenceTitle,
|
|
323
210
|
}), [data, subject.fields, referenceTitle]);
|
|
211
|
+
// The changes the form still holds: the base against the form's value. A saved change missing
|
|
212
|
+
// from them is undone in the form, so the drawer keeps no undo state of its own.
|
|
213
|
+
const formValue = noEditReason ? undefined : undo?.value;
|
|
214
|
+
const heldKeys = useMemo(() => {
|
|
215
|
+
if (!formValue || !data?.before || !data.after)
|
|
216
|
+
return undefined;
|
|
217
|
+
const held = computeEntryDiff({
|
|
218
|
+
fields: subject.fields,
|
|
219
|
+
before: data.before,
|
|
220
|
+
after: sideOfFormValue(formValue, data.after),
|
|
221
|
+
marks: false,
|
|
222
|
+
});
|
|
223
|
+
return new Set(held.changes.map((change) => change.key));
|
|
224
|
+
}, [formValue, data, subject.fields]);
|
|
225
|
+
// What taking out an item did to its threads, said beside its undo until it is redone.
|
|
226
|
+
const [notes, setNotes] = useState({});
|
|
227
|
+
// What the last undo or redo did, said once here rather than by every card it touched. `id`
|
|
228
|
+
// re-keys the text, so a second action with the same words is announced too.
|
|
229
|
+
const [announcement, setAnnouncement] = useState();
|
|
230
|
+
const announce = (text) => setAnnouncement((last) => ({ id: (last?.id ?? 0) + 1, text }));
|
|
231
|
+
const [restoring, setRestoring] = useState(false);
|
|
232
|
+
const [restoreRefusal, setRestoreRefusal] = useState();
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
setNotes({});
|
|
235
|
+
setAnnouncement(undefined);
|
|
236
|
+
setRestoreRefusal(undefined);
|
|
237
|
+
}, [subject.logicalPath, undo?.saved]);
|
|
324
238
|
// A refusal no retry passes: its reason, and the ways the entry can still be looked at.
|
|
325
239
|
const refusal = error?.kind === 'tooLarge' || error?.kind === 'notFound' || error?.kind === 'unreadable'
|
|
326
240
|
? error.kind
|
|
@@ -349,7 +263,95 @@ export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenE
|
|
|
349
263
|
: movedFrom
|
|
350
264
|
? undefined
|
|
351
265
|
: 'Only formatting changed; no field values differ.';
|
|
352
|
-
|
|
266
|
+
// Undo sets an edited entry's values back; an entry type change has no field to set back.
|
|
267
|
+
const typeChanged = diff.changes.some((change) => !canSetBack(change));
|
|
268
|
+
const undoable = !!undo && !!heldKeys && !!data.before && !!data.after && count > 0 && !typeChanged;
|
|
269
|
+
// The field the diff keys an md/mdx body under, as `computeEntryDiff` picks it.
|
|
270
|
+
const bodySide = data.after ?? data.before;
|
|
271
|
+
const bodyField = bodySide?.body === undefined
|
|
272
|
+
? undefined
|
|
273
|
+
: (bodySide.bodyFieldName ?? findBodyFieldName(subject.fields));
|
|
274
|
+
const baseValue = data.before && formValueOfSide(data.before);
|
|
275
|
+
const savedValue = data.after && (undo?.saved ?? formValueOfSide(data.after));
|
|
276
|
+
const isUndone = (change) => !!heldKeys && !heldKeys.has(change.key);
|
|
277
|
+
const allUndone = undoable && diff.changes.every(isUndone);
|
|
278
|
+
// The form holds `change` as neither the side it was last set to has it, the saved value
|
|
279
|
+
// or, once undone, the base's: an edit made since, which an undo or redo would replace.
|
|
280
|
+
const editedSince = (change) => !!undo &&
|
|
281
|
+
!!savedValue &&
|
|
282
|
+
!!baseValue &&
|
|
283
|
+
!holdsAs(undo.value, isUndone(change) ? baseValue : savedValue, change, bodyField);
|
|
284
|
+
const cardUndo = (change) => {
|
|
285
|
+
if (!undoable || !undo || !baseValue || !savedValue)
|
|
286
|
+
return undefined;
|
|
287
|
+
const held = isUndone(change);
|
|
288
|
+
const source = held ? savedValue : baseValue;
|
|
289
|
+
const placeable = !!setChangeBack(undo.value, source, change, bodyField);
|
|
290
|
+
// A change the form can't take back, such as one inside an item it no longer has, isn't
|
|
291
|
+
// offered as undone.
|
|
292
|
+
const undone = held && placeable;
|
|
293
|
+
return {
|
|
294
|
+
undone,
|
|
295
|
+
reason: lockedReason ??
|
|
296
|
+
(!placeable ? NOT_IN_FORM_REASON : editedSince(change) ? UNSAVED_EDIT_REASON : undefined),
|
|
297
|
+
note: undone ? notes[change.key] : undefined,
|
|
298
|
+
onToggle: () => {
|
|
299
|
+
// Taking out an item the branch added moves its threads to the entry, as a Remove does.
|
|
300
|
+
const path = !undone && change.item && change.kind === 'added'
|
|
301
|
+
? canopyPathOf(undo.value, change, bodyField)
|
|
302
|
+
: undefined;
|
|
303
|
+
const note = path && undo.threadsWithin && movedThreadsNote(undo.threadsWithin(path));
|
|
304
|
+
setNotes(({ [change.key]: _dropped, ...rest }) => note ? { ...rest, [change.key]: note } : rest);
|
|
305
|
+
const where = change.path.join(' › ');
|
|
306
|
+
announce(undone
|
|
307
|
+
? `Redone: ${where}.`
|
|
308
|
+
: [`Undone: ${where}. Save to keep it.`, note].filter(Boolean).join(' '));
|
|
309
|
+
undo.update((value) => setChangeBack(value, source, change, bodyField) ?? value);
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
const sameCollection = !!subject.from &&
|
|
314
|
+
subject.from.slice(0, subject.from.lastIndexOf('/')) ===
|
|
315
|
+
subject.logicalPath.slice(0, subject.logicalPath.lastIndexOf('/'));
|
|
316
|
+
const editable = !noEditReason;
|
|
317
|
+
const restorable = !!data.before && !data.after && !!onRestoreEntry;
|
|
318
|
+
// With one change, its card's "Undo change" is the whole of "Undo all changes".
|
|
319
|
+
const undoAll = undoable && count > 1;
|
|
320
|
+
const entryControls = editable && (undoAll || (!data.before && onDeleteEntry) || restorable);
|
|
321
|
+
const restore = async () => {
|
|
322
|
+
if (!onRestoreEntry || restoring)
|
|
323
|
+
return;
|
|
324
|
+
setRestoring(true);
|
|
325
|
+
setRestoreRefusal(undefined);
|
|
326
|
+
try {
|
|
327
|
+
setRestoreRefusal(await onRestoreEntry());
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
setRestoring(false);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
return (_jsxs(Stack, { gap: "sm", children: [_jsxs(StatusRegion, { children: [movedFrom && (_jsxs(Text, { size: "sm", style: { overflowWrap: 'anywhere' }, "data-testid": "entry-changes-moved", children: ["Moved from ", movedFrom] })), summary && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-summary", children: summary }))] }), errorNotice, undoable && (_jsx(VisuallyHidden, { role: "status", "data-testid": "entry-changes-announcement", children: announcement && _jsx("span", { children: announcement.text }, announcement.id) })), subject.unsavedEdits && !undoable && (_jsx(Alert, { variant: "light", color: "blue", icon: _jsx(IconInfoCircle, { size: 16 }), "data-testid": "entry-changes-unsaved", children: _jsx(Text, { size: "sm", children: "Unsaved edits aren\u2019t included. Save to include them." }) })), noEditReason && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-no-edit", children: noEditReason })), editable && undo && typeChanged && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-type-changed", children: "This entry\u2019s type changed on this branch, so its changes can\u2019t be undone here." })), editable && undo && movedFrom && (_jsxs(Text, { size: "sm", "data-testid": "entry-changes-move-hint", children: ["Undoing changes doesn\u2019t move the entry back.", sameCollection && ` To move it back, use "${EDITOR_ACTIONS.changeUrl}…".`] })), editable && lockedReason && (undoable || entryControls) && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-locked", children: lockedReason })), entryControls && (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [undoAll && undo && baseValue && savedValue && (_jsx(ReasonedButton, { reason: lockedReason ?? (diff.changes.some(editedSince) ? UNSAVED_EDITS_REASON : undefined), onClick: () => {
|
|
334
|
+
if (allUndone) {
|
|
335
|
+
setNotes({});
|
|
336
|
+
announce('All changes redone in the form.');
|
|
337
|
+
undo.update((value) => setAllBack(value, savedValue, diff.changes, bodyField));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// Taking out the items the branch added moves their threads, as a Remove does.
|
|
341
|
+
const moved = diff.changes.flatMap((change) => {
|
|
342
|
+
if (isUndone(change) || !change.item || change.kind !== 'added')
|
|
343
|
+
return [];
|
|
344
|
+
const path = canopyPathOf(undo.value, change, bodyField);
|
|
345
|
+
const count = path && undo.threadsWithin ? undo.threadsWithin(path) : 0;
|
|
346
|
+
return count > 0 ? [{ key: change.key, count }] : [];
|
|
347
|
+
});
|
|
348
|
+
setNotes(Object.fromEntries(moved.map(({ key, count }) => [key, movedThreadsNote(count) ?? ''])));
|
|
349
|
+
const total = moved.reduce((sum, { count }) => sum + count, 0);
|
|
350
|
+
announce([ALL_UNDONE_IN_FORM, movedThreadsNote(total)].filter(Boolean).join(' '));
|
|
351
|
+
undo.update((value) => setAllBack(value, baseValue, diff.changes, bodyField));
|
|
352
|
+
}, "data-testid": "entry-changes-undo-all", children: allUndone ? EDITOR_ACTIONS.redoAllChanges : EDITOR_ACTIONS.undoAllChanges })), !data.before && onDeleteEntry && (_jsx(ReasonedButton, { reason: lockedReason, onClick: onDeleteEntry, danger: true, "data-testid": "entry-changes-delete", children: EDITOR_ACTIONS.deleteEntry })), restorable && (_jsx(ReasonedButton, { reason: lockedReason, onClick: () => void restore(), loading: restoring, "data-testid": "entry-changes-restore", children: EDITOR_ACTIONS.restoreEntry }))] })), undoable && (
|
|
353
|
+
// Its own line, held from the start, so the text arriving with an undo shifts nothing.
|
|
354
|
+
_jsx(Text, { size: "xs", mih: 20, "data-testid": "entry-changes-all-undone", children: allUndone ? ALL_UNDONE_IN_FORM : subject.unsavedEdits ? UNSAVED_IN_FORM : '' })), editable && restoreRefusal && (_jsx(Alert, { variant: "light", color: restoreRefusal.tone === 'warning' ? 'yellow' : 'red', icon: restoreRefusal.tone === 'warning' ? (_jsx(IconAlertTriangle, { size: 16 })) : (_jsx(IconAlertCircle, { size: 16 })), "data-testid": "entry-changes-restore-refused", children: _jsx(Text, { size: "sm", children: restoreRefusal.message }) })), count > 0 && (_jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: diff.changes.map((change) => (_jsx(ChangeCard, { change: change, wholeEntry: wholeEntry, undo: editable ? cardUndo(change) : undefined }, change.key))) }))] }));
|
|
353
355
|
};
|
|
354
356
|
/**
|
|
355
357
|
* A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
|
|
@@ -69,6 +69,8 @@ export interface EntryHeaderProps {
|
|
|
69
69
|
branchPullRequestClosed?: boolean;
|
|
70
70
|
/** The server refuses Approve on this branch (`describeBranchLock`). */
|
|
71
71
|
branchApproveBlocked?: boolean;
|
|
72
|
+
/** Why content writes wait though the branch isn't locked: the latest changes coming in. */
|
|
73
|
+
writesWaitReason?: string;
|
|
72
74
|
}
|
|
73
75
|
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
74
|
-
export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, branchApproveBlocked, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
76
|
+
export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, branchApproveBlocked, writesWaitReason, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -5,7 +5,7 @@ import { useMediaQuery } from '@mantine/hooks';
|
|
|
5
5
|
import { IconArrowsDiff } from '@tabler/icons-react';
|
|
6
6
|
import { SHELL_HEADER_HEIGHT, SHELL_REGION_BORDER } from '../shell/EditorShell.js';
|
|
7
7
|
import { describeBranchLock } from '../branch-lock.js';
|
|
8
|
-
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
|
+
import { EDITOR_ACTIONS, NO_EDIT_ACCESS_REASON } from '../copy.js';
|
|
9
9
|
import { EntryBreadcrumb } from './EntryBreadcrumb.js';
|
|
10
10
|
import { EntrySaveButton } from './EntrySaveButton.js';
|
|
11
11
|
/**
|
|
@@ -28,7 +28,7 @@ const ShowChangesButton = ({ onClick, buttonRef, }) => {
|
|
|
28
28
|
return (_jsx(Button, { ref: placeholder ? undefined : buttonRef, size: "compact-sm", variant: "subtle", leftSection: icon, onClick: onClick, "data-testid": placeholder ? 'entry-show-changes-pending' : 'entry-show-changes', ...hold, children: EDITOR_ACTIONS.showChanges }));
|
|
29
29
|
};
|
|
30
30
|
/** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
|
|
31
|
-
export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending = false, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, branchApproveBlocked = false, }) {
|
|
31
|
+
export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending = false, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, branchApproveBlocked = false, writesWaitReason, }) {
|
|
32
32
|
const branchLockReason = describeBranchLock({
|
|
33
33
|
branchStatus,
|
|
34
34
|
branchReadOnly,
|
|
@@ -38,10 +38,8 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
|
|
|
38
38
|
reviewing: branchReviewing,
|
|
39
39
|
pullRequestClosed: branchPullRequestClosed,
|
|
40
40
|
approveBlocked: branchApproveBlocked,
|
|
41
|
-
});
|
|
42
|
-
const entryLockReason = entryReadOnly
|
|
43
|
-
? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
|
|
44
|
-
: undefined;
|
|
41
|
+
}) ?? writesWaitReason;
|
|
42
|
+
const entryLockReason = entryReadOnly ? NO_EDIT_ACCESS_REASON : undefined;
|
|
45
43
|
// The Tooltip hides it while the editor is busy.
|
|
46
44
|
const saveBlockedReason = branchLockReason ??
|
|
47
45
|
(!branchName
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Ref } from 'react';
|
|
2
|
+
import { type BaseUpdateControl } from '../base-update-wait.js';
|
|
3
|
+
/** The Tooltip's text: why it waits, why it can't act, or what it does. */
|
|
4
|
+
export declare function getLatestChangesReason(control: BaseUpdateControl): string;
|
|
5
|
+
/**
|
|
6
|
+
* "Get latest changes", or "Getting latest changes…" with a spinner while the request stands. It
|
|
7
|
+
* takes the wider label's width either way, so the bar never shifts. It can't act while it works
|
|
8
|
+
* or while something blocks it, and stays focusable (`aria-disabled`) with its Tooltip saying why.
|
|
9
|
+
* Only a block dims it: the working label is progress, read at full contrast.
|
|
10
|
+
*/
|
|
11
|
+
export declare function GetLatestChangesButton({ control, buttonRef, testId, }: {
|
|
12
|
+
control: BaseUpdateControl;
|
|
13
|
+
buttonRef?: Ref<HTMLButtonElement>;
|
|
14
|
+
testId?: string;
|
|
15
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Button, Loader, Tooltip } from '@mantine/core';
|
|
4
|
+
import { IconRefresh } from '@tabler/icons-react';
|
|
5
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
6
|
+
import { GETTING_LATEST_CHANGES, SLOW_NOTICE, WAITING_NOTICE, } from '../base-update-wait.js';
|
|
7
|
+
const TOOLTIP_EVENTS = { hover: true, focus: true, touch: true };
|
|
8
|
+
/** The Tooltip's text: why it waits, why it can't act, or what it does. */
|
|
9
|
+
export function getLatestChangesReason(control) {
|
|
10
|
+
if (control.requesting || control.wait !== 'none') {
|
|
11
|
+
return control.wait === 'slow' ? SLOW_NOTICE : WAITING_NOTICE;
|
|
12
|
+
}
|
|
13
|
+
return control.blockedReason ?? control.tooltip;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* "Get latest changes", or "Getting latest changes…" with a spinner while the request stands. It
|
|
17
|
+
* takes the wider label's width either way, so the bar never shifts. It can't act while it works
|
|
18
|
+
* or while something blocks it, and stays focusable (`aria-disabled`) with its Tooltip saying why.
|
|
19
|
+
* Only a block dims it: the working label is progress, read at full contrast.
|
|
20
|
+
*/
|
|
21
|
+
export function GetLatestChangesButton({ control, buttonRef, testId = 'get-latest-changes-button', }) {
|
|
22
|
+
const working = control.requesting || control.wait !== 'none';
|
|
23
|
+
const blocked = working || !!control.blockedReason;
|
|
24
|
+
const tooltip = getLatestChangesReason(control);
|
|
25
|
+
return (_jsx(Tooltip, { label: tooltip, events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { ref: buttonRef, size: "sm", variant: "default", style: { flexShrink: 0 }, leftSection: working ? (_jsx(Loader, { size: 14, "aria-hidden": "true" })) : (_jsx(IconRefresh, { size: 14, "aria-hidden": "true" })), onClick: () => {
|
|
26
|
+
if (!blocked)
|
|
27
|
+
control.onRequest();
|
|
28
|
+
}, "data-disabled": blocked && !working ? true : undefined, "aria-disabled": blocked ? true : undefined, "data-working": working || undefined, "data-testid": testId, children: _jsxs("span", { style: { display: 'inline-grid' }, children: [_jsx("span", { style: { gridArea: '1 / 1', visibility: working ? 'hidden' : undefined }, children: EDITOR_ACTIONS.getLatestChanges }), _jsx("span", { style: { gridArea: '1 / 1', visibility: working ? undefined : 'hidden' }, children: GETTING_LATEST_CHANGES })] }) }) }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a release came to: released (or already gone), or refused with a message and its tone,
|
|
3
|
+
* `warning` where nothing is lost yet or the outcome is unknown.
|
|
4
|
+
*/
|
|
5
|
+
export type ReleaseOutcome = {
|
|
6
|
+
released: true;
|
|
7
|
+
} | {
|
|
8
|
+
error: string;
|
|
9
|
+
tone: 'warning' | 'error';
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* An admin's way out of a branch held because its conflict record was set aside
|
|
13
|
+
* (api/merge-conflicts.ts `releaseHold`), behind a confirm that says what is lost. A refusal shows
|
|
14
|
+
* in the confirm, which stays open; a release closes it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ReleaseHoldButton({ branchLabel, onRelease, }: {
|
|
17
|
+
/** The branch as the bar names it, which the confirm names. */
|
|
18
|
+
branchLabel?: string;
|
|
19
|
+
onRelease: () => Promise<ReleaseOutcome>;
|
|
20
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Button, Text } from '@mantine/core';
|
|
5
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
6
|
+
import { releaseHoldConsequences } from '../conflict-notice.js';
|
|
7
|
+
import { ConfirmDeleteModal } from './ConfirmDeleteModal.js';
|
|
8
|
+
/**
|
|
9
|
+
* An admin's way out of a branch held because its conflict record was set aside
|
|
10
|
+
* (api/merge-conflicts.ts `releaseHold`), behind a confirm that says what is lost. A refusal shows
|
|
11
|
+
* in the confirm, which stays open; a release closes it.
|
|
12
|
+
*/
|
|
13
|
+
export function ReleaseHoldButton({ branchLabel, onRelease, }) {
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
const [releasing, setReleasing] = useState(false);
|
|
16
|
+
const [refusal, setRefusal] = useState();
|
|
17
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { size: "compact-sm", variant: "default", style: { flexShrink: 0 }, onClick: () => {
|
|
18
|
+
setRefusal(undefined);
|
|
19
|
+
setOpen(true);
|
|
20
|
+
}, "data-testid": "release-hold", children: EDITOR_ACTIONS.releaseHold }), _jsx(ConfirmDeleteModal, { isOpen: open, title: branchLabel ? `Release the hold on "${branchLabel}"?` : "Release this branch's hold?", message: releaseHoldConsequences, confirmLabel: EDITOR_ACTIONS.releaseHold, loading: releasing, onClose: () => setOpen(false), onConfirm: async () => {
|
|
21
|
+
setReleasing(true);
|
|
22
|
+
setRefusal(undefined);
|
|
23
|
+
try {
|
|
24
|
+
const outcome = await onRelease();
|
|
25
|
+
if ('released' in outcome)
|
|
26
|
+
setOpen(false);
|
|
27
|
+
else
|
|
28
|
+
setRefusal(outcome);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
setReleasing(false);
|
|
32
|
+
}
|
|
33
|
+
}, children: refusal && (_jsx(Text, { size: "sm", role: "alert", c: refusal.tone === 'warning'
|
|
34
|
+
? 'var(--mantine-color-yellow-text)'
|
|
35
|
+
: 'var(--mantine-color-red-text)', "data-testid": "release-hold-refusal", children: refusal.error })) })] }));
|
|
36
|
+
}
|
|
@@ -6,5 +6,5 @@ import { Anchor, Button, Code, Group, Stack, Text } from '@mantine/core';
|
|
|
6
6
|
export function RetryToastMessage({ message, onRetry, actionLabel = 'Retry', details, }) {
|
|
7
7
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
8
8
|
const detailsId = useId();
|
|
9
|
-
return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
|
|
9
|
+
return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, style: { flexShrink: 0 }, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
|
|
10
10
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the "Show changes" drawer shows a value and a text change, shared by its change cards and its
|
|
3
|
+
* conflict cards: marked text, values as the form would show them, and the drawer's reasoned button.
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import type { DiffPart, DisplayValue, TextDiff } from '../entry-diff.js';
|
|
7
|
+
export declare const TEXT_STYLE: React.CSSProperties;
|
|
8
|
+
export declare const plural: (count: number, one: string, many: string) => string;
|
|
9
|
+
export declare const ordinal: (n: number) => string;
|
|
10
|
+
export type Side = 'before' | 'after';
|
|
11
|
+
/**
|
|
12
|
+
* One side's text with its runs marked by a strike or an underline and by words, never colour
|
|
13
|
+
* alone. A line mostly changed says so once, up front, instead of run by run.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MarkedText: React.FC<{
|
|
16
|
+
parts: readonly DiffPart[];
|
|
17
|
+
side: Side;
|
|
18
|
+
unit: 'line' | 'text';
|
|
19
|
+
}>;
|
|
20
|
+
/** A value as the form would show it: titles for references, file names for images. */
|
|
21
|
+
export declare const Value: React.FC<{
|
|
22
|
+
value: DisplayValue;
|
|
23
|
+
alt?: {
|
|
24
|
+
text: TextDiff;
|
|
25
|
+
side: Side;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
30
|
+
* place the editor shows a date absolute first and relative after. A day has no relative form: it
|
|
31
|
+
* may be a future event, and a past one is not hours ago.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DateValue: React.FC<{
|
|
34
|
+
value: Extract<DisplayValue, {
|
|
35
|
+
kind: 'date';
|
|
36
|
+
}>;
|
|
37
|
+
text?: React.ReactNode;
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
|
|
41
|
+
* why (`data-disabled` keeps the Tooltip working on focus and touch).
|
|
42
|
+
*/
|
|
43
|
+
export declare const ReasonedButton: React.FC<{
|
|
44
|
+
reason?: string;
|
|
45
|
+
onClick: () => void;
|
|
46
|
+
children: React.ReactNode;
|
|
47
|
+
danger?: boolean;
|
|
48
|
+
loading?: boolean;
|
|
49
|
+
'aria-label'?: string;
|
|
50
|
+
'data-testid'?: string;
|
|
51
|
+
}>;
|