@byline/admin 5.1.3 → 5.2.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 (93) hide show
  1. package/dist/modules/analytics/components/dashboard.d.ts +13 -3
  2. package/dist/modules/analytics/components/dashboard.js +86 -68
  3. package/dist/modules/analytics/components/dashboard.module.js +10 -1
  4. package/dist/modules/analytics/components/dashboard_module.css +57 -0
  5. package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
  6. package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
  7. package/dist/modules/analytics/components/ranking.d.ts +45 -0
  8. package/dist/modules/analytics/components/ranking.js +71 -0
  9. package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
  10. package/package.json +14 -14
  11. package/src/modules/analytics/components/dashboard.module.css +69 -0
  12. package/src/modules/analytics/components/dashboard.tsx +105 -83
  13. package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
  14. package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
  15. package/src/modules/analytics/components/ranking.test.node.ts +43 -0
  16. package/src/modules/analytics/components/ranking.tsx +122 -0
  17. package/dist/exports-parity.test.node.js +0 -37
  18. package/dist/fields/array/array-field.jsx +0 -209
  19. package/dist/fields/blocks/blocks-field.jsx +0 -248
  20. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  21. package/dist/fields/code/code-editor.jsx +0 -211
  22. package/dist/fields/code/code-field.jsx +0 -109
  23. package/dist/fields/column-formatter.jsx +0 -25
  24. package/dist/fields/date-time-formatter.jsx +0 -18
  25. package/dist/fields/datetime/datetime-field.jsx +0 -22
  26. package/dist/fields/draggable-context-menu.jsx +0 -50
  27. package/dist/fields/field-admin.test.node.js +0 -40
  28. package/dist/fields/field-renderer.jsx +0 -136
  29. package/dist/fields/field-services-context.jsx +0 -17
  30. package/dist/fields/file/file-field.jsx +0 -176
  31. package/dist/fields/file/file-upload-field.jsx +0 -89
  32. package/dist/fields/group/group-field.jsx +0 -46
  33. package/dist/fields/image/image-field.jsx +0 -183
  34. package/dist/fields/image/image-upload-field.jsx +0 -157
  35. package/dist/fields/local-date-time.jsx +0 -69
  36. package/dist/fields/locale-badge.jsx +0 -19
  37. package/dist/fields/numerical/numerical-field.jsx +0 -93
  38. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  39. package/dist/fields/relation/relation-display.jsx +0 -118
  40. package/dist/fields/relation/relation-field.jsx +0 -108
  41. package/dist/fields/relation/relation-many-field.jsx +0 -142
  42. package/dist/fields/relation/relation-picker.jsx +0 -240
  43. package/dist/fields/relation/relation-summary.jsx +0 -45
  44. package/dist/fields/select/select-field.jsx +0 -27
  45. package/dist/fields/sortable-item.jsx +0 -54
  46. package/dist/fields/text/text-field.jsx +0 -79
  47. package/dist/fields/text-area/text-area-field.jsx +0 -79
  48. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  49. package/dist/forms/available-locales-widget.jsx +0 -63
  50. package/dist/forms/document-actions.jsx +0 -486
  51. package/dist/forms/form-context.jsx +0 -598
  52. package/dist/forms/form-modals.jsx +0 -128
  53. package/dist/forms/form-renderer.jsx +0 -532
  54. package/dist/forms/form-status-display.jsx +0 -69
  55. package/dist/forms/navigation-guard.jsx +0 -68
  56. package/dist/forms/nested-path.test.node.js +0 -115
  57. package/dist/forms/path-widget.jsx +0 -125
  58. package/dist/forms/pending-uploads.test.node.js +0 -19
  59. package/dist/forms/repeating-items.test.node.js +0 -29
  60. package/dist/forms/scheduled-publication-control.jsx +0 -389
  61. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  62. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  63. package/dist/forms/status-transitions.test.node.js +0 -76
  64. package/dist/forms/tree-placement-widget.jsx +0 -160
  65. package/dist/forms/upload-executor.test.node.js +0 -368
  66. package/dist/forms/use-form-layout.test.node.js +0 -69
  67. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  68. package/dist/modules/admin-account/components/container.jsx +0 -164
  69. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  70. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  71. package/dist/modules/admin-account/components/update.jsx +0 -186
  72. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  73. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  74. package/dist/modules/admin-roles/components/create.jsx +0 -155
  75. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  76. package/dist/modules/admin-roles/components/update.jsx +0 -148
  77. package/dist/modules/admin-users/components/create.jsx +0 -206
  78. package/dist/modules/admin-users/components/roles.jsx +0 -126
  79. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  80. package/dist/modules/admin-users/components/update.jsx +0 -215
  81. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  82. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  83. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  84. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  85. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  86. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  87. package/dist/presentation/group.jsx +0 -26
  88. package/dist/presentation/row.jsx +0 -23
  89. package/dist/presentation/tabs.jsx +0 -39
  90. package/dist/services/admin-services-context.jsx +0 -17
  91. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  92. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  93. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -1,128 +0,0 @@
1
- 'use client';
2
- /**
3
- * This Source Code is subject to the terms of the Mozilla Public
4
- * License, v. 2.0. If a copy of the MPL was not distributed with this
5
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
- *
7
- * Copyright (c) Infonomic Company Limited
8
- */
9
- import { useTranslation } from '@byline/i18n/react';
10
- import { Button, CloseIcon, IconButton, Modal } from '@byline/ui/react';
11
- import cx from 'clsx';
12
- import styles from './form-renderer.module.css';
13
- /**
14
- * Shown when the editor triggers a guarded action (status change, duplicate,
15
- * copy-to-locale) while the form is dirty. Those actions operate on the saved
16
- * version, so the editor must save first.
17
- */
18
- export const UnsavedChangesModal = ({ onClose }) => {
19
- const { t } = useTranslation('byline-admin');
20
- return (<Modal isOpen={true} closeOnOverlayClick={true} onDismiss={onClose}>
21
- <Modal.Container style={{ maxWidth: '460px' }}>
22
- <Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
23
- <h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
24
- {t('forms.unsavedChanges.title')}
25
- </h3>
26
- </Modal.Header>
27
- <Modal.Content>
28
- <p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
29
- {t('forms.unsavedChanges.message')}
30
- </p>
31
- </Modal.Content>
32
- <Modal.Actions>
33
- <Button size="sm" style={{ minWidth: '60px' }} intent="primary" type="button" onClick={onClose}>
34
- {t('forms.unsavedChanges.okButton')}
35
- </Button>
36
- </Modal.Actions>
37
- </Modal.Container>
38
- </Modal>);
39
- };
40
- /**
41
- * Confirms an immediate, non-versioned write of the document-grain system
42
- * fields (path / advertised locales) — which does NOT reset workflow status.
43
- * When content is also dirty, the copy reassures that content edits still
44
- * follow the normal revision + publish workflow. See docs/08-internationalization/index.md.
45
- */
46
- export const SystemFieldsConfirmModal = ({ contentDirty, pathDirty, availableLocalesDirty, onCancel, onConfirm, }) => {
47
- const { t } = useTranslation('byline-admin');
48
- return (<Modal isOpen={true} closeOnOverlayClick={true} onDismiss={onCancel}>
49
- <Modal.Container style={{ maxWidth: '520px' }}>
50
- <Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
51
- <h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
52
- {contentDirty
53
- ? t('forms.systemFieldsConfirm.bothTitle')
54
- : t('forms.systemFieldsConfirm.title')}
55
- </h3>
56
- <IconButton aria-label={t('common.actions.close')} size="xs" onClick={onCancel}>
57
- <CloseIcon width="16px" height="16px" svgClassName="white-icon"/>
58
- </IconButton>
59
- </Modal.Header>
60
- <Modal.Content className="prose">
61
- {/* Lead with reassurance: content edits follow the normal
62
- revision + publish workflow. The immediate, document-level
63
- system-field write is explained below the divider. */}
64
- {contentDirty && (<p className={cx('byline-form-system-fields-content-note', 'm-0 mt-2')}>
65
- {t('forms.systemFieldsConfirm.contentNote')}
66
- </p>)}
67
- <p className="m-0 mt-2" style={contentDirty
68
- ? {
69
- marginTop: 'var(--spacing-8)',
70
- paddingTop: 'var(--spacing-12)',
71
- borderTop: '1px solid var(--border-color)',
72
- }
73
- : undefined}>
74
- {t('forms.systemFieldsConfirm.intro')}
75
- </p>
76
- <ul className={cx('byline-form-system-fields-list', styles['guard-modal-text'], 'm-0')}>
77
- {pathDirty && <li>{t('forms.systemFieldsConfirm.bulletPath')}</li>}
78
- {availableLocalesDirty && <li>{t('forms.systemFieldsConfirm.bulletLocales')}</li>}
79
- </ul>
80
- <p className={cx('byline-form-system-fields-effect', styles['guard-modal-text'])} style={{
81
- marginTop: 'var(--spacing-4)',
82
- marginBottom: 0,
83
- color: 'var(--text-subtle)',
84
- }}>
85
- {t('forms.systemFieldsConfirm.effectLine')}
86
- </p>
87
- </Modal.Content>
88
- <Modal.Actions>
89
- <Button size="sm" style={{ minWidth: '80px' }} intent="noeffect" type="button" onClick={onCancel}>
90
- {t('common.actions.cancel')}
91
- </Button>
92
- <Button size="sm" style={{ minWidth: '80px' }} intent="primary" type="button" onClick={onConfirm}>
93
- {t('forms.systemFieldsConfirm.confirmButton')}
94
- </Button>
95
- </Modal.Actions>
96
- </Modal.Container>
97
- </Modal>);
98
- };
99
- /**
100
- * Blocks router navigation / browser unload while the form is dirty. Driven by
101
- * the navigation-guard adapter's `isBlocked` state; `onStay` keeps the editor
102
- * on the page, `onProceed` discards and continues.
103
- */
104
- export const NavigationGuardModal = ({ onStay, onProceed, }) => {
105
- const { t } = useTranslation('byline-admin');
106
- return (<Modal isOpen={true} closeOnOverlayClick={false} onDismiss={onStay}>
107
- <Modal.Container style={{ maxWidth: '460px' }}>
108
- <Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
109
- <h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
110
- {t('forms.navigationGuard.title')}
111
- </h3>
112
- </Modal.Header>
113
- <Modal.Content>
114
- <p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
115
- {t('forms.navigationGuard.message')}
116
- </p>
117
- </Modal.Content>
118
- <Modal.Actions>
119
- <Button size="sm" intent="noeffect" type="button" onClick={onStay}>
120
- {t('forms.navigationGuard.stayButton')}
121
- </Button>
122
- <Button size="sm" intent="danger" type="button" onClick={onProceed}>
123
- {t('forms.navigationGuard.leaveButton')}
124
- </Button>
125
- </Modal.Actions>
126
- </Modal.Container>
127
- </Modal>);
128
- };
@@ -1,532 +0,0 @@
1
- 'use client';
2
- /**
3
- * This Source Code is subject to the terms of the Mozilla Public
4
- * License, v. 2.0. If a copy of the MPL was not distributed with this
5
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
- *
7
- * Copyright (c) Infonomic Company Limited
8
- */
9
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
- import { getAdminConfig } from '@byline/core';
11
- import { useTranslation } from '@byline/i18n/react';
12
- import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react';
13
- import cx from 'clsx';
14
- import { sliceFieldAdmin } from '../fields/field-admin';
15
- import { FieldRenderer } from '../fields/field-renderer';
16
- import { useBylineFieldServices } from '../fields/field-services-context';
17
- import { AdminGroup } from '../presentation/group';
18
- import { AdminRow } from '../presentation/row';
19
- import { AdminTabs } from '../presentation/tabs';
20
- import { AvailableLocalesWidget } from './available-locales-widget';
21
- import { DocumentActions } from './document-actions';
22
- import { FormProvider, useFieldValue, useFormContext } from './form-context';
23
- import { NavigationGuardModal, SystemFieldsConfirmModal, UnsavedChangesModal } from './form-modals';
24
- import styles from './form-renderer.module.css';
25
- import { FormStatusDisplay } from './form-status-display';
26
- import { useNavigationGuardAdapter } from './navigation-guard';
27
- import { PathWidget } from './path-widget';
28
- import { ScheduledPublicationCell, ScheduledPublicationNotice, useScheduledPublication, } from './scheduled-publication-control';
29
- import { computeStatusTransitions } from './status-transitions';
30
- import { TreePlacementWidget } from './tree-placement-widget';
31
- import { executeUploadsWithProgress } from './upload-executor';
32
- import { useFormLayout } from './use-form-layout';
33
- 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, }) => {
34
- const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getDirtyBreakdown, getSystemPath, getSystemAvailableLocales, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading, } = useFormContext();
35
- const { t } = useTranslation('byline-admin');
36
- const [errors, setErrors] = useState(initialErrors);
37
- const [hasChanges, setHasChanges] = useState(hasChangesFn());
38
- const [statusBusy, setStatusBusy] = useState(false);
39
- const [isUploading, setIsUploading] = useState(false);
40
- const submittingRef = useRef(false);
41
- const [isSubmitting, setIsSubmitting] = useState(false);
42
- const isBusy = isUploading || isSubmitting;
43
- const formRef = useRef(null);
44
- const focusBeforeBusyRef = useRef(null);
45
- const restoreFocusAfterBusyRef = useRef(false);
46
- // Block-only "save first" guard. Set true when the editor triggers a
47
- // guarded action (status change, duplicate, copy-to-locale) while the form
48
- // is dirty — those actions operate on the saved version, so unsaved edits
49
- // would be silently excluded.
50
- const [showUnsavedModal, setShowUnsavedModal] = useState(false);
51
- // Holds the pending Save payload while the editor confirms an immediate,
52
- // non-versioned system-field write (path / advertised locales). Non-null
53
- // means the confirmation modal is open. See docs/08-internationalization/index.md.
54
- const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] = useState(null);
55
- const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale);
56
- // Scheduled publication owns three placements — a status-bar cell, an
57
- // escalated notice, and the schedule modal — so its state lives here and the
58
- // surfaces are rendered where each belongs.
59
- const scheduling = useScheduledPublication({
60
- disabled: mutationsBlocked,
61
- schedule: scheduledPublication ?? null,
62
- onSchedule: onSchedulePublication,
63
- onConfirm: onConfirmScheduledPublication,
64
- onCancel: onCancelScheduledPublication,
65
- hasUnsavedChanges: hasChanges,
66
- onUnsavedChanges: () => setShowUnsavedModal(true),
67
- });
68
- const { uploadField } = useBylineFieldServices();
69
- // Path-widget wiring. The live preview must use the installation's
70
- // client-side slugifier (same function as `ServerConfig.slugifier`) so it
71
- // agrees with the persisted path. And when the `useAsPath` source is a
72
- // server-assigned `counter` or a read-only field, its value can't be
73
- // reproduced or changed through the form, so the widget suppresses its
74
- // source-derived preview and "Regenerate" affordance.
75
- const pathSlugifier = getAdminConfig().slugifier;
76
- const pathSourceLocked = useMemo(() => {
77
- if (!useAsPath)
78
- return false;
79
- const source = fields.find((f) => f.name === useAsPath);
80
- return source != null && (source.type === 'counter' || source.readOnly === true);
81
- }, [useAsPath, fields]);
82
- // Sync contentLocale when the route re-fetches with a different locale.
83
- useEffect(() => {
84
- if (initialLocale)
85
- setContentLocale(initialLocale);
86
- }, [initialLocale]);
87
- // Layout primitives + lookup tables — pure derivations of `adminConfig` +
88
- // `fields`. The validator at startup guarantees every reachable name
89
- // resolves and every schema field is placed at most once, so the render-time
90
- // lookups below are unguarded. See ./use-form-layout.
91
- const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } = useFormLayout(adminConfig, fields);
92
- // ---------------------------------------------------------------------
93
- // Active-tab state — one tab name per declared tab set.
94
- // Lifted into FormRenderer via `_activeTabBySet` / `_onTabChange` so the
95
- // user's tab choices survive the locale-change remount triggered by
96
- // FormProvider's `key` prop.
97
- // ---------------------------------------------------------------------
98
- const tabSets = adminConfig?.tabSets ?? [];
99
- const initialActiveTabBySet = useMemo(() => {
100
- const result = {};
101
- for (const set of tabSets) {
102
- const saved = _activeTabBySet?.[set.name];
103
- if (saved && set.tabs.some((t) => t.name === saved)) {
104
- result[set.name] = saved;
105
- }
106
- else {
107
- result[set.name] = set.tabs[0]?.name ?? '';
108
- }
109
- }
110
- return result;
111
- // initial-only; subsequent updates flow through setActiveTabBySet.
112
- // eslint-disable-next-line react-hooks/exhaustive-deps
113
- }, [tabSets, _activeTabBySet]);
114
- const [activeTabBySet, setActiveTabBySet] = useState(initialActiveTabBySet);
115
- const handleTabChange = useCallback((tabSetName, tabName) => {
116
- setActiveTabBySet((prev) => ({ ...prev, [tabSetName]: tabName }));
117
- _onTabChange?.(tabSetName, tabName);
118
- }, [_onTabChange]);
119
- // Track live form data so TabDefinition.condition functions can react to
120
- // field changes. Re-evaluated per keystroke via the meta-subscribe loop.
121
- const [formData, setFormData] = useState(() => getFieldValues());
122
- // Live document heading — tracks the useAsTitle field as the user types
123
- const liveTitle = useFieldValue(useAsTitle ?? '');
124
- const computedHeading = heading ||
125
- liveTitle ||
126
- (headingLabel
127
- ? mode === 'create'
128
- ? t('forms.heading.createLabel', { label: headingLabel })
129
- : t('forms.heading.editLabel', { label: headingLabel })
130
- : mode === 'create'
131
- ? t('forms.heading.create')
132
- : t('forms.heading.edit'));
133
- // Navigation guard — block router navigation and browser unload when dirty.
134
- // The guard hook is injected by the consuming framework (prop > context > no-op fallback).
135
- const guardFromContext = useNavigationGuardAdapter();
136
- const useGuard = useNavigationGuardProp ?? guardFromContext;
137
- const [discarding, setDiscarding] = useState(false);
138
- const [reloadFailed, setReloadFailed] = useState(false);
139
- const warningRef = useRef(null);
140
- const mutationBlockedRef = useRef(mutationsBlocked);
141
- mutationBlockedRef.current = mutationsBlocked || discarding;
142
- const guard = useGuard(hasChanges && !discarding);
143
- useEffect(() => {
144
- if (mutationIssue)
145
- warningRef.current?.focus();
146
- }, [mutationIssue]);
147
- useEffect(() => {
148
- if (!discarding)
149
- return;
150
- // Let the guard's beforeunload listener detach before the explicit discard.
151
- Promise.resolve()
152
- .then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
153
- .catch(() => {
154
- setDiscarding(false);
155
- setReloadFailed(true);
156
- });
157
- }, [discarding, onReloadDocument]);
158
- // Compute available status transitions
159
- const currentStatus = initialData?.status;
160
- const { primaryStatus, secondaryStatuses, isTerminal } = computeStatusTransitions(currentStatus, workflowStatuses, nextStatus);
161
- useEffect(() => {
162
- return subscribeErrors((newErrors) => setErrors(newErrors));
163
- }, [subscribeErrors]);
164
- useEffect(() => {
165
- return subscribeMeta(() => setHasChanges(hasChangesFn()));
166
- }, [subscribeMeta, hasChangesFn]);
167
- // Keep formData in sync for evaluating TabDefinition.condition functions
168
- useEffect(() => {
169
- return subscribeMeta(() => setFormData(getFieldValues()));
170
- }, [subscribeMeta, getFieldValues]);
171
- // `inert` removes the active control from the tab order while a save is in
172
- // flight. Restore the editor's position after React has removed `inert`;
173
- // when the original control became disabled, fall back to the first usable
174
- // form control instead of leaving focus on <body>.
175
- useEffect(() => {
176
- if (isBusy || !restoreFocusAfterBusyRef.current)
177
- return;
178
- restoreFocusAfterBusyRef.current = false;
179
- if (mutationIssue) {
180
- warningRef.current?.focus();
181
- focusBeforeBusyRef.current = null;
182
- return;
183
- }
184
- const original = focusBeforeBusyRef.current;
185
- focusBeforeBusyRef.current = null;
186
- const originalCanReceiveFocus = original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]');
187
- const target = originalCanReceiveFocus
188
- ? original
189
- : formRef.current?.querySelector('input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])');
190
- target?.focus({ preventScroll: true });
191
- }, [isBusy, mutationIssue]);
192
- const captureFocusBeforeBusy = useCallback(() => {
193
- if (focusBeforeBusyRef.current != null)
194
- return;
195
- const activeElement = document.activeElement;
196
- if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement))
197
- return;
198
- focusBeforeBusyRef.current = activeElement;
199
- restoreFocusAfterBusyRef.current = true;
200
- }, []);
201
- const handleCancel = () => {
202
- if (onCancel && typeof onCancel === 'function') {
203
- onCancel();
204
- }
205
- };
206
- // Await the host handler. Resolution means the save succeeded and the clean
207
- // baseline can be committed; rejection preserves dirty state so the editor
208
- // does not lose work and the navigation guard keeps blocking. Host handlers
209
- // that surface their own toast MUST rethrow afterwards.
210
- // Re-entry guard lives in a ref so the callback identity does not change
211
- // mid-flight; the mirrored state drives the Save button's disabled prop.
212
- const submitPayload = useCallback(async (payload) => {
213
- if (mutationBlockedRef.current || typeof onSubmit !== 'function')
214
- return;
215
- if (submittingRef.current)
216
- return;
217
- submittingRef.current = true;
218
- captureFocusBeforeBusy();
219
- setIsSubmitting(true);
220
- try {
221
- await onSubmit(payload);
222
- resetHasChanges();
223
- }
224
- catch {
225
- // Intentionally swallowed here — the host has already reported the
226
- // failure to the user. Dirty state is preserved by not resetting.
227
- }
228
- finally {
229
- submittingRef.current = false;
230
- setIsSubmitting(false);
231
- }
232
- }, [captureFocusBeforeBusy, onSubmit, resetHasChanges]);
233
- const handleSubmit = (e) => {
234
- if (mutationBlockedRef.current) {
235
- e.preventDefault();
236
- return;
237
- }
238
- e.preventDefault();
239
- // Run field-level beforeValidate hooks (submit-time), then validate
240
- void (async () => {
241
- const hookErrors = await runFieldHooks(fields);
242
- const formErrors = validateForm(fields);
243
- const allErrors = [...hookErrors, ...formErrors];
244
- if (allErrors.length > 0) {
245
- console.error('Form validation failed:', allErrors);
246
- return;
247
- }
248
- if (mutationBlockedRef.current)
249
- return;
250
- // Execute any pending uploads before submitting
251
- const pendingUploads = getPendingUploads();
252
- if (pendingUploads.size > 0) {
253
- captureFocusBeforeBusy();
254
- setIsUploading(true);
255
- try {
256
- const uploadResult = await executeUploadsWithProgress(pendingUploads, uploadField, ({ fieldPath, status }) => {
257
- setFieldUploading(fieldPath, status === 'uploading');
258
- }, {
259
- // Document context for server-side upload hooks: the persisted
260
- // document id (edit mode only) plus any `upload.context` form
261
- // values declared on the schema field. See UploadConfig.context
262
- // in @byline/core.
263
- documentId: mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
264
- fields,
265
- getFormValues: getFieldValues,
266
- });
267
- // Check for upload errors
268
- if (!uploadResult.allSucceeded) {
269
- // Set field-level errors for failed uploads
270
- for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
271
- setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }));
272
- }
273
- console.error('One or more uploads failed:', uploadResult.errors);
274
- setIsUploading(false);
275
- return;
276
- }
277
- // Replace pending StoredFileValues with real ones in form data
278
- for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
279
- setFieldValue(fieldPath, storedFile);
280
- }
281
- // Clear pending uploads (blob URLs already revoked by clearPendingUploads)
282
- clearPendingUploads();
283
- }
284
- catch (err) {
285
- console.error('Upload execution error:', err);
286
- setIsUploading(false);
287
- return;
288
- }
289
- setIsUploading(false);
290
- }
291
- const data = getFieldValues();
292
- const patches = getPatches();
293
- const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown();
294
- const systemPath = getSystemPath();
295
- // Only emit the advertised-locale set for collections that opted into the
296
- // widget — otherwise leave it undefined so the write path never touches
297
- // `byline_document_available_locales` for non-advertising collections.
298
- const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : undefined;
299
- const payload = {
300
- data,
301
- patches,
302
- contentDirty,
303
- pathDirty,
304
- systemPath,
305
- availableLocalesDirty,
306
- systemAvailableLocales,
307
- };
308
- // Editing the document-grain system fields (path / advertised locales) is
309
- // an immediate, non-versioned write that does NOT reset workflow status,
310
- // so confirm it before saving. Create mode writes everything as part of
311
- // the initial version, so no confirmation applies there.
312
- if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
313
- setPendingSystemFieldsSubmit(payload);
314
- return;
315
- }
316
- await submitPayload(payload);
317
- })();
318
- };
319
- // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
320
- // Each <Tabs> bar consumes its own slice.
321
- const tabErrorCountsBySet = useMemo(() => {
322
- const result = {};
323
- for (const err of errors) {
324
- const path = fieldToTabPath.get(err.field);
325
- if (!path)
326
- continue;
327
- result[path.tabSetName] ??= {};
328
- result[path.tabSetName][path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1;
329
- }
330
- return result;
331
- }, [errors, fieldToTabPath]);
332
- // -------------------------------------------------------------------
333
- // Layout walk — recursively dispatches each name in a region to the
334
- // appropriate primitive renderer or to <FieldRenderer>.
335
- // -------------------------------------------------------------------
336
- const renderField = (fieldName) => {
337
- const field = fieldByName.get(fieldName);
338
- if (!field)
339
- return null;
340
- return (<FieldRenderer key={field.name} field={field} defaultValue={initialData?.fields?.[field.name]} contentLocale={contentLocale} components={adminConfig?.fields?.[field.name]?.components} editor={adminConfig?.fields?.[field.name]?.editor} fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}/>);
341
- };
342
- const renderItem = (name) => {
343
- const tabSet = tabSetByName.get(name);
344
- if (tabSet)
345
- return renderTabSet(tabSet);
346
- const group = groupByName.get(name);
347
- if (group)
348
- return renderGroup(group);
349
- const row = rowByName.get(name);
350
- if (row)
351
- return renderRow(row);
352
- return renderField(name);
353
- };
354
- const renderRow = (row) => (<AdminRow key={`row:${row.name}`}>{row.fields.map((name) => renderField(name))}</AdminRow>);
355
- const renderGroup = (group) => (<AdminGroup key={`group:${group.name}`} label={group.label}>
356
- {group.fields.map((name) => renderItem(name))}
357
- </AdminGroup>);
358
- const renderTabSet = (set) => {
359
- const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData));
360
- const requested = activeTabBySet[set.name] ?? '';
361
- const resolvedActive = visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
362
- ? (visibleTabs[0]?.name ?? requested)
363
- : requested;
364
- const activeTab = visibleTabs.find((t) => t.name === resolvedActive);
365
- return (<div key={`tabset:${set.name}`} className={cx('byline-form-tabset', styles.tabset)}>
366
- {visibleTabs.length > 0 && (<AdminTabs tabs={visibleTabs} activeTab={resolvedActive} onChange={(tabName) => handleTabChange(set.name, tabName)} errorCounts={tabErrorCountsBySet[set.name]} className={cx('byline-form-tabset-tabs', styles['tabset-tabs'])}/>)}
367
- {activeTab && (<div className={cx('byline-form-tabset-fields', styles['tabset-fields'])}>
368
- {activeTab.fields.map((name) => renderItem(name))}
369
- </div>)}
370
- </div>);
371
- };
372
- const busyAnnouncement = isUploading
373
- ? t('forms.actions.uploading')
374
- : isSubmitting
375
- ? t('forms.actions.saving')
376
- : '';
377
- return (<>
378
- <span className={cx('byline-form-busy-status', styles['busy-status'])} role="status" aria-live="polite">
379
- {busyAnnouncement}
380
- </span>
381
- <div aria-busy={isBusy}>
382
- <form ref={formRef} method="post" noValidate onSubmit={handleSubmit} className={cx('byline-form', styles.form)} inert={isBusy ? true : undefined}>
383
- <div className={cx('byline-form-heading-row', styles['heading-row'])}>
384
- <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
385
- {/* Source-locale anchor indicator removed pending heading-layout work.
386
- To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
387
- here from `initialData.sourceLocale` (mismatch-only is the intended
388
- end state). See docs/08-internationalization/index.md. */}
389
- {headerSlot}
390
- </div>
391
- <div className={cx('byline-form-status-bar', styles['status-bar'])}>
392
- <div className={cx('byline-form-status-details', styles['status-details'])}>
393
- <FormStatusDisplay disabled={mutationsBlocked || discarding} initialData={initialData} workflowStatuses={workflowStatuses} publishedVersion={publishedVersion} onUnpublish={onUnpublish} afterStatusCells={<ScheduledPublicationCell state={scheduling.state} timeZone={scheduling.timeZone}/>}/>
394
- </div>
395
- <div className={cx('byline-form-actions', styles.actions)}>
396
- <Button className={cx('byline-form-actions-button', styles['actions-button'])} size="sm" intent="noeffect" type="button" onClick={handleCancel}>
397
- {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
398
- </Button>
399
- <Button className={cx('byline-form-actions-button', styles['actions-button'])} size="sm" type="submit" disabled={mutationsBlocked ||
400
- discarding ||
401
- hasChanges === false ||
402
- isUploading ||
403
- isSubmitting} aria-label={isSubmitting ? t('common.actions.save') : undefined}>
404
- {isUploading ? (t('forms.actions.uploading')) : (<span className={cx('byline-form-save-content', styles['save-content'])}>
405
- <span className={cx('byline-form-save-label', styles['save-label'], isSubmitting && styles['save-label-hidden'])}>
406
- {t('common.actions.save')}
407
- </span>
408
- {isSubmitting ? (<span className={cx('byline-form-save-loader', styles['save-loader'])}>
409
- <LoaderEllipsis size={28} aria-hidden="true"/>
410
- </span>) : null}
411
- </span>)}
412
- </Button>
413
- {primaryStatus && onStatusChange && (<div className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}>
414
- <ComboButton buttonClassName={cx('byline-form-actions-combo-button', styles['actions-combo-button'])} triggerClassName={cx('byline-form-actions-combo-trigger', styles['actions-combo-trigger'])} options={secondaryStatuses.map((s) => ({
415
- label: isTerminal
416
- ? t('forms.actions.revertTo', { label: s.label ?? s.name })
417
- : (s.verb ?? s.label ?? s.name),
418
- value: s.name,
419
- }))} sideOffset={5} size="sm" type="button" intent={isTerminal ? 'info' : 'success'} disabled={mutationsBlocked || discarding || statusBusy} onOptionSelect={async (value) => {
420
- if (mutationBlockedRef.current)
421
- return;
422
- if (hasChanges) {
423
- setShowUnsavedModal(true);
424
- return;
425
- }
426
- setStatusBusy(true);
427
- try {
428
- await onStatusChange(value);
429
- }
430
- catch (error) {
431
- onMutationError?.(error);
432
- }
433
- finally {
434
- setStatusBusy(false);
435
- }
436
- }} onButtonClick={isTerminal
437
- ? undefined
438
- : async () => {
439
- if (mutationBlockedRef.current)
440
- return;
441
- if (hasChanges) {
442
- setShowUnsavedModal(true);
443
- return;
444
- }
445
- setStatusBusy(true);
446
- try {
447
- await onStatusChange(primaryStatus.name);
448
- }
449
- catch (error) {
450
- onMutationError?.(error);
451
- }
452
- finally {
453
- setStatusBusy(false);
454
- }
455
- }}>
456
- {statusBusy
457
- ? '...'
458
- : isTerminal
459
- ? (primaryStatus.label ?? primaryStatus.name)
460
- : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
461
- </ComboButton>
462
- </div>)}
463
- <DocumentActions disabled={mutationsBlocked || discarding} publishedVersion={publishedVersion} onUnpublish={onUnpublish} onDelete={onDelete} onDuplicate={onDuplicate} sourceTitle={useAsTitle != null && initialData != null
464
- ? initialData[useAsTitle]
465
- : null} onCopyToLocale={onCopyToLocale} sourceLocale={contentLocale} contentLocales={contentLocales} hasUnsavedChanges={hasChanges} onUnsavedChanges={() => setShowUnsavedModal(true)} onDeleteLocale={onDeleteLocale} defaultLocale={defaultLocale} availableLocales={initialData?._availableVersionLocales} scheduledPublicationState={scheduling.state} onSchedulePublication={scheduling.openSchedule} onConfirmScheduledPublication={scheduling.confirm} onCancelScheduledPublication={scheduling.cancel}/>
466
- </div>
467
- </div>
468
- {(mutationIssue || scheduledPublicationsNeedReconfirmation) && (<div ref={warningRef} tabIndex={-1} role="alert" aria-live="assertive" className={cx('byline-document-concurrency', styles.concurrency)}>
469
- {mutationIssue && (<Alert intent="warning" icon close={false} title={t(`documentConcurrency.${mutationIssue}Title`)}>
470
- <p>{t(`documentConcurrency.${mutationIssue}`)}</p>
471
- {mutationIssue !== 'committed' && (<Button type="button" disabled={discarding} onClick={() => {
472
- setReloadFailed(false);
473
- setDiscarding(true);
474
- }}>
475
- {t('documentConcurrency.reloadAction')}
476
- </Button>)}
477
- {reloadFailed && <p>{t('documentConcurrency.reloadFailed')}</p>}
478
- </Alert>)}
479
- {scheduledPublicationsNeedReconfirmation && (<Alert intent="warning" icon close={false} title={t('documentConcurrency.schedulesTitle')}>
480
- <p>{t('documentConcurrency.schedules')}</p>
481
- {scheduledPublicationsHref && (<a href={scheduledPublicationsHref}>
482
- {t('documentConcurrency.reviewSchedules')}
483
- </a>)}
484
- </Alert>)}
485
- </div>)}
486
- <ScheduledPublicationNotice state={scheduling.state} timeZone={scheduling.timeZone} busy={mutationsBlocked || discarding || scheduling.busy} onConfirm={scheduling.confirm} onReschedule={scheduling.openSchedule} onCancel={scheduling.cancel}/>
487
- {scheduling.modal}
488
- {restoreWarnings && restoreWarnings.length > 0 && (<Alert className="m-0 mt-4" intent="warning" icon={true} close={false} title={t('forms.restoreWarnings.title')}>
489
- <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
490
- <ul>
491
- {restoreWarnings.map((w) => (<li key={w}>{w}</li>))}
492
- </ul>
493
- </Alert>)}
494
- <div className={cx('byline-form-layout', styles.layout)}>
495
- <div className={cx('byline-form-content', styles.content)}>
496
- {layout.main.map((name) => renderItem(name))}
497
- </div>
498
- <div className={cx('byline-form-sidebar', styles.sidebar)}>
499
- {showPath &&
500
- (useAsPath ||
501
- (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (<PathWidget disabled={mutationsBlocked || discarding} useAsPath={useAsPath} collectionPath={collectionPath ?? ''} defaultLocale={defaultLocale} activeLocale={contentLocale} mode={mode} slugifier={pathSlugifier} sourceLocked={pathSourceLocked}/>)}
502
- {tree && mode === 'edit' && typeof initialData?.id === 'string' && (<TreePlacementWidget disabled={mutationsBlocked || discarding} onMutationError={onMutationError} onCommitted={onTreeMutationCommitted} expectedRevision={observedRevision ?? initialData.revision} collectionPath={collectionPath ?? ''} documentId={initialData.id} useAsTitle={useAsTitle}/>)}
503
- {advertiseLocales && (<AvailableLocalesWidget disabled={mutationsBlocked || discarding} contentLocales={contentLocales ?? []} availableVersionLocales={initialData?._availableVersionLocales ?? []}/>)}
504
- {(layout.sidebar ?? []).map((name) => renderItem(name))}
505
- </div>
506
- </div>
507
- {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)}/>}
508
- {!mutationsBlocked && !discarding && pendingSystemFieldsSubmit != null && (<SystemFieldsConfirmModal contentDirty={pendingSystemFieldsSubmit.contentDirty} pathDirty={pendingSystemFieldsSubmit.pathDirty} availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty} onCancel={() => setPendingSystemFieldsSubmit(null)} onConfirm={() => {
509
- const payload = pendingSystemFieldsSubmit;
510
- setPendingSystemFieldsSubmit(null);
511
- void submitPayload(payload);
512
- }}/>)}
513
- {guard.isBlocked && (<NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed}/>)}
514
- </form>
515
- </div>
516
- </>);
517
- };
518
- export const FormRenderer = (props) => {
519
- const { mode, initialData, initialLocale, collectionPath } = props;
520
- // Persists per-tab-set active tab across locale-change remounts of FormContent.
521
- // useRef so mutations never trigger a re-render of FormRenderer itself.
522
- const savedTabsRef = useRef({});
523
- return (<FormProvider key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`} initialData={initialData} documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null} collectionPath={collectionPath ?? null}>
524
- {/* Forwarded wholesale. An enumerated prop list here silently dropped
525
- every prop added to FormRendererProps but not copied down — which is
526
- how the scheduled-publication handlers reached FormContent as
527
- `undefined` and the control never rendered. */}
528
- <FormContent {...props} _activeTabBySet={savedTabsRef.current} _onTabChange={(tabSetName, tabName) => {
529
- savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName };
530
- }}/>
531
- </FormProvider>);
532
- };