@byline/admin 2.7.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/forms/available-locales-reconcile.d.ts +30 -0
- package/dist/forms/available-locales-reconcile.js +15 -0
- package/dist/forms/available-locales-reconcile.test.node.d.ts +8 -0
- package/dist/forms/available-locales-widget.d.ts +29 -0
- package/dist/forms/available-locales-widget.js +68 -0
- package/dist/forms/available-locales-widget.module.js +8 -0
- package/dist/forms/available-locales-widget_module.css +18 -0
- package/dist/forms/form-context.d.ts +10 -0
- package/dist/forms/form-context.js +54 -2
- package/dist/forms/form-renderer.d.ts +8 -1
- package/dist/forms/form-renderer.js +12 -4
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +30 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.js +13 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.module.js +5 -0
- package/dist/widgets/source-locale-badge/source-locale-badge_module.css +10 -0
- package/package.json +5 -5
- package/src/forms/available-locales-reconcile.test.node.ts +51 -0
- package/src/forms/available-locales-reconcile.ts +37 -0
- package/src/forms/available-locales-widget.module.css +39 -0
- package/src/forms/available-locales-widget.tsx +119 -0
- package/src/forms/form-context.tsx +83 -0
- package/src/forms/form-renderer.tsx +29 -1
- package/src/react.ts +1 -0
- package/src/widgets/source-locale-badge/source-locale-badge.module.css +17 -0
- package/src/widgets/source-locale-badge/source-locale-badge.tsx +45 -0
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
* Pure reconciliation logic for the available-locales widget, kept React- and
|
|
10
|
+
* CSS-free so it can be unit-tested in the node-mode admin suite (importing the
|
|
11
|
+
* `.tsx` widget would drag in JSX + a CSS-module import the node runner can't
|
|
12
|
+
* transform).
|
|
13
|
+
*/
|
|
14
|
+
/** Checkbox intent + interactivity for one reconciled locale row. */
|
|
15
|
+
export interface ReconciledLocaleState {
|
|
16
|
+
intent: 'success' | 'warning' | 'noeffect';
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reconcile a locale's editorial state (`checked` — in the stored advertised
|
|
21
|
+
* set) against the ledger fact (`complete` — in `_availableVersionLocales`)
|
|
22
|
+
* into a checkbox intent + disabled flag. The reconciliation is expressed
|
|
23
|
+
* entirely through colour/interactivity, with no per-row text:
|
|
24
|
+
*
|
|
25
|
+
* complete → `success` (green), enabled — toggle on/off
|
|
26
|
+
* not complete, advertised → `warning` (amber), enabled — advertising an
|
|
27
|
+
* incomplete locale; the editor can uncheck to resolve the over-advert
|
|
28
|
+
* not complete, not advert → `noeffect` (gray), disabled — nothing to do
|
|
29
|
+
*/
|
|
30
|
+
export declare function reconcileLocaleState(checked: boolean, complete: boolean): ReconciledLocaleState;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function reconcileLocaleState(checked, complete) {
|
|
2
|
+
if (complete) return {
|
|
3
|
+
intent: 'success',
|
|
4
|
+
disabled: false
|
|
5
|
+
};
|
|
6
|
+
if (checked) return {
|
|
7
|
+
intent: 'warning',
|
|
8
|
+
disabled: false
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
intent: 'noeffect',
|
|
12
|
+
disabled: true
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export { reconcileLocaleState };
|
|
@@ -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,29 @@
|
|
|
1
|
+
export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile';
|
|
2
|
+
/** A content locale to render a checkbox for. */
|
|
3
|
+
export interface AvailableLocalesWidgetLocale {
|
|
4
|
+
code: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
export interface AvailableLocalesWidgetProps {
|
|
8
|
+
/** All configured content locales — one checkbox each (code + display label). */
|
|
9
|
+
contentLocales: ReadonlyArray<AvailableLocalesWidgetLocale>;
|
|
10
|
+
/**
|
|
11
|
+
* The saved version's ledger-complete locale set (`_availableVersionLocales`,
|
|
12
|
+
* read-only structural fact). Drives the per-row intent. Empty until the read
|
|
13
|
+
* surface supplies it (Slice 6) — in which case every row renders neutral.
|
|
14
|
+
*/
|
|
15
|
+
availableVersionLocales: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* System-managed `availableLocales` widget — the editorial "advertise these
|
|
19
|
+
* content locales" control. Renders one checkbox per content locale in the
|
|
20
|
+
* sidebar, directly below the path widget. The checked state reflects the
|
|
21
|
+
* stored advertised set (`useSystemAvailableLocales`); each row's intent
|
|
22
|
+
* reconciles that against the structural ledger fact (`availableVersionLocales`)
|
|
23
|
+
* via {@link reconcileLocaleState}. Opt-in: nothing is advertised until the
|
|
24
|
+
* editor checks a (green) locale.
|
|
25
|
+
*
|
|
26
|
+
* Stable override handles: `.byline-form-available-locales`,
|
|
27
|
+
* `.byline-form-available-locales-list`.
|
|
28
|
+
*/
|
|
29
|
+
export declare const AvailableLocalesWidget: ({ contentLocales, availableVersionLocales, }: AvailableLocalesWidgetProps) => import("react").JSX.Element | null;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useMemo } from "react";
|
|
4
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
+
import { Checkbox, Label } from "@byline/ui/react";
|
|
6
|
+
import classnames from "classnames";
|
|
7
|
+
import { reconcileLocaleState } from "./available-locales-reconcile.js";
|
|
8
|
+
import available_locales_widget_module from "./available-locales-widget.module.js";
|
|
9
|
+
import { useFormContext, useSystemAvailableLocales } from "./form-context.js";
|
|
10
|
+
const AvailableLocalesWidget = ({ contentLocales, availableVersionLocales })=>{
|
|
11
|
+
const { t } = useTranslation('byline-admin');
|
|
12
|
+
const { setSystemAvailableLocales } = useFormContext();
|
|
13
|
+
const advertised = useSystemAvailableLocales();
|
|
14
|
+
const advertisedSet = useMemo(()=>new Set(advertised), [
|
|
15
|
+
advertised
|
|
16
|
+
]);
|
|
17
|
+
const ledgerSet = useMemo(()=>new Set(availableVersionLocales), [
|
|
18
|
+
availableVersionLocales
|
|
19
|
+
]);
|
|
20
|
+
const toggle = useCallback((code, checked)=>{
|
|
21
|
+
const next = new Set(advertised);
|
|
22
|
+
if (checked) next.add(code);
|
|
23
|
+
else next.delete(code);
|
|
24
|
+
setSystemAvailableLocales([
|
|
25
|
+
...next
|
|
26
|
+
]);
|
|
27
|
+
}, [
|
|
28
|
+
advertised,
|
|
29
|
+
setSystemAvailableLocales
|
|
30
|
+
]);
|
|
31
|
+
if (0 === contentLocales.length) return null;
|
|
32
|
+
return /*#__PURE__*/ jsxs("div", {
|
|
33
|
+
className: classnames('byline-form-available-locales', available_locales_widget_module.container),
|
|
34
|
+
children: [
|
|
35
|
+
/*#__PURE__*/ jsx(Label, {
|
|
36
|
+
id: "available-locales",
|
|
37
|
+
htmlFor: "available-locales-group",
|
|
38
|
+
label: t('availableLocalesWidget.label')
|
|
39
|
+
}),
|
|
40
|
+
/*#__PURE__*/ jsx("div", {
|
|
41
|
+
id: "available-locales-group",
|
|
42
|
+
className: classnames('byline-form-available-locales-list', available_locales_widget_module.list),
|
|
43
|
+
role: "group",
|
|
44
|
+
"aria-labelledby": "label-for-available-locales",
|
|
45
|
+
"aria-describedby": "available-locales-description",
|
|
46
|
+
children: contentLocales.map(({ code, label })=>{
|
|
47
|
+
const checked = advertisedSet.has(code);
|
|
48
|
+
const { intent, disabled } = reconcileLocaleState(checked, ledgerSet.has(code));
|
|
49
|
+
return /*#__PURE__*/ jsx(Checkbox, {
|
|
50
|
+
id: `available-locale-${code}`,
|
|
51
|
+
name: `__availableLocale_${code}__`,
|
|
52
|
+
label: label,
|
|
53
|
+
intent: intent,
|
|
54
|
+
checked: checked,
|
|
55
|
+
disabled: disabled,
|
|
56
|
+
onCheckedChange: (value)=>toggle(code, true === value)
|
|
57
|
+
}, code);
|
|
58
|
+
})
|
|
59
|
+
}),
|
|
60
|
+
/*#__PURE__*/ jsx("span", {
|
|
61
|
+
id: "available-locales-description",
|
|
62
|
+
className: classnames('byline-form-available-locales-sr-only', available_locales_widget_module["sr-only"]),
|
|
63
|
+
children: t("availableLocalesWidget.srDescription")
|
|
64
|
+
})
|
|
65
|
+
]
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
export { AvailableLocalesWidget, reconcileLocaleState };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
:is(.container-exFiHE, .byline-form-available-locales), :is(.list-q2z1qA, .byline-form-available-locales-list) {
|
|
2
|
+
gap: var(--spacing-8);
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
display: flex;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
:is(.sr-only-duiY99, .byline-form-available-locales-sr-only) {
|
|
8
|
+
clip: rect(0, 0, 0, 0);
|
|
9
|
+
white-space: nowrap;
|
|
10
|
+
border: 0;
|
|
11
|
+
width: 1px;
|
|
12
|
+
height: 1px;
|
|
13
|
+
margin: -1px;
|
|
14
|
+
padding: 0;
|
|
15
|
+
position: absolute;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
|
|
@@ -28,6 +28,7 @@ type FieldListener = (value: any) => void;
|
|
|
28
28
|
type ErrorsListener = (errors: FormError[]) => void;
|
|
29
29
|
type MetaListener = () => void;
|
|
30
30
|
type SystemPathListener = (value: string | null) => void;
|
|
31
|
+
type SystemAvailableLocalesListener = (value: string[]) => void;
|
|
31
32
|
type FieldUploadingListener = (uploading: boolean) => void;
|
|
32
33
|
interface FormContextType {
|
|
33
34
|
setFieldValue: (name: string, value: any) => void;
|
|
@@ -61,6 +62,9 @@ interface FormContextType {
|
|
|
61
62
|
getSystemPath: () => string | null;
|
|
62
63
|
setSystemPath: (value: string | null) => void;
|
|
63
64
|
subscribeSystemPath: (listener: SystemPathListener) => () => void;
|
|
65
|
+
getSystemAvailableLocales: () => string[];
|
|
66
|
+
setSystemAvailableLocales: (value: string[]) => void;
|
|
67
|
+
subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void;
|
|
64
68
|
}
|
|
65
69
|
export declare const useFormContext: () => FormContextType;
|
|
66
70
|
export declare const FormProvider: ({ children, initialData, }: {
|
|
@@ -72,6 +76,12 @@ export declare const FormProvider: ({ children, initialData, }: {
|
|
|
72
76
|
* Returns the current value (or `null` when no override is set).
|
|
73
77
|
*/
|
|
74
78
|
export declare const useSystemPath: () => string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Subscribe to the system `availableLocales` slot edited by the
|
|
81
|
+
* available-locales widget. Returns the current advertised set (or `[]` when
|
|
82
|
+
* nothing is advertised / not yet surfaced).
|
|
83
|
+
*/
|
|
84
|
+
export declare const useSystemAvailableLocales: () => string[];
|
|
75
85
|
export declare const useFormStore: () => FormContextType;
|
|
76
86
|
export declare const useFieldError: (name: string) => string | undefined;
|
|
77
87
|
export declare const useFormMeta: () => {
|
|
@@ -3,6 +3,16 @@ 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
5
|
import { get, set as external_lodash_es_set } from "lodash-es";
|
|
6
|
+
const sameLocaleSet = (a, b)=>{
|
|
7
|
+
if (a.length !== b.length) return false;
|
|
8
|
+
const sa = [
|
|
9
|
+
...a
|
|
10
|
+
].sort();
|
|
11
|
+
const sb = [
|
|
12
|
+
...b
|
|
13
|
+
].sort();
|
|
14
|
+
return sa.every((v, i)=>v === sb[i]);
|
|
15
|
+
};
|
|
6
16
|
const FormContext = /*#__PURE__*/ createContext(null);
|
|
7
17
|
const useFormContext = ()=>{
|
|
8
18
|
const context = useContext(FormContext);
|
|
@@ -24,6 +34,13 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
24
34
|
const systemPathRef = useRef('string' == typeof initialData?.path && initialData.path.length > 0 ? initialData.path : null);
|
|
25
35
|
const initialSystemPath = useRef(systemPathRef.current);
|
|
26
36
|
const systemPathListeners = useRef(new Set());
|
|
37
|
+
const systemAvailableLocalesRef = useRef(Array.isArray(initialData?.availableLocales) ? [
|
|
38
|
+
...initialData.availableLocales
|
|
39
|
+
] : []);
|
|
40
|
+
const initialSystemAvailableLocales = useRef([
|
|
41
|
+
...systemAvailableLocalesRef.current
|
|
42
|
+
]);
|
|
43
|
+
const systemAvailableLocalesListeners = useRef(new Set());
|
|
27
44
|
const subscribeField = useCallback((name, listener)=>{
|
|
28
45
|
if (!fieldListeners.current.has(name)) fieldListeners.current.set(name, new Set());
|
|
29
46
|
fieldListeners.current.get(name)?.add(listener);
|
|
@@ -134,6 +151,9 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
134
151
|
dirtyFields.current.clear();
|
|
135
152
|
patchesRef.current = [];
|
|
136
153
|
initialSystemPath.current = systemPathRef.current;
|
|
154
|
+
initialSystemAvailableLocales.current = [
|
|
155
|
+
...systemAvailableLocalesRef.current
|
|
156
|
+
];
|
|
137
157
|
notifyMetaListeners();
|
|
138
158
|
}, [
|
|
139
159
|
notifyMetaListeners
|
|
@@ -157,6 +177,27 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
157
177
|
systemPathListeners.current.delete(listener);
|
|
158
178
|
};
|
|
159
179
|
}, []);
|
|
180
|
+
const getSystemAvailableLocales = useCallback(()=>systemAvailableLocalesRef.current, []);
|
|
181
|
+
const setSystemAvailableLocales = useCallback((value)=>{
|
|
182
|
+
const next = [
|
|
183
|
+
...value
|
|
184
|
+
];
|
|
185
|
+
systemAvailableLocalesRef.current = next;
|
|
186
|
+
if (sameLocaleSet(next, initialSystemAvailableLocales.current)) dirtyFields.current.delete('__systemAvailableLocales__');
|
|
187
|
+
else dirtyFields.current.add('__systemAvailableLocales__');
|
|
188
|
+
systemAvailableLocalesListeners.current.forEach((listener)=>{
|
|
189
|
+
listener(next);
|
|
190
|
+
});
|
|
191
|
+
notifyMetaListeners();
|
|
192
|
+
}, [
|
|
193
|
+
notifyMetaListeners
|
|
194
|
+
]);
|
|
195
|
+
const subscribeSystemAvailableLocales = useCallback((listener)=>{
|
|
196
|
+
systemAvailableLocalesListeners.current.add(listener);
|
|
197
|
+
return ()=>{
|
|
198
|
+
systemAvailableLocalesListeners.current.delete(listener);
|
|
199
|
+
};
|
|
200
|
+
}, []);
|
|
160
201
|
const addPendingUpload = useCallback((fieldPath, upload)=>{
|
|
161
202
|
const existing = pendingUploadsRef.current.get(fieldPath);
|
|
162
203
|
if (existing) URL.revokeObjectURL(existing.previewUrl);
|
|
@@ -378,7 +419,10 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
378
419
|
subscribeFieldUploading,
|
|
379
420
|
getSystemPath,
|
|
380
421
|
setSystemPath,
|
|
381
|
-
subscribeSystemPath
|
|
422
|
+
subscribeSystemPath,
|
|
423
|
+
getSystemAvailableLocales,
|
|
424
|
+
setSystemAvailableLocales,
|
|
425
|
+
subscribeSystemAvailableLocales
|
|
382
426
|
},
|
|
383
427
|
children: children
|
|
384
428
|
});
|
|
@@ -391,6 +435,14 @@ const useSystemPath = ()=>{
|
|
|
391
435
|
]);
|
|
392
436
|
return value;
|
|
393
437
|
};
|
|
438
|
+
const useSystemAvailableLocales = ()=>{
|
|
439
|
+
const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext();
|
|
440
|
+
const [value, setValue] = useState(()=>getSystemAvailableLocales());
|
|
441
|
+
useEffect(()=>subscribeSystemAvailableLocales((next)=>setValue(next)), [
|
|
442
|
+
subscribeSystemAvailableLocales
|
|
443
|
+
]);
|
|
444
|
+
return value;
|
|
445
|
+
};
|
|
394
446
|
const useFormStore = ()=>useFormContext();
|
|
395
447
|
const useFieldError = (name)=>{
|
|
396
448
|
const { getErrors, subscribeErrors } = useFormContext();
|
|
@@ -463,4 +515,4 @@ const useIsFieldUploading = (fieldPath)=>{
|
|
|
463
515
|
]);
|
|
464
516
|
return uploading;
|
|
465
517
|
};
|
|
466
|
-
export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useIsFieldUploading, useSystemPath };
|
|
518
|
+
export { FormProvider, useFieldError, useFieldValue, useFormContext, useFormMeta, useFormStore, useIsDirty, useIsFieldUploading, useSystemAvailableLocales, useSystemPath };
|
|
@@ -66,6 +66,13 @@ export interface FormRendererProps {
|
|
|
66
66
|
* present the path widget renders in the sidebar.
|
|
67
67
|
*/
|
|
68
68
|
useAsPath?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Opts the available-locales widget into the sidebar (below the path
|
|
71
|
+
* widget). Sourced from `CollectionDefinition.advertiseLocales` by the
|
|
72
|
+
* caller. When true, one checkbox per content locale renders, reconciled
|
|
73
|
+
* against the document's `_availableVersionLocales` ledger fact.
|
|
74
|
+
*/
|
|
75
|
+
advertiseLocales?: boolean;
|
|
69
76
|
headingLabel?: string;
|
|
70
77
|
headerSlot?: ReactNode;
|
|
71
78
|
/** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
|
|
@@ -95,4 +102,4 @@ export interface FormRendererProps {
|
|
|
95
102
|
*/
|
|
96
103
|
useNavigationGuard?: UseNavigationGuard;
|
|
97
104
|
}
|
|
98
|
-
export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
|
|
105
|
+
export declare const FormRenderer: ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings, }: FormRendererProps) => import("react").JSX.Element;
|
|
@@ -10,6 +10,7 @@ import { LocalDateTime } from "../fields/local-date-time.js";
|
|
|
10
10
|
import { AdminGroup } from "../presentation/group.js";
|
|
11
11
|
import { AdminRow } from "../presentation/row.js";
|
|
12
12
|
import { AdminTabs } from "../presentation/tabs.js";
|
|
13
|
+
import { AvailableLocalesWidget } from "./available-locales-widget.js";
|
|
13
14
|
import { DocumentActions } from "./document-actions.js";
|
|
14
15
|
import { FormProvider, useFieldValue, useFormContext } from "./form-context.js";
|
|
15
16
|
import form_renderer_module from "./form-renderer.module.js";
|
|
@@ -139,8 +140,8 @@ function computeStatusTransitions(currentStatus, workflowStatuses, nextStatus) {
|
|
|
139
140
|
isTerminal: false
|
|
140
141
|
};
|
|
141
142
|
}
|
|
142
|
-
const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
|
|
143
|
-
const { getFieldValues, runFieldHooks, validateForm, errors: initialErrors, hasChanges: hasChangesFn, resetHasChanges, getPatches, getSystemPath, subscribeErrors, subscribeMeta, setFieldValue, setFieldError, getPendingUploads, clearPendingUploads, setFieldUploading } = useFormContext();
|
|
143
|
+
const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, 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
145
|
const { t } = useTranslation('byline-admin');
|
|
145
146
|
const [errors, setErrors] = useState(initialErrors);
|
|
146
147
|
const [hasChanges, setHasChanges] = useState(hasChangesFn());
|
|
@@ -302,11 +303,13 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
302
303
|
const data = getFieldValues();
|
|
303
304
|
const patches = getPatches();
|
|
304
305
|
const systemPath = getSystemPath();
|
|
306
|
+
const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : void 0;
|
|
305
307
|
if (onSubmit && 'function' == typeof onSubmit) {
|
|
306
308
|
onSubmit({
|
|
307
309
|
data,
|
|
308
310
|
patches,
|
|
309
|
-
systemPath
|
|
311
|
+
systemPath,
|
|
312
|
+
systemAvailableLocales
|
|
310
313
|
});
|
|
311
314
|
resetHasChanges();
|
|
312
315
|
}
|
|
@@ -503,6 +506,10 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
503
506
|
activeLocale: contentLocale,
|
|
504
507
|
mode: mode
|
|
505
508
|
}),
|
|
509
|
+
advertiseLocales && /*#__PURE__*/ jsx(AvailableLocalesWidget, {
|
|
510
|
+
contentLocales: contentLocales ?? [],
|
|
511
|
+
availableVersionLocales: initialData?._availableVersionLocales ?? []
|
|
512
|
+
}),
|
|
506
513
|
(layout.sidebar ?? []).map((name)=>renderItem(name))
|
|
507
514
|
]
|
|
508
515
|
})
|
|
@@ -554,7 +561,7 @@ const FormContent = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpub
|
|
|
554
561
|
]
|
|
555
562
|
});
|
|
556
563
|
};
|
|
557
|
-
const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
|
|
564
|
+
const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpublish, onDelete, onDuplicate, onCopyToLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, advertiseLocales, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale, useNavigationGuard, restoreWarnings })=>{
|
|
558
565
|
const savedTabsRef = useRef({});
|
|
559
566
|
return /*#__PURE__*/ jsx(FormProvider, {
|
|
560
567
|
initialData: initialData,
|
|
@@ -576,6 +583,7 @@ const FormRenderer = ({ mode, fields, onSubmit, onCancel, onStatusChange, onUnpu
|
|
|
576
583
|
adminConfig: adminConfig,
|
|
577
584
|
useAsTitle: useAsTitle,
|
|
578
585
|
useAsPath: useAsPath,
|
|
586
|
+
advertiseLocales: advertiseLocales,
|
|
579
587
|
headingLabel: headingLabel,
|
|
580
588
|
headerSlot: headerSlot,
|
|
581
589
|
collectionPath: collectionPath,
|
package/dist/react.d.ts
CHANGED
|
@@ -62,5 +62,6 @@ export * from './presentation/group.js';
|
|
|
62
62
|
export * from './presentation/row.js';
|
|
63
63
|
export * from './presentation/tabs.js';
|
|
64
64
|
export * from './widgets/diff-viewer/diff-modal.js';
|
|
65
|
+
export * from './widgets/source-locale-badge/source-locale-badge.js';
|
|
65
66
|
export * from './widgets/status-badge/status-badge.js';
|
|
66
67
|
export type { BylineFieldServices, CollectionListDoc, CollectionListParams, CollectionListResponse, GetCollectionDocumentsFn, UploadedFileResult, UploadFieldFn, } from './fields/field-services-types.js';
|
package/dist/react.js
CHANGED
|
@@ -33,4 +33,5 @@ export * from "./presentation/group.js";
|
|
|
33
33
|
export * from "./presentation/row.js";
|
|
34
34
|
export * from "./presentation/tabs.js";
|
|
35
35
|
export * from "./widgets/diff-viewer/diff-modal.js";
|
|
36
|
+
export * from "./widgets/source-locale-badge/source-locale-badge.js";
|
|
36
37
|
export * from "./widgets/status-badge/status-badge.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
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 interface SourceLocaleBadgeProps {
|
|
9
|
+
/** The document's content source locale code (e.g. `en`, `fr`). */
|
|
10
|
+
locale: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Small neutral badge indicating a document's content **source locale** — the
|
|
15
|
+
* locale it was authored in (its anchor: fallback floor, path locale,
|
|
16
|
+
* completeness yardstick). Rendered next to the document title in the edit and
|
|
17
|
+
* list views. Mirrors the localized-field {@link LocaleBadge} in spirit but uses
|
|
18
|
+
* the shared {@link Badge} with the `noeffect` (neutral) intent.
|
|
19
|
+
*
|
|
20
|
+
* NOTE: currently rendered for *every* document so the anchor is visible during
|
|
21
|
+
* development. The intended end state is to show it only when `locale` differs
|
|
22
|
+
* from the system's current default content locale (a normal single-default
|
|
23
|
+
* install then shows nothing). See docs/DEFAULT-LOCALE-SWITCHING.md (Slice 6).
|
|
24
|
+
*
|
|
25
|
+
* Stable override handle: `.byline-source-locale-badge`.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SourceLocaleBadge: {
|
|
28
|
+
({ locale, className }: SourceLocaleBadgeProps): import("react").JSX.Element;
|
|
29
|
+
displayName: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Badge } from "@byline/ui/react";
|
|
3
|
+
import classnames from "classnames";
|
|
4
|
+
import source_locale_badge_module from "./source-locale-badge.module.js";
|
|
5
|
+
const SourceLocaleBadge = ({ locale, className })=>/*#__PURE__*/ jsx(Badge, {
|
|
6
|
+
intent: "noeffect",
|
|
7
|
+
render: /*#__PURE__*/ jsx("span", {}),
|
|
8
|
+
title: `Primary content language: ${locale.toUpperCase()}`,
|
|
9
|
+
className: classnames('byline-source-locale-badge', source_locale_badge_module.badge, className),
|
|
10
|
+
children: locale.toUpperCase()
|
|
11
|
+
});
|
|
12
|
+
SourceLocaleBadge.displayName = 'SourceLocaleBadge';
|
|
13
|
+
export { SourceLocaleBadge };
|
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": "
|
|
5
|
+
"version": "3.0.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -147,10 +147,10 @@
|
|
|
147
147
|
"uuid": "^14.0.0",
|
|
148
148
|
"zod": "^4.4.3",
|
|
149
149
|
"zod-form-data": "^3.0.1",
|
|
150
|
-
"@byline/
|
|
151
|
-
"@byline/
|
|
152
|
-
"@byline/
|
|
153
|
-
"@byline/
|
|
150
|
+
"@byline/core": "3.0.0",
|
|
151
|
+
"@byline/i18n": "3.0.0",
|
|
152
|
+
"@byline/auth": "3.0.0",
|
|
153
|
+
"@byline/ui": "3.0.0"
|
|
154
154
|
},
|
|
155
155
|
"peerDependencies": {
|
|
156
156
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { reconcileLocaleState } from './available-locales-reconcile.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The widget's reconciliation is expressed purely as Checkbox intent +
|
|
15
|
+
* disabled state — no per-row text. `reconcileLocaleState` is the pure heart
|
|
16
|
+
* of that mapping; the four cells below are the full truth table from
|
|
17
|
+
* docs/AVAILABLE-LOCALES.md.
|
|
18
|
+
*/
|
|
19
|
+
describe('reconcileLocaleState', () => {
|
|
20
|
+
it('ledger-complete + advertised → green, enabled (advertised & complete)', () => {
|
|
21
|
+
expect(reconcileLocaleState(true, true)).toEqual({ intent: 'success', disabled: false })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('ledger-complete + not advertised → green, enabled (invitation to advertise)', () => {
|
|
25
|
+
expect(reconcileLocaleState(false, true)).toEqual({ intent: 'success', disabled: false })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('not complete + advertised → amber, enabled (advertising an incomplete locale)', () => {
|
|
29
|
+
// The ⚠ row: surfaced as `warning` and kept enabled so the editor can
|
|
30
|
+
// uncheck to resolve the over-advertisement.
|
|
31
|
+
expect(reconcileLocaleState(true, false)).toEqual({ intent: 'warning', disabled: false })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('not complete + not advertised → gray, disabled (nothing to advertise)', () => {
|
|
35
|
+
expect(reconcileLocaleState(false, false)).toEqual({ intent: 'noeffect', disabled: true })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('completeness drives green regardless of advertised state', () => {
|
|
39
|
+
// The ledger fact wins the colour when complete; only the disabled/amber
|
|
40
|
+
// distinction depends on the advertised flag.
|
|
41
|
+
expect(reconcileLocaleState(true, true).intent).toBe('success')
|
|
42
|
+
expect(reconcileLocaleState(false, true).intent).toBe('success')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('only the not-complete + not-advertised cell is non-interactive', () => {
|
|
46
|
+
expect(reconcileLocaleState(true, true).disabled).toBe(false)
|
|
47
|
+
expect(reconcileLocaleState(false, true).disabled).toBe(false)
|
|
48
|
+
expect(reconcileLocaleState(true, false).disabled).toBe(false)
|
|
49
|
+
expect(reconcileLocaleState(false, false).disabled).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Pure reconciliation logic for the available-locales widget, kept React- and
|
|
11
|
+
* CSS-free so it can be unit-tested in the node-mode admin suite (importing the
|
|
12
|
+
* `.tsx` widget would drag in JSX + a CSS-module import the node runner can't
|
|
13
|
+
* transform).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Checkbox intent + interactivity for one reconciled locale row. */
|
|
17
|
+
export interface ReconciledLocaleState {
|
|
18
|
+
intent: 'success' | 'warning' | 'noeffect'
|
|
19
|
+
disabled: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reconcile a locale's editorial state (`checked` — in the stored advertised
|
|
24
|
+
* set) against the ledger fact (`complete` — in `_availableVersionLocales`)
|
|
25
|
+
* into a checkbox intent + disabled flag. The reconciliation is expressed
|
|
26
|
+
* entirely through colour/interactivity, with no per-row text:
|
|
27
|
+
*
|
|
28
|
+
* complete → `success` (green), enabled — toggle on/off
|
|
29
|
+
* not complete, advertised → `warning` (amber), enabled — advertising an
|
|
30
|
+
* incomplete locale; the editor can uncheck to resolve the over-advert
|
|
31
|
+
* not complete, not advert → `noeffect` (gray), disabled — nothing to do
|
|
32
|
+
*/
|
|
33
|
+
export function reconcileLocaleState(checked: boolean, complete: boolean): ReconciledLocaleState {
|
|
34
|
+
if (complete) return { intent: 'success', disabled: false }
|
|
35
|
+
if (checked) return { intent: 'warning', disabled: false }
|
|
36
|
+
return { intent: 'noeffect', disabled: true }
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AvailableLocalesWidget — system-managed `availableLocales` form widget.
|
|
3
|
+
*
|
|
4
|
+
* The editorial "advertise these locales" checkbox list, rendered in the
|
|
5
|
+
* sidebar below the path widget. Per-row reconciliation against the ledger is
|
|
6
|
+
* conveyed by Checkbox `intent` (green/amber/gray) — no per-row text.
|
|
7
|
+
*
|
|
8
|
+
* Override handles:
|
|
9
|
+
* .byline-form-available-locales — wrapper div
|
|
10
|
+
* .byline-form-available-locales-list — checkbox group
|
|
11
|
+
* .byline-form-available-locales-sr-only — visually-hidden screen-reader hint
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
.container,
|
|
15
|
+
:global(.byline-form-available-locales) {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
gap: var(--spacing-8);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.list,
|
|
22
|
+
:global(.byline-form-available-locales-list) {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
gap: var(--spacing-8);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.sr-only,
|
|
29
|
+
:global(.byline-form-available-locales-sr-only) {
|
|
30
|
+
position: absolute;
|
|
31
|
+
width: 1px;
|
|
32
|
+
height: 1px;
|
|
33
|
+
padding: 0;
|
|
34
|
+
margin: -1px;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
clip: rect(0, 0, 0, 0);
|
|
37
|
+
white-space: nowrap;
|
|
38
|
+
border: 0;
|
|
39
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { useCallback, useMemo } from 'react'
|
|
12
|
+
|
|
13
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
14
|
+
import { Checkbox, Label } from '@byline/ui/react'
|
|
15
|
+
import cx from 'classnames'
|
|
16
|
+
|
|
17
|
+
import { reconcileLocaleState } from './available-locales-reconcile'
|
|
18
|
+
import styles from './available-locales-widget.module.css'
|
|
19
|
+
import { useFormContext, useSystemAvailableLocales } from './form-context'
|
|
20
|
+
|
|
21
|
+
export { type ReconciledLocaleState, reconcileLocaleState } from './available-locales-reconcile'
|
|
22
|
+
|
|
23
|
+
/** A content locale to render a checkbox for. */
|
|
24
|
+
export interface AvailableLocalesWidgetLocale {
|
|
25
|
+
code: string
|
|
26
|
+
label: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AvailableLocalesWidgetProps {
|
|
30
|
+
/** All configured content locales — one checkbox each (code + display label). */
|
|
31
|
+
contentLocales: ReadonlyArray<AvailableLocalesWidgetLocale>
|
|
32
|
+
/**
|
|
33
|
+
* The saved version's ledger-complete locale set (`_availableVersionLocales`,
|
|
34
|
+
* read-only structural fact). Drives the per-row intent. Empty until the read
|
|
35
|
+
* surface supplies it (Slice 6) — in which case every row renders neutral.
|
|
36
|
+
*/
|
|
37
|
+
availableVersionLocales: readonly string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* System-managed `availableLocales` widget — the editorial "advertise these
|
|
42
|
+
* content locales" control. Renders one checkbox per content locale in the
|
|
43
|
+
* sidebar, directly below the path widget. The checked state reflects the
|
|
44
|
+
* stored advertised set (`useSystemAvailableLocales`); each row's intent
|
|
45
|
+
* reconciles that against the structural ledger fact (`availableVersionLocales`)
|
|
46
|
+
* via {@link reconcileLocaleState}. Opt-in: nothing is advertised until the
|
|
47
|
+
* editor checks a (green) locale.
|
|
48
|
+
*
|
|
49
|
+
* Stable override handles: `.byline-form-available-locales`,
|
|
50
|
+
* `.byline-form-available-locales-list`.
|
|
51
|
+
*/
|
|
52
|
+
export const AvailableLocalesWidget = ({
|
|
53
|
+
contentLocales,
|
|
54
|
+
availableVersionLocales,
|
|
55
|
+
}: AvailableLocalesWidgetProps) => {
|
|
56
|
+
const { t } = useTranslation('byline-admin')
|
|
57
|
+
const { setSystemAvailableLocales } = useFormContext()
|
|
58
|
+
const advertised = useSystemAvailableLocales()
|
|
59
|
+
|
|
60
|
+
const advertisedSet = useMemo(() => new Set(advertised), [advertised])
|
|
61
|
+
const ledgerSet = useMemo(() => new Set(availableVersionLocales), [availableVersionLocales])
|
|
62
|
+
|
|
63
|
+
const toggle = useCallback(
|
|
64
|
+
(code: string, checked: boolean) => {
|
|
65
|
+
const next = new Set(advertised)
|
|
66
|
+
if (checked) {
|
|
67
|
+
next.add(code)
|
|
68
|
+
} else {
|
|
69
|
+
next.delete(code)
|
|
70
|
+
}
|
|
71
|
+
setSystemAvailableLocales([...next])
|
|
72
|
+
},
|
|
73
|
+
[advertised, setSystemAvailableLocales]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (contentLocales.length === 0) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className={cx('byline-form-available-locales', styles.container)}>
|
|
82
|
+
<Label
|
|
83
|
+
id="available-locales"
|
|
84
|
+
htmlFor="available-locales-group"
|
|
85
|
+
label={t('availableLocalesWidget.label')}
|
|
86
|
+
/>
|
|
87
|
+
<div
|
|
88
|
+
id="available-locales-group"
|
|
89
|
+
className={cx('byline-form-available-locales-list', styles.list)}
|
|
90
|
+
role="group"
|
|
91
|
+
aria-labelledby="label-for-available-locales"
|
|
92
|
+
aria-describedby="available-locales-description"
|
|
93
|
+
>
|
|
94
|
+
{contentLocales.map(({ code, label }) => {
|
|
95
|
+
const checked = advertisedSet.has(code)
|
|
96
|
+
const { intent, disabled } = reconcileLocaleState(checked, ledgerSet.has(code))
|
|
97
|
+
return (
|
|
98
|
+
<Checkbox
|
|
99
|
+
key={code}
|
|
100
|
+
id={`available-locale-${code}`}
|
|
101
|
+
name={`__availableLocale_${code}__`}
|
|
102
|
+
label={label}
|
|
103
|
+
intent={intent}
|
|
104
|
+
checked={checked}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
onCheckedChange={(value) => toggle(code, value === true)}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
<span
|
|
112
|
+
id="available-locales-description"
|
|
113
|
+
className={cx('byline-form-available-locales-sr-only', styles['sr-only'])}
|
|
114
|
+
>
|
|
115
|
+
{t('availableLocalesWidget.srDescription')}
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -38,8 +38,21 @@ type FieldListener = (value: any) => void
|
|
|
38
38
|
type ErrorsListener = (errors: FormError[]) => void
|
|
39
39
|
type MetaListener = () => void
|
|
40
40
|
type SystemPathListener = (value: string | null) => void
|
|
41
|
+
type SystemAvailableLocalesListener = (value: string[]) => void
|
|
41
42
|
type FieldUploadingListener = (uploading: boolean) => void
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Order-insensitive set equality for the advertised-locale slot. The slot
|
|
46
|
+
* holds an array, so a fresh array reference is never `===` its initial — dirty
|
|
47
|
+
* tracking must compare membership, not identity.
|
|
48
|
+
*/
|
|
49
|
+
const sameLocaleSet = (a: string[], b: string[]): boolean => {
|
|
50
|
+
if (a.length !== b.length) return false
|
|
51
|
+
const sa = [...a].sort()
|
|
52
|
+
const sb = [...b].sort()
|
|
53
|
+
return sa.every((v, i) => v === sb[i])
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
interface FormContextType {
|
|
44
57
|
setFieldValue: (name: string, value: any) => void
|
|
45
58
|
setFieldStore: (name: string, value: any) => void
|
|
@@ -80,6 +93,13 @@ interface FormContextType {
|
|
|
80
93
|
getSystemPath: () => string | null
|
|
81
94
|
setSystemPath: (value: string | null) => void
|
|
82
95
|
subscribeSystemPath: (listener: SystemPathListener) => () => void
|
|
96
|
+
// System-managed `availableLocales` slot (the editorial advertised-locale
|
|
97
|
+
// set, persisted in `byline_document_available_locales`), edited by the
|
|
98
|
+
// available-locales widget. Holds the full set; the value is sent verbatim
|
|
99
|
+
// to the server. Document-grain and sticky, like the path slot above.
|
|
100
|
+
getSystemAvailableLocales: () => string[]
|
|
101
|
+
setSystemAvailableLocales: (value: string[]) => void
|
|
102
|
+
subscribeSystemAvailableLocales: (listener: SystemAvailableLocalesListener) => () => void
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
const FormContext = createContext<FormContextType | null>(null)
|
|
@@ -125,6 +145,16 @@ export const FormProvider = ({
|
|
|
125
145
|
const initialSystemPath = useRef<string | null>(systemPathRef.current)
|
|
126
146
|
const systemPathListeners = useRef<Set<SystemPathListener>>(new Set())
|
|
127
147
|
|
|
148
|
+
// System available-locales slot — initialised from the loaded version's
|
|
149
|
+
// top-level `availableLocales` (edit mode) or `[]` (create mode / not yet
|
|
150
|
+
// surfaced). Edits via `setSystemAvailableLocales` mark the form dirty so
|
|
151
|
+
// the Save button enables. Stored as a defensive copy.
|
|
152
|
+
const systemAvailableLocalesRef = useRef<string[]>(
|
|
153
|
+
Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : []
|
|
154
|
+
)
|
|
155
|
+
const initialSystemAvailableLocales = useRef<string[]>([...systemAvailableLocalesRef.current])
|
|
156
|
+
const systemAvailableLocalesListeners = useRef<Set<SystemAvailableLocalesListener>>(new Set())
|
|
157
|
+
|
|
128
158
|
const subscribeField = useCallback((name: string, listener: FieldListener) => {
|
|
129
159
|
if (!fieldListeners.current.has(name)) {
|
|
130
160
|
fieldListeners.current.set(name, new Set())
|
|
@@ -267,6 +297,7 @@ export const FormProvider = ({
|
|
|
267
297
|
dirtyFields.current.clear()
|
|
268
298
|
patchesRef.current = []
|
|
269
299
|
initialSystemPath.current = systemPathRef.current
|
|
300
|
+
initialSystemAvailableLocales.current = [...systemAvailableLocalesRef.current]
|
|
270
301
|
notifyMetaListeners()
|
|
271
302
|
}, [notifyMetaListeners])
|
|
272
303
|
|
|
@@ -303,6 +334,39 @@ export const FormProvider = ({
|
|
|
303
334
|
}
|
|
304
335
|
}, [])
|
|
305
336
|
|
|
337
|
+
// -------------------------------------------------------------------------
|
|
338
|
+
// System available-locales slot
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
const getSystemAvailableLocales = useCallback(() => systemAvailableLocalesRef.current, [])
|
|
342
|
+
|
|
343
|
+
const setSystemAvailableLocales = useCallback(
|
|
344
|
+
(value: string[]) => {
|
|
345
|
+
const next = [...value]
|
|
346
|
+
systemAvailableLocalesRef.current = next
|
|
347
|
+
if (!sameLocaleSet(next, initialSystemAvailableLocales.current)) {
|
|
348
|
+
dirtyFields.current.add('__systemAvailableLocales__')
|
|
349
|
+
} else {
|
|
350
|
+
dirtyFields.current.delete('__systemAvailableLocales__')
|
|
351
|
+
}
|
|
352
|
+
systemAvailableLocalesListeners.current.forEach((listener) => {
|
|
353
|
+
listener(next)
|
|
354
|
+
})
|
|
355
|
+
notifyMetaListeners()
|
|
356
|
+
},
|
|
357
|
+
[notifyMetaListeners]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
const subscribeSystemAvailableLocales = useCallback(
|
|
361
|
+
(listener: SystemAvailableLocalesListener) => {
|
|
362
|
+
systemAvailableLocalesListeners.current.add(listener)
|
|
363
|
+
return () => {
|
|
364
|
+
systemAvailableLocalesListeners.current.delete(listener)
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
[]
|
|
368
|
+
)
|
|
369
|
+
|
|
306
370
|
// ---------------------------------------------------------------------------
|
|
307
371
|
// Pending uploads (deferred until save)
|
|
308
372
|
// ---------------------------------------------------------------------------
|
|
@@ -591,6 +655,9 @@ export const FormProvider = ({
|
|
|
591
655
|
getSystemPath,
|
|
592
656
|
setSystemPath,
|
|
593
657
|
subscribeSystemPath,
|
|
658
|
+
getSystemAvailableLocales,
|
|
659
|
+
setSystemAvailableLocales,
|
|
660
|
+
subscribeSystemAvailableLocales,
|
|
594
661
|
}}
|
|
595
662
|
>
|
|
596
663
|
{children}
|
|
@@ -613,6 +680,22 @@ export const useSystemPath = (): string | null => {
|
|
|
613
680
|
return value
|
|
614
681
|
}
|
|
615
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Subscribe to the system `availableLocales` slot edited by the
|
|
685
|
+
* available-locales widget. Returns the current advertised set (or `[]` when
|
|
686
|
+
* nothing is advertised / not yet surfaced).
|
|
687
|
+
*/
|
|
688
|
+
export const useSystemAvailableLocales = (): string[] => {
|
|
689
|
+
const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext()
|
|
690
|
+
const [value, setValue] = useState<string[]>(() => getSystemAvailableLocales())
|
|
691
|
+
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
return subscribeSystemAvailableLocales((next) => setValue(next))
|
|
694
|
+
}, [subscribeSystemAvailableLocales])
|
|
695
|
+
|
|
696
|
+
return value
|
|
697
|
+
}
|
|
698
|
+
|
|
616
699
|
export const useFormStore = () => {
|
|
617
700
|
return useFormContext()
|
|
618
701
|
}
|
|
@@ -28,6 +28,7 @@ import { LocalDateTime } from '../fields/local-date-time'
|
|
|
28
28
|
import { AdminGroup } from '../presentation/group'
|
|
29
29
|
import { AdminRow } from '../presentation/row'
|
|
30
30
|
import { AdminTabs } from '../presentation/tabs'
|
|
31
|
+
import { AvailableLocalesWidget } from './available-locales-widget'
|
|
31
32
|
import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
|
|
32
33
|
import { FormProvider, useFieldValue, useFormContext } from './form-context'
|
|
33
34
|
import styles from './form-renderer.module.css'
|
|
@@ -91,6 +92,13 @@ export interface FormRendererProps {
|
|
|
91
92
|
* present the path widget renders in the sidebar.
|
|
92
93
|
*/
|
|
93
94
|
useAsPath?: string
|
|
95
|
+
/**
|
|
96
|
+
* Opts the available-locales widget into the sidebar (below the path
|
|
97
|
+
* widget). Sourced from `CollectionDefinition.advertiseLocales` by the
|
|
98
|
+
* caller. When true, one checkbox per content locale renders, reconciled
|
|
99
|
+
* against the document's `_availableVersionLocales` ledger fact.
|
|
100
|
+
*/
|
|
101
|
+
advertiseLocales?: boolean
|
|
94
102
|
headingLabel?: string
|
|
95
103
|
headerSlot?: ReactNode
|
|
96
104
|
/** Collection path forwarded to upload-capable fields (e.g. `'media'`). */
|
|
@@ -299,6 +307,7 @@ const FormContent = ({
|
|
|
299
307
|
adminConfig,
|
|
300
308
|
useAsTitle,
|
|
301
309
|
useAsPath,
|
|
310
|
+
advertiseLocales,
|
|
302
311
|
headingLabel,
|
|
303
312
|
headerSlot,
|
|
304
313
|
collectionPath,
|
|
@@ -323,6 +332,7 @@ const FormContent = ({
|
|
|
323
332
|
resetHasChanges,
|
|
324
333
|
getPatches,
|
|
325
334
|
getSystemPath,
|
|
335
|
+
getSystemAvailableLocales,
|
|
326
336
|
subscribeErrors,
|
|
327
337
|
subscribeMeta,
|
|
328
338
|
setFieldValue,
|
|
@@ -560,9 +570,13 @@ const FormContent = ({
|
|
|
560
570
|
const data = getFieldValues()
|
|
561
571
|
const patches = getPatches()
|
|
562
572
|
const systemPath = getSystemPath()
|
|
573
|
+
// Only emit the advertised-locale set for collections that opted into the
|
|
574
|
+
// widget — otherwise leave it undefined so the write path never touches
|
|
575
|
+
// `byline_document_available_locales` for non-advertising collections.
|
|
576
|
+
const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : undefined
|
|
563
577
|
|
|
564
578
|
if (onSubmit && typeof onSubmit === 'function') {
|
|
565
|
-
onSubmit({ data, patches, systemPath })
|
|
579
|
+
onSubmit({ data, patches, systemPath, systemAvailableLocales })
|
|
566
580
|
resetHasChanges()
|
|
567
581
|
}
|
|
568
582
|
})()
|
|
@@ -658,6 +672,10 @@ const FormContent = ({
|
|
|
658
672
|
<form noValidate onSubmit={handleSubmit} className={cx('byline-form', styles.form)}>
|
|
659
673
|
<div className={cx('byline-form-heading-row', styles['heading-row'])}>
|
|
660
674
|
<h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
|
|
675
|
+
{/* Source-locale anchor indicator removed pending heading-layout work.
|
|
676
|
+
To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
|
|
677
|
+
here from `initialData.sourceLocale` (mismatch-only is the intended
|
|
678
|
+
end state). See docs/DEFAULT-LOCALE-SWITCHING.md (Slice 6). */}
|
|
661
679
|
{headerSlot}
|
|
662
680
|
</div>
|
|
663
681
|
<div className={cx('byline-form-status-bar', styles['status-bar'])}>
|
|
@@ -786,6 +804,14 @@ const FormContent = ({
|
|
|
786
804
|
mode={mode}
|
|
787
805
|
/>
|
|
788
806
|
)}
|
|
807
|
+
{advertiseLocales && (
|
|
808
|
+
<AvailableLocalesWidget
|
|
809
|
+
contentLocales={contentLocales ?? []}
|
|
810
|
+
availableVersionLocales={
|
|
811
|
+
(initialData?._availableVersionLocales as string[] | undefined) ?? []
|
|
812
|
+
}
|
|
813
|
+
/>
|
|
814
|
+
)}
|
|
789
815
|
{(layout.sidebar ?? []).map((name) => renderItem(name))}
|
|
790
816
|
</div>
|
|
791
817
|
</div>
|
|
@@ -837,6 +863,7 @@ export const FormRenderer = ({
|
|
|
837
863
|
adminConfig,
|
|
838
864
|
useAsTitle,
|
|
839
865
|
useAsPath,
|
|
866
|
+
advertiseLocales,
|
|
840
867
|
headingLabel,
|
|
841
868
|
headerSlot,
|
|
842
869
|
collectionPath,
|
|
@@ -873,6 +900,7 @@ export const FormRenderer = ({
|
|
|
873
900
|
adminConfig={adminConfig}
|
|
874
901
|
useAsTitle={useAsTitle}
|
|
875
902
|
useAsPath={useAsPath}
|
|
903
|
+
advertiseLocales={advertiseLocales}
|
|
876
904
|
headingLabel={headingLabel}
|
|
877
905
|
headerSlot={headerSlot}
|
|
878
906
|
collectionPath={collectionPath}
|
package/src/react.ts
CHANGED
|
@@ -71,6 +71,7 @@ export * from './presentation/row.js'
|
|
|
71
71
|
export * from './presentation/tabs.js'
|
|
72
72
|
// Collection-editor-shared widgets.
|
|
73
73
|
export * from './widgets/diff-viewer/diff-modal.js'
|
|
74
|
+
export * from './widgets/source-locale-badge/source-locale-badge.js'
|
|
74
75
|
export * from './widgets/status-badge/status-badge.js'
|
|
75
76
|
// Field-side service contract types.
|
|
76
77
|
export type {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SourceLocaleBadge — compact neutral pill marking a document's content source
|
|
3
|
+
* locale, shown next to the document title in edit and list views.
|
|
4
|
+
*
|
|
5
|
+
* Override handle: `.byline-source-locale-badge`
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.badge,
|
|
9
|
+
:global(.byline-source-locale-badge) {
|
|
10
|
+
margin-left: 0.5rem;
|
|
11
|
+
padding: 0 0.375rem;
|
|
12
|
+
font-size: 0.65rem;
|
|
13
|
+
font-weight: var(--font-weight-semibold);
|
|
14
|
+
line-height: 1.5;
|
|
15
|
+
letter-spacing: var(--letter-spacing-wide);
|
|
16
|
+
vertical-align: middle;
|
|
17
|
+
}
|
|
@@ -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
|
+
|
|
9
|
+
import { Badge } from '@byline/ui/react'
|
|
10
|
+
import cx from 'classnames'
|
|
11
|
+
|
|
12
|
+
import styles from './source-locale-badge.module.css'
|
|
13
|
+
|
|
14
|
+
export interface SourceLocaleBadgeProps {
|
|
15
|
+
/** The document's content source locale code (e.g. `en`, `fr`). */
|
|
16
|
+
locale: string
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Small neutral badge indicating a document's content **source locale** — the
|
|
22
|
+
* locale it was authored in (its anchor: fallback floor, path locale,
|
|
23
|
+
* completeness yardstick). Rendered next to the document title in the edit and
|
|
24
|
+
* list views. Mirrors the localized-field {@link LocaleBadge} in spirit but uses
|
|
25
|
+
* the shared {@link Badge} with the `noeffect` (neutral) intent.
|
|
26
|
+
*
|
|
27
|
+
* NOTE: currently rendered for *every* document so the anchor is visible during
|
|
28
|
+
* development. The intended end state is to show it only when `locale` differs
|
|
29
|
+
* from the system's current default content locale (a normal single-default
|
|
30
|
+
* install then shows nothing). See docs/DEFAULT-LOCALE-SWITCHING.md (Slice 6).
|
|
31
|
+
*
|
|
32
|
+
* Stable override handle: `.byline-source-locale-badge`.
|
|
33
|
+
*/
|
|
34
|
+
export const SourceLocaleBadge = ({ locale, className }: SourceLocaleBadgeProps) => (
|
|
35
|
+
<Badge
|
|
36
|
+
intent="noeffect"
|
|
37
|
+
render={<span />}
|
|
38
|
+
title={`Primary content language: ${locale.toUpperCase()}`}
|
|
39
|
+
className={cx('byline-source-locale-badge', styles.badge, className)}
|
|
40
|
+
>
|
|
41
|
+
{locale.toUpperCase()}
|
|
42
|
+
</Badge>
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
SourceLocaleBadge.displayName = 'SourceLocaleBadge'
|