@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.
Files changed (35) hide show
  1. package/dist/fields/field-services-types.d.ts +50 -0
  2. package/dist/forms/form-context.js +30 -60
  3. package/dist/forms/form-modals.d.ts +30 -0
  4. package/dist/forms/form-modals.js +189 -0
  5. package/dist/forms/form-renderer.d.ts +8 -1
  6. package/dist/forms/form-renderer.js +31 -365
  7. package/dist/forms/form-status-display.d.ts +21 -0
  8. package/dist/forms/form-status-display.js +94 -0
  9. package/dist/forms/status-transitions.d.ts +23 -0
  10. package/dist/forms/status-transitions.js +37 -0
  11. package/dist/forms/status-transitions.test.node.d.ts +8 -0
  12. package/dist/forms/tree-placement-widget.d.ts +23 -0
  13. package/dist/forms/tree-placement-widget.js +176 -0
  14. package/dist/forms/tree-placement-widget.module.js +15 -0
  15. package/dist/forms/tree-placement-widget_module.css +69 -0
  16. package/dist/forms/use-form-layout.d.ts +35 -0
  17. package/dist/forms/use-form-layout.js +77 -0
  18. package/dist/forms/use-form-layout.test.node.d.ts +8 -0
  19. package/dist/forms/use-tracked-slot.d.ts +45 -0
  20. package/dist/forms/use-tracked-slot.js +46 -0
  21. package/dist/react.d.ts +1 -1
  22. package/package.json +5 -5
  23. package/src/fields/field-services-types.ts +50 -0
  24. package/src/forms/form-context.tsx +36 -92
  25. package/src/forms/form-modals.tsx +186 -0
  26. package/src/forms/form-renderer.tsx +42 -387
  27. package/src/forms/form-status-display.tsx +108 -0
  28. package/src/forms/status-transitions.test.node.ts +87 -0
  29. package/src/forms/status-transitions.ts +86 -0
  30. package/src/forms/tree-placement-widget.module.css +87 -0
  31. package/src/forms/tree-placement-widget.tsx +202 -0
  32. package/src/forms/use-form-layout.test.node.ts +82 -0
  33. package/src/forms/use-form-layout.ts +134 -0
  34. package/src/forms/use-tracked-slot.ts +101 -0
  35. 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
- initialSystemPath.current = systemPathRef.current
334
- initialSystemAvailableLocales.current = [...systemAvailableLocalesRef.current]
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
+ }