@nocobase/client-v2 2.1.11 → 2.2.0-alpha.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.
Files changed (137) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/RouteRepository.d.ts +21 -6
  3. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  4. package/es/components/category-tabs/index.d.ts +9 -0
  5. package/es/components/form/TypedVariableInput.d.ts +22 -4
  6. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  7. package/es/components/form/filter/index.d.ts +2 -0
  8. package/es/components/index.d.ts +2 -0
  9. package/es/flow/actions/afterSuccess.d.ts +8 -1
  10. package/es/flow/actions/index.d.ts +1 -1
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  13. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  14. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  15. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  16. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  17. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  18. package/es/flow/index.d.ts +1 -0
  19. package/es/flow/models/base/GridModel.d.ts +1 -1
  20. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  21. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  23. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  24. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  25. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  26. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  27. package/es/index.d.ts +2 -1
  28. package/es/index.mjs +181 -131
  29. package/lib/index.js +186 -136
  30. package/package.json +7 -7
  31. package/src/PluginSettingsManager.ts +53 -0
  32. package/src/RouteRepository.ts +126 -24
  33. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  34. package/src/__tests__/RouteRepository.test.ts +216 -0
  35. package/src/__tests__/browserChecker.test.ts +61 -0
  36. package/src/__tests__/exports.test.ts +16 -0
  37. package/src/__tests__/plugin-manager.test.tsx +44 -2
  38. package/src/__tests__/settings-center.test.tsx +40 -1
  39. package/src/collection-manager/field-configure.ts +1 -1
  40. package/src/collection-manager/interfaces/id.ts +1 -1
  41. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  42. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  43. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  44. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  45. package/src/collection-manager/interfaces/obo.tsx +2 -2
  46. package/src/collection-manager/interfaces/oho.tsx +2 -2
  47. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  48. package/src/collection-manager/interfaces/uuid.ts +1 -1
  49. package/src/components/README.md +7 -1
  50. package/src/components/README.zh-CN.md +6 -1
  51. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  52. package/src/components/category-tabs/index.ts +10 -0
  53. package/src/components/form/TypedVariableInput.tsx +416 -93
  54. package/src/components/form/__tests__/TypedVariableInput.test.tsx +266 -9
  55. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  56. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  57. package/src/components/form/filter/index.ts +2 -0
  58. package/src/components/index.ts +2 -0
  59. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  60. package/src/flow/__tests__/getKey.test.ts +7 -0
  61. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  62. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  63. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
  64. package/src/flow/actions/afterSuccess.tsx +142 -3
  65. package/src/flow/actions/customVariable.tsx +1 -2
  66. package/src/flow/actions/index.ts +1 -1
  67. package/src/flow/actions/linkageRules.tsx +2 -7
  68. package/src/flow/actions/openView.tsx +38 -4
  69. package/src/flow/actions/runjs.tsx +2 -14
  70. package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
  71. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
  72. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
  73. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +34 -10
  74. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  75. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  76. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  77. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  78. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  79. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  80. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  81. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  82. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  83. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
  84. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  85. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  86. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  87. package/src/flow/components/DefaultValue.tsx +1 -2
  88. package/src/flow/components/FieldAssignValueInput.tsx +10 -5
  89. package/src/flow/components/FlowRoute.tsx +56 -11
  90. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  91. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  92. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  93. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  94. package/src/flow/index.ts +1 -0
  95. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  96. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  97. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  98. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  99. package/src/flow/models/base/ActionModel.tsx +12 -1
  100. package/src/flow/models/base/GridModel.tsx +38 -7
  101. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +0 -4
  102. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  103. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  104. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  105. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -2
  106. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  107. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  108. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  109. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  110. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  111. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  112. package/src/flow/models/blocks/form/value-runtime/rules.ts +2 -2
  113. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  114. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  115. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +36 -13
  116. package/src/flow/models/blocks/table/TableBlockModel.tsx +38 -17
  117. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +111 -1
  118. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  119. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  120. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  121. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  122. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  123. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  124. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  125. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  126. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  127. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  128. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  129. package/src/flow-compat/Popover.tsx +43 -4
  130. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  131. package/src/index.ts +8 -1
  132. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  133. package/src/nocobase-buildin-plugin/index.tsx +0 -2
  134. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  135. package/src/settings-center/SystemSettingsPage.tsx +1 -2
  136. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  137. package/src/settings-center/utils.tsx +0 -6
@@ -10,13 +10,15 @@
10
10
  import { CloseCircleFilled } from '@ant-design/icons';
11
11
  import {
12
12
  buildContextSelectorItems,
13
+ loadMetaTreeChildren,
13
14
  useFlowContext,
14
15
  type ContextSelectorItem,
15
16
  type MetaTreeNode,
16
17
  } from '@nocobase/flow-engine';
17
18
  import { Button, Cascader, DatePicker, Input, InputNumber, Select, Space, Tag, theme, type CascaderProps } from 'antd';
18
19
  import dayjs from 'dayjs';
19
- import React, { useCallback, useMemo } from 'react';
20
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
21
+ import { css } from '@emotion/css';
20
22
  import {
21
23
  makeFormatVariablePath,
22
24
  makeParseVariablePath,
@@ -25,11 +27,11 @@ import {
25
27
  } from './VariableInput';
26
28
 
27
29
  /**
28
- * Constant types this input can edit. Subset of v1 `Variable.Input`
29
- * `useTypedConstant` drops `'object'` (no v2 JSON editor yet) and `'null'`
30
- * (handled by the dedicated `nullable` prop).
30
+ * Constant types this input can edit. Matches v1 `Variable.Input`
31
+ * `useTypedConstant` minus `'null'` (handled by the dedicated `nullable` prop).
32
+ * `'object'` renders an inline JSON editor (textarea).
31
33
  */
32
- export type TypedConstantType = 'string' | 'number' | 'boolean' | 'date';
34
+ export type TypedConstantType = 'string' | 'number' | 'boolean' | 'date' | 'object';
33
35
 
34
36
  /**
35
37
  * One allowed constant type. Either a bare type name (`'number'`) or a
@@ -57,16 +59,34 @@ export interface TypedVariableInputProps {
57
59
  * Restrict the variable picker to specific top-level meta-tree namespaces
58
60
  * (e.g. `['$env']`). When omitted, every registered top-level property is
59
61
  * exposed — matching `VariableInput`'s default behaviour.
62
+ *
63
+ * Ignored when `metaTree` is supplied (an explicit tree wins).
60
64
  */
61
65
  namespaces?: string[];
62
66
  /** Additional leaves appended to the picker after the namespace-filtered nodes. */
63
67
  extraNodes?: MetaTreeNode[];
68
+ /**
69
+ * Provide the variable tree explicitly instead of reading the global
70
+ * FlowContext meta tree. When set, `namespaces`/`extraNodes` are ignored and
71
+ * this tree is used verbatim — use for context-scoped variable sources that
72
+ * are not part of the global registry (e.g. a workflow node's upstream
73
+ * outputs). Lazy `children` thunks are resolved on demand as the user
74
+ * expands the cascader.
75
+ */
76
+ metaTree?: MetaTreeNode[];
64
77
  /**
65
78
  * When true (default), the switcher exposes a `Null` option that resets the
66
79
  * value to `null`. When false, the value is constrained to one of the
67
80
  * allowed types or a variable reference.
68
81
  */
69
82
  nullable?: boolean;
83
+ /**
84
+ * Opt-in: when the incoming `value` is `undefined`, initialize the editor to
85
+ * the first allowed constant type instead of rendering the null-mode
86
+ * placeholder. `null` still keeps its explicit null semantics; only
87
+ * `undefined` triggers this path.
88
+ */
89
+ defaultToFirstConstantTypeWhenUndefined?: boolean;
70
90
  /** Variable-token delimiters. Default `['{{', '}}']` — see `VariableInput`. */
71
91
  delimiters?: VariableDelimiters;
72
92
  disabled?: boolean;
@@ -82,6 +102,7 @@ const TYPE_LABEL_KEYS: Record<TypedConstantType, string> = {
82
102
  number: 'Number',
83
103
  boolean: 'Boolean',
84
104
  date: 'Date',
105
+ object: 'JSON',
85
106
  };
86
107
 
87
108
  type NormalizedType = { type: TypedConstantType; props: Record<string, unknown> };
@@ -104,6 +125,8 @@ function defaultValueFor(type: TypedConstantType): unknown {
104
125
  const now = new Date();
105
126
  return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
106
127
  }
128
+ case 'object':
129
+ return {};
107
130
  default:
108
131
  return null;
109
132
  }
@@ -121,6 +144,7 @@ function detectMode(value: unknown, parseVariablePath: (v?: string) => string[]
121
144
  if (typeof value === 'number') return { mode: 'number' };
122
145
  if (typeof value === 'boolean') return { mode: 'boolean' };
123
146
  if (value instanceof Date) return { mode: 'date' };
147
+ if (typeof value === 'object') return { mode: 'object' };
124
148
  return { mode: 'string' };
125
149
  }
126
150
 
@@ -131,6 +155,7 @@ interface SwitcherOption {
131
155
  value: string;
132
156
  label: React.ReactNode;
133
157
  isLeaf?: boolean;
158
+ loading?: boolean;
134
159
  children?: SwitcherOption[];
135
160
  meta?: MetaTreeNode;
136
161
  paths?: string[];
@@ -175,9 +200,10 @@ function renderConstantEditor(
175
200
  disabled?: boolean;
176
201
  placeholder?: string;
177
202
  t: (text: string) => string;
203
+ onJsonError?: (message: string | null) => void;
178
204
  },
179
205
  ): React.ReactNode {
180
- const { typedProps, disabled, placeholder, t } = options;
206
+ const { typedProps, disabled, placeholder, t, onJsonError } = options;
181
207
  switch (type) {
182
208
  case 'string':
183
209
  return (
@@ -229,11 +255,126 @@ function renderConstantEditor(
229
255
  />
230
256
  );
231
257
  }
258
+ case 'object':
259
+ return (
260
+ <JsonConstantEditor
261
+ value={value}
262
+ onChange={onChange}
263
+ onError={onJsonError}
264
+ disabled={disabled}
265
+ typedProps={typedProps}
266
+ />
267
+ );
232
268
  default:
233
269
  return null;
234
270
  }
235
271
  }
236
272
 
273
+ const jsonEditorClassName = css`
274
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
275
+ font-size: 80%;
276
+ `;
277
+
278
+ /**
279
+ * Inline JSON editor for the `'object'` constant type. Formily-free port of v1's
280
+ * `Json` input (which used a Formily field for error feedback): keeps the raw
281
+ * text as local draft state, parses on blur, and writes the parsed object up via
282
+ * `onChange`. A parse error is reported up through `onError` (the raw
283
+ * `JSON.parse` message, matching v1) so the parent can render it on its own row
284
+ * below the input — keeping the textarea + switcher button row intact.
285
+ */
286
+ function JsonConstantEditor({
287
+ value,
288
+ onChange,
289
+ onError,
290
+ disabled,
291
+ typedProps,
292
+ }: {
293
+ value: unknown;
294
+ onChange?: (next: unknown) => void;
295
+ onError?: (message: string | null) => void;
296
+ disabled?: boolean;
297
+ typedProps: Record<string, unknown>;
298
+ }) {
299
+ const stringify = useCallback((v: unknown) => {
300
+ if (v == null) return '';
301
+ if (typeof v === 'string') return v;
302
+ try {
303
+ return JSON.stringify(v, null, 2);
304
+ } catch {
305
+ return '';
306
+ }
307
+ }, []);
308
+
309
+ const [text, setText] = useState<string>(() => stringify(value));
310
+ const [hasError, setHasError] = useState(false);
311
+
312
+ // Re-sync the draft when the external value changes (e.g. switching type).
313
+ useEffect(() => {
314
+ setText(stringify(value));
315
+ setHasError(false);
316
+ onError?.(null);
317
+ // `onError` intentionally omitted — only resync on external value change.
318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
319
+ }, [value, stringify]);
320
+
321
+ // Validate as the user types (live red border + error row), mirroring v1's `Json` which calls `setFeedback` on every
322
+ // change — not just on blur.
323
+ const handleChange = useCallback(
324
+ (raw: string) => {
325
+ setText(raw);
326
+ const trimmed = raw.trim();
327
+ if (trimmed === '') {
328
+ setHasError(false);
329
+ onError?.(null);
330
+ return;
331
+ }
332
+ try {
333
+ JSON.parse(trimmed);
334
+ setHasError(false);
335
+ onError?.(null);
336
+ } catch (err) {
337
+ setHasError(true);
338
+ onError?.((err as Error).message);
339
+ }
340
+ },
341
+ [onError],
342
+ );
343
+
344
+ // Commit the parsed object up on blur (v1 emits the value on blur). A still-invalid value keeps its error and is not
345
+ // emitted.
346
+ const commit = useCallback(
347
+ (raw: string) => {
348
+ const trimmed = raw.trim();
349
+ if (trimmed === '') {
350
+ onChange?.(null);
351
+ return;
352
+ }
353
+ try {
354
+ onChange?.(JSON.parse(trimmed));
355
+ } catch {
356
+ /* error already shown live via handleChange */
357
+ }
358
+ },
359
+ [onChange],
360
+ );
361
+
362
+ return (
363
+ // No `autoSize` — it would disable the resize grip. Default to 2 rows (mirrors v1's `Json`) and let the user
364
+ // drag-resize from the corner.
365
+ <Input.TextArea
366
+ className={jsonEditorClassName}
367
+ value={text}
368
+ onChange={(ev) => handleChange(ev.target.value)}
369
+ onBlur={(ev) => commit(ev.target.value)}
370
+ disabled={disabled}
371
+ rows={2}
372
+ status={hasError ? 'error' : undefined}
373
+ {...typedProps}
374
+ />
375
+ );
376
+ }
377
+
237
378
  /**
238
379
  * Constant-or-variable input. Port of v1 `Variable.Input` typed-constant
239
380
  * Cascader pattern (the `[Null | Constant<type> | Variable<…namespaces>]`
@@ -255,7 +396,9 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
255
396
  types = DEFAULT_TYPES,
256
397
  namespaces,
257
398
  extraNodes,
399
+ metaTree: metaTreeProp,
258
400
  nullable = true,
401
+ defaultToFirstConstantTypeWhenUndefined = true,
259
402
  delimiters,
260
403
  disabled,
261
404
  placeholder,
@@ -266,16 +409,77 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
266
409
  const t = useCallback((text: string): string => (typeof ctx?.t === 'function' ? ctx.t(text) : text), [ctx]);
267
410
  const { token } = theme.useToken();
268
411
 
269
- const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
412
+ // An explicit `metaTree` wins over the global FlowContext tree. The hook is still called unconditionally (Rules of
413
+ // Hooks); its result is simply unused when a tree is injected.
414
+ const filteredMetaTree = useFilteredMetaTree({ namespaces, extraNodes });
415
+ const metaTree = metaTreeProp ?? filteredMetaTree;
270
416
 
271
417
  const parseVariablePath = useMemo(() => makeParseVariablePath(delimiters), [delimiters]);
272
418
  const formatVariablePath = useMemo(() => makeFormatVariablePath(delimiters), [delimiters]);
273
419
 
274
420
  const normalizedTypes = useMemo(() => normalizeTypes(types), [types]);
275
- const detected = useMemo(() => detectMode(value, parseVariablePath), [value, parseVariablePath]);
276
- const variableItems = useMemo(() => buildContextSelectorItems(metaTree).map(fromContextItem), [metaTree]);
421
+ const defaultedValue = useMemo(() => {
422
+ if (!defaultToFirstConstantTypeWhenUndefined || value !== undefined) {
423
+ return undefined;
424
+ }
425
+ const firstType = normalizedTypes[0];
426
+ return firstType ? defaultValueFor(firstType.type) : undefined;
427
+ }, [defaultToFirstConstantTypeWhenUndefined, normalizedTypes, value]);
428
+ const effectiveValue = value === undefined && defaultedValue !== undefined ? defaultedValue : value;
429
+ const detected = useMemo(() => detectMode(effectiveValue, parseVariablePath), [effectiveValue, parseVariablePath]);
430
+
431
+ useEffect(() => {
432
+ if (value === undefined && defaultedValue !== undefined) {
433
+ onChange?.(defaultedValue);
434
+ }
435
+ }, [defaultedValue, onChange, value]);
436
+
437
+ // rc-cascader caches its options by *reference* (useEntities), so lazily filling a node's children in place is
438
+ // invisible to it. We mirror `FlowContextSelector`: lazy `loadData` mutates the **meta tree** in place, bumps
439
+ // `updateFlag`, and the options are rebuilt from scratch (fresh references) on every bump — forcing rc-cascader to
440
+ // re-index the new column.
441
+ const [updateFlag, setUpdateFlag] = useState(0);
442
+ const triggerUpdate = useCallback(() => setUpdateFlag((prev) => prev + 1), []);
443
+
444
+ const loadData = useCallback(
445
+ (selectedOptions: SwitcherOption[]) => {
446
+ const target = selectedOptions[selectedOptions.length - 1];
447
+ // Only variable nodes lazy-load; the Null / Constant switcher entries never do.
448
+ if (!target || target.value === NULL_KEY || target.value === CONST_KEY) {
449
+ return;
450
+ }
451
+ const meta = target.meta;
452
+ if (!meta) {
453
+ return;
454
+ }
455
+ // Already-resolved children (array): nothing to fetch.
456
+ if (Array.isArray(meta.children)) {
457
+ return;
458
+ }
459
+ if (typeof meta.children !== 'function') {
460
+ return;
461
+ }
462
+ target.loading = true;
463
+ triggerUpdate();
464
+ // `loadMetaTreeChildren` already swallows its own errors (returns []), so a bare chain is safe; `return`
465
+ // satisfies the promise/catch-or-return rule.
466
+ return loadMetaTreeChildren(meta)
467
+ .then((childMetas) => {
468
+ // Cache resolved children on the meta node; the options tree is then rebuilt from the mutated meta tree on
469
+ // the next `updateFlag` bump.
470
+ meta.children = childMetas;
471
+ })
472
+ .finally(() => {
473
+ target.loading = false;
474
+ triggerUpdate();
475
+ });
476
+ },
477
+ [triggerUpdate],
478
+ );
277
479
 
278
480
  const switcherOptions = useMemo<SwitcherOption[]>(() => {
481
+ // `updateFlag` is read so this recomputes (with fresh option references) after a lazy load mutates the meta tree.
482
+ void updateFlag;
279
483
  const items: SwitcherOption[] = [];
280
484
  if (nullable) {
281
485
  items.push({ value: NULL_KEY, label: t('Null'), isLeaf: true });
@@ -294,9 +498,9 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
294
498
  })),
295
499
  });
296
500
  }
297
- items.push(...variableItems);
501
+ items.push(...buildContextSelectorItems(metaTree).map(fromContextItem));
298
502
  return items;
299
- }, [nullable, normalizedTypes, variableItems, t]);
503
+ }, [nullable, normalizedTypes, metaTree, updateFlag, t]);
300
504
 
301
505
  const onSwitcherChange = useCallback<NonNullable<CascaderProps<SwitcherOption>['onChange']>>(
302
506
  (path, selectedOptions) => {
@@ -326,17 +530,19 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
326
530
  );
327
531
 
328
532
  const onClearVariable = useCallback(() => {
533
+ const first = normalizedTypes[0];
534
+ if (first) {
535
+ onChange?.(defaultValueFor(first.type));
536
+ return;
537
+ }
329
538
  if (nullable) {
330
539
  onChange?.(null);
331
- return;
332
540
  }
333
- const first = normalizedTypes[0];
334
- if (first) onChange?.(defaultValueFor(first.type));
335
541
  }, [nullable, normalizedTypes, onChange]);
336
542
 
337
543
  const constantTypeForRendering: TypedConstantType = useMemo(() => {
338
544
  const m = detected.mode;
339
- if (m === 'string' || m === 'number' || m === 'boolean' || m === 'date') return m;
545
+ if (m === 'string' || m === 'number' || m === 'boolean' || m === 'date' || m === 'object') return m;
340
546
  return normalizedTypes[0]?.type ?? 'string';
341
547
  }, [detected.mode, normalizedTypes]);
342
548
 
@@ -348,93 +554,210 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
348
554
  const isVariable = detected.mode === 'variable';
349
555
  const isNull = detected.mode === 'null';
350
556
 
351
- const variableLabels = useMemo(
352
- () => (isVariable && detected.variablePath ? resolveVariableLabels(detected.variablePath, metaTree) : []),
353
- [isVariable, detected.variablePath, metaTree],
557
+ const variableLabels = useMemo(() => {
558
+ // `updateFlag` is read so this recomputes after the preload effect below resolves a lazy level in the tree (same
559
+ // pattern as `switcherOptions`).
560
+ void updateFlag;
561
+ return isVariable && detected.variablePath ? resolveVariableLabels(detected.variablePath, metaTree) : [];
562
+ }, [isVariable, detected.variablePath, metaTree, updateFlag]);
563
+
564
+ const switcherValue = useMemo(() => {
565
+ if (isVariable && detected.variablePath?.length) {
566
+ return detected.variablePath;
567
+ }
568
+ if (isNull) {
569
+ return [NULL_KEY];
570
+ }
571
+ return [CONST_KEY, constantTypeForRendering];
572
+ }, [constantTypeForRendering, detected.variablePath, isNull, isVariable]);
573
+
574
+ // Preload a saved variable's label path across lazy levels. `resolveVariableLabels` can only read already-loaded
575
+ // `children`; when a saved reference points below a node whose children are still a lazy thunk (e.g. a relation field
576
+ // that hasn't been expanded), the deep segments render as raw names. Walk the saved path on mount / value change,
577
+ // resolving each lazy level in place (then bump `updateFlag` so the labels recompute). Mirrors v1 `Variable.Input`'s
578
+ // preload effect.
579
+ useEffect(() => {
580
+ if (!isVariable || !detected.variablePath?.length) {
581
+ return;
582
+ }
583
+ let cancelled = false;
584
+ const run = async () => {
585
+ let nodes: MetaTreeNode[] | undefined = metaTree;
586
+ let didLoad = false;
587
+ for (const segment of detected.variablePath as string[]) {
588
+ if (!nodes) {
589
+ break;
590
+ }
591
+ const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
592
+ if (!matched) {
593
+ break;
594
+ }
595
+ if (typeof matched.children === 'function') {
596
+ const resolved = await loadMetaTreeChildren(matched);
597
+ if (cancelled) {
598
+ return;
599
+ }
600
+ matched.children = resolved;
601
+ didLoad = true;
602
+ }
603
+ nodes = Array.isArray(matched.children) ? matched.children : undefined;
604
+ }
605
+ if (didLoad && !cancelled) {
606
+ triggerUpdate();
607
+ }
608
+ };
609
+ run();
610
+ return () => {
611
+ cancelled = true;
612
+ };
613
+ // `metaTree` identity is stable per structural change (see consumers); re-run when the saved path or the tree
614
+ // changes.
615
+ }, [isVariable, detected.variablePath, metaTree, triggerUpdate]);
616
+
617
+ // JSON parse error from the object editor, rendered on its own row below the input (not squeezed into the compact
618
+ // row). Cleared whenever the value is no longer an object literal.
619
+ const [jsonError, setJsonError] = useState<string | null>(null);
620
+ useEffect(() => {
621
+ if (constantTypeForRendering !== 'object') setJsonError(null);
622
+ }, [constantTypeForRendering]);
623
+
624
+ const variableValueClassName = useMemo(
625
+ () => css`
626
+ &:hover .clear-button,
627
+ &:focus-within .clear-button {
628
+ visibility: visible;
629
+ pointer-events: auto;
630
+ opacity: 1;
631
+ }
632
+
633
+ .clear-button {
634
+ display: inline-flex;
635
+ align-items: center;
636
+ justify-content: center;
637
+ width: ${token.fontSizeIcon}px;
638
+ height: ${token.fontSizeIcon}px;
639
+ padding: 0;
640
+ color: ${token.colorTextQuaternary};
641
+ background: transparent;
642
+ cursor: pointer;
643
+ visibility: hidden;
644
+ pointer-events: none;
645
+ opacity: 0;
646
+ transition:
647
+ visibility ${token.motionDurationMid} ease,
648
+ color ${token.motionDurationMid} ease,
649
+ opacity ${token.motionDurationSlow} ease;
650
+
651
+ &:hover {
652
+ color: ${token.colorTextTertiary};
653
+ background: transparent;
654
+ }
655
+ }
656
+ `,
657
+ [token],
354
658
  );
355
659
 
356
660
  return (
357
- <Space.Compact style={{ display: 'flex', width: '100%', ...style }} className={className}>
358
- <div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
359
- {isVariable ? (
360
- <div
361
- role="button"
362
- aria-label="variable-tag"
363
- style={{
364
- display: 'flex',
365
- alignItems: 'center',
366
- gap: token.marginXXS,
367
- padding: `0 ${token.paddingSM}px`,
368
- minHeight: token.controlHeight,
369
- border: `1px solid ${token.colorBorder}`,
370
- borderRadius: token.borderRadius,
371
- borderTopRightRadius: 0,
372
- borderBottomRightRadius: 0,
373
- background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
374
- overflow: 'hidden',
375
- }}
376
- >
377
- <Tag
378
- color="blue"
661
+ <div style={{ width: '100%' }}>
662
+ {/* Default `Space.Compact` (align-items: stretch) so the switcher button
663
+ grows to the value component's height — its `height: auto` (below) lets
664
+ a multi-line value (the JSON textarea) stretch the button to match,
665
+ joined like v1 (which sets `.ant-btn { height: auto }` likewise). */}
666
+ <Space.Compact style={{ display: 'flex', width: '100%', ...style }} className={className}>
667
+ <div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
668
+ {isVariable ? (
669
+ <div
670
+ className={variableValueClassName}
671
+ role="button"
672
+ aria-label="variable-tag"
379
673
  style={{
380
- marginInlineEnd: 0,
381
- maxWidth: '100%',
674
+ display: 'flex',
675
+ alignItems: 'center',
676
+ gap: token.marginXXS,
677
+ padding: `0 ${token.paddingSM}px`,
678
+ minHeight: token.controlHeight,
679
+ border: `1px solid ${token.colorBorder}`,
680
+ borderRadius: token.borderRadius,
681
+ borderTopRightRadius: 0,
682
+ borderBottomRightRadius: 0,
683
+ background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
382
684
  overflow: 'hidden',
383
- textOverflow: 'ellipsis',
384
- whiteSpace: 'nowrap',
385
685
  }}
386
686
  >
387
- {variableLabels.map((label, index) => (
388
- <React.Fragment key={`${label}-${index}`}>
389
- {index ? ' / ' : ''}
390
- {label}
391
- </React.Fragment>
392
- ))}
393
- </Tag>
394
- {!disabled ? (
395
- <Button
396
- type="text"
397
- size="small"
398
- aria-label="icon-close"
399
- onClick={onClearVariable}
400
- icon={<CloseCircleFilled style={{ color: token.colorTextTertiary }} />}
401
- />
402
- ) : null}
403
- </div>
404
- ) : isNull ? (
405
- // v1 used the `placeholder` slot (not `value`) so the antd default
406
- // placeholder colour applies — keeps the field looking visibly
407
- // empty/inactive rather than holding a real text value.
408
- <Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
409
- ) : (
410
- renderConstantEditor(constantTypeForRendering, value, onChange, {
411
- typedProps,
412
- disabled,
413
- placeholder,
414
- t,
415
- })
416
- )}
417
- </div>
418
- <Cascader<SwitcherOption>
419
- options={switcherOptions}
420
- onChange={onSwitcherChange}
421
- disabled={disabled}
422
- changeOnSelect
423
- >
424
- <Button
425
- aria-label="variable-switcher"
687
+ <Tag
688
+ color="blue"
689
+ style={{
690
+ marginInlineEnd: 0,
691
+ maxWidth: '100%',
692
+ overflow: 'hidden',
693
+ textOverflow: 'ellipsis',
694
+ whiteSpace: 'nowrap',
695
+ }}
696
+ >
697
+ {variableLabels.map((label, index) => (
698
+ <React.Fragment key={`${label}-${index}`}>
699
+ {index ? ' / ' : ''}
700
+ {label}
701
+ </React.Fragment>
702
+ ))}
703
+ </Tag>
704
+ {!disabled ? (
705
+ <Button
706
+ type="text"
707
+ size="small"
708
+ className="clear-button"
709
+ aria-label="icon-close"
710
+ onClick={onClearVariable}
711
+ icon={<CloseCircleFilled />}
712
+ />
713
+ ) : null}
714
+ </div>
715
+ ) : isNull ? (
716
+ // v1 used the `placeholder` slot (not `value`) so the antd default placeholder colour applies — keeps the
717
+ // field looking visibly empty/inactive rather than holding a real text value.
718
+ <Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
719
+ ) : (
720
+ renderConstantEditor(constantTypeForRendering, effectiveValue, onChange, {
721
+ typedProps,
722
+ disabled,
723
+ placeholder,
724
+ t,
725
+ onJsonError: setJsonError,
726
+ })
727
+ )}
728
+ </div>
729
+ <Cascader<SwitcherOption>
730
+ options={switcherOptions}
731
+ value={switcherValue}
732
+ onChange={onSwitcherChange}
733
+ loadData={loadData}
426
734
  disabled={disabled}
427
- type={isVariable ? 'primary' : 'default'}
428
- style={{
429
- flexShrink: 0,
430
- fontStyle: 'italic',
431
- fontFamily: '"New York", "Times New Roman", Times, serif',
432
- }}
735
+ changeOnSelect
433
736
  >
434
- x
435
- </Button>
436
- </Cascader>
437
- </Space.Compact>
737
+ <Button
738
+ aria-label="variable-switcher"
739
+ disabled={disabled}
740
+ type={isVariable ? 'primary' : 'default'}
741
+ style={{
742
+ flexShrink: 0,
743
+ // `height: auto` (instead of antd's fixed control height) lets the button stretch to the value
744
+ // component's height under the compact row's default `align-items: stretch` — so it stays joined to a
745
+ // tall JSON textarea. Mirrors v1's `.ant-btn { height: auto }`.
746
+ height: 'auto',
747
+ fontStyle: 'italic',
748
+ fontFamily: '"New York", "Times New Roman", Times, serif',
749
+ }}
750
+ >
751
+ x
752
+ </Button>
753
+ </Cascader>
754
+ </Space.Compact>
755
+ {jsonError ? (
756
+ <div style={{ marginTop: token.marginXXS, color: token.colorError, fontSize: token.fontSizeSM }}>
757
+ {jsonError}
758
+ </div>
759
+ ) : null}
760
+ </div>
438
761
  );
439
762
  }
440
763