@byline/admin 4.19.0 → 5.1.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.
Files changed (57) hide show
  1. package/dist/fields/field-services-context.d.ts +1 -1
  2. package/dist/fields/field-services-types.d.ts +5 -2
  3. package/dist/forms/available-locales-widget.d.ts +3 -2
  4. package/dist/forms/available-locales-widget.js +4 -2
  5. package/dist/forms/document-actions.d.ts +3 -2
  6. package/dist/forms/document-actions.js +32 -14
  7. package/dist/forms/form-renderer.d.ts +12 -3
  8. package/dist/forms/form-renderer.js +98 -8
  9. package/dist/forms/form-renderer.module.js +1 -0
  10. package/dist/forms/form-renderer_module.css +12 -0
  11. package/dist/forms/form-status-display.d.ts +3 -2
  12. package/dist/forms/form-status-display.js +5 -2
  13. package/dist/forms/path-widget.d.ts +2 -1
  14. package/dist/forms/path-widget.js +7 -2
  15. package/dist/forms/scheduled-publication-control.d.ts +2 -1
  16. package/dist/forms/scheduled-publication-control.js +12 -8
  17. package/dist/forms/tree-placement-widget.d.ts +5 -1
  18. package/dist/forms/tree-placement-widget.js +14 -7
  19. package/dist/forms/upload-executor.d.ts +2 -2
  20. package/dist/modules/admin-account/components/change-password.js +18 -0
  21. package/dist/modules/admin-account/service.d.ts +2 -6
  22. package/dist/modules/admin-account/service.js +2 -1
  23. package/dist/modules/admin-users/repository.d.ts +10 -1
  24. package/dist/modules/auth/index.d.ts +3 -1
  25. package/dist/modules/auth/index.js +1 -0
  26. package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
  27. package/dist/modules/auth/jwt-session-provider.js +222 -70
  28. package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
  29. package/dist/modules/auth/login-sessions-repository.js +1 -0
  30. package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
  31. package/dist/modules/auth/resolve-actor.d.ts +3 -0
  32. package/dist/modules/auth/resolve-actor.js +5 -2
  33. package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
  34. package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
  35. package/dist/store.d.ts +15 -2
  36. package/package.json +17 -17
  37. package/src/fields/field-services-types.ts +10 -2
  38. package/src/forms/available-locales-widget.tsx +5 -2
  39. package/src/forms/document-actions.tsx +55 -20
  40. package/src/forms/form-renderer-submit.test.tsx +131 -2
  41. package/src/forms/form-renderer.module.css +20 -0
  42. package/src/forms/form-renderer.tsx +122 -7
  43. package/src/forms/form-status-display.tsx +6 -1
  44. package/src/forms/path-widget.tsx +8 -3
  45. package/src/forms/scheduled-publication-control.tsx +16 -7
  46. package/src/forms/tree-placement-widget.tsx +34 -7
  47. package/src/modules/admin-account/components/change-password.test.tsx +72 -0
  48. package/src/modules/admin-account/components/change-password.tsx +15 -4
  49. package/src/modules/admin-account/service.ts +8 -7
  50. package/src/modules/admin-users/repository.ts +10 -1
  51. package/src/modules/auth/index.ts +13 -1
  52. package/src/modules/auth/jwt-session-provider.ts +276 -91
  53. package/src/modules/auth/login-sessions-repository.ts +25 -0
  54. package/src/modules/auth/refresh-tokens-repository.ts +12 -9
  55. package/src/modules/auth/resolve-actor.ts +10 -1
  56. package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
  57. package/src/store.ts +22 -2
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { type ReactNode } from 'react';
9
- import type { BylineFieldServices } from './field-services-types';
9
+ import type { BylineFieldServices } from './field-services-types.js';
10
10
  interface BylineFieldServicesProviderProps {
11
11
  services: BylineFieldServices;
12
12
  children: ReactNode;
@@ -1,3 +1,4 @@
1
+ import type { StructuralMutationReceipt } from '@byline/core';
1
2
  /**
2
3
  * This Source Code is subject to the terms of the Mozilla Public
3
4
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -64,6 +65,7 @@ export interface TreeAncestor {
64
65
  path?: string;
65
66
  }
66
67
  export interface PlaceTreeNodeInput {
68
+ expectedRevision: number;
67
69
  collection: string;
68
70
  documentId: string;
69
71
  /** The new parent; `null` makes the document a root node. */
@@ -75,12 +77,13 @@ export interface PlaceTreeNodeInput {
75
77
  /** Place / move a document within its collection's tree. */
76
78
  export type PlaceTreeNodeFn = (input: PlaceTreeNodeInput) => Promise<{
77
79
  orderKey: string;
78
- }>;
80
+ } & StructuralMutationReceipt>;
79
81
  /** Remove a document from the tree (back to the unplaced state). */
80
82
  export type RemoveFromTreeFn = (input: {
81
83
  collection: string;
82
84
  documentId: string;
83
- }) => Promise<void>;
85
+ expectedRevision: number;
86
+ }) => Promise<StructuralMutationReceipt>;
84
87
  /** Resolve a document's ancestor chain, root-first, hydrated with titles. */
85
88
  export type GetTreeAncestorsFn = (input: {
86
89
  collection: string;
@@ -1,10 +1,11 @@
1
- export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile';
1
+ export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile.js';
2
2
  /** A content locale to render a checkbox for. */
3
3
  export interface AvailableLocalesWidgetLocale {
4
4
  code: string;
5
5
  label: string;
6
6
  }
7
7
  export interface AvailableLocalesWidgetProps {
8
+ disabled?: boolean;
8
9
  /** All configured content locales — one checkbox each (code + display label). */
9
10
  contentLocales: ReadonlyArray<AvailableLocalesWidgetLocale>;
10
11
  /**
@@ -26,4 +27,4 @@ export interface AvailableLocalesWidgetProps {
26
27
  * Stable override handles: `.byline-form-available-locales`,
27
28
  * `.byline-form-available-locales-list`.
28
29
  */
29
- export declare const AvailableLocalesWidget: ({ contentLocales, availableVersionLocales, }: AvailableLocalesWidgetProps) => import("react").JSX.Element | null;
30
+ export declare const AvailableLocalesWidget: ({ disabled: mutationsBlocked, contentLocales, availableVersionLocales, }: AvailableLocalesWidgetProps) => import("react").JSX.Element | null;
@@ -7,7 +7,7 @@ import clsx from "clsx";
7
7
  import { reconcileLocaleState } from "./available-locales-reconcile.js";
8
8
  import available_locales_widget_module from "./available-locales-widget.module.js";
9
9
  import { useFormContext, useSystemAvailableLocales } from "./form-context.js";
10
- const AvailableLocalesWidget = ({ contentLocales, availableVersionLocales })=>{
10
+ const AvailableLocalesWidget = ({ disabled: mutationsBlocked = false, contentLocales, availableVersionLocales })=>{
11
11
  const { t } = useTranslation('byline-admin');
12
12
  const { setSystemAvailableLocales } = useFormContext();
13
13
  const advertised = useSystemAvailableLocales();
@@ -18,6 +18,7 @@ const AvailableLocalesWidget = ({ contentLocales, availableVersionLocales })=>{
18
18
  availableVersionLocales
19
19
  ]);
20
20
  const toggle = useCallback((code, checked)=>{
21
+ if (mutationsBlocked) return;
21
22
  const next = new Set(advertised);
22
23
  if (checked) next.add(code);
23
24
  else next.delete(code);
@@ -25,6 +26,7 @@ const AvailableLocalesWidget = ({ contentLocales, availableVersionLocales })=>{
25
26
  ...next
26
27
  ]);
27
28
  }, [
29
+ mutationsBlocked,
28
30
  advertised,
29
31
  setSystemAvailableLocales
30
32
  ]);
@@ -52,7 +54,7 @@ const AvailableLocalesWidget = ({ contentLocales, availableVersionLocales })=>{
52
54
  label: label,
53
55
  intent: intent,
54
56
  checked: checked,
55
- disabled: disabled,
57
+ disabled: mutationsBlocked || disabled,
56
58
  onCheckedChange: (value)=>toggle(code, true === value)
57
59
  }, code);
58
60
  })
@@ -1,4 +1,4 @@
1
- import type { PublishedVersionInfo } from './form-renderer';
1
+ import type { PublishedVersionInfo } from './form-renderer.js';
2
2
  import type { ScheduledPublicationState } from './scheduled-publication-state.js';
3
3
  /**
4
4
  * Shape of a content-locale option as consumed by the Copy-to-Locale
@@ -9,7 +9,8 @@ export interface DocumentActionsLocaleOption {
9
9
  code: string;
10
10
  label: string;
11
11
  }
12
- export declare function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate, sourceTitle, onCopyToLocale, sourceLocale, contentLocales, hasUnsavedChanges, onUnsavedChanges, onDeleteLocale, defaultLocale, availableLocales, scheduledPublicationState, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, }: {
12
+ export declare function DocumentActions({ disabled, publishedVersion, onUnpublish, onDelete, onDuplicate, sourceTitle, onCopyToLocale, sourceLocale, contentLocales, hasUnsavedChanges, onUnsavedChanges, onDeleteLocale, defaultLocale, availableLocales, scheduledPublicationState, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, }: {
13
+ disabled?: boolean;
13
14
  publishedVersion?: PublishedVersionInfo | null;
14
15
  onUnpublish?: () => Promise<void>;
15
16
  onDelete?: () => Promise<void>;
@@ -6,7 +6,7 @@ import { Button, Checkbox, CloseIcon, DeleteIcon, Dropdown, EllipsisIcon, IconBu
6
6
  import clsx from "clsx";
7
7
  import document_actions_module from "./document-actions.module.js";
8
8
  const DUPLICATE_TITLE_SUFFIX = ' (copy)';
9
- function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate, sourceTitle, onCopyToLocale, sourceLocale, contentLocales, hasUnsavedChanges, onUnsavedChanges, onDeleteLocale, defaultLocale, availableLocales, scheduledPublicationState, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication }) {
9
+ function DocumentActions({ disabled = false, publishedVersion, onUnpublish, onDelete, onDuplicate, sourceTitle, onCopyToLocale, sourceLocale, contentLocales, hasUnsavedChanges, onUnsavedChanges, onDeleteLocale, defaultLocale, availableLocales, scheduledPublicationState, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication }) {
10
10
  const { t } = useTranslation('byline-admin');
11
11
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
12
12
  const [showDuplicateConfirm, setShowDuplicateConfirm] = useState(false);
@@ -51,30 +51,35 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
51
51
  }
52
52
  const hasAnyAction = schedulingActions.length > 0 || copyToLocaleAvailable || deleteLocaleAvailable || null != onDuplicate || null != onDelete;
53
53
  const handleOnDelete = ()=>{
54
+ if (disabled) return;
54
55
  setShowDeleteConfirm(false);
55
- if (onDelete) onDelete();
56
+ if (onDelete) onDelete().catch(()=>{});
56
57
  };
57
58
  const handleOnDuplicate = async ()=>{
59
+ if (disabled) return;
58
60
  if (!onDuplicate) return;
59
61
  setDuplicateBusy(true);
60
62
  try {
61
63
  await onDuplicate();
62
64
  setShowDuplicateConfirm(false);
63
- } finally{
65
+ } catch {} finally{
64
66
  setDuplicateBusy(false);
65
67
  }
66
68
  };
67
69
  const handleOpenDuplicate = ()=>{
70
+ if (disabled) return;
68
71
  if (hasUnsavedChanges) return void onUnsavedChanges?.();
69
72
  setShowDuplicateConfirm(true);
70
73
  };
71
74
  const handleOpenCopyToLocale = ()=>{
75
+ if (disabled) return;
72
76
  if (hasUnsavedChanges) return void onUnsavedChanges?.();
73
77
  setCopyTargetLocale(availableTargetLocales[0]?.code ?? '');
74
78
  setCopyOverwrite(false);
75
79
  setShowCopyToLocaleConfirm(true);
76
80
  };
77
81
  const handleOnCopyToLocale = async ()=>{
82
+ if (disabled) return;
78
83
  if (!onCopyToLocale || !copyTargetLocale) return;
79
84
  setCopyToLocaleBusy(true);
80
85
  try {
@@ -83,17 +88,19 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
83
88
  overwrite: copyOverwrite
84
89
  });
85
90
  setShowCopyToLocaleConfirm(false);
86
- } finally{
91
+ } catch {} finally{
87
92
  setCopyToLocaleBusy(false);
88
93
  }
89
94
  };
90
95
  const handleOpenDeleteLocale = ()=>{
96
+ if (disabled) return;
91
97
  if (hasUnsavedChanges) return void onUnsavedChanges?.();
92
98
  const preferred = deletableLocales.find((loc)=>loc.code === sourceLocale)?.code;
93
99
  setDeleteTargetLocale(preferred ?? deletableLocales[0]?.code ?? '');
94
100
  setShowDeleteLocaleConfirm(true);
95
101
  };
96
102
  const handleOnDeleteLocale = async ()=>{
103
+ if (disabled) return;
97
104
  if (!onDeleteLocale || !deleteTargetLocale) return;
98
105
  setDeleteLocaleBusy(true);
99
106
  try {
@@ -101,7 +108,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
101
108
  targetLocale: deleteTargetLocale
102
109
  });
103
110
  setShowDeleteLocaleConfirm(false);
104
- } finally{
111
+ } catch {} finally{
105
112
  setDeleteLocaleBusy(false);
106
113
  }
107
114
  };
@@ -113,6 +120,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
113
120
  hasAnyAction && /*#__PURE__*/ jsxs(Dropdown.Root, {
114
121
  children: [
115
122
  /*#__PURE__*/ jsx(Dropdown.Trigger, {
123
+ disabled: disabled,
116
124
  render: /*#__PURE__*/ jsx(IconButton, {
117
125
  variant: "text",
118
126
  intent: "noeffect",
@@ -134,6 +142,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
134
142
  schedulingActions.length > 0 && /*#__PURE__*/ jsxs(Fragment, {
135
143
  children: [
136
144
  schedulingActions.map((action)=>/*#__PURE__*/ jsx(Dropdown.Item, {
145
+ disabled: disabled,
137
146
  onClick: action.onSelect,
138
147
  children: /*#__PURE__*/ jsx("div", {
139
148
  className: clsx('byline-form-actions-item', document_actions_module.item),
@@ -141,6 +150,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
141
150
  className: clsx('byline-form-actions-item-text', document_actions_module["item-text"]),
142
151
  children: /*#__PURE__*/ jsx("button", {
143
152
  type: "button",
153
+ disabled: disabled,
144
154
  children: action.label
145
155
  })
146
156
  })
@@ -150,6 +160,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
150
160
  ]
151
161
  }),
152
162
  copyToLocaleAvailable && /*#__PURE__*/ jsx(Dropdown.Item, {
163
+ disabled: disabled,
153
164
  onClick: handleOpenCopyToLocale,
154
165
  children: /*#__PURE__*/ jsx("div", {
155
166
  className: clsx('byline-form-actions-item', document_actions_module.item),
@@ -157,12 +168,14 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
157
168
  className: clsx('byline-form-actions-item-text', document_actions_module["item-text"]),
158
169
  children: /*#__PURE__*/ jsx("button", {
159
170
  type: "button",
171
+ disabled: disabled,
160
172
  children: t('documentActions.copyToLocaleMenuItem')
161
173
  })
162
174
  })
163
175
  })
164
176
  }),
165
177
  deleteLocaleAvailable && /*#__PURE__*/ jsx(Dropdown.Item, {
178
+ disabled: disabled,
166
179
  onClick: handleOpenDeleteLocale,
167
180
  children: /*#__PURE__*/ jsx("div", {
168
181
  className: clsx('byline-form-actions-item', document_actions_module.item),
@@ -170,12 +183,14 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
170
183
  className: clsx('byline-form-actions-item-text', document_actions_module["item-text"]),
171
184
  children: /*#__PURE__*/ jsx("button", {
172
185
  type: "button",
186
+ disabled: disabled,
173
187
  children: t('documentActions.deleteLocale.menuItem')
174
188
  })
175
189
  })
176
190
  })
177
191
  }),
178
192
  onDuplicate && /*#__PURE__*/ jsx(Dropdown.Item, {
193
+ disabled: disabled,
179
194
  onClick: handleOpenDuplicate,
180
195
  children: /*#__PURE__*/ jsx("div", {
181
196
  className: clsx('byline-form-actions-item', document_actions_module.item),
@@ -183,6 +198,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
183
198
  className: clsx('byline-form-actions-item-text', document_actions_module["item-text"]),
184
199
  children: /*#__PURE__*/ jsx("button", {
185
200
  type: "button",
201
+ disabled: disabled,
186
202
  children: t('common.actions.duplicate')
187
203
  })
188
204
  })
@@ -192,6 +208,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
192
208
  children: [
193
209
  /*#__PURE__*/ jsx(Dropdown.Separator, {}),
194
210
  /*#__PURE__*/ jsx(Dropdown.Item, {
211
+ disabled: disabled,
195
212
  onClick: ()=>{
196
213
  setShowDeleteConfirm(true);
197
214
  },
@@ -286,6 +303,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
286
303
  minWidth: '80px'
287
304
  },
288
305
  intent: "danger",
306
+ disabled: disabled,
289
307
  onClick: handleOnDelete,
290
308
  children: t('common.actions.delete')
291
309
  })
@@ -397,7 +415,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
397
415
  onClick: ()=>{
398
416
  if (!duplicateBusy) setShowDuplicateConfirm(false);
399
417
  },
400
- disabled: duplicateBusy,
418
+ disabled: disabled || duplicateBusy,
401
419
  children: t('common.actions.cancel')
402
420
  }),
403
421
  /*#__PURE__*/ jsx(Button, {
@@ -407,7 +425,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
407
425
  },
408
426
  intent: "primary",
409
427
  onClick: handleOnDuplicate,
410
- disabled: duplicateBusy,
428
+ disabled: disabled || duplicateBusy,
411
429
  children: duplicateBusy ? t('documentActions.duplicate.busyButton') : t('common.actions.duplicate')
412
430
  })
413
431
  ]
@@ -499,7 +517,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
499
517
  onValueChange: (value)=>{
500
518
  if (null != value) setCopyTargetLocale(value);
501
519
  },
502
- disabled: copyToLocaleBusy
520
+ disabled: disabled || copyToLocaleBusy
503
521
  })
504
522
  ]
505
523
  }),
@@ -513,7 +531,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
513
531
  name: "overwrite",
514
532
  label: t('documentActions.copyToLocale.overwriteLabel'),
515
533
  checked: copyOverwrite,
516
- disabled: copyToLocaleBusy,
534
+ disabled: disabled || copyToLocaleBusy,
517
535
  helpText: t('documentActions.copyToLocale.overwriteHelp'),
518
536
  onCheckedChange: (value)=>{
519
537
  setCopyOverwrite(true === value);
@@ -540,7 +558,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
540
558
  onClick: ()=>{
541
559
  if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false);
542
560
  },
543
- disabled: copyToLocaleBusy,
561
+ disabled: disabled || copyToLocaleBusy,
544
562
  children: t('common.actions.cancel')
545
563
  }),
546
564
  /*#__PURE__*/ jsx(Button, {
@@ -550,7 +568,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
550
568
  },
551
569
  intent: "primary",
552
570
  onClick: handleOnCopyToLocale,
553
- disabled: copyToLocaleBusy || !copyTargetLocale,
571
+ disabled: disabled || copyToLocaleBusy || !copyTargetLocale,
554
572
  children: copyToLocaleBusy ? t('documentActions.copyToLocale.busyButton') : t('documentActions.copyToLocale.confirmButton')
555
573
  })
556
574
  ]
@@ -620,7 +638,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
620
638
  onValueChange: (value)=>{
621
639
  if (null != value) setDeleteTargetLocale(value);
622
640
  },
623
- disabled: deleteLocaleBusy
641
+ disabled: disabled || deleteLocaleBusy
624
642
  })
625
643
  ]
626
644
  }),
@@ -650,7 +668,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
650
668
  onClick: ()=>{
651
669
  if (!deleteLocaleBusy) setShowDeleteLocaleConfirm(false);
652
670
  },
653
- disabled: deleteLocaleBusy,
671
+ disabled: disabled || deleteLocaleBusy,
654
672
  children: t('common.actions.cancel')
655
673
  }),
656
674
  /*#__PURE__*/ jsx(Button, {
@@ -660,7 +678,7 @@ function DocumentActions({ publishedVersion, onUnpublish, onDelete, onDuplicate,
660
678
  },
661
679
  intent: "danger",
662
680
  onClick: handleOnDeleteLocale,
663
- disabled: deleteLocaleBusy || !deleteTargetLocale,
681
+ disabled: disabled || deleteLocaleBusy || !deleteTargetLocale,
664
682
  children: deleteLocaleBusy ? t('documentActions.deleteLocale.busyButton') : t('documentActions.deleteLocale.confirmButton')
665
683
  })
666
684
  ]
@@ -8,9 +8,9 @@
8
8
  import { type ReactNode } from 'react';
9
9
  import type { Field, FormAdminConfig, WorkflowStatus } from '@byline/core';
10
10
  import type { DocumentPatch } from '@byline/core/patches';
11
- import { type DocumentActionsLocaleOption } from './document-actions';
12
- import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control';
13
- import type { UseNavigationGuard } from './navigation-guard';
11
+ import { type DocumentActionsLocaleOption } from './document-actions.js';
12
+ import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control.js';
13
+ import type { UseNavigationGuard } from './navigation-guard.js';
14
14
  /** Metadata about a previously published version that is still live. */
15
15
  export interface PublishedVersionInfo {
16
16
  id: string;
@@ -37,6 +37,15 @@ export interface SystemFieldsSubmitPayload {
37
37
  }
38
38
  /** Props shared by both the public FormRenderer and its internal FormContent component. */
39
39
  export interface FormRendererProps {
40
+ mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null;
41
+ mutationsBlocked?: boolean;
42
+ observedRevision?: number;
43
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void;
44
+ onTreeMutationCommitted?: (receipt: import('@byline/core').StructuralMutationReceipt) => void;
45
+ scheduledPublicationsNeedReconfirmation?: boolean;
46
+ scheduledPublicationsHref?: string;
47
+ /** Explicit discard action; defaults to a complete server reload. */
48
+ onReloadDocument?: () => void | Promise<void>;
40
49
  mode: 'create' | 'edit';
41
50
  fields: Field[];
42
51
  onSubmit: (data: any) => void | Promise<void>;
@@ -24,7 +24,7 @@ import { computeStatusTransitions } from "./status-transitions.js";
24
24
  import { TreePlacementWidget } from "./tree-placement-widget.js";
25
25
  import { executeUploadsWithProgress } from "./upload-executor.js";
26
26
  import { useFormLayout } from "./use-form-layout.js";
27
- const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, scheduledPublication, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, showPath = true, heading, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
27
+ const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRevision, onMutationError, onTreeMutationCommitted, scheduledPublicationsNeedReconfirmation = false, scheduledPublicationsHref, onReloadDocument, fields, onSubmit, onCancel, onStatusChange, onUnpublish, scheduledPublication, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, showPath = true, heading, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
28
28
  const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getDirtyBreakdown, getSystemPath, getSystemAvailableLocales, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
29
29
  const { t } = useTranslation('byline-admin');
30
30
  const [errors, setErrors] = useState(initialErrors);
@@ -41,6 +41,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
41
41
  const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] = useState(null);
42
42
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale);
43
43
  const scheduling = useScheduledPublication({
44
+ disabled: mutationsBlocked,
44
45
  schedule: scheduledPublication ?? null,
45
46
  onSchedule: onSchedulePublication,
46
47
  onConfirm: onConfirmScheduledPublication,
@@ -96,7 +97,27 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
96
97
  }) : 'create' === mode ? t('forms.heading.create') : t('forms.heading.edit'));
97
98
  const guardFromContext = useNavigationGuardAdapter();
98
99
  const useGuard = useNavigationGuardProp ?? guardFromContext;
99
- const guard = useGuard(hasChanges);
100
+ const [discarding, setDiscarding] = useState(false);
101
+ const [reloadFailed, setReloadFailed] = useState(false);
102
+ const warningRef = useRef(null);
103
+ const mutationBlockedRef = useRef(mutationsBlocked);
104
+ mutationBlockedRef.current = mutationsBlocked || discarding;
105
+ const guard = useGuard(hasChanges && !discarding);
106
+ useEffect(()=>{
107
+ if (mutationIssue) warningRef.current?.focus();
108
+ }, [
109
+ mutationIssue
110
+ ]);
111
+ useEffect(()=>{
112
+ if (!discarding) return;
113
+ Promise.resolve().then(()=>onReloadDocument ? onReloadDocument() : window.location.reload()).catch(()=>{
114
+ setDiscarding(false);
115
+ setReloadFailed(true);
116
+ });
117
+ }, [
118
+ discarding,
119
+ onReloadDocument
120
+ ]);
100
121
  const currentStatus = initialData?.status;
101
122
  const { primaryStatus, secondaryStatuses, isTerminal } = computeStatusTransitions(currentStatus, workflowStatuses, nextStatus);
102
123
  useEffect(()=>subscribeErrors((newErrors)=>setErrors(newErrors)), [
@@ -113,6 +134,11 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
113
134
  useEffect(()=>{
114
135
  if (isBusy || !restoreFocusAfterBusyRef.current) return;
115
136
  restoreFocusAfterBusyRef.current = false;
137
+ if (mutationIssue) {
138
+ warningRef.current?.focus();
139
+ focusBeforeBusyRef.current = null;
140
+ return;
141
+ }
116
142
  const original = focusBeforeBusyRef.current;
117
143
  focusBeforeBusyRef.current = null;
118
144
  const originalCanReceiveFocus = original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]');
@@ -121,7 +147,8 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
121
147
  preventScroll: true
122
148
  });
123
149
  }, [
124
- isBusy
150
+ isBusy,
151
+ mutationIssue
125
152
  ]);
126
153
  const captureFocusBeforeBusy = useCallback(()=>{
127
154
  if (null != focusBeforeBusyRef.current) return;
@@ -134,7 +161,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
134
161
  if (onCancel && 'function' == typeof onCancel) onCancel();
135
162
  };
136
163
  const submitPayload = useCallback(async (payload)=>{
137
- if ('function' != typeof onSubmit) return;
164
+ if (mutationBlockedRef.current || 'function' != typeof onSubmit) return;
138
165
  if (submittingRef.current) return;
139
166
  submittingRef.current = true;
140
167
  captureFocusBeforeBusy();
@@ -152,6 +179,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
152
179
  resetHasChanges
153
180
  ]);
154
181
  const handleSubmit = (e)=>{
182
+ if (mutationBlockedRef.current) return void e.preventDefault();
155
183
  e.preventDefault();
156
184
  (async ()=>{
157
185
  const hookErrors = await runFieldHooks(fields);
@@ -161,6 +189,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
161
189
  ...formErrors
162
190
  ];
163
191
  if (allErrors.length > 0) return void console.error('Form validation failed:', allErrors);
192
+ if (mutationBlockedRef.current) return;
164
193
  const pendingUploads = getPendingUploads();
165
194
  if (pendingUploads.size > 0) {
166
195
  captureFocusBeforeBusy();
@@ -307,6 +336,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
307
336
  /*#__PURE__*/ jsx("div", {
308
337
  className: clsx('byline-form-status-details', form_renderer_module["status-details"]),
309
338
  children: /*#__PURE__*/ jsx(FormStatusDisplay, {
339
+ disabled: mutationsBlocked || discarding,
310
340
  initialData: initialData,
311
341
  workflowStatuses: workflowStatuses,
312
342
  publishedVersion: publishedVersion,
@@ -332,7 +362,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
332
362
  className: clsx('byline-form-actions-button', form_renderer_module["actions-button"]),
333
363
  size: "sm",
334
364
  type: "submit",
335
- disabled: false === hasChanges || isUploading || isSubmitting,
365
+ disabled: mutationsBlocked || discarding || false === hasChanges || isUploading || isSubmitting,
336
366
  "aria-label": isSubmitting ? t('common.actions.save') : void 0,
337
367
  children: isUploading ? t('forms.actions.uploading') : /*#__PURE__*/ jsxs("span", {
338
368
  className: clsx('byline-form-save-content', form_renderer_module["save-content"]),
@@ -366,21 +396,27 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
366
396
  size: "sm",
367
397
  type: "button",
368
398
  intent: isTerminal ? 'info' : 'success',
369
- disabled: statusBusy,
399
+ disabled: mutationsBlocked || discarding || statusBusy,
370
400
  onOptionSelect: async (value)=>{
401
+ if (mutationBlockedRef.current) return;
371
402
  if (hasChanges) return void setShowUnsavedModal(true);
372
403
  setStatusBusy(true);
373
404
  try {
374
405
  await onStatusChange(value);
406
+ } catch (error) {
407
+ onMutationError?.(error);
375
408
  } finally{
376
409
  setStatusBusy(false);
377
410
  }
378
411
  },
379
412
  onButtonClick: isTerminal ? void 0 : async ()=>{
413
+ if (mutationBlockedRef.current) return;
380
414
  if (hasChanges) return void setShowUnsavedModal(true);
381
415
  setStatusBusy(true);
382
416
  try {
383
417
  await onStatusChange(primaryStatus.name);
418
+ } catch (error) {
419
+ onMutationError?.(error);
384
420
  } finally{
385
421
  setStatusBusy(false);
386
422
  }
@@ -389,6 +425,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
389
425
  })
390
426
  }),
391
427
  /*#__PURE__*/ jsx(DocumentActions, {
428
+ disabled: mutationsBlocked || discarding,
392
429
  publishedVersion: publishedVersion,
393
430
  onUnpublish: onUnpublish,
394
431
  onDelete: onDelete,
@@ -411,10 +448,57 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
411
448
  })
412
449
  ]
413
450
  }),
451
+ (mutationIssue || scheduledPublicationsNeedReconfirmation) && /*#__PURE__*/ jsxs("div", {
452
+ ref: warningRef,
453
+ tabIndex: -1,
454
+ role: "alert",
455
+ "aria-live": "assertive",
456
+ className: clsx('byline-document-concurrency', form_renderer_module.concurrency),
457
+ children: [
458
+ mutationIssue && /*#__PURE__*/ jsxs(Alert, {
459
+ intent: "warning",
460
+ icon: true,
461
+ close: false,
462
+ title: t(`documentConcurrency.${mutationIssue}Title`),
463
+ children: [
464
+ /*#__PURE__*/ jsx("p", {
465
+ children: t(`documentConcurrency.${mutationIssue}`)
466
+ }),
467
+ 'committed' !== mutationIssue && /*#__PURE__*/ jsx(Button, {
468
+ type: "button",
469
+ disabled: discarding,
470
+ onClick: ()=>{
471
+ setReloadFailed(false);
472
+ setDiscarding(true);
473
+ },
474
+ children: t('documentConcurrency.reloadAction')
475
+ }),
476
+ reloadFailed && /*#__PURE__*/ jsx("p", {
477
+ children: t('documentConcurrency.reloadFailed')
478
+ })
479
+ ]
480
+ }),
481
+ scheduledPublicationsNeedReconfirmation && /*#__PURE__*/ jsxs(Alert, {
482
+ intent: "warning",
483
+ icon: true,
484
+ close: false,
485
+ title: t('documentConcurrency.schedulesTitle'),
486
+ children: [
487
+ /*#__PURE__*/ jsx("p", {
488
+ children: t('documentConcurrency.schedules')
489
+ }),
490
+ scheduledPublicationsHref && /*#__PURE__*/ jsx("a", {
491
+ href: scheduledPublicationsHref,
492
+ children: t('documentConcurrency.reviewSchedules')
493
+ })
494
+ ]
495
+ })
496
+ ]
497
+ }),
414
498
  /*#__PURE__*/ jsx(ScheduledPublicationNotice, {
415
499
  state: scheduling.state,
416
500
  timeZone: scheduling.timeZone,
417
- busy: scheduling.busy,
501
+ busy: mutationsBlocked || discarding || scheduling.busy,
418
502
  onConfirm: scheduling.confirm,
419
503
  onReschedule: scheduling.openSchedule,
420
504
  onCancel: scheduling.cancel
@@ -450,6 +534,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
450
534
  className: clsx('byline-form-sidebar', form_renderer_module.sidebar),
451
535
  children: [
452
536
  showPath && (useAsPath || 'string' == typeof initialData?.path && initialData.path.length > 0) && /*#__PURE__*/ jsx(PathWidget, {
537
+ disabled: mutationsBlocked || discarding,
453
538
  useAsPath: useAsPath,
454
539
  collectionPath: collectionPath ?? '',
455
540
  defaultLocale: defaultLocale,
@@ -459,11 +544,16 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
459
544
  sourceLocked: pathSourceLocked
460
545
  }),
461
546
  tree && 'edit' === mode && 'string' == typeof initialData?.id && /*#__PURE__*/ jsx(TreePlacementWidget, {
547
+ disabled: mutationsBlocked || discarding,
548
+ onMutationError: onMutationError,
549
+ onCommitted: onTreeMutationCommitted,
550
+ expectedRevision: observedRevision ?? initialData.revision,
462
551
  collectionPath: collectionPath ?? '',
463
552
  documentId: initialData.id,
464
553
  useAsTitle: useAsTitle
465
554
  }),
466
555
  advertiseLocales && /*#__PURE__*/ jsx(AvailableLocalesWidget, {
556
+ disabled: mutationsBlocked || discarding,
467
557
  contentLocales: contentLocales ?? [],
468
558
  availableVersionLocales: initialData?._availableVersionLocales ?? []
469
559
  }),
@@ -475,7 +565,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
475
565
  showUnsavedModal && /*#__PURE__*/ jsx(UnsavedChangesModal, {
476
566
  onClose: ()=>setShowUnsavedModal(false)
477
567
  }),
478
- null != pendingSystemFieldsSubmit && /*#__PURE__*/ jsx(SystemFieldsConfirmModal, {
568
+ !mutationsBlocked && !discarding && null != pendingSystemFieldsSubmit && /*#__PURE__*/ jsx(SystemFieldsConfirmModal, {
479
569
  contentDirty: pendingSystemFieldsSubmit.contentDirty,
480
570
  pathDirty: pendingSystemFieldsSubmit.pathDirty,
481
571
  availableLocalesDirty: pendingSystemFieldsSubmit.availableLocalesDirty,
@@ -40,6 +40,7 @@ const form_renderer_module = {
40
40
  actionsComboButton: "actions-combo-button-XvqHCC",
41
41
  "actions-combo-trigger": "actions-combo-trigger-bmXUzc",
42
42
  actionsComboTrigger: "actions-combo-trigger-bmXUzc",
43
+ concurrency: "concurrency-UT6bX5",
43
44
  layout: "layout-WTbLYr",
44
45
  content: "content-_P5cdJ",
45
46
  sidebar: "sidebar-WsxX88",
@@ -189,6 +189,18 @@
189
189
  min-height: 28px;
190
190
  }
191
191
 
192
+ :is(.concurrency-UT6bX5, .byline-document-concurrency) {
193
+ margin-top: var(--spacing-16);
194
+ }
195
+
196
+ :is(.concurrency-UT6bX5 button, .byline-document-concurrency button) {
197
+ margin-top: var(--spacing-12);
198
+ }
199
+
200
+ :is(.concurrency-UT6bX5 ~ .layout-WTbLYr, .byline-document-concurrency ~ .byline-form-layout) {
201
+ padding-top: var(--spacing-16);
202
+ }
203
+
192
204
  :is(.layout-WTbLYr, .byline-form-layout) {
193
205
  gap: var(--spacing-12);
194
206
  padding-top: var(--spacing-32);