@nocobase/client-v2 2.1.30 → 2.1.32

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.30",
3
+ "version": "2.1.32",
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.30",
31
- "@nocobase/flow-engine": "2.1.30",
32
- "@nocobase/sdk": "2.1.30",
33
- "@nocobase/shared": "2.1.30",
34
- "@nocobase/utils": "2.1.30",
30
+ "@nocobase/evaluators": "2.1.32",
31
+ "@nocobase/flow-engine": "2.1.32",
32
+ "@nocobase/sdk": "2.1.32",
33
+ "@nocobase/shared": "2.1.32",
34
+ "@nocobase/utils": "2.1.32",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -48,5 +48,5 @@
48
48
  "react-i18next": "^11.15.1",
49
49
  "react-router-dom": "^6.30.1"
50
50
  },
51
- "gitHead": "c1a4bff0fd3c8b30e06fc6f50ad33e054b04061a"
51
+ "gitHead": "737a1324f650d7bc66594203b68eb0180553a551"
52
52
  }
@@ -11,7 +11,7 @@ import { FlowEngine } from '@nocobase/flow-engine';
11
11
  import { describe, expect, it, vi } from 'vitest';
12
12
  import { ActionModel as ConfiguredActionModel } from '../../models/base/ActionModel';
13
13
  import { ActionModel } from '../../models/base/ActionModelCore';
14
- import { actionLinkageRules, linkageSetActionProps } from '../linkageRules';
14
+ import { actionLinkageRules, linkageSetActionProps, updateLinkageRules } from '../linkageRules';
15
15
 
16
16
  class TestActionModel extends ActionModel {}
17
17
 
@@ -73,16 +73,180 @@ describe('action linkage rules on action forks', () => {
73
73
  });
74
74
 
75
75
  expect(fork.getProps().title).toBe('Updated');
76
- expect(fork.serialize().props.title).toBe('Updated');
76
+ expect(fork.serialize().props).toMatchObject({ title: 'Updated' });
77
+ expect(fork.serialize().props).not.toHaveProperty('disabled');
77
78
 
78
79
  const refreshedFork = master.createFork({ className: 'row-action' });
79
80
  expect(refreshedFork.getProps().title).toBe('Updated');
80
81
  });
81
82
 
83
+ it.each(['disable', 'delete'] as const)(
84
+ 'restores a row action when linkage rules are %sd without persisting disabled',
85
+ async (change) => {
86
+ const engine = new FlowEngine();
87
+ engine.registerModels({ ConfiguredActionModel });
88
+ engine.registerActions({ actionLinkageRules, linkageSetActionProps });
89
+
90
+ let savedData: ReturnType<ConfiguredActionModel['serialize']> | undefined;
91
+ engine.setModelRepository({
92
+ save: async (model) => {
93
+ savedData = model.serialize();
94
+ return savedData;
95
+ },
96
+ } as Parameters<FlowEngine['setModelRepository']>[0]);
97
+
98
+ const enabledRules = [
99
+ {
100
+ key: 'disable-edit',
101
+ enable: true,
102
+ condition: { logic: '$and', items: [] },
103
+ actions: [{ name: 'linkageSetActionProps', params: { value: 'disabled' } }],
104
+ },
105
+ ];
106
+ const master = engine.createModel<ConfiguredActionModel>({
107
+ use: 'ConfiguredActionModel',
108
+ props: { title: 'Edit' },
109
+ stepParams: {
110
+ buttonSettings: {
111
+ linkageRules: { value: enabledRules },
112
+ },
113
+ },
114
+ });
115
+ const fork = master.createFork({ className: 'row-action' });
116
+
117
+ await fork.dispatchEvent('beforeRender', undefined, { useCache: false });
118
+ expect(fork.getProps().disabled).toBe(true);
119
+
120
+ const nextRules =
121
+ change === 'disable'
122
+ ? updateLinkageRules(enabledRules, (rules) => {
123
+ rules[0].enable = false;
124
+ })
125
+ : [];
126
+ fork.setStepParams('buttonSettings', 'linkageRules', { value: nextRules });
127
+ await fork.saveStepParams();
128
+ await fork.rerender();
129
+
130
+ expect(savedData).toBeDefined();
131
+ expect(savedData?.props).not.toHaveProperty('disabled');
132
+ expect(savedData?.stepParams.buttonSettings.linkageRules.value).toEqual(nextRules);
133
+ expect(fork.getProps().disabled).toBeUndefined();
134
+
135
+ const reloadedEngine = new FlowEngine();
136
+ reloadedEngine.registerModels({ ConfiguredActionModel });
137
+ reloadedEngine.registerActions({ actionLinkageRules, linkageSetActionProps });
138
+ const reloadedMaster = reloadedEngine.createModel<ConfiguredActionModel>({
139
+ use: 'ConfiguredActionModel',
140
+ props: savedData?.props,
141
+ stepParams: savedData?.stepParams,
142
+ });
143
+ const reloadedFork = reloadedMaster.createFork({ className: 'row-action' });
144
+
145
+ await reloadedFork.dispatchEvent('beforeRender', undefined, { useCache: false });
146
+ expect(reloadedFork.getProps().disabled).toBeUndefined();
147
+ },
148
+ );
149
+
150
+ it.each(['disabled', 'deleted'] as const)(
151
+ 'ignores a historical disabled prop after the linkage rule is %s',
152
+ async (state) => {
153
+ const engine = new FlowEngine();
154
+ engine.registerModels({ ConfiguredActionModel });
155
+ engine.registerActions({ actionLinkageRules, linkageSetActionProps });
156
+ const rules =
157
+ state === 'disabled'
158
+ ? [
159
+ {
160
+ key: 'disable-edit',
161
+ enable: false,
162
+ condition: { logic: '$and', items: [] },
163
+ actions: [{ name: 'linkageSetActionProps', params: { value: 'disabled' } }],
164
+ },
165
+ ]
166
+ : [];
167
+ const master = engine.createModel<ConfiguredActionModel>({
168
+ use: 'ConfiguredActionModel',
169
+ props: { title: 'Edit', disabled: true },
170
+ stepParams: {
171
+ buttonSettings: {
172
+ linkageRules: { value: rules },
173
+ },
174
+ },
175
+ });
176
+ const fork = master.createFork({ className: 'row-action' });
177
+
178
+ expect(master.getProps()).not.toHaveProperty('disabled');
179
+ await fork.dispatchEvent('beforeRender', undefined, { useCache: false });
180
+
181
+ expect(fork.getProps().disabled).toBeUndefined();
182
+ },
183
+ );
184
+
185
+ it('still applies an enabled linkage rule after dropping a historical disabled prop', async () => {
186
+ const engine = new FlowEngine();
187
+ engine.registerModels({ ConfiguredActionModel });
188
+ engine.registerActions({ actionLinkageRules, linkageSetActionProps });
189
+ const master = engine.createModel<ConfiguredActionModel>({
190
+ use: 'ConfiguredActionModel',
191
+ props: { title: 'Edit', disabled: true },
192
+ stepParams: {
193
+ buttonSettings: {
194
+ linkageRules: {
195
+ value: [
196
+ {
197
+ key: 'disable-edit',
198
+ enable: true,
199
+ condition: { logic: '$and', items: [] },
200
+ actions: [{ name: 'linkageSetActionProps', params: { value: 'disabled' } }],
201
+ },
202
+ ],
203
+ },
204
+ },
205
+ },
206
+ });
207
+ const fork = master.createFork({ className: 'row-action' });
208
+
209
+ expect(master.getProps()).not.toHaveProperty('disabled');
210
+ await fork.dispatchEvent('beforeRender', undefined, { useCache: false });
211
+
212
+ expect(fork.getProps().disabled).toBe(true);
213
+ });
214
+
215
+ it('preserves a default disabled state as runtime-only state', async () => {
216
+ class DefaultDisabledActionModel extends ConfiguredActionModel {
217
+ defaultProps = { ...this.defaultProps, disabled: true };
218
+ }
219
+ const engine = new FlowEngine();
220
+ engine.registerModels({ DefaultDisabledActionModel });
221
+ engine.registerActions({ actionLinkageRules, linkageSetActionProps });
222
+ const master = engine.createModel<DefaultDisabledActionModel>({
223
+ use: 'DefaultDisabledActionModel',
224
+ props: { title: 'Edit' },
225
+ stepParams: {
226
+ buttonSettings: {
227
+ linkageRules: { value: [] },
228
+ },
229
+ },
230
+ });
231
+ const fork = master.createFork({ className: 'row-action' });
232
+
233
+ await fork.dispatchEvent('beforeRender', undefined, { useCache: false });
234
+
235
+ expect(fork.getProps().disabled).toBe(true);
236
+ expect(fork.serialize().props).not.toHaveProperty('disabled');
237
+ });
238
+
82
239
  it('updates a disabled row action title through the real beforeRender flow', async () => {
83
240
  const engine = new FlowEngine();
84
241
  engine.registerModels({ ConfiguredActionModel });
85
242
  engine.registerActions({ actionLinkageRules, linkageSetActionProps });
243
+ let savedData: ReturnType<ConfiguredActionModel['serialize']> | undefined;
244
+ engine.setModelRepository({
245
+ save: async (model) => {
246
+ savedData = model.serialize();
247
+ return savedData;
248
+ },
249
+ } as Parameters<FlowEngine['setModelRepository']>[0]);
86
250
  const master = engine.createModel<ConfiguredActionModel>({
87
251
  use: 'ConfiguredActionModel',
88
252
  props: { title: 'Edit' },
@@ -107,9 +271,44 @@ describe('action linkage rules on action forks', () => {
107
271
  await fork.dispatchEvent('beforeRender', undefined, { useCache: false });
108
272
  expect(fork.getProps()).toMatchObject({ title: 'Edit', disabled: true });
109
273
 
110
- master.setStepParams('buttonSettings', 'general', { title: 'Updated' });
111
- await new Promise((resolve) => setTimeout(resolve, 0));
274
+ fork.setStepParams('buttonSettings', 'general', { title: 'Updated' });
275
+ await fork.saveStepParams();
276
+ await fork.rerender();
112
277
 
278
+ expect(savedData?.props).not.toHaveProperty('disabled');
279
+ expect(savedData?.stepParams.buttonSettings.general.title).toBe('Updated');
113
280
  expect(fork.getProps()).toMatchObject({ title: 'Updated', disabled: true });
281
+
282
+ const reloadedEngine = new FlowEngine();
283
+ reloadedEngine.registerModels({ ConfiguredActionModel });
284
+ reloadedEngine.registerActions({ actionLinkageRules, linkageSetActionProps });
285
+ const reloadedMaster = reloadedEngine.createModel<ConfiguredActionModel>({
286
+ use: 'ConfiguredActionModel',
287
+ props: savedData?.props,
288
+ stepParams: savedData?.stepParams,
289
+ });
290
+ const reloadedFork = reloadedMaster.createFork({ className: 'row-action' });
291
+
292
+ await reloadedFork.dispatchEvent('beforeRender', undefined, { useCache: false });
293
+ expect(reloadedFork.getProps()).toMatchObject({ title: 'Updated', disabled: true });
294
+ });
295
+
296
+ it('creates a new rules value when disabling a linkage rule', () => {
297
+ const rules = [
298
+ {
299
+ key: 'disable-edit',
300
+ title: 'Linkage rule',
301
+ enable: true,
302
+ condition: { logic: '$and', items: [] },
303
+ actions: [],
304
+ },
305
+ ];
306
+ const nextRules = updateLinkageRules(rules, (next) => {
307
+ next[0].enable = false;
308
+ });
309
+
310
+ expect(nextRules).toEqual([expect.objectContaining({ key: 'disable-edit', enable: false })]);
311
+ expect(nextRules).not.toBe(rules);
312
+ expect(rules[0].enable).toBe(true);
114
313
  });
115
314
  });
@@ -72,6 +72,12 @@ interface LinkageRule {
72
72
  }[];
73
73
  }
74
74
 
75
+ export function updateLinkageRules<T>(rules: T[], updater: (nextRules: T[]) => void): T[] {
76
+ const nextRules = _.cloneDeep(rules);
77
+ updater(nextRules);
78
+ return nextRules;
79
+ }
80
+
75
81
  const previewValueForLog = (value: any) => {
76
82
  if (value == null) return value;
77
83
  const t = typeof value;
@@ -1469,13 +1475,26 @@ async function resolveLinkageRulesParamsPreservingRunJsScripts(ctx: FlowContext,
1469
1475
  }
1470
1476
 
1471
1477
  const LinkageRulesUI = observer(
1472
- (props: { readonly value: LinkageRule[]; supportedActions: string[]; title?: string }) => {
1473
- const { value: rules, supportedActions } = props;
1478
+ (props: {
1479
+ readonly value: LinkageRule[];
1480
+ onChange?: (value: LinkageRule[]) => void;
1481
+ supportedActions: string[];
1482
+ title?: string;
1483
+ }) => {
1484
+ const { value: rules = [], onChange, supportedActions } = props;
1474
1485
  const ctx = useFlowContext();
1475
1486
  const flowEngine = useFlowEngine();
1476
1487
  const t = ctx.model.translate.bind(ctx.model);
1477
1488
  const assignPriorityTip = t('Assignment takes precedence over form field assignment');
1478
1489
 
1490
+ const replaceRules = (updater: (nextRules: LinkageRule[]) => void) => {
1491
+ if (onChange) {
1492
+ onChange(updateLinkageRules(rules, updater));
1493
+ } else {
1494
+ updater(rules);
1495
+ }
1496
+ };
1497
+
1479
1498
  // 创建新规则的默认值
1480
1499
  const createNewRule = (): LinkageRule => ({
1481
1500
  key: uid(),
@@ -1492,7 +1511,7 @@ const LinkageRulesUI = observer(
1492
1511
 
1493
1512
  // 删除规则
1494
1513
  const handleDeleteRule = (index: number) => {
1495
- rules.splice(index, 1);
1514
+ replaceRules((nextRules) => nextRules.splice(index, 1));
1496
1515
  };
1497
1516
 
1498
1517
  // 上移规则
@@ -1531,7 +1550,9 @@ const LinkageRulesUI = observer(
1531
1550
 
1532
1551
  // 切换规则启用状态
1533
1552
  const handleToggleEnable = (index: number, enable: boolean) => {
1534
- rules[index].enable = enable;
1553
+ replaceRules((nextRules) => {
1554
+ nextRules[index].enable = enable;
1555
+ });
1535
1556
  };
1536
1557
 
1537
1558
  // 获取可用的动作类型
@@ -7,7 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { DefaultStructure, FlowModel, tExpr, useFlowModel } from '@nocobase/flow-engine';
10
+ import { FlowModel, tExpr, useFlowModel } from '@nocobase/flow-engine';
11
+ import type { DefaultStructure, FlowModelOptions } from '@nocobase/flow-engine';
11
12
  import { Icon } from '../../../components';
12
13
  import { Button, Tooltip } from 'antd';
13
14
  import type { ButtonProps } from 'antd/es/button';
@@ -86,8 +87,10 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
86
87
  return null;
87
88
  }
88
89
 
89
- onInit(options: any): void {
90
+ onInit(options: FlowModelOptions<T>): void {
90
91
  super.onInit(options);
92
+ // Action disabled state is computed at runtime and must never be restored from persisted props.
93
+ delete this.props.disabled;
91
94
  this.context.defineProperty('actionName', {
92
95
  get: () => {
93
96
  return this.getAclActionName();
@@ -96,6 +99,12 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
96
99
  });
97
100
  }
98
101
 
102
+ serialize() {
103
+ const data = super.serialize();
104
+ delete data.props.disabled;
105
+ return data;
106
+ }
107
+
99
108
  getInputArgs() {
100
109
  const inputArgs: Record<string, any> = {};
101
110
  const defaultKeys: string[] = [];
@@ -1,3 +1,12 @@
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
+
1
10
  import { render, screen } from '@nocobase/test/client';
2
11
  import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
3
12
  import { App, ConfigProvider } from 'antd';
@@ -35,3 +44,43 @@ describe('ActionModel rendering', () => {
35
44
  expect(screen.getByRole('button', { name: 'Open details' })).toBeInTheDocument();
36
45
  });
37
46
  });
47
+
48
+ describe('ActionModel disabled state persistence', () => {
49
+ it('drops disabled from persisted props during initialization', () => {
50
+ const engine = new FlowEngine();
51
+ engine.registerModels({ NoIconActionModel });
52
+ const model = engine.createModel<NoIconActionModel>({
53
+ use: 'NoIconActionModel',
54
+ props: { title: 'Open details', disabled: true },
55
+ });
56
+
57
+ expect(model.getProps()).not.toHaveProperty('disabled');
58
+ });
59
+
60
+ it('keeps runtime disabled state without serializing it', () => {
61
+ const engine = new FlowEngine();
62
+ engine.registerModels({ NoIconActionModel });
63
+ const model = engine.createModel<NoIconActionModel>({
64
+ use: 'NoIconActionModel',
65
+ props: { title: 'Open details' },
66
+ });
67
+
68
+ model.setProps('disabled', true);
69
+
70
+ expect(model.getProps().disabled).toBe(true);
71
+ expect(model.serialize().props).toEqual({ title: 'Open details' });
72
+ });
73
+
74
+ it('does not serialize fork-local disabled state', () => {
75
+ const engine = new FlowEngine();
76
+ engine.registerModels({ NoIconActionModel });
77
+ const model = engine.createModel<NoIconActionModel>({
78
+ use: 'NoIconActionModel',
79
+ props: { title: 'Open details' },
80
+ });
81
+ const fork = model.createFork({ disabled: true });
82
+
83
+ expect(fork.getProps().disabled).toBe(true);
84
+ expect(fork.serialize().props).toEqual({ title: 'Open details' });
85
+ });
86
+ });
@@ -234,7 +234,9 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
234
234
  }
235
235
 
236
236
  createResource(ctx, params) {
237
- return this.context.createResource(MultiRecordResource);
237
+ const resource = this.context.createResource(MultiRecordResource);
238
+ resource.addRequestHeader('X-With-ACL-Meta', 'true');
239
+ return resource;
238
240
  }
239
241
 
240
242
  /**
@@ -194,6 +194,35 @@ export class TableColumnModel extends DisplayItemModel {
194
194
  .filter(Boolean);
195
195
  }
196
196
 
197
+ canQuickEdit(record: Record<string, unknown>): boolean {
198
+ if (!this.props.editable) {
199
+ return false;
200
+ }
201
+ if (this.context.skipAclCheck) {
202
+ return true;
203
+ }
204
+
205
+ const blockModel = this.context.blockModel;
206
+ const collection = blockModel?.collection;
207
+ const resource = blockModel?.resource;
208
+ const collectionField = this.collectionField;
209
+ if (!collection || !resource || !collectionField) {
210
+ return false;
211
+ }
212
+
213
+ const filterByTk = collection.getFilterByTK(record);
214
+ const recordPkValue = typeof filterByTk === 'string' || typeof filterByTk === 'number' ? filterByTk : undefined;
215
+
216
+ return this.context.acl.can({
217
+ dataSourceKey: collection.dataSourceKey,
218
+ resourceName: resource.getResourceName() || collection.name,
219
+ actionName: 'update',
220
+ recordPkValue,
221
+ allowedActions: resource.getMeta('allowedActions'),
222
+ fields: [collectionField.name],
223
+ });
224
+ }
225
+
197
226
  getColumnProps(): TableColumnProps & { sortField?: string } {
198
227
  if (!this.props.width) {
199
228
  return;
@@ -250,7 +279,7 @@ export class TableColumnModel extends DisplayItemModel {
250
279
  record,
251
280
  recordIndex: record?.__index || recordIndex,
252
281
  width: this.props.width - 16,
253
- editable: this.props.editable,
282
+ editable: this.canQuickEdit(record),
254
283
  dataIndex: this.props.dataIndex,
255
284
  title: this.props.title,
256
285
  overflowMode: this.props.overflowMode,
@@ -9,8 +9,20 @@
9
9
 
10
10
  import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
11
11
  import { describe, expect, it } from 'vitest';
12
+ import { TableBlockModel } from '../TableBlockModel';
12
13
 
13
14
  describe('TableBlockModel quick edit refresh', () => {
15
+ it('requests record-level ACL metadata for table data', () => {
16
+ const engine = new FlowEngine();
17
+ const model = {
18
+ context: engine.context,
19
+ };
20
+
21
+ const resource = TableBlockModel.prototype.createResource.call(model, {}, {});
22
+
23
+ expect(resource.getRequestOptions().headers['X-With-ACL-Meta']).toBe('true');
24
+ });
25
+
14
26
  it('updates table data through a new array reference after quick editing a flat row', () => {
15
27
  const engine = new FlowEngine();
16
28
  const resource = engine.context.createResource(MultiRecordResource);
@@ -66,6 +66,39 @@ describe('TableColumnModel sorter settings', () => {
66
66
  expect(setProps).toHaveBeenCalledWith('editable', false);
67
67
  });
68
68
 
69
+ it('checks record update scope and field permission before enabling quick edit', () => {
70
+ const can = vi.fn(() => false);
71
+ const model = {
72
+ props: { editable: true },
73
+ collectionField: { name: 'title' },
74
+ context: {
75
+ skipAclCheck: false,
76
+ acl: { can },
77
+ blockModel: {
78
+ collection: {
79
+ dataSourceKey: 'main',
80
+ name: 'posts',
81
+ getFilterByTK: () => 2,
82
+ },
83
+ resource: {
84
+ getResourceName: () => 'posts',
85
+ getMeta: () => ({ update: [1] }),
86
+ },
87
+ },
88
+ },
89
+ };
90
+
91
+ expect(TableColumnModel.prototype.canQuickEdit.call(model, { id: 2 })).toBe(false);
92
+ expect(can).toHaveBeenCalledWith({
93
+ dataSourceKey: 'main',
94
+ resourceName: 'posts',
95
+ actionName: 'update',
96
+ recordPkValue: 2,
97
+ allowedActions: { update: [1] },
98
+ fields: ['title'],
99
+ });
100
+ });
101
+
69
102
  it('hides sortable setting for association fields', async () => {
70
103
  const engine = new FlowEngine();
71
104
  const model = new TableColumnModel({ uid: 'table-column-association-sorter', flowEngine: engine } as any);