@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.
- package/dist/fields/field-services-context.d.ts +1 -1
- package/dist/fields/field-services-types.d.ts +5 -2
- package/dist/forms/available-locales-widget.d.ts +3 -2
- package/dist/forms/available-locales-widget.js +4 -2
- package/dist/forms/document-actions.d.ts +3 -2
- package/dist/forms/document-actions.js +32 -14
- package/dist/forms/form-renderer.d.ts +12 -3
- package/dist/forms/form-renderer.js +98 -8
- package/dist/forms/form-renderer.module.js +1 -0
- package/dist/forms/form-renderer_module.css +12 -0
- package/dist/forms/form-status-display.d.ts +3 -2
- package/dist/forms/form-status-display.js +5 -2
- package/dist/forms/path-widget.d.ts +2 -1
- package/dist/forms/path-widget.js +7 -2
- package/dist/forms/scheduled-publication-control.d.ts +2 -1
- package/dist/forms/scheduled-publication-control.js +12 -8
- package/dist/forms/tree-placement-widget.d.ts +5 -1
- package/dist/forms/tree-placement-widget.js +14 -7
- package/dist/forms/upload-executor.d.ts +2 -2
- package/dist/modules/admin-account/components/change-password.js +18 -0
- package/dist/modules/admin-account/service.d.ts +2 -6
- package/dist/modules/admin-account/service.js +2 -1
- package/dist/modules/admin-users/repository.d.ts +10 -1
- package/dist/modules/auth/index.d.ts +3 -1
- package/dist/modules/auth/index.js +1 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
- package/dist/modules/auth/jwt-session-provider.js +222 -70
- package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
- package/dist/modules/auth/login-sessions-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
- package/dist/modules/auth/resolve-actor.d.ts +3 -0
- package/dist/modules/auth/resolve-actor.js +5 -2
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
- package/dist/store.d.ts +15 -2
- package/package.json +17 -17
- package/src/fields/field-services-types.ts +10 -2
- package/src/forms/available-locales-widget.tsx +5 -2
- package/src/forms/document-actions.tsx +55 -20
- package/src/forms/form-renderer-submit.test.tsx +131 -2
- package/src/forms/form-renderer.module.css +20 -0
- package/src/forms/form-renderer.tsx +122 -7
- package/src/forms/form-status-display.tsx +6 -1
- package/src/forms/path-widget.tsx +8 -3
- package/src/forms/scheduled-publication-control.tsx +16 -7
- package/src/forms/tree-placement-widget.tsx +34 -7
- package/src/modules/admin-account/components/change-password.test.tsx +72 -0
- package/src/modules/admin-account/components/change-password.tsx +15 -4
- package/src/modules/admin-account/service.ts +8 -7
- package/src/modules/admin-users/repository.ts +10 -1
- package/src/modules/auth/index.ts +13 -1
- package/src/modules/auth/jwt-session-provider.ts +276 -91
- package/src/modules/auth/login-sessions-repository.ts +25 -0
- package/src/modules/auth/refresh-tokens-repository.ts +12 -9
- package/src/modules/auth/resolve-actor.ts +10 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
- package/src/store.ts +22 -2
|
@@ -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 }:
|
|
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
|
|
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={
|
|
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
|
-
|
|
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
|
|