@byline/admin 5.1.4 → 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.
- package/package.json +14 -14
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- 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
|
-
};
|