canopycms 0.6.0 → 0.7.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 +188 -47
- package/dist/api/branch-review.d.ts +11 -1
- package/dist/api/branch-review.js +96 -18
- package/dist/api/branch-status.d.ts +26 -2
- package/dist/api/branch-status.js +38 -2
- package/dist/api/branch-withdraw.js +4 -7
- package/dist/api/branch.d.ts +2 -0
- package/dist/api/client.d.ts +3 -3
- package/dist/api/client.js +4 -4
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/comments.js +4 -0
- package/dist/api/content.d.ts +2 -2
- package/dist/api/github-sync.d.ts +4 -0
- package/dist/api/github-sync.js +38 -6
- package/dist/api/pr-comment-sync.d.ts +18 -0
- package/dist/api/pr-comment-sync.js +77 -0
- package/dist/api/request-deadline.d.ts +15 -7
- package/dist/api/request-deadline.js +15 -7
- package/dist/api/schema.d.ts +16 -16
- package/dist/authorization/review-role.d.ts +33 -0
- package/dist/authorization/review-role.js +17 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/cli/cli.js +699 -130
- package/dist/cli/generate-ai-content.js +524 -59
- package/dist/cli/github-app-output.d.ts +34 -0
- package/dist/cli/github-app-output.js +85 -0
- package/dist/cli/init-github-app.d.ts +18 -0
- package/dist/cli/init-github-app.js +66 -58
- package/dist/cli/init.js +10 -1
- package/dist/comment-store.d.ts +5 -0
- package/dist/comment-store.js +24 -0
- package/dist/config/helpers.js +1 -3
- package/dist/config/schemas/collection.d.ts +2 -2
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -1
- package/dist/config/schemas/url.d.ts +6 -0
- package/dist/config/schemas/url.js +16 -0
- package/dist/config/types.d.ts +12 -1
- package/dist/content-id-index.d.ts +9 -0
- package/dist/content-id-index.js +31 -0
- package/dist/editor/BranchManager.d.ts +3 -0
- package/dist/editor/BranchManager.js +75 -44
- package/dist/editor/CanopyEditor.js +2 -2
- package/dist/editor/CanopyEditorPage.js +3 -4
- package/dist/editor/Editor.js +47 -8
- package/dist/editor/branch-lock.d.ts +7 -0
- package/dist/editor/branch-lock.js +9 -2
- package/dist/editor/client-reference-resolver.d.ts +5 -3
- package/dist/editor/client-reference-resolver.js +10 -5
- package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
- package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
- package/dist/editor/components/EditorHeader.d.ts +10 -4
- package/dist/editor/components/EditorHeader.js +108 -28
- package/dist/editor/components/EntryHeader.d.ts +5 -1
- package/dist/editor/components/EntryHeader.js +3 -1
- package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
- package/dist/editor/components/ReasonedMenuItem.js +2 -2
- package/dist/editor/components/ReviewDialogs.d.ts +19 -0
- package/dist/editor/components/ReviewDialogs.js +41 -0
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/fields/MarkdownField.js +1 -1
- package/dist/editor/hooks/create-branch-request.d.ts +6 -2
- package/dist/editor/hooks/create-branch-request.js +7 -2
- package/dist/editor/hooks/useBranchManager.d.ts +7 -2
- package/dist/editor/hooks/useBranchManager.js +252 -37
- package/dist/editor-url.d.ts +20 -0
- package/dist/editor-url.js +71 -0
- package/dist/git-manager.js +10 -6
- package/dist/github-service.d.ts +2 -2
- package/dist/github-service.js +4 -2
- package/dist/pr-comments.d.ts +33 -0
- package/dist/pr-comments.js +41 -0
- package/dist/schema/schema-store.d.ts +8 -8
- package/dist/services.d.ts +2 -17
- package/dist/services.js +38 -1
- package/dist/settings-workspace.d.ts +21 -7
- package/dist/settings-workspace.js +91 -16
- package/dist/submission-attribution.d.ts +38 -1
- package/dist/submission-attribution.js +197 -18
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/content-serialize.js +7 -6
- package/dist/utils/markdown-body-splice.d.ts +15 -10
- package/dist/utils/markdown-body-splice.js +71 -28
- package/dist/utils/markdown-export-options.d.ts +13 -0
- package/dist/utils/markdown-export-options.js +13 -0
- package/dist/utils/markdown-restyle.d.ts +58 -0
- package/dist/utils/markdown-restyle.js +482 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/bundle-header.d.ts +13 -9
- package/dist/worker/bundle-header.js +17 -11
- package/dist/worker/git-process.d.ts +2 -2
- package/dist/worker/git-sync.js +5 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +3 -0
- package/dist/worker/github-gateway-wire.js +1 -0
- package/dist/worker/github-gateway.d.ts +1 -1
- package/dist/worker/github-transfer.js +72 -2
- package/dist/worker/task-runner.js +53 -3
- package/package.json +2 -2
- package/dist/editor/fields/markdown-export-options.d.ts +0 -2
- package/dist/editor/fields/markdown-export-options.js +0 -8
package/dist/content-id-index.js
CHANGED
|
@@ -25,6 +25,9 @@ import path from 'node:path';
|
|
|
25
25
|
import { isValidId } from './id.js';
|
|
26
26
|
import { isNotFoundError } from './utils/error.js';
|
|
27
27
|
import { canopyLogWarn } from './utils/logger.js';
|
|
28
|
+
import { entryLogicalPath, normalizeFilesystemPath } from './paths/normalize.js';
|
|
29
|
+
import { parseTypedFilename } from './utils/typed-filename.js';
|
|
30
|
+
import { getFormatExtension } from './utils/format.js';
|
|
28
31
|
/** Logical path representing entries stored at the branch root (no parent collection). Rare in practice. */
|
|
29
32
|
const EMPTY_LOGICAL_PATH = '';
|
|
30
33
|
/**
|
|
@@ -39,6 +42,34 @@ function toLogicalCollectionPath(physicalPath) {
|
|
|
39
42
|
.map((seg) => extractSlugFromFilename(seg))
|
|
40
43
|
.join('/');
|
|
41
44
|
}
|
|
45
|
+
/** The extension of every ContentFormat, dot included; keyed so a new format fails to compile. */
|
|
46
|
+
const ENTRY_EXTENSIONS = new Set(Object.keys({ md: true, mdx: true, json: true, yaml: true }).map((format) => getFormatExtension(format)));
|
|
47
|
+
/**
|
|
48
|
+
* The path the editor lists the entry a file stores under (`EditorEntry.path`, which its
|
|
49
|
+
* `entry` param matches), or null for a file that cannot be a listed entry: outside
|
|
50
|
+
* `contentRoot`, under a dot-prefixed or `_ids_` segment, or not named
|
|
51
|
+
* `{type}.{slug}.{id}.{ext}` with a content format's extension (parseTypedFilename). The content root is kept as
|
|
52
|
+
* spelled, as the schema spells it; the directories below lose their IDs. No I/O, and no
|
|
53
|
+
* check that the directory is a collection or the type one of its entry types.
|
|
54
|
+
*/
|
|
55
|
+
export function entryLogicalPathOfFile(relativePath, contentRoot) {
|
|
56
|
+
const root = normalizeFilesystemPath(contentRoot);
|
|
57
|
+
const file = normalizeFilesystemPath(relativePath);
|
|
58
|
+
if (!file.startsWith(`${root}/`))
|
|
59
|
+
return null;
|
|
60
|
+
const below = file.slice(root.length + 1).split('/');
|
|
61
|
+
if (below.some((s) => s.startsWith('.') || s === '_ids_'))
|
|
62
|
+
return null;
|
|
63
|
+
const name = below[below.length - 1];
|
|
64
|
+
if (!ENTRY_EXTENSIONS.has(name.slice(name.lastIndexOf('.'))))
|
|
65
|
+
return null;
|
|
66
|
+
const parsed = parseTypedFilename(name);
|
|
67
|
+
if (!parsed)
|
|
68
|
+
return null;
|
|
69
|
+
const dirs = below.slice(0, -1);
|
|
70
|
+
const collection = dirs.length > 0 ? `${root}/${toLogicalCollectionPath(dirs.join('/'))}` : root;
|
|
71
|
+
return entryLogicalPath(collection, parsed.slug);
|
|
72
|
+
}
|
|
42
73
|
/**
|
|
43
74
|
* Bidirectional ContentId <-> path index over `{type}.{slug}.{12-char-id}.{ext}`
|
|
44
75
|
* files and `{slug}.{12-char-id}/` directories, scanned lazily on first access
|
|
@@ -59,6 +59,8 @@ export declare const getBranchPermissions: (branch: BranchSummary, user: UserCon
|
|
|
59
59
|
canSubmit: boolean;
|
|
60
60
|
canWithdraw: boolean;
|
|
61
61
|
canDelete: boolean;
|
|
62
|
+
/** Approve is open to this user now: a reviewer other than the creator, on a submitted branch with a live PR. */
|
|
63
|
+
canApprove: boolean;
|
|
62
64
|
canRequestChanges: boolean;
|
|
63
65
|
};
|
|
64
66
|
export interface BranchManagerProps {
|
|
@@ -87,6 +89,7 @@ export interface BranchManagerProps {
|
|
|
87
89
|
onDelete?: (name: string) => void;
|
|
88
90
|
onSubmit?: (name: string) => void;
|
|
89
91
|
onWithdraw?: (name: string) => void;
|
|
92
|
+
onApprove?: (name: string) => void;
|
|
90
93
|
onRequestChanges?: (name: string) => void;
|
|
91
94
|
onClose?: () => void;
|
|
92
95
|
comments?: CommentThread[];
|
|
@@ -13,7 +13,8 @@ import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
|
13
13
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
14
14
|
import { RelativeTime } from './components/RelativeTime.js';
|
|
15
15
|
import { branchStatusPresentation } from './branch-status.js';
|
|
16
|
-
import { EDITOR_ACTIONS } from './copy.js';
|
|
16
|
+
import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
|
|
17
|
+
import { isReviewerOfOthersReview } from '../authorization/review-role.js';
|
|
17
18
|
import { SYSTEM_USER_ID } from '../system-users.js';
|
|
18
19
|
/**
|
|
19
20
|
* What the current user can do to a branch, by the backend's hybrid model. Workflow access
|
|
@@ -30,6 +31,7 @@ export const getBranchPermissions = (branch, user) => {
|
|
|
30
31
|
canSubmit: false,
|
|
31
32
|
canWithdraw: false,
|
|
32
33
|
canDelete: false,
|
|
34
|
+
canApprove: false,
|
|
33
35
|
canRequestChanges: false,
|
|
34
36
|
};
|
|
35
37
|
}
|
|
@@ -58,8 +60,8 @@ export const getBranchPermissions = (branch, user) => {
|
|
|
58
60
|
// fail-open for anything constructing a BranchSummary directly.
|
|
59
61
|
const canSubmit = canPerformWorkflowActions && !(branch.submitBlocked ?? true);
|
|
60
62
|
// Withdraw: can perform workflow actions AND branch is submitted or
|
|
61
|
-
// approved -- 'approved' is included because withdraw is
|
|
62
|
-
//
|
|
63
|
+
// approved -- 'approved' is included because withdraw is the author's way back from it (an
|
|
64
|
+
// author is offered no Request changes), and getBranchPermissions must grant canWithdraw for
|
|
63
65
|
// it to be reachable. Allowed even when the PR was closed without merging:
|
|
64
66
|
// that's the deliberate recovery path for a closed-unmerged PR (server-side
|
|
65
67
|
// skip in api/branch-withdraw.ts). Not additionally gated on
|
|
@@ -73,26 +75,29 @@ export const getBranchPermissions = (branch, user) => {
|
|
|
73
75
|
branch.status !== 'submitted' &&
|
|
74
76
|
branch.status !== 'approved' &&
|
|
75
77
|
!branch.isProtected;
|
|
76
|
-
// Request changes:
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// Approve and Request changes: a Reviewer or Admin other than the creator, who for an author
|
|
79
|
+
// would only do what Withdraw does. Neither applies to the protected base branch, whose way
|
|
80
|
+
// out of review is Withdraw. A closed PR refuses both (Request changes converts the PR to
|
|
81
|
+
// a draft); Approve also needs the branch submitted, Request changes accepts approved too.
|
|
82
|
+
const reviewsOthers = (userIsAdmin || userIsReviewer) && !userIsCreator && !branch.isProtected;
|
|
83
|
+
const prLive = branch.pullRequestState !== 'closed';
|
|
84
|
+
const canApprove = reviewsOthers && branch.status === 'submitted' && prLive;
|
|
85
|
+
const canRequestChanges = reviewsOthers && (branch.status === 'submitted' || branch.status === 'approved') && prLive;
|
|
81
86
|
return {
|
|
82
87
|
hasWorkflowAccess: canPerformWorkflowActions,
|
|
83
88
|
canSubmit,
|
|
84
89
|
canWithdraw,
|
|
85
90
|
canDelete,
|
|
91
|
+
canApprove,
|
|
86
92
|
canRequestChanges,
|
|
87
93
|
};
|
|
88
94
|
};
|
|
89
95
|
/** The lookup when a card has none: an owner reads "Name unavailable" and access users are counted, not named. */
|
|
90
96
|
const noUserLookup = async () => null;
|
|
91
|
-
const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Withdraw the branch from review, then submit it again to open a new one.';
|
|
92
97
|
/**
|
|
93
98
|
* The workflow action a branch's status calls for, or undefined when none applies: Submit for
|
|
94
|
-
* an unprotected branch in editing, Withdraw once it is in review
|
|
95
|
-
*
|
|
99
|
+
* an unprotected branch in editing, Withdraw once it is in review, 'approved' included: it is the
|
|
100
|
+
* author's way back. A user without workflow access is offered neither, since it
|
|
96
101
|
* would never apply to them; a reason is given only for a block that depends on the branch's state.
|
|
97
102
|
*/
|
|
98
103
|
const workflowAction = (b, perms, onSubmit, onWithdraw) => {
|
|
@@ -117,58 +122,84 @@ const workflowAction = (b, perms, onSubmit, onWithdraw) => {
|
|
|
117
122
|
}
|
|
118
123
|
return undefined;
|
|
119
124
|
};
|
|
120
|
-
/** Whether `user` reviews `b` rather than owns it
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
b.
|
|
124
|
-
b.
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
/** Whether `user` reviews `b` rather than owns it; see `isReviewerOfOthersReview`. */
|
|
126
|
+
const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
|
|
127
|
+
status: b.status,
|
|
128
|
+
createdBy: b.createdBy,
|
|
129
|
+
users: b.access?.users,
|
|
130
|
+
groups: b.access?.groups,
|
|
131
|
+
isProtected: b.isProtected,
|
|
132
|
+
pullRequestState: b.pullRequestState,
|
|
133
|
+
}, user);
|
|
127
134
|
/**
|
|
128
135
|
* One branch: its state, and one primary action, with the rest in a "…" menu. The current branch
|
|
129
|
-
* shows its workflow action
|
|
130
|
-
*
|
|
136
|
+
* shows its workflow action; to a reviewer of someone else's submission it shows Approve, with
|
|
137
|
+
* Request changes beside it (Request changes alone once approved). Another such branch shows that
|
|
138
|
+
* reviewer Review, which opens it; the other cards show Open, which the current one never needs.
|
|
139
|
+
* No action is shown twice.
|
|
131
140
|
*/
|
|
132
|
-
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
141
|
+
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
133
142
|
const statusBadge = branchStatusPresentation(b.status, b.isProtected);
|
|
134
143
|
const perms = getBranchPermissions(b, user);
|
|
135
144
|
const action = workflowAction(b, perms, onSubmit, onWithdraw);
|
|
136
145
|
const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
|
|
137
146
|
const userIsAdmin = isAdmin(user?.groups);
|
|
138
|
-
|
|
147
|
+
// The open branch holds back its review actions as it does its workflow action; the reason is
|
|
148
|
+
// given only for the open branch.
|
|
149
|
+
const heldBack = (a) => a && workflowBlockedReason ? { ...a, blockedReason: workflowBlockedReason } : a;
|
|
150
|
+
// Shown to a reviewer other than the creator; a closed PR or a held-back open branch blocks it.
|
|
151
|
+
const reviewsOthers = isReviewer(user?.groups) && !!user && b.createdBy !== user.userId && !b.isProtected;
|
|
152
|
+
const approve = heldBack(reviewsOthers && b.status === 'submitted'
|
|
153
|
+
? {
|
|
154
|
+
label: EDITOR_ACTIONS.approve,
|
|
155
|
+
color: 'brand',
|
|
156
|
+
testId: `approve-branch-button-${b.name}`,
|
|
157
|
+
run: () => onApprove?.(b.name),
|
|
158
|
+
blockedReason: perms.canApprove ? undefined : CLOSED_PULL_REQUEST_REASON,
|
|
159
|
+
}
|
|
160
|
+
: undefined);
|
|
161
|
+
const requestChanges = heldBack(reviewsOthers && (b.status === 'submitted' || b.status === 'approved')
|
|
139
162
|
? {
|
|
140
163
|
label: EDITOR_ACTIONS.requestChanges,
|
|
141
164
|
color: 'orange',
|
|
142
165
|
testId: `request-changes-branch-button-${b.name}`,
|
|
143
166
|
run: () => onRequestChanges?.(b.name),
|
|
144
|
-
blockedReason:
|
|
167
|
+
blockedReason: perms.canRequestChanges ? undefined : CLOSED_PULL_REQUEST_REASON,
|
|
168
|
+
// With Approve above it, blocked by the same closed pull request, the reason prints once.
|
|
169
|
+
reasonShownBy: approve && !perms.canApprove && b.pullRequestState === 'closed'
|
|
170
|
+
? approve.testId
|
|
171
|
+
: undefined,
|
|
145
172
|
}
|
|
146
|
-
: undefined;
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
: undefined);
|
|
174
|
+
const reviewing = reviewsOthersBranch(b, user);
|
|
175
|
+
// Reviewing the open branch: Approve leads with Request changes beside it, or Request changes
|
|
176
|
+
// alone once approved. Reviewing another one, Review opens it and the actions wait in the menu.
|
|
177
|
+
const primary = !isCurrent ? undefined : reviewing ? (approve ?? requestChanges) : workflow;
|
|
178
|
+
const secondary = isCurrent && reviewing && approve ? requestChanges : undefined;
|
|
179
|
+
// Two visible actions, or the long "Withdraw from review", would crowd the title at the drawer's
|
|
180
|
+
// width, so they take a row below it. Open and Review stay beside the title.
|
|
181
|
+
const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview;
|
|
182
|
+
const menuActions = [approve, requestChanges, workflow].filter((a) => !!a && a !== primary && a !== secondary);
|
|
153
183
|
const showViewPullRequest = !!b.pullRequestUrl;
|
|
154
184
|
const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
|
|
155
185
|
const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
|
|
156
186
|
const menuLabel = `More actions for ${b.name}`;
|
|
157
187
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
158
|
-
return (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
: 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
|
|
168
|
-
? 'This branch is in review. Withdraw it from review before deleting it.'
|
|
169
|
-
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }) }));
|
|
188
|
+
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, children: b.name }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
|
|
189
|
+
? 'red'
|
|
190
|
+
: b.pullRequestState === 'merged'
|
|
191
|
+
? 'violet'
|
|
192
|
+
: 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Tooltip, { label: "GitHub sync failed \u2014 an admin can retry it from System health.", disabled: Boolean(b.syncFailureReason), multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" }) })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(Tooltip, { label: "This branch's version was kept for conflicting entries during a rebase \u2014 review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.syncStatus === 'sync-failed' && b.syncFailureReason && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, "data-testid": `sync-failed-reason-${b.name}`, children: [b.syncFailureReason, b.status === 'submitted' && ' Withdraw it from review to make changes.'] })), _jsxs(Group, { gap: "xs", align: "center", children: [b.updatedAt && (_jsx(RelativeTime, { iso: b.updatedAt, prefix: "Updated ", size: "xs", c: "dimmed" })), b.createdBy && (_jsxs(Group, { gap: "xs", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "\u2022" }), _jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Owner:" }), _jsx(UserBadge, { userId: b.createdBy, getUserMetadata: onGetOwnerMetadata ?? noUserLookup, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })] })] }))] }), b.access && (_jsx(BranchAccessList, { users: b.access.users, groups: b.access.groups, withheldUsers: b.access.withheldUsers, withheldGroups: b.access.withheldGroups, getUserMetadata: onGetOwnerMetadata ?? noUserLookup }))] }), _jsxs(Group, { gap: 8, wrap: "nowrap", children: [!isCurrent && (_jsx(Button, { variant: "light", onClick: () => onSelect?.(b.name), "data-testid": reviewing ? `review-branch-button-${b.name}` : `switch-to-branch-button-${b.name}`, children: reviewing ? EDITOR_ACTIONS.review : 'Open' })), !actionRow && primary && _jsx(CardActionButton, { action: primary }), hasMenu && (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, onChange: setMenuOpen, children: [_jsx(Tooltip, { label: menuLabel, disabled: menuOpen, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": menuLabel, "data-testid": `branch-actions-menu-${b.name}`, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [menuActions.map((action) => (_jsx(ReasonedMenuItem, { blockedReason: action.blockedReason, showReason: !action.reasonShownBy, reasonId: `${action.testId}-reason`, reasonDescribedBy: action.reasonShownBy ? `${action.reasonShownBy}-reason` : undefined, onClick: action.run, "data-testid": action.testId, children: action.label }, action.testId))), showViewPullRequest && (_jsx(Menu.Item, { component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), "data-testid": `view-pull-request-${b.name}`, children: b.pullRequestNumber
|
|
193
|
+
? `View pull request #${b.pullRequestNumber}`
|
|
194
|
+
: 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
|
|
195
|
+
? 'This branch is in review. Withdraw it from review before deleting it.'
|
|
196
|
+
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
|
|
170
197
|
};
|
|
171
|
-
|
|
198
|
+
const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
|
|
199
|
+
if (!action.blockedReason)
|
|
200
|
+
action.run();
|
|
201
|
+
}, "data-disabled": action.blockedReason ? true : undefined, "aria-disabled": action.blockedReason ? true : undefined, "data-testid": action.testId, children: action.label }) }));
|
|
202
|
+
export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
|
|
172
203
|
const supportsBranching = clientOperatingStrategy(mode).supportsBranching();
|
|
173
204
|
const [showCreateForm, setShowCreateForm] = useState(startCreating);
|
|
174
205
|
const [newBranchName, setNewBranchName] = useState('');
|
|
@@ -209,5 +240,5 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
209
240
|
]
|
|
210
241
|
: branches;
|
|
211
242
|
const hasBranchThreads = comments.some((t) => t.type === 'branch');
|
|
212
|
-
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
|
|
243
|
+
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onApprove: onApprove, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
|
|
213
244
|
};
|
|
@@ -8,8 +8,8 @@ import { buildEditorCollections } from './editor-config.js';
|
|
|
8
8
|
export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialValues, renderPreview, onCreateEntry, branchName, customRenderers, }) => {
|
|
9
9
|
const collections = useMemo(() => buildEditorCollections(config.flatSchema), [config.flatSchema]);
|
|
10
10
|
// Empty string = branchless start; useBranchManager adopts the server's
|
|
11
|
-
//
|
|
12
|
-
const resolvedBranchName = branchName ??
|
|
11
|
+
// default branch on the first branches.list() load.
|
|
12
|
+
const resolvedBranchName = branchName ?? '';
|
|
13
13
|
const resolvedTitle = config.editor?.title ?? 'CanopyCMS Editor';
|
|
14
14
|
const resolvedTheme = config.editor?.theme ?? undefined;
|
|
15
15
|
// The gate owns the SWRProvider (keyed by user id), so it sits between the API client and the
|
|
@@ -9,10 +9,9 @@ import { CanopyEditor } from './CanopyEditor.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export const CanopyEditorPage = (config, customRenderers) => {
|
|
11
11
|
const CanopyEditorPageInner = ({ searchParams, }) => {
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
const branchName = searchParams?.branch ?? config.defaultActiveBranch ?? config.defaultBaseBranch;
|
|
12
|
+
// Without ?branch= the editor starts branchless and useBranchManager adopts
|
|
13
|
+
// the branches API's defaultBranch.
|
|
14
|
+
const branchName = searchParams?.branch;
|
|
16
15
|
const initialSelectedId = searchParams?.entry;
|
|
17
16
|
return (_jsx(CanopyEditor, { config: config, branchName: branchName, initialSelectedId: initialSelectedId, entries: [], customRenderers: customRenderers }));
|
|
18
17
|
};
|
package/dist/editor/Editor.js
CHANGED
|
@@ -24,6 +24,7 @@ import { PermissionManager } from './PermissionManager.js';
|
|
|
24
24
|
import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
|
|
25
25
|
// Import directly from helpers to avoid server-only code in authorization barrel
|
|
26
26
|
import { isAdmin, isPrivileged, isReviewer } from '../authorization/helpers.js';
|
|
27
|
+
import { reviewRoleOf } from '../authorization/review-role.js';
|
|
27
28
|
import { fieldBreadcrumb } from './field-breadcrumb.js';
|
|
28
29
|
import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
|
|
29
30
|
import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
|
|
@@ -44,6 +45,7 @@ import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
|
|
|
44
45
|
import { ReferencedByList, referencedDeleteMessage } from './components/ReferencedByList.js';
|
|
45
46
|
import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
|
|
46
47
|
import { BranchNotAvailableNotice } from './components/BranchNotAvailableNotice.js';
|
|
48
|
+
import { BranchesLoadFailedNotice } from './components/BranchesLoadFailedNotice.js';
|
|
47
49
|
import { ReadOnlyDraftNotice } from './components/ReadOnlyDraftNotice.js';
|
|
48
50
|
import { UnavailableEntryNotice } from './components/UnavailableEntryNotice.js';
|
|
49
51
|
import { unavailableSchemaRefs } from './unavailable-entry-type.js';
|
|
@@ -160,7 +162,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
160
162
|
labels: [],
|
|
161
163
|
}));
|
|
162
164
|
const applySubmitStampsRef = useRef(() => { });
|
|
163
|
-
|
|
165
|
+
// Reloads the open branch's comments after a request for changes, whose note is a comment; the
|
|
166
|
+
// comment system is built further down.
|
|
167
|
+
const reloadCommentsRef = useRef(() => { });
|
|
168
|
+
const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
164
169
|
initialBranch: branchName,
|
|
165
170
|
operatingMode,
|
|
166
171
|
setBusy: setBranchesLoading,
|
|
@@ -168,6 +173,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
168
173
|
// The draft manager comes later, as it depends on this hook's branch.
|
|
169
174
|
getUnsaved: () => resolveUnsavedRef.current(),
|
|
170
175
|
onStamped: (stamped) => applySubmitStampsRef.current(stamped),
|
|
176
|
+
onChangesRequested: (branch) => reloadCommentsRef.current(branch),
|
|
171
177
|
});
|
|
172
178
|
// Content mutations (add/delete/rename/reorder entries) are locked whenever the
|
|
173
179
|
// server says writes are blocked -- the protected base branch, or a workflow
|
|
@@ -185,6 +191,17 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
185
191
|
// No lock reason is true yet while the list is unanswered; once it has failed, or answered
|
|
186
192
|
// without this branch, the lock says which.
|
|
187
193
|
const branchPending = !currentBranch && !branchesLoaded && !branchesLoadFailed;
|
|
194
|
+
// The same inputs EditorHeader gives its own check, for the lock copy that names the way forward.
|
|
195
|
+
const branchReviewRole = reviewRoleOf({
|
|
196
|
+
status: currentBranch?.status,
|
|
197
|
+
createdBy: currentBranch?.createdBy,
|
|
198
|
+
users: currentBranch?.access.allowedUsers,
|
|
199
|
+
groups: currentBranch?.access.allowedGroups,
|
|
200
|
+
isProtected: currentBranch?.isProtected ?? true,
|
|
201
|
+
pullRequestState: currentBranch?.pullRequestState,
|
|
202
|
+
}, userContext);
|
|
203
|
+
const branchReviewing = branchReviewRole === 'reviewing';
|
|
204
|
+
const branchPullRequestClosed = branchReviewRole === 'reviewing-closed';
|
|
188
205
|
const branchMissing = !currentBranch && branchesLoaded;
|
|
189
206
|
// The bar's lock-state row, with the inputs EditorHeader gets, for the states that must agree.
|
|
190
207
|
const branchBarState = branchBarStatus({
|
|
@@ -226,6 +243,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
226
243
|
// The "Not available" empty states wait for the load to answer with nothing: content that does
|
|
227
244
|
// load, for a branch the list lags in granting, shows as usual.
|
|
228
245
|
const branchShowsNotAvailable = branchNotAvailable && !entriesInitializing && entriesState.length === 0;
|
|
246
|
+
// An unpinned editor takes its branch from the branch list, so without the list it has none.
|
|
247
|
+
const branchListFailed = !branchNameState && branchesLoadFailed;
|
|
229
248
|
// The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
|
|
230
249
|
// overlay's. Anything that focuses something in the form reveals it first. Opening an entry
|
|
231
250
|
// closes the overlay only, so the preview stays maximized across entries (see
|
|
@@ -336,7 +355,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
336
355
|
await handleBranchChange(branch);
|
|
337
356
|
};
|
|
338
357
|
// 5. Comment system (depends on branchNameState)
|
|
339
|
-
const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
|
|
358
|
+
const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, loadComments, } = useCommentSystem({
|
|
340
359
|
branchName: branchNameState,
|
|
341
360
|
selectedPath,
|
|
342
361
|
currentEntry,
|
|
@@ -347,6 +366,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
347
366
|
onCommentsChange: setCommentsForBranchSummaries,
|
|
348
367
|
onRevealForm: revealForm,
|
|
349
368
|
});
|
|
369
|
+
reloadCommentsRef.current = (branch) => {
|
|
370
|
+
if (sanitizeBranchName(branch) === sanitizeBranchName(branchNameState))
|
|
371
|
+
void loadComments(branch);
|
|
372
|
+
};
|
|
350
373
|
const { groupsData, groupsLoading, handleSaveGroups, handleSearchUsers, handleGetUserMetadata, handleSearchExternalGroups, } = useGroupManager({ isOpen: groupManagerOpen });
|
|
351
374
|
// The comments and branches responses name their people, without email; a privileged lookup
|
|
352
375
|
// adds the email.
|
|
@@ -1368,7 +1391,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1368
1391
|
display: 'flex',
|
|
1369
1392
|
alignItems: 'center',
|
|
1370
1393
|
justifyContent: 'center',
|
|
1371
|
-
}, children: !currentEntry && branchShowsNotAvailable ? (_jsx(BranchNotAvailableNotice, { align: "center", "data-testid": "preview-branch-not-available" })) : (_jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
|
|
1394
|
+
}, children: !currentEntry && branchShowsNotAvailable ? (_jsx(BranchNotAvailableNotice, { align: "center", "data-testid": "preview-branch-not-available" })) : branchListFailed ? (_jsx(BranchesLoadFailedNotice, { align: "center", "data-testid": "preview-branches-load-failed" })) : (_jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
|
|
1372
1395
|
? 'Setting up your branch workspace…'
|
|
1373
1396
|
: entriesInitializing
|
|
1374
1397
|
? 'Loading content…'
|
|
@@ -1396,8 +1419,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1396
1419
|
}, [tabTitle]);
|
|
1397
1420
|
const openBranchManager = (options) => {
|
|
1398
1421
|
openBranchesDrawer(options?.create === true);
|
|
1399
|
-
// Always retry, not only when branchless.
|
|
1400
|
-
//
|
|
1422
|
+
// Always retry, not only when branchless. Besides the form pane's Retry on a
|
|
1423
|
+
// branchless start, opening the manager retries a failed branches fetch, and the fetch
|
|
1401
1424
|
// is terminal on its own: SWRProvider sets shouldRetryOnError and
|
|
1402
1425
|
// revalidateOnFocus false, and useBranchesData's refreshInterval
|
|
1403
1426
|
// is 0 without data. Reloading is idempotent.
|
|
@@ -1434,6 +1457,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1434
1457
|
branchReadOnly: currentBranch?.readOnly,
|
|
1435
1458
|
branchWriteBlocked: branchContentLocked,
|
|
1436
1459
|
branchMissing,
|
|
1460
|
+
reviewing: branchReviewing,
|
|
1461
|
+
pullRequestClosed: branchPullRequestClosed,
|
|
1437
1462
|
}), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), changedOnly: changedOnly, onChangedOnlyChange: setChangedOnly, unsavedCount: modifiedCount, unsavedEntries: editedFiles, entryListFailed: entriesLoadFailed && entriesState.length === 0, branchNotAvailable: branchShowsNotAvailable, onSwitchBranch: () => openBranchManager(), filterRef: entryFilterRef, filterShortcut: findShortcut, onFilterEnter: contentPanelTransient
|
|
1438
1463
|
? (path) => {
|
|
1439
1464
|
openEntry(path);
|
|
@@ -1441,7 +1466,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1441
1466
|
setFormFocusPath(path);
|
|
1442
1467
|
}
|
|
1443
1468
|
: undefined, onFilterEscape: contentPanelTransient ? () => setContentPanelTransient(false) : undefined, onRowMenuOpen: keepContentPanelOpen }) }) }));
|
|
1444
|
-
return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
|
|
1469
|
+
return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchPullRequestState: currentBranch?.pullRequestState, branchAccess: currentBranch?.access,
|
|
1445
1470
|
// Fail-closed for the same reason branchContentLocked above is:
|
|
1446
1471
|
// isProtected/writeBlocked gate real actions in EditorHeader and EntryHeader
|
|
1447
1472
|
// (hiding Submit, disabling Save and Save all), so a missing wire value must read as
|
|
@@ -1463,6 +1488,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1463
1488
|
if (branchNameState && !isSaveAllRunning()) {
|
|
1464
1489
|
handleWithdraw(branchNameState, returnFocusTo).catch(console.error);
|
|
1465
1490
|
}
|
|
1491
|
+
}, onApprove: (returnFocusTo) => {
|
|
1492
|
+
if (branchNameState && !isSaveAllRunning()) {
|
|
1493
|
+
handleApprove(branchNameState, returnFocusTo).catch(console.error);
|
|
1494
|
+
}
|
|
1495
|
+
}, onRequestChanges: (returnFocusTo) => {
|
|
1496
|
+
if (branchNameState && !isSaveAllRunning()) {
|
|
1497
|
+
handleRequestChanges(branchNameState, returnFocusTo).catch(console.error);
|
|
1498
|
+
}
|
|
1466
1499
|
} }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelTransient
|
|
1467
1500
|
? keepContentPanelOpen()
|
|
1468
1501
|
: contentPanelShown
|
|
@@ -1474,7 +1507,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1474
1507
|
// True exactly while the form below shows "Setting up…" or "Loading content…".
|
|
1475
1508
|
entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
|
|
1476
1509
|
// A draft kept for an unavailable entry stays in storage but cannot be saved.
|
|
1477
|
-
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1510
|
+
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1478
1511
|
singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
|
|
1479
1512
|
lastSaved.contentId === currentEntry?.contentId }), _jsx(Box, { style: { flex: '1 1 0%', minHeight: 0 }, children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
|
|
1480
1513
|
// Sizing the pane, by pointer or key, gives the page the pane's width.
|
|
@@ -1487,7 +1520,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1487
1520
|
setPreviewWidth('fit');
|
|
1488
1521
|
}, preview: renderPreview && currentEntry && !currentEntryUnavailable
|
|
1489
1522
|
? renderPreview(currentEntry, displayValue)
|
|
1490
|
-
: defaultPreview, form: !currentEntry && branchShowsNotAvailable ? (_jsx(Center, { h: "100%", children: _jsx(BranchNotAvailableNotice, { align: "center", onSwitchBranch: () => openBranchManager(), "data-testid": "form-branch-not-available" }) })) : !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
|
|
1523
|
+
: defaultPreview, form: !currentEntry && branchShowsNotAvailable ? (_jsx(Center, { h: "100%", children: _jsx(BranchNotAvailableNotice, { align: "center", onSwitchBranch: () => openBranchManager(), "data-testid": "form-branch-not-available" }) })) : branchListFailed ? (_jsx(Center, { h: "100%", children: _jsx(BranchesLoadFailedNotice, { align: "center", onRetry: () => void loadBranches().catch(console.error), retrying: busy, "data-testid": "form-branches-load-failed" }) })) : !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
|
|
1491
1524
|
? 'Setting up your branch workspace…'
|
|
1492
1525
|
: entriesInitializing
|
|
1493
1526
|
? 'Loading content…'
|
|
@@ -1532,7 +1565,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1532
1565
|
if (name === branchNameState && isSaveAllRunning())
|
|
1533
1566
|
return;
|
|
1534
1567
|
handleWithdraw(name).catch((err) => console.error(err));
|
|
1568
|
+
}, onApprove: (name) => {
|
|
1569
|
+
if (name === branchNameState && isSaveAllRunning())
|
|
1570
|
+
return;
|
|
1571
|
+
handleApprove(name).catch((err) => console.error(err));
|
|
1535
1572
|
}, onRequestChanges: (name) => {
|
|
1573
|
+
if (name === branchNameState && isSaveAllRunning())
|
|
1574
|
+
return;
|
|
1536
1575
|
handleRequestChanges(name).catch((err) => console.error(err));
|
|
1537
1576
|
}, onDelete: (name) => {
|
|
1538
1577
|
handleDelete(name).catch((err) => console.error(err));
|
|
@@ -8,6 +8,13 @@ export interface BranchLockInput {
|
|
|
8
8
|
branchPending?: boolean;
|
|
9
9
|
/** The branch list answered without this branch: no access to it, or it no longer exists. */
|
|
10
10
|
branchMissing?: boolean;
|
|
11
|
+
/** The user reviews this branch (`isReviewerOfOthersReview`), so Approve and Request changes lead, not Withdraw. */
|
|
12
|
+
reviewing?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The user would review this branch but its pull request was closed (`reviewRoleOf`), so the
|
|
15
|
+
* way forward is Withdraw and the reason says why Approve is not offered.
|
|
16
|
+
*/
|
|
17
|
+
pullRequestClosed?: boolean;
|
|
11
18
|
}
|
|
12
19
|
/**
|
|
13
20
|
* Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { branchStatusPresentation } from './branch-status.js';
|
|
2
|
+
import { CLOSED_PULL_REQUEST_REASON } from './copy.js';
|
|
2
3
|
/**
|
|
3
4
|
* Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
|
|
4
5
|
* nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
|
|
@@ -26,10 +27,16 @@ export function describeBranchLock(lock) {
|
|
|
26
27
|
: 'Branch details could not be loaded. Use "Refresh branch" to try again.';
|
|
27
28
|
if (!statusLocked)
|
|
28
29
|
return undefined;
|
|
30
|
+
if (lock.pullRequestClosed)
|
|
31
|
+
return CLOSED_PULL_REQUEST_REASON;
|
|
29
32
|
if (lock.branchStatus === 'submitted')
|
|
30
|
-
return
|
|
33
|
+
return lock.reviewing
|
|
34
|
+
? 'This branch is in review. Approve it, or request changes to send it back to its editor.'
|
|
35
|
+
: 'This branch is in review. Withdraw it to make changes.';
|
|
31
36
|
if (lock.branchStatus === 'approved')
|
|
32
|
-
return
|
|
37
|
+
return lock.reviewing
|
|
38
|
+
? 'This branch is approved. Request changes to send it back to its editor.'
|
|
39
|
+
: 'This branch is approved. Withdraw it to make changes.';
|
|
33
40
|
return `This branch is ${lock.branchStatus}. Its content is read-only.`;
|
|
34
41
|
}
|
|
35
42
|
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
@@ -38,9 +38,11 @@ export declare function expireReferences(cache: ReferenceCache): void;
|
|
|
38
38
|
/**
|
|
39
39
|
* The draft as the preview sees it, computed synchronously from the cache.
|
|
40
40
|
*
|
|
41
|
-
* Each
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* Each id becomes its cached value (the target, or an `unavailable` reference), or `null` while it
|
|
42
|
+
* has none (still resolving, a failed lookup, or a malformed id). A reference the form holds as an
|
|
43
|
+
* object is the target the entry's read resolved, and passes through as it is, alone or in a list.
|
|
44
|
+
* A list field's array maps element by element, any other non-string element to `null`; anything
|
|
45
|
+
* else is left as the form holds it, as on the server.
|
|
44
46
|
* `loadingState` holds a boolean only at reference positions, `true` while resolving, at the
|
|
45
47
|
* reference's own path (`boolean[]` for a list).
|
|
46
48
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createApiClient } from '../api/client.js';
|
|
2
2
|
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
3
3
|
import { isValidContentId } from '../paths/validation.js';
|
|
4
|
-
import { traverseFields } from '../validation/field-traversal.js';
|
|
4
|
+
import { referenceValueId, traverseFields } from '../validation/field-traversal.js';
|
|
5
5
|
/** The resolve-references endpoint's per-request id cap (`MAX_RESOLVE_REFERENCE_IDS`). */
|
|
6
6
|
const MAX_IDS_PER_REQUEST = 100;
|
|
7
7
|
/**
|
|
@@ -135,9 +135,11 @@ function setCreating(root, path, leaf, source) {
|
|
|
135
135
|
/**
|
|
136
136
|
* The draft as the preview sees it, computed synchronously from the cache.
|
|
137
137
|
*
|
|
138
|
-
* Each
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* Each id becomes its cached value (the target, or an `unavailable` reference), or `null` while it
|
|
139
|
+
* has none (still resolving, a failed lookup, or a malformed id). A reference the form holds as an
|
|
140
|
+
* object is the target the entry's read resolved, and passes through as it is, alone or in a list.
|
|
141
|
+
* A list field's array maps element by element, any other non-string element to `null`; anything
|
|
142
|
+
* else is left as the form holds it, as on the server.
|
|
141
143
|
* `loadingState` holds a boolean only at reference positions, `true` while resolving, at the
|
|
142
144
|
* reference's own path (`boolean[]` for a list).
|
|
143
145
|
*/
|
|
@@ -146,7 +148,10 @@ export function applyReferenceCache(fields, value, branch, cache) {
|
|
|
146
148
|
const loadingState = {};
|
|
147
149
|
const copies = new WeakSet();
|
|
148
150
|
const lookup = (id) => {
|
|
149
|
-
if (typeof id !== 'string'
|
|
151
|
+
if (typeof id !== 'string') {
|
|
152
|
+
return { value: referenceValueId(id) === undefined ? null : id, loading: false };
|
|
153
|
+
}
|
|
154
|
+
if (!isValidContentId(id))
|
|
150
155
|
return { value: null, loading: false };
|
|
151
156
|
const entry = cache.get(cacheKey(branch, id));
|
|
152
157
|
return entry ? { value: entry.value, loading: false } : { value: null, loading: true };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface BranchesLoadFailedNoticeProps {
|
|
2
|
+
/** Loads the branch list again. Without it the notice is text alone. */
|
|
3
|
+
onRetry?: () => void;
|
|
4
|
+
/** A retry is in flight. */
|
|
5
|
+
retrying?: boolean;
|
|
6
|
+
align?: 'flex-start' | 'center';
|
|
7
|
+
'data-testid'?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The error state of an editor no branch is pinned in, while the branch list it takes its
|
|
11
|
+
* branch from has failed to load. It outlasts the dismissible toast for the same failure.
|
|
12
|
+
*/
|
|
13
|
+
export declare function BranchesLoadFailedNotice({ onRetry, retrying, align, 'data-testid': testId, }: BranchesLoadFailedNoticeProps): import("react/jsx-runtime").JSX.Element;
|