@byline/admin 3.12.0 → 3.12.2
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.js +30 -60
- package/dist/forms/form-modals.d.ts +30 -0
- package/dist/forms/form-modals.js +189 -0
- package/dist/forms/form-renderer.js +22 -363
- package/dist/forms/form-status-display.d.ts +21 -0
- package/dist/forms/form-status-display.js +94 -0
- package/dist/forms/status-transitions.d.ts +23 -0
- package/dist/forms/status-transitions.js +37 -0
- package/dist/forms/status-transitions.test.node.d.ts +8 -0
- package/dist/forms/use-form-layout.d.ts +35 -0
- package/dist/forms/use-form-layout.js +77 -0
- package/dist/forms/use-form-layout.test.node.d.ts +8 -0
- package/dist/forms/use-tracked-slot.d.ts +45 -0
- package/dist/forms/use-tracked-slot.js +46 -0
- package/package.json +5 -5
- package/src/forms/form-context.tsx +36 -92
- package/src/forms/form-modals.tsx +186 -0
- package/src/forms/form-renderer.tsx +24 -387
- package/src/forms/form-status-display.tsx +108 -0
- package/src/forms/status-transitions.test.node.ts +87 -0
- package/src/forms/status-transitions.ts +86 -0
- package/src/forms/use-form-layout.test.node.ts +82 -0
- package/src/forms/use-form-layout.ts +134 -0
- package/src/forms/use-tracked-slot.ts +101 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
function buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName) {
|
|
4
|
+
const map = new Map();
|
|
5
|
+
const visit = (names, tabSetName, tabName, seen)=>{
|
|
6
|
+
for (const name of names)if (fieldByName.has(name)) map.set(name, {
|
|
7
|
+
tabSetName,
|
|
8
|
+
tabName
|
|
9
|
+
});
|
|
10
|
+
else if (seen.has(name)) ;
|
|
11
|
+
else if (rowByName.has(name)) {
|
|
12
|
+
const row = rowByName.get(name);
|
|
13
|
+
const next = new Set(seen).add(name);
|
|
14
|
+
visit(row.fields, tabSetName, tabName, next);
|
|
15
|
+
} else if (groupByName.has(name)) {
|
|
16
|
+
const group = groupByName.get(name);
|
|
17
|
+
const next = new Set(seen).add(name);
|
|
18
|
+
visit(group.fields, tabSetName, tabName, next);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
for (const set of adminConfig?.tabSets ?? [])for (const tab of set.tabs)visit(tab.fields, set.name, tab.name, new Set());
|
|
22
|
+
return map;
|
|
23
|
+
}
|
|
24
|
+
function useFormLayout(adminConfig, fields) {
|
|
25
|
+
const fieldByName = useMemo(()=>{
|
|
26
|
+
const map = new Map();
|
|
27
|
+
for (const field of fields)if ('name' in field) map.set(field.name, field);
|
|
28
|
+
return map;
|
|
29
|
+
}, [
|
|
30
|
+
fields
|
|
31
|
+
]);
|
|
32
|
+
const tabSetByName = useMemo(()=>{
|
|
33
|
+
const map = new Map();
|
|
34
|
+
for (const set of adminConfig?.tabSets ?? [])map.set(set.name, set);
|
|
35
|
+
return map;
|
|
36
|
+
}, [
|
|
37
|
+
adminConfig
|
|
38
|
+
]);
|
|
39
|
+
const rowByName = useMemo(()=>{
|
|
40
|
+
const map = new Map();
|
|
41
|
+
for (const row of adminConfig?.rows ?? [])map.set(row.name, row);
|
|
42
|
+
return map;
|
|
43
|
+
}, [
|
|
44
|
+
adminConfig
|
|
45
|
+
]);
|
|
46
|
+
const groupByName = useMemo(()=>{
|
|
47
|
+
const map = new Map();
|
|
48
|
+
for (const group of adminConfig?.groups ?? [])map.set(group.name, group);
|
|
49
|
+
return map;
|
|
50
|
+
}, [
|
|
51
|
+
adminConfig
|
|
52
|
+
]);
|
|
53
|
+
const layout = useMemo(()=>{
|
|
54
|
+
if (adminConfig?.layout) return adminConfig.layout;
|
|
55
|
+
return {
|
|
56
|
+
main: fields.filter((f)=>'name' in f).map((f)=>f.name)
|
|
57
|
+
};
|
|
58
|
+
}, [
|
|
59
|
+
adminConfig,
|
|
60
|
+
fields
|
|
61
|
+
]);
|
|
62
|
+
const fieldToTabPath = useMemo(()=>buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName), [
|
|
63
|
+
adminConfig,
|
|
64
|
+
fieldByName,
|
|
65
|
+
rowByName,
|
|
66
|
+
groupByName
|
|
67
|
+
]);
|
|
68
|
+
return {
|
|
69
|
+
fieldByName,
|
|
70
|
+
tabSetByName,
|
|
71
|
+
rowByName,
|
|
72
|
+
groupByName,
|
|
73
|
+
layout,
|
|
74
|
+
fieldToTabPath
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export { buildFieldToTabPath, useFormLayout };
|
|
@@ -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 {};
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import { type RefObject } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* A dirty-tracked, ref-backed form slot with its own listener set — the shared
|
|
11
|
+
* machinery behind the document-grain system fields (`path`, advertised
|
|
12
|
+
* `availableLocales`, …). Each slot holds the current value, the value it was
|
|
13
|
+
* loaded with, and a set of subscribers; a `set` that moves the value away from
|
|
14
|
+
* its loaded baseline registers the slot's `dirtyKey` in the form's shared
|
|
15
|
+
* dirty set (and clears it again when the value returns to baseline), so the
|
|
16
|
+
* single Save button can branch on which buckets changed.
|
|
17
|
+
*
|
|
18
|
+
* Adding a new system slot is then a one-liner at the call site rather than a
|
|
19
|
+
* copy of ref + initial-ref + listener-set + get/set/subscribe.
|
|
20
|
+
*/
|
|
21
|
+
export interface TrackedSlot<T> {
|
|
22
|
+
/** Current value (live ref read — not React state). */
|
|
23
|
+
get: () => T;
|
|
24
|
+
/** Write a value; toggles `dirtyKey` against the loaded baseline. */
|
|
25
|
+
set: (value: T) => void;
|
|
26
|
+
/** Subscribe to value changes; returns an unsubscribe fn. */
|
|
27
|
+
subscribe: (listener: (value: T) => void) => () => void;
|
|
28
|
+
/** Re-baseline: adopt the current value as the new "clean" baseline (called on save). */
|
|
29
|
+
commitInitial: () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface UseTrackedSlotConfig<T> {
|
|
32
|
+
/** Initial / loaded value. */
|
|
33
|
+
initial: T;
|
|
34
|
+
/** Key registered in the shared dirty set while this slot diverges from baseline. */
|
|
35
|
+
dirtyKey: string;
|
|
36
|
+
/** The form's shared dirty-key set. */
|
|
37
|
+
dirtyFields: RefObject<Set<string>>;
|
|
38
|
+
/** Notify the form's meta listeners (drives hasChanges → Save button). */
|
|
39
|
+
notifyMeta: () => void;
|
|
40
|
+
/** Equality against the baseline. Defaults to `===` (identity). */
|
|
41
|
+
isEqual?: (a: T, b: T) => boolean;
|
|
42
|
+
/** Defensive copy on read-in / write. Defaults to identity (fine for immutables). */
|
|
43
|
+
clone?: (value: T) => T;
|
|
44
|
+
}
|
|
45
|
+
export declare function useTrackedSlot<T>(config: UseTrackedSlotConfig<T>): TrackedSlot<T>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useRef } from "react";
|
|
3
|
+
function useTrackedSlot(config) {
|
|
4
|
+
const cfgRef = useRef(config);
|
|
5
|
+
cfgRef.current = config;
|
|
6
|
+
const clone = useCallback((value)=>{
|
|
7
|
+
const fn = cfgRef.current.clone;
|
|
8
|
+
return fn ? fn(value) : value;
|
|
9
|
+
}, []);
|
|
10
|
+
const valueRef = useRef(clone(config.initial));
|
|
11
|
+
const initialRef = useRef(clone(config.initial));
|
|
12
|
+
const listeners = useRef(new Set());
|
|
13
|
+
const get = useCallback(()=>valueRef.current, []);
|
|
14
|
+
const set = useCallback((value)=>{
|
|
15
|
+
const { dirtyKey, dirtyFields, notifyMeta, isEqual } = cfgRef.current;
|
|
16
|
+
const next = clone(value);
|
|
17
|
+
valueRef.current = next;
|
|
18
|
+
const equal = isEqual ? isEqual(next, initialRef.current) : next === initialRef.current;
|
|
19
|
+
if (equal) dirtyFields.current.delete(dirtyKey);
|
|
20
|
+
else dirtyFields.current.add(dirtyKey);
|
|
21
|
+
listeners.current.forEach((listener)=>{
|
|
22
|
+
listener(next);
|
|
23
|
+
});
|
|
24
|
+
notifyMeta();
|
|
25
|
+
}, [
|
|
26
|
+
clone
|
|
27
|
+
]);
|
|
28
|
+
const subscribe = useCallback((listener)=>{
|
|
29
|
+
listeners.current.add(listener);
|
|
30
|
+
return ()=>{
|
|
31
|
+
listeners.current.delete(listener);
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
const commitInitial = useCallback(()=>{
|
|
35
|
+
initialRef.current = clone(valueRef.current);
|
|
36
|
+
}, [
|
|
37
|
+
clone
|
|
38
|
+
]);
|
|
39
|
+
return {
|
|
40
|
+
get,
|
|
41
|
+
set,
|
|
42
|
+
subscribe,
|
|
43
|
+
commitInitial
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export { useTrackedSlot };
|
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.12.
|
|
5
|
+
"version": "3.12.2",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -151,10 +151,10 @@
|
|
|
151
151
|
"uuid": "^14.0.0",
|
|
152
152
|
"zod": "^4.4.3",
|
|
153
153
|
"zod-form-data": "^3.0.1",
|
|
154
|
-
"@byline/
|
|
155
|
-
"@byline/
|
|
156
|
-
"@byline/core": "3.12.
|
|
157
|
-
"@byline/i18n": "3.12.
|
|
154
|
+
"@byline/auth": "3.12.2",
|
|
155
|
+
"@byline/ui": "3.12.2",
|
|
156
|
+
"@byline/core": "3.12.2",
|
|
157
|
+
"@byline/i18n": "3.12.2"
|
|
158
158
|
},
|
|
159
159
|
"peerDependencies": {
|
|
160
160
|
"react": "^19.0.0",
|
|
@@ -20,6 +20,7 @@ import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
|
|
|
20
20
|
// ~85KB chunk that leaks onto the public frontend bundle (form-context is
|
|
21
21
|
// reachable from the layout graph).
|
|
22
22
|
import { get as getNestedValue, set as setNestedValue } from './nested-path'
|
|
23
|
+
import { useTrackedSlot } from './use-tracked-slot'
|
|
23
24
|
|
|
24
25
|
interface FormError {
|
|
25
26
|
field: string
|
|
@@ -168,27 +169,6 @@ export const FormProvider = ({
|
|
|
168
169
|
const errorListeners = useRef<Set<ErrorsListener>>(new Set())
|
|
169
170
|
const metaListeners = useRef<Set<MetaListener>>(new Set())
|
|
170
171
|
|
|
171
|
-
// System path slot — initialised from the loaded version's top-level
|
|
172
|
-
// `path` (edit mode) or `null` (create mode). Edits via `setSystemPath`
|
|
173
|
-
// mark the form dirty so the Save button enables.
|
|
174
|
-
const systemPathRef = useRef<string | null>(
|
|
175
|
-
typeof initialData?.path === 'string' && (initialData.path as string).length > 0
|
|
176
|
-
? (initialData.path as string)
|
|
177
|
-
: null
|
|
178
|
-
)
|
|
179
|
-
const initialSystemPath = useRef<string | null>(systemPathRef.current)
|
|
180
|
-
const systemPathListeners = useRef<Set<SystemPathListener>>(new Set())
|
|
181
|
-
|
|
182
|
-
// System available-locales slot — initialised from the loaded version's
|
|
183
|
-
// top-level `availableLocales` (edit mode) or `[]` (create mode / not yet
|
|
184
|
-
// surfaced). Edits via `setSystemAvailableLocales` mark the form dirty so
|
|
185
|
-
// the Save button enables. Stored as a defensive copy.
|
|
186
|
-
const systemAvailableLocalesRef = useRef<string[]>(
|
|
187
|
-
Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : []
|
|
188
|
-
)
|
|
189
|
-
const initialSystemAvailableLocales = useRef<string[]>([...systemAvailableLocalesRef.current])
|
|
190
|
-
const systemAvailableLocalesListeners = useRef<Set<SystemAvailableLocalesListener>>(new Set())
|
|
191
|
-
|
|
192
172
|
const subscribeField = useCallback((name: string, listener: FieldListener) => {
|
|
193
173
|
if (!fieldListeners.current.has(name)) {
|
|
194
174
|
fieldListeners.current.set(name, new Set())
|
|
@@ -240,6 +220,32 @@ export const FormProvider = ({
|
|
|
240
220
|
})
|
|
241
221
|
}, [])
|
|
242
222
|
|
|
223
|
+
// Document-grain system-field slots — dirty-tracked, ref-backed, each with
|
|
224
|
+
// its own listener set. The `path` slot is initialised from the loaded
|
|
225
|
+
// version's top-level `path` (edit) or `null` (create); the available-locales
|
|
226
|
+
// slot from `availableLocales` (edit) or `[]`. Edits toggle the slot's dirty
|
|
227
|
+
// key so the single Save button can branch. See ./use-tracked-slot.
|
|
228
|
+
const pathSlot = useTrackedSlot<string | null>({
|
|
229
|
+
initial:
|
|
230
|
+
typeof initialData?.path === 'string' && (initialData.path as string).length > 0
|
|
231
|
+
? (initialData.path as string)
|
|
232
|
+
: null,
|
|
233
|
+
dirtyKey: SYSTEM_PATH_DIRTY_KEY,
|
|
234
|
+
dirtyFields,
|
|
235
|
+
notifyMeta: notifyMetaListeners,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const availableLocalesSlot = useTrackedSlot<string[]>({
|
|
239
|
+
initial: Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : [],
|
|
240
|
+
dirtyKey: SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY,
|
|
241
|
+
dirtyFields,
|
|
242
|
+
notifyMeta: notifyMetaListeners,
|
|
243
|
+
// The slot holds an array; a fresh reference is never `===` its baseline,
|
|
244
|
+
// so dirty tracking compares membership, not identity. Stored as a copy.
|
|
245
|
+
isEqual: sameLocaleSet,
|
|
246
|
+
clone: (value) => [...value],
|
|
247
|
+
})
|
|
248
|
+
|
|
243
249
|
const updateFieldStoreInternal = useCallback(
|
|
244
250
|
(name: string, value: any) => {
|
|
245
251
|
const newFieldValues = { ...fieldValues.current }
|
|
@@ -330,10 +336,10 @@ export const FormProvider = ({
|
|
|
330
336
|
const resetHasChanges = useCallback(() => {
|
|
331
337
|
dirtyFields.current.clear()
|
|
332
338
|
patchesRef.current = []
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
pathSlot.commitInitial()
|
|
340
|
+
availableLocalesSlot.commitInitial()
|
|
335
341
|
notifyMetaListeners()
|
|
336
|
-
}, [notifyMetaListeners])
|
|
342
|
+
}, [notifyMetaListeners, pathSlot.commitInitial, availableLocalesSlot.commitInitial])
|
|
337
343
|
|
|
338
344
|
const isDirty = useCallback((fieldName: string) => {
|
|
339
345
|
return dirtyFields.current.has(fieldName)
|
|
@@ -367,68 +373,6 @@ export const FormProvider = ({
|
|
|
367
373
|
return { reason, contentDirty, pathDirty, availableLocalesDirty }
|
|
368
374
|
}, [])
|
|
369
375
|
|
|
370
|
-
// -------------------------------------------------------------------------
|
|
371
|
-
// System path slot
|
|
372
|
-
// -------------------------------------------------------------------------
|
|
373
|
-
|
|
374
|
-
const getSystemPath = useCallback(() => systemPathRef.current, [])
|
|
375
|
-
|
|
376
|
-
const setSystemPath = useCallback(
|
|
377
|
-
(value: string | null) => {
|
|
378
|
-
systemPathRef.current = value
|
|
379
|
-
if (value !== initialSystemPath.current) {
|
|
380
|
-
dirtyFields.current.add(SYSTEM_PATH_DIRTY_KEY)
|
|
381
|
-
} else {
|
|
382
|
-
dirtyFields.current.delete(SYSTEM_PATH_DIRTY_KEY)
|
|
383
|
-
}
|
|
384
|
-
systemPathListeners.current.forEach((listener) => {
|
|
385
|
-
listener(value)
|
|
386
|
-
})
|
|
387
|
-
notifyMetaListeners()
|
|
388
|
-
},
|
|
389
|
-
[notifyMetaListeners]
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
const subscribeSystemPath = useCallback((listener: SystemPathListener) => {
|
|
393
|
-
systemPathListeners.current.add(listener)
|
|
394
|
-
return () => {
|
|
395
|
-
systemPathListeners.current.delete(listener)
|
|
396
|
-
}
|
|
397
|
-
}, [])
|
|
398
|
-
|
|
399
|
-
// -------------------------------------------------------------------------
|
|
400
|
-
// System available-locales slot
|
|
401
|
-
// -------------------------------------------------------------------------
|
|
402
|
-
|
|
403
|
-
const getSystemAvailableLocales = useCallback(() => systemAvailableLocalesRef.current, [])
|
|
404
|
-
|
|
405
|
-
const setSystemAvailableLocales = useCallback(
|
|
406
|
-
(value: string[]) => {
|
|
407
|
-
const next = [...value]
|
|
408
|
-
systemAvailableLocalesRef.current = next
|
|
409
|
-
if (!sameLocaleSet(next, initialSystemAvailableLocales.current)) {
|
|
410
|
-
dirtyFields.current.add(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
411
|
-
} else {
|
|
412
|
-
dirtyFields.current.delete(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY)
|
|
413
|
-
}
|
|
414
|
-
systemAvailableLocalesListeners.current.forEach((listener) => {
|
|
415
|
-
listener(next)
|
|
416
|
-
})
|
|
417
|
-
notifyMetaListeners()
|
|
418
|
-
},
|
|
419
|
-
[notifyMetaListeners]
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
const subscribeSystemAvailableLocales = useCallback(
|
|
423
|
-
(listener: SystemAvailableLocalesListener) => {
|
|
424
|
-
systemAvailableLocalesListeners.current.add(listener)
|
|
425
|
-
return () => {
|
|
426
|
-
systemAvailableLocalesListeners.current.delete(listener)
|
|
427
|
-
}
|
|
428
|
-
},
|
|
429
|
-
[]
|
|
430
|
-
)
|
|
431
|
-
|
|
432
376
|
// ---------------------------------------------------------------------------
|
|
433
377
|
// Pending uploads (deferred until save)
|
|
434
378
|
// ---------------------------------------------------------------------------
|
|
@@ -715,12 +659,12 @@ export const FormProvider = ({
|
|
|
715
659
|
setFieldUploading,
|
|
716
660
|
getIsFieldUploading,
|
|
717
661
|
subscribeFieldUploading,
|
|
718
|
-
getSystemPath,
|
|
719
|
-
setSystemPath,
|
|
720
|
-
subscribeSystemPath,
|
|
721
|
-
getSystemAvailableLocales,
|
|
722
|
-
setSystemAvailableLocales,
|
|
723
|
-
subscribeSystemAvailableLocales,
|
|
662
|
+
getSystemPath: pathSlot.get,
|
|
663
|
+
setSystemPath: pathSlot.set,
|
|
664
|
+
subscribeSystemPath: pathSlot.subscribe,
|
|
665
|
+
getSystemAvailableLocales: availableLocalesSlot.get,
|
|
666
|
+
setSystemAvailableLocales: availableLocalesSlot.set,
|
|
667
|
+
subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
|
|
724
668
|
}}
|
|
725
669
|
>
|
|
726
670
|
{children}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
12
|
+
import { Button, CloseIcon, IconButton, Modal } from '@byline/ui/react'
|
|
13
|
+
import cx from 'classnames'
|
|
14
|
+
|
|
15
|
+
import styles from './form-renderer.module.css'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shown when the editor triggers a guarded action (status change, duplicate,
|
|
19
|
+
* copy-to-locale) while the form is dirty. Those actions operate on the saved
|
|
20
|
+
* version, so the editor must save first.
|
|
21
|
+
*/
|
|
22
|
+
export const UnsavedChangesModal = ({ onClose }: { onClose: () => void }) => {
|
|
23
|
+
const { t } = useTranslation('byline-admin')
|
|
24
|
+
return (
|
|
25
|
+
<Modal isOpen={true} closeOnOverlayClick={true} onDismiss={onClose}>
|
|
26
|
+
<Modal.Container style={{ maxWidth: '460px' }}>
|
|
27
|
+
<Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
|
|
28
|
+
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
29
|
+
{t('forms.unsavedChanges.title')}
|
|
30
|
+
</h3>
|
|
31
|
+
</Modal.Header>
|
|
32
|
+
<Modal.Content>
|
|
33
|
+
<p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
|
|
34
|
+
{t('forms.unsavedChanges.message')}
|
|
35
|
+
</p>
|
|
36
|
+
</Modal.Content>
|
|
37
|
+
<Modal.Actions>
|
|
38
|
+
<Button
|
|
39
|
+
size="sm"
|
|
40
|
+
style={{ minWidth: '60px' }}
|
|
41
|
+
intent="primary"
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={onClose}
|
|
44
|
+
>
|
|
45
|
+
{t('forms.unsavedChanges.okButton')}
|
|
46
|
+
</Button>
|
|
47
|
+
</Modal.Actions>
|
|
48
|
+
</Modal.Container>
|
|
49
|
+
</Modal>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Confirms an immediate, non-versioned write of the document-grain system
|
|
55
|
+
* fields (path / advertised locales) — which does NOT reset workflow status.
|
|
56
|
+
* When content is also dirty, the copy reassures that content edits still
|
|
57
|
+
* follow the normal revision + publish workflow. See docs/I18N.md.
|
|
58
|
+
*/
|
|
59
|
+
export const SystemFieldsConfirmModal = ({
|
|
60
|
+
contentDirty,
|
|
61
|
+
pathDirty,
|
|
62
|
+
availableLocalesDirty,
|
|
63
|
+
onCancel,
|
|
64
|
+
onConfirm,
|
|
65
|
+
}: {
|
|
66
|
+
contentDirty: boolean
|
|
67
|
+
pathDirty: boolean
|
|
68
|
+
availableLocalesDirty: boolean
|
|
69
|
+
onCancel: () => void
|
|
70
|
+
onConfirm: () => void
|
|
71
|
+
}) => {
|
|
72
|
+
const { t } = useTranslation('byline-admin')
|
|
73
|
+
return (
|
|
74
|
+
<Modal isOpen={true} closeOnOverlayClick={true} onDismiss={onCancel}>
|
|
75
|
+
<Modal.Container style={{ maxWidth: '520px' }}>
|
|
76
|
+
<Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
|
|
77
|
+
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
78
|
+
{contentDirty
|
|
79
|
+
? t('forms.systemFieldsConfirm.bothTitle')
|
|
80
|
+
: t('forms.systemFieldsConfirm.title')}
|
|
81
|
+
</h3>
|
|
82
|
+
<IconButton aria-label={t('common.actions.close')} size="xs" onClick={onCancel}>
|
|
83
|
+
<CloseIcon width="16px" height="16px" svgClassName="white-icon" />
|
|
84
|
+
</IconButton>
|
|
85
|
+
</Modal.Header>
|
|
86
|
+
<Modal.Content className="prose">
|
|
87
|
+
{/* Lead with reassurance: content edits follow the normal
|
|
88
|
+
revision + publish workflow. The immediate, document-level
|
|
89
|
+
system-field write is explained below the divider. */}
|
|
90
|
+
{contentDirty && (
|
|
91
|
+
<p className={cx('byline-form-system-fields-content-note', 'm-0 mt-2')}>
|
|
92
|
+
{t('forms.systemFieldsConfirm.contentNote')}
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
<p
|
|
96
|
+
className="m-0 mt-2"
|
|
97
|
+
style={
|
|
98
|
+
contentDirty
|
|
99
|
+
? {
|
|
100
|
+
marginTop: 'var(--spacing-8)',
|
|
101
|
+
paddingTop: 'var(--spacing-12)',
|
|
102
|
+
borderTop: '1px solid var(--border-color)',
|
|
103
|
+
}
|
|
104
|
+
: undefined
|
|
105
|
+
}
|
|
106
|
+
>
|
|
107
|
+
{t('forms.systemFieldsConfirm.intro')}
|
|
108
|
+
</p>
|
|
109
|
+
<ul className={cx('byline-form-system-fields-list', styles['guard-modal-text'], 'm-0')}>
|
|
110
|
+
{pathDirty && <li>{t('forms.systemFieldsConfirm.bulletPath')}</li>}
|
|
111
|
+
{availableLocalesDirty && <li>{t('forms.systemFieldsConfirm.bulletLocales')}</li>}
|
|
112
|
+
</ul>
|
|
113
|
+
<p
|
|
114
|
+
className={cx('byline-form-system-fields-effect', styles['guard-modal-text'])}
|
|
115
|
+
style={{
|
|
116
|
+
marginTop: 'var(--spacing-4)',
|
|
117
|
+
marginBottom: 0,
|
|
118
|
+
color: 'var(--text-subtle)',
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{t('forms.systemFieldsConfirm.effectLine')}
|
|
122
|
+
</p>
|
|
123
|
+
</Modal.Content>
|
|
124
|
+
<Modal.Actions>
|
|
125
|
+
<Button
|
|
126
|
+
size="sm"
|
|
127
|
+
style={{ minWidth: '80px' }}
|
|
128
|
+
intent="noeffect"
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={onCancel}
|
|
131
|
+
>
|
|
132
|
+
{t('common.actions.cancel')}
|
|
133
|
+
</Button>
|
|
134
|
+
<Button
|
|
135
|
+
size="sm"
|
|
136
|
+
style={{ minWidth: '80px' }}
|
|
137
|
+
intent="primary"
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={onConfirm}
|
|
140
|
+
>
|
|
141
|
+
{t('forms.systemFieldsConfirm.confirmButton')}
|
|
142
|
+
</Button>
|
|
143
|
+
</Modal.Actions>
|
|
144
|
+
</Modal.Container>
|
|
145
|
+
</Modal>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Blocks router navigation / browser unload while the form is dirty. Driven by
|
|
151
|
+
* the navigation-guard adapter's `isBlocked` state; `onStay` keeps the editor
|
|
152
|
+
* on the page, `onProceed` discards and continues.
|
|
153
|
+
*/
|
|
154
|
+
export const NavigationGuardModal = ({
|
|
155
|
+
onStay,
|
|
156
|
+
onProceed,
|
|
157
|
+
}: {
|
|
158
|
+
onStay: () => void
|
|
159
|
+
onProceed: () => void
|
|
160
|
+
}) => {
|
|
161
|
+
const { t } = useTranslation('byline-admin')
|
|
162
|
+
return (
|
|
163
|
+
<Modal isOpen={true} closeOnOverlayClick={false} onDismiss={onStay}>
|
|
164
|
+
<Modal.Container style={{ maxWidth: '460px' }}>
|
|
165
|
+
<Modal.Header className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}>
|
|
166
|
+
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
167
|
+
{t('forms.navigationGuard.title')}
|
|
168
|
+
</h3>
|
|
169
|
+
</Modal.Header>
|
|
170
|
+
<Modal.Content>
|
|
171
|
+
<p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
|
|
172
|
+
{t('forms.navigationGuard.message')}
|
|
173
|
+
</p>
|
|
174
|
+
</Modal.Content>
|
|
175
|
+
<Modal.Actions>
|
|
176
|
+
<Button size="sm" intent="noeffect" type="button" onClick={onStay}>
|
|
177
|
+
{t('forms.navigationGuard.stayButton')}
|
|
178
|
+
</Button>
|
|
179
|
+
<Button size="sm" intent="danger" type="button" onClick={onProceed}>
|
|
180
|
+
{t('forms.navigationGuard.leaveButton')}
|
|
181
|
+
</Button>
|
|
182
|
+
</Modal.Actions>
|
|
183
|
+
</Modal.Container>
|
|
184
|
+
</Modal>
|
|
185
|
+
)
|
|
186
|
+
}
|