@byline/admin 3.2.1 → 3.3.1

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;
@@ -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('__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 mt-2'),
609
+ children: t('forms.systemFieldsConfirm.contentNote')
610
+ }),
611
+ /*#__PURE__*/ jsx("p", {
612
+ className: "m-0 mt-2",
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"], 'm-0'),
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,
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.1",
5
+ "version": "3.3.1",
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.2.1",
150
- "@byline/i18n": "3.2.1",
151
- "@byline/ui": "3.2.1",
152
- "@byline/auth": "3.2.1"
149
+ "@byline/auth": "3.3.1",
150
+ "@byline/core": "3.3.1",
151
+ "@byline/i18n": "3.3.1",
152
+ "@byline/ui": "3.3.1"
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('__systemPath__')
380
+ dirtyFields.current.add(SYSTEM_PATH_DIRTY_KEY)
324
381
  } else {
325
- dirtyFields.current.delete('__systemPath__')
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('__systemAvailableLocales__')
410
+ dirtyFields.current.add(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
354
411
  } else {
355
- dirtyFields.current.delete('__systemAvailableLocales__')
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
- 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,
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
595
647
  }
648
+
649
+ submitPayload(payload)
596
650
  })()
597
651
  }
598
652
 
@@ -875,6 +929,100 @@ 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 mt-2')}>
961
+ {t('forms.systemFieldsConfirm.contentNote')}
962
+ </p>
963
+ )}
964
+ <p
965
+ className="m-0 mt-2"
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
979
+ className={cx('byline-form-system-fields-list', styles['guard-modal-text'], 'm-0')}
980
+ >
981
+ {pendingSystemFieldsSubmit.pathDirty && (
982
+ <li>{t('forms.systemFieldsConfirm.bulletPath')}</li>
983
+ )}
984
+ {pendingSystemFieldsSubmit.availableLocalesDirty && (
985
+ <li>{t('forms.systemFieldsConfirm.bulletLocales')}</li>
986
+ )}
987
+ </ul>
988
+ <p
989
+ className={cx('byline-form-system-fields-effect', styles['guard-modal-text'])}
990
+ style={{
991
+ marginTop: 'var(--spacing-4)',
992
+ marginBottom: 0,
993
+ color: 'var(--text-subtle)',
994
+ }}
995
+ >
996
+ {t('forms.systemFieldsConfirm.effectLine')}
997
+ </p>
998
+ </Modal.Content>
999
+ <Modal.Actions>
1000
+ <Button
1001
+ size="sm"
1002
+ style={{ minWidth: '80px' }}
1003
+ intent="noeffect"
1004
+ type="button"
1005
+ onClick={() => setPendingSystemFieldsSubmit(null)}
1006
+ >
1007
+ {t('common.actions.cancel')}
1008
+ </Button>
1009
+ <Button
1010
+ size="sm"
1011
+ style={{ minWidth: '80px' }}
1012
+ intent="primary"
1013
+ type="button"
1014
+ onClick={() => {
1015
+ const payload = pendingSystemFieldsSubmit
1016
+ setPendingSystemFieldsSubmit(null)
1017
+ submitPayload(payload)
1018
+ }}
1019
+ >
1020
+ {t('forms.systemFieldsConfirm.confirmButton')}
1021
+ </Button>
1022
+ </Modal.Actions>
1023
+ </Modal.Container>
1024
+ </Modal>
1025
+ )}
878
1026
  {guard.isBlocked && (
879
1027
  <Modal isOpen={true} closeOnOverlayClick={false} onDismiss={guard.stay}>
880
1028
  <Modal.Container style={{ maxWidth: '460px' }}>