canopycms 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -48
- package/dist/api/branch-changes.d.ts +1 -0
- package/dist/api/branch-changes.js +1 -1
- package/dist/api/branch-entry-changes.d.ts +92 -7
- package/dist/api/branch-entry-changes.js +272 -38
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/cli/cli.js +62 -1
- package/dist/cli/generate-ai-content.js +61 -0
- package/dist/content-store.d.ts +26 -0
- package/dist/content-store.js +73 -0
- package/dist/editor/Editor.js +134 -15
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +36 -0
- package/dist/editor/components/EntryChangesDrawer.js +141 -5
- package/dist/editor/components/EntryHeader.js +2 -4
- package/dist/editor/copy.d.ts +8 -0
- package/dist/editor/copy.js +8 -0
- package/dist/editor/entry-diff.d.ts +16 -1
- package/dist/editor/entry-diff.js +21 -11
- package/dist/editor/undo-change.d.ts +45 -0
- package/dist/editor/undo-change.js +226 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/content-store.js
CHANGED
|
@@ -56,6 +56,10 @@ export class ContentStoreError extends Error {
|
|
|
56
56
|
this.code = code;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/** Why a restore refuses: another entry took the slug after this one was deleted. */
|
|
60
|
+
export const RESTORE_SLUG_TAKEN_MESSAGE = "Another entry now has this entry's URL. Change that entry's URL, then restore this one.";
|
|
61
|
+
/** Why a restore refuses: the entry, by its content ID, is on the branch already. */
|
|
62
|
+
export const RESTORE_ID_TAKEN_MESSAGE = 'This entry is already on the branch, so there is nothing to restore. Refresh to see where it is.';
|
|
59
63
|
/**
|
|
60
64
|
* Thrown when `expectedVersion` on a write doesn't match the file's current mtime.
|
|
61
65
|
* Indicates a cross-process concurrent write — the caller should reload and retry.
|
|
@@ -1283,6 +1287,75 @@ export class ContentStore {
|
|
|
1283
1287
|
throw new ContentConflictError();
|
|
1284
1288
|
});
|
|
1285
1289
|
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
|
|
1292
|
+
* under its old slug, entry type and content ID, so references and comment threads find it
|
|
1293
|
+
* again and the branch no longer differs from the base there.
|
|
1294
|
+
*
|
|
1295
|
+
* A create under write()'s locks and create-only guards that never replaces or relocates
|
|
1296
|
+
* anything. It refuses with `ContentConflictError` while a file holds the slug, or the index
|
|
1297
|
+
* or the target directory holds the ID: unlike write(), it has no index repair, so an ID
|
|
1298
|
+
* found elsewhere is a moved entry, not a deleted one, and is left alone. The index is
|
|
1299
|
+
* warmed outside the lock, as write() warms it, and rescanned under the lock only when the
|
|
1300
|
+
* generation marker says another host changed the tree.
|
|
1301
|
+
*/
|
|
1302
|
+
async restore(collectionPath, slug, entry, options = {}) {
|
|
1303
|
+
await this.idIndex();
|
|
1304
|
+
const collection = this.assertCollection(collectionPath);
|
|
1305
|
+
if (!collection.entries?.some((e) => e.name === entry.entryTypeName)) {
|
|
1306
|
+
throw new ContentStoreError(`Entry type '${entry.entryTypeName}' not found in collection`, 'NO_SCHEMA_ITEM');
|
|
1307
|
+
}
|
|
1308
|
+
this.assertEntryTypeAvailable(collection, entry.entryTypeName);
|
|
1309
|
+
return this.withContentWriteExclusion(async () => {
|
|
1310
|
+
await options.beforeWrite?.();
|
|
1311
|
+
// Every content write and every git or sync change to the tree bumps the marker
|
|
1312
|
+
// (content-index-generation.ts), so a marker that still matches the index's means the
|
|
1313
|
+
// index is current. Read here unthrottled, unlike idIndex()'s own probe; a rescan holds no
|
|
1314
|
+
// entry lock, which it must not.
|
|
1315
|
+
if ((await readContentIndexGeneration(this.root)) !== this.loadedDiskGeneration) {
|
|
1316
|
+
this.invalidateIndex();
|
|
1317
|
+
}
|
|
1318
|
+
await this.idIndex();
|
|
1319
|
+
return withLock(this.createLockKey(collection, slug), async () => {
|
|
1320
|
+
// The ID first: an entry already back at its own slug is this one, not another.
|
|
1321
|
+
const target = await this.buildPaths(collection, slug, {
|
|
1322
|
+
existingId: entry.id,
|
|
1323
|
+
entryTypeName: entry.entryTypeName,
|
|
1324
|
+
});
|
|
1325
|
+
const dir = path.dirname(target.absolutePath);
|
|
1326
|
+
const liveIndex = this._idIndex;
|
|
1327
|
+
if (liveIndex.findById(entry.id) ||
|
|
1328
|
+
liveIndex.getDuplicateFor(entry.id) ||
|
|
1329
|
+
(await this.findEntryPathsById(dir, entry.id)).length > 0) {
|
|
1330
|
+
throw new ContentConflictError(RESTORE_ID_TAKEN_MESSAGE);
|
|
1331
|
+
}
|
|
1332
|
+
if ((await this.buildPaths(collection, slug)).existed) {
|
|
1333
|
+
throw new ContentConflictError(RESTORE_SLUG_TAKEN_MESSAGE);
|
|
1334
|
+
}
|
|
1335
|
+
// [SLUG] and [URL], as write() checks a create.
|
|
1336
|
+
const routable = parseSlug(target.slug);
|
|
1337
|
+
if (!routable.ok) {
|
|
1338
|
+
throw new ContentStoreError(`Cannot restore entry "${target.slug}": ${routable.error}.`, 'VALIDATION');
|
|
1339
|
+
}
|
|
1340
|
+
await this.assertUrlPathAvailable(dir, target.slug);
|
|
1341
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1342
|
+
await atomicWriteFile(target.absolutePath, entry.raw);
|
|
1343
|
+
// The live index, looked up and changed with no await between: a rebuild since the check
|
|
1344
|
+
// above may have swapped in one that already scanned the restored file.
|
|
1345
|
+
const index = this._idIndex;
|
|
1346
|
+
if (!index.findById(entry.id)) {
|
|
1347
|
+
index.add({
|
|
1348
|
+
type: 'entry',
|
|
1349
|
+
relativePath: target.relativePath,
|
|
1350
|
+
collection: collectionPath,
|
|
1351
|
+
slug,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
await this.recordOwnMutation(index);
|
|
1355
|
+
return { relativePath: target.relativePath };
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1286
1359
|
/**
|
|
1287
1360
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
1288
1361
|
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
package/dist/editor/Editor.js
CHANGED
|
@@ -38,6 +38,7 @@ import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
|
|
|
38
38
|
import { useReferenceResolution } from './hooks/useReferenceResolution.js';
|
|
39
39
|
import { usePreviewMarks } from './hooks/usePreviewMarks.js';
|
|
40
40
|
import { branchBarStatus, describeBranchLock } from './branch-lock.js';
|
|
41
|
+
import { NO_EDIT_ACCESS_REASON } from './copy.js';
|
|
41
42
|
import { EditorHeader, EditorRail, EntryHeader } from './components/index.js';
|
|
42
43
|
import { showSaveAllResult } from './components/SaveAllResult.js';
|
|
43
44
|
import { RenameEntryModal } from './components/RenameEntryModal.js';
|
|
@@ -67,7 +68,20 @@ import { findFieldTarget, focusFieldTarget } from './field-target.js';
|
|
|
67
68
|
import { PreviewFollowContext, usePreviewFollow } from './hooks/usePreviewFollow.js';
|
|
68
69
|
import { UndoToastMessage } from './components/UndoToastMessage.js';
|
|
69
70
|
import { getNotificationDuration } from './utils/env.js';
|
|
70
|
-
import { MayHaveLandedError } from './unanswered.js';
|
|
71
|
+
import { MayHaveLandedError, isGatewayFailure, isUnanswered } from './unanswered.js';
|
|
72
|
+
/** The collection at `path` in a collection tree. */
|
|
73
|
+
const findCollection = (cols, path) => {
|
|
74
|
+
if (!cols)
|
|
75
|
+
return undefined;
|
|
76
|
+
for (const col of cols) {
|
|
77
|
+
if (col.path === path)
|
|
78
|
+
return col;
|
|
79
|
+
const found = findCollection(col.children, path);
|
|
80
|
+
if (found)
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
};
|
|
71
85
|
// Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
|
|
72
86
|
// while no entry is open.
|
|
73
87
|
const EMPTY_SCHEMA = [];
|
|
@@ -1012,18 +1026,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1012
1026
|
}
|
|
1013
1027
|
};
|
|
1014
1028
|
const handleReorderEntry = async (collectionPath, contentId, direction) => {
|
|
1015
|
-
const findCollection = (cols, path) => {
|
|
1016
|
-
if (!cols)
|
|
1017
|
-
return undefined;
|
|
1018
|
-
for (const col of cols) {
|
|
1019
|
-
if (col.path === path)
|
|
1020
|
-
return col;
|
|
1021
|
-
const found = findCollection(col.children, path);
|
|
1022
|
-
if (found)
|
|
1023
|
-
return found;
|
|
1024
|
-
}
|
|
1025
|
-
return undefined;
|
|
1026
|
-
};
|
|
1027
1029
|
// Resolve against `collectionsFromApi`, NOT `activeCollections`: the latter
|
|
1028
1030
|
// falls back to the build-time `collections` prop whenever the fetch is
|
|
1029
1031
|
// still empty -- exactly the state mid-branch-switch. A write resolved
|
|
@@ -1284,7 +1286,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1284
1286
|
standIn: () => null,
|
|
1285
1287
|
});
|
|
1286
1288
|
const showChangesRef = useRef(null);
|
|
1289
|
+
// A confirm never opens over the drawer, nor while it closes: its focus return would take
|
|
1290
|
+
// focus out of the confirm. So "Delete entry" closes the drawer, and its exit opens the confirm.
|
|
1291
|
+
const deleteAfterChangesRef = useRef();
|
|
1287
1292
|
useEffect(() => {
|
|
1293
|
+
deleteAfterChangesRef.current = undefined;
|
|
1288
1294
|
setChangesOpen(false);
|
|
1289
1295
|
setChangesShown(false);
|
|
1290
1296
|
}, [branchNameState]);
|
|
@@ -1321,6 +1327,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1321
1327
|
const fields = entriesState.find((e) => e.path === entry.logicalPath)?.schema ??
|
|
1322
1328
|
entriesState.find((e) => e.collectionPath === entry.collectionPath && e.entryType === entry.entryType)?.schema ??
|
|
1323
1329
|
EMPTY_SCHEMA;
|
|
1330
|
+
deleteAfterChangesRef.current = undefined;
|
|
1324
1331
|
setChangesSubject({
|
|
1325
1332
|
logicalPath: entry.logicalPath,
|
|
1326
1333
|
contentId: entry.contentId,
|
|
@@ -1350,6 +1357,108 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1350
1357
|
const changesPullRequestUrl = isReviewer(userContext?.groups)
|
|
1351
1358
|
? currentBranch?.pullRequestUrl
|
|
1352
1359
|
: undefined;
|
|
1360
|
+
// The drawer's undo, delete and restore follow the form's rules: hidden without edit access,
|
|
1361
|
+
// disabled with the branch lock's reason while the branch takes no writes. Undo writes the open
|
|
1362
|
+
// entry's draft, through the functional update the remove-Undo uses, so it is an unsaved edit.
|
|
1363
|
+
const changesSubjectOpen = !!changesSubject &&
|
|
1364
|
+
!!currentEntry &&
|
|
1365
|
+
changesSubject.status !== 'deleted' &&
|
|
1366
|
+
changesSubject.logicalPath === currentEntry.path &&
|
|
1367
|
+
(changesSubject.contentId === undefined || changesSubject.contentId === currentEntry.contentId);
|
|
1368
|
+
const changesLockReason = branchContentLocked
|
|
1369
|
+
? describeBranchLock({
|
|
1370
|
+
branchStatus: currentBranch?.status,
|
|
1371
|
+
branchReadOnly: currentBranch?.readOnly,
|
|
1372
|
+
branchWriteBlocked: true,
|
|
1373
|
+
branchPending,
|
|
1374
|
+
branchMissing,
|
|
1375
|
+
reviewing: branchReviewing,
|
|
1376
|
+
pullRequestClosed: branchPullRequestClosed,
|
|
1377
|
+
approveBlocked: currentBranch?.approveBlocked,
|
|
1378
|
+
})
|
|
1379
|
+
: undefined;
|
|
1380
|
+
const changesUndo = changesSubjectOpen && currentEntry && displayValue
|
|
1381
|
+
? {
|
|
1382
|
+
// What the form shows: a draft hidden behind a lock isn't what the cards compare.
|
|
1383
|
+
value: displayValue,
|
|
1384
|
+
saved: loadedValues[currentEntry.contentId],
|
|
1385
|
+
update: (update) => {
|
|
1386
|
+
if (contentReadOnlyRef.current)
|
|
1387
|
+
return;
|
|
1388
|
+
const contentId = currentEntry.contentId;
|
|
1389
|
+
setDrafts((prev) => {
|
|
1390
|
+
const value = prev[contentId] ?? loadedValuesRef.current[contentId];
|
|
1391
|
+
if (!value)
|
|
1392
|
+
return prev;
|
|
1393
|
+
const next = update(value);
|
|
1394
|
+
return next === value ? prev : { ...prev, [contentId]: next };
|
|
1395
|
+
});
|
|
1396
|
+
},
|
|
1397
|
+
threadsWithin: (path) => openThreadsWithin(displayComments, currentEntry.path, path),
|
|
1398
|
+
}
|
|
1399
|
+
: undefined;
|
|
1400
|
+
// An entry the branch deleted has no row to carry `canEdit`; its collection's `canAddEntry`
|
|
1401
|
+
// stands in, and the server judges the entry's own path.
|
|
1402
|
+
const deletedSubjectCollection = changesSubject?.status === 'deleted'
|
|
1403
|
+
? changesSubject.logicalPath.slice(0, changesSubject.logicalPath.lastIndexOf('/'))
|
|
1404
|
+
: undefined;
|
|
1405
|
+
const restoreRefusedByPath = deletedSubjectCollection !== undefined &&
|
|
1406
|
+
findCollection(collectionsFromApi, deletedSubjectCollection)?.canAddEntry === false;
|
|
1407
|
+
const changesRestoreEntry = changesSubject?.status === 'deleted' && changesSubject.contentId
|
|
1408
|
+
? async () => {
|
|
1409
|
+
const { logicalPath, contentId, title } = changesSubject;
|
|
1410
|
+
const branch = branchNameState;
|
|
1411
|
+
let answer;
|
|
1412
|
+
try {
|
|
1413
|
+
answer = await apiClient.changes.restore({ branch, entryPath: logicalPath }, { id: contentId });
|
|
1414
|
+
}
|
|
1415
|
+
catch (err) {
|
|
1416
|
+
if (!isUnanswered(err)) {
|
|
1417
|
+
return { tone: 'error', message: 'Couldn’t restore this entry. Try again.' };
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
// A 409 whose write may have run is as unanswered as a timeout.
|
|
1421
|
+
const outcomeUnknown = !!answer &&
|
|
1422
|
+
!answer.ok &&
|
|
1423
|
+
(isGatewayFailure(answer) || answer.code === 'WRITE_OUTCOME_UNKNOWN');
|
|
1424
|
+
if (answer && !answer.ok && !outcomeUnknown) {
|
|
1425
|
+
return {
|
|
1426
|
+
tone: answer.status === 409 ? 'warning' : 'error',
|
|
1427
|
+
message: answer.error ?? 'Couldn’t restore this entry. Try again.',
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
const entries = await refreshEntries(branch).catch(() => undefined);
|
|
1431
|
+
void branchChanges.reload();
|
|
1432
|
+
if (answer?.ok && answer.data?.warning) {
|
|
1433
|
+
notifications.show({ message: answer.data.warning, color: 'yellow' });
|
|
1434
|
+
}
|
|
1435
|
+
if (answer?.ok && !entries) {
|
|
1436
|
+
notifications.show({
|
|
1437
|
+
message: `"${title}" is restored. Refresh the page to see it in the "Content" panel.`,
|
|
1438
|
+
color: 'yellow',
|
|
1439
|
+
});
|
|
1440
|
+
setChangesOpen(false);
|
|
1441
|
+
return undefined;
|
|
1442
|
+
}
|
|
1443
|
+
// Unanswered, the entry list settles it: this entry, by id, listed again.
|
|
1444
|
+
if (!answer?.ok &&
|
|
1445
|
+
!entries?.some((e) => e.path === logicalPath && e.contentId === contentId)) {
|
|
1446
|
+
return {
|
|
1447
|
+
tone: 'warning',
|
|
1448
|
+
message: `Restoring "${title}" may have gone through, but the server couldn’t confirm it. Check the "Content" panel for it before trying again.`,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
setChangesOpen(false);
|
|
1452
|
+
openEntry(logicalPath);
|
|
1453
|
+
return undefined;
|
|
1454
|
+
}
|
|
1455
|
+
: undefined;
|
|
1456
|
+
const changesDeleteEntry = changesSubjectOpen && changesSubject?.status === 'added'
|
|
1457
|
+
? () => {
|
|
1458
|
+
deleteAfterChangesRef.current = changesSubject.logicalPath;
|
|
1459
|
+
setChangesOpen(false);
|
|
1460
|
+
}
|
|
1461
|
+
: undefined;
|
|
1353
1462
|
const runSaveAll = () => {
|
|
1354
1463
|
void handleSaveAll()
|
|
1355
1464
|
.then((result) => {
|
|
@@ -1729,10 +1838,20 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1729
1838
|
if (namesBranch(name, branchNameState) && isSaveAllRunning())
|
|
1730
1839
|
return;
|
|
1731
1840
|
handleArchive(name).catch((err) => console.error(err));
|
|
1732
|
-
}, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), changesSubject && (_jsx(LazyEntryChangesDrawer, { opened: changesOpen, onClose: () => setChangesOpen(false), onExited: () =>
|
|
1841
|
+
}, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), changesSubject && (_jsx(LazyEntryChangesDrawer, { opened: changesOpen, onClose: () => setChangesOpen(false), onExited: () => {
|
|
1842
|
+
setChangesShown(false);
|
|
1843
|
+
const deleting = deleteAfterChangesRef.current;
|
|
1844
|
+
deleteAfterChangesRef.current = undefined;
|
|
1845
|
+
// Opened as from the entry menu, whose trigger stands in for the gone button.
|
|
1846
|
+
if (deleting && deleting === changesSubject.logicalPath) {
|
|
1847
|
+
handleDeleteEntry(deleting, true);
|
|
1848
|
+
}
|
|
1849
|
+
}, returnFocusTo: changesReturnTarget, base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch', subject: {
|
|
1733
1850
|
...changesSubject,
|
|
1734
1851
|
unsavedEdits: changesSubject.logicalPath === currentEntry?.path && isSelectedDirty(),
|
|
1735
|
-
}, state: entryChange, referenceTitle: referenceTitle, onOpenEntry: changesOpenEntry, pullRequestUrl: changesPullRequestUrl
|
|
1852
|
+
}, state: entryChange, referenceTitle: referenceTitle, onOpenEntry: changesOpenEntry, pullRequestUrl: changesPullRequestUrl, undo: changesUndo, noEditReason: (changesSubjectOpen && currentEntry?.canEdit === false) || restoreRefusedByPath
|
|
1853
|
+
? NO_EDIT_ACCESS_REASON
|
|
1854
|
+
: undefined, lockedReason: changesLockReason, onDeleteEntry: changesDeleteEntry, onRestoreEntry: changesRestoreEntry })), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchLabel, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
|
|
1736
1855
|
? (path, entryType) => addEntryType(path, entryType)
|
|
1737
1856
|
: undefined, onUpdateEntryType: editingCollection
|
|
1738
1857
|
? (path, name, updates) => updateEntryType(path, name, updates)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { Button, Group, Stack, Textarea } from '@mantine/core';
|
|
4
|
+
import { Button, Group, Stack, Textarea, Tooltip } from '@mantine/core';
|
|
5
5
|
import { moveFocusIfUnmoved } from './move-focus-if-unmoved.js';
|
|
6
6
|
import { CommentWriteAlert, toCommentWriteFailure, } from './CommentWriteAlert.js';
|
|
7
|
+
const RESOLVE_BLOCKED_REASON = "Only the person who started this thread or a reviewer can resolve it. Reply to let them know it's done.";
|
|
7
8
|
/** Refocuses `box` when a submit settles: the busy button disabled itself and dropped focus. */
|
|
8
9
|
export const useRefocusAfterSubmit = (submitting, box) => {
|
|
9
10
|
const wasSubmitting = useRef(false);
|
|
@@ -90,6 +91,8 @@ export const ThreadReplyControls = ({ thread, onAddReply, onResolve, focusAfterR
|
|
|
90
91
|
}
|
|
91
92
|
};
|
|
92
93
|
const mayResolve = canResolve || thread.authorId === currentUserId;
|
|
93
|
-
|
|
94
|
+
// Shown disabled with its reason rather than hidden: the same user may resolve threads they start.
|
|
95
|
+
// `data-disabled` without native `disabled` keeps it focusable, so the Tooltip can open on focus.
|
|
96
|
+
const resolveButton = mayResolve ? (_jsx(Button, { variant: "light", color: "green", onClick: handleResolve, loading: resolving, disabled: replying, "data-testid": "resolve-thread-button", children: hasDraft ? 'Reply and resolve' : 'Resolve' })) : (_jsx(Tooltip, { label: RESOLVE_BLOCKED_REASON, multiline: true, maw: 280, events: { hover: true, focus: true, touch: true }, children: _jsx(Button, { variant: "light", color: "green", "data-disabled": true, "aria-disabled": true, onClick: (e) => e.preventDefault(), "data-testid": "resolve-thread-button", children: "Resolve" }) }));
|
|
94
97
|
return (_jsxs(Stack, { gap: "xs", children: [error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), expanded ? (_jsxs(_Fragment, { children: [_jsx(Textarea, { ref: boxRef, placeholder: "Write a reply\u2026", "aria-label": "Reply to this thread", value: replyText, onChange: (e) => setReplyText(e.target.value), minRows: 2, disabled: replying || resolving }), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { onClick: handleReply, loading: replying, disabled: !replyText.trim() || resolving, "data-testid": "submit-reply-button", children: "Reply" }), _jsx(Button, { variant: "subtle", color: "gray", onClick: collapse, disabled: replying, children: "Cancel" }), resolveButton] })] })) : (_jsxs(Group, { gap: "xs", children: [_jsx(Button, { ref: replyButtonRef, variant: "light", onClick: expand, disabled: resolving, "data-testid": "reply-thread-button", children: "Reply\u2026" }), resolveButton] }))] }));
|
|
95
98
|
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import React from 'react';
|
|
7
7
|
import type { BranchChangeEntry } from '../../api/branch-changes.js';
|
|
8
8
|
import type { FieldConfig } from '../../config/index.js';
|
|
9
|
+
import type { CanopyPathSegment } from '../canopy-path.js';
|
|
9
10
|
import type { EntryChangeState } from '../hooks/useBranchChangesData.js';
|
|
10
11
|
/** The entry a drawer compares, as the editor knows it. */
|
|
11
12
|
export interface EntryChangesSubject {
|
|
@@ -41,6 +42,41 @@ export interface EntryChangesDrawerProps {
|
|
|
41
42
|
onOpenEntry?: () => void;
|
|
42
43
|
/** The branch's pull request, offered where this drawer can't compare. */
|
|
43
44
|
pullRequestUrl?: string;
|
|
45
|
+
/** Undoing the entry's changes in its form; given only for the open entry. */
|
|
46
|
+
undo?: EntryChangesUndo;
|
|
47
|
+
/**
|
|
48
|
+
* Why this user can't change the entry at all, such as a path rule: its undo, delete and
|
|
49
|
+
* restore controls are hidden and this one line says why.
|
|
50
|
+
*/
|
|
51
|
+
noEditReason?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Why those controls can't act now, such as a locked branch: they stay, disabled, saying so,
|
|
54
|
+
* as the form's do.
|
|
55
|
+
*/
|
|
56
|
+
lockedReason?: string;
|
|
57
|
+
/** Opens the confirm that deletes an entry added on the branch, closing the drawer first. */
|
|
58
|
+
onDeleteEntry?: () => void;
|
|
59
|
+
/** Puts back an entry deleted on the branch; resolves with why it couldn't, when it couldn't. */
|
|
60
|
+
onRestoreEntry?: () => Promise<RestoreRefusal | undefined>;
|
|
61
|
+
}
|
|
62
|
+
/** Why "Restore entry" didn't: `warning` where nothing is lost, or the outcome is unknown. */
|
|
63
|
+
export interface RestoreRefusal {
|
|
64
|
+
message: string;
|
|
65
|
+
tone: 'warning' | 'error';
|
|
66
|
+
}
|
|
67
|
+
/** The open entry's form value, and the way to change its draft; see `undo-change.ts`. */
|
|
68
|
+
export interface EntryChangesUndo {
|
|
69
|
+
/** The entry's value as its form holds it, unsaved edits included. */
|
|
70
|
+
value: Record<string, unknown>;
|
|
71
|
+
/**
|
|
72
|
+
* Its saved value as the form loaded it, which a redo puts back: references resolved, so a
|
|
73
|
+
* redone entry matches its saved state and isn't left unsaved. Else the comparison's saved side.
|
|
74
|
+
*/
|
|
75
|
+
saved?: Record<string, unknown>;
|
|
76
|
+
/** Sets the entry's draft to what `update` makes of its value at that moment. */
|
|
77
|
+
update: (update: (value: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
78
|
+
/** Open field threads inside a canopy path, which taking out an item there moves to the entry. */
|
|
79
|
+
threadsWithin?: (path: CanopyPathSegment[]) => number;
|
|
44
80
|
}
|
|
45
81
|
/** The drawer's body: its states, then the summary and one card per change. */
|
|
46
82
|
export declare const EntryChangesView: React.FC<Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>>;
|
|
@@ -6,15 +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, List, Paper, SimpleGrid, Stack, Text, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
9
|
+
import { Alert, Badge, Box, Button, Group, List, Paper, SimpleGrid, Stack, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
10
10
|
import { useMediaQuery } from '@mantine/hooks';
|
|
11
11
|
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil, IconPhotoOff, IconPlus, } from '@tabler/icons-react';
|
|
12
12
|
import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
|
|
13
13
|
import { useAssetContext } from '../context/index.js';
|
|
14
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
14
15
|
import { computeEntryDiff, } from '../entry-diff.js';
|
|
15
16
|
import { editorImageSrc } from '../media/editor-image-src.js';
|
|
17
|
+
import { findBodyFieldName } from '../../utils/body-field.js';
|
|
16
18
|
import { formatRelativeTime } from '../relative-time.js';
|
|
17
19
|
import { OverlayDrawer } from '../shell/OverlayHeader.js';
|
|
20
|
+
import { movedThreadsNote } from '../undo-removal.js';
|
|
21
|
+
import { canSetBack, canopyPathOf, formValueOfSide, holdsAs, setAllBack, setChangeBack, sideOfFormValue, } from '../undo-change.js';
|
|
18
22
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
19
23
|
import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
|
|
20
24
|
const KIND_BADGES = {
|
|
@@ -275,14 +279,31 @@ const SideColumn = ({ label, change, side, }) => {
|
|
|
275
279
|
const words = change.text?.mode === 'words' ? change.text : undefined;
|
|
276
280
|
return (_jsxs(Stack, { gap: 4, miw: 0, children: [_jsx(Text, { size: "xs", c: "dimmed", children: label }), value.kind === 'date' && words ? (_jsx(DateValue, { value: value, text: _jsx(MarkedText, { parts: words.parts, side: side, unit: "text" }) })) : words ? (_jsx(WordsChange, { text: words, side: side })) : (_jsx(Value, { value: value, alt: change.altText && { text: change.altText, side } }))] }));
|
|
277
281
|
};
|
|
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
|
+
const UNDONE_IN_FORM = 'Undone in the form. Save to keep it.';
|
|
291
|
+
const ALL_UNDONE_IN_FORM = 'All changes undone in the form. Save to keep them.';
|
|
292
|
+
// Undo and redo replace what the form holds at a change with the base's or the saved value, so
|
|
293
|
+
// neither acts on a change the form has edited since: the edit would be lost.
|
|
294
|
+
const UNSAVED_EDIT_REASON = 'This change has unsaved edits. Save them, or choose "Discard changes" in the entry\'s menu, on its name, first.';
|
|
295
|
+
const UNSAVED_EDITS_REASON = 'Some of these changes have unsaved edits. Save them, or choose "Discard changes" in the entry\'s menu, on its name, first.';
|
|
296
|
+
/** Beside the undo controls, where an undo makes the entry unsaved, in place of a new notice. */
|
|
297
|
+
const UNSAVED_IN_FORM = 'Unsaved edits, undos included, apply when you save.';
|
|
298
|
+
const NOT_IN_FORM_REASON = 'The form no longer has the item this change is in, so it can’t be undone here.';
|
|
278
299
|
/** One change: its labelled breadcrumb, its kind in a word, and the values on each side. */
|
|
279
|
-
const ChangeCard = ({ change, wholeEntry, }) => {
|
|
300
|
+
const ChangeCard = ({ change, wholeEntry, undo }) => {
|
|
280
301
|
const moved = change.kind === 'moved' &&
|
|
281
302
|
change.before?.kind === 'position' &&
|
|
282
303
|
change.after?.kind === 'position'
|
|
283
304
|
? { from: change.before.position, to: change.after.position, of: change.after.of }
|
|
284
305
|
: undefined;
|
|
285
|
-
return (_jsxs(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", "data-testid": "entry-change-card", children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", mb: "xs", align: "flex-start", children: [_jsx(Text, { component: "h3", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: change.path.join(' › ') }), _jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: KIND_BADGES[change.kind].icon, style: { flexShrink: 0 }, children: KIND_BADGES[change.kind].label })] }), moved ? (_jsxs(Text, { size: "sm", children: ["Moved from ", ordinal(moved.from), " to ", ordinal(moved.to), " of ", moved.of] })) : wholeEntry ? (_jsx(Value, { value: change[wholeEntry] ?? { kind: 'empty' } })) : change.text?.mode === 'lines' ? (_jsx(LineRows, { rows: change.text.rows })) : change.text?.mode === 'endLineBreak' ? (_jsx(Text, { size: "sm", children: change.text.added ? 'Line break added at the end' : 'Line break removed at the end' })) : (_jsxs(SimpleGrid, { cols: { base: 1, md: 2 }, spacing: "sm", children: [_jsx(SideColumn, { label: "Before", change: change, side: "before" }), _jsx(SideColumn, { label: "After", change: change, side: "after" })] })), change.tooLargeToMark && (_jsx(Text, { size: "xs", c: "dimmed", mt: "xs", "data-testid": "entry-change-unmarked", children: "Too many changes to highlight." }))] }));
|
|
306
|
+
return (_jsxs(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", "data-testid": "entry-change-card", children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", mb: "xs", align: "flex-start", children: [_jsx(Text, { component: "h3", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: change.path.join(' › ') }), _jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: KIND_BADGES[change.kind].icon, style: { flexShrink: 0 }, children: KIND_BADGES[change.kind].label })] }), moved ? (_jsxs(Text, { size: "sm", children: ["Moved from ", ordinal(moved.from), " to ", ordinal(moved.to), " of ", moved.of] })) : wholeEntry ? (_jsx(Value, { value: change[wholeEntry] ?? { kind: 'empty' } })) : change.text?.mode === 'lines' ? (_jsx(LineRows, { rows: change.text.rows })) : change.text?.mode === 'endLineBreak' ? (_jsx(Text, { size: "sm", children: change.text.added ? 'Line break added at the end' : 'Line break removed at the end' })) : (_jsxs(SimpleGrid, { cols: { base: 1, md: 2 }, spacing: "sm", children: [_jsx(SideColumn, { label: "Before", change: change, side: "before" }), _jsx(SideColumn, { label: "After", change: change, side: "after" })] })), change.tooLargeToMark && (_jsx(Text, { size: "xs", c: "dimmed", mt: "xs", "data-testid": "entry-change-unmarked", children: "Too many changes to highlight." })), undo && (_jsxs(Group, { gap: "xs", mt: "xs", wrap: "wrap", align: "center", children: [_jsx(ReasonedButton, { reason: undo.reason, onClick: undo.onToggle, "aria-label": `${undo.undone ? EDITOR_ACTIONS.redoChange : EDITOR_ACTIONS.undoChange} to ${change.path.join(' › ')}`, "data-testid": "entry-change-undo", children: undo.undone ? EDITOR_ACTIONS.redoChange : EDITOR_ACTIONS.undoChange }), _jsx(Text, { size: "xs", "data-testid": "entry-change-undone", children: undo.undone ? [UNDONE_IN_FORM, undo.note].filter(Boolean).join(' ') : '' })] }))] }));
|
|
286
307
|
};
|
|
287
308
|
/**
|
|
288
309
|
* The summary's place, a status region from the first render on, so a screen reader hears
|
|
@@ -312,7 +333,7 @@ const changeSummary = (changes, base) => {
|
|
|
312
333
|
return `${parts.join(', ')} since ${base}`;
|
|
313
334
|
};
|
|
314
335
|
/** The drawer's body: its states, then the summary and one card per change. */
|
|
315
|
-
export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl }) => {
|
|
336
|
+
export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl, undo, noEditReason, lockedReason, onDeleteEntry, onRestoreEntry, }) => {
|
|
316
337
|
const { data, error } = state;
|
|
317
338
|
const diff = useMemo(() => data &&
|
|
318
339
|
computeEntryDiff({
|
|
@@ -321,6 +342,33 @@ export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenE
|
|
|
321
342
|
after: data.after,
|
|
322
343
|
referenceTitle,
|
|
323
344
|
}), [data, subject.fields, referenceTitle]);
|
|
345
|
+
// The changes the form still holds: the base against the form's value. A saved change missing
|
|
346
|
+
// from them is undone in the form, so the drawer keeps no undo state of its own.
|
|
347
|
+
const formValue = noEditReason ? undefined : undo?.value;
|
|
348
|
+
const heldKeys = useMemo(() => {
|
|
349
|
+
if (!formValue || !data?.before || !data.after)
|
|
350
|
+
return undefined;
|
|
351
|
+
const held = computeEntryDiff({
|
|
352
|
+
fields: subject.fields,
|
|
353
|
+
before: data.before,
|
|
354
|
+
after: sideOfFormValue(formValue, data.after),
|
|
355
|
+
marks: false,
|
|
356
|
+
});
|
|
357
|
+
return new Set(held.changes.map((change) => change.key));
|
|
358
|
+
}, [formValue, data, subject.fields]);
|
|
359
|
+
// What taking out an item did to its threads, said beside its undo until it is redone.
|
|
360
|
+
const [notes, setNotes] = useState({});
|
|
361
|
+
// What the last undo or redo did, said once here rather than by every card it touched. `id`
|
|
362
|
+
// re-keys the text, so a second action with the same words is announced too.
|
|
363
|
+
const [announcement, setAnnouncement] = useState();
|
|
364
|
+
const announce = (text) => setAnnouncement((last) => ({ id: (last?.id ?? 0) + 1, text }));
|
|
365
|
+
const [restoring, setRestoring] = useState(false);
|
|
366
|
+
const [restoreRefusal, setRestoreRefusal] = useState();
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
setNotes({});
|
|
369
|
+
setAnnouncement(undefined);
|
|
370
|
+
setRestoreRefusal(undefined);
|
|
371
|
+
}, [subject.logicalPath, undo?.saved]);
|
|
324
372
|
// A refusal no retry passes: its reason, and the ways the entry can still be looked at.
|
|
325
373
|
const refusal = error?.kind === 'tooLarge' || error?.kind === 'notFound' || error?.kind === 'unreadable'
|
|
326
374
|
? error.kind
|
|
@@ -349,7 +397,95 @@ export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenE
|
|
|
349
397
|
: movedFrom
|
|
350
398
|
? undefined
|
|
351
399
|
: 'Only formatting changed; no field values differ.';
|
|
352
|
-
|
|
400
|
+
// Undo sets an edited entry's values back; an entry type change has no field to set back.
|
|
401
|
+
const typeChanged = diff.changes.some((change) => !canSetBack(change));
|
|
402
|
+
const undoable = !!undo && !!heldKeys && !!data.before && !!data.after && count > 0 && !typeChanged;
|
|
403
|
+
// The field the diff keys an md/mdx body under, as `computeEntryDiff` picks it.
|
|
404
|
+
const bodySide = data.after ?? data.before;
|
|
405
|
+
const bodyField = bodySide?.body === undefined
|
|
406
|
+
? undefined
|
|
407
|
+
: (bodySide.bodyFieldName ?? findBodyFieldName(subject.fields));
|
|
408
|
+
const baseValue = data.before && formValueOfSide(data.before);
|
|
409
|
+
const savedValue = data.after && (undo?.saved ?? formValueOfSide(data.after));
|
|
410
|
+
const isUndone = (change) => !!heldKeys && !heldKeys.has(change.key);
|
|
411
|
+
const allUndone = undoable && diff.changes.every(isUndone);
|
|
412
|
+
// The form holds `change` as neither the side it was last set to has it, the saved value
|
|
413
|
+
// or, once undone, the base's: an edit made since, which an undo or redo would replace.
|
|
414
|
+
const editedSince = (change) => !!undo &&
|
|
415
|
+
!!savedValue &&
|
|
416
|
+
!!baseValue &&
|
|
417
|
+
!holdsAs(undo.value, isUndone(change) ? baseValue : savedValue, change, bodyField);
|
|
418
|
+
const cardUndo = (change) => {
|
|
419
|
+
if (!undoable || !undo || !baseValue || !savedValue)
|
|
420
|
+
return undefined;
|
|
421
|
+
const held = isUndone(change);
|
|
422
|
+
const source = held ? savedValue : baseValue;
|
|
423
|
+
const placeable = !!setChangeBack(undo.value, source, change, bodyField);
|
|
424
|
+
// A change the form can't take back, such as one inside an item it no longer has, isn't
|
|
425
|
+
// offered as undone.
|
|
426
|
+
const undone = held && placeable;
|
|
427
|
+
return {
|
|
428
|
+
undone,
|
|
429
|
+
reason: lockedReason ??
|
|
430
|
+
(!placeable ? NOT_IN_FORM_REASON : editedSince(change) ? UNSAVED_EDIT_REASON : undefined),
|
|
431
|
+
note: undone ? notes[change.key] : undefined,
|
|
432
|
+
onToggle: () => {
|
|
433
|
+
// Taking out an item the branch added moves its threads to the entry, as a Remove does.
|
|
434
|
+
const path = !undone && change.item && change.kind === 'added'
|
|
435
|
+
? canopyPathOf(undo.value, change, bodyField)
|
|
436
|
+
: undefined;
|
|
437
|
+
const note = path && undo.threadsWithin && movedThreadsNote(undo.threadsWithin(path));
|
|
438
|
+
setNotes(({ [change.key]: _dropped, ...rest }) => note ? { ...rest, [change.key]: note } : rest);
|
|
439
|
+
const where = change.path.join(' › ');
|
|
440
|
+
announce(undone
|
|
441
|
+
? `Redone: ${where}.`
|
|
442
|
+
: [`Undone: ${where}. Save to keep it.`, note].filter(Boolean).join(' '));
|
|
443
|
+
undo.update((value) => setChangeBack(value, source, change, bodyField) ?? value);
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
const sameCollection = !!subject.from &&
|
|
448
|
+
subject.from.slice(0, subject.from.lastIndexOf('/')) ===
|
|
449
|
+
subject.logicalPath.slice(0, subject.logicalPath.lastIndexOf('/'));
|
|
450
|
+
const editable = !noEditReason;
|
|
451
|
+
const restorable = !!data.before && !data.after && !!onRestoreEntry;
|
|
452
|
+
// With one change, its card's "Undo change" is the whole of "Undo all changes".
|
|
453
|
+
const undoAll = undoable && count > 1;
|
|
454
|
+
const entryControls = editable && (undoAll || (!data.before && onDeleteEntry) || restorable);
|
|
455
|
+
const restore = async () => {
|
|
456
|
+
if (!onRestoreEntry || restoring)
|
|
457
|
+
return;
|
|
458
|
+
setRestoring(true);
|
|
459
|
+
setRestoreRefusal(undefined);
|
|
460
|
+
try {
|
|
461
|
+
setRestoreRefusal(await onRestoreEntry());
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
setRestoring(false);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
return (_jsxs(Stack, { gap: "sm", children: [_jsxs(StatusRegion, { children: [movedFrom && (_jsxs(Text, { size: "sm", style: { overflowWrap: 'anywhere' }, "data-testid": "entry-changes-moved", children: ["Moved from ", movedFrom] })), summary && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-summary", children: summary }))] }), errorNotice, undoable && (_jsx(VisuallyHidden, { role: "status", "data-testid": "entry-changes-announcement", children: announcement && _jsx("span", { children: announcement.text }, announcement.id) })), subject.unsavedEdits && !undoable && (_jsx(Alert, { variant: "light", color: "blue", icon: _jsx(IconInfoCircle, { size: 16 }), "data-testid": "entry-changes-unsaved", children: _jsx(Text, { size: "sm", children: "Unsaved edits aren\u2019t included. Save to include them." }) })), noEditReason && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-no-edit", children: noEditReason })), editable && undo && typeChanged && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-type-changed", children: "This entry\u2019s type changed on this branch, so its changes can\u2019t be undone here." })), editable && undo && movedFrom && (_jsxs(Text, { size: "sm", "data-testid": "entry-changes-move-hint", children: ["Undoing changes doesn\u2019t move the entry back.", sameCollection && ` To move it back, use "${EDITOR_ACTIONS.changeUrl}…".`] })), editable && lockedReason && (undoable || entryControls) && (_jsx(Text, { size: "sm", "data-testid": "entry-changes-locked", children: lockedReason })), entryControls && (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [undoAll && undo && baseValue && savedValue && (_jsx(ReasonedButton, { reason: lockedReason ?? (diff.changes.some(editedSince) ? UNSAVED_EDITS_REASON : undefined), onClick: () => {
|
|
468
|
+
if (allUndone) {
|
|
469
|
+
setNotes({});
|
|
470
|
+
announce('All changes redone in the form.');
|
|
471
|
+
undo.update((value) => setAllBack(value, savedValue, diff.changes, bodyField));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// Taking out the items the branch added moves their threads, as a Remove does.
|
|
475
|
+
const moved = diff.changes.flatMap((change) => {
|
|
476
|
+
if (isUndone(change) || !change.item || change.kind !== 'added')
|
|
477
|
+
return [];
|
|
478
|
+
const path = canopyPathOf(undo.value, change, bodyField);
|
|
479
|
+
const count = path && undo.threadsWithin ? undo.threadsWithin(path) : 0;
|
|
480
|
+
return count > 0 ? [{ key: change.key, count }] : [];
|
|
481
|
+
});
|
|
482
|
+
setNotes(Object.fromEntries(moved.map(({ key, count }) => [key, movedThreadsNote(count) ?? ''])));
|
|
483
|
+
const total = moved.reduce((sum, { count }) => sum + count, 0);
|
|
484
|
+
announce([ALL_UNDONE_IN_FORM, movedThreadsNote(total)].filter(Boolean).join(' '));
|
|
485
|
+
undo.update((value) => setAllBack(value, baseValue, diff.changes, bodyField));
|
|
486
|
+
}, "data-testid": "entry-changes-undo-all", children: allUndone ? EDITOR_ACTIONS.redoAllChanges : EDITOR_ACTIONS.undoAllChanges })), !data.before && onDeleteEntry && (_jsx(ReasonedButton, { reason: lockedReason, onClick: onDeleteEntry, danger: true, "data-testid": "entry-changes-delete", children: EDITOR_ACTIONS.deleteEntry })), restorable && (_jsx(ReasonedButton, { reason: lockedReason, onClick: () => void restore(), loading: restoring, "data-testid": "entry-changes-restore", children: EDITOR_ACTIONS.restoreEntry }))] })), undoable && (
|
|
487
|
+
// Its own line, held from the start, so the text arriving with an undo shifts nothing.
|
|
488
|
+
_jsx(Text, { size: "xs", mih: 20, "data-testid": "entry-changes-all-undone", children: allUndone ? ALL_UNDONE_IN_FORM : subject.unsavedEdits ? UNSAVED_IN_FORM : '' })), editable && restoreRefusal && (_jsx(Alert, { variant: "light", color: restoreRefusal.tone === 'warning' ? 'yellow' : 'red', icon: restoreRefusal.tone === 'warning' ? (_jsx(IconAlertTriangle, { size: 16 })) : (_jsx(IconAlertCircle, { size: 16 })), "data-testid": "entry-changes-restore-refused", children: _jsx(Text, { size: "sm", children: restoreRefusal.message }) })), count > 0 && (_jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: diff.changes.map((change) => (_jsx(ChangeCard, { change: change, wholeEntry: wholeEntry, undo: editable ? cardUndo(change) : undefined }, change.key))) }))] }));
|
|
353
489
|
};
|
|
354
490
|
/**
|
|
355
491
|
* A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
|
|
@@ -5,7 +5,7 @@ import { useMediaQuery } from '@mantine/hooks';
|
|
|
5
5
|
import { IconArrowsDiff } from '@tabler/icons-react';
|
|
6
6
|
import { SHELL_HEADER_HEIGHT, SHELL_REGION_BORDER } from '../shell/EditorShell.js';
|
|
7
7
|
import { describeBranchLock } from '../branch-lock.js';
|
|
8
|
-
import { EDITOR_ACTIONS } from '../copy.js';
|
|
8
|
+
import { EDITOR_ACTIONS, NO_EDIT_ACCESS_REASON } from '../copy.js';
|
|
9
9
|
import { EntryBreadcrumb } from './EntryBreadcrumb.js';
|
|
10
10
|
import { EntrySaveButton } from './EntrySaveButton.js';
|
|
11
11
|
/**
|
|
@@ -39,9 +39,7 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
|
|
|
39
39
|
pullRequestClosed: branchPullRequestClosed,
|
|
40
40
|
approveBlocked: branchApproveBlocked,
|
|
41
41
|
});
|
|
42
|
-
const entryLockReason = entryReadOnly
|
|
43
|
-
? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
|
|
44
|
-
: undefined;
|
|
42
|
+
const entryLockReason = entryReadOnly ? NO_EDIT_ACCESS_REASON : undefined;
|
|
45
43
|
// The Tooltip hides it while the editor is busy.
|
|
46
44
|
const saveBlockedReason = branchLockReason ??
|
|
47
45
|
(!branchName
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export declare const EDITOR_ACTIONS: {
|
|
|
22
22
|
readonly changeUrl: "Change URL";
|
|
23
23
|
readonly changeSlug: "Change slug";
|
|
24
24
|
readonly showChanges: "Show changes";
|
|
25
|
+
readonly undoChange: "Undo change";
|
|
26
|
+
readonly redoChange: "Redo change";
|
|
27
|
+
readonly undoAllChanges: "Undo all changes";
|
|
28
|
+
readonly redoAllChanges: "Redo all changes";
|
|
29
|
+
readonly restoreEntry: "Restore entry";
|
|
30
|
+
readonly deleteEntry: "Delete entry";
|
|
25
31
|
};
|
|
26
32
|
/**
|
|
27
33
|
* Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
|
|
@@ -35,6 +41,8 @@ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
|
|
|
35
41
|
* says how to finish the submit, where it can be retried at all.
|
|
36
42
|
*/
|
|
37
43
|
export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
|
|
44
|
+
/** Why an entry's form, and every control that would change the entry, can't act for this user. */
|
|
45
|
+
export declare const NO_EDIT_ACCESS_REASON = "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it.";
|
|
38
46
|
/** Why only an admin may delete a branch that may hold commits nothing else has. */
|
|
39
47
|
export declare const DELETE_NEEDS_ADMIN_REASON = "This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.";
|
|
40
48
|
/** Why Approve and Request changes can't act: the pull request they act on is gone. */
|