@byline/admin 3.16.1 → 3.17.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.
- package/dist/fields/field-renderer.d.ts +1 -1
- package/dist/fields/field-renderer.js +8 -1
- package/dist/fields/use-field-change-handler.js +2 -1
- package/dist/fields/use-field-condition.d.ts +24 -0
- package/dist/fields/use-field-condition.js +28 -0
- package/dist/forms/form-context.js +4 -5
- package/package.json +5 -5
- package/src/fields/field-renderer.tsx +21 -1
- package/src/fields/use-field-change-handler.ts +4 -0
- package/src/fields/use-field-condition.ts +52 -0
- package/src/forms/form-context.tsx +16 -4
- package/src/forms/upload-executor.ts +4 -4
|
@@ -33,5 +33,5 @@ interface FieldRendererProps {
|
|
|
33
33
|
*/
|
|
34
34
|
editor?: RichTextEditorComponent;
|
|
35
35
|
}
|
|
36
|
-
export declare const FieldRenderer: ({ field, defaultValue, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, }: FieldRendererProps) => import("react").JSX.Element | null;
|
|
36
|
+
export declare const FieldRenderer: ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor, }: FieldRendererProps) => import("react").JSX.Element | null;
|
|
37
37
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { getClientConfig } from "@byline/core";
|
|
3
3
|
import classnames from "classnames";
|
|
4
|
+
import { useFormContext } from "../forms/form-context.js";
|
|
4
5
|
import { ArrayField } from "./array/array-field.js";
|
|
5
6
|
import { BlocksField } from "./blocks/blocks-field.js";
|
|
6
7
|
import { CheckboxField } from "./checkbox/checkbox-field.js";
|
|
@@ -17,14 +18,20 @@ import { SelectField } from "./select/select-field.js";
|
|
|
17
18
|
import { TextField } from "./text/text-field.js";
|
|
18
19
|
import { TextAreaField } from "./text-area/text-area-field.js";
|
|
19
20
|
import { useFieldChangeHandler } from "./use-field-change-handler.js";
|
|
20
|
-
|
|
21
|
+
import { useFieldCondition } from "./use-field-condition.js";
|
|
22
|
+
const FieldRenderer = ({ field, defaultValue: initialDefault, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor })=>{
|
|
21
23
|
const path = basePath ? `${basePath}.${field.name}` : field.name;
|
|
22
24
|
const htmlId = path.replace(/[[\].]/g, '-');
|
|
23
25
|
const handleChange = useFieldChangeHandler(field, path);
|
|
26
|
+
const { getFieldValue } = useFormContext();
|
|
27
|
+
const visible = useFieldCondition(field, basePath);
|
|
28
|
+
const storedValue = field.condition ? getFieldValue(path) : void 0;
|
|
29
|
+
const defaultValue = void 0 !== storedValue ? storedValue : initialDefault;
|
|
24
30
|
const isLocalised = true === field.localized;
|
|
25
31
|
const badge = isLocalised && contentLocale && !hideLabel ? /*#__PURE__*/ jsx(LocaleBadge, {
|
|
26
32
|
locale: contentLocale
|
|
27
33
|
}) : null;
|
|
34
|
+
if (!visible) return null;
|
|
28
35
|
const renderField = ()=>{
|
|
29
36
|
switch(field.type){
|
|
30
37
|
case 'text':
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { Field } from '@byline/core';
|
|
9
|
+
/**
|
|
10
|
+
* Evaluate a field's visibility `condition` against live form data.
|
|
11
|
+
*
|
|
12
|
+
* Subscribes to the form's meta listeners — fired on every field commit, the
|
|
13
|
+
* same loop `TabDefinition.condition` rides — and recomputes the predicate.
|
|
14
|
+
* The consumer only re-renders when the boolean actually flips, so a stable
|
|
15
|
+
* condition costs one function call per form edit, no reconciliation.
|
|
16
|
+
*
|
|
17
|
+
* `basePath` is the field's sibling scope: the enclosing group / array item
|
|
18
|
+
* (e.g. `files[0].filesGroup` for `files[0].filesGroup.thumbnailPage`).
|
|
19
|
+
* Root-level fields pass no basePath and receive the full form data as their
|
|
20
|
+
* sibling scope, mirroring `FieldCondition`'s contract.
|
|
21
|
+
*
|
|
22
|
+
* Fields without a condition are always visible and no subscription is made.
|
|
23
|
+
*/
|
|
24
|
+
export declare const useFieldCondition: (field: Field, basePath?: string) => boolean;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { useFormContext } from "../forms/form-context.js";
|
|
3
|
+
import { get } from "../forms/nested-path.js";
|
|
4
|
+
const useFieldCondition = (field, basePath)=>{
|
|
5
|
+
const { getFieldValues, subscribeMeta } = useFormContext();
|
|
6
|
+
const evaluate = useCallback(()=>{
|
|
7
|
+
if (!field.condition) return true;
|
|
8
|
+
const data = getFieldValues();
|
|
9
|
+
const siblingData = basePath ? get(data, basePath) ?? {} : data;
|
|
10
|
+
return Boolean(field.condition(data, siblingData));
|
|
11
|
+
}, [
|
|
12
|
+
field,
|
|
13
|
+
basePath,
|
|
14
|
+
getFieldValues
|
|
15
|
+
]);
|
|
16
|
+
const [visible, setVisible] = useState(evaluate);
|
|
17
|
+
useEffect(()=>{
|
|
18
|
+
if (!field.condition) return;
|
|
19
|
+
setVisible(evaluate());
|
|
20
|
+
return subscribeMeta(()=>setVisible(evaluate()));
|
|
21
|
+
}, [
|
|
22
|
+
field.condition,
|
|
23
|
+
subscribeMeta,
|
|
24
|
+
evaluate
|
|
25
|
+
]);
|
|
26
|
+
return visible;
|
|
27
|
+
};
|
|
28
|
+
export { useFieldCondition };
|
|
@@ -144,10 +144,6 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
144
144
|
];
|
|
145
145
|
dirtyFields.current.add('__patch__');
|
|
146
146
|
notifyMetaListeners();
|
|
147
|
-
if ('production' !== process.env.NODE_ENV) console.debug('FormContext.appendPatch', {
|
|
148
|
-
patch,
|
|
149
|
-
dirtyCount: dirtyFields.current.size
|
|
150
|
-
});
|
|
151
147
|
}, [
|
|
152
148
|
notifyMetaListeners
|
|
153
149
|
]);
|
|
@@ -248,6 +244,7 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
248
244
|
const formErrors = [];
|
|
249
245
|
const data = getFieldValues();
|
|
250
246
|
for (const field of fields){
|
|
247
|
+
if (field.condition && !field.condition(data, data)) continue;
|
|
251
248
|
const value = getFieldValue(field.name);
|
|
252
249
|
if (!field.optional && (null == value || '' === value)) formErrors.push({
|
|
253
250
|
field: field.name,
|
|
@@ -331,6 +328,7 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
331
328
|
for (const field of fields){
|
|
332
329
|
const fns = normalizeHooks(field.hooks?.beforeValidate);
|
|
333
330
|
if (0 === fns.length) continue;
|
|
331
|
+
if (field.condition && !field.condition(data, data)) continue;
|
|
334
332
|
const path = field.name;
|
|
335
333
|
const value = getFieldValue(path);
|
|
336
334
|
const ctx = {
|
|
@@ -339,7 +337,8 @@ const FormProvider = ({ children, initialData = {} })=>{
|
|
|
339
337
|
data,
|
|
340
338
|
path,
|
|
341
339
|
field,
|
|
342
|
-
operation: 'submit'
|
|
340
|
+
operation: 'submit',
|
|
341
|
+
setFieldValue
|
|
343
342
|
};
|
|
344
343
|
try {
|
|
345
344
|
for (const fn of fns){
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.17.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -155,10 +155,10 @@
|
|
|
155
155
|
"react-diff-viewer-continued": "^4.2.2",
|
|
156
156
|
"uuid": "^14.0.0",
|
|
157
157
|
"zod": "^4.4.3",
|
|
158
|
-
"@byline/
|
|
159
|
-
"@byline/
|
|
160
|
-
"@byline/ui": "3.
|
|
161
|
-
"@byline/
|
|
158
|
+
"@byline/auth": "3.17.1",
|
|
159
|
+
"@byline/i18n": "3.17.1",
|
|
160
|
+
"@byline/ui": "3.17.1",
|
|
161
|
+
"@byline/core": "3.17.1"
|
|
162
162
|
},
|
|
163
163
|
"peerDependencies": {
|
|
164
164
|
"react": "^19.0.0",
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
import { getClientConfig } from '@byline/core'
|
|
18
18
|
import cx from 'classnames'
|
|
19
19
|
|
|
20
|
+
import { useFormContext } from '../forms/form-context'
|
|
20
21
|
import { ArrayField } from './array/array-field'
|
|
21
22
|
import { BlocksField } from './blocks/blocks-field'
|
|
22
23
|
import { CheckboxField } from './checkbox/checkbox-field'
|
|
@@ -33,6 +34,7 @@ import { SelectField } from './select/select-field'
|
|
|
33
34
|
import { TextField } from './text/text-field'
|
|
34
35
|
import { TextAreaField } from './text-area/text-area-field'
|
|
35
36
|
import { useFieldChangeHandler } from './use-field-change-handler'
|
|
37
|
+
import { useFieldCondition } from './use-field-condition'
|
|
36
38
|
|
|
37
39
|
// ---------------------------------------------------------------------------
|
|
38
40
|
// FieldRenderer — the main field type switch. Delegates to the appropriate
|
|
@@ -69,7 +71,7 @@ interface FieldRendererProps {
|
|
|
69
71
|
|
|
70
72
|
export const FieldRenderer = ({
|
|
71
73
|
field,
|
|
72
|
-
defaultValue,
|
|
74
|
+
defaultValue: initialDefault,
|
|
73
75
|
basePath,
|
|
74
76
|
disableSorting,
|
|
75
77
|
hideLabel,
|
|
@@ -82,6 +84,20 @@ export const FieldRenderer = ({
|
|
|
82
84
|
const htmlId = path.replace(/[[\].]/g, '-')
|
|
83
85
|
|
|
84
86
|
const handleChange = useFieldChangeHandler(field, path)
|
|
87
|
+
const { getFieldValue } = useFormContext()
|
|
88
|
+
|
|
89
|
+
// Conditional visibility (BaseField.condition) — re-evaluated on every form
|
|
90
|
+
// edit; the field unmounts while its condition is false.
|
|
91
|
+
const visible = useFieldCondition(field, basePath)
|
|
92
|
+
|
|
93
|
+
// Conditional fields unmount while hidden, so on re-show the uncontrolled
|
|
94
|
+
// widget must be re-seeded from the live form store (which survives the
|
|
95
|
+
// unmount) rather than the initial document data — otherwise an edit made
|
|
96
|
+
// before hiding would be visually reverted while the store (and the patch
|
|
97
|
+
// stream) still carried it. `undefined` means the path was never written,
|
|
98
|
+
// in which case the initial default stands.
|
|
99
|
+
const storedValue = field.condition ? getFieldValue(path) : undefined
|
|
100
|
+
const defaultValue = storedValue !== undefined ? storedValue : initialDefault
|
|
85
101
|
|
|
86
102
|
// When a locale is active and the field is localised, inject a badge into
|
|
87
103
|
// the field label so the editor knows they are editing locale-specific content.
|
|
@@ -90,6 +106,10 @@ export const FieldRenderer = ({
|
|
|
90
106
|
const badge =
|
|
91
107
|
isLocalised && contentLocale && !hideLabel ? <LocaleBadge locale={contentLocale} /> : null
|
|
92
108
|
|
|
109
|
+
// All hooks have run by this point, so a conditional bail-out is safe.
|
|
110
|
+
// Hiding retains the field's stored value — no clearing patch is emitted.
|
|
111
|
+
if (!visible) return null
|
|
112
|
+
|
|
93
113
|
/**
|
|
94
114
|
* Render the underlying field widget. If the field is localised, we wrap it
|
|
95
115
|
* so we can append the locale badge after the label.
|
|
@@ -53,6 +53,10 @@ export function useFieldChangeHandler(field: Field, path: string) {
|
|
|
53
53
|
path,
|
|
54
54
|
field,
|
|
55
55
|
operation: 'change',
|
|
56
|
+
// Raw store write for cross-field behaviour (e.g. mutual exclusivity
|
|
57
|
+
// across array items). Deliberately does not run the target field's
|
|
58
|
+
// own hooks — see FieldHookContext.setFieldValue.
|
|
59
|
+
setFieldValue,
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
clearFieldError(path)
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { useCallback, useEffect, useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import type { Field } from '@byline/core'
|
|
12
|
+
|
|
13
|
+
import { useFormContext } from '../forms/form-context'
|
|
14
|
+
import { get as getNestedValue } from '../forms/nested-path'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Evaluate a field's visibility `condition` against live form data.
|
|
18
|
+
*
|
|
19
|
+
* Subscribes to the form's meta listeners — fired on every field commit, the
|
|
20
|
+
* same loop `TabDefinition.condition` rides — and recomputes the predicate.
|
|
21
|
+
* The consumer only re-renders when the boolean actually flips, so a stable
|
|
22
|
+
* condition costs one function call per form edit, no reconciliation.
|
|
23
|
+
*
|
|
24
|
+
* `basePath` is the field's sibling scope: the enclosing group / array item
|
|
25
|
+
* (e.g. `files[0].filesGroup` for `files[0].filesGroup.thumbnailPage`).
|
|
26
|
+
* Root-level fields pass no basePath and receive the full form data as their
|
|
27
|
+
* sibling scope, mirroring `FieldCondition`'s contract.
|
|
28
|
+
*
|
|
29
|
+
* Fields without a condition are always visible and no subscription is made.
|
|
30
|
+
*/
|
|
31
|
+
export const useFieldCondition = (field: Field, basePath?: string): boolean => {
|
|
32
|
+
const { getFieldValues, subscribeMeta } = useFormContext()
|
|
33
|
+
|
|
34
|
+
const evaluate = useCallback((): boolean => {
|
|
35
|
+
if (!field.condition) return true
|
|
36
|
+
const data = getFieldValues()
|
|
37
|
+
const siblingData = basePath ? (getNestedValue(data, basePath) ?? {}) : data
|
|
38
|
+
return Boolean(field.condition(data, siblingData))
|
|
39
|
+
}, [field, basePath, getFieldValues])
|
|
40
|
+
|
|
41
|
+
const [visible, setVisible] = useState<boolean>(evaluate)
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!field.condition) return undefined
|
|
45
|
+
// Re-evaluate on (re)subscribe as well as on every subsequent form edit —
|
|
46
|
+
// the store may have changed between the initial render and this effect.
|
|
47
|
+
setVisible(evaluate())
|
|
48
|
+
return subscribeMeta(() => setVisible(evaluate()))
|
|
49
|
+
}, [field.condition, subscribeMeta, evaluate])
|
|
50
|
+
|
|
51
|
+
return visible
|
|
52
|
+
}
|
|
@@ -308,10 +308,11 @@ export const FormProvider = ({
|
|
|
308
308
|
// for patches that don't correspond to a specific field.set.
|
|
309
309
|
dirtyFields.current.add('__patch__')
|
|
310
310
|
notifyMetaListeners()
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
311
|
+
// Dev-time patch tracing — uncomment when debugging the patch stream.
|
|
312
|
+
// if (process.env.NODE_ENV !== 'production') {
|
|
313
|
+
// // eslint-disable-next-line no-console
|
|
314
|
+
// console.debug('FormContext.appendPatch', { patch, dirtyCount: dirtyFields.current.size })
|
|
315
|
+
// }
|
|
315
316
|
},
|
|
316
317
|
[notifyMetaListeners]
|
|
317
318
|
)
|
|
@@ -476,6 +477,12 @@ export const FormProvider = ({
|
|
|
476
477
|
const data = getFieldValues()
|
|
477
478
|
|
|
478
479
|
for (const field of fields) {
|
|
480
|
+
// Condition-hidden fields are exempt from client-side validation — a
|
|
481
|
+
// field the editor cannot currently see must not block submit. Only
|
|
482
|
+
// top-level fields flow through this walk, and a root-level field's
|
|
483
|
+
// sibling scope is the form data itself (see FieldCondition).
|
|
484
|
+
if (field.condition && !field.condition(data, data)) continue
|
|
485
|
+
|
|
479
486
|
const value = getFieldValue(field.name)
|
|
480
487
|
|
|
481
488
|
// Required field validation
|
|
@@ -583,6 +590,10 @@ export const FormProvider = ({
|
|
|
583
590
|
const fns = normalizeHooks(field.hooks?.beforeValidate)
|
|
584
591
|
if (fns.length === 0) continue
|
|
585
592
|
|
|
593
|
+
// Condition-hidden fields skip submit-time hooks, mirroring their
|
|
594
|
+
// exemption from validateForm below.
|
|
595
|
+
if (field.condition && !field.condition(data, data)) continue
|
|
596
|
+
|
|
586
597
|
const path = field.name
|
|
587
598
|
const value = getFieldValue(path)
|
|
588
599
|
|
|
@@ -593,6 +604,7 @@ export const FormProvider = ({
|
|
|
593
604
|
path,
|
|
594
605
|
field,
|
|
595
606
|
operation: 'submit',
|
|
607
|
+
setFieldValue,
|
|
596
608
|
}
|
|
597
609
|
|
|
598
610
|
try {
|
|
@@ -97,10 +97,10 @@ export async function executeUploads(
|
|
|
97
97
|
/**
|
|
98
98
|
* Extract the leaf field name from a `fieldPath`. Top-level upload
|
|
99
99
|
* fields (`'image'`, `'avatar'`) pass through unchanged; nested paths
|
|
100
|
-
* (`'
|
|
101
|
-
* server-side resolver
|
|
102
|
-
*
|
|
103
|
-
*
|
|
100
|
+
* (`'files[0].filesGroup.publicationFile'`) reduce to their last
|
|
101
|
+
* segment. The server-side resolver walks the schema recursively and
|
|
102
|
+
* matches upload fields by leaf name at any nesting depth, so leaf
|
|
103
|
+
* names must be unique among a collection's upload-capable fields.
|
|
104
104
|
*/
|
|
105
105
|
function uploadFieldName(fieldPath: string): string {
|
|
106
106
|
const dot = fieldPath.lastIndexOf('.')
|