@byline/admin 4.19.0 → 5.0.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.
@@ -46,8 +46,8 @@ vi.mock('@byline/ui/react', async (importOriginal) => {
46
46
  Separator: () => <hr />,
47
47
  },
48
48
  Modal,
49
- Input: ({ name, value, onChange }: any) => (
50
- <input name={name} value={value ?? ''} onChange={onChange} />
49
+ Input: ({ id, name, value, onChange, disabled }: React.ComponentProps<'input'>) => (
50
+ <input id={id} name={name} disabled={disabled} value={value ?? ''} onChange={onChange} />
51
51
  ),
52
52
  }
53
53
  })
@@ -286,3 +286,132 @@ describe('FormRenderer submit contract', () => {
286
286
  expect(onSubmit).toHaveBeenCalledTimes(1)
287
287
  })
288
288
  })
289
+
290
+ describe('persistent document concurrency recovery', () => {
291
+ const original = {
292
+ id: 'doc',
293
+ versionId: 'v1',
294
+ revision: 7,
295
+ path: 'opened',
296
+ status: 'draft',
297
+ fields: { title: 'Opened' },
298
+ _availableVersionLocales: ['en'],
299
+ }
300
+ const props = {
301
+ mode: 'edit' as const,
302
+ fields,
303
+ initialData: original,
304
+ collectionPath: 'pages',
305
+ useAsPath: 'title',
306
+ advertiseLocales: true,
307
+ contentLocales: [{ code: 'en', label: 'English' }],
308
+ workflowStatuses: [
309
+ { name: 'draft', label: 'Draft' },
310
+ { name: 'published', label: 'Published' },
311
+ ],
312
+ }
313
+ it.each(['stale', 'reload', 'lock', 'unavailable'] as const)(
314
+ 'retains dirty fields and blocks mutations for %s',
315
+ async (mutationIssue) => {
316
+ const onSubmit = vi.fn()
317
+ const onDelete = vi.fn()
318
+ const onStatusChange = vi.fn()
319
+ const guard = vi.fn(() => ({ isBlocked: false, stay() {}, proceed() {} }))
320
+ const render = (blocked: boolean) =>
321
+ renderInProvider(
322
+ <FormRenderer
323
+ {...props}
324
+ onSubmit={onSubmit}
325
+ onDelete={onDelete}
326
+ onStatusChange={onStatusChange}
327
+ mutationsBlocked={blocked}
328
+ mutationIssue={blocked ? mutationIssue : null}
329
+ useNavigationGuard={guard}
330
+ />
331
+ )
332
+ render(false)
333
+ typeIntoTitle('Unsaved text to copy')
334
+ render(true)
335
+ expect(container.querySelector<HTMLInputElement>('input[name="title"]')?.value).toBe(
336
+ 'Unsaved text to copy'
337
+ )
338
+ expect(container.querySelector<HTMLInputElement>('input[name="title"]')?.disabled).toBe(false)
339
+ expect(guard).toHaveBeenLastCalledWith(true)
340
+ expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled).toBe(
341
+ true
342
+ )
343
+ expect(container.querySelector<HTMLInputElement>('#system-path')?.disabled).toBe(true)
344
+ expect(container.querySelector<HTMLInputElement>('#available-locale-en')?.disabled).toBe(true)
345
+ const warning = container.querySelector('.byline-document-concurrency')
346
+ expect(warning?.getAttribute('role')).toBe('alert')
347
+ expect(document.activeElement).toBe(warning)
348
+ submitForm()
349
+ await act(async () => {})
350
+ expect(onSubmit).not.toHaveBeenCalled()
351
+ expect(onDelete).not.toHaveBeenCalled()
352
+ expect(onStatusChange).not.toHaveBeenCalled()
353
+ }
354
+ )
355
+ it('disables the dirty navigation guard only after explicit discard and retains it on reload failure', async () => {
356
+ const guard = vi.fn(() => ({ isBlocked: false, stay() {}, proceed() {} }))
357
+ const reload = vi.fn(async () => {
358
+ throw new Error('reload failed')
359
+ })
360
+ const render = (blocked: boolean) =>
361
+ renderInProvider(
362
+ <FormRenderer
363
+ {...props}
364
+ onSubmit={async () => {}}
365
+ mutationsBlocked={blocked}
366
+ mutationIssue={blocked ? 'stale' : null}
367
+ useNavigationGuard={guard}
368
+ onReloadDocument={reload}
369
+ />
370
+ )
371
+ render(false)
372
+ typeIntoTitle('Keep until I discard')
373
+ render(true)
374
+ expect(reload).not.toHaveBeenCalled()
375
+ expect(guard).toHaveBeenLastCalledWith(true)
376
+ const reloadButton = Array.from(container.querySelectorAll('button')).find(
377
+ (button) => button.textContent === 'Reload and discard my changes'
378
+ )
379
+ expect(reloadButton).toBeDefined()
380
+ await act(async () => {
381
+ reloadButton?.click()
382
+ })
383
+ expect(reload).toHaveBeenCalledTimes(1)
384
+ expect(guard).toHaveBeenCalledWith(false)
385
+ expect(guard).toHaveBeenLastCalledWith(true)
386
+ expect(container.textContent).toContain('Reload failed. Your unsaved changes are still here.')
387
+ expect(container.querySelector<HTMLInputElement>('input[name="title"]')?.value).toBe(
388
+ 'Keep until I discard'
389
+ )
390
+ })
391
+ it('shows structural schedule suspension separately without marking the form stale or clearing edits', () => {
392
+ const render = (notice: boolean) =>
393
+ renderInProvider(
394
+ <FormRenderer
395
+ {...props}
396
+ onSubmit={async () => {}}
397
+ scheduledPublicationsNeedReconfirmation={notice}
398
+ scheduledPublicationsHref="/admin/scheduled-publications"
399
+ />
400
+ )
401
+ render(false)
402
+ typeIntoTitle('Draft retained after tree move')
403
+ render(true)
404
+ expect(container.textContent).toContain('The structure was saved.')
405
+ expect(
406
+ container.querySelector<HTMLAnchorElement>('a[href="/admin/scheduled-publications"]')
407
+ ?.textContent
408
+ ).toBe('Review scheduled publications')
409
+ expect(container.textContent).not.toContain('Your changes were not saved.')
410
+ expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled).toBe(
411
+ false
412
+ )
413
+ expect(container.querySelector<HTMLInputElement>('input[name="title"]')?.value).toBe(
414
+ 'Draft retained after tree move'
415
+ )
416
+ })
417
+ })
@@ -265,6 +265,26 @@
265
265
  min-height: 28px;
266
266
  }
267
267
 
268
+ /*
269
+ * Concurrency alerts (stale document, lock conflict, schedule reconfirmation).
270
+ * Separated from the status bar above and from the form layout below, which
271
+ * otherwise carries its standing top padding on top of the alert's own gap.
272
+ */
273
+ .concurrency,
274
+ :global(.byline-document-concurrency) {
275
+ margin-top: var(--spacing-16);
276
+ }
277
+
278
+ .concurrency button,
279
+ :global(.byline-document-concurrency) button {
280
+ margin-top: var(--spacing-12);
281
+ }
282
+
283
+ .concurrency ~ .layout,
284
+ :global(.byline-document-concurrency) ~ :global(.byline-form-layout) {
285
+ padding-top: var(--spacing-16);
286
+ }
287
+
268
288
  .layout,
269
289
  :global(.byline-form-layout) {
270
290
  display: flex;
@@ -80,6 +80,15 @@ export interface SystemFieldsSubmitPayload {
80
80
 
81
81
  /** Props shared by both the public FormRenderer and its internal FormContent component. */
82
82
  export interface FormRendererProps {
83
+ mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null
84
+ mutationsBlocked?: boolean
85
+ observedRevision?: number
86
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void
87
+ onTreeMutationCommitted?: (receipt: import('@byline/core').StructuralMutationReceipt) => void
88
+ scheduledPublicationsNeedReconfirmation?: boolean
89
+ scheduledPublicationsHref?: string
90
+ /** Explicit discard action; defaults to a complete server reload. */
91
+ onReloadDocument?: () => void | Promise<void>
83
92
  mode: 'create' | 'edit'
84
93
  fields: Field[]
85
94
  onSubmit: (data: any) => void | Promise<void>
@@ -196,6 +205,14 @@ export interface FormRendererProps {
196
205
 
197
206
  const FormContent = ({
198
207
  mode,
208
+ mutationIssue,
209
+ mutationsBlocked = false,
210
+ observedRevision,
211
+ onMutationError,
212
+ onTreeMutationCommitted,
213
+ scheduledPublicationsNeedReconfirmation = false,
214
+ scheduledPublicationsHref,
215
+ onReloadDocument,
199
216
  fields,
200
217
  onSubmit,
201
218
  onCancel,
@@ -283,6 +300,7 @@ const FormContent = ({
283
300
  // escalated notice, and the schedule modal — so its state lives here and the
284
301
  // surfaces are rendered where each belongs.
285
302
  const scheduling = useScheduledPublication({
303
+ disabled: mutationsBlocked,
286
304
  schedule: scheduledPublication ?? null,
287
305
  onSchedule: onSchedulePublication,
288
306
  onConfirm: onConfirmScheduledPublication,
@@ -373,7 +391,25 @@ const FormContent = ({
373
391
  // The guard hook is injected by the consuming framework (prop > context > no-op fallback).
374
392
  const guardFromContext = useNavigationGuardAdapter()
375
393
  const useGuard = useNavigationGuardProp ?? guardFromContext
376
- const guard = useGuard(hasChanges)
394
+ const [discarding, setDiscarding] = useState(false)
395
+ const [reloadFailed, setReloadFailed] = useState(false)
396
+ const warningRef = useRef<HTMLDivElement>(null)
397
+ const mutationBlockedRef = useRef(mutationsBlocked)
398
+ mutationBlockedRef.current = mutationsBlocked || discarding
399
+ const guard = useGuard(hasChanges && !discarding)
400
+ useEffect(() => {
401
+ if (mutationIssue) warningRef.current?.focus()
402
+ }, [mutationIssue])
403
+ useEffect(() => {
404
+ if (!discarding) return
405
+ // Let the guard's beforeunload listener detach before the explicit discard.
406
+ Promise.resolve()
407
+ .then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
408
+ .catch(() => {
409
+ setDiscarding(false)
410
+ setReloadFailed(true)
411
+ })
412
+ }, [discarding, onReloadDocument])
377
413
 
378
414
  // Compute available status transitions
379
415
  const currentStatus = initialData?.status
@@ -404,6 +440,11 @@ const FormContent = ({
404
440
  if (isBusy || !restoreFocusAfterBusyRef.current) return
405
441
  restoreFocusAfterBusyRef.current = false
406
442
 
443
+ if (mutationIssue) {
444
+ warningRef.current?.focus()
445
+ focusBeforeBusyRef.current = null
446
+ return
447
+ }
407
448
  const original = focusBeforeBusyRef.current
408
449
  focusBeforeBusyRef.current = null
409
450
  const originalCanReceiveFocus =
@@ -414,7 +455,7 @@ const FormContent = ({
414
455
  'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
415
456
  )
416
457
  target?.focus({ preventScroll: true })
417
- }, [isBusy])
458
+ }, [isBusy, mutationIssue])
418
459
 
419
460
  const captureFocusBeforeBusy = useCallback(() => {
420
461
  if (focusBeforeBusyRef.current != null) return
@@ -438,7 +479,7 @@ const FormContent = ({
438
479
  // mid-flight; the mirrored state drives the Save button's disabled prop.
439
480
  const submitPayload = useCallback(
440
481
  async (payload: SystemFieldsSubmitPayload) => {
441
- if (typeof onSubmit !== 'function') return
482
+ if (mutationBlockedRef.current || typeof onSubmit !== 'function') return
442
483
  if (submittingRef.current) return
443
484
  submittingRef.current = true
444
485
  captureFocusBeforeBusy()
@@ -458,6 +499,10 @@ const FormContent = ({
458
499
  )
459
500
 
460
501
  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
502
+ if (mutationBlockedRef.current) {
503
+ e.preventDefault()
504
+ return
505
+ }
461
506
  e.preventDefault()
462
507
 
463
508
  // Run field-level beforeValidate hooks (submit-time), then validate
@@ -471,6 +516,8 @@ const FormContent = ({
471
516
  return
472
517
  }
473
518
 
519
+ if (mutationBlockedRef.current) return
520
+
474
521
  // Execute any pending uploads before submitting
475
522
  const pendingUploads = getPendingUploads()
476
523
  if (pendingUploads.size > 0) {
@@ -674,6 +721,7 @@ const FormContent = ({
674
721
  <div className={cx('byline-form-status-bar', styles['status-bar'])}>
675
722
  <div className={cx('byline-form-status-details', styles['status-details'])}>
676
723
  <FormStatusDisplay
724
+ disabled={mutationsBlocked || discarding}
677
725
  initialData={initialData}
678
726
  workflowStatuses={workflowStatuses}
679
727
  publishedVersion={publishedVersion}
@@ -700,7 +748,13 @@ const FormContent = ({
700
748
  className={cx('byline-form-actions-button', styles['actions-button'])}
701
749
  size="sm"
702
750
  type="submit"
703
- disabled={hasChanges === false || isUploading || isSubmitting}
751
+ disabled={
752
+ mutationsBlocked ||
753
+ discarding ||
754
+ hasChanges === false ||
755
+ isUploading ||
756
+ isSubmitting
757
+ }
704
758
  aria-label={isSubmitting ? t('common.actions.save') : undefined}
705
759
  >
706
760
  {isUploading ? (
@@ -747,8 +801,9 @@ const FormContent = ({
747
801
  size="sm"
748
802
  type="button"
749
803
  intent={isTerminal ? 'info' : 'success'}
750
- disabled={statusBusy}
804
+ disabled={mutationsBlocked || discarding || statusBusy}
751
805
  onOptionSelect={async (value: string) => {
806
+ if (mutationBlockedRef.current) return
752
807
  if (hasChanges) {
753
808
  setShowUnsavedModal(true)
754
809
  return
@@ -756,6 +811,8 @@ const FormContent = ({
756
811
  setStatusBusy(true)
757
812
  try {
758
813
  await onStatusChange(value)
814
+ } catch (error) {
815
+ onMutationError?.(error)
759
816
  } finally {
760
817
  setStatusBusy(false)
761
818
  }
@@ -764,6 +821,7 @@ const FormContent = ({
764
821
  isTerminal
765
822
  ? undefined
766
823
  : async () => {
824
+ if (mutationBlockedRef.current) return
767
825
  if (hasChanges) {
768
826
  setShowUnsavedModal(true)
769
827
  return
@@ -771,6 +829,8 @@ const FormContent = ({
771
829
  setStatusBusy(true)
772
830
  try {
773
831
  await onStatusChange(primaryStatus.name)
832
+ } catch (error) {
833
+ onMutationError?.(error)
774
834
  } finally {
775
835
  setStatusBusy(false)
776
836
  }
@@ -786,6 +846,7 @@ const FormContent = ({
786
846
  </div>
787
847
  )}
788
848
  <DocumentActions
849
+ disabled={mutationsBlocked || discarding}
789
850
  publishedVersion={publishedVersion}
790
851
  onUnpublish={onUnpublish}
791
852
  onDelete={onDelete}
@@ -813,10 +874,58 @@ const FormContent = ({
813
874
  />
814
875
  </div>
815
876
  </div>
877
+ {(mutationIssue || scheduledPublicationsNeedReconfirmation) && (
878
+ <div
879
+ ref={warningRef}
880
+ tabIndex={-1}
881
+ role="alert"
882
+ aria-live="assertive"
883
+ className={cx('byline-document-concurrency', styles.concurrency)}
884
+ >
885
+ {mutationIssue && (
886
+ <Alert
887
+ intent="warning"
888
+ icon
889
+ close={false}
890
+ title={t(`documentConcurrency.${mutationIssue}Title`)}
891
+ >
892
+ <p>{t(`documentConcurrency.${mutationIssue}`)}</p>
893
+ {mutationIssue !== 'committed' && (
894
+ <Button
895
+ type="button"
896
+ disabled={discarding}
897
+ onClick={() => {
898
+ setReloadFailed(false)
899
+ setDiscarding(true)
900
+ }}
901
+ >
902
+ {t('documentConcurrency.reloadAction')}
903
+ </Button>
904
+ )}
905
+ {reloadFailed && <p>{t('documentConcurrency.reloadFailed')}</p>}
906
+ </Alert>
907
+ )}
908
+ {scheduledPublicationsNeedReconfirmation && (
909
+ <Alert
910
+ intent="warning"
911
+ icon
912
+ close={false}
913
+ title={t('documentConcurrency.schedulesTitle')}
914
+ >
915
+ <p>{t('documentConcurrency.schedules')}</p>
916
+ {scheduledPublicationsHref && (
917
+ <a href={scheduledPublicationsHref}>
918
+ {t('documentConcurrency.reviewSchedules')}
919
+ </a>
920
+ )}
921
+ </Alert>
922
+ )}
923
+ </div>
924
+ )}
816
925
  <ScheduledPublicationNotice
817
926
  state={scheduling.state}
818
927
  timeZone={scheduling.timeZone}
819
- busy={scheduling.busy}
928
+ busy={mutationsBlocked || discarding || scheduling.busy}
820
929
  onConfirm={scheduling.confirm}
821
930
  onReschedule={scheduling.openSchedule}
822
931
  onCancel={scheduling.cancel}
@@ -847,6 +956,7 @@ const FormContent = ({
847
956
  (useAsPath ||
848
957
  (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
849
958
  <PathWidget
959
+ disabled={mutationsBlocked || discarding}
850
960
  useAsPath={useAsPath}
851
961
  collectionPath={collectionPath ?? ''}
852
962
  defaultLocale={defaultLocale}
@@ -858,6 +968,10 @@ const FormContent = ({
858
968
  )}
859
969
  {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
860
970
  <TreePlacementWidget
971
+ disabled={mutationsBlocked || discarding}
972
+ onMutationError={onMutationError}
973
+ onCommitted={onTreeMutationCommitted}
974
+ expectedRevision={observedRevision ?? initialData.revision}
861
975
  collectionPath={collectionPath ?? ''}
862
976
  documentId={initialData.id as string}
863
977
  useAsTitle={useAsTitle}
@@ -865,6 +979,7 @@ const FormContent = ({
865
979
  )}
866
980
  {advertiseLocales && (
867
981
  <AvailableLocalesWidget
982
+ disabled={mutationsBlocked || discarding}
868
983
  contentLocales={contentLocales ?? []}
869
984
  availableVersionLocales={
870
985
  (initialData?._availableVersionLocales as string[] | undefined) ?? []
@@ -875,7 +990,7 @@ const FormContent = ({
875
990
  </div>
876
991
  </div>
877
992
  {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
878
- {pendingSystemFieldsSubmit != null && (
993
+ {!mutationsBlocked && !discarding && pendingSystemFieldsSubmit != null && (
879
994
  <SystemFieldsConfirmModal
880
995
  contentDirty={pendingSystemFieldsSubmit.contentDirty}
881
996
  pathDirty={pendingSystemFieldsSubmit.pathDirty}
@@ -23,12 +23,14 @@ import type { PublishedVersionInfo } from './form-renderer'
23
23
  * Unpublish action when a previously-published version is still live.
24
24
  */
25
25
  export const FormStatusDisplay = ({
26
+ disabled = false,
26
27
  initialData,
27
28
  workflowStatuses,
28
29
  publishedVersion,
29
30
  onUnpublish,
30
31
  afterStatusCells,
31
32
  }: {
33
+ disabled?: boolean
32
34
  initialData?: Record<string, any>
33
35
  workflowStatuses?: WorkflowStatus[]
34
36
  publishedVersion?: PublishedVersionInfo | null
@@ -106,7 +108,10 @@ export const FormStatusDisplay = ({
106
108
  {' '}
107
109
  <button
108
110
  type="button"
109
- onClick={onUnpublish}
111
+ disabled={disabled}
112
+ onClick={() => {
113
+ if (!disabled) void onUnpublish().catch(() => {})
114
+ }}
110
115
  className={cx('byline-form-status-unpublish', styles['status-unpublish'])}
111
116
  >
112
117
  {t('common.actions.unpublish')}
@@ -31,6 +31,7 @@ function coerceToString(value: unknown): string {
31
31
  }
32
32
 
33
33
  export interface PathWidgetProps {
34
+ disabled?: boolean
34
35
  /** The collection's `useAsPath` source field name, when configured. */
35
36
  useAsPath: string | undefined
36
37
  /** Collection path, forwarded to the slugifier as context. */
@@ -91,6 +92,7 @@ export const PathWidget = ({
91
92
  mode,
92
93
  slugifier,
93
94
  sourceLocked = false,
95
+ disabled = false,
94
96
  }: PathWidgetProps) => {
95
97
  const { setSystemPath } = useFormContext()
96
98
  const { t } = useTranslation('byline-admin')
@@ -125,18 +127,19 @@ export const PathWidget = ({
125
127
 
126
128
  const handleChange = useCallback(
127
129
  (next: string) => {
130
+ if (disabled) return
128
131
  // Empty string clears the override — server falls back to derive
129
132
  // (create) or sticky (update).
130
133
  setSystemPath(next.length === 0 ? null : next)
131
134
  },
132
- [setSystemPath]
135
+ [disabled, setSystemPath]
133
136
  )
134
137
 
135
138
  const handleRegenerate = useCallback(() => {
136
- if (livePreview.length > 0) {
139
+ if (!disabled && livePreview.length > 0) {
137
140
  setSystemPath(livePreview)
138
141
  }
139
- }, [livePreview, setSystemPath])
142
+ }, [disabled, livePreview, setSystemPath])
140
143
 
141
144
  // Validate live: if the typed value differs from its slugified form,
142
145
  // surface an inline hint without blocking input (mirrors the previous
@@ -184,6 +187,7 @@ export const PathWidget = ({
184
187
  type="button"
185
188
  onClick={handleRegenerate}
186
189
  className={cx('byline-form-path-regenerate', styles.regenerate)}
190
+ disabled={disabled}
187
191
  aria-label={t('pathWidget.regenerateAriaLabel', { field: useAsPath })}
188
192
  >
189
193
  {t('pathWidget.regenerateButton', { field: useAsPath })}
@@ -191,6 +195,7 @@ export const PathWidget = ({
191
195
  )}
192
196
  </div>
193
197
  <Input
198
+ disabled={disabled}
194
199
  id="system-path"
195
200
  name="__systemPath__"
196
201
  value={inputValue}
@@ -110,6 +110,7 @@ function seedScheduleInstant(schedule: ScheduledPublicationInfo | null): Date {
110
110
  }
111
111
 
112
112
  export interface UseScheduledPublicationArgs {
113
+ disabled?: boolean
113
114
  schedule: ScheduledPublicationInfo | null
114
115
  onSchedule?: (input: SchedulePublicationInput) => Promise<void>
115
116
  onConfirm?: () => Promise<void>
@@ -132,6 +133,7 @@ export interface UseScheduledPublicationReturn {
132
133
  }
133
134
 
134
135
  export function useScheduledPublication({
136
+ disabled = false,
135
137
  schedule,
136
138
  onSchedule,
137
139
  onConfirm,
@@ -171,15 +173,16 @@ export function useScheduledPublication({
171
173
  // to be resolved before any of these operations can name a version. Cancel
172
174
  // is exempt: withdrawing a schedule says nothing about content.
173
175
  const openSchedule = useCallback(() => {
176
+ if (disabled) return
174
177
  if (hasUnsavedChanges) {
175
178
  onUnsavedChanges()
176
179
  return
177
180
  }
178
181
  setShowSchedule(true)
179
- }, [hasUnsavedChanges, onUnsavedChanges])
182
+ }, [disabled, hasUnsavedChanges, onUnsavedChanges])
180
183
 
181
184
  const confirm = useCallback(async () => {
182
- if (onConfirm == null) return
185
+ if (disabled || onConfirm == null) return
183
186
  if (hasUnsavedChanges) {
184
187
  onUnsavedChanges()
185
188
  return
@@ -187,37 +190,43 @@ export function useScheduledPublication({
187
190
  setBusy(true)
188
191
  try {
189
192
  await onConfirm()
193
+ } catch {
194
+ // The host reports the failure; keep this view open.
190
195
  } finally {
191
196
  setBusy(false)
192
197
  }
193
- }, [onConfirm, hasUnsavedChanges, onUnsavedChanges])
198
+ }, [disabled, onConfirm, hasUnsavedChanges, onUnsavedChanges])
194
199
 
195
200
  const cancel = useCallback(async () => {
196
- if (onCancel == null) return
201
+ if (disabled || onCancel == null) return
197
202
  setBusy(true)
198
203
  try {
199
204
  await onCancel()
205
+ } catch {
206
+ // The host reports the failure; keep this view open.
200
207
  } finally {
201
208
  setBusy(false)
202
209
  }
203
- }, [onCancel])
210
+ }, [disabled, onCancel])
204
211
 
205
212
  const modal = showSchedule ? (
206
213
  <ScheduleModal
207
214
  schedule={schedule}
208
215
  timeZone={timeZone}
209
216
  onSubmit={async (input) => {
210
- if (onSchedule == null) return
217
+ if (disabled || onSchedule == null) return
211
218
  setBusy(true)
212
219
  try {
213
220
  await onSchedule(input)
214
221
  setShowSchedule(false)
222
+ } catch {
223
+ // The host reports the failure; keep this view open.
215
224
  } finally {
216
225
  setBusy(false)
217
226
  }
218
227
  }}
219
228
  onDismiss={() => setShowSchedule(false)}
220
- busy={busy}
229
+ busy={disabled || busy}
221
230
  />
222
231
  ) : null
223
232