@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.
@@ -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 external_lodash_es_set } from "lodash-es";
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
- external_lodash_es_set(newFieldValues, name, value);
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('__systemPath__');
166
- else dirtyFields.current.delete('__systemPath__');
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('__systemAvailableLocales__');
187
- else dirtyFields.current.add('__systemAvailableLocales__');
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
- if (onSubmit && 'function' == typeof onSubmit) {
309
- onSubmit({
310
- data,
311
- patches,
312
- systemPath,
313
- systemAvailableLocales
314
- });
315
- resetHasChanges();
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.2.0",
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/auth": "3.2.0",
151
- "@byline/core": "3.2.0",
152
- "@byline/ui": "3.2.0",
153
- "@byline/i18n": "3.2.0"
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
- import { get as getNestedValue, set as setNestedValue } from 'lodash-es'
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('__systemPath__')
380
+ dirtyFields.current.add(SYSTEM_PATH_DIRTY_KEY)
319
381
  } else {
320
- dirtyFields.current.delete('__systemPath__')
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('__systemAvailableLocales__')
410
+ dirtyFields.current.add(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
349
411
  } else {
350
- dirtyFields.current.delete('__systemAvailableLocales__')
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
- if (onSubmit && typeof onSubmit === 'function') {
593
- onSubmit({ data, patches, systemPath, systemAvailableLocales })
594
- resetHasChanges()
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
+ }