@byline/admin 3.2.0 → 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 +27 -6
- package/dist/forms/form-renderer.d.ts +17 -0
- package/dist/forms/form-renderer.js +121 -11
- package/dist/forms/nested-path.d.ts +24 -0
- package/dist/forms/nested-path.js +29 -0
- package/dist/forms/nested-path.test.node.d.ts +8 -0
- package/dist/modules/admin-permissions/schemas.d.ts +4 -4
- package/package.json +5 -7
- package/src/forms/form-context.tsx +68 -5
- package/src/forms/form-renderer.tsx +150 -4
- package/src/forms/nested-path.test.node.ts +85 -0
- package/src/forms/nested-path.ts +60 -0
|
@@ -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;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { normalizeHooks } from "@byline/core";
|
|
5
|
-
import { get, set as
|
|
5
|
+
import { get, set as external_nested_path_js_set } from "./nested-path.js";
|
|
6
6
|
const sameLocaleSet = (a, b)=>{
|
|
7
7
|
if (a.length !== b.length) return false;
|
|
8
8
|
const sa = [
|
|
@@ -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);
|
|
@@ -84,7 +86,7 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
84
86
|
const newFieldValues = {
|
|
85
87
|
...fieldValues.current
|
|
86
88
|
};
|
|
87
|
-
|
|
89
|
+
external_nested_path_js_set(newFieldValues, name, value);
|
|
88
90
|
fieldValues.current = newFieldValues;
|
|
89
91
|
dirtyFields.current.add(name);
|
|
90
92
|
notifyFieldListeners(name, value);
|
|
@@ -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,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*
|
|
8
|
+
* Minimal nested `get`/`set` over string field paths, replacing lodash-es
|
|
9
|
+
* (which pulled a large shared chunk onto unrelated bundles). Supports the
|
|
10
|
+
* dot + bracket notation produced by the form field-path builders, e.g.
|
|
11
|
+
* `title`, `a.b.c`, `items[0].title`, `blocks[2].nested[1].field`.
|
|
12
|
+
*
|
|
13
|
+
* `set` mirrors lodash semantics: it creates intermediate **arrays** when the
|
|
14
|
+
* next path segment is a numeric index and plain **objects** otherwise, and it
|
|
15
|
+
* mutates `object` in place (callers shallow-copy the root first, as before).
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT a general lodash replacement — it does not handle quoted
|
|
18
|
+
* keys (`a["b.c"]`), negative indices, or array-path inputs, none of which the
|
|
19
|
+
* form paths ever produce. See nested-path.test.node.ts for the covered cases.
|
|
20
|
+
*/
|
|
21
|
+
/** Split a field path into segments: `items[0].title` -> ['items','0','title']. */
|
|
22
|
+
export declare function toPath(path: string): string[];
|
|
23
|
+
export declare function get<T = any>(object: unknown, path: string): T;
|
|
24
|
+
export declare function set<T extends object>(object: T, path: string, value: unknown): T;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const isIndexKey = (key)=>/^(?:0|[1-9]\d*)$/.test(key);
|
|
2
|
+
function toPath(path) {
|
|
3
|
+
return path.match(/[^.[\]]+/g) ?? [];
|
|
4
|
+
}
|
|
5
|
+
function get(object, path) {
|
|
6
|
+
if (null == object) return;
|
|
7
|
+
let current = object;
|
|
8
|
+
for (const key of toPath(path)){
|
|
9
|
+
if (null == current) return;
|
|
10
|
+
current = current[key];
|
|
11
|
+
}
|
|
12
|
+
return current;
|
|
13
|
+
}
|
|
14
|
+
function set(object, path, value) {
|
|
15
|
+
if (null == object) return object;
|
|
16
|
+
const keys = toPath(path);
|
|
17
|
+
if (0 === keys.length) return object;
|
|
18
|
+
let current = object;
|
|
19
|
+
for(let i = 0; i < keys.length - 1; i++){
|
|
20
|
+
const key = keys[i];
|
|
21
|
+
const nextKey = keys[i + 1];
|
|
22
|
+
const existing = current[key];
|
|
23
|
+
if (null == existing || 'object' != typeof existing) current[key] = isIndexKey(nextKey) ? [] : {};
|
|
24
|
+
current = current[key];
|
|
25
|
+
}
|
|
26
|
+
current[keys[keys.length - 1]] = value;
|
|
27
|
+
return object;
|
|
28
|
+
}
|
|
29
|
+
export { get, set, toPath };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -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
|
},
|
|
@@ -142,15 +142,14 @@
|
|
|
142
142
|
"@tanstack/react-form-start": "^1.32.0",
|
|
143
143
|
"classnames": "^2.5.1",
|
|
144
144
|
"jose": "^6.2.3",
|
|
145
|
-
"lodash-es": "^4.18.1",
|
|
146
145
|
"react-diff-viewer-continued": "^4.2.2",
|
|
147
146
|
"uuid": "^14.0.0",
|
|
148
147
|
"zod": "^4.4.3",
|
|
149
148
|
"zod-form-data": "^3.0.1",
|
|
150
|
-
"@byline/
|
|
151
|
-
"@byline/
|
|
152
|
-
"@byline/
|
|
153
|
-
"@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"
|
|
154
153
|
},
|
|
155
154
|
"peerDependencies": {
|
|
156
155
|
"react": "^19.0.0",
|
|
@@ -160,7 +159,6 @@
|
|
|
160
159
|
"@biomejs/biome": "2.4.15",
|
|
161
160
|
"@rsbuild/plugin-react": "^2.0.0",
|
|
162
161
|
"@rslib/core": "^0.21.5",
|
|
163
|
-
"@types/lodash-es": "^4.17.12",
|
|
164
162
|
"@types/node": "^25.9.1",
|
|
165
163
|
"@types/react": "19.2.15",
|
|
166
164
|
"@types/react-dom": "19.2.3",
|
|
@@ -14,7 +14,12 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|
|
14
14
|
import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/core'
|
|
15
15
|
import { normalizeHooks } from '@byline/core'
|
|
16
16
|
import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
// Vendored nested get/set (see ./nested-path) — removes the lodash-es dep
|
|
19
|
+
// outright. A bare `from 'lodash-es'` import otherwise pools into a single
|
|
20
|
+
// ~85KB chunk that leaks onto the public frontend bundle (form-context is
|
|
21
|
+
// reachable from the layout graph).
|
|
22
|
+
import { get as getNestedValue, set as setNestedValue } from './nested-path'
|
|
18
23
|
|
|
19
24
|
interface FormError {
|
|
20
25
|
field: string
|
|
@@ -53,6 +58,29 @@ const sameLocaleSet = (a: string[], b: string[]): boolean => {
|
|
|
53
58
|
return sa.every((v, i) => v === sb[i])
|
|
54
59
|
}
|
|
55
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
|
+
|
|
56
84
|
interface FormContextType {
|
|
57
85
|
setFieldValue: (name: string, value: any) => void
|
|
58
86
|
setFieldStore: (name: string, value: any) => void
|
|
@@ -71,6 +99,12 @@ interface FormContextType {
|
|
|
71
99
|
setFieldError: (field: string, message: string) => void
|
|
72
100
|
clearFieldError: (field: string) => void
|
|
73
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
|
|
74
108
|
subscribeField: (name: string, listener: FieldListener) => () => void
|
|
75
109
|
subscribeErrors: (listener: ErrorsListener) => () => void
|
|
76
110
|
subscribeMeta: (listener: MetaListener) => () => void
|
|
@@ -305,6 +339,34 @@ export const FormProvider = ({
|
|
|
305
339
|
return dirtyFields.current.has(fieldName)
|
|
306
340
|
}, [])
|
|
307
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
|
+
|
|
308
370
|
// -------------------------------------------------------------------------
|
|
309
371
|
// System path slot
|
|
310
372
|
// -------------------------------------------------------------------------
|
|
@@ -315,9 +377,9 @@ export const FormProvider = ({
|
|
|
315
377
|
(value: string | null) => {
|
|
316
378
|
systemPathRef.current = value
|
|
317
379
|
if (value !== initialSystemPath.current) {
|
|
318
|
-
dirtyFields.current.add(
|
|
380
|
+
dirtyFields.current.add(SYSTEM_PATH_DIRTY_KEY)
|
|
319
381
|
} else {
|
|
320
|
-
dirtyFields.current.delete(
|
|
382
|
+
dirtyFields.current.delete(SYSTEM_PATH_DIRTY_KEY)
|
|
321
383
|
}
|
|
322
384
|
systemPathListeners.current.forEach((listener) => {
|
|
323
385
|
listener(value)
|
|
@@ -345,9 +407,9 @@ export const FormProvider = ({
|
|
|
345
407
|
const next = [...value]
|
|
346
408
|
systemAvailableLocalesRef.current = next
|
|
347
409
|
if (!sameLocaleSet(next, initialSystemAvailableLocales.current)) {
|
|
348
|
-
dirtyFields.current.add(
|
|
410
|
+
dirtyFields.current.add(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
349
411
|
} else {
|
|
350
|
-
dirtyFields.current.delete(
|
|
412
|
+
dirtyFields.current.delete(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
351
413
|
}
|
|
352
414
|
systemAvailableLocalesListeners.current.forEach((listener) => {
|
|
353
415
|
listener(next)
|
|
@@ -641,6 +703,7 @@ export const FormProvider = ({
|
|
|
641
703
|
setFieldError,
|
|
642
704
|
clearFieldError,
|
|
643
705
|
isDirty,
|
|
706
|
+
getDirtyBreakdown,
|
|
644
707
|
subscribeField,
|
|
645
708
|
subscribeErrors,
|
|
646
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' }}>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { get, set, toPath } from './nested-path'
|
|
12
|
+
|
|
13
|
+
describe('toPath', () => {
|
|
14
|
+
it('parses dot and bracket notation', () => {
|
|
15
|
+
expect(toPath('title')).toEqual(['title'])
|
|
16
|
+
expect(toPath('a.b.c')).toEqual(['a', 'b', 'c'])
|
|
17
|
+
expect(toPath('items[0].title')).toEqual(['items', '0', 'title'])
|
|
18
|
+
expect(toPath('blocks[2].nested[1].field')).toEqual(['blocks', '2', 'nested', '1', 'field'])
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('get', () => {
|
|
23
|
+
const obj = { a: { b: 1, zero: 0, empty: '' }, items: [{ title: 'x' }, { title: 'y' }] }
|
|
24
|
+
|
|
25
|
+
it('reads nested, array, and mixed paths', () => {
|
|
26
|
+
expect(get(obj, 'a.b')).toBe(1)
|
|
27
|
+
expect(get(obj, 'items[0].title')).toBe('x')
|
|
28
|
+
expect(get(obj, 'items[1].title')).toBe('y')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('preserves falsy values (does not conflate with missing)', () => {
|
|
32
|
+
expect(get(obj, 'a.zero')).toBe(0)
|
|
33
|
+
expect(get(obj, 'a.empty')).toBe('')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns undefined for missing paths or nullish roots', () => {
|
|
37
|
+
expect(get(obj, 'a.x')).toBeUndefined()
|
|
38
|
+
expect(get(obj, 'missing.deep.path')).toBeUndefined()
|
|
39
|
+
expect(get(obj, 'items[5].title')).toBeUndefined()
|
|
40
|
+
expect(get(null, 'a.b')).toBeUndefined()
|
|
41
|
+
expect(get(undefined, 'a.b')).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('set', () => {
|
|
46
|
+
it('sets simple and nested values, creating intermediate objects', () => {
|
|
47
|
+
const o: any = {}
|
|
48
|
+
set(o, 'a.b.c', 1)
|
|
49
|
+
expect(o).toEqual({ a: { b: { c: 1 } } })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('creates arrays for numeric index segments', () => {
|
|
53
|
+
const o: any = {}
|
|
54
|
+
set(o, 'items[0].title', 'x')
|
|
55
|
+
expect(Array.isArray(o.items)).toBe(true)
|
|
56
|
+
expect(o.items[0]).toEqual({ title: 'x' })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('handles deep, mixed array/object paths', () => {
|
|
60
|
+
const o: any = {}
|
|
61
|
+
set(o, 'blocks[1].nested[0].field', 42)
|
|
62
|
+
expect(Array.isArray(o.blocks)).toBe(true)
|
|
63
|
+
expect(Array.isArray(o.blocks[1].nested)).toBe(true)
|
|
64
|
+
expect(o.blocks[1].nested[0].field).toBe(42)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('overwrites existing values and preserves siblings', () => {
|
|
68
|
+
const o: any = { a: { b: 1, keep: 2 } }
|
|
69
|
+
set(o, 'a.b', 9)
|
|
70
|
+
expect(o).toEqual({ a: { b: 9, keep: 2 } })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('writes into a pre-existing array element without clobbering the array', () => {
|
|
74
|
+
const o: any = { items: [{ title: 'x' }, { title: 'y' }] }
|
|
75
|
+
set(o, 'items[1].title', 'z')
|
|
76
|
+
expect(o.items[1].title).toBe('z')
|
|
77
|
+
expect(o.items[0].title).toBe('x')
|
|
78
|
+
expect(Array.isArray(o.items)).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns the mutated root', () => {
|
|
82
|
+
const o: any = {}
|
|
83
|
+
expect(set(o, 'x', 1)).toBe(o)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*
|
|
8
|
+
* Minimal nested `get`/`set` over string field paths, replacing lodash-es
|
|
9
|
+
* (which pulled a large shared chunk onto unrelated bundles). Supports the
|
|
10
|
+
* dot + bracket notation produced by the form field-path builders, e.g.
|
|
11
|
+
* `title`, `a.b.c`, `items[0].title`, `blocks[2].nested[1].field`.
|
|
12
|
+
*
|
|
13
|
+
* `set` mirrors lodash semantics: it creates intermediate **arrays** when the
|
|
14
|
+
* next path segment is a numeric index and plain **objects** otherwise, and it
|
|
15
|
+
* mutates `object` in place (callers shallow-copy the root first, as before).
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT a general lodash replacement — it does not handle quoted
|
|
18
|
+
* keys (`a["b.c"]`), negative indices, or array-path inputs, none of which the
|
|
19
|
+
* form paths ever produce. See nested-path.test.node.ts for the covered cases.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const isIndexKey = (key: string): boolean => /^(?:0|[1-9]\d*)$/.test(key)
|
|
23
|
+
|
|
24
|
+
/** Split a field path into segments: `items[0].title` -> ['items','0','title']. */
|
|
25
|
+
export function toPath(path: string): string[] {
|
|
26
|
+
return path.match(/[^.[\]]+/g) ?? []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Returns `any` (not `T | undefined`) to match lodash's loose `get` contract,
|
|
30
|
+
// so existing call sites that treat the result as `any` keep type-checking.
|
|
31
|
+
export function get<T = any>(object: unknown, path: string): T {
|
|
32
|
+
if (object == null) return undefined as T
|
|
33
|
+
let current: any = object
|
|
34
|
+
for (const key of toPath(path)) {
|
|
35
|
+
if (current == null) return undefined as T
|
|
36
|
+
current = current[key]
|
|
37
|
+
}
|
|
38
|
+
return current as T
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function set<T extends object>(object: T, path: string, value: unknown): T {
|
|
42
|
+
if (object == null) return object
|
|
43
|
+
const keys = toPath(path)
|
|
44
|
+
if (keys.length === 0) return object
|
|
45
|
+
|
|
46
|
+
let current: any = object
|
|
47
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
48
|
+
// Bounded by the loop condition, so these indexed reads are always defined.
|
|
49
|
+
const key = keys[i] as string
|
|
50
|
+
const nextKey = keys[i + 1] as string
|
|
51
|
+
const existing = current[key]
|
|
52
|
+
if (existing == null || typeof existing !== 'object') {
|
|
53
|
+
// Create the container the next segment needs: array for an index, else object.
|
|
54
|
+
current[key] = isIndexKey(nextKey) ? [] : {}
|
|
55
|
+
}
|
|
56
|
+
current = current[key]
|
|
57
|
+
}
|
|
58
|
+
current[keys[keys.length - 1] as string] = value
|
|
59
|
+
return object
|
|
60
|
+
}
|