@byline/admin 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/forms/form-context.d.ts +23 -0
- package/dist/forms/form-context.js +25 -4
- package/dist/forms/form-renderer.d.ts +17 -0
- package/dist/forms/form-renderer.js +121 -11
- package/dist/modules/admin-permissions/schemas.d.ts +4 -4
- package/package.json +5 -5
- package/src/forms/form-context.tsx +62 -4
- package/src/forms/form-renderer.tsx +150 -4
|
@@ -30,6 +30,23 @@ type MetaListener = () => void;
|
|
|
30
30
|
type SystemPathListener = (value: string | null) => void;
|
|
31
31
|
type SystemAvailableLocalesListener = (value: string[]) => void;
|
|
32
32
|
type FieldUploadingListener = (uploading: boolean) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Why the form is dirty, partitioned by write semantics — drives the single
|
|
35
|
+
* Save button. `content` mints a new version (normal workflow). `direct-write`
|
|
36
|
+
* is an immediate, non-versioned write of the document-grain system fields
|
|
37
|
+
* (path / advertised locales) that does NOT reset workflow status. `both` does
|
|
38
|
+
* each through its own write path. See docs/I18N.md.
|
|
39
|
+
*/
|
|
40
|
+
export type DirtyReason = 'none' | 'content' | 'direct-write' | 'both';
|
|
41
|
+
export interface DirtyBreakdown {
|
|
42
|
+
reason: DirtyReason;
|
|
43
|
+
/** Document field data / patches changed → versioned write. */
|
|
44
|
+
contentDirty: boolean;
|
|
45
|
+
/** Path widget changed → non-versioned direct write. */
|
|
46
|
+
pathDirty: boolean;
|
|
47
|
+
/** Available-locales widget changed → non-versioned direct write. */
|
|
48
|
+
availableLocalesDirty: boolean;
|
|
49
|
+
}
|
|
33
50
|
interface FormContextType {
|
|
34
51
|
setFieldValue: (name: string, value: any) => void;
|
|
35
52
|
setFieldStore: (name: string, value: any) => void;
|
|
@@ -48,6 +65,12 @@ interface FormContextType {
|
|
|
48
65
|
setFieldError: (field: string, message: string) => void;
|
|
49
66
|
clearFieldError: (field: string) => void;
|
|
50
67
|
isDirty: (fieldName: string) => boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Partition the current dirty state into content vs. system-field (path /
|
|
70
|
+
* advertised-locales) writes so the Save button can branch. See
|
|
71
|
+
* docs/I18N.md.
|
|
72
|
+
*/
|
|
73
|
+
getDirtyBreakdown: () => DirtyBreakdown;
|
|
51
74
|
subscribeField: (name: string, listener: FieldListener) => () => void;
|
|
52
75
|
subscribeErrors: (listener: ErrorsListener) => () => void;
|
|
53
76
|
subscribeMeta: (listener: MetaListener) => () => void;
|
|
@@ -13,6 +13,8 @@ const sameLocaleSet = (a, b)=>{
|
|
|
13
13
|
].sort();
|
|
14
14
|
return sa.every((v, i)=>v === sb[i]);
|
|
15
15
|
};
|
|
16
|
+
const SYSTEM_PATH_DIRTY_KEY = '__systemPath__';
|
|
17
|
+
const SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY = '__systemAvailableLocales__';
|
|
16
18
|
const FormContext = /*#__PURE__*/ createContext(null);
|
|
17
19
|
const useFormContext = ()=>{
|
|
18
20
|
const context = useContext(FormContext);
|
|
@@ -159,11 +161,29 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
159
161
|
notifyMetaListeners
|
|
160
162
|
]);
|
|
161
163
|
const isDirty = useCallback((fieldName)=>dirtyFields.current.has(fieldName), []);
|
|
164
|
+
const getDirtyBreakdown = useCallback(()=>{
|
|
165
|
+
const keys = dirtyFields.current;
|
|
166
|
+
const pathDirty = keys.has(SYSTEM_PATH_DIRTY_KEY);
|
|
167
|
+
const availableLocalesDirty = keys.has(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY);
|
|
168
|
+
let contentDirty = false;
|
|
169
|
+
for (const key of keys)if (key !== SYSTEM_PATH_DIRTY_KEY && key !== SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY) {
|
|
170
|
+
contentDirty = true;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
const directWrite = pathDirty || availableLocalesDirty;
|
|
174
|
+
const reason = contentDirty && directWrite ? 'both' : contentDirty ? 'content' : directWrite ? 'direct-write' : 'none';
|
|
175
|
+
return {
|
|
176
|
+
reason,
|
|
177
|
+
contentDirty,
|
|
178
|
+
pathDirty,
|
|
179
|
+
availableLocalesDirty
|
|
180
|
+
};
|
|
181
|
+
}, []);
|
|
162
182
|
const getSystemPath = useCallback(()=>systemPathRef.current, []);
|
|
163
183
|
const setSystemPath = useCallback((value)=>{
|
|
164
184
|
systemPathRef.current = value;
|
|
165
|
-
if (value !== initialSystemPath.current) dirtyFields.current.add(
|
|
166
|
-
else dirtyFields.current.delete(
|
|
185
|
+
if (value !== initialSystemPath.current) dirtyFields.current.add(SYSTEM_PATH_DIRTY_KEY);
|
|
186
|
+
else dirtyFields.current.delete(SYSTEM_PATH_DIRTY_KEY);
|
|
167
187
|
systemPathListeners.current.forEach((listener)=>{
|
|
168
188
|
listener(value);
|
|
169
189
|
});
|
|
@@ -183,8 +203,8 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
183
203
|
...value
|
|
184
204
|
];
|
|
185
205
|
systemAvailableLocalesRef.current = next;
|
|
186
|
-
if (sameLocaleSet(next, initialSystemAvailableLocales.current)) dirtyFields.current.delete(
|
|
187
|
-
else dirtyFields.current.add(
|
|
206
|
+
if (sameLocaleSet(next, initialSystemAvailableLocales.current)) dirtyFields.current.delete(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY);
|
|
207
|
+
else dirtyFields.current.add(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY);
|
|
188
208
|
systemAvailableLocalesListeners.current.forEach((listener)=>{
|
|
189
209
|
listener(next);
|
|
190
210
|
});
|
|
@@ -406,6 +426,7 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
406
426
|
setFieldError,
|
|
407
427
|
clearFieldError,
|
|
408
428
|
isDirty,
|
|
429
|
+
getDirtyBreakdown,
|
|
409
430
|
subscribeField,
|
|
410
431
|
subscribeErrors,
|
|
411
432
|
subscribeMeta,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type ReactNode } from 'react';
|
|
9
9
|
import type { CollectionAdminConfig, Field, WorkflowStatus } from '@byline/core';
|
|
10
|
+
import type { DocumentPatch } from '@byline/core/patches';
|
|
10
11
|
import { type DocumentActionsLocaleOption } from './document-actions';
|
|
11
12
|
import type { UseNavigationGuard } from './navigation-guard';
|
|
12
13
|
/** Metadata about a previously published version that is still live. */
|
|
@@ -17,6 +18,22 @@ export interface PublishedVersionInfo {
|
|
|
17
18
|
createdAt: string | Date;
|
|
18
19
|
updatedAt: string | Date;
|
|
19
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Payload emitted by the form on Save. Carries the content (field data +
|
|
23
|
+
* patches) alongside the document-grain system fields (path / advertised
|
|
24
|
+
* locales) and per-bucket dirty flags so the host can route each piece to the
|
|
25
|
+
* right write path — versioned for content, immediate/non-versioned for the
|
|
26
|
+
* system fields. See docs/I18N.md.
|
|
27
|
+
*/
|
|
28
|
+
export interface SystemFieldsSubmitPayload {
|
|
29
|
+
data: any;
|
|
30
|
+
patches: DocumentPatch[];
|
|
31
|
+
contentDirty: boolean;
|
|
32
|
+
pathDirty: boolean;
|
|
33
|
+
systemPath?: string | null;
|
|
34
|
+
availableLocalesDirty: boolean;
|
|
35
|
+
systemAvailableLocales?: string[];
|
|
36
|
+
}
|
|
20
37
|
/** Props shared by both the public FormRenderer and its internal FormContent component. */
|
|
21
38
|
export interface FormRendererProps {
|
|
22
39
|
mode: 'create' | 'edit';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
-
import { Alert, Button, ComboButton, Modal } from "@byline/ui/react";
|
|
5
|
+
import { Alert, Button, CloseIcon, ComboButton, IconButton, Modal } from "@byline/ui/react";
|
|
6
6
|
import classnames from "classnames";
|
|
7
7
|
import { FieldRenderer } from "../fields/field-renderer.js";
|
|
8
8
|
import { useBylineFieldServices } from "../fields/field-services-context.js";
|
|
@@ -141,13 +141,14 @@ function computeStatusTransitions(currentStatus, workflowStatuses, nextStatus) {
|
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
|
|
144
|
-
const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getSystemPath, getSystemAvailableLocales, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
|
|
144
|
+
const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getDirtyBreakdown, getSystemPath, getSystemAvailableLocales, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
|
|
145
145
|
const { t } = useTranslation('byline-admin');
|
|
146
146
|
const [errors, setErrors] = useState(initialErrors);
|
|
147
147
|
const [hasChanges, setHasChanges] = useState(hasChangesFn());
|
|
148
148
|
const [statusBusy, setStatusBusy] = useState(false);
|
|
149
149
|
const [isUploading, setIsUploading] = useState(false);
|
|
150
150
|
const [showUnsavedModal, setShowUnsavedModal] = useState(false);
|
|
151
|
+
const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] = useState(null);
|
|
151
152
|
const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale);
|
|
152
153
|
const { uploadField } = useBylineFieldServices();
|
|
153
154
|
useEffect(()=>{
|
|
@@ -267,6 +268,15 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
267
268
|
const handleCancel = ()=>{
|
|
268
269
|
if (onCancel && 'function' == typeof onCancel) onCancel();
|
|
269
270
|
};
|
|
271
|
+
const submitPayload = useCallback((payload)=>{
|
|
272
|
+
if (onSubmit && 'function' == typeof onSubmit) {
|
|
273
|
+
onSubmit(payload);
|
|
274
|
+
resetHasChanges();
|
|
275
|
+
}
|
|
276
|
+
}, [
|
|
277
|
+
onSubmit,
|
|
278
|
+
resetHasChanges
|
|
279
|
+
]);
|
|
270
280
|
const handleSubmit = (e)=>{
|
|
271
281
|
e.preventDefault();
|
|
272
282
|
(async ()=>{
|
|
@@ -303,17 +313,20 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
303
313
|
}
|
|
304
314
|
const data = getFieldValues();
|
|
305
315
|
const patches = getPatches();
|
|
316
|
+
const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown();
|
|
306
317
|
const systemPath = getSystemPath();
|
|
307
318
|
const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : void 0;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
319
|
+
const payload = {
|
|
320
|
+
data,
|
|
321
|
+
patches,
|
|
322
|
+
contentDirty,
|
|
323
|
+
pathDirty,
|
|
324
|
+
systemPath,
|
|
325
|
+
availableLocalesDirty,
|
|
326
|
+
systemAvailableLocales
|
|
327
|
+
};
|
|
328
|
+
if ('edit' === mode && ('direct-write' === reason || 'both' === reason)) return void setPendingSystemFieldsSubmit(payload);
|
|
329
|
+
submitPayload(payload);
|
|
317
330
|
})();
|
|
318
331
|
};
|
|
319
332
|
const tabErrorCountsBySet = useMemo(()=>{
|
|
@@ -560,6 +573,103 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
560
573
|
]
|
|
561
574
|
})
|
|
562
575
|
}),
|
|
576
|
+
null != pendingSystemFieldsSubmit && /*#__PURE__*/ jsx(Modal, {
|
|
577
|
+
isOpen: true,
|
|
578
|
+
closeOnOverlayClick: true,
|
|
579
|
+
onDismiss: ()=>setPendingSystemFieldsSubmit(null),
|
|
580
|
+
children: /*#__PURE__*/ jsxs(Modal.Container, {
|
|
581
|
+
style: {
|
|
582
|
+
maxWidth: '520px'
|
|
583
|
+
},
|
|
584
|
+
children: [
|
|
585
|
+
/*#__PURE__*/ jsxs(Modal.Header, {
|
|
586
|
+
className: classnames('byline-form-guard-modal-head', form_renderer_module["guard-modal-head"]),
|
|
587
|
+
children: [
|
|
588
|
+
/*#__PURE__*/ jsx("h3", {
|
|
589
|
+
className: classnames('byline-form-guard-modal-title', form_renderer_module["guard-modal-title"]),
|
|
590
|
+
children: pendingSystemFieldsSubmit.contentDirty ? t('forms.systemFieldsConfirm.bothTitle') : t('forms.systemFieldsConfirm.title')
|
|
591
|
+
}),
|
|
592
|
+
/*#__PURE__*/ jsx(IconButton, {
|
|
593
|
+
"aria-label": t('common.actions.close'),
|
|
594
|
+
size: "xs",
|
|
595
|
+
onClick: ()=>setPendingSystemFieldsSubmit(null),
|
|
596
|
+
children: /*#__PURE__*/ jsx(CloseIcon, {
|
|
597
|
+
width: "16px",
|
|
598
|
+
height: "16px",
|
|
599
|
+
svgClassName: "white-icon"
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
]
|
|
603
|
+
}),
|
|
604
|
+
/*#__PURE__*/ jsxs(Modal.Content, {
|
|
605
|
+
className: "prose",
|
|
606
|
+
children: [
|
|
607
|
+
pendingSystemFieldsSubmit.contentDirty && /*#__PURE__*/ jsx("p", {
|
|
608
|
+
className: classnames('byline-form-system-fields-content-note', 'm-0'),
|
|
609
|
+
children: t('forms.systemFieldsConfirm.contentNote')
|
|
610
|
+
}),
|
|
611
|
+
/*#__PURE__*/ jsx("p", {
|
|
612
|
+
className: "m-0",
|
|
613
|
+
style: pendingSystemFieldsSubmit.contentDirty ? {
|
|
614
|
+
marginTop: 'var(--spacing-8)',
|
|
615
|
+
paddingTop: 'var(--spacing-12)',
|
|
616
|
+
borderTop: '1px solid var(--border-color)'
|
|
617
|
+
} : void 0,
|
|
618
|
+
children: t('forms.systemFieldsConfirm.intro')
|
|
619
|
+
}),
|
|
620
|
+
/*#__PURE__*/ jsxs("ul", {
|
|
621
|
+
className: classnames('byline-form-system-fields-list', form_renderer_module["guard-modal-text"]),
|
|
622
|
+
children: [
|
|
623
|
+
pendingSystemFieldsSubmit.pathDirty && /*#__PURE__*/ jsx("li", {
|
|
624
|
+
children: t('forms.systemFieldsConfirm.bulletPath')
|
|
625
|
+
}),
|
|
626
|
+
pendingSystemFieldsSubmit.availableLocalesDirty && /*#__PURE__*/ jsx("li", {
|
|
627
|
+
children: t('forms.systemFieldsConfirm.bulletLocales')
|
|
628
|
+
})
|
|
629
|
+
]
|
|
630
|
+
}),
|
|
631
|
+
/*#__PURE__*/ jsx("p", {
|
|
632
|
+
className: classnames('byline-form-system-fields-effect', form_renderer_module["guard-modal-text"]),
|
|
633
|
+
style: {
|
|
634
|
+
marginTop: 'var(--spacing-4)',
|
|
635
|
+
marginBottom: 0,
|
|
636
|
+
color: 'var(--text-subtle)'
|
|
637
|
+
},
|
|
638
|
+
children: t('forms.systemFieldsConfirm.effectLine')
|
|
639
|
+
})
|
|
640
|
+
]
|
|
641
|
+
}),
|
|
642
|
+
/*#__PURE__*/ jsxs(Modal.Actions, {
|
|
643
|
+
children: [
|
|
644
|
+
/*#__PURE__*/ jsx(Button, {
|
|
645
|
+
size: "sm",
|
|
646
|
+
style: {
|
|
647
|
+
minWidth: '80px'
|
|
648
|
+
},
|
|
649
|
+
intent: "noeffect",
|
|
650
|
+
type: "button",
|
|
651
|
+
onClick: ()=>setPendingSystemFieldsSubmit(null),
|
|
652
|
+
children: t('common.actions.cancel')
|
|
653
|
+
}),
|
|
654
|
+
/*#__PURE__*/ jsx(Button, {
|
|
655
|
+
size: "sm",
|
|
656
|
+
style: {
|
|
657
|
+
minWidth: '80px'
|
|
658
|
+
},
|
|
659
|
+
intent: "primary",
|
|
660
|
+
type: "button",
|
|
661
|
+
onClick: ()=>{
|
|
662
|
+
const payload = pendingSystemFieldsSubmit;
|
|
663
|
+
setPendingSystemFieldsSubmit(null);
|
|
664
|
+
submitPayload(payload);
|
|
665
|
+
},
|
|
666
|
+
children: t('forms.systemFieldsConfirm.confirmButton')
|
|
667
|
+
})
|
|
668
|
+
]
|
|
669
|
+
})
|
|
670
|
+
]
|
|
671
|
+
})
|
|
672
|
+
}),
|
|
563
673
|
guard.isBlocked && /*#__PURE__*/ jsx(Modal, {
|
|
564
674
|
isOpen: true,
|
|
565
675
|
closeOnOverlayClick: false,
|
|
@@ -27,10 +27,10 @@ export declare const abilityDescriptorResponseSchema: z.ZodObject<{
|
|
|
27
27
|
description: z.ZodNullable<z.ZodString>;
|
|
28
28
|
group: z.ZodString;
|
|
29
29
|
source: z.ZodNullable<z.ZodEnum<{
|
|
30
|
-
admin: "admin";
|
|
31
30
|
collection: "collection";
|
|
32
31
|
plugin: "plugin";
|
|
33
32
|
core: "core";
|
|
33
|
+
admin: "admin";
|
|
34
34
|
}>>;
|
|
35
35
|
}, z.core.$strip>;
|
|
36
36
|
export type AbilityDescriptorResponse = z.infer<typeof abilityDescriptorResponseSchema>;
|
|
@@ -42,10 +42,10 @@ export declare const abilityGroupResponseSchema: z.ZodObject<{
|
|
|
42
42
|
description: z.ZodNullable<z.ZodString>;
|
|
43
43
|
group: z.ZodString;
|
|
44
44
|
source: z.ZodNullable<z.ZodEnum<{
|
|
45
|
-
admin: "admin";
|
|
46
45
|
collection: "collection";
|
|
47
46
|
plugin: "plugin";
|
|
48
47
|
core: "core";
|
|
48
|
+
admin: "admin";
|
|
49
49
|
}>>;
|
|
50
50
|
}, z.core.$strip>>;
|
|
51
51
|
}, z.core.$strip>;
|
|
@@ -61,10 +61,10 @@ export declare const listRegisteredAbilitiesResponseSchema: z.ZodObject<{
|
|
|
61
61
|
description: z.ZodNullable<z.ZodString>;
|
|
62
62
|
group: z.ZodString;
|
|
63
63
|
source: z.ZodNullable<z.ZodEnum<{
|
|
64
|
-
admin: "admin";
|
|
65
64
|
collection: "collection";
|
|
66
65
|
plugin: "plugin";
|
|
67
66
|
core: "core";
|
|
67
|
+
admin: "admin";
|
|
68
68
|
}>>;
|
|
69
69
|
}, z.core.$strip>>;
|
|
70
70
|
groups: z.ZodArray<z.ZodObject<{
|
|
@@ -75,10 +75,10 @@ export declare const listRegisteredAbilitiesResponseSchema: z.ZodObject<{
|
|
|
75
75
|
description: z.ZodNullable<z.ZodString>;
|
|
76
76
|
group: z.ZodString;
|
|
77
77
|
source: z.ZodNullable<z.ZodEnum<{
|
|
78
|
-
admin: "admin";
|
|
79
78
|
collection: "collection";
|
|
80
79
|
plugin: "plugin";
|
|
81
80
|
core: "core";
|
|
81
|
+
admin: "admin";
|
|
82
82
|
}>>;
|
|
83
83
|
}, z.core.$strip>>;
|
|
84
84
|
}, z.core.$strip>>;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.3.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -146,10 +146,10 @@
|
|
|
146
146
|
"uuid": "^14.0.0",
|
|
147
147
|
"zod": "^4.4.3",
|
|
148
148
|
"zod-form-data": "^3.0.1",
|
|
149
|
-
"@byline/core": "3.
|
|
150
|
-
"@byline/i18n": "3.
|
|
151
|
-
"@byline/
|
|
152
|
-
"@byline/
|
|
149
|
+
"@byline/core": "3.3.0",
|
|
150
|
+
"@byline/i18n": "3.3.0",
|
|
151
|
+
"@byline/auth": "3.3.0",
|
|
152
|
+
"@byline/ui": "3.3.0"
|
|
153
153
|
},
|
|
154
154
|
"peerDependencies": {
|
|
155
155
|
"react": "^19.0.0",
|
|
@@ -58,6 +58,29 @@ const sameLocaleSet = (a: string[], b: string[]): boolean => {
|
|
|
58
58
|
return sa.every((v, i) => v === sb[i])
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Why the form is dirty, partitioned by write semantics — drives the single
|
|
63
|
+
* Save button. `content` mints a new version (normal workflow). `direct-write`
|
|
64
|
+
* is an immediate, non-versioned write of the document-grain system fields
|
|
65
|
+
* (path / advertised locales) that does NOT reset workflow status. `both` does
|
|
66
|
+
* each through its own write path. See docs/I18N.md.
|
|
67
|
+
*/
|
|
68
|
+
export type DirtyReason = 'none' | 'content' | 'direct-write' | 'both'
|
|
69
|
+
|
|
70
|
+
export interface DirtyBreakdown {
|
|
71
|
+
reason: DirtyReason
|
|
72
|
+
/** Document field data / patches changed → versioned write. */
|
|
73
|
+
contentDirty: boolean
|
|
74
|
+
/** Path widget changed → non-versioned direct write. */
|
|
75
|
+
pathDirty: boolean
|
|
76
|
+
/** Available-locales widget changed → non-versioned direct write. */
|
|
77
|
+
availableLocalesDirty: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Dirty-tracking keys for the two system-managed, document-grain slots. */
|
|
81
|
+
const SYSTEM_PATH_DIRTY_KEY = '__systemPath__'
|
|
82
|
+
const SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY = '__systemAvailableLocales__'
|
|
83
|
+
|
|
61
84
|
interface FormContextType {
|
|
62
85
|
setFieldValue: (name: string, value: any) => void
|
|
63
86
|
setFieldStore: (name: string, value: any) => void
|
|
@@ -76,6 +99,12 @@ interface FormContextType {
|
|
|
76
99
|
setFieldError: (field: string, message: string) => void
|
|
77
100
|
clearFieldError: (field: string) => void
|
|
78
101
|
isDirty: (fieldName: string) => boolean
|
|
102
|
+
/**
|
|
103
|
+
* Partition the current dirty state into content vs. system-field (path /
|
|
104
|
+
* advertised-locales) writes so the Save button can branch. See
|
|
105
|
+
* docs/I18N.md.
|
|
106
|
+
*/
|
|
107
|
+
getDirtyBreakdown: () => DirtyBreakdown
|
|
79
108
|
subscribeField: (name: string, listener: FieldListener) => () => void
|
|
80
109
|
subscribeErrors: (listener: ErrorsListener) => () => void
|
|
81
110
|
subscribeMeta: (listener: MetaListener) => () => void
|
|
@@ -310,6 +339,34 @@ export const FormProvider = ({
|
|
|
310
339
|
return dirtyFields.current.has(fieldName)
|
|
311
340
|
}, [])
|
|
312
341
|
|
|
342
|
+
// Partition the current dirty set by write semantics so the single Save
|
|
343
|
+
// button can route each piece correctly: content → versioned write; the
|
|
344
|
+
// document-grain system fields (path / advertised locales) → immediate,
|
|
345
|
+
// non-versioned direct write that leaves workflow status untouched.
|
|
346
|
+
// See docs/I18N.md.
|
|
347
|
+
const getDirtyBreakdown = useCallback((): DirtyBreakdown => {
|
|
348
|
+
const keys = dirtyFields.current
|
|
349
|
+
const pathDirty = keys.has(SYSTEM_PATH_DIRTY_KEY)
|
|
350
|
+
const availableLocalesDirty = keys.has(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
351
|
+
let contentDirty = false
|
|
352
|
+
for (const key of keys) {
|
|
353
|
+
if (key !== SYSTEM_PATH_DIRTY_KEY && key !== SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY) {
|
|
354
|
+
contentDirty = true
|
|
355
|
+
break
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const directWrite = pathDirty || availableLocalesDirty
|
|
359
|
+
const reason: DirtyReason =
|
|
360
|
+
contentDirty && directWrite
|
|
361
|
+
? 'both'
|
|
362
|
+
: contentDirty
|
|
363
|
+
? 'content'
|
|
364
|
+
: directWrite
|
|
365
|
+
? 'direct-write'
|
|
366
|
+
: 'none'
|
|
367
|
+
return { reason, contentDirty, pathDirty, availableLocalesDirty }
|
|
368
|
+
}, [])
|
|
369
|
+
|
|
313
370
|
// -------------------------------------------------------------------------
|
|
314
371
|
// System path slot
|
|
315
372
|
// -------------------------------------------------------------------------
|
|
@@ -320,9 +377,9 @@ export const FormProvider = ({
|
|
|
320
377
|
(value: string | null) => {
|
|
321
378
|
systemPathRef.current = value
|
|
322
379
|
if (value !== initialSystemPath.current) {
|
|
323
|
-
dirtyFields.current.add(
|
|
380
|
+
dirtyFields.current.add(SYSTEM_PATH_DIRTY_KEY)
|
|
324
381
|
} else {
|
|
325
|
-
dirtyFields.current.delete(
|
|
382
|
+
dirtyFields.current.delete(SYSTEM_PATH_DIRTY_KEY)
|
|
326
383
|
}
|
|
327
384
|
systemPathListeners.current.forEach((listener) => {
|
|
328
385
|
listener(value)
|
|
@@ -350,9 +407,9 @@ export const FormProvider = ({
|
|
|
350
407
|
const next = [...value]
|
|
351
408
|
systemAvailableLocalesRef.current = next
|
|
352
409
|
if (!sameLocaleSet(next, initialSystemAvailableLocales.current)) {
|
|
353
|
-
dirtyFields.current.add(
|
|
410
|
+
dirtyFields.current.add(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
354
411
|
} else {
|
|
355
|
-
dirtyFields.current.delete(
|
|
412
|
+
dirtyFields.current.delete(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
356
413
|
}
|
|
357
414
|
systemAvailableLocalesListeners.current.forEach((listener) => {
|
|
358
415
|
listener(next)
|
|
@@ -646,6 +703,7 @@ export const FormProvider = ({
|
|
|
646
703
|
setFieldError,
|
|
647
704
|
clearFieldError,
|
|
648
705
|
isDirty,
|
|
706
|
+
getDirtyBreakdown,
|
|
649
707
|
subscribeField,
|
|
650
708
|
subscribeErrors,
|
|
651
709
|
subscribeMeta,
|
|
@@ -18,8 +18,9 @@ import type {
|
|
|
18
18
|
TabSetDefinition,
|
|
19
19
|
WorkflowStatus,
|
|
20
20
|
} from '@byline/core'
|
|
21
|
+
import type { DocumentPatch } from '@byline/core/patches'
|
|
21
22
|
import { useTranslation } from '@byline/i18n/react'
|
|
22
|
-
import { Alert, Button, ComboButton, Modal } from '@byline/ui/react'
|
|
23
|
+
import { Alert, Button, CloseIcon, ComboButton, IconButton, Modal } from '@byline/ui/react'
|
|
23
24
|
import cx from 'classnames'
|
|
24
25
|
|
|
25
26
|
import { FieldRenderer } from '../fields/field-renderer'
|
|
@@ -46,6 +47,24 @@ export interface PublishedVersionInfo {
|
|
|
46
47
|
updatedAt: string | Date
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Payload emitted by the form on Save. Carries the content (field data +
|
|
52
|
+
* patches) alongside the document-grain system fields (path / advertised
|
|
53
|
+
* locales) and per-bucket dirty flags so the host can route each piece to the
|
|
54
|
+
* right write path — versioned for content, immediate/non-versioned for the
|
|
55
|
+
* system fields. See docs/I18N.md.
|
|
56
|
+
*/
|
|
57
|
+
export interface SystemFieldsSubmitPayload {
|
|
58
|
+
// biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
|
|
59
|
+
data: any
|
|
60
|
+
patches: DocumentPatch[]
|
|
61
|
+
contentDirty: boolean
|
|
62
|
+
pathDirty: boolean
|
|
63
|
+
systemPath?: string | null
|
|
64
|
+
availableLocalesDirty: boolean
|
|
65
|
+
systemAvailableLocales?: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
/** Props shared by both the public FormRenderer and its internal FormContent component. */
|
|
50
69
|
export interface FormRendererProps {
|
|
51
70
|
mode: 'create' | 'edit'
|
|
@@ -340,6 +359,7 @@ const FormContent = ({
|
|
|
340
359
|
hasChanges: hasChangesFn,
|
|
341
360
|
resetHasChanges,
|
|
342
361
|
getPatches,
|
|
362
|
+
getDirtyBreakdown,
|
|
343
363
|
getSystemPath,
|
|
344
364
|
getSystemAvailableLocales,
|
|
345
365
|
subscribeErrors,
|
|
@@ -361,6 +381,11 @@ const FormContent = ({
|
|
|
361
381
|
// is dirty — those actions operate on the saved version, so unsaved edits
|
|
362
382
|
// would be silently excluded.
|
|
363
383
|
const [showUnsavedModal, setShowUnsavedModal] = useState(false)
|
|
384
|
+
// Holds the pending Save payload while the editor confirms an immediate,
|
|
385
|
+
// non-versioned system-field write (path / advertised locales). Non-null
|
|
386
|
+
// means the confirmation modal is open. See docs/I18N.md.
|
|
387
|
+
const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] =
|
|
388
|
+
useState<SystemFieldsSubmitPayload | null>(null)
|
|
364
389
|
const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
|
|
365
390
|
const { uploadField } = useBylineFieldServices()
|
|
366
391
|
|
|
@@ -528,6 +553,18 @@ const FormContent = ({
|
|
|
528
553
|
}
|
|
529
554
|
}
|
|
530
555
|
|
|
556
|
+
// Emit the payload and optimistically clear dirty state (parity with the
|
|
557
|
+
// prior submit behaviour — the host surfaces failures via toast).
|
|
558
|
+
const submitPayload = useCallback(
|
|
559
|
+
(payload: SystemFieldsSubmitPayload) => {
|
|
560
|
+
if (onSubmit && typeof onSubmit === 'function') {
|
|
561
|
+
onSubmit(payload)
|
|
562
|
+
resetHasChanges()
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
[onSubmit, resetHasChanges]
|
|
566
|
+
)
|
|
567
|
+
|
|
531
568
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
532
569
|
e.preventDefault()
|
|
533
570
|
|
|
@@ -583,16 +620,33 @@ const FormContent = ({
|
|
|
583
620
|
|
|
584
621
|
const data = getFieldValues()
|
|
585
622
|
const patches = getPatches()
|
|
623
|
+
const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown()
|
|
586
624
|
const systemPath = getSystemPath()
|
|
587
625
|
// Only emit the advertised-locale set for collections that opted into the
|
|
588
626
|
// widget — otherwise leave it undefined so the write path never touches
|
|
589
627
|
// `byline_document_available_locales` for non-advertising collections.
|
|
590
628
|
const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : undefined
|
|
591
629
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
630
|
+
const payload: SystemFieldsSubmitPayload = {
|
|
631
|
+
data,
|
|
632
|
+
patches,
|
|
633
|
+
contentDirty,
|
|
634
|
+
pathDirty,
|
|
635
|
+
systemPath,
|
|
636
|
+
availableLocalesDirty,
|
|
637
|
+
systemAvailableLocales,
|
|
595
638
|
}
|
|
639
|
+
|
|
640
|
+
// Editing the document-grain system fields (path / advertised locales) is
|
|
641
|
+
// an immediate, non-versioned write that does NOT reset workflow status,
|
|
642
|
+
// so confirm it before saving. Create mode writes everything as part of
|
|
643
|
+
// the initial version, so no confirmation applies there.
|
|
644
|
+
if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
|
|
645
|
+
setPendingSystemFieldsSubmit(payload)
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
submitPayload(payload)
|
|
596
650
|
})()
|
|
597
651
|
}
|
|
598
652
|
|
|
@@ -875,6 +929,98 @@ const FormContent = ({
|
|
|
875
929
|
</Modal.Container>
|
|
876
930
|
</Modal>
|
|
877
931
|
)}
|
|
932
|
+
{pendingSystemFieldsSubmit != null && (
|
|
933
|
+
<Modal
|
|
934
|
+
isOpen={true}
|
|
935
|
+
closeOnOverlayClick={true}
|
|
936
|
+
onDismiss={() => setPendingSystemFieldsSubmit(null)}
|
|
937
|
+
>
|
|
938
|
+
<Modal.Container style={{ maxWidth: '520px' }}>
|
|
939
|
+
<Modal.Header
|
|
940
|
+
className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}
|
|
941
|
+
>
|
|
942
|
+
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
943
|
+
{pendingSystemFieldsSubmit.contentDirty
|
|
944
|
+
? t('forms.systemFieldsConfirm.bothTitle')
|
|
945
|
+
: t('forms.systemFieldsConfirm.title')}
|
|
946
|
+
</h3>
|
|
947
|
+
<IconButton
|
|
948
|
+
aria-label={t('common.actions.close')}
|
|
949
|
+
size="xs"
|
|
950
|
+
onClick={() => setPendingSystemFieldsSubmit(null)}
|
|
951
|
+
>
|
|
952
|
+
<CloseIcon width="16px" height="16px" svgClassName="white-icon" />
|
|
953
|
+
</IconButton>
|
|
954
|
+
</Modal.Header>
|
|
955
|
+
<Modal.Content className="prose">
|
|
956
|
+
{/* Lead with reassurance: content edits follow the normal
|
|
957
|
+
revision + publish workflow. The immediate, document-level
|
|
958
|
+
system-field write is explained below the divider. */}
|
|
959
|
+
{pendingSystemFieldsSubmit.contentDirty && (
|
|
960
|
+
<p className={cx('byline-form-system-fields-content-note', 'm-0')}>
|
|
961
|
+
{t('forms.systemFieldsConfirm.contentNote')}
|
|
962
|
+
</p>
|
|
963
|
+
)}
|
|
964
|
+
<p
|
|
965
|
+
className="m-0"
|
|
966
|
+
style={
|
|
967
|
+
pendingSystemFieldsSubmit.contentDirty
|
|
968
|
+
? {
|
|
969
|
+
marginTop: 'var(--spacing-8)',
|
|
970
|
+
paddingTop: 'var(--spacing-12)',
|
|
971
|
+
borderTop: '1px solid var(--border-color)',
|
|
972
|
+
}
|
|
973
|
+
: undefined
|
|
974
|
+
}
|
|
975
|
+
>
|
|
976
|
+
{t('forms.systemFieldsConfirm.intro')}
|
|
977
|
+
</p>
|
|
978
|
+
<ul className={cx('byline-form-system-fields-list', styles['guard-modal-text'])}>
|
|
979
|
+
{pendingSystemFieldsSubmit.pathDirty && (
|
|
980
|
+
<li>{t('forms.systemFieldsConfirm.bulletPath')}</li>
|
|
981
|
+
)}
|
|
982
|
+
{pendingSystemFieldsSubmit.availableLocalesDirty && (
|
|
983
|
+
<li>{t('forms.systemFieldsConfirm.bulletLocales')}</li>
|
|
984
|
+
)}
|
|
985
|
+
</ul>
|
|
986
|
+
<p
|
|
987
|
+
className={cx('byline-form-system-fields-effect', styles['guard-modal-text'])}
|
|
988
|
+
style={{
|
|
989
|
+
marginTop: 'var(--spacing-4)',
|
|
990
|
+
marginBottom: 0,
|
|
991
|
+
color: 'var(--text-subtle)',
|
|
992
|
+
}}
|
|
993
|
+
>
|
|
994
|
+
{t('forms.systemFieldsConfirm.effectLine')}
|
|
995
|
+
</p>
|
|
996
|
+
</Modal.Content>
|
|
997
|
+
<Modal.Actions>
|
|
998
|
+
<Button
|
|
999
|
+
size="sm"
|
|
1000
|
+
style={{ minWidth: '80px' }}
|
|
1001
|
+
intent="noeffect"
|
|
1002
|
+
type="button"
|
|
1003
|
+
onClick={() => setPendingSystemFieldsSubmit(null)}
|
|
1004
|
+
>
|
|
1005
|
+
{t('common.actions.cancel')}
|
|
1006
|
+
</Button>
|
|
1007
|
+
<Button
|
|
1008
|
+
size="sm"
|
|
1009
|
+
style={{ minWidth: '80px' }}
|
|
1010
|
+
intent="primary"
|
|
1011
|
+
type="button"
|
|
1012
|
+
onClick={() => {
|
|
1013
|
+
const payload = pendingSystemFieldsSubmit
|
|
1014
|
+
setPendingSystemFieldsSubmit(null)
|
|
1015
|
+
submitPayload(payload)
|
|
1016
|
+
}}
|
|
1017
|
+
>
|
|
1018
|
+
{t('forms.systemFieldsConfirm.confirmButton')}
|
|
1019
|
+
</Button>
|
|
1020
|
+
</Modal.Actions>
|
|
1021
|
+
</Modal.Container>
|
|
1022
|
+
</Modal>
|
|
1023
|
+
)}
|
|
878
1024
|
{guard.isBlocked && (
|
|
879
1025
|
<Modal isOpen={true} closeOnOverlayClick={false} onDismiss={guard.stay}>
|
|
880
1026
|
<Modal.Container style={{ maxWidth: '460px' }}>
|