canopycms 0.12.0 → 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 +164 -48
- package/dist/api/admin.d.ts +4 -4
- 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 +14 -1
- package/dist/api/client.js +22 -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 +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -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 +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +429 -52
- 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 +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- 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 +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- 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 +7 -0
- package/dist/editor/undo-change.js +19 -4
- 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
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useRef } from 'react';
|
|
3
|
-
import { Badge, Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import { Badge, Box, Button, Group, Loader, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
4
4
|
import { useMediaQuery, useMergedRef } from '@mantine/hooks';
|
|
5
5
|
import { IconChevronDown, IconLock } from '@tabler/icons-react';
|
|
6
6
|
import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
|
|
7
7
|
import { branchBarStatus, describeBranchLock } from '../branch-lock.js';
|
|
8
8
|
import { HOLD_BADGE, branchHoldMessage } from '../branch-status.js';
|
|
9
|
+
import { conflictRecordHeldAdminMessage, conflictRecordHeldMessage, conflictsHoldMessage, conflictsSubmitReason, } from '../conflict-notice.js';
|
|
10
|
+
import { ReleaseHoldButton } from './ReleaseHoldButton.js';
|
|
9
11
|
import { EDITOR_ACTIONS, UNFINISHED_SUBMIT_APPROVE_REASON, rewrittenSubmitReason } from '../copy.js';
|
|
10
12
|
import { reviewRoleOf } from '../../authorization/review-role.js';
|
|
11
13
|
import { SYSTEM_USER_ID } from '../../system-users.js';
|
|
@@ -15,13 +17,15 @@ import { TooltipDetail } from './TooltipDetail.js';
|
|
|
15
17
|
import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
16
18
|
import { UnsavedMark } from './UnsavedMark.js';
|
|
17
19
|
import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButton.js';
|
|
20
|
+
import { GetLatestChangesButton } from './GetLatestChangesButton.js';
|
|
21
|
+
import { GETTING_LATEST_CHANGES, WAITING_NOTICE, WAITING_SUBMIT_REASON, } from '../base-update-wait.js';
|
|
18
22
|
import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
19
23
|
/**
|
|
20
24
|
* The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
|
|
21
25
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
22
26
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
23
27
|
*/
|
|
24
|
-
export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
|
|
28
|
+
export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, baseUpdate, latestWait = 'none', openConflicts = 0, conflictRecordHeld, }) {
|
|
25
29
|
const reviewRole = reviewRoleOf({
|
|
26
30
|
status: branchStatus,
|
|
27
31
|
createdBy: branchCreatedBy,
|
|
@@ -30,6 +34,12 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
30
34
|
isProtected: branchIsProtected,
|
|
31
35
|
pullRequestState: branchPullRequestState,
|
|
32
36
|
}, userContext);
|
|
37
|
+
const releaseHold = branchName ? conflictRecordHeld?.onRelease : undefined;
|
|
38
|
+
const heldMessage = !conflictRecordHeld
|
|
39
|
+
? undefined
|
|
40
|
+
: releaseHold
|
|
41
|
+
? conflictRecordHeldAdminMessage
|
|
42
|
+
: conflictRecordHeldMessage;
|
|
33
43
|
const workflow = workflowAction({
|
|
34
44
|
branchName,
|
|
35
45
|
busy,
|
|
@@ -42,6 +52,9 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
42
52
|
savingAll: !!saveAllProgress,
|
|
43
53
|
rewritten: branchHold?.kind === 'rewritten',
|
|
44
54
|
approveBlocked: branchApproveBlocked,
|
|
55
|
+
waitingForLatest: latestWait === 'waiting',
|
|
56
|
+
openConflicts,
|
|
57
|
+
heldReason: heldMessage,
|
|
45
58
|
});
|
|
46
59
|
const lock = {
|
|
47
60
|
branchStatus,
|
|
@@ -55,12 +68,19 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
55
68
|
};
|
|
56
69
|
const branchLockReason = describeBranchLock(lock);
|
|
57
70
|
const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
|
|
71
|
+
// A hold of the pull request outranks open conflicts, which only hold the next submit.
|
|
72
|
+
const conflictsHeld = openConflicts > 0 && branchStatus === 'editing';
|
|
58
73
|
const holdMessage = branchName && branchHold && status.value !== 'loading'
|
|
59
74
|
? branchHoldMessage(branchHold, {
|
|
60
75
|
role: workflow?.kind === 'review' ? 'review' : workflow?.permitted ? 'withdraw' : 'none',
|
|
61
76
|
isAdmin: isAdmin(userContext?.groups ?? []),
|
|
62
77
|
})
|
|
63
|
-
:
|
|
78
|
+
: branchName && heldMessage && status.value !== 'loading'
|
|
79
|
+
? heldMessage
|
|
80
|
+
: branchName && conflictsHeld && status.value !== 'loading'
|
|
81
|
+
? conflictsHoldMessage(openConflicts)
|
|
82
|
+
: undefined;
|
|
83
|
+
const holdCount = !branchHold && !conflictRecordHeld && conflictsHeld ? openConflicts : undefined;
|
|
64
84
|
const theme = useMantineTheme();
|
|
65
85
|
// Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
|
|
66
86
|
const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
|
|
@@ -81,6 +101,20 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
81
101
|
: status.action;
|
|
82
102
|
// Controls come and go as a submit or withdraw changes the lock, and focus follows them.
|
|
83
103
|
const [[wayRef, saveAllRef, workflowRef, secondaryRef], focusTarget] = useFocusHandOff(4);
|
|
104
|
+
// A release unmounts its confirm with the button that opened it, which leaves focus nowhere: it
|
|
105
|
+
// goes to the workflow control, which the release has just freed.
|
|
106
|
+
const releasable = !!releaseHold;
|
|
107
|
+
const wasReleasable = useRef(releasable);
|
|
108
|
+
const releasedHere = useRef(false);
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const active = document.activeElement;
|
|
111
|
+
if (wasReleasable.current && !releasable && releasedHere.current) {
|
|
112
|
+
releasedHere.current = false;
|
|
113
|
+
if (!active || active === document.body)
|
|
114
|
+
focusTarget(2)?.focus({ preventScroll: true });
|
|
115
|
+
}
|
|
116
|
+
wasReleasable.current = releasable;
|
|
117
|
+
});
|
|
84
118
|
const workflowControl = {
|
|
85
119
|
onSubmit,
|
|
86
120
|
onWithdraw,
|
|
@@ -92,14 +126,27 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
92
126
|
returnFocusTo: () => focusTarget(2),
|
|
93
127
|
};
|
|
94
128
|
const caretOnWay = narrow && !!barAction && !!workflow;
|
|
95
|
-
return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef
|
|
129
|
+
return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef,
|
|
130
|
+
// Below `md` the bar has no room for it, so the branch's menu offers it.
|
|
131
|
+
getLatest: narrow ? baseUpdate : undefined }), _jsxs(Group, { gap: "xs", wrap: "nowrap", style: { flexShrink: 0 }, children: [_jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason, holdMessage: holdMessage, holdCount: holdCount, working: latestWait !== 'none' ? GETTING_LATEST_CHANGES : undefined,
|
|
132
|
+
// The bar's button carries the spinner where it shows.
|
|
133
|
+
workingSpinner: narrow || !baseUpdate }), releaseHold && (_jsx(ReleaseHoldButton, { branchLabel: branchLabel ?? branchName, onRelease: async () => {
|
|
134
|
+
// Marked while in flight: the re-read that clears the hold can land first.
|
|
135
|
+
releasedHere.current = true;
|
|
136
|
+
const outcome = await releaseHold();
|
|
137
|
+
if (!('released' in outcome))
|
|
138
|
+
releasedHere.current = false;
|
|
139
|
+
return outcome;
|
|
140
|
+
} }))] }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), !narrow && baseUpdate && _jsx(GetLatestChangesButton, { control: baseUpdate }), (!narrow || editable) && (_jsx(SaveAllButton
|
|
96
141
|
// Its reserved width follows this branch's counts, not the session's largest.
|
|
97
142
|
, { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
|
|
98
143
|
(!branchName
|
|
99
144
|
? 'Open a branch first.'
|
|
100
|
-
:
|
|
101
|
-
?
|
|
102
|
-
:
|
|
145
|
+
: latestWait === 'waiting'
|
|
146
|
+
? WAITING_NOTICE
|
|
147
|
+
: entryListFailed && modifiedCount > 0
|
|
148
|
+
? 'The list of entries couldn\'t be loaded, so these changes can\'t be saved yet. Choose "Refresh branch" in the branch menu to try again.'
|
|
149
|
+
: undefined), saveAllProgress: saveAllProgress, singleSaving: singleSaving, busy: busy, buttonRef: saveAllRef, caret: split && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined }, branchName)), barAction && (_jsx(BranchWayButton, { action: barAction, buttonRef: wayRef, onClick: barAction === 'newBranch'
|
|
103
150
|
? onNewBranch
|
|
104
151
|
: barAction === 'refreshBranch'
|
|
105
152
|
? onBranchReloadData
|
|
@@ -131,13 +178,18 @@ function unsavedTooltip(status, count, mayWithdraw) {
|
|
|
131
178
|
* The branch's state beside the pill, heard in words with its reason, but not while it loads. A
|
|
132
179
|
* hold follows it whatever the lock, since a lock doesn't say that the branch is stuck.
|
|
133
180
|
*/
|
|
134
|
-
function BranchStatusLabel({ status, reason, holdMessage, }) {
|
|
135
|
-
const spoken =
|
|
136
|
-
|
|
137
|
-
|
|
181
|
+
function BranchStatusLabel({ status, reason, holdMessage, holdCount, working, workingSpinner = true, }) {
|
|
182
|
+
const spoken = [
|
|
183
|
+
status.label && status.value !== 'loading'
|
|
184
|
+
? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
|
|
185
|
+
: '',
|
|
186
|
+
working,
|
|
187
|
+
]
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.join('. ');
|
|
138
190
|
return (
|
|
139
191
|
// Always mounted, so a lock that arrives is announced.
|
|
140
|
-
_jsxs(Box, { role: "status", style: status.label || holdMessage ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: HOLD_BADGE.label }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }));
|
|
192
|
+
_jsxs(Box, { role: "status", style: status.label || holdMessage || working ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [working && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-working-status", children: [workingSpinner && _jsx(Loader, { size: 12 }), _jsx(Text, { span: true, size: "sm", children: working })] })), status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: holdCount === undefined ? HOLD_BADGE.label : `${HOLD_BADGE.label} (${holdCount})` }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }));
|
|
141
193
|
}
|
|
142
194
|
const WAY_LABEL = {
|
|
143
195
|
newBranch: `${EDITOR_ACTIONS.newBranch}…`,
|
|
@@ -154,7 +206,7 @@ function BranchWayButton({ action, onClick, caret, buttonRef, }) {
|
|
|
154
206
|
* Submit for review, or Withdraw from review once submitted or approved, or Approve and Request
|
|
155
207
|
* changes to a reviewer of someone else's submission; null when none shows.
|
|
156
208
|
*/
|
|
157
|
-
function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, }) {
|
|
209
|
+
function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, waitingForLatest, openConflicts, heldReason, }) {
|
|
158
210
|
const isSubmitted = branchStatus === 'submitted';
|
|
159
211
|
const isEditing = branchStatus === 'editing';
|
|
160
212
|
// 'approved' withdraws too -- it is the author's way back from
|
|
@@ -176,7 +228,7 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
|
|
|
176
228
|
blocked: !branchName || busy || savingAll,
|
|
177
229
|
permitted: true,
|
|
178
230
|
reason: savingAll ? 'Wait for Save all to finish.' : busy ? BUSY_REASON : '',
|
|
179
|
-
approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON :
|
|
231
|
+
approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON : heldReason,
|
|
180
232
|
};
|
|
181
233
|
}
|
|
182
234
|
// Who may submit/withdraw: creator, ACL access, the system branch, or an
|
|
@@ -201,24 +253,34 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
|
|
|
201
253
|
return {
|
|
202
254
|
kind: isWithdrawable ? 'withdraw' : 'submit',
|
|
203
255
|
// `busy` alone can't hold it: an entry load clears that shared flag mid-run.
|
|
204
|
-
blocked: !branchName ||
|
|
256
|
+
blocked: !branchName ||
|
|
257
|
+
busy ||
|
|
258
|
+
savingAll ||
|
|
259
|
+
!canPerformAction ||
|
|
260
|
+
(isEditing && (rewritten || waitingForLatest || openConflicts > 0 || !!heldReason)),
|
|
205
261
|
permitted: canPerformAction,
|
|
206
262
|
reason: savingAll
|
|
207
263
|
? 'Wait for Save all to finish.'
|
|
208
|
-
:
|
|
209
|
-
?
|
|
210
|
-
:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
264
|
+
: waitingForLatest && isEditing
|
|
265
|
+
? WAITING_SUBMIT_REASON
|
|
266
|
+
: heldReason && isEditing
|
|
267
|
+
? heldReason
|
|
268
|
+
: openConflicts > 0 && isEditing
|
|
269
|
+
? conflictsSubmitReason(openConflicts)
|
|
270
|
+
: rewritten && isEditing
|
|
271
|
+
? rewrittenSubmitReason(isAdmin(userContext?.groups ?? []))
|
|
272
|
+
: // No branch data (`branchStatus === undefined`) must not be
|
|
273
|
+
// interpolated raw into this label. Guarded explicitly here rather
|
|
274
|
+
// than left to the early return above, which hides the action
|
|
275
|
+
// today only as a side effect of `branchIsProtected`'s `?? true`
|
|
276
|
+
// default -- not a guarantee this arm stays unreachable.
|
|
277
|
+
branchStatus === undefined
|
|
278
|
+
? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
|
|
279
|
+
: !statusHasAction
|
|
280
|
+
? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
|
|
281
|
+
: !userHasPermission
|
|
282
|
+
? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
|
|
283
|
+
: '',
|
|
222
284
|
};
|
|
223
285
|
}
|
|
224
286
|
function WorkflowButton({ narrow, ...props }) {
|
|
@@ -8,6 +8,9 @@ import type { BranchChangeEntry } from '../../api/branch-changes.js';
|
|
|
8
8
|
import type { FieldConfig } from '../../config/index.js';
|
|
9
9
|
import type { CanopyPathSegment } from '../canopy-path.js';
|
|
10
10
|
import type { EntryChangeState } from '../hooks/useBranchChangesData.js';
|
|
11
|
+
import type { ConflictsState } from '../hooks/useConflictsData.js';
|
|
12
|
+
import type { EntryConflictData } from '../../api/merge-conflicts.js';
|
|
13
|
+
import { type ConflictsGroupProps } from './ConflictsGroup.js';
|
|
11
14
|
/** The entry a drawer compares, as the editor knows it. */
|
|
12
15
|
export interface EntryChangesSubject {
|
|
13
16
|
logicalPath: string;
|
|
@@ -58,6 +61,15 @@ export interface EntryChangesDrawerProps {
|
|
|
58
61
|
onDeleteEntry?: () => void;
|
|
59
62
|
/** Puts back an entry deleted on the branch; resolves with why it couldn't, when it couldn't. */
|
|
60
63
|
onRestoreEntry?: () => Promise<RestoreRefusal | undefined>;
|
|
64
|
+
/** The entry's open conflicts, given while the branch lists any for it. */
|
|
65
|
+
conflicts?: EntryChangesConflicts;
|
|
66
|
+
}
|
|
67
|
+
/** What the "Conflicts" group needs beyond the drawer's own props (ConflictsGroup.tsx). */
|
|
68
|
+
export interface EntryChangesConflicts extends Pick<ConflictsGroupProps, 'adminBaseBranch' | 'choices' | 'onChoose' | 'onEdit'> {
|
|
69
|
+
state: ConflictsState<EntryConflictData | null>;
|
|
70
|
+
onResolveEntry?: ConflictsGroupProps['onResolveEntry'];
|
|
71
|
+
/** The entry has a draft, open or not, which an entry-level choice would leave behind. */
|
|
72
|
+
unsavedEdits?: boolean;
|
|
61
73
|
}
|
|
62
74
|
/** Why "Restore entry" didn't: `warning` where nothing is lost, or the outcome is unknown. */
|
|
63
75
|
export interface RestoreRefusal {
|
|
@@ -78,10 +90,12 @@ export interface EntryChangesUndo {
|
|
|
78
90
|
/** Open field threads inside a canopy path, which taking out an item there moves to the entry. */
|
|
79
91
|
threadsWithin?: (path: CanopyPathSegment[]) => number;
|
|
80
92
|
}
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
type EntryChangesViewProps = Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>;
|
|
94
|
+
/** The drawer's body: the entry's conflicts, if any, above its changes. */
|
|
95
|
+
export declare const EntryChangesView: React.FC<EntryChangesViewProps>;
|
|
83
96
|
/**
|
|
84
97
|
* A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
|
|
85
98
|
* and the form and preview wait behind its backdrop.
|
|
86
99
|
*/
|
|
87
100
|
export declare const EntryChangesDrawer: React.FC<EntryChangesDrawerProps>;
|
|
101
|
+
export {};
|
|
@@ -6,20 +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 { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
|
|
13
|
-
import { useAssetContext } from '../context/index.js';
|
|
11
|
+
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil, IconPlus, } from '@tabler/icons-react';
|
|
14
12
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
15
|
-
import { computeEntryDiff
|
|
16
|
-
import {
|
|
13
|
+
import { computeEntryDiff } from '../entry-diff.js';
|
|
14
|
+
import { buildEntryConflicts } from '../conflict-cards.js';
|
|
17
15
|
import { findBodyFieldName } from '../../utils/body-field.js';
|
|
18
|
-
import { formatRelativeTime } from '../relative-time.js';
|
|
19
16
|
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
20
17
|
import { movedThreadsNote } from '../undo-removal.js';
|
|
21
18
|
import { canSetBack, canopyPathOf, formValueOfSide, holdsAs, setAllBack, setChangeBack, sideOfFormValue, } from '../undo-change.js';
|
|
22
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';
|
|
23
22
|
import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
|
|
24
23
|
const KIND_BADGES = {
|
|
25
24
|
added: { label: 'Added', icon: _jsx(IconPlus, { size: 10, "aria-hidden": true }) },
|
|
@@ -27,98 +26,6 @@ const KIND_BADGES = {
|
|
|
27
26
|
changed: { label: 'Changed', icon: _jsx(IconPencil, { size: 10, "aria-hidden": true }) },
|
|
28
27
|
moved: { label: 'Moved', icon: _jsx(IconArrowsMove, { size: 10, "aria-hidden": true }) },
|
|
29
28
|
};
|
|
30
|
-
/**
|
|
31
|
-
* Struck on a tint at least 1.3:1 from the card in either scheme, with text kept at 4.5:1 on it:
|
|
32
|
-
* `red.2` in light, `red.6` at 30% over the dark card.
|
|
33
|
-
*/
|
|
34
|
-
const REMOVED_STYLE = {
|
|
35
|
-
background: 'light-dark(var(--mantine-color-red-2), color-mix(in srgb, var(--mantine-color-red-6) 30%, transparent))',
|
|
36
|
-
color: 'inherit',
|
|
37
|
-
textDecoration: 'line-through',
|
|
38
|
-
textDecorationThickness: 2,
|
|
39
|
-
};
|
|
40
|
-
/** Underlined on a tint, measured as REMOVED_STYLE's: `green.3`, or `green.6` at 30%. */
|
|
41
|
-
const ADDED_STYLE = {
|
|
42
|
-
background: 'light-dark(var(--mantine-color-green-3), color-mix(in srgb, var(--mantine-color-green-6) 30%, transparent))',
|
|
43
|
-
color: 'inherit',
|
|
44
|
-
textDecoration: 'underline',
|
|
45
|
-
textDecorationThickness: 2,
|
|
46
|
-
};
|
|
47
|
-
const TEXT_STYLE = { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
|
|
48
|
-
/** Long text on one side only is cut to this many lines once it has more than LONG_TEXT_LINES. */
|
|
49
|
-
const COLLAPSED_LINES = 50;
|
|
50
|
-
const LONG_TEXT_LINES = 200;
|
|
51
|
-
/** Above this share of a line's text changed, one prefix names the whole line instead of each run. */
|
|
52
|
-
const WHOLE_LINE_SHARE = 0.6;
|
|
53
|
-
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
54
|
-
const ordinal = (n) => {
|
|
55
|
-
const tens = n % 100;
|
|
56
|
-
const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
|
|
57
|
-
return `${n}${suffix}`;
|
|
58
|
-
};
|
|
59
|
-
/**
|
|
60
|
-
* `side`'s runs of `parts`, a marked run joined to the next of its mark across whitespace alone,
|
|
61
|
-
* so a screen reader hears one removal, not one per word.
|
|
62
|
-
*/
|
|
63
|
-
const sideRuns = (parts, side) => {
|
|
64
|
-
const runs = [];
|
|
65
|
-
for (const part of parts) {
|
|
66
|
-
if (side === 'before' ? part.added : part.removed)
|
|
67
|
-
continue;
|
|
68
|
-
const mark = part.removed ? 'removed' : part.added ? 'added' : undefined;
|
|
69
|
-
const last = runs[runs.length - 1];
|
|
70
|
-
const gap = runs[runs.length - 2];
|
|
71
|
-
if (mark && last && last.mark === mark) {
|
|
72
|
-
last.value += part.value;
|
|
73
|
-
}
|
|
74
|
-
else if (mark && last && !last.mark && /^\s+$/.test(last.value) && gap?.mark === mark) {
|
|
75
|
-
runs.pop();
|
|
76
|
-
gap.value += last.value + part.value;
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
runs.push({ value: part.value, ...(mark && { mark }) });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return runs;
|
|
83
|
-
};
|
|
84
|
-
const WHITESPACE_MARKS = {
|
|
85
|
-
' ': { shown: '·', said: 'space' },
|
|
86
|
-
'\t': { shown: '→', said: 'tab' },
|
|
87
|
-
'\n': { shown: '⏎', said: 'line break' },
|
|
88
|
-
};
|
|
89
|
-
/** A run's text; whitespace alone shows a mark for each character, said in words. */
|
|
90
|
-
const RunText = ({ value }) => {
|
|
91
|
-
if (value === '')
|
|
92
|
-
return _jsx("em", { children: "(blank line)" });
|
|
93
|
-
if (!/^\s+$/.test(value))
|
|
94
|
-
return _jsx(_Fragment, { children: value });
|
|
95
|
-
const chars = [...value];
|
|
96
|
-
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(', ') })] }));
|
|
97
|
-
};
|
|
98
|
-
const MARK_WORDS = {
|
|
99
|
-
removed: { start: 'Removed: ', end: ', end of removal', line: 'Removed line: ' },
|
|
100
|
-
added: { start: 'Added: ', end: ', end of addition', line: 'Added line: ' },
|
|
101
|
-
};
|
|
102
|
-
/**
|
|
103
|
-
* One side's text with its runs marked by a strike or an underline and by words, never colour
|
|
104
|
-
* alone. A line mostly changed says so once, up front, instead of run by run.
|
|
105
|
-
*/
|
|
106
|
-
const MarkedText = ({ parts, side, unit, }) => {
|
|
107
|
-
const runs = sideRuns(parts, side);
|
|
108
|
-
const total = runs.reduce((n, run) => n + run.value.length, 0);
|
|
109
|
-
const marked = runs.reduce((n, run) => n + (run.mark ? run.value.length : 0), 0);
|
|
110
|
-
const mark = side === 'before' ? 'removed' : 'added';
|
|
111
|
-
const whole = runs.some((run) => run.mark) && (total === 0 || marked / total > WHOLE_LINE_SHARE);
|
|
112
|
-
return (_jsxs(_Fragment, { children: [whole && (_jsx(VisuallyHidden, { component: "span", children: unit === 'line' ? MARK_WORDS[mark].line : MARK_WORDS[mark].start })), runs.map((run, i) => {
|
|
113
|
-
if (!run.mark)
|
|
114
|
-
return _jsx(React.Fragment, { children: run.value }, i);
|
|
115
|
-
const Tag = run.mark === 'removed' ? 'del' : 'ins';
|
|
116
|
-
const style = run.mark === 'removed' ? REMOVED_STYLE : ADDED_STYLE;
|
|
117
|
-
// Whitespace alone, or a blank line's label, has nothing for a line to cross.
|
|
118
|
-
const shown = /^\s*$/.test(run.value) ? { ...style, textDecoration: 'none' } : style;
|
|
119
|
-
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));
|
|
120
|
-
})] }));
|
|
121
|
-
};
|
|
122
29
|
const Gutter = ({ sign }) => (_jsx(Text, { size: "sm", c: "dimmed", "aria-hidden": true, ta: "center", style: { userSelect: 'none' }, children: sign ?? '' }));
|
|
123
30
|
const Skipped = ({ count, style }) => (_jsx(Text, { size: "xs", c: "dimmed", py: 2, style: style, children: plural(count, 'unchanged line', 'unchanged lines') }));
|
|
124
31
|
const LINE_GRID = {
|
|
@@ -216,77 +123,11 @@ const LineRows = ({ rows }) => {
|
|
|
216
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)))] }));
|
|
217
124
|
};
|
|
218
125
|
const WordsChange = ({ text, side, }) => (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: _jsx(MarkedText, { parts: text.parts, side: side, unit: "text" }) }));
|
|
219
|
-
const THUMBNAIL_SIZE = EDITOR_THUMBNAIL_WIDTHS.listRow / 2;
|
|
220
|
-
/** An image's thumbnail beside its name, which says what it is; an icon if it won't load. */
|
|
221
|
-
const Thumbnail = ({ src }) => {
|
|
222
|
-
const { baseUrl } = useAssetContext();
|
|
223
|
-
const [failed, setFailed] = useState(false);
|
|
224
|
-
if (failed) {
|
|
225
|
-
return (_jsx(IconPhotoOff, { size: THUMBNAIL_SIZE, stroke: 1.5, "aria-hidden": true, style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
|
|
226
|
-
}
|
|
227
|
-
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 } }));
|
|
228
|
-
};
|
|
229
|
-
/** Text on one side only; more than LONG_TEXT_LINES lines show COLLAPSED_LINES until asked. */
|
|
230
|
-
const LongText = ({ text }) => {
|
|
231
|
-
const [expanded, setExpanded] = useState(false);
|
|
232
|
-
const firstNew = useRef(null);
|
|
233
|
-
useEffect(() => {
|
|
234
|
-
if (expanded)
|
|
235
|
-
firstNew.current?.focus();
|
|
236
|
-
}, [expanded]);
|
|
237
|
-
const lines = text.split('\n');
|
|
238
|
-
const long = lines.length > LONG_TEXT_LINES;
|
|
239
|
-
const collapsed = !expanded && long;
|
|
240
|
-
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"] }))] }));
|
|
241
|
-
};
|
|
242
|
-
/** A value as the form would show it: titles for references, file names for images. */
|
|
243
|
-
const Value = ({ value, alt, }) => {
|
|
244
|
-
switch (value.kind) {
|
|
245
|
-
case 'empty':
|
|
246
|
-
return (_jsx(Text, { size: "sm", c: "dimmed", children: "Not set" }));
|
|
247
|
-
case 'text':
|
|
248
|
-
return _jsx(LongText, { text: value.text });
|
|
249
|
-
case 'number':
|
|
250
|
-
return _jsx(Text, { size: "sm", children: value.value.toLocaleString() });
|
|
251
|
-
case 'boolean':
|
|
252
|
-
return _jsx(Text, { size: "sm", children: value.value ? 'Yes' : 'No' });
|
|
253
|
-
case 'date':
|
|
254
|
-
return _jsx(DateValue, { value: value });
|
|
255
|
-
case 'select':
|
|
256
|
-
return _jsx(Text, { size: "sm", children: value.label });
|
|
257
|
-
case 'reference':
|
|
258
|
-
return value.title !== undefined ? (_jsx(Text, { size: "sm", children: value.title })) : (_jsx(Text, { size: "sm", c: "dimmed", children: "Entry unavailable" }));
|
|
259
|
-
case 'image':
|
|
260
|
-
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)] }))] })] }));
|
|
261
|
-
case 'position':
|
|
262
|
-
return (_jsxs(Text, { size: "sm", children: [ordinal(value.position), " of ", value.of] }));
|
|
263
|
-
case 'list':
|
|
264
|
-
return (_jsx(List, { size: "sm", spacing: 4, children: value.items.map((item, i) => (_jsx(List.Item, { children: _jsx(Value, { value: item }) }, i))) }));
|
|
265
|
-
case 'fields':
|
|
266
|
-
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))) }));
|
|
267
|
-
case 'raw':
|
|
268
|
-
return (_jsx(Text, { size: "xs", ff: "monospace", style: TEXT_STYLE, children: value.text }));
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
/**
|
|
272
|
-
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
273
|
-
* place the editor shows a date absolute first and relative after. A day has no relative form: it
|
|
274
|
-
* may be a future event, and a past one is not hours ago.
|
|
275
|
-
*/
|
|
276
|
-
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) }))] }));
|
|
277
126
|
const SideColumn = ({ label, change, side, }) => {
|
|
278
127
|
const value = change[side] ?? { kind: 'empty' };
|
|
279
128
|
const words = change.text?.mode === 'words' ? change.text : undefined;
|
|
280
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 } }))] }));
|
|
281
130
|
};
|
|
282
|
-
/**
|
|
283
|
-
* A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
|
|
284
|
-
* why (`data-disabled` keeps the Tooltip working on focus and touch).
|
|
285
|
-
*/
|
|
286
|
-
const ReasonedButton = ({ reason, onClick, danger, ...props }) => (_jsx(Tooltip, { label: reason, disabled: !reason, multiline: true, maw: 280, position: "bottom-start", children: _jsx(Button, { size: "compact-sm", variant: "subtle", ...(danger && { color: 'red', c: 'var(--canopy-danger-text)' }), ...props, onClick: () => {
|
|
287
|
-
if (!reason)
|
|
288
|
-
onClick();
|
|
289
|
-
}, "data-disabled": reason ? true : undefined, "aria-disabled": reason ? true : undefined }) }));
|
|
290
131
|
const UNDONE_IN_FORM = 'Undone in the form. Save to keep it.';
|
|
291
132
|
const ALL_UNDONE_IN_FORM = 'All changes undone in the form. Save to keep them.';
|
|
292
133
|
// Undo and redo replace what the form holds at a change with the base's or the saved value, so
|
|
@@ -332,8 +173,33 @@ const changeSummary = (changes, base) => {
|
|
|
332
173
|
];
|
|
333
174
|
return `${parts.join(', ')} since ${base}`;
|
|
334
175
|
};
|
|
335
|
-
/** The
|
|
336
|
-
|
|
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, }) => {
|
|
337
203
|
const { data, error } = state;
|
|
338
204
|
const diff = useMemo(() => data &&
|
|
339
205
|
computeEntryDiff({
|
|
@@ -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;
|
|
@@ -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,7 +38,7 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
|
|
|
38
38
|
reviewing: branchReviewing,
|
|
39
39
|
pullRequestClosed: branchPullRequestClosed,
|
|
40
40
|
approveBlocked: branchApproveBlocked,
|
|
41
|
-
});
|
|
41
|
+
}) ?? writesWaitReason;
|
|
42
42
|
const entryLockReason = entryReadOnly ? NO_EDIT_ACCESS_REASON : undefined;
|
|
43
43
|
// The Tooltip hides it while the editor is busy.
|
|
44
44
|
const saveBlockedReason = branchLockReason ??
|
|
@@ -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;
|