@nocobase/client-v2 2.1.0-beta.37 → 2.1.0-beta.38

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 (123) hide show
  1. package/es/Application.d.ts +1 -0
  2. package/es/BaseApplication.d.ts +3 -0
  3. package/es/RouterManager.d.ts +1 -0
  4. package/es/components/KeepAlive.d.ts +22 -0
  5. package/es/components/RouterBridge.d.ts +9 -0
  6. package/es/data-source/ExtendCollectionsProvider.d.ts +28 -2
  7. package/es/flow/FlowPage.d.ts +2 -1
  8. package/es/flow/admin-shell/AdminLayoutRouteCoordinator.d.ts +8 -40
  9. package/es/flow/admin-shell/BaseLayoutModel.d.ts +89 -0
  10. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +74 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +12 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -92
  13. package/es/flow/admin-shell/admin-layout/index.d.ts +2 -0
  14. package/es/flow/admin-shell/useAdminLayoutRoutePage.d.ts +2 -2
  15. package/es/flow/admin-shell/useLayoutRoutePage.d.ts +23 -0
  16. package/es/flow/components/FlowRoute.d.ts +10 -1
  17. package/es/flow/index.d.ts +4 -0
  18. package/es/flow/models/base/PageModel/PageModel.d.ts +3 -1
  19. package/es/flow/models/blocks/form/FormActionGroupModel.d.ts +1 -0
  20. package/es/flow/models/blocks/table/TableBlockModel.d.ts +10 -0
  21. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.d.ts +1 -1
  22. package/es/index.d.ts +1 -0
  23. package/es/index.mjs +484 -437
  24. package/es/layout-manager/LayoutContentRoute.d.ts +14 -0
  25. package/es/layout-manager/LayoutManager.d.ts +22 -0
  26. package/es/layout-manager/LayoutRoute.d.ts +14 -0
  27. package/es/layout-manager/index.d.ts +13 -0
  28. package/es/layout-manager/types.d.ts +20 -0
  29. package/es/layout-manager/utils.d.ts +14 -0
  30. package/es/settings-center/index.d.ts +1 -1
  31. package/es/settings-center/plugin-manager/BulkEnableButton.d.ts +15 -0
  32. package/es/settings-center/plugin-manager/PluginCard.d.ts +15 -0
  33. package/es/settings-center/plugin-manager/PluginDetail.d.ts +16 -0
  34. package/es/settings-center/{PluginManagerPage.d.ts → plugin-manager/index.d.ts} +1 -7
  35. package/es/settings-center/plugin-manager/types.d.ts +34 -0
  36. package/lib/index.js +484 -437
  37. package/package.json +8 -7
  38. package/src/Application.tsx +27 -12
  39. package/src/BaseApplication.tsx +6 -0
  40. package/src/PluginSettingsManager.ts +1 -1
  41. package/src/RouterManager.tsx +17 -1
  42. package/src/__tests__/PluginSettingsManager.test.ts +41 -2
  43. package/src/__tests__/app.test.tsx +8 -1
  44. package/src/__tests__/globalDeps.test.ts +1 -0
  45. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +45 -2
  46. package/src/__tests__/plugin-manager.test.tsx +177 -0
  47. package/src/__tests__/settings-center.test.tsx +24 -2
  48. package/src/components/KeepAlive.tsx +131 -0
  49. package/src/components/RouterBridge.tsx +28 -4
  50. package/src/components/__tests__/KeepAlive.test.tsx +63 -0
  51. package/src/components/__tests__/RouterBridge.test.tsx +27 -0
  52. package/src/data-source/ExtendCollectionsProvider.tsx +94 -20
  53. package/src/data-source/__tests__/ExtendCollectionsProvider.test.tsx +264 -0
  54. package/src/flow/FlowPage.tsx +35 -7
  55. package/src/flow/__tests__/FlowPage.test.tsx +79 -0
  56. package/src/flow/__tests__/FlowRoute.test.tsx +529 -2
  57. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +191 -0
  58. package/src/flow/actions/__tests__/openView.subModelKey.test.tsx +33 -0
  59. package/src/flow/actions/aclCheck.tsx +4 -0
  60. package/src/flow/actions/aclCheckRefresh.tsx +4 -0
  61. package/src/flow/actions/dateTimeFormat.tsx +12 -8
  62. package/src/flow/actions/linkageRules.tsx +122 -0
  63. package/src/flow/actions/openView.tsx +28 -4
  64. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +11 -329
  65. package/src/flow/admin-shell/BaseLayoutModel.tsx +455 -0
  66. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +502 -0
  67. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +547 -3
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +4 -4
  69. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +160 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -12
  71. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +28 -201
  72. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +11 -2
  73. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +1 -26
  74. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +149 -27
  75. package/src/flow/admin-shell/admin-layout/index.ts +2 -0
  76. package/src/flow/admin-shell/useAdminLayoutRoutePage.ts +10 -26
  77. package/src/flow/admin-shell/useLayoutRoutePage.ts +61 -0
  78. package/src/flow/components/AdminLayout.tsx +4 -154
  79. package/src/flow/components/FlowRoute.tsx +105 -15
  80. package/src/flow/index.ts +4 -0
  81. package/src/flow/models/base/ActionModel.tsx +8 -1
  82. package/src/flow/models/base/PageModel/PageModel.tsx +51 -18
  83. package/src/flow/models/base/PageModel/RootPageModel.tsx +6 -13
  84. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +102 -1
  85. package/src/flow/models/base/RouteModel.tsx +1 -1
  86. package/src/flow/models/blocks/form/FormActionGroupModel.tsx +14 -0
  87. package/src/flow/models/blocks/form/FormItemModel.tsx +8 -1
  88. package/src/flow/models/blocks/form/__tests__/FormActionGroupModel.test.ts +46 -0
  89. package/src/flow/models/blocks/form/submitValues.ts +4 -1
  90. package/src/flow/models/blocks/table/TableBlockModel.tsx +118 -16
  91. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +114 -0
  92. package/src/flow/models/fields/AssociationFieldModel/SubFormFieldModel.tsx +7 -1
  93. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -1
  94. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -5
  95. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -1
  96. package/src/flow/models/fields/DisplayTimeFieldModel.tsx +1 -1
  97. package/src/flow/models/fields/TimeFieldModel.tsx +1 -1
  98. package/src/flow/models/fields/__tests__/TimeFieldModel.test.tsx +61 -0
  99. package/src/flow/models/fields/mobile-components/MobileDatePicker.tsx +19 -3
  100. package/src/flow/models/fields/mobile-components/__tests__/MobileDatePicker.test.tsx +94 -0
  101. package/src/flow/models/topbar/TopbarActionModel.tsx +1 -1
  102. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +91 -0
  103. package/src/index.ts +1 -0
  104. package/src/layout-manager/LayoutContentRoute.tsx +90 -0
  105. package/src/layout-manager/LayoutManager.tsx +185 -0
  106. package/src/layout-manager/LayoutRoute.tsx +138 -0
  107. package/src/layout-manager/__tests__/LayoutManager.test.tsx +335 -0
  108. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +473 -0
  109. package/src/layout-manager/index.ts +14 -0
  110. package/src/layout-manager/types.ts +22 -0
  111. package/src/layout-manager/utils.ts +37 -0
  112. package/src/nocobase-buildin-plugin/index.tsx +56 -48
  113. package/src/settings-center/index.ts +1 -1
  114. package/src/settings-center/plugin-manager/BulkEnableButton.tsx +111 -0
  115. package/src/settings-center/plugin-manager/PluginCard.tsx +270 -0
  116. package/src/settings-center/plugin-manager/PluginDetail.tsx +195 -0
  117. package/src/settings-center/plugin-manager/index.tsx +254 -0
  118. package/src/settings-center/plugin-manager/types.ts +35 -0
  119. package/src/settings-center/utils.tsx +8 -1
  120. package/src/theme/__tests__/globalStyles.test.ts +24 -0
  121. package/src/theme/globalStyles.ts +10 -0
  122. package/src/utils/globalDeps.ts +2 -0
  123. package/src/settings-center/PluginManagerPage.tsx +0 -162
@@ -15,7 +15,7 @@ import type { Application } from '../Application';
15
15
  import { getCurrentV2RedirectPath, getDefaultV2AdminRedirectPath } from '../authRedirect';
16
16
  import { AppNotFound } from '../components';
17
17
  import { PluginFlowEngine } from '../flow';
18
- import { AdminLayoutMenuItemModel, AdminLayoutModel } from '../flow/admin-shell/admin-layout';
18
+ import { ADMIN_LAYOUT_MODEL_UID, AdminLayoutMenuItemModel, AdminLayoutModel } from '../flow/admin-shell/admin-layout';
19
19
  import { useApp } from '../hooks/useApp';
20
20
  import { Plugin } from '../Plugin';
21
21
  import { AdminSettingsLayoutModel } from '../settings-center';
@@ -56,6 +56,30 @@ function isAdminRuntimeRoute(pathname: string, basename?: string) {
56
56
  return normalizedPathname === '/admin' || normalizedPathname.startsWith('/admin/');
57
57
  }
58
58
 
59
+ function hasAuthCheckRoute(app: Application, pathname: string) {
60
+ const matchedRoutes = app.router.matchRoutes(pathname) || [];
61
+ return matchedRoutes.some((match) => match?.route?.authCheck === true);
62
+ }
63
+
64
+ function shouldCheckRuntimeRoute(app: Application, pathname: string) {
65
+ return isAdminRuntimeRoute(pathname, app.router.getBasename()) || hasAuthCheckRoute(app, pathname);
66
+ }
67
+
68
+ type CurrentUserAuthCheckRouteState = 'skipped' | 'unchecked' | 'required';
69
+
70
+ function getCurrentUserAuthCheckRouteState(app: Application, pathname: string): CurrentUserAuthCheckRouteState {
71
+ const basename = app.router.getBasename();
72
+ if (isBuiltinAuthRoute(pathname, basename) || app.router.isSkippedAuthCheckRoute(pathname)) {
73
+ return 'skipped';
74
+ }
75
+
76
+ if (!shouldCheckRuntimeRoute(app, pathname)) {
77
+ return 'unchecked';
78
+ }
79
+
80
+ return 'required';
81
+ }
82
+
59
83
  export const CurrentUserContext = createContext<CurrentUserState | null>(null);
60
84
  CurrentUserContext.displayName = 'CurrentUserContext';
61
85
 
@@ -87,7 +111,7 @@ export function useCurrentRoles(): CurrentRoleOption[] {
87
111
  }
88
112
 
89
113
  const DataSourceBootstrapProvider: FC = ({ children }) => {
90
- const app = useApp();
114
+ const app = useApp<Application>();
91
115
  const location = useLocation();
92
116
  const [loading, setLoading] = useState(true);
93
117
  const [error, setError] = useState<Error | null>(null);
@@ -98,7 +122,7 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
98
122
  const basename = app.router.getBasename();
99
123
  const isSkippedAuthCheckRoute =
100
124
  isBuiltinAuthRoute(location.pathname, basename) || app.router.isSkippedAuthCheckRoute(location.pathname);
101
- const shouldBootstrap = isAdminRuntimeRoute(location.pathname, basename);
125
+ const shouldBootstrap = shouldCheckRuntimeRoute(app, location.pathname);
102
126
 
103
127
  if (isSkippedAuthCheckRoute || !shouldBootstrap) {
104
128
  setLoading(false);
@@ -146,28 +170,25 @@ const DataSourceBootstrapProvider: FC = ({ children }) => {
146
170
  };
147
171
 
148
172
  const CurrentUserProvider: FC = ({ children }) => {
149
- const app = useApp();
173
+ const app = useApp<Application>();
150
174
  const location = useLocation();
151
175
  const navigate = useNavigate();
152
176
  const [state, setState] = useState<CurrentUserState>({ loading: true });
153
- const pathnameRef = useRef(location.pathname);
154
- pathnameRef.current = location.pathname;
155
177
  const locationRef = useRef(location);
156
178
  locationRef.current = location;
179
+ const authCheckRouteState = getCurrentUserAuthCheckRouteState(app, location.pathname);
157
180
 
158
181
  useEffect(() => {
159
182
  let mounted = true;
160
- const isSkippedAuthCheckRoute =
161
- isBuiltinAuthRoute(pathnameRef.current, app.router.getBasename()) ||
162
- app.router.isSkippedAuthCheckRoute(pathnameRef.current);
163
- const shouldCheckCurrentUser = isAdminRuntimeRoute(pathnameRef.current, app.router.getBasename());
164
183
 
165
- if (isSkippedAuthCheckRoute || !shouldCheckCurrentUser) {
184
+ if (authCheckRouteState !== 'required') {
166
185
  // 认证页等免鉴权路由不应再执行 `/auth:check`,否则未登录时会重复鉴权并触发重定向抖动。
167
186
  setState({ loading: false });
168
187
  return;
169
188
  }
170
189
 
190
+ setState((previous) => (previous.loading ? previous : { ...previous, loading: true }));
191
+
171
192
  const run = async () => {
172
193
  try {
173
194
  const res = await app.apiClient.request({
@@ -176,12 +197,14 @@ const CurrentUserProvider: FC = ({ children }) => {
176
197
  skipAuth: true,
177
198
  });
178
199
 
200
+ if (!mounted) {
201
+ return;
202
+ }
203
+
179
204
  const user = res?.data?.data;
180
205
  // 服务端通过 `{ code: 302, redirect }` 通知客户端先去某个中间页(例如 2FA 验证页)。这类响应没有 user.id,但也不能视为未登录——否则会和处理 302 的全局响应拦截器 (例如 plugin-two-factor-authentication 注册的那一个)竞态,而 `window.location.replace` 会覆盖更早发出的 `window.location.href`,把用户错误地弹回登录页。让响应拦截器接管跳转。
181
206
  if (user?.code === 302) {
182
- if (mounted) {
183
- setState({ loading: false });
184
- }
207
+ setState({ loading: false });
185
208
  return;
186
209
  }
187
210
  if (user?.id == null) {
@@ -204,13 +227,15 @@ const CurrentUserProvider: FC = ({ children }) => {
204
227
  meta: userMeta,
205
228
  });
206
229
 
207
- if (mounted) {
208
- setState({
209
- data: res?.data,
210
- loading: false,
211
- });
212
- }
230
+ setState({
231
+ data: res?.data,
232
+ loading: false,
233
+ });
213
234
  } catch (error: any) {
235
+ if (!mounted) {
236
+ return;
237
+ }
238
+
214
239
  const isAuthError = error?.response?.status === 401 || error?.status === 401;
215
240
  if (isAuthError) {
216
241
  navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
@@ -218,19 +243,17 @@ const CurrentUserProvider: FC = ({ children }) => {
218
243
  });
219
244
  return;
220
245
  }
221
- if (mounted) {
222
- setState({ loading: false });
223
- }
246
+ setState({ loading: false });
224
247
  throw error;
225
248
  }
226
249
  };
227
250
 
228
- void run();
251
+ run();
229
252
 
230
253
  return () => {
231
254
  mounted = false;
232
255
  };
233
- }, [app, navigate]);
256
+ }, [app, authCheckRouteState, navigate]);
234
257
 
235
258
  if (state.loading) {
236
259
  return app.renderComponent('AppSpin');
@@ -240,7 +263,7 @@ const CurrentUserProvider: FC = ({ children }) => {
240
263
  };
241
264
 
242
265
  const RootRedirect: FC = () => {
243
- const app = useApp();
266
+ const app = useApp<Application>();
244
267
  const hasToken = !!app?.apiClient?.auth?.token;
245
268
  const targetPath = getDefaultV2AdminRedirectPath(app);
246
269
 
@@ -273,6 +296,12 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
273
296
  AdminLayoutMenuItemModel,
274
297
  AdminSettingsLayoutModel,
275
298
  });
299
+ this.app.layoutManager.registerLayout({
300
+ routeName: 'admin',
301
+ routePath: '/admin',
302
+ uid: ADMIN_LAYOUT_MODEL_UID,
303
+ layoutModelClass: 'AdminLayoutModel',
304
+ });
276
305
 
277
306
  this.app.pluginSettingsManager.addMenuItem({
278
307
  key: 'plugin-manager',
@@ -285,7 +314,7 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
285
314
  menuKey: 'plugin-manager',
286
315
  key: 'index',
287
316
  title: this.app.i18n.t('Plugin manager'),
288
- componentLoader: () => import('../settings-center/PluginManagerPage'),
317
+ componentLoader: () => import('../settings-center/plugin-manager'),
289
318
  aclSnippet: 'pm',
290
319
  sort: -200,
291
320
  });
@@ -324,10 +353,6 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
324
353
  Component: AppNotFound,
325
354
  });
326
355
 
327
- this.router.add('admin', {
328
- path: '/admin',
329
- componentLoader: () => import('../flow/components/AdminLayout'),
330
- });
331
356
  this.router.add('admin.settings', {
332
357
  path: '/admin/settings',
333
358
  componentLoader: () => import('../settings-center/AdminSettingsLayout'),
@@ -336,23 +361,6 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
336
361
  path: '*',
337
362
  Component: Outlet,
338
363
  });
339
- this.router.add('admin.page', {
340
- path: '/admin/:name',
341
- componentLoader: () => import('../flow/components/FlowRoute'),
342
- });
343
-
344
- this.router.add('admin.page.tab', {
345
- path: '/admin/:name/tab/:tabUid',
346
- componentLoader: () => import('../flow/components/FlowRoute'),
347
- });
348
- this.router.add('admin.page.view', {
349
- path: '/admin/:name/view/*',
350
- componentLoader: () => import('../flow/components/FlowRoute'),
351
- });
352
- this.router.add('admin.page.tab.view', {
353
- path: '/admin/:name/tab/:tabUid/view/*',
354
- componentLoader: () => import('../flow/components/FlowRoute'),
355
- });
356
364
  }
357
365
 
358
366
  addComponents() {}
@@ -9,6 +9,6 @@
9
9
 
10
10
  export * from './AdminSettingsLayout';
11
11
  export * from './AdminSettingsLayoutModel';
12
- export * from './PluginManagerPage';
12
+ export * from './plugin-manager';
13
13
  export * from './SystemSettingsPage';
14
14
  export * from './utils';
@@ -0,0 +1,111 @@
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 { useMemoizedFn } from 'ahooks';
11
+ import { Button, Input, Modal, Table } from 'antd';
12
+ import type { TableProps } from 'antd';
13
+ import _ from 'lodash';
14
+ import React, { useMemo, useState } from 'react';
15
+ import { useTranslation } from 'react-i18next';
16
+ import { useApp } from '../../hooks/useApp';
17
+ import type { IPluginData } from './types';
18
+
19
+ interface BulkEnableButtonProps {
20
+ plugins: IPluginData[];
21
+ }
22
+
23
+ export const BulkEnableButton: React.FC<BulkEnableButtonProps> = ({ plugins }) => {
24
+ const { t } = useTranslation();
25
+ const app = useApp();
26
+ const [isModalOpen, setIsModalOpen] = useState(false);
27
+ const [searchValue, setSearchValue] = useState('');
28
+ const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
29
+
30
+ const disabledPlugins = useMemo(() => plugins.filter((plugin) => !plugin.enabled), [plugins]);
31
+
32
+ const items = useMemo(() => {
33
+ const value = searchValue.toLowerCase().trim();
34
+ if (!value) return disabledPlugins;
35
+ return disabledPlugins.filter(
36
+ (plugin) =>
37
+ (plugin.displayName || '').toLowerCase().includes(value) ||
38
+ (plugin.description || '').toLowerCase().includes(value),
39
+ );
40
+ }, [disabledPlugins, searchValue]);
41
+
42
+ const handleOpen = useMemoizedFn(() => setIsModalOpen(true));
43
+
44
+ const handleCancel = useMemoizedFn(() => {
45
+ setSelectedRowKeys([]);
46
+ setIsModalOpen(false);
47
+ });
48
+
49
+ const handleOk = useMemoizedFn(async () => {
50
+ await app.apiClient.request({
51
+ url: 'pm:enable',
52
+ params: { filterByTk: selectedRowKeys },
53
+ });
54
+ setIsModalOpen(false);
55
+ });
56
+
57
+ const columns: TableProps<IPluginData>['columns'] = useMemo(
58
+ () => [
59
+ { title: t('Plugin'), dataIndex: 'displayName', ellipsis: true },
60
+ { title: t('Description'), dataIndex: 'description', ellipsis: true, width: 300 },
61
+ { title: t('Package name'), dataIndex: 'packageName', width: 300, ellipsis: true },
62
+ ],
63
+ [t],
64
+ );
65
+
66
+ return (
67
+ <>
68
+ <Button onClick={handleOpen}>{t('Bulk enable')}</Button>
69
+ <Modal width={1000} title={t('Bulk enable')} open={isModalOpen} onOk={handleOk} onCancel={handleCancel}>
70
+ <Input
71
+ style={{ marginBottom: '1em' }}
72
+ placeholder={t('Search plugin...')}
73
+ allowClear
74
+ onChange={(e) => setSearchValue(e.target.value)}
75
+ />
76
+ <Table<IPluginData>
77
+ rowSelection={{
78
+ type: 'checkbox',
79
+ selectedRowKeys,
80
+ onChange(selectedKeys) {
81
+ const names = items.map((item) => item.name);
82
+ setSelectedRowKeys((preSelectedRowKeys) => {
83
+ if (selectedKeys.length === 0) {
84
+ return preSelectedRowKeys.filter((key) => !names.includes(String(key)));
85
+ }
86
+ if (selectedKeys.length === names.length) {
87
+ return _.uniq([...preSelectedRowKeys, ...selectedKeys]);
88
+ }
89
+ return preSelectedRowKeys;
90
+ });
91
+ },
92
+ onSelect(record) {
93
+ setSelectedRowKeys((preSelectedRowKeys) => {
94
+ if (preSelectedRowKeys.includes(record.name)) {
95
+ return preSelectedRowKeys.filter((key) => key !== record.name);
96
+ }
97
+ return preSelectedRowKeys.concat(record.name);
98
+ });
99
+ },
100
+ }}
101
+ rowKey="name"
102
+ scroll={{ y: '60vh' }}
103
+ size="small"
104
+ pagination={false}
105
+ columns={columns}
106
+ dataSource={items}
107
+ />
108
+ </Modal>
109
+ </>
110
+ );
111
+ };
@@ -0,0 +1,270 @@
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 {
11
+ CloseCircleFilled,
12
+ DeleteOutlined,
13
+ LoadingOutlined,
14
+ ReadOutlined,
15
+ SettingOutlined,
16
+ WarningFilled,
17
+ } from '@ant-design/icons';
18
+ import { css } from '@emotion/css';
19
+ import { useMemoizedFn } from 'ahooks';
20
+ import { App, Card, Divider, Modal, Popconfirm, Result, Space, Switch, Tooltip, Typography, theme } from 'antd';
21
+ import React, { FC, useMemo, useState } from 'react';
22
+ import { useTranslation } from 'react-i18next';
23
+ import { useNavigate } from 'react-router-dom';
24
+ import { useApp } from '../../hooks/useApp';
25
+ import { PluginDetail } from './PluginDetail';
26
+ import type { IPluginData } from './types';
27
+
28
+ interface PluginCardProps {
29
+ data: IPluginData;
30
+ }
31
+
32
+ export const PluginCard: FC<PluginCardProps> = ({ data }) => {
33
+ const { token } = theme.useToken();
34
+ const { t } = useTranslation();
35
+ const navigate = useNavigate();
36
+ const app = useApp();
37
+ const { modal } = App.useApp();
38
+ const [detailOpen, setDetailOpen] = useState(false);
39
+
40
+ const { name, displayName, isCompatible, packageName, builtIn, enabled, removable, description, error, homepage } =
41
+ data;
42
+
43
+ const title = displayName || name || packageName;
44
+
45
+ const openDetail = useMemoizedFn(() => {
46
+ if (!error) {
47
+ setDetailOpen(true);
48
+ }
49
+ });
50
+
51
+ const handleEnable = useMemoizedFn(async () => {
52
+ await app.apiClient.request({
53
+ url: 'pm:enable',
54
+ params: { filterByTk: name },
55
+ });
56
+ });
57
+
58
+ const handleDisable = useMemoizedFn(async () => {
59
+ await app.apiClient.request({
60
+ url: 'pm:disable',
61
+ params: { filterByTk: name },
62
+ });
63
+ });
64
+
65
+ const handleRemove = useMemoizedFn(async () => {
66
+ await app.apiClient.request({
67
+ url: 'pm:remove',
68
+ params: { filterByTk: name },
69
+ });
70
+ modal.info({
71
+ icon: null,
72
+ width: 520,
73
+ content: (
74
+ <Result
75
+ icon={<LoadingOutlined />}
76
+ title={t('Plugin removing')}
77
+ subTitle={t('Plugin is removing, please wait...')}
78
+ />
79
+ ),
80
+ footer: null,
81
+ });
82
+ const checkHealth = () => {
83
+ app.apiClient
84
+ .request({ url: '__health_check', method: 'get', skipNotify: true })
85
+ .then((response) => {
86
+ if (response?.data === 'ok') {
87
+ window.location.reload();
88
+ }
89
+ })
90
+ .catch(() => {
91
+ // health check still failing, keep polling
92
+ });
93
+ };
94
+ setInterval(checkHealth, 1000);
95
+ });
96
+
97
+ const handleSwitchChange = useMemoizedFn(async (checked: boolean) => {
98
+ if (!isCompatible && checked) {
99
+ modal.confirm({
100
+ title: t('Plugin dependency version mismatch'),
101
+ content: t(
102
+ 'The current dependency version of the plugin does not match the version of the application and may not work properly. Are you sure you want to continue enabling the plugin?',
103
+ ),
104
+ onOk: handleEnable,
105
+ });
106
+ return;
107
+ }
108
+ if (!checked) {
109
+ modal.confirm({
110
+ title: t('Are you sure to disable this plugin?'),
111
+ onOk: handleDisable,
112
+ });
113
+ return;
114
+ }
115
+ await handleEnable();
116
+ });
117
+
118
+ const openSettings = useMemoizedFn(() => {
119
+ navigate(app.pluginSettingsManager.getRoutePath(name));
120
+ });
121
+
122
+ const cardClassName = useMemo(
123
+ () => css`
124
+ display: flex;
125
+ flex-direction: column;
126
+ height: 100%;
127
+
128
+ .ant-card-body {
129
+ flex-grow: 1;
130
+ }
131
+
132
+ .ant-card-actions {
133
+ li .ant-space {
134
+ gap: ${token.marginXXS}px !important;
135
+ }
136
+ li:first-child {
137
+ width: 80% !important;
138
+ border-inline-end: 0;
139
+ text-align: left;
140
+ padding-inline-start: ${token.padding}px;
141
+ }
142
+ li:last-child {
143
+ width: 20% !important;
144
+ }
145
+ }
146
+ `,
147
+ [token.marginXXS, token.padding],
148
+ );
149
+
150
+ const cardTitle = (
151
+ <Space>
152
+ {error ? (
153
+ <Tooltip title={t('Plugin loading failed. Please check the server logs.')}>
154
+ <Typography.Text type="danger">
155
+ <CloseCircleFilled />
156
+ </Typography.Text>
157
+ </Tooltip>
158
+ ) : null}
159
+ {!isCompatible ? (
160
+ <Tooltip title={t('Plugin dependencies check failed')}>
161
+ <Typography.Text type="warning">
162
+ <WarningFilled />
163
+ </Typography.Text>
164
+ </Tooltip>
165
+ ) : null}
166
+ <span>{title}</span>
167
+ </Space>
168
+ );
169
+
170
+ const linksAction = (
171
+ <Space split={<Divider type="vertical" />} key="links" size={token.marginXXS}>
172
+ {homepage && (
173
+ <a
174
+ href={homepage}
175
+ target="_blank"
176
+ rel="noreferrer"
177
+ onClick={(event) => event.stopPropagation()}
178
+ aria-label={t('Docs')}
179
+ >
180
+ <ReadOutlined /> {t('Docs')}
181
+ </a>
182
+ )}
183
+ {enabled && app.pluginSettingsManager.has(name) && (
184
+ <a
185
+ onClick={(e) => {
186
+ e.stopPropagation();
187
+ openSettings();
188
+ }}
189
+ aria-label={t('Settings')}
190
+ >
191
+ <SettingOutlined /> {t('Settings')}
192
+ </a>
193
+ )}
194
+ {removable && (
195
+ <Popconfirm
196
+ key="delete"
197
+ disabled={builtIn}
198
+ title={t('Are you sure to delete this plugin?')}
199
+ onConfirm={(e) => {
200
+ e?.stopPropagation();
201
+ handleRemove();
202
+ }}
203
+ onCancel={(e) => e?.stopPropagation()}
204
+ okText={t('Yes')}
205
+ cancelText={t('No')}
206
+ >
207
+ <a
208
+ onClick={(e) => e.stopPropagation()}
209
+ aria-label={t('Remove')}
210
+ style={builtIn ? { color: token.colorTextDisabled, cursor: 'not-allowed' } : undefined}
211
+ >
212
+ <DeleteOutlined /> {t('Remove')}
213
+ </a>
214
+ </Popconfirm>
215
+ )}
216
+ </Space>
217
+ );
218
+
219
+ const switchAction = (
220
+ <Switch
221
+ aria-label={t('Enable')}
222
+ key="enable"
223
+ size="small"
224
+ disabled={builtIn || error}
225
+ onChange={(checked, e) => {
226
+ e.stopPropagation();
227
+ handleSwitchChange(checked);
228
+ }}
229
+ checked={!!enabled}
230
+ />
231
+ );
232
+
233
+ return (
234
+ <>
235
+ {detailOpen && <PluginDetail plugin={data} onCancel={() => setDetailOpen(false)} />}
236
+ <Card
237
+ role="button"
238
+ aria-label={title}
239
+ size="small"
240
+ variant="borderless"
241
+ hoverable
242
+ onClick={openDetail}
243
+ styles={{
244
+ body: { paddingTop: token.paddingSM },
245
+ header: { border: 'none', minHeight: 'inherit', paddingTop: token.padding },
246
+ }}
247
+ className={cardClassName}
248
+ title={cardTitle}
249
+ actions={[linksAction, switchAction]}
250
+ >
251
+ <Card.Meta
252
+ description={
253
+ <Typography.Paragraph
254
+ type="secondary"
255
+ className={css`
256
+ display: -webkit-box;
257
+ -webkit-box-orient: vertical;
258
+ -webkit-line-clamp: 2;
259
+ overflow: hidden;
260
+ margin-bottom: 0;
261
+ `}
262
+ >
263
+ {description}
264
+ </Typography.Paragraph>
265
+ }
266
+ />
267
+ </Card>
268
+ </>
269
+ );
270
+ };