@byline/admin 3.12.1 → 3.13.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-types.d.ts +50 -0
- package/dist/forms/form-context.js +30 -60
- package/dist/forms/form-modals.d.ts +30 -0
- package/dist/forms/form-modals.js +189 -0
- package/dist/forms/form-renderer.d.ts +8 -1
- package/dist/forms/form-renderer.js +31 -365
- package/dist/forms/form-status-display.d.ts +21 -0
- package/dist/forms/form-status-display.js +94 -0
- package/dist/forms/status-transitions.d.ts +23 -0
- package/dist/forms/status-transitions.js +37 -0
- package/dist/forms/status-transitions.test.node.d.ts +8 -0
- package/dist/forms/tree-placement-widget.d.ts +23 -0
- package/dist/forms/tree-placement-widget.js +176 -0
- package/dist/forms/tree-placement-widget.module.js +15 -0
- package/dist/forms/tree-placement-widget_module.css +69 -0
- package/dist/forms/use-form-layout.d.ts +35 -0
- package/dist/forms/use-form-layout.js +77 -0
- package/dist/forms/use-form-layout.test.node.d.ts +8 -0
- package/dist/forms/use-tracked-slot.d.ts +45 -0
- package/dist/forms/use-tracked-slot.js +46 -0
- package/dist/react.d.ts +1 -1
- package/package.json +5 -5
- package/src/fields/field-services-types.ts +50 -0
- package/src/forms/form-context.tsx +36 -92
- package/src/forms/form-modals.tsx +186 -0
- package/src/forms/form-renderer.tsx +42 -387
- package/src/forms/form-status-display.tsx +108 -0
- package/src/forms/status-transitions.test.node.ts +87 -0
- package/src/forms/status-transitions.ts +86 -0
- package/src/forms/tree-placement-widget.module.css +87 -0
- package/src/forms/tree-placement-widget.tsx +202 -0
- package/src/forms/use-form-layout.test.node.ts +82 -0
- package/src/forms/use-form-layout.ts +134 -0
- package/src/forms/use-tracked-slot.ts +101 -0
- package/src/react.ts +6 -0
|
@@ -20,6 +20,7 @@ import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
|
|
|
20
20
|
// ~85KB chunk that leaks onto the public frontend bundle (form-context is
|
|
21
21
|
// reachable from the layout graph).
|
|
22
22
|
import { get as getNestedValue, set as setNestedValue } from './nested-path'
|
|
23
|
+
import { useTrackedSlot } from './use-tracked-slot'
|
|
23
24
|
|
|
24
25
|
interface FormError {
|
|
25
26
|
field: string
|
|
@@ -168,27 +169,6 @@ export const FormProvider = ({
|
|
|
168
169
|
const errorListeners = useRef<Set<ErrorsListener>>(new Set())
|
|
169
170
|
const metaListeners = useRef<Set<MetaListener>>(new Set())
|
|
170
171
|
|
|
171
|
-
// System path slot — initialised from the loaded version's top-level
|
|
172
|
-
// `path` (edit mode) or `null` (create mode). Edits via `setSystemPath`
|
|
173
|
-
// mark the form dirty so the Save button enables.
|
|
174
|
-
const systemPathRef = useRef<string | null>(
|
|
175
|
-
typeof initialData?.path === 'string' && (initialData.path as string).length > 0
|
|
176
|
-
? (initialData.path as string)
|
|
177
|
-
: null
|
|
178
|
-
)
|
|
179
|
-
const initialSystemPath = useRef<string | null>(systemPathRef.current)
|
|
180
|
-
const systemPathListeners = useRef<Set<SystemPathListener>>(new Set())
|
|
181
|
-
|
|
182
|
-
// System available-locales slot — initialised from the loaded version's
|
|
183
|
-
// top-level `availableLocales` (edit mode) or `[]` (create mode / not yet
|
|
184
|
-
// surfaced). Edits via `setSystemAvailableLocales` mark the form dirty so
|
|
185
|
-
// the Save button enables. Stored as a defensive copy.
|
|
186
|
-
const systemAvailableLocalesRef = useRef<string[]>(
|
|
187
|
-
Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : []
|
|
188
|
-
)
|
|
189
|
-
const initialSystemAvailableLocales = useRef<string[]>([...systemAvailableLocalesRef.current])
|
|
190
|
-
const systemAvailableLocalesListeners = useRef<Set<SystemAvailableLocalesListener>>(new Set())
|
|
191
|
-
|
|
192
172
|
const subscribeField = useCallback((name: string, listener: FieldListener) => {
|
|
193
173
|
if (!fieldListeners.current.has(name)) {
|
|
194
174
|
fieldListeners.current.set(name, new Set())
|
|
@@ -240,6 +220,32 @@ export const FormProvider = ({
|
|
|
240
220
|
})
|
|
241
221
|
}, [])
|
|
242
222
|
|
|
223
|
+
// Document-grain system-field slots — dirty-tracked, ref-backed, each with
|
|
224
|
+
// its own listener set. The `path` slot is initialised from the loaded
|
|
225
|
+
// version's top-level `path` (edit) or `null` (create); the available-locales
|
|
226
|
+
// slot from `availableLocales` (edit) or `[]`. Edits toggle the slot's dirty
|
|
227
|
+
// key so the single Save button can branch. See ./use-tracked-slot.
|
|
228
|
+
const pathSlot = useTrackedSlot<string | null>({
|
|
229
|
+
initial:
|
|
230
|
+
typeof initialData?.path === 'string' && (initialData.path as string).length > 0
|
|
231
|
+
? (initialData.path as string)
|
|
232
|
+
: null,
|
|
233
|
+
dirtyKey: SYSTEM_PATH_DIRTY_KEY,
|
|
234
|
+
dirtyFields,
|
|
235
|
+
notifyMeta: notifyMetaListeners,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const availableLocalesSlot = useTrackedSlot<string[]>({
|
|
239
|
+
initial: Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : [],
|
|
240
|
+
dirtyKey: SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY,
|
|
241
|
+
dirtyFields,
|
|
242
|
+
notifyMeta: notifyMetaListeners,
|
|
243
|
+
// The slot holds an array; a fresh reference is never `===` its baseline,
|
|
244
|
+
// so dirty tracking compares membership, not identity. Stored as a copy.
|
|
245
|
+
isEqual: sameLocaleSet,
|
|
246
|
+
clone: (value) => [...value],
|
|
247
|
+
})
|
|
248
|
+
|
|
243
249
|
const updateFieldStoreInternal = useCallback(
|
|
244
250
|
(name: string, value: any) => {
|
|
245
251
|
const newFieldValues = { ...fieldValues.current }
|
|
@@ -330,10 +336,10 @@ export const FormProvider = ({
|
|
|
330
336
|
const resetHasChanges = useCallback(() => {
|
|
331
337
|
dirtyFields.current.clear()
|
|
332
338
|
patchesRef.current = []
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
pathSlot.commitInitial()
|
|
340
|
+
availableLocalesSlot.commitInitial()
|
|
335
341
|
notifyMetaListeners()
|
|
336
|
-
}, [notifyMetaListeners])
|
|
342
|
+
}, [notifyMetaListeners, pathSlot.commitInitial, availableLocalesSlot.commitInitial])
|
|
337
343
|
|
|
338
344
|
const isDirty = useCallback((fieldName: string) => {
|
|
339
345
|
return dirtyFields.current.has(fieldName)
|
|
@@ -367,68 +373,6 @@ export const FormProvider = ({
|
|
|
367
373
|
return { reason, contentDirty, pathDirty, availableLocalesDirty }
|
|
368
374
|
}, [])
|
|
369
375
|
|
|
370
|
-
// -------------------------------------------------------------------------
|
|
371
|
-
// System path slot
|
|
372
|
-
// -------------------------------------------------------------------------
|
|
373
|
-
|
|
374
|
-
const getSystemPath = useCallback(() => systemPathRef.current, [])
|
|
375
|
-
|
|
376
|
-
const setSystemPath = useCallback(
|
|
377
|
-
(value: string | null) => {
|
|
378
|
-
systemPathRef.current = value
|
|
379
|
-
if (value !== initialSystemPath.current) {
|
|
380
|
-
dirtyFields.current.add(SYSTEM_PATH_DIRTY_KEY)
|
|
381
|
-
} else {
|
|
382
|
-
dirtyFields.current.delete(SYSTEM_PATH_DIRTY_KEY)
|
|
383
|
-
}
|
|
384
|
-
systemPathListeners.current.forEach((listener) => {
|
|
385
|
-
listener(value)
|
|
386
|
-
})
|
|
387
|
-
notifyMetaListeners()
|
|
388
|
-
},
|
|
389
|
-
[notifyMetaListeners]
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
const subscribeSystemPath = useCallback((listener: SystemPathListener) => {
|
|
393
|
-
systemPathListeners.current.add(listener)
|
|
394
|
-
return () => {
|
|
395
|
-
systemPathListeners.current.delete(listener)
|
|
396
|
-
}
|
|
397
|
-
}, [])
|
|
398
|
-
|
|
399
|
-
// -------------------------------------------------------------------------
|
|
400
|
-
// System available-locales slot
|
|
401
|
-
// -------------------------------------------------------------------------
|
|
402
|
-
|
|
403
|
-
const getSystemAvailableLocales = useCallback(() => systemAvailableLocalesRef.current, [])
|
|
404
|
-
|
|
405
|
-
const setSystemAvailableLocales = useCallback(
|
|
406
|
-
(value: string[]) => {
|
|
407
|
-
const next = [...value]
|
|
408
|
-
systemAvailableLocalesRef.current = next
|
|
409
|
-
if (!sameLocaleSet(next, initialSystemAvailableLocales.current)) {
|
|
410
|
-
dirtyFields.current.add(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
411
|
-
} else {
|
|
412
|
-
dirtyFields.current.delete(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
413
|
-
}
|
|
414
|
-
systemAvailableLocalesListeners.current.forEach((listener) => {
|
|
415
|
-
listener(next)
|
|
416
|
-
})
|
|
417
|
-
notifyMetaListeners()
|
|
418
|
-
},
|
|
419
|
-
[notifyMetaListeners]
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
const subscribeSystemAvailableLocales = useCallback(
|
|
423
|
-
(listener: SystemAvailableLocalesListener) => {
|
|
424
|
-
systemAvailableLocalesListeners.current.add(listener)
|
|
425
|
-
return () => {
|
|
426
|
-
systemAvailableLocalesListeners.current.delete(listener)
|
|
427
|
-
}
|
|
428
|
-
},
|
|
429
|
-
[]
|
|
430
|
-
)
|
|
431
|
-
|
|
432
376
|
// ---------------------------------------------------------------------------
|
|
433
377
|
// Pending uploads (deferred until save)
|
|
434
378
|
// ---------------------------------------------------------------------------
|
|
@@ -715,12 +659,12 @@ export const FormProvider = ({
|
|
|
715
659
|
setFieldUploading,
|
|
716
660
|
getIsFieldUploading,
|
|
717
661
|
subscribeFieldUploading,
|
|
718
|
-
getSystemPath,
|
|
719
|
-
setSystemPath,
|
|
720
|
-
subscribeSystemPath,
|
|
721
|
-
getSystemAvailableLocales,
|
|
722
|
-
setSystemAvailableLocales,
|
|
723
|
-
subscribeSystemAvailableLocales,
|
|
662
|
+
getSystemPath: pathSlot.get,
|
|
663
|
+
setSystemPath: pathSlot.set,
|
|
664
|
+
subscribeSystemPath: pathSlot.subscribe,
|
|
665
|
+
getSystemAvailableLocales: availableLocalesSlot.get,
|
|
666
|
+
setSystemAvailableLocales: availableLocalesSlot.set,
|
|
667
|
+
subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
|
|
724
668
|
}}
|
|
725
669
|
>
|
|
726
670
|
{children}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
12
|
+
import { Button, CloseIcon, IconButton, Modal } from '@byline/ui/react'
|
|
13
|
+
import cx from 'classnames'
|
|
14
|
+
|
|
15
|
+
import styles from './form-renderer.module.css'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shown when the editor triggers a guarded action (status change, duplicate,
|
|
19
|
+
* copy-to-locale) while the form is dirty. Those actions operate on the saved
|
|
20
|
+
* version, so the editor must save first.
|
|
21
|
+
*/
|
|
22
|
+
export const UnsavedChangesModal = ({ onClose }: { onClose: () => void }) => {
|
|
23
|
+
const { t } = useTranslation('byline-admin')
|
|
24
|
+
return (
|
|
25
|
+
<Modal isOpen={true} closeOnOverlayClick={true} onDismiss={onClose}>
|
|
26
|
+
<Modal.Container style={{ maxWidth: '460px' }}>
|
|
27
|
+
<Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
|
|
28
|
+
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
29
|
+
{t('forms.unsavedChanges.title')}
|
|
30
|
+
</h3>
|
|
31
|
+
</Modal.Header>
|
|
32
|
+
<Modal.Content>
|
|
33
|
+
<p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
|
|
34
|
+
{t('forms.unsavedChanges.message')}
|
|
35
|
+
</p>
|
|
36
|
+
</Modal.Content>
|
|
37
|
+
<Modal.Actions>
|
|
38
|
+
<Button
|
|
39
|
+
size="sm"
|
|
40
|
+
style={{ minWidth: '60px' }}
|
|
41
|
+
intent="primary"
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={onClose}
|
|
44
|
+
>
|
|
45
|
+
{t('forms.unsavedChanges.okButton')}
|
|
46
|
+
</Button>
|
|
47
|
+
</Modal.Actions>
|
|
48
|
+
</Modal.Container>
|
|
49
|
+
</Modal>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Confirms an immediate, non-versioned write of the document-grain system
|
|
55
|
+
* fields (path / advertised locales) — which does NOT reset workflow status.
|
|
56
|
+
* When content is also dirty, the copy reassures that content edits still
|
|
57
|
+
* follow the normal revision + publish workflow. See docs/I18N.md.
|
|
58
|
+
*/
|
|
59
|
+
export const SystemFieldsConfirmModal = ({
|
|
60
|
+
contentDirty,
|
|
61
|
+
pathDirty,
|
|
62
|
+
availableLocalesDirty,
|
|
63
|
+
onCancel,
|
|
64
|
+
onConfirm,
|
|
65
|
+
}: {
|
|
66
|
+
contentDirty: boolean
|
|
67
|
+
pathDirty: boolean
|
|
68
|
+
availableLocalesDirty: boolean
|
|
69
|
+
onCancel: () => void
|
|
70
|
+
onConfirm: () => void
|
|
71
|
+
}) => {
|
|
72
|
+
const { t } = useTranslation('byline-admin')
|
|
73
|
+
return (
|
|
74
|
+
<Modal isOpen={true} closeOnOverlayClick={true} onDismiss={onCancel}>
|
|
75
|
+
<Modal.Container style={{ maxWidth: '520px' }}>
|
|
76
|
+
<Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
|
|
77
|
+
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
78
|
+
{contentDirty
|
|
79
|
+
? t('forms.systemFieldsConfirm.bothTitle')
|
|
80
|
+
: t('forms.systemFieldsConfirm.title')}
|
|
81
|
+
</h3>
|
|
82
|
+
<IconButton aria-label={t('common.actions.close')} size="xs" onClick={onCancel}>
|
|
83
|
+
<CloseIcon width="16px" height="16px" svgClassName="white-icon" />
|
|
84
|
+
</IconButton>
|
|
85
|
+
</Modal.Header>
|
|
86
|
+
<Modal.Content className="prose">
|
|
87
|
+
{/* Lead with reassurance: content edits follow the normal
|
|
88
|
+
revision + publish workflow. The immediate, document-level
|
|
89
|
+
system-field write is explained below the divider. */}
|
|
90
|
+
{contentDirty && (
|
|
91
|
+
<p className={cx('byline-form-system-fields-content-note', 'm-0 mt-2')}>
|
|
92
|
+
{t('forms.systemFieldsConfirm.contentNote')}
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
<p
|
|
96
|
+
className="m-0 mt-2"
|
|
97
|
+
style={
|
|
98
|
+
contentDirty
|
|
99
|
+
? {
|
|
100
|
+
marginTop: 'var(--spacing-8)',
|
|
101
|
+
paddingTop: 'var(--spacing-12)',
|
|
102
|
+
borderTop: '1px solid var(--border-color)',
|
|
103
|
+
}
|
|
104
|
+
: undefined
|
|
105
|
+
}
|
|
106
|
+
>
|
|
107
|
+
{t('forms.systemFieldsConfirm.intro')}
|
|
108
|
+
</p>
|
|
109
|
+
<ul className={cx('byline-form-system-fields-list', styles['guard-modal-text'], 'm-0')}>
|
|
110
|
+
{pathDirty && <li>{t('forms.systemFieldsConfirm.bulletPath')}</li>}
|
|
111
|
+
{availableLocalesDirty && <li>{t('forms.systemFieldsConfirm.bulletLocales')}</li>}
|
|
112
|
+
</ul>
|
|
113
|
+
<p
|
|
114
|
+
className={cx('byline-form-system-fields-effect', styles['guard-modal-text'])}
|
|
115
|
+
style={{
|
|
116
|
+
marginTop: 'var(--spacing-4)',
|
|
117
|
+
marginBottom: 0,
|
|
118
|
+
color: 'var(--text-subtle)',
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{t('forms.systemFieldsConfirm.effectLine')}
|
|
122
|
+
</p>
|
|
123
|
+
</Modal.Content>
|
|
124
|
+
<Modal.Actions>
|
|
125
|
+
<Button
|
|
126
|
+
size="sm"
|
|
127
|
+
style={{ minWidth: '80px' }}
|
|
128
|
+
intent="noeffect"
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={onCancel}
|
|
131
|
+
>
|
|
132
|
+
{t('common.actions.cancel')}
|
|
133
|
+
</Button>
|
|
134
|
+
<Button
|
|
135
|
+
size="sm"
|
|
136
|
+
style={{ minWidth: '80px' }}
|
|
137
|
+
intent="primary"
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={onConfirm}
|
|
140
|
+
>
|
|
141
|
+
{t('forms.systemFieldsConfirm.confirmButton')}
|
|
142
|
+
</Button>
|
|
143
|
+
</Modal.Actions>
|
|
144
|
+
</Modal.Container>
|
|
145
|
+
</Modal>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Blocks router navigation / browser unload while the form is dirty. Driven by
|
|
151
|
+
* the navigation-guard adapter's `isBlocked` state; `onStay` keeps the editor
|
|
152
|
+
* on the page, `onProceed` discards and continues.
|
|
153
|
+
*/
|
|
154
|
+
export const NavigationGuardModal = ({
|
|
155
|
+
onStay,
|
|
156
|
+
onProceed,
|
|
157
|
+
}: {
|
|
158
|
+
onStay: () => void
|
|
159
|
+
onProceed: () => void
|
|
160
|
+
}) => {
|
|
161
|
+
const { t } = useTranslation('byline-admin')
|
|
162
|
+
return (
|
|
163
|
+
<Modal isOpen={true} closeOnOverlayClick={false} onDismiss={onStay}>
|
|
164
|
+
<Modal.Container style={{ maxWidth: '460px' }}>
|
|
165
|
+
<Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
|
|
166
|
+
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
167
|
+
{t('forms.navigationGuard.title')}
|
|
168
|
+
</h3>
|
|
169
|
+
</Modal.Header>
|
|
170
|
+
<Modal.Content>
|
|
171
|
+
<p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
|
|
172
|
+
{t('forms.navigationGuard.message')}
|
|
173
|
+
</p>
|
|
174
|
+
</Modal.Content>
|
|
175
|
+
<Modal.Actions>
|
|
176
|
+
<Button size="sm" intent="noeffect" type="button" onClick={onStay}>
|
|
177
|
+
{t('forms.navigationGuard.stayButton')}
|
|
178
|
+
</Button>
|
|
179
|
+
<Button size="sm" intent="danger" type="button" onClick={onProceed}>
|
|
180
|
+
{t('forms.navigationGuard.leaveButton')}
|
|
181
|
+
</Button>
|
|
182
|
+
</Modal.Actions>
|
|
183
|
+
</Modal.Container>
|
|
184
|
+
</Modal>
|
|
185
|
+
)
|
|
186
|
+
}
|