@nocobase/client-v2 2.2.0-alpha.11 → 2.2.0-alpha.12

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 (87) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/index.d.ts +1 -0
  5. package/es/flow/actions/linkageRules.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  7. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  8. package/es/index.d.ts +4 -1
  9. package/es/index.mjs +123 -112
  10. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  11. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  12. package/es/settings-app/SettingsApplication.d.ts +18 -0
  13. package/es/settings-app/SettingsBrand.d.ts +16 -0
  14. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  15. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  16. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  17. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  18. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  19. package/es/settings-app/SettingsSearch.d.ts +17 -0
  20. package/es/settings-app/SettingsShell.d.ts +10 -0
  21. package/es/settings-app/isSettingsApp.d.ts +18 -0
  22. package/es/settings-app/runtimePaths.d.ts +18 -0
  23. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  24. package/es/settings-app/settingsTheme.d.ts +87 -0
  25. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  26. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  27. package/es/settings-center/groups.d.ts +66 -0
  28. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  29. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  30. package/es/settings-center/utils.d.ts +24 -0
  31. package/es/theme/index.d.ts +1 -0
  32. package/es/theme/itemActive.d.ts +24 -0
  33. package/es/theme/type.d.ts +9 -0
  34. package/lib/index.js +126 -115
  35. package/package.json +7 -7
  36. package/src/__tests__/authRedirect.test.ts +137 -0
  37. package/src/__tests__/browserChecker.test.ts +124 -6
  38. package/src/__tests__/mockSettingsApplication.ts +29 -0
  39. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +44 -8
  40. package/src/__tests__/plugin-manager.test.tsx +7 -6
  41. package/src/__tests__/settings-application.test.ts +164 -0
  42. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  43. package/src/__tests__/settings-center.test.tsx +38 -37
  44. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  45. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  46. package/src/__tests__/settings-shell.test.tsx +200 -0
  47. package/src/acl/ACLProvider.tsx +21 -0
  48. package/src/authRedirect.ts +72 -3
  49. package/src/components/AttachmentUpload.tsx +275 -0
  50. package/src/components/index.ts +1 -0
  51. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +203 -4
  52. package/src/flow/actions/linkageRules.tsx +25 -4
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  54. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  55. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  56. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  57. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  58. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  59. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  60. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  61. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  62. package/src/index.ts +10 -1
  63. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  64. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  65. package/src/settings-app/SettingsApplication.ts +31 -0
  66. package/src/settings-app/SettingsBrand.tsx +108 -0
  67. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  68. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  69. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  70. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  71. package/src/settings-app/SettingsRouterManager.ts +92 -0
  72. package/src/settings-app/SettingsSearch.tsx +238 -0
  73. package/src/settings-app/SettingsShell.tsx +167 -0
  74. package/src/settings-app/isSettingsApp.ts +21 -0
  75. package/src/settings-app/runtimePaths.ts +104 -0
  76. package/src/settings-app/settingsDocumentPath.ts +66 -0
  77. package/src/settings-app/settingsTheme.ts +301 -0
  78. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  79. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  80. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  81. package/src/settings-center/groups.ts +108 -0
  82. package/src/settings-center/useSettingsGroups.ts +105 -0
  83. package/src/settings-center/useSettingsSearch.ts +173 -0
  84. package/src/settings-center/utils.tsx +50 -1
  85. package/src/theme/index.tsx +1 -0
  86. package/src/theme/itemActive.ts +31 -0
  87. package/src/theme/type.ts +10 -0
@@ -0,0 +1,275 @@
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 { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
11
+ import { css, cx } from '@emotion/css';
12
+ import { Upload, message } from 'antd';
13
+ import type { UploadFile } from 'antd';
14
+ import mime from 'mime';
15
+ import match from 'mime-match';
16
+ import type { RcFile, UploadRequestOption } from 'rc-upload/lib/interface';
17
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { useTranslation } from 'react-i18next';
19
+ import { useApp } from '../hooks/useApp';
20
+
21
+ export type UploadedAttachment = {
22
+ id?: number;
23
+ title?: string;
24
+ filename?: string;
25
+ url?: string;
26
+ };
27
+
28
+ export type AttachmentStorageRules = {
29
+ size?: number;
30
+ mimetype?: string | string[];
31
+ };
32
+
33
+ /**
34
+ * 读取默认附件存储的上传限制。
35
+ *
36
+ * 校验交给存储自身的配置(大小、mimetype),而不是在每个调用点各写一套硬编码规则。
37
+ *
38
+ * @returns {AttachmentStorageRules | null} 存储规则,取不到时为 null
39
+ */
40
+ export function useAttachmentStorageRules() {
41
+ const app = useApp();
42
+ const [storageRules, setStorageRules] = useState<AttachmentStorageRules | null>(null);
43
+
44
+ useEffect(() => {
45
+ let mounted = true;
46
+
47
+ const loadStorageRules = async () => {
48
+ try {
49
+ const attachmentsCollection = app.dataSourceManager?.getCollection?.('main', 'attachments');
50
+ const storageName = attachmentsCollection?.getOption?.('storage');
51
+
52
+ if (!storageName) {
53
+ if (mounted) {
54
+ setStorageRules(null);
55
+ }
56
+ return;
57
+ }
58
+
59
+ const response = await app.apiClient.request({
60
+ url: `storages:getBasicInfo/${storageName}`,
61
+ skipNotify: true,
62
+ });
63
+
64
+ if (mounted) {
65
+ setStorageRules(response?.data?.data?.rules || null);
66
+ }
67
+ } catch (error) {
68
+ if (mounted) {
69
+ setStorageRules(null);
70
+ }
71
+ }
72
+ };
73
+
74
+ loadStorageRules().catch(() => {
75
+ if (mounted) {
76
+ setStorageRules(null);
77
+ }
78
+ });
79
+
80
+ return () => {
81
+ mounted = false;
82
+ };
83
+ }, [app]);
84
+
85
+ return storageRules;
86
+ }
87
+
88
+ export type AttachmentUploadProps = {
89
+ value?: UploadedAttachment | null;
90
+ onChange?: (value: UploadedAttachment | null) => void;
91
+ /** 卡片尺寸;`fit` 对应 CSS object-fit */
92
+ preview?: {
93
+ width?: number;
94
+ height?: number;
95
+ fit?: 'contain' | 'cover';
96
+ };
97
+ /** 在存储规则之外再收窄可选文件类型,例如只收图片 */
98
+ accept?: string;
99
+ /** 上传卡片里 `+` 下方的文字 */
100
+ uploadText?: string;
101
+ disabled?: boolean;
102
+ };
103
+
104
+ const DEFAULT_PREVIEW = { width: 104, height: 104, fit: 'contain' as const };
105
+
106
+ /**
107
+ * 附件上传控件。
108
+ *
109
+ * 受控组件,值就是 `attachments:create` 返回的附件对象,可直接挂在 `Form.Item` 上。
110
+ * 外观沿用附件字段的原生形态:一张 picture-card,删除在卡片自身的悬浮操作里,不额外摆按钮。
111
+ * 上传前按默认存储的规则校验大小与 mimetype,与业务表单里的附件字段保持同一套限制。
112
+ */
113
+ export const AttachmentUpload: React.FC<AttachmentUploadProps> = (props) => {
114
+ const { value, onChange, preview, accept, uploadText, disabled } = props;
115
+ const app = useApp();
116
+ const { t } = useTranslation();
117
+ const [uploading, setUploading] = useState(false);
118
+ const storageRules = useAttachmentStorageRules();
119
+ const previewConfig = { ...DEFAULT_PREVIEW, ...preview };
120
+
121
+ const acceptValue = useMemo(() => {
122
+ if (accept) {
123
+ return accept;
124
+ }
125
+ const rule = storageRules?.mimetype;
126
+ if (!rule) {
127
+ return undefined;
128
+ }
129
+ return Array.isArray(rule) ? rule.join(',') : rule;
130
+ }, [accept, storageRules?.mimetype]);
131
+
132
+ const beforeUpload = useCallback(
133
+ async (file: RcFile) => {
134
+ const sizeLimit = storageRules?.size;
135
+
136
+ if (typeof sizeLimit === 'number' && sizeLimit > 0 && file.size > sizeLimit) {
137
+ message.error(t('File size exceeds the limit'));
138
+ return Upload.LIST_IGNORE;
139
+ }
140
+
141
+ const mimetypeRule = storageRules?.mimetype;
142
+ const normalizedRule = Array.isArray(mimetypeRule)
143
+ ? mimetypeRule.join(',').trim()
144
+ : `${mimetypeRule || ''}`.trim();
145
+ const acceptRule = accept?.trim();
146
+ const rules = [normalizedRule, acceptRule].filter((rule) => rule && rule !== '*');
147
+
148
+ if (!rules.length) {
149
+ return file;
150
+ }
151
+
152
+ // 有些浏览器对不常见扩展名给不出 type,这里按扩展名补一次再判断。
153
+ let targetFile = file;
154
+ if (!targetFile.type) {
155
+ const extname = targetFile.name?.match(/\.[^.]+$/)?.[0];
156
+ if (extname) {
157
+ targetFile = new File([targetFile], targetFile.name, {
158
+ type: mime.getType(extname) || 'application/octet-stream',
159
+ lastModified: targetFile.lastModified,
160
+ }) as RcFile;
161
+ }
162
+ }
163
+
164
+ const isAllowed = rules.every((rule) =>
165
+ rule
166
+ .split(',')
167
+ .filter(Boolean)
168
+ .some((item) => match(targetFile.type)(item.trim())),
169
+ );
170
+
171
+ if (!isAllowed) {
172
+ message.error(t('File type is not allowed'));
173
+ return Upload.LIST_IGNORE;
174
+ }
175
+
176
+ return targetFile;
177
+ },
178
+ [accept, storageRules?.mimetype, storageRules?.size, t],
179
+ );
180
+
181
+ const handleUpload = useCallback(
182
+ async (options: UploadRequestOption) => {
183
+ const { file, onError, onSuccess } = options;
184
+ setUploading(true);
185
+
186
+ try {
187
+ const formData = new FormData();
188
+ formData.append('file', file as RcFile);
189
+
190
+ const response = await app.apiClient.request({
191
+ url: 'attachments:create',
192
+ method: 'post',
193
+ data: formData,
194
+ });
195
+
196
+ onChange?.(response?.data?.data || null);
197
+ onSuccess?.(response?.data, file as RcFile);
198
+ } catch (error) {
199
+ onError?.(error as Error);
200
+ } finally {
201
+ setUploading(false);
202
+ }
203
+ },
204
+ [app.apiClient, onChange],
205
+ );
206
+
207
+ const fileList = useMemo<UploadFile[]>(() => {
208
+ if (!value?.url) {
209
+ return [];
210
+ }
211
+
212
+ return [
213
+ {
214
+ uid: `${value.id ?? value.url}`,
215
+ name: value.title || value.filename || '',
216
+ status: 'done',
217
+ url: value.url,
218
+ thumbUrl: value.url,
219
+ },
220
+ ];
221
+ }, [value?.filename, value?.id, value?.title, value?.url]);
222
+
223
+ const handlePreview = useCallback((file: UploadFile) => {
224
+ if (file.url) {
225
+ window.open(file.url, '_blank', 'noopener,noreferrer');
226
+ }
227
+ }, []);
228
+
229
+ const handleRemove = useCallback(() => {
230
+ onChange?.(null);
231
+ return true;
232
+ }, [onChange]);
233
+
234
+ // picture-card 的卡片尺寸写死在 104×104,这里按调用方要的比例覆盖掉。
235
+ const sizeClassName = useMemo(
236
+ () => css`
237
+ .ant-upload.ant-upload-select,
238
+ .ant-upload-list-item-container {
239
+ width: ${previewConfig.width}px !important;
240
+ height: ${previewConfig.height}px !important;
241
+ }
242
+
243
+ .ant-upload-list-item-thumbnail img {
244
+ object-fit: ${previewConfig.fit} !important;
245
+ }
246
+ `,
247
+ [previewConfig.fit, previewConfig.height, previewConfig.width],
248
+ );
249
+
250
+ return (
251
+ <Upload
252
+ accept={acceptValue}
253
+ beforeUpload={beforeUpload}
254
+ className={cx('nb-attachment-upload', sizeClassName)}
255
+ customRequest={handleUpload}
256
+ // 上传期间一并禁用:否则可以在第一个请求返回前再选一个文件,
257
+ // 两个请求谁后返回谁覆盖 onChange,最终留下的不一定是用户最后选的那张。
258
+ disabled={disabled || uploading}
259
+ fileList={fileList}
260
+ listType="picture-card"
261
+ maxCount={1}
262
+ onPreview={handlePreview}
263
+ onRemove={handleRemove}
264
+ >
265
+ {fileList.length ? null : (
266
+ <div>
267
+ {uploading ? <LoadingOutlined /> : <PlusOutlined />}
268
+ <div style={{ marginTop: 8 }}>{uploadText || t('Upload')}</div>
269
+ </div>
270
+ )}
271
+ </Upload>
272
+ );
273
+ };
274
+
275
+ export default AttachmentUpload;
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  export * from './AppComponents';
11
+ export * from './AttachmentUpload';
11
12
  export * from './BlankComponent';
12
13
  export * from './category-tabs';
13
14
  export * from './form/table/dnd';
@@ -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
  });
@@ -68,6 +68,12 @@ interface LinkageRule {
68
68
  }[];
69
69
  }
70
70
 
71
+ export function updateLinkageRules<T>(rules: T[], updater: (nextRules: T[]) => void): T[] {
72
+ const nextRules = _.cloneDeep(rules);
73
+ updater(nextRules);
74
+ return nextRules;
75
+ }
76
+
71
77
  const previewValueForLog = (value: any) => {
72
78
  if (value == null) return value;
73
79
  const t = typeof value;
@@ -1500,13 +1506,26 @@ async function resolveLinkageRulesParamsPreservingRunJsScripts(ctx: FlowContext,
1500
1506
  }
1501
1507
 
1502
1508
  const LinkageRulesUI = observer(
1503
- (props: { readonly value: LinkageRule[]; supportedActions: string[]; title?: string }) => {
1504
- const { value: rules, supportedActions } = props;
1509
+ (props: {
1510
+ readonly value: LinkageRule[];
1511
+ onChange?: (value: LinkageRule[]) => void;
1512
+ supportedActions: string[];
1513
+ title?: string;
1514
+ }) => {
1515
+ const { value: rules = [], onChange, supportedActions } = props;
1505
1516
  const ctx = useFlowContext();
1506
1517
  const flowEngine = useFlowEngine();
1507
1518
  const t = ctx.model.translate.bind(ctx.model);
1508
1519
  const assignPriorityTip = t('Assignment takes precedence over form field assignment');
1509
1520
 
1521
+ const replaceRules = (updater: (nextRules: LinkageRule[]) => void) => {
1522
+ if (onChange) {
1523
+ onChange(updateLinkageRules(rules, updater));
1524
+ } else {
1525
+ updater(rules);
1526
+ }
1527
+ };
1528
+
1510
1529
  // 创建新规则的默认值
1511
1530
  const createNewRule = (): LinkageRule => ({
1512
1531
  key: uid(),
@@ -1523,7 +1542,7 @@ const LinkageRulesUI = observer(
1523
1542
 
1524
1543
  // 删除规则
1525
1544
  const handleDeleteRule = (index: number) => {
1526
- rules.splice(index, 1);
1545
+ replaceRules((nextRules) => nextRules.splice(index, 1));
1527
1546
  };
1528
1547
 
1529
1548
  // 上移规则
@@ -1562,7 +1581,9 @@ const LinkageRulesUI = observer(
1562
1581
 
1563
1582
  // 切换规则启用状态
1564
1583
  const handleToggleEnable = (index: number, enable: boolean) => {
1565
- rules[index].enable = enable;
1584
+ replaceRules((nextRules) => {
1585
+ nextRules[index].enable = enable;
1586
+ });
1566
1587
  };
1567
1588
 
1568
1589
  // 获取可用的动作类型
@@ -18,7 +18,6 @@ import { theme as antdTheme, ConfigProvider, Grid, Popover } from 'antd';
18
18
  import { createStyles, createGlobalStyle } from 'antd-style';
19
19
  import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
20
20
  import ReactDOM from 'react-dom';
21
- import { useTranslation } from 'react-i18next';
22
21
  import { useLocation } from 'react-router-dom';
23
22
  import {
24
23
  AdminLayoutMenuModelRenderer,
@@ -35,7 +34,6 @@ import { ResetThemeTokenAndKeepAlgorithm } from './ResetThemeTokenAndKeepAlgorit
35
34
  import { PinnedPluginListLite } from './PinnedPluginListLite';
36
35
  import { useApplications } from './useApplications';
37
36
  import { useGlobalTheme, type CustomToken } from '../../../theme';
38
- import { useSystemSettings } from '../../system-settings';
39
37
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
40
38
  import {
41
39
  FLOW_SETTINGS_PREFERENCE_CHANGE_EVENT,
@@ -44,60 +42,7 @@ import {
44
42
  } from './flowSettingsPreference';
45
43
  import { joinAdminLayoutRoutePath, type AdminLayoutRoutePathLike } from './resolveAdminRouteRuntimeTarget';
46
44
  import { useAppListRender } from './AppListRender';
47
-
48
- const className1 = css`
49
- height: var(--nb-header-height);
50
- margin-right: 4px;
51
- display: inline-flex;
52
- flex-shrink: 0;
53
- color: #fff;
54
- padding: 0;
55
- align-items: center;
56
- `;
57
- const className1WithFixedWidth = css`
58
- ${className1}
59
- width: 168px;
60
- `;
61
- const className1WithAutoWidth = css`
62
- ${className1}
63
- width: auto;
64
- min-width: 168px;
65
- `;
66
- const className2 = css`
67
- object-fit: contain;
68
- width: 100%;
69
- height: 100%;
70
- `;
71
- const className3 = css`
72
- width: 100%;
73
- height: 100%;
74
- font-weight: 500;
75
- text-align: center;
76
- overflow: hidden;
77
- text-overflow: ellipsis;
78
- white-space: nowrap;
79
- `;
80
-
81
- const NocoBaseLogo = observer(() => {
82
- const { token } = antdTheme.useToken();
83
- const customToken = token as CustomToken;
84
- const result = useSystemSettings();
85
- const { t } = useTranslation('lm-collections');
86
- const fontSizeStyle = useMemo(() => ({ fontSize: customToken.fontSizeHeading3 }), [customToken.fontSizeHeading3]);
87
-
88
- const hasLogo = result?.data?.data?.logo?.url;
89
- const logo = hasLogo ? (
90
- <img className={className2} src={result?.data?.data?.logo?.url} />
91
- ) : (
92
- <span style={fontSizeStyle} className={className3}>
93
- {t(result?.data?.data?.title)}
94
- </span>
95
- );
96
-
97
- return (
98
- <div className={hasLogo ? className1WithFixedWidth : className1WithAutoWidth}>{result?.loading ? null : logo}</div>
99
- );
100
- });
45
+ import { NocoBaseLogo } from './NocoBaseLogo';
101
46
 
102
47
  const resetStyle = css`
103
48
  .ant-layout-sider-children {