@nocobase/client-v2 2.2.0-alpha.7 → 2.2.0-alpha.8

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.
@@ -62,6 +62,51 @@ async function buildCollectionLeftMetaTreeLocal(ctx: any): Promise<MetaTreeNode[
62
62
  return await resolve(subTree);
63
63
  }
64
64
 
65
+ function buildCollectionFieldMetaNode(collection: any, path: string[]): MetaTreeNode | null {
66
+ if (!collection || !path.length) {
67
+ return null;
68
+ }
69
+
70
+ const normalizedPath = path[0] === 'collection' ? path.slice(1) : path;
71
+ if (!normalizedPath.length) {
72
+ return null;
73
+ }
74
+
75
+ const parentTitles: string[] = [];
76
+ let currentCollection = collection;
77
+ let currentNode: MetaTreeNode | null = null;
78
+
79
+ for (const segment of normalizedPath) {
80
+ const field =
81
+ currentCollection?.getField?.(segment) ??
82
+ currentCollection?.getFields?.()?.find?.((candidate: any) => candidate?.name === segment);
83
+ if (!field) {
84
+ return null;
85
+ }
86
+
87
+ const title = field.title || field.uiSchema?.title || field.name || segment;
88
+ currentNode = {
89
+ name: field.name || segment,
90
+ title,
91
+ type: field.type,
92
+ interface: field.interface,
93
+ uiSchema: field.uiSchema,
94
+ options: field.options,
95
+ paths: ['collection', ...normalizedPath.slice(0, parentTitles.length + 1)],
96
+ parentTitles: [...parentTitles],
97
+ } as MetaTreeNode;
98
+
99
+ if (field.targetCollection) {
100
+ parentTitles.push(title);
101
+ currentCollection = field.targetCollection;
102
+ } else {
103
+ currentCollection = null;
104
+ }
105
+ }
106
+
107
+ return currentNode;
108
+ }
109
+
65
110
  export interface VariableFilterItemValue {
66
111
  path: string;
67
112
  operator: string;
@@ -75,6 +120,7 @@ export interface VariableFilterItemProps {
75
120
  /** 筛选条件值对象 */
76
121
  value: VariableFilterItemValue;
77
122
  model: FlowModel;
123
+ disabled?: boolean;
78
124
  /**
79
125
  * 是否启用右侧 VariableInput(变量或静态值二合一)。
80
126
  * 默认 false:保持原有行为,右侧仅静态输入组件。
@@ -265,11 +311,48 @@ function normalizeRightValueInput(input: unknown) {
265
311
  return input;
266
312
  }
267
313
 
314
+ function findMetaTreeNodeByPath(metaTree: MetaTreeNode[], targetPath: string[]): MetaTreeNode | null {
315
+ if (!Array.isArray(metaTree) || !targetPath.length) {
316
+ return null;
317
+ }
318
+
319
+ const traverseByName = (nodes: MetaTreeNode[], path: string[]): MetaTreeNode | null => {
320
+ if (!path.length) {
321
+ return null;
322
+ }
323
+
324
+ const [head, ...rest] = path;
325
+ const matched = nodes.find((node) => String(node.name) === String(head));
326
+ if (!matched) {
327
+ return null;
328
+ }
329
+ if (!rest.length) {
330
+ return matched;
331
+ }
332
+ if (!Array.isArray(matched.children)) {
333
+ return null;
334
+ }
335
+ return traverseByName(matched.children as MetaTreeNode[], rest);
336
+ };
337
+
338
+ const byNames = traverseByName(metaTree, targetPath);
339
+ if (byNames) {
340
+ return byNames;
341
+ }
342
+
343
+ const topNames = new Set(metaTree.map((node) => String(node.name)));
344
+ if (!topNames.has(String(targetPath[0]))) {
345
+ return traverseByName(metaTree, targetPath.slice(1));
346
+ }
347
+
348
+ return null;
349
+ }
350
+
268
351
  /**
269
352
  * 上下文筛选项组件
270
353
  */
271
354
  export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
272
- ({ value, model, rightAsVariable, rightMetaTree, ignoreFieldNames, maxAssociationFieldDepth }) => {
355
+ ({ value, model, disabled = false, rightAsVariable, rightMetaTree, ignoreFieldNames, maxAssociationFieldDepth }) => {
273
356
  // 使用 View 上下文,确保可访问 ctx.view 的异步子树
274
357
  const ctx = useFlowViewContext();
275
358
  const t = model.translate;
@@ -334,6 +417,9 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
334
417
  // 处理左侧值变化(值由 converters 决定如何解析)
335
418
  const handleLeftChange = useCallback(
336
419
  (variableValue: string, meta?: MetaTreeNode) => {
420
+ if (disabled) {
421
+ return;
422
+ }
337
423
  const prevPath = value.path || '';
338
424
  const nextPath = variableValue || '';
339
425
  const changed = nextPath !== prevPath;
@@ -348,7 +434,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
348
434
  setLeftMeta(meta);
349
435
  }
350
436
  },
351
- [value],
437
+ [disabled, value],
352
438
  );
353
439
 
354
440
  // 自定义转换器来捕获 MetaTreeNode,并在选择左值时设置默认操作符
@@ -376,6 +462,9 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
376
462
  // 处理操作符变化
377
463
  const handleOperatorChange = useCallback(
378
464
  (operatorValue: string) => {
465
+ if (disabled) {
466
+ return;
467
+ }
379
468
  value.operator = operatorValue;
380
469
  const cur = operatorMetaList.find((op) => op.value === operatorValue);
381
470
  if (cur?.noValue) {
@@ -386,7 +475,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
386
475
  value.noValue = false;
387
476
  }
388
477
  },
389
- [operatorMetaList, value],
478
+ [disabled, operatorMetaList, value],
390
479
  );
391
480
 
392
481
  // 使用公共静态输入渲染器(抽取到 utils)
@@ -464,9 +553,12 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
464
553
 
465
554
  const setRightValue = useCallback(
466
555
  (next: unknown) => {
556
+ if (disabled) {
557
+ return;
558
+ }
467
559
  value.value = normalizeRightValueInput(next) as VariableFilterItemValue['value'];
468
560
  },
469
- [value],
561
+ [disabled, value],
470
562
  );
471
563
 
472
564
  // 右侧静态输入(无变量模式)与右侧 VariableInput 的静态渲染组件,统一复用
@@ -520,6 +612,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
520
612
  {...nextProps}
521
613
  style={{ width: '100%', ...pickStyle(nextProps.style) }}
522
614
  value={normalized}
615
+ disabled={disabled}
523
616
  onChange={(vals: any) => setRightValue(vals)}
524
617
  />
525
618
  </div>
@@ -529,10 +622,19 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
529
622
  const Comp = staticInputRenderer;
530
623
  return (
531
624
  <div style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}>
532
- <Comp value={rightValue} onChange={(val) => setRightValue(val)} />
625
+ <Comp value={rightValue} onChange={(val) => setRightValue(val)} disabled={disabled} />
533
626
  </div>
534
627
  );
535
- }, [operator, operatorMetaList, rightValue, staticInputRenderer, model.context.app, setRightValue, enumOptions]);
628
+ }, [
629
+ disabled,
630
+ operator,
631
+ operatorMetaList,
632
+ rightValue,
633
+ staticInputRenderer,
634
+ model.context.app,
635
+ setRightValue,
636
+ enumOptions,
637
+ ]);
536
638
 
537
639
  // Null 占位组件(仿照 DefaultValue.tsx 的实现)
538
640
  const NullComponent = useMemo(() => {
@@ -652,6 +754,34 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
652
754
  };
653
755
  }, [getFieldInterface, model, maxAssociationFieldDepth]);
654
756
 
757
+ useEffect(() => {
758
+ if (leftMeta || !value.path) {
759
+ return;
760
+ }
761
+
762
+ let cancelled = false;
763
+ const restore = async () => {
764
+ const nodes = await enhancedMetaTree();
765
+ if (cancelled) {
766
+ return;
767
+ }
768
+ const path = customConverters.resolvePathFromValue(value.path);
769
+ if (!path?.length) {
770
+ return;
771
+ }
772
+ const matched =
773
+ findMetaTreeNodeByPath(nodes, path) || buildCollectionFieldMetaNode(model.context.collection, path);
774
+ if (matched) {
775
+ setLeftMeta(matched);
776
+ }
777
+ };
778
+
779
+ restore();
780
+ return () => {
781
+ cancelled = true;
782
+ };
783
+ }, [customConverters, enhancedMetaTree, leftMeta, model.context.collection, value.path]);
784
+
655
785
  return (
656
786
  <Space wrap style={{ width: '100%' }}>
657
787
  <VariableInput
@@ -659,6 +789,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
659
789
  metaTree={enhancedMetaTree}
660
790
  onChange={handleLeftChange}
661
791
  converters={customConverters}
792
+ disabled={disabled}
662
793
  showValueComponent={false}
663
794
  style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}
664
795
  onlyLeafSelectable={true}
@@ -670,6 +801,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
670
801
  style={{ flex: '0 0 140px', minWidth: 120, maxWidth: '100%' }}
671
802
  placeholder={t('Comparison')}
672
803
  value={operator || undefined}
804
+ disabled={disabled}
673
805
  onChange={handleOperatorChange}
674
806
  >
675
807
  {operatorOptions.map((op) => (
@@ -686,6 +818,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
686
818
  onChange={(v) => setRightValue(v)}
687
819
  metaTree={mergedRightMetaTree}
688
820
  converters={rightConverters}
821
+ disabled={disabled}
689
822
  showValueComponent
690
823
  style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}
691
824
  placeholder={t('Enter value')}
@@ -140,4 +140,49 @@ describe('FilterGroup closeIcon', () => {
140
140
  expect(onChange).toHaveBeenCalledTimes(1);
141
141
  expect(value.items[0].items).toHaveLength(1);
142
142
  });
143
+
144
+ it('disables close and add actions when the filter group is disabled', () => {
145
+ const value = {
146
+ logic: '$and',
147
+ items: [
148
+ {
149
+ path: 'name',
150
+ operator: 'eq',
151
+ value: 'test',
152
+ },
153
+ ],
154
+ };
155
+ const onRemove = vi.fn();
156
+ const onChange = vi.fn();
157
+
158
+ renderWithProviders(
159
+ <FilterGroup
160
+ value={value}
161
+ FilterItem={DummyFilterItem}
162
+ showBorder
163
+ onRemove={onRemove}
164
+ onChange={onChange}
165
+ disabled
166
+ />,
167
+ );
168
+
169
+ const closeButtons = screen
170
+ .getAllByLabelText('icon-close')
171
+ .map((element) => element.closest('button'))
172
+ .filter(Boolean) as HTMLButtonElement[];
173
+ expect(closeButtons).toHaveLength(2);
174
+ expect(closeButtons[0]).toBeDisabled();
175
+ expect(closeButtons[1]).toBeDisabled();
176
+ expect(screen.getByText('Add condition').closest('button')).toBeDisabled();
177
+ expect(screen.getByText('Add condition group').closest('button')).toBeDisabled();
178
+
179
+ fireEvent.click(closeButtons[0]);
180
+ fireEvent.click(closeButtons[1]);
181
+ fireEvent.click(screen.getByText('Add condition').closest('button') as HTMLButtonElement);
182
+ fireEvent.click(screen.getByText('Add condition group').closest('button') as HTMLButtonElement);
183
+
184
+ expect(onRemove).not.toHaveBeenCalled();
185
+ expect(onChange).not.toHaveBeenCalled();
186
+ expect(value.items).toHaveLength(1);
187
+ });
143
188
  });
@@ -193,4 +193,13 @@ describe('VariableFilterItem with leftMetaTree', () => {
193
193
  fireEvent.click(screen.getByTestId('variable-input'));
194
194
  expect(value.path).toBe('title');
195
195
  });
196
+
197
+ it('restores the saved left meta so the operator select shows its label on reopen', async () => {
198
+ const value = { path: 'title', operator: '$eq', value: 'x' } as any;
199
+ const model = createModelWithCollection();
200
+
201
+ render(<VariableFilterItem value={value} model={model} rightAsVariable={false} />);
202
+
203
+ expect(await screen.findByText('Equals')).toBeInTheDocument();
204
+ });
196
205
  });
@@ -158,6 +158,27 @@ describe('VariableFilterItem', () => {
158
158
  expect(value.value).toBe('abc');
159
159
  });
160
160
 
161
+ it('passes disabled through to the left selector, operator select, and right variable input', async () => {
162
+ const value: VariableFilterItemValue = { path: '', operator: '', value: '' };
163
+ const model = CreateModel();
164
+
165
+ render(<VariableFilterItem value={value} model={model} rightAsVariable disabled />);
166
+
167
+ const leftVariableInputProps = (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
168
+ expect(leftVariableInputProps.disabled).toBe(true);
169
+
170
+ fireEvent.click(screen.getAllByTestId('variable-input')[0]);
171
+
172
+ const variableInputs = screen.getAllByTestId('variable-input');
173
+ expect(variableInputs.length).toBeGreaterThanOrEqual(2);
174
+ const rightVariableInputProps = (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
175
+ expect(rightVariableInputProps.disabled).toBe(true);
176
+
177
+ const operatorSelect = document.body.querySelector('.ant-select') as HTMLDivElement | null;
178
+ expect(operatorSelect).not.toBeNull();
179
+ expect(operatorSelect).toHaveClass('ant-select-disabled');
180
+ });
181
+
161
182
  it('uses scoped context dataSourceManager when app dataSourceManager has no field interface manager', async () => {
162
183
  const value = observable({ path: '', operator: '', value: '' }) as any;
163
184
  const model = CreateModel();
@@ -121,10 +121,11 @@ const Columns = observer<any>(({ record, model, index }) => {
121
121
  fork.context.defineProperty('recordIndex', {
122
122
  get: () => index,
123
123
  });
124
+ const rendererKey = `${fork.uid}:${fork.forkId}`;
124
125
  const renderer = (
125
126
  <FlowModelRenderer
126
127
  showFlowSettings={{ showBorder: false, toolbarPosition: 'above' }}
127
- key={fork.uid}
128
+ key={rendererKey}
128
129
  model={fork}
129
130
  inputArgs={record}
130
131
  fallback={<Skeleton.Button size="small" />}
@@ -48,12 +48,12 @@ export class VariableFieldFormModel extends FlowModel {
48
48
  <FormProvider form={this.form}>
49
49
  <FormLayout layout={'vertical'}>
50
50
  {this.mapSubModels('fields', (field) => {
51
- // 确保字段模型具备稳定的 id/name,便于依赖路径的组件(如公式字段)正确解析
51
+ // Avoid conflicting with the DOM nodeName property when rendering the temporary input.
52
52
  const init = field?.getStepParams?.('fieldSettings', 'init') || {};
53
53
  const fp = init?.fieldPath as string | undefined;
54
54
  if (fp) {
55
- const namePath = fp.includes('.') ? fp.split('.') : [fp];
56
- const toSet: any = {};
55
+ const namePath = (fp === 'nodeName' ? '__nb_variable_field_nodeName' : fp).split('.');
56
+ const toSet: Record<string, unknown> = {};
57
57
  if (!field?.props?.id) toSet.id = namePath;
58
58
  if (!field?.props?.name) toSet.name = namePath;
59
59
  if (Object.keys(toSet).length) field?.setProps?.(toSet);
@@ -0,0 +1,51 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
11
+ import { render, screen, waitFor } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { describe, expect, it } from 'vitest';
14
+ import { InputFieldModel } from '../InputFieldModel';
15
+ import { VariableFieldFormModel } from '../VariableFieldFormModel';
16
+
17
+ describe('VariableFieldFormModel', () => {
18
+ it('uses DOM-safe name and id for temporary controls created from reserved form property names', async () => {
19
+ const engine = new FlowEngine();
20
+ engine.registerModels({ InputFieldModel, VariableFieldFormModel });
21
+ const model = engine.createModel<VariableFieldFormModel>({
22
+ use: 'VariableFieldFormModel',
23
+ subModels: {
24
+ fields: [
25
+ {
26
+ use: 'InputFieldModel',
27
+ stepParams: {
28
+ fieldSettings: {
29
+ init: {
30
+ fieldPath: 'nodeName',
31
+ },
32
+ },
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ });
38
+ const field = model.subModels.fields[0];
39
+
40
+ render(<FlowEngineProvider engine={engine}>{model.render()}</FlowEngineProvider>);
41
+
42
+ const input = await screen.findByRole('textbox');
43
+ await waitFor(() => {
44
+ expect(field.props.name).toEqual(['__nb_variable_field_nodeName']);
45
+ });
46
+ expect(input).toHaveAttribute('name', '__nb_variable_field_nodeName');
47
+ expect(input).toHaveAttribute('id', '__nb_variable_field_nodeName');
48
+ expect(field.props.id).toEqual(['__nb_variable_field_nodeName']);
49
+ expect(field.getStepParams('fieldSettings', 'init')).toEqual({ fieldPath: 'nodeName' });
50
+ });
51
+ });