@nocobase/client-v2 3.0.0-alpha.6 → 3.0.0-alpha.7

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": "3.0.0-alpha.6",
3
+ "version": "3.0.0-alpha.7",
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": "3.0.0-alpha.6",
31
- "@nocobase/flow-engine": "3.0.0-alpha.6",
32
- "@nocobase/sdk": "3.0.0-alpha.6",
33
- "@nocobase/shared": "3.0.0-alpha.6",
34
- "@nocobase/utils": "3.0.0-alpha.6",
30
+ "@nocobase/evaluators": "3.0.0-alpha.7",
31
+ "@nocobase/flow-engine": "3.0.0-alpha.7",
32
+ "@nocobase/sdk": "3.0.0-alpha.7",
33
+ "@nocobase/shared": "3.0.0-alpha.7",
34
+ "@nocobase/utils": "3.0.0-alpha.7",
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": "3508eabe683b9a47a28d0f3425098eb58873a9d7"
51
+ "gitHead": "19cd28c710f3b3c614ebe2fd7d5a415d527ee559"
52
52
  }
@@ -406,6 +406,116 @@ describe('app', () => {
406
406
  expect(screen.getByText('Enabling plugin')).toBeInTheDocument();
407
407
  });
408
408
 
409
+ it('should keep current content without a dialog while app upgrading', async () => {
410
+ const CurrentPage = () => <div>Current page</div>;
411
+
412
+ class PluginHelloClient extends Plugin {
413
+ async load() {
414
+ this.router.add('root', { path: '/', Component: CurrentPage });
415
+ }
416
+ }
417
+
418
+ const app = createMockClient({ plugins: [PluginHelloClient] });
419
+ await renderApp(app);
420
+
421
+ act(() => {
422
+ app.maintained = true;
423
+ app.maintaining = true;
424
+ app.error = Object.assign(new Error('Loading data sources...'), {
425
+ code: 'APP_COMMANDING',
426
+ command: { name: 'upgrade' },
427
+ });
428
+ });
429
+
430
+ expect(screen.getByText('Current page')).toBeInTheDocument();
431
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
432
+ });
433
+
434
+ it('should not show Try again for an app upgrade error state', async () => {
435
+ class PluginHelloClient extends Plugin {}
436
+ const app = createMockClient({ plugins: [PluginHelloClient] });
437
+ app.error = Object.assign(new Error('Loading data sources...'), {
438
+ code: 'APP_COMMANDING',
439
+ command: { name: 'upgrade' },
440
+ });
441
+
442
+ await renderApp(app);
443
+
444
+ expect(screen.getByText('App upgrading')).toBeInTheDocument();
445
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
446
+ });
447
+
448
+ it('should clear upgrade feedback after an existing page error', async () => {
449
+ const BrokenPage = () => {
450
+ throw new Error('page error');
451
+ };
452
+
453
+ class PluginHelloClient extends Plugin {
454
+ async load() {
455
+ this.router.add('root', { path: '/', Component: BrokenPage });
456
+ }
457
+ }
458
+
459
+ const app = createMockClient({
460
+ plugins: [PluginHelloClient],
461
+ ws: { url: 'ws://localhost:3000/ws' },
462
+ });
463
+ app.maintained = true;
464
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
465
+ try {
466
+ await renderApp(app);
467
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
468
+
469
+ act(() => {
470
+ app.ws.emit('message', {
471
+ type: 'maintaining',
472
+ payload: {
473
+ code: 'APP_COMMANDING',
474
+ command: { name: 'upgrade' },
475
+ message: 'Loading data sources...',
476
+ },
477
+ });
478
+ });
479
+
480
+ expect(screen.getByText('App upgrading')).toBeInTheDocument();
481
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
482
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
483
+ } finally {
484
+ errorSpy.mockRestore();
485
+ }
486
+ });
487
+
488
+ it('should keep a custom maintaining dialog while app upgrading', async () => {
489
+ class PluginHelloClient extends Plugin {}
490
+ const app = createMockClient({ plugins: [PluginHelloClient] });
491
+ app.addComponents({
492
+ CustomUpgradeStatus: () => <div>Custom upgrade status</div>,
493
+ });
494
+ app.maintained = true;
495
+ app.maintaining = true;
496
+ app.error = Object.assign(new Error('Loading data sources...'), {
497
+ code: 'APP_COMMANDING',
498
+ command: {
499
+ name: 'upgrade',
500
+ components: { maintainingDialog: 'CustomUpgradeStatus' },
501
+ },
502
+ });
503
+
504
+ await renderApp(app);
505
+
506
+ expect(screen.getByText('Custom upgrade status')).toBeInTheDocument();
507
+ });
508
+
509
+ it('should keep Try again for recoverable app errors', async () => {
510
+ class PluginHelloClient extends Plugin {}
511
+ const app = createMockClient({ plugins: [PluginHelloClient] });
512
+ app.error = Object.assign(new Error('load error'), { code: 'LOAD_ERROR' });
513
+
514
+ await renderApp(app);
515
+
516
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
517
+ });
518
+
409
519
  it('should handle long loading state gracefully', async () => {
410
520
  class PluginHelloClient extends Plugin {
411
521
  async load() {
@@ -147,6 +147,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
147
147
  expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
148
148
  });
149
149
 
150
+ it.each(['/', '/index.html'])('redirects app root entry %s to Settings for settings-default', (pathname) => {
151
+ const replace = executeBrowserChecker(scriptPath, {
152
+ pathname,
153
+ publicPath: '/',
154
+ modernClientPrefix: 'v',
155
+ appClientEntryMode: 'settings-default',
156
+ search: '?from=entry',
157
+ hash: '#panel',
158
+ });
159
+
160
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/settings/?from=entry#panel');
161
+ });
162
+
150
163
  it('does not redirect legacy deep links for modern-default', () => {
151
164
  const replace = executeBrowserChecker(scriptPath, {
152
165
  pathname: '/admin',
@@ -158,6 +171,17 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
158
171
  expect(replace).not.toHaveBeenCalled();
159
172
  });
160
173
 
174
+ it('does not redirect legacy deep links for settings-default', () => {
175
+ const replace = executeBrowserChecker(scriptPath, {
176
+ pathname: '/admin',
177
+ publicPath: '/',
178
+ modernClientPrefix: 'v',
179
+ appClientEntryMode: 'settings-default',
180
+ });
181
+
182
+ expect(replace).not.toHaveBeenCalled();
183
+ });
184
+
161
185
  it('rewrites legacy document paths for modern-only', () => {
162
186
  const replace = executeBrowserChecker(scriptPath, {
163
187
  pathname: '/admin/settings/workflow',
@@ -180,6 +204,19 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
180
204
  expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
181
205
  });
182
206
 
207
+ it('redirects a sub-path app root directly to Settings for settings-default', () => {
208
+ const replace = executeBrowserChecker(scriptPath, {
209
+ pathname: '/nocobase/',
210
+ publicPath: '/nocobase/',
211
+ modernClientPrefix: 'v',
212
+ appClientEntryMode: 'settings-default',
213
+ search: '?from=entry',
214
+ hash: '#panel',
215
+ });
216
+
217
+ expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/settings/?from=entry#panel');
218
+ });
219
+
183
220
  it('rewrites sub-app legacy deep links for modern-only without collapsing the sub-app segment', () => {
184
221
  const replace = executeBrowserChecker(scriptPath, {
185
222
  pathname: '/nocobase/apps/a_31itq60q4kg/admin/',
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { render, screen } from '@testing-library/react';
11
- import { ConfigProvider } from 'antd';
11
+ import { ConfigProvider, theme as antdTheme } from 'antd';
12
12
  import React from 'react';
13
13
  import { MemoryRouter } from 'react-router-dom';
14
14
  import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -19,6 +19,7 @@ import type { ThemeConfig } from '../theme';
19
19
  const userCenterModel = { uid: 'settings-user-center' };
20
20
  const createModel = vi.fn(() => userCenterModel);
21
21
  const matchRoutes = vi.fn(() => [{ route: { id: 'settings' } }]);
22
+ let settingsThemeConfig: ThemeConfig | null = null;
22
23
  const mockApp = {
23
24
  flowEngine: {
24
25
  createModel,
@@ -46,6 +47,10 @@ vi.mock('../settings-app/SettingsSearch', () => ({
46
47
  SettingsSearch: () => <div data-testid="settings-search">search</div>,
47
48
  }));
48
49
 
50
+ vi.mock('../settings-app/useSettingsThemeConfig', () => ({
51
+ useSettingsThemeConfig: () => settingsThemeConfig,
52
+ }));
53
+
49
54
  vi.mock('../flow/admin-shell/admin-layout/HelpLite', () => ({
50
55
  HelpLite: () => <div data-testid="settings-help">help</div>,
51
56
  }));
@@ -60,8 +65,15 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
60
65
  };
61
66
  });
62
67
 
68
+ const TokenProbe = () => {
69
+ const { token } = antdTheme.useToken();
70
+
71
+ return <div data-testid="settings-theme-token">{`${token.marginBlock}:${token.sizeLG}`}</div>;
72
+ };
73
+
63
74
  describe('SettingsShell', () => {
64
75
  beforeEach(() => {
76
+ settingsThemeConfig = null;
65
77
  createModel.mockClear();
66
78
  matchRoutes.mockReset();
67
79
  matchRoutes.mockReturnValue([{ route: { id: 'settings' } }]);
@@ -111,6 +123,20 @@ describe('SettingsShell', () => {
111
123
  expect(screen.getByRole('banner')).toHaveStyle({ background: '#ffffff' });
112
124
  });
113
125
 
126
+ it('preserves NocoBase custom tokens in the stored compact theme', () => {
127
+ settingsThemeConfig = { algorithm: antdTheme.compactAlgorithm };
128
+
129
+ render(
130
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
131
+ <SettingsShell>
132
+ <TokenProbe />
133
+ </SettingsShell>
134
+ </MemoryRouter>,
135
+ );
136
+
137
+ expect(screen.getByTestId('settings-theme-token')).toHaveTextContent('16:16');
138
+ });
139
+
114
140
  it('places the settings content and embed container side by side below the header', () => {
115
141
  const { container } = render(
116
142
  <MemoryRouter initialEntries={['/settings/theme-editor']}>
@@ -28,9 +28,11 @@ interface AppErrorPayload {
28
28
  maintainingDialog?: string;
29
29
  };
30
30
  };
31
- [key: string]: any;
32
31
  }
33
32
 
33
+ const isAppUpgrading = (error?: AppErrorPayload) =>
34
+ error?.code === 'APP_COMMANDING' && error.command?.name === 'upgrade';
35
+
34
36
  export const AppSpin = () => {
35
37
  return (
36
38
  <Spin style={{ position: 'fixed', top: '50%', left: '50%', fontSize: 72, transform: 'translate(-50%, -50%)' }} />
@@ -168,6 +170,7 @@ const getProps = (app: Application) => {
168
170
  export const AppError: FC<{ error: Error & { title?: string }; app: Application }> = observer(
169
171
  ({ app, error }) => {
170
172
  const props = getProps(app);
173
+ const upgrading = isAppUpgrading(app.error as AppErrorPayload | undefined);
171
174
  return (
172
175
  <div>
173
176
  <Result
@@ -180,11 +183,13 @@ export const AppError: FC<{ error: Error & { title?: string }; app: Application
180
183
  status="error"
181
184
  title={error?.title || app.i18n.t('App error', { ns: 'client' })}
182
185
  subTitle={app.i18n.t(error?.message)}
183
- extra={[
184
- <Button type="primary" key="try" onClick={() => window.location.reload()}>
185
- {app.i18n.t('Try again')}
186
- </Button>,
187
- ]}
186
+ extra={
187
+ upgrading ? null : (
188
+ <Button type="primary" key="try" onClick={() => window.location.reload()}>
189
+ {app.i18n.t('Try again')}
190
+ </Button>
191
+ )
192
+ }
188
193
  {...props}
189
194
  />
190
195
  </div>
@@ -223,10 +228,14 @@ export const AppMaintaining: FC<{ app: Application; error: Error }> = observer(
223
228
 
224
229
  export const AppMaintainingDialog: FC<{ app: Application; error: Error }> = observer(
225
230
  ({ app, error }) => {
226
- const component = (error as AppErrorPayload | undefined)?.command?.components?.maintainingDialog;
231
+ const payload = error as AppErrorPayload | undefined;
232
+ const component = payload?.command?.components?.maintainingDialog;
227
233
  if (component) {
228
234
  return app.renderComponent(component, { app, error });
229
235
  }
236
+ if (isAppUpgrading(payload)) {
237
+ return null;
238
+ }
230
239
 
231
240
  const { icon, status, title, subTitle } = getProps(app);
232
241
  return (
@@ -9,11 +9,11 @@
9
9
 
10
10
  import { DragOverlay, type DragEndEvent, type DragStartEvent } from '@dnd-kit/core';
11
11
  import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
12
- import { cx } from '@emotion/css';
12
+ import { css, cx, injectGlobal } from '@emotion/css';
13
13
  import { DndProvider } from '@nocobase/flow-engine';
14
14
  import { useMemoizedFn } from 'ahooks';
15
- import { Table as AntdTable, type TableProps as AntdTableProps } from 'antd';
16
- import type { ColumnsType, ColumnType, GetRowKey } from 'antd/es/table/interface';
15
+ import { Table as AntdTable, theme, type TableProps as AntdTableProps } from 'antd';
16
+ import type { ColumnsType, ColumnGroupType, ColumnType, GetRowKey } from 'antd/es/table/interface';
17
17
  import type { RenderedCell } from 'rc-table/lib/interface';
18
18
  import React, { useMemo, useState } from 'react';
19
19
  import { SortableRow, SortHandle } from './dnd/SortableRow';
@@ -24,6 +24,20 @@ import { readRowKey, snapshotSourceRow, type RowKey, type RowSnapshot } from './
24
24
 
25
25
  type RowSelectionRenderCellResult<RecordType> = React.ReactNode | RenderedCell<RecordType>;
26
26
 
27
+ const DEFAULT_COLUMN_CONTENT_CLASS_NAME = 'nb-table-default-column-content';
28
+ const DEFAULT_COLUMN_CONTENT_MAX_WIDTH_PROPERTY = '--nb-table-default-column-content-max-width';
29
+
30
+ // Zero specificity lets caller classes remain authoritative regardless of stylesheet insertion order. The max-width
31
+ // value itself comes from a token-derived private CSS variable set by each Table instance.
32
+ injectGlobal`
33
+ :where(.${DEFAULT_COLUMN_CONTENT_CLASS_NAME}) {
34
+ max-width: var(${DEFAULT_COLUMN_CONTENT_MAX_WIDTH_PROPERTY});
35
+ white-space: normal;
36
+ overflow-wrap: anywhere;
37
+ word-break: break-word;
38
+ }
39
+ `;
40
+
27
41
  /**
28
42
  * Default initial page size for `Table`. Exposed so consumers can seed their
29
43
  * controlled `pageSize` state with the same value the component would use if
@@ -50,6 +64,54 @@ function isRenderedCell<RecordType>(value: unknown): value is RenderedCell<Recor
50
64
  return typeof value === 'object' && value !== null && !React.isValidElement(value) && 'children' in value;
51
65
  }
52
66
 
67
+ function isColumnGroup<RecordType>(
68
+ column: ColumnGroupType<RecordType> | ColumnType<RecordType>,
69
+ ): column is ColumnGroupType<RecordType> {
70
+ return 'children' in column && Array.isArray(column.children) && column.children.length > 0;
71
+ }
72
+
73
+ function addDefaultColumnContentClassName<RecordType>(
74
+ columns: ColumnsType<RecordType>,
75
+ defaultClassName: string,
76
+ ): ColumnsType<RecordType> {
77
+ return columns.map((column) => {
78
+ if (column === AntdTable.EXPAND_COLUMN || column === AntdTable.SELECTION_COLUMN) {
79
+ return column;
80
+ }
81
+
82
+ if (isColumnGroup(column)) {
83
+ return {
84
+ ...column,
85
+ children: addDefaultColumnContentClassName(column.children, defaultClassName),
86
+ };
87
+ }
88
+
89
+ if (column.width !== undefined || column.ellipsis || column.fixed) {
90
+ return column;
91
+ }
92
+
93
+ const originalOnCell = column.onCell;
94
+ const originalOnHeaderCell = column.onHeaderCell;
95
+ return {
96
+ ...column,
97
+ onCell: (record, index) => {
98
+ const cellProps = originalOnCell?.(record, index) ?? {};
99
+ return {
100
+ ...cellProps,
101
+ className: cx(defaultClassName, cellProps.className),
102
+ };
103
+ },
104
+ onHeaderCell: (headerColumn) => {
105
+ const cellProps = originalOnHeaderCell?.(headerColumn) ?? {};
106
+ return {
107
+ ...cellProps,
108
+ className: cx(defaultClassName, cellProps.className),
109
+ };
110
+ },
111
+ };
112
+ });
113
+ }
114
+
53
115
  export interface TableProps<RecordType extends object = any> extends AntdTableProps<RecordType> {
54
116
  /**
55
117
  * Required so drag-sort, hover-swap and row identity work. Accepts the same
@@ -108,6 +170,7 @@ export interface TableProps<RecordType extends object = any> extends AntdTablePr
108
170
  * plus the index swap, so it is safe as the default table on any page.
109
171
  */
110
172
  export function Table<RecordType extends object = any>(props: TableProps<RecordType>) {
173
+ const { token } = theme.useToken();
111
174
  const {
112
175
  rowKey,
113
176
  showIndex = true,
@@ -143,6 +206,17 @@ export function Table<RecordType extends object = any>(props: TableProps<RecordT
143
206
  const showHandleInSelection = isDraggable && showSortHandle && !!rowSelection;
144
207
  const showStandaloneHandleColumn = isDraggable && showSortHandle && !rowSelection;
145
208
 
209
+ const defaultColumnContentClassName = useMemo(
210
+ () =>
211
+ cx(
212
+ DEFAULT_COLUMN_CONTENT_CLASS_NAME,
213
+ css`
214
+ ${DEFAULT_COLUMN_CONTENT_MAX_WIDTH_PROPERTY}: ${token.screenXS - token.paddingXL * 3 + token.padding * 2}px;
215
+ `,
216
+ ),
217
+ [token.padding, token.paddingXL, token.screenXS],
218
+ );
219
+
146
220
  const itemKeys = useMemo<string[]>(() => {
147
221
  if (!isDraggable || !dataSource) return [];
148
222
  return dataSource
@@ -191,15 +265,16 @@ export function Table<RecordType extends object = any>(props: TableProps<RecordT
191
265
  // selection cell — see `augmentedRowSelection` below.
192
266
  const augmentedColumns = useMemo<ColumnsType<RecordType>>(() => {
193
267
  const baseColumns: ColumnsType<RecordType> = columns ?? [];
194
- if (!showStandaloneHandleColumn) return baseColumns;
268
+ const styledColumns = addDefaultColumnContentClassName(baseColumns, defaultColumnContentClassName);
269
+ if (!showStandaloneHandleColumn) return styledColumns;
195
270
  const handleColumn: ColumnType<RecordType> = {
196
271
  key: '__sort__',
197
272
  width: sortHandleColumnWidth,
198
273
  align: 'center',
199
274
  render: () => <SortHandle />,
200
275
  };
201
- return [handleColumn, ...baseColumns];
202
- }, [columns, showStandaloneHandleColumn, sortHandleColumnWidth]);
276
+ return [handleColumn, ...styledColumns];
277
+ }, [columns, defaultColumnContentClassName, showStandaloneHandleColumn, sortHandleColumnWidth]);
203
278
 
204
279
  const augmentedRowSelection = useMemo(() => {
205
280
  if (!rowSelection) return rowSelection;