@byline/admin 3.16.1 → 3.17.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.
@@ -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
- const FieldRenderer = ({ field, defaultValue, basePath, disableSorting, hideLabel, collectionPath, contentLocale, components, editor })=>{
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':
@@ -15,7 +15,8 @@ function useFieldChangeHandler(field, path) {
15
15
  data: getFieldValues(),
16
16
  path,
17
17
  field,
18
- operation: 'change'
18
+ operation: 'change',
19
+ setFieldValue
19
20
  };
20
21
  clearFieldError(path);
21
22
  if (0 === changeFns.length) setFieldValue(path, value);
@@ -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.16.1",
5
+ "version": "3.17.0",
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/i18n": "3.16.1",
159
- "@byline/core": "3.16.1",
160
- "@byline/ui": "3.16.1",
161
- "@byline/auth": "3.16.1"
158
+ "@byline/auth": "3.17.0",
159
+ "@byline/i18n": "3.17.0",
160
+ "@byline/core": "3.17.0",
161
+ "@byline/ui": "3.17.0"
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
- if (process.env.NODE_ENV !== 'production') {
312
- // eslint-disable-next-line no-console
313
- console.debug('FormContext.appendPatch', { patch, dirtyCount: dirtyFields.current.size })
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 {