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

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 (56) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
  3. package/es/flow/actions/index.d.ts +1 -1
  4. package/es/flow/actions/linkageRules.d.ts +2 -0
  5. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  6. package/es/flow/components/FieldAssignValueInput.d.ts +6 -0
  7. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  9. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  10. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  11. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  12. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  13. package/es/flow-compat/routeTypes.d.ts +1 -0
  14. package/es/index.mjs +105 -104
  15. package/lib/index.js +117 -116
  16. package/lib/locale/languageCodes.js +2 -1
  17. package/package.json +7 -7
  18. package/src/APIClient.ts +1 -10
  19. package/src/Application.tsx +4 -0
  20. package/src/BaseApplication.tsx +3 -2
  21. package/src/__tests__/app.test.tsx +47 -0
  22. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  23. package/src/collection-manager/field-validation.ts +1 -1
  24. package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
  25. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +35 -1
  26. package/src/components/form/ScanInput/useCodeScanner.ts +16 -3
  27. package/src/components/form/TypedVariableInput.tsx +12 -8
  28. package/src/components/form/__tests__/TypedVariableInput.test.tsx +44 -3
  29. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  30. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  31. package/src/flow/actions/index.ts +2 -0
  32. package/src/flow/actions/linkageRules.tsx +86 -11
  33. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  34. package/src/flow/components/FieldAssignValueInput.tsx +21 -0
  35. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +84 -2
  36. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  37. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  38. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  39. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  40. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  41. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  42. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  43. package/src/flow/models/base/PageModel/PageTabModel.tsx +184 -3
  44. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  45. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  46. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  47. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +572 -1
  48. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  49. package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
  50. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -1
  51. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  52. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  53. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  54. package/src/flow-compat/routeTypes.ts +1 -0
  55. package/src/locale/languageCodes.ts +2 -1
  56. package/src/nocobase-buildin-plugin/index.tsx +12 -0
@@ -0,0 +1,171 @@
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 { ActionScene } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+
13
+ import * as registeredActions from '../index';
14
+ import * as linkageActions from '../linkageRules';
15
+
16
+ const tabLinkageRules = (linkageActions as Record<string, unknown>).tabLinkageRules as
17
+ | typeof linkageActions.menuLinkageRules
18
+ | undefined;
19
+ const linkageSetTabProps = (linkageActions as Record<string, unknown>).linkageSetTabProps as
20
+ | typeof linkageActions.linkageSetMenuItemProps
21
+ | undefined;
22
+
23
+ function createRuntime() {
24
+ let matched = true;
25
+ const setProps = vi.fn(function (this: { props: Record<string, unknown> }, props: Record<string, unknown>) {
26
+ this.props = {
27
+ ...this.props,
28
+ ...props,
29
+ };
30
+ });
31
+ const setHidden = vi.fn(function (this: { hidden: boolean }, value: boolean) {
32
+ this.hidden = value;
33
+ });
34
+ const model = {
35
+ uid: 'tab-1',
36
+ props: {} as Record<string, unknown>,
37
+ hidden: false,
38
+ __allModels: [],
39
+ setProps,
40
+ setHidden,
41
+ };
42
+ const ctx = {
43
+ app: { jsonLogic: { apply: () => matched } },
44
+ model,
45
+ t: (text: string) => text,
46
+ getAction: (name: string) => (name === 'linkageSetTabProps' ? linkageSetTabProps : undefined),
47
+ resolveJsonTemplate: vi.fn(async (value: unknown) => value),
48
+ };
49
+
50
+ return {
51
+ ctx,
52
+ model,
53
+ setMatched(value: boolean) {
54
+ matched = value;
55
+ },
56
+ };
57
+ }
58
+
59
+ const hideRule = {
60
+ value: [
61
+ {
62
+ key: 'rule-1',
63
+ title: 'Hide tab',
64
+ enable: true,
65
+ condition: {
66
+ logic: '$and',
67
+ items: [{ path: 'status', operator: '$eq', value: 'visible' }],
68
+ },
69
+ actions: [
70
+ {
71
+ key: 'action-1',
72
+ name: 'linkageSetTabProps',
73
+ params: { value: 'hidden' },
74
+ },
75
+ ],
76
+ },
77
+ ],
78
+ };
79
+
80
+ describe('tab linkage rules', () => {
81
+ it('should append the tab linkage scene without changing existing scene values', () => {
82
+ const scenes = ActionScene as unknown as Record<string, number>;
83
+
84
+ expect(scenes.MENU_LINKAGE_RULES).toBe(7);
85
+ expect(scenes.TAB_LINKAGE_RULES).toBe(8);
86
+ expect(scenes.TAB_LINKAGE_RULES).toBe(Math.max(...Object.values(scenes).filter(Number.isInteger)));
87
+ });
88
+
89
+ it('should register tab linkage actions from the actions entry', () => {
90
+ expect((registeredActions as Record<string, unknown>).tabLinkageRules).toBe(tabLinkageRules);
91
+ expect((registeredActions as Record<string, unknown>).linkageSetTabProps).toBe(linkageSetTabProps);
92
+ expect(tabLinkageRules).toBeDefined();
93
+ expect(linkageSetTabProps).toBeDefined();
94
+ });
95
+
96
+ it('should expose tab state and runjs actions in ui schema', () => {
97
+ expect(tabLinkageRules).toBeDefined();
98
+ expect(linkageSetTabProps).toBeDefined();
99
+
100
+ const schema = tabLinkageRules?.uiSchema?.({
101
+ getActions: () =>
102
+ new Map([
103
+ ['linkageSetTabProps', linkageSetTabProps],
104
+ ['linkageRunjs', linkageActions.linkageRunjs],
105
+ ['linkageSetMenuItemProps', linkageActions.linkageSetMenuItemProps],
106
+ ]),
107
+ } as never) as Record<string, Record<string, Record<string, unknown>>> | undefined;
108
+
109
+ expect(schema?.value?.['x-component-props']?.supportedActions).toEqual(['linkageSetTabProps', 'linkageRunjs']);
110
+ expect(linkageSetTabProps?.scene).toBe((ActionScene as unknown as Record<string, number>).TAB_LINKAGE_RULES);
111
+ expect(linkageActions.linkageRunjs.scene).toContain(
112
+ (ActionScene as unknown as Record<string, number>).TAB_LINKAGE_RULES,
113
+ );
114
+ });
115
+
116
+ it('should hide the tab when the condition matches and restore it when the condition misses', async () => {
117
+ expect(tabLinkageRules).toBeDefined();
118
+ const runtime = createRuntime();
119
+
120
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
121
+ expect(runtime.model.hidden).toBe(true);
122
+
123
+ runtime.setMatched(false);
124
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
125
+ expect(runtime.model.hidden).toBe(false);
126
+ });
127
+
128
+ it('should restore the tab when linkage rules are cleared', async () => {
129
+ expect(tabLinkageRules).toBeDefined();
130
+ const runtime = createRuntime();
131
+
132
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
133
+ expect(runtime.model.hidden).toBe(true);
134
+
135
+ await tabLinkageRules?.handler(runtime.ctx as never, { value: [] });
136
+ expect(runtime.model.hidden).toBe(false);
137
+ });
138
+
139
+ it('should not restore stale tab props while applying or clearing visibility rules', async () => {
140
+ expect(tabLinkageRules).toBeDefined();
141
+ const runtime = createRuntime();
142
+ runtime.model.props = {
143
+ title: 'Original title',
144
+ icon: 'original-icon',
145
+ route: { options: { badge: 'original' } },
146
+ };
147
+
148
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
149
+ expect(runtime.model.hidden).toBe(true);
150
+
151
+ const updatedProps = {
152
+ title: 'Updated title',
153
+ icon: 'updated-icon',
154
+ route: { options: { badge: 'updated' } },
155
+ };
156
+ runtime.model.props = updatedProps;
157
+
158
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
159
+ expect(runtime.model.props).toMatchObject(updatedProps);
160
+ expect(runtime.model.hidden).toBe(true);
161
+
162
+ runtime.setMatched(false);
163
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
164
+ expect(runtime.model.props).toMatchObject(updatedProps);
165
+ expect(runtime.model.hidden).toBe(false);
166
+
167
+ await tabLinkageRules?.handler(runtime.ctx as never, { value: [] });
168
+ expect(runtime.model.props).toMatchObject(updatedProps);
169
+ expect(runtime.model.hidden).toBe(false);
170
+ });
171
+ });
@@ -135,11 +135,15 @@ describe('linkageRulesRefresh action', () => {
135
135
  expect(handler).toHaveBeenCalledWith(ctx, { value: ['master-mounted'] });
136
136
  });
137
137
 
138
- it('runs linkage action on master model when forks exist in design mode', async () => {
138
+ it('skips master model when forks can handle flow in design mode', async () => {
139
139
  const handler = vi.fn(async () => {});
140
140
  const model: any = {
141
141
  isFork: false,
142
- forks: new Set([{}]),
142
+ forks: new Set([
143
+ {
144
+ getFlow: vi.fn(() => ({})),
145
+ },
146
+ ]),
143
147
  getFlow: vi.fn(() => ({})),
144
148
  getStepParams: vi.fn(() => ({ value: ['master'] })),
145
149
  context: {
@@ -157,7 +161,7 @@ describe('linkageRulesRefresh action', () => {
157
161
  flowKey: 'buttonSettings',
158
162
  });
159
163
 
160
- expect(handler).toHaveBeenCalledWith(ctx, { value: ['master'] });
164
+ expect(handler).not.toHaveBeenCalled();
161
165
  });
162
166
 
163
167
  it('runs linkage action on fork model and resolves params', async () => {
@@ -43,10 +43,12 @@ export {
43
43
  linkageSetDetailsFieldProps,
44
44
  actionLinkageRules,
45
45
  menuLinkageRules,
46
+ tabLinkageRules,
46
47
  blockLinkageRules,
47
48
  linkageSetBlockProps,
48
49
  linkageSetActionProps,
49
50
  linkageSetMenuItemProps,
51
+ linkageSetTabProps,
50
52
  linkageSetFieldProps,
51
53
  subFormLinkageSetFieldProps,
52
54
  linkageAssignField,
@@ -731,6 +731,41 @@ export const linkageSetMenuItemProps = defineAction({
731
731
  },
732
732
  });
733
733
 
734
+ export const linkageSetTabProps = defineAction({
735
+ name: 'linkageSetTabProps',
736
+ title: tExpr('Set tab state'),
737
+ scene: ActionScene.TAB_LINKAGE_RULES,
738
+ sort: 100,
739
+ uiSchema: {
740
+ value: {
741
+ type: 'string',
742
+ 'x-component': (props) => {
743
+ const { value, onChange } = props;
744
+ // eslint-disable-next-line react-hooks/rules-of-hooks
745
+ const ctx = useFlowContext();
746
+ const t = ctx.model.translate.bind(ctx.model);
747
+
748
+ return (
749
+ <Select
750
+ value={value}
751
+ onChange={onChange}
752
+ placeholder={t('Please select state')}
753
+ style={{ width: '100%' }}
754
+ options={[
755
+ { label: t('Visible'), value: 'visible' },
756
+ { label: t('Hidden'), value: 'hidden' },
757
+ ]}
758
+ allowClear
759
+ />
760
+ );
761
+ },
762
+ },
763
+ },
764
+ handler(ctx, { value, setProps }) {
765
+ setProps(ctx.model, { hiddenModel: value === 'hidden' });
766
+ },
767
+ });
768
+
734
769
  export const linkageSetFieldProps = defineAction({
735
770
  name: 'linkageSetFieldProps',
736
771
  title: tExpr('Set field state'),
@@ -1339,6 +1374,7 @@ export const linkageRunjs = defineAction({
1339
1374
  ActionScene.FIELD_LINKAGE_RULES,
1340
1375
  ActionScene.ACTION_LINKAGE_RULES,
1341
1376
  ActionScene.MENU_LINKAGE_RULES,
1377
+ ActionScene.TAB_LINKAGE_RULES,
1342
1378
  ActionScene.DETAILS_FIELD_LINKAGE_RULES,
1343
1379
  ActionScene.SUB_FORM_FIELD_LINKAGE_RULES,
1344
1380
  ],
@@ -2169,17 +2205,30 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2169
2205
  }
2170
2206
  : props;
2171
2207
 
2172
- // 存储原始值,用于恢复
2173
- if (!model.__originalProps) {
2174
- model.__originalProps = {
2175
- hiddenModel: model.hidden,
2176
- hiddenText: undefined,
2177
- disabled: undefined,
2178
- required: undefined,
2179
- hidden: undefined,
2180
- ...model.props,
2181
- };
2182
- }
2208
+ // 只记录联动实际控制的属性,避免之后恢复状态时把标题、路由等无关的新配置回滚到旧快照。
2209
+ const originalProps = model.__originalProps || (model.__originalProps = {});
2210
+ const rememberOriginalProp = (key: string, value: unknown) => {
2211
+ if (!Object.prototype.hasOwnProperty.call(originalProps, key)) {
2212
+ originalProps[key] = value;
2213
+ }
2214
+ };
2215
+ Object.keys(normalizedProps || {}).forEach((key) => {
2216
+ if (key === 'hiddenModel') {
2217
+ rememberOriginalProp(
2218
+ key,
2219
+ Object.prototype.hasOwnProperty.call(model.props || {}, key) ? model.props?.[key] : model.hidden,
2220
+ );
2221
+ return;
2222
+ }
2223
+
2224
+ rememberOriginalProp(key, model.props?.[key]);
2225
+ if (key === 'hiddenText') {
2226
+ rememberOriginalProp('title', model.props?.title);
2227
+ }
2228
+ if (key === 'required') {
2229
+ rememberOriginalProp('rules', model.props?.rules);
2230
+ }
2231
+ });
2183
2232
 
2184
2233
  // 临时存起来,遍历完所有规则后,再统一处理
2185
2234
  patchPropsByModel.set(model, {
@@ -2463,6 +2512,32 @@ export const menuLinkageRules = defineAction({
2463
2512
  },
2464
2513
  });
2465
2514
 
2515
+ export const tabLinkageRules = defineAction({
2516
+ name: 'tabLinkageRules',
2517
+ title: tExpr('Tab linkage rules'),
2518
+ uiMode: 'embed',
2519
+ uiSchema(ctx) {
2520
+ return {
2521
+ value: {
2522
+ type: 'array',
2523
+ 'x-component': LinkageRulesUI,
2524
+ 'x-component-props': {
2525
+ supportedActions: getSupportedActions(ctx, ActionScene.TAB_LINKAGE_RULES),
2526
+ title: tExpr('Tab linkage rules'),
2527
+ },
2528
+ },
2529
+ };
2530
+ },
2531
+ defaultParams: {
2532
+ value: [],
2533
+ },
2534
+ useRawParams: true,
2535
+ handler: async (ctx, params) => {
2536
+ const resolved = await resolveLinkageRulesParamsPreservingRunJsScripts(ctx, params);
2537
+ return commonLinkageRulesHandler(ctx, resolved);
2538
+ },
2539
+ });
2540
+
2466
2541
  export const fieldLinkageRules = defineAction({
2467
2542
  name: 'fieldLinkageRules',
2468
2543
  title: tExpr('Field linkage rules'),
@@ -35,12 +35,8 @@ export const linkageRulesRefresh = defineAction({
35
35
  // Prefer running on the current model; fallback to blockModel when the current model doesn't own the flow.
36
36
  if (!hasFlow) return;
37
37
 
38
- // In runtime, only skip master when there are mounted forks that can handle the same flow.
38
+ // Skip master when unmounted forks can handle the same flow.
39
39
  // Otherwise master is likely the rendered model and still needs refresh.
40
- // In design mode, always refresh master so the currently edited model state stays in sync.
41
- const flowSettingsEnabled = Boolean(
42
- (ctx as any)?.flowSettingsEnabled || (model as any)?.context?.flowSettingsEnabled,
43
- );
44
40
  const hasForkWithFlow =
45
41
  !model?.isFork &&
46
42
  !!model?.forks?.size &&
@@ -49,7 +45,7 @@ export const linkageRulesRefresh = defineAction({
49
45
  return !!fork?.getFlow?.(flowKey);
50
46
  });
51
47
  const isMasterMounted = Boolean((model as any)?.context?.ref?.current);
52
- if (hasForkWithFlow && !isMasterMounted && !flowSettingsEnabled) {
48
+ if (hasForkWithFlow && !isMasterMounted) {
53
49
  return;
54
50
  }
55
51
 
@@ -42,6 +42,12 @@ import { normalizeFilterValueByOperator } from '../models/blocks/filter-form/val
42
42
  import { FieldAssignExactDatePicker, type ExactDatePickerMode } from './FieldAssignExactDatePicker';
43
43
  import { limitAssociationMetaTree } from './filter/metaTreeAssociationDepth';
44
44
 
45
+ type VariableInputConverters = {
46
+ renderInputComponent?: (metaTreeNode: MetaTreeNode | null) => React.ComponentType<any> | null;
47
+ resolvePathFromValue?: (value: any) => string[] | undefined;
48
+ resolveValueFromPath?: (metaTreeNode: MetaTreeNode) => any;
49
+ };
50
+
45
51
  const DATE_FIELD_INTERFACES = new Set(['date', 'datetime', 'datetimeNoTz', 'createdAt', 'updatedAt', 'unixTimestamp']);
46
52
 
47
53
  const TZ_AWARE_DATE_INTERFACES = new Set(['datetime', 'createdAt', 'updatedAt', 'unixTimestamp']);
@@ -345,6 +351,7 @@ interface Props {
345
351
  allowRunJS?: boolean;
346
352
  maxAssociationFieldDepth?: number;
347
353
  disabled?: boolean;
354
+ variableConverters?: VariableInputConverters;
348
355
  }
349
356
 
350
357
  type ResolvedFieldContext = {
@@ -718,6 +725,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
718
725
  allowRunJS = true,
719
726
  maxAssociationFieldDepth = 2,
720
727
  disabled = false,
728
+ variableConverters,
721
729
  }) => {
722
730
  const flowCtx = useFlowContext<FlowModelContext>();
723
731
  const normalizeEventValue = React.useCallback((eventOrValue: unknown) => {
@@ -1511,7 +1519,12 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1511
1519
  clearValue={''}
1512
1520
  disabled={disabled}
1513
1521
  converters={{
1522
+ ...variableConverters,
1514
1523
  renderInputComponent: (meta) => {
1524
+ const external = variableConverters?.renderInputComponent?.(meta ?? null);
1525
+ if (external) {
1526
+ return external;
1527
+ }
1515
1528
  const firstPath = meta?.paths?.[0];
1516
1529
  if (firstPath === 'constant') return ConstantEditor;
1517
1530
  if (firstPath === 'null') return NullComponent;
@@ -1519,6 +1532,10 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1519
1532
  return null;
1520
1533
  },
1521
1534
  resolveValueFromPath: (item) => {
1535
+ const external = variableConverters?.resolveValueFromPath?.(item);
1536
+ if (external !== undefined) {
1537
+ return external;
1538
+ }
1522
1539
  const firstPath = item?.paths?.[0];
1523
1540
  if (firstPath === 'constant') {
1524
1541
  return useDateVariableConstant ? { type: 'today' } : '';
@@ -1528,6 +1545,10 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1528
1545
  return undefined;
1529
1546
  },
1530
1547
  resolvePathFromValue: (currentValue) => {
1548
+ const external = variableConverters?.resolvePathFromValue?.(currentValue);
1549
+ if (external !== undefined) {
1550
+ return external;
1551
+ }
1531
1552
  if (currentValue === null) return ['null'];
1532
1553
  if (allowRunJS && isRunJSValue(currentValue)) return ['runjs'];
1533
1554
  if (useDateVariableConstant && isCtxDateExpression(currentValue)) {
@@ -12,9 +12,10 @@ import { describe, expect, it, vi } from 'vitest';
12
12
  import { render, waitFor } from '@nocobase/test/client';
13
13
  import { FieldAssignValueInput } from '../FieldAssignValueInput';
14
14
 
15
- const { mockUseFlowContext, mockGetDefaultBindingByField } = vi.hoisted(() => ({
15
+ const { mockUseFlowContext, mockGetDefaultBindingByField, mockVariableInput } = vi.hoisted(() => ({
16
16
  mockUseFlowContext: vi.fn(),
17
17
  mockGetDefaultBindingByField: vi.fn(),
18
+ mockVariableInput: vi.fn(() => <div data-testid="variable-input" />),
18
19
  }));
19
20
 
20
21
  vi.mock('@nocobase/flow-engine', async () => {
@@ -25,7 +26,7 @@ vi.mock('@nocobase/flow-engine', async () => {
25
26
  return {
26
27
  ...actual,
27
28
  useFlowContext: () => mockUseFlowContext(),
28
- VariableInput: () => <div data-testid="variable-input" />,
29
+ VariableInput: mockVariableInput,
29
30
  FlowModelRenderer: () => <div data-testid="flow-model-renderer" />,
30
31
  EditableItemModel: MockEditableItemModel,
31
32
  };
@@ -131,4 +132,85 @@ describe('FieldAssignValueInput context', () => {
131
132
  });
132
133
  expect(engine.createModel).toHaveBeenCalledWith(expect.any(Object), { delegate: sourceContext });
133
134
  });
135
+
136
+ it('lets callers override variable path parsing for domain-specific stored formats', async () => {
137
+ mockVariableInput.mockClear();
138
+ const sourceContext = {
139
+ dataSourceManager: {
140
+ getDataSource: vi.fn(() => ({})),
141
+ },
142
+ t: (key: string) => key,
143
+ };
144
+ const fieldModel = {
145
+ props: {},
146
+ setProps: vi.fn(),
147
+ dispatchEvent: vi.fn(),
148
+ remove: vi.fn(),
149
+ };
150
+ const tempRoot = {
151
+ context: {
152
+ defineProperty: vi.fn(),
153
+ },
154
+ subModels: {
155
+ fields: [fieldModel],
156
+ },
157
+ setProps: vi.fn(),
158
+ remove: vi.fn(),
159
+ };
160
+ const engine = {
161
+ createModel: vi.fn(() => tempRoot),
162
+ };
163
+ const collectionField = {
164
+ name: 'status',
165
+ interface: 'input',
166
+ uiSchema: { 'x-component': 'Input' },
167
+ isAssociationField: () => false,
168
+ getComponentProps: () => ({}),
169
+ };
170
+ const collection = {
171
+ dataSourceKey: 'main',
172
+ name: 'tasks',
173
+ getField: (name: string) => (name === 'status' ? collectionField : null),
174
+ getFields: () => [collectionField],
175
+ };
176
+ const formModel = {
177
+ context: { ...sourceContext, engine, collection, blockModel: null as any },
178
+ collection,
179
+ subModels: {},
180
+ };
181
+ formModel.context.blockModel = formModel;
182
+
183
+ mockGetDefaultBindingByField.mockReturnValue({ modelName: 'InputFieldModel' });
184
+ mockUseFlowContext.mockReturnValue({
185
+ model: formModel,
186
+ t: (key: string) => key,
187
+ getPropertyMetaTree: vi.fn(async () => []),
188
+ });
189
+
190
+ const variableConverters = {
191
+ resolvePathFromValue: vi.fn((value: string) =>
192
+ value === '{{$context.data.updatedAt}}' ? ['$context', 'data', 'updatedAt'] : undefined,
193
+ ),
194
+ resolveValueFromPath: vi.fn(() => undefined),
195
+ };
196
+
197
+ render(
198
+ <FieldAssignValueInput
199
+ targetPath="status"
200
+ value="{{$context.data.updatedAt}}"
201
+ onChange={vi.fn()}
202
+ variableConverters={variableConverters}
203
+ />,
204
+ );
205
+
206
+ await waitFor(() => {
207
+ expect(engine.createModel).toHaveBeenCalled();
208
+ });
209
+ const latestVariableInputCall = mockVariableInput.mock.calls.at(-1)?.[0];
210
+ expect(latestVariableInputCall.converters.resolvePathFromValue('{{$context.data.updatedAt}}')).toEqual([
211
+ '$context',
212
+ 'data',
213
+ 'updatedAt',
214
+ ]);
215
+ });
134
216
  });
@@ -45,6 +45,7 @@ interface FilterGroupProps {
45
45
  value: Record<string, any>;
46
46
  /** 自定义筛选项组件 */
47
47
  FilterItem?: React.FC<FilterItemProps>;
48
+ disabled?: boolean;
48
49
  closeIcon?: ReactNode;
49
50
  /** 是否显示边框 */
50
51
  showBorder?: boolean;
@@ -89,6 +90,7 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
89
90
  const {
90
91
  value = { logic: '$and', items: [] },
91
92
  FilterItem,
93
+ disabled = false,
92
94
  showBorder = false,
93
95
  onRemove,
94
96
  onChange,
@@ -120,11 +122,17 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
120
122
  };
121
123
 
122
124
  const handleLogicChange = (newLogic: '$and' | '$or') => {
125
+ if (disabled) {
126
+ return;
127
+ }
123
128
  value.logic = newLogic;
124
129
  onChange?.(value);
125
130
  };
126
131
 
127
132
  const handleAddCondition = () => {
133
+ if (disabled) {
134
+ return;
135
+ }
128
136
  items.push({
129
137
  path: '',
130
138
  operator: '',
@@ -134,6 +142,9 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
134
142
  };
135
143
 
136
144
  const handleAddConditionGroup = () => {
145
+ if (disabled) {
146
+ return;
147
+ }
137
148
  items.push({
138
149
  logic: '$and',
139
150
  items: [],
@@ -142,6 +153,9 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
142
153
  };
143
154
 
144
155
  const handleRemoveItem = (index: number) => {
156
+ if (disabled) {
157
+ return;
158
+ }
145
159
  items.splice(index, 1);
146
160
  onChange?.(value);
147
161
  };
@@ -160,11 +174,12 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
160
174
  <button
161
175
  type="button"
162
176
  aria-label="icon-close"
177
+ disabled={disabled}
163
178
  style={{
164
179
  position: 'absolute',
165
180
  right: 10,
166
181
  top: 10,
167
- cursor: 'pointer',
182
+ cursor: disabled ? 'not-allowed' : 'pointer',
168
183
  background: 'transparent',
169
184
  border: 0,
170
185
  padding: 0,
@@ -182,6 +197,7 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
182
197
  data-testid="filter-select-all-or-any"
183
198
  style={{ width: 'auto' }}
184
199
  value={logic}
200
+ disabled={disabled}
185
201
  onChange={handleLogicChange}
186
202
  >
187
203
  <Select.Option value="$and">{t('All')}</Select.Option>
@@ -200,6 +216,7 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
200
216
  key={getFilterItemKey(item)}
201
217
  value={item}
202
218
  FilterItem={FilterItem}
219
+ disabled={disabled}
203
220
  showBorder={true}
204
221
  onRemove={() => handleRemoveItem(index)}
205
222
  onChange={(v) => {
@@ -221,11 +238,12 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
221
238
  <button
222
239
  type="button"
223
240
  aria-label="icon-close"
241
+ disabled={disabled}
224
242
  style={{
225
243
  marginLeft: 8,
226
244
  marginRight: 8,
227
245
  flex: '0 0 auto',
228
- cursor: 'pointer',
246
+ cursor: disabled ? 'not-allowed' : 'pointer',
229
247
  background: 'transparent',
230
248
  border: 0,
231
249
  padding: 0,
@@ -251,11 +269,12 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
251
269
  <button
252
270
  type="button"
253
271
  aria-label="icon-close"
272
+ disabled={disabled}
254
273
  style={{
255
274
  marginLeft: 8,
256
275
  marginRight: 8,
257
276
  flex: '0 0 auto',
258
- cursor: 'pointer',
277
+ cursor: disabled ? 'not-allowed' : 'pointer',
259
278
  background: 'transparent',
260
279
  border: 0,
261
280
  padding: 0,
@@ -277,11 +296,12 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
277
296
  <button
278
297
  type="button"
279
298
  aria-label="icon-close"
299
+ disabled={disabled}
280
300
  style={{
281
301
  marginLeft: 8,
282
302
  marginRight: 8,
283
303
  flex: '0 0 auto',
284
- cursor: 'pointer',
304
+ cursor: disabled ? 'not-allowed' : 'pointer',
285
305
  background: 'transparent',
286
306
  border: 0,
287
307
  padding: 0,
@@ -298,7 +318,14 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
298
318
  </div>
299
319
 
300
320
  <Space size={16} style={{ marginTop: 8, marginBottom: 8 }}>
301
- <Button style={{ padding: 0 }} type="link" size="small" icon={<PlusOutlined />} onClick={handleAddCondition}>
321
+ <Button
322
+ style={{ padding: 0 }}
323
+ type="link"
324
+ size="small"
325
+ icon={<PlusOutlined />}
326
+ onClick={handleAddCondition}
327
+ disabled={disabled}
328
+ >
302
329
  {t('Add condition')}
303
330
  </Button>
304
331
  <Button
@@ -307,6 +334,7 @@ export const FilterGroup: FC<FilterGroupProps> = observer(
307
334
  size="small"
308
335
  icon={<PlusOutlined />}
309
336
  onClick={handleAddConditionGroup}
337
+ disabled={disabled}
310
338
  >
311
339
  {t('Add condition group')}
312
340
  </Button>