@nocobase/client-v2 2.1.9 → 2.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.9",
3
+ "version": "2.1.11",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.1.9",
31
- "@nocobase/flow-engine": "2.1.9",
32
- "@nocobase/sdk": "2.1.9",
33
- "@nocobase/shared": "2.1.9",
34
- "@nocobase/utils": "2.1.9",
30
+ "@nocobase/evaluators": "2.1.11",
31
+ "@nocobase/flow-engine": "2.1.11",
32
+ "@nocobase/sdk": "2.1.11",
33
+ "@nocobase/shared": "2.1.11",
34
+ "@nocobase/utils": "2.1.11",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -46,5 +46,5 @@
46
46
  "react-i18next": "^11.15.1",
47
47
  "react-router-dom": "^6.30.1"
48
48
  },
49
- "gitHead": "2b612d81c6f6115af75ea7b279384751af869510"
49
+ "gitHead": "1ccf891d837e21089f65f84b892407b34a0a0cb9"
50
50
  }
@@ -139,6 +139,64 @@ describe('normalizeDataScopeFilter', () => {
139
139
  expect(resource.removeFilterGroup).not.toHaveBeenCalled();
140
140
  });
141
141
 
142
+ it('dataScope handler preserves current role as server-side variable', async () => {
143
+ const engine = new FlowEngine();
144
+ const resource = {
145
+ addFilterGroup: vi.fn(),
146
+ removeFilterGroup: vi.fn(),
147
+ };
148
+ const ctx = engine.context;
149
+ ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
150
+ ctx.defineProperty('user', {
151
+ value: {
152
+ roles: [{ name: 'admin' }, { name: 'member' }],
153
+ },
154
+ });
155
+ ctx.defineProperty('model', {
156
+ value: {
157
+ uid: 'table-1',
158
+ resource,
159
+ },
160
+ });
161
+ const params = {
162
+ filter: {
163
+ logic: '$and',
164
+ items: [{ path: 'roles.name', operator: '$includes', value: '{{ ctx.role }}' }],
165
+ },
166
+ };
167
+
168
+ await (dataScope as { handler: (ctx: typeof ctx, params: typeof params) => Promise<void> }).handler(ctx, params);
169
+
170
+ expect(resource.addFilterGroup).toHaveBeenCalledWith('table-1', {
171
+ $and: [{ roles: { name: { $includes: '{{$nRole}}' } } }],
172
+ });
173
+ expect(resource.removeFilterGroup).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it('setTargetDataScope handler preserves current role as server-side variable', async () => {
177
+ const resource = {
178
+ addFilterGroup: vi.fn(),
179
+ removeFilterGroup: vi.fn(),
180
+ hasData: vi.fn(() => false),
181
+ refresh: vi.fn(),
182
+ };
183
+ const ctx = createSetTargetDataScopeContext(resource, { resolvedValue: ['admin', 'member'] });
184
+ const params = {
185
+ targetBlockUid: 'target-1',
186
+ filter: {
187
+ logic: '$and',
188
+ items: [{ path: 'roles.name', operator: '$includes', value: '{{ctx.role}}' }],
189
+ },
190
+ };
191
+
192
+ await (setTargetDataScope as any).handler(ctx, params);
193
+
194
+ expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
195
+ $and: [{ roles: { name: { $includes: '{{$nRole}}' } } }],
196
+ });
197
+ expect(resource.removeFilterGroup).not.toHaveBeenCalled();
198
+ });
199
+
142
200
  it('setTargetDataScope handler sends null for empty variable dependencies', async () => {
143
201
  const resource = {
144
202
  addFilterGroup: vi.fn(),
@@ -12,6 +12,12 @@ import { transformFilter } from '@nocobase/utils/client';
12
12
  import _ from 'lodash';
13
13
 
14
14
  const PRESERVE_NULL = { __nocobaseDataScopeNull__: true };
15
+ const SERVER_CURRENT_ROLE_VARIABLE = '{{$nRole}}';
16
+ const CURRENT_ROLE_EXPRESSION_RE = /^\s*\{\{\s*ctx\.role\s*\}\}\s*$/;
17
+
18
+ function isCurrentRoleExpression(value: unknown) {
19
+ return typeof value === 'string' && CURRENT_ROLE_EXPRESSION_RE.test(value);
20
+ }
15
21
 
16
22
  function isPreserveNull(value: any) {
17
23
  return (
@@ -45,6 +51,10 @@ function markEmptyVariableValues(rawNode: any, resolvedNode: any) {
45
51
  }
46
52
 
47
53
  if ('path' in rawNode && 'operator' in rawNode) {
54
+ if (isCurrentRoleExpression(rawNode.value)) {
55
+ resolvedNode.value = SERVER_CURRENT_ROLE_VARIABLE;
56
+ return;
57
+ }
48
58
  if (
49
59
  isVariableExpression(rawNode.value) &&
50
60
  (resolvedNode.value === undefined || resolvedNode.value === null || resolvedNode.value === '')
@@ -10,6 +10,56 @@
10
10
  import { defineAction, jioToJoiSchema, tExpr } from '@nocobase/flow-engine';
11
11
  import { FieldValidation } from '../../flow-compat';
12
12
 
13
+ type ValidationType = 'string' | 'number' | 'array' | 'boolean' | 'any';
14
+
15
+ interface ValidationRule {
16
+ name: string;
17
+ args?: any;
18
+ }
19
+
20
+ interface ValidationData {
21
+ type: ValidationType;
22
+ rules?: ValidationRule[];
23
+ }
24
+
25
+ interface ValidationContext {
26
+ model: {
27
+ props: {
28
+ label?: string;
29
+ };
30
+ };
31
+ t: (key: string, options?: Record<string, unknown>) => string;
32
+ }
33
+
34
+ function buildValidationRules(ctx: ValidationContext, validation: ValidationData) {
35
+ const rules = [];
36
+ const schema = jioToJoiSchema(validation);
37
+ const label = ctx.model.props.label;
38
+ rules.push({
39
+ validator: (_: unknown, value: unknown) => {
40
+ const { error } = schema.validate(value, {
41
+ abortEarly: false,
42
+ });
43
+
44
+ if (error) {
45
+ const messages = error.details.map((d: { type: string; context?: Record<string, unknown> }) => {
46
+ return ctx.t(`${d.type}`, {
47
+ ...d.context,
48
+ ns: 'data-source-main',
49
+ label,
50
+ });
51
+ });
52
+ const div = document.createElement('div');
53
+ div.innerHTML = messages.join('; ');
54
+ return Promise.reject(div.textContent);
55
+ }
56
+
57
+ return Promise.resolve();
58
+ },
59
+ });
60
+ return rules;
61
+ }
62
+
13
63
  export const validation = defineAction({
14
64
  title: tExpr('Validation'),
15
65
  name: 'validation',
@@ -29,6 +79,7 @@ export const validation = defineAction({
29
79
  type: targetInterface.validationType,
30
80
  availableValidationOptions: [...new Set(targetInterface.availableValidationOptions)],
31
81
  excludeValidationOptions: [...new Set(targetInterface.excludeValidationOptions)],
82
+ inheritedValue: ctx.model.collectionField.validation,
32
83
  isAssociation: targetInterface.isAssociation,
33
84
  },
34
85
  },
@@ -36,40 +87,21 @@ export const validation = defineAction({
36
87
  },
37
88
  handler(ctx, params) {
38
89
  if (params.validation) {
39
- const rules = [];
40
- const schema = jioToJoiSchema(params.validation);
41
- const label = ctx.model.props.label;
42
- rules.push({
43
- validator: (_, value) => {
44
- const { error } = schema.validate(value, {
45
- abortEarly: false,
46
- });
47
-
48
- if (error) {
49
- const messages = error.details.map((d) => {
50
- return ctx.t(`${d.type}`, {
51
- ...d.context,
52
- ns: 'data-source-main',
53
- label,
54
- });
55
- });
56
- const div = document.createElement('div');
57
- div.innerHTML = messages.join('; ');
58
- return Promise.reject(div.textContent);
59
- }
60
-
61
- return Promise.resolve();
62
- },
63
- });
64
- const hasRequiredInCollection = params.validation.rules.some((rule) => rule.name === 'required');
65
- if (hasRequiredInCollection) {
90
+ const collectionValidation = ctx.model.collectionField?.validation;
91
+ const collectionRules = ctx.model.collectionField?.getComponentProps?.().rules || [];
92
+ const uiRules = params.validation.rules?.length
93
+ ? buildValidationRules(ctx as unknown as ValidationContext, params.validation)
94
+ : [];
95
+ const hasRequiredInValidation = [collectionValidation, params.validation].some(
96
+ (validation) => validation?.rules?.some((rule) => rule.name === 'required'),
97
+ );
98
+ if (hasRequiredInValidation) {
66
99
  ctx.model.setProps({
67
- required: hasRequiredInCollection,
100
+ required: hasRequiredInValidation,
68
101
  });
69
102
  }
70
- console.log(rules);
71
103
  ctx.model.setProps({
72
- rules,
104
+ rules: [...collectionRules, ...uiRules],
73
105
  validation: params.validation,
74
106
  });
75
107
  }
@@ -113,6 +113,48 @@ export interface FieldAssignRulesEditorProps {
113
113
 
114
114
  const ALL_ASSIGN_MODES: AssignMode[] = ['default', 'override', 'assign'];
115
115
 
116
+ function cloneCascaderOption(option: FieldAssignCascaderOption): FieldAssignCascaderOption {
117
+ return {
118
+ ...option,
119
+ children: option.children?.map((child) => cloneCascaderOption(child)),
120
+ };
121
+ }
122
+
123
+ function mergeCascaderOptionInto(options: FieldAssignCascaderOption[], source: FieldAssignCascaderOption) {
124
+ const value = source?.value ? String(source.value) : '';
125
+ if (!value) return;
126
+
127
+ const existing = options.find((item) => String(item?.value || '') === value);
128
+ if (!existing) {
129
+ options.push(cloneCascaderOption(source));
130
+ return;
131
+ }
132
+
133
+ if (!existing.label && source.label) {
134
+ existing.label = source.label;
135
+ }
136
+ if (source.isLeaf === false || existing.children?.length || source.children?.length) {
137
+ existing.isLeaf = false;
138
+ }
139
+ if (source.loading) {
140
+ existing.loading = source.loading;
141
+ }
142
+ if (source.children?.length) {
143
+ existing.children = mergeCascaderOptions(existing.children || [], source.children);
144
+ }
145
+ }
146
+
147
+ function mergeCascaderOptions(
148
+ configured: FieldAssignCascaderOption[],
149
+ allFields: FieldAssignCascaderOption[],
150
+ ): FieldAssignCascaderOption[] {
151
+ const result = (Array.isArray(configured) ? configured : []).map((item) => cloneCascaderOption(item));
152
+ for (const item of Array.isArray(allFields) ? allFields : []) {
153
+ mergeCascaderOptionInto(result, item);
154
+ }
155
+ return result;
156
+ }
157
+
116
158
  function isAssignMode(mode: unknown): mode is AssignMode {
117
159
  return mode === 'default' || mode === 'override' || mode === 'assign';
118
160
  }
@@ -171,13 +213,21 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
171
213
  },
172
214
  [normalizeItemMode, onChange],
173
215
  );
216
+
217
+ const normalizeFieldOptions = React.useCallback((options: FieldAssignRulesEditorProps['fieldOptions']) => {
218
+ return Array.isArray(options)
219
+ ? (options as FieldAssignCascaderOption[]).map((option) => cloneCascaderOption(option))
220
+ : [];
221
+ }, []);
174
222
  const [cascaderOptions, setCascaderOptions] = React.useState<FieldAssignCascaderOption[]>(() =>
175
- Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : [],
223
+ normalizeFieldOptions(fieldOptions),
176
224
  );
225
+ const loadedCascaderOptionsRef = React.useRef<WeakSet<FieldAssignCascaderOption>>(new WeakSet());
177
226
 
178
227
  React.useEffect(() => {
179
- setCascaderOptions(Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : []);
180
- }, [fieldOptions]);
228
+ loadedCascaderOptionsRef.current = new WeakSet();
229
+ setCascaderOptions(normalizeFieldOptions(fieldOptions));
230
+ }, [fieldOptions, normalizeFieldOptions, rootCollection, maxAssociationFieldDepth]);
181
231
 
182
232
  const getRuleKey = React.useCallback((item: FieldAssignRuleItem, index: number) => item?.key || String(index), []);
183
233
  const [titleFieldDraftMap, setTitleFieldDraftMap] = React.useState<Record<string, string | undefined>>({});
@@ -597,23 +647,26 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
597
647
  const opts = selectedOptions || [];
598
648
  const target = opts[opts.length - 1];
599
649
  if (!target) return;
600
- if (target.children && Array.isArray(target.children) && target.children.length) return;
601
650
  if (target.isLeaf) return;
651
+ if (loadedCascaderOptionsRef.current.has(target)) return;
602
652
 
603
653
  const segments = opts.map((o) => String(o?.value)).filter(Boolean);
604
654
  const targetCollection = resolveTargetCollectionBySegments(segments);
605
655
  if (!targetCollection) {
606
- target.isLeaf = true;
656
+ target.isLeaf = !(target.children && target.children.length);
657
+ loadedCascaderOptionsRef.current.add(target);
607
658
  setCascaderOptions((prev) => [...prev]);
608
659
  return;
609
660
  }
610
661
 
611
662
  const children = buildChildrenFromCollection(targetCollection, segments.length);
612
663
  if (!children.length) {
613
- target.isLeaf = true;
664
+ target.isLeaf = !(target.children && target.children.length);
614
665
  } else {
615
- target.children = children;
666
+ target.children = mergeCascaderOptions(target.children || [], children);
667
+ target.isLeaf = false;
616
668
  }
669
+ loadedCascaderOptionsRef.current.add(target);
617
670
  setCascaderOptions((prev) => [...prev]);
618
671
  },
619
672
  [buildChildrenFromCollection, resolveTargetCollectionBySegments],
@@ -629,12 +682,14 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
629
682
  const hit = options.find((o) => String(o?.value) === seg);
630
683
  if (!hit) return;
631
684
  selected.push(hit);
685
+ if (!hit.isLeaf && !loadedCascaderOptionsRef.current.has(hit)) {
686
+ await loadCascaderData(selected);
687
+ }
632
688
  if (hit.children?.length) {
633
689
  options = hit.children;
634
690
  continue;
635
691
  }
636
692
  if (hit.isLeaf) return;
637
- await loadCascaderData(selected);
638
693
  options = hit.children || [];
639
694
  }
640
695
  },
@@ -665,7 +720,9 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
665
720
  await preloadCascaderPath(segs);
666
721
  }
667
722
  };
668
- void run();
723
+ run().catch((error) => {
724
+ console.warn('[FieldAssignRulesEditor] Failed to preload cascader path', error);
725
+ });
669
726
  return () => {
670
727
  cancelled = true;
671
728
  };