@nocobase/client-v2 2.1.0-beta.33 → 2.1.0-beta.35
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/es/APIClient.d.ts +16 -0
- package/es/Application.d.ts +2 -1
- package/es/BaseApplication.d.ts +6 -0
- package/es/PluginManager.d.ts +2 -0
- package/es/authRedirect.d.ts +9 -16
- package/es/components/form/EnvVariableInput.d.ts +8 -6
- package/es/components/form/VariableInput.d.ts +73 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/components/form/table/RowOverlayPreview.d.ts +27 -0
- package/es/components/form/table/SelectionCell.d.ts +36 -0
- package/es/components/form/table/Table.d.ts +82 -0
- package/es/components/form/table/constants.d.ts +15 -0
- package/es/components/form/table/dnd/SortableRow.d.ts +40 -0
- package/es/components/form/table/dnd/index.d.ts +9 -0
- package/es/components/form/table/index.d.ts +9 -0
- package/es/components/form/table/styles.d.ts +41 -0
- package/es/components/form/table/utils.d.ts +44 -0
- package/es/components/index.d.ts +2 -0
- package/es/flow/components/TextAreaWithContextSelector.d.ts +15 -0
- package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +9 -1
- package/es/flow/models/blocks/table/dragSort/dragSortComponents.d.ts +1 -6
- package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +5 -1
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +166 -99
- package/es/json-logic/globalOperators.d.ts +11 -0
- package/es/theme/globalStyles.d.ts +9 -0
- package/es/theme/index.d.ts +1 -0
- package/es/utils/globalDeps.d.ts +7 -0
- package/lib/index.js +173 -106
- package/package.json +9 -6
- package/src/APIClient.ts +68 -0
- package/src/Application.tsx +6 -2
- package/src/BaseApplication.tsx +8 -0
- package/src/PluginManager.ts +2 -0
- package/src/__tests__/app.test.tsx +8 -0
- package/src/__tests__/authRedirect.test.ts +170 -64
- package/src/__tests__/globalDeps.test.ts +2 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +6 -6
- package/src/__tests__/remotePlugins.test.ts +148 -0
- package/src/authRedirect.ts +23 -84
- package/src/components/form/EnvVariableInput.tsx +11 -46
- package/src/components/form/VariableInput.tsx +177 -0
- package/src/components/form/__tests__/EnvVariableInput.test.tsx +175 -0
- package/src/components/form/index.tsx +1 -0
- package/src/components/form/table/RowOverlayPreview.tsx +51 -0
- package/src/components/form/table/SelectionCell.tsx +72 -0
- package/src/components/form/table/Table.tsx +279 -0
- package/src/components/form/table/__tests__/Table.pagination.test.tsx +80 -0
- package/src/components/form/table/constants.ts +16 -0
- package/src/components/form/table/dnd/SortableRow.tsx +106 -0
- package/src/components/form/table/dnd/index.ts +10 -0
- package/src/components/form/table/index.tsx +13 -0
- package/src/components/form/table/styles.ts +110 -0
- package/src/components/form/table/utils.ts +75 -0
- package/src/components/index.ts +2 -0
- package/src/css-variable/CSSVariableProvider.tsx +1 -1
- package/src/flow/actions/filterFormDefaultValues.tsx +1 -2
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +2 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +111 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -1
- package/src/flow/components/TextAreaWithContextSelector.tsx +30 -6
- package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +81 -0
- package/src/flow/components/code-editor/hooks/useCodeRunner.ts +34 -2
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +329 -5
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +337 -0
- package/src/flow/models/blocks/table/dragSort/dragSortComponents.tsx +1 -81
- package/src/flow/models/fields/JSEditableFieldModel.tsx +107 -7
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +97 -0
- package/src/index.ts +1 -0
- package/src/json-logic/globalOperators.js +731 -0
- package/src/nocobase-buildin-plugin/index.tsx +4 -4
- package/src/theme/globalStyles.ts +21 -0
- package/src/theme/index.tsx +1 -0
- package/src/utils/globalDeps.ts +50 -30
- package/src/utils/remotePlugins.ts +107 -6
|
@@ -0,0 +1,175 @@
|
|
|
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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { FlowContext, FlowContextProvider, MetaTreeNode } from '@nocobase/flow-engine';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { EnvVariableInput, formatEnvPath, parseEnvPath } from '../EnvVariableInput';
|
|
15
|
+
|
|
16
|
+
function createContextWithEnv() {
|
|
17
|
+
const ctx = new FlowContext();
|
|
18
|
+
(ctx as any).t = (key: string) => key;
|
|
19
|
+
|
|
20
|
+
ctx.defineProperty('$env', {
|
|
21
|
+
value: { API_KEY: 'secret', BASE_URL: 'https://example.com' },
|
|
22
|
+
meta: {
|
|
23
|
+
title: 'Env',
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
API_KEY: { title: 'API Key', type: 'string' },
|
|
27
|
+
BASE_URL: { title: 'Base URL', type: 'string' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
ctx.defineProperty('$user', {
|
|
33
|
+
value: { name: 'John' },
|
|
34
|
+
meta: {
|
|
35
|
+
title: 'User',
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
name: { title: 'Name', type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return ctx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderWithCtx(ctx: FlowContext, node: React.ReactNode) {
|
|
47
|
+
return render(<FlowContextProvider context={ctx}>{node}</FlowContextProvider>);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('parseEnvPath', () => {
|
|
51
|
+
it('parses single segment', () => {
|
|
52
|
+
expect(parseEnvPath('{{ $env.API_KEY }}')).toEqual(['$env', 'API_KEY']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('parses nested segments', () => {
|
|
56
|
+
expect(parseEnvPath('{{ $env.foo.bar.baz }}')).toEqual(['$env', 'foo', 'bar', 'baz']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('trims surrounding whitespace', () => {
|
|
60
|
+
expect(parseEnvPath(' {{ $env.API_KEY }} ')).toEqual(['$env', 'API_KEY']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns undefined for plain text', () => {
|
|
64
|
+
expect(parseEnvPath('plain value')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns undefined for non-$env variable', () => {
|
|
68
|
+
expect(parseEnvPath('{{ $user.name }}')).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns undefined for mixed content', () => {
|
|
72
|
+
expect(parseEnvPath('prefix {{ $env.API_KEY }} suffix')).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns undefined for empty / undefined', () => {
|
|
76
|
+
expect(parseEnvPath('')).toBeUndefined();
|
|
77
|
+
expect(parseEnvPath(undefined)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('formatEnvPath', () => {
|
|
82
|
+
it('formats a single nested env path', () => {
|
|
83
|
+
expect(formatEnvPath({ paths: ['$env', 'API_KEY'] } as MetaTreeNode)).toBe('{{ $env.API_KEY }}');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('formats multi-level env path', () => {
|
|
87
|
+
expect(formatEnvPath({ paths: ['$env', 'foo', 'bar'] } as MetaTreeNode)).toBe('{{ $env.foo.bar }}');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns undefined for non-$env namespace', () => {
|
|
91
|
+
expect(formatEnvPath({ paths: ['$user', 'name'] } as MetaTreeNode)).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns undefined for $env root only', () => {
|
|
95
|
+
expect(formatEnvPath({ paths: ['$env'] } as MetaTreeNode)).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns undefined for empty meta', () => {
|
|
99
|
+
expect(formatEnvPath(undefined)).toBeUndefined();
|
|
100
|
+
expect(formatEnvPath({ paths: [] } as MetaTreeNode)).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('EnvVariableInput component', () => {
|
|
105
|
+
it('renders contenteditable editor with the variable selector button', async () => {
|
|
106
|
+
const ctx = createContextWithEnv();
|
|
107
|
+
renderWithCtx(ctx, <EnvVariableInput value="" onChange={() => undefined} />);
|
|
108
|
+
|
|
109
|
+
await waitFor(() => {
|
|
110
|
+
expect(screen.getByRole('textbox', { name: 'textbox' })).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByRole('button')).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('renders the variable as a styled pill when value is a $env expression', async () => {
|
|
116
|
+
const ctx = createContextWithEnv();
|
|
117
|
+
renderWithCtx(ctx, <EnvVariableInput value="{{ $env.API_KEY }}" onChange={() => undefined} />);
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
const tag = screen.getByText((_, node) => node?.textContent === 'Env/API Key' && node.tagName === 'SPAN');
|
|
121
|
+
expect(tag).toBeInTheDocument();
|
|
122
|
+
expect(tag.className).toContain('nb-variable-tag');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders password input when value is plain text and password=true', async () => {
|
|
127
|
+
const ctx = createContextWithEnv();
|
|
128
|
+
renderWithCtx(ctx, <EnvVariableInput value="my-secret" password onChange={() => undefined} />);
|
|
129
|
+
|
|
130
|
+
const input = await screen.findByDisplayValue('my-secret');
|
|
131
|
+
expect(input).toBeInTheDocument();
|
|
132
|
+
expect(input.getAttribute('type')).toBe('password');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does NOT mask when value is a $env expression even with password=true', async () => {
|
|
136
|
+
const ctx = createContextWithEnv();
|
|
137
|
+
renderWithCtx(ctx, <EnvVariableInput value="{{ $env.API_KEY }}" password onChange={() => undefined} />);
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
const tag = screen.getByText((_, node) => node?.textContent === 'Env/API Key' && node.tagName === 'SPAN');
|
|
141
|
+
expect(tag).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
expect(screen.queryByDisplayValue('{{ $env.API_KEY }}')).not.toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('propagates onChange when typing into the password input', async () => {
|
|
147
|
+
const ctx = createContextWithEnv();
|
|
148
|
+
const handleChange = vi.fn();
|
|
149
|
+
renderWithCtx(ctx, <EnvVariableInput value="initial" password onChange={handleChange} />);
|
|
150
|
+
|
|
151
|
+
const input = await screen.findByDisplayValue('initial');
|
|
152
|
+
fireEvent.change(input, { target: { value: 'next-value' } });
|
|
153
|
+
expect(handleChange).toHaveBeenCalledWith('next-value');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('renders placeholder text on the editor', async () => {
|
|
157
|
+
const ctx = createContextWithEnv();
|
|
158
|
+
renderWithCtx(ctx, <EnvVariableInput value="" placeholder="enter or pick" onChange={() => undefined} />);
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
const editor = screen.getByRole('textbox', { name: 'textbox' });
|
|
162
|
+
expect(editor.getAttribute('data-placeholder')).toBe('enter or pick');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('honors disabled state', async () => {
|
|
167
|
+
const ctx = createContextWithEnv();
|
|
168
|
+
renderWithCtx(ctx, <EnvVariableInput value="" disabled onChange={() => undefined} />);
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
const editor = screen.getByRole('textbox', { name: 'textbox' });
|
|
172
|
+
expect(editor.getAttribute('contenteditable')).toBe('false');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { cx } from '@emotion/css';
|
|
11
|
+
import { theme } from 'antd';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { overlayCellStylesClassName } from './styles';
|
|
14
|
+
import type { RowSnapshot } from './utils';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Floating clone of the row being dragged, rendered inside a `<DragOverlay>`
|
|
18
|
+
* so it follows the cursor while the source row stays in place. We inject the
|
|
19
|
+
* snapshot's `outerHTML` straight into a mini `<table>` so every antd class
|
|
20
|
+
* (`ant-table-cell`, `ant-table-selection-column`, theme tokens, etc.) is
|
|
21
|
+
* preserved. A `<colgroup>` re-applies the measured cell widths so the clone
|
|
22
|
+
* lines up column-for-column with the source row; the wrapper's pinned
|
|
23
|
+
* `height` + table `height:100%` keeps vertical sizing consistent regardless
|
|
24
|
+
* of which antd table size variant the caller picked.
|
|
25
|
+
*
|
|
26
|
+
* The wrapper also carries `overlayCellStylesClassName` so the index numeric
|
|
27
|
+
* (`.nb-table-index`) is hidden while dragging — only the handle + checkbox
|
|
28
|
+
* are surfaced inside the floating clone.
|
|
29
|
+
*/
|
|
30
|
+
export function RowOverlayPreview(props: { snapshot: RowSnapshot }) {
|
|
31
|
+
const { token } = theme.useToken();
|
|
32
|
+
const { html, cellWidths, totalWidth, totalHeight } = props.snapshot;
|
|
33
|
+
const colGroupHTML = cellWidths.map((width) => `<col style="width:${width}px" />`).join('');
|
|
34
|
+
const tableHTML = `<table style="table-layout:fixed;width:100%;height:100%;border-collapse:collapse"><colgroup>${colGroupHTML}</colgroup><tbody class="ant-table-tbody">${html}</tbody></table>`;
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cx('ant-table-wrapper', overlayCellStylesClassName)}
|
|
38
|
+
style={{
|
|
39
|
+
width: totalWidth || 'auto',
|
|
40
|
+
height: totalHeight || 'auto',
|
|
41
|
+
background: token.colorBgContainer,
|
|
42
|
+
boxShadow: token.boxShadowSecondary,
|
|
43
|
+
borderRadius: token.borderRadius,
|
|
44
|
+
pointerEvents: 'none',
|
|
45
|
+
opacity: 0.95,
|
|
46
|
+
overflow: 'hidden',
|
|
47
|
+
}}
|
|
48
|
+
dangerouslySetInnerHTML={{ __html: tableHTML }}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { cx } from '@emotion/css';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { SORT_HANDLE_GUTTER } from './constants';
|
|
13
|
+
import { SortHandle } from './dnd/SortableRow';
|
|
14
|
+
|
|
15
|
+
export interface SelectionCellProps {
|
|
16
|
+
/** Whether the row is currently selected — drives the index/checkbox swap. */
|
|
17
|
+
checked: boolean;
|
|
18
|
+
/** Zero-based row index (within the current page). `TableIndex` displays it as `index + 1`. */
|
|
19
|
+
index: number;
|
|
20
|
+
/** Render the drag handle on the left gutter. Mutually exclusive with `showStandaloneHandle`. */
|
|
21
|
+
showHandle: boolean;
|
|
22
|
+
/** Render the row index numeric. When `false` only the antd checkbox is shown (default antd UX). */
|
|
23
|
+
showIndex: boolean;
|
|
24
|
+
/** The original antd checkbox node produced by `rowSelection.renderCell`. */
|
|
25
|
+
originalNode: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handleSpanStyle: React.CSSProperties = {
|
|
29
|
+
position: 'absolute',
|
|
30
|
+
left: 0,
|
|
31
|
+
top: 0,
|
|
32
|
+
bottom: 0,
|
|
33
|
+
width: SORT_HANDLE_GUTTER,
|
|
34
|
+
display: 'inline-flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'center',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The body half of the rowSelection column. Renders, in order:
|
|
41
|
+
* 1. An absolute-positioned drag handle pinned to the left gutter (only
|
|
42
|
+
* when `showHandle` is true). The parent `<td>` must be
|
|
43
|
+
* `position: relative` for the handle to land inside the gutter —
|
|
44
|
+
* `selectionGutterClassName` in `styles.ts` adds that.
|
|
45
|
+
* 2. A `.nb-row-selection-cell` wrapper that contains both the row index
|
|
46
|
+
* and the checkbox. CSS in `indexSwapClassName` swaps which one is
|
|
47
|
+
* visible depending on hover / `checked` state.
|
|
48
|
+
*
|
|
49
|
+
* When `showIndex` is false we drop the swap entirely and render only the
|
|
50
|
+
* original antd checkbox — matches the default antd selection column UX for
|
|
51
|
+
* tables that don't want the index numeric.
|
|
52
|
+
*/
|
|
53
|
+
export function SelectionCell(props: SelectionCellProps) {
|
|
54
|
+
const { checked, index, showHandle, showIndex, originalNode } = props;
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{showHandle ? (
|
|
58
|
+
<span style={handleSpanStyle}>
|
|
59
|
+
<SortHandle />
|
|
60
|
+
</span>
|
|
61
|
+
) : null}
|
|
62
|
+
{showIndex ? (
|
|
63
|
+
<span className={cx('nb-row-selection-cell', { checked })}>
|
|
64
|
+
<span className="nb-table-index">{index + 1}</span>
|
|
65
|
+
<span className="nb-origin-node">{originalNode}</span>
|
|
66
|
+
</span>
|
|
67
|
+
) : (
|
|
68
|
+
originalNode
|
|
69
|
+
)}
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
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 { DragOverlay, type DragEndEvent, type DragStartEvent } from '@dnd-kit/core';
|
|
11
|
+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
12
|
+
import { cx } from '@emotion/css';
|
|
13
|
+
import { DndProvider } from '@nocobase/flow-engine';
|
|
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';
|
|
17
|
+
import type { RenderedCell } from 'rc-table/lib/interface';
|
|
18
|
+
import React, { useMemo, useState } from 'react';
|
|
19
|
+
import { SortableRow, SortHandle } from './dnd/SortableRow';
|
|
20
|
+
import { RowOverlayPreview } from './RowOverlayPreview';
|
|
21
|
+
import { SelectionCell } from './SelectionCell';
|
|
22
|
+
import { indexSwapClassName, selectionGutterClassName } from './styles';
|
|
23
|
+
import { readRowKey, snapshotSourceRow, type RowKey, type RowSnapshot } from './utils';
|
|
24
|
+
|
|
25
|
+
type RowSelectionRenderCellResult<RecordType> = React.ReactNode | RenderedCell<RecordType>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default initial page size for `Table`. Exposed so consumers can seed their
|
|
29
|
+
* controlled `pageSize` state with the same value the component would use if
|
|
30
|
+
* they relied purely on the built-in pagination defaults.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_PAGE_SIZE = 50;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default `pageSizeOptions` injected into the pagination config. Matches the
|
|
36
|
+
* v1 settings-page table so users see the same choices across versions.
|
|
37
|
+
* Consumers can override by passing their own `pageSizeOptions` in
|
|
38
|
+
* `pagination`.
|
|
39
|
+
*/
|
|
40
|
+
export const PAGE_SIZE_OPTIONS: readonly number[] = [5, 10, 20, 50, 100, 200];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* antd's `rowSelection.renderCell` can return either a `ReactNode` or a
|
|
44
|
+
* `RenderedCell` (the `{ children, props }` shape used to drive colSpan /
|
|
45
|
+
* rowSpan). `SelectionCell` only paints inside an existing selection cell —
|
|
46
|
+
* cell-spanning isn't supported here — so this guard unwraps to the inner
|
|
47
|
+
* children when a `RenderedCell` slips through.
|
|
48
|
+
*/
|
|
49
|
+
function isRenderedCell<RecordType>(value: unknown): value is RenderedCell<RecordType> {
|
|
50
|
+
return typeof value === 'object' && value !== null && !React.isValidElement(value) && 'children' in value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TableProps<RecordType extends object = any> extends AntdTableProps<RecordType> {
|
|
54
|
+
/**
|
|
55
|
+
* Required so drag-sort, hover-swap and row identity work. Accepts the same
|
|
56
|
+
* shape as antd `Table.rowKey`. When passed as a function it gets the
|
|
57
|
+
* record + index and must return a serializable id.
|
|
58
|
+
*/
|
|
59
|
+
rowKey: RowKey<RecordType>;
|
|
60
|
+
/**
|
|
61
|
+
* Show row index (1, 2, 3, …) in the rowSelection column by default; hovering
|
|
62
|
+
* a row or selecting it reveals the checkbox in the same cell. Requires
|
|
63
|
+
* `rowSelection` — the index lives in the selection column. Defaults to
|
|
64
|
+
* `true`.
|
|
65
|
+
*/
|
|
66
|
+
showIndex?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Enable vertical drag-and-drop row reordering. When true, rows show a drag
|
|
69
|
+
* handle on the left and `onSortEnd` fires after each drop. Defaults to
|
|
70
|
+
* `false`; the rest of the table behaves like a plain antd Table.
|
|
71
|
+
*/
|
|
72
|
+
isDraggable?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Called after a row is dropped onto another. Only used when `isDraggable`
|
|
75
|
+
* is true. The caller persists the move (e.g. `resource.move(...)`) and
|
|
76
|
+
* refreshes `dataSource` — this component does NOT mutate the data array.
|
|
77
|
+
*/
|
|
78
|
+
onSortEnd?: (from: RecordType, to: RecordType) => void | Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Hide the drag handle entirely while still keeping `isDraggable` on. Useful
|
|
81
|
+
* when the caller wants to embed `<SortHandle />` inside a custom column.
|
|
82
|
+
* Defaults to `true`.
|
|
83
|
+
*/
|
|
84
|
+
showSortHandle?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Override the width of the auto-inserted handle column when `rowSelection`
|
|
87
|
+
* is absent. When `rowSelection` is provided the handle is rendered inside
|
|
88
|
+
* the selection column instead, so this prop is ignored.
|
|
89
|
+
*/
|
|
90
|
+
sortHandleColumnWidth?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generic v2 settings-page table primitive. Built on antd's `Table` and adds:
|
|
95
|
+
*
|
|
96
|
+
* - Row-index ↔ checkbox swap inside the selection column (`showIndex`,
|
|
97
|
+
* on by default): index visible at rest, checkbox shows on hover or when
|
|
98
|
+
* selected. Both elements are absolutely positioned inside the cell so
|
|
99
|
+
* they share the same center anchor and never compete for layout space.
|
|
100
|
+
* - Optional vertical drag-and-drop reordering (`isDraggable`): handle is
|
|
101
|
+
* absolute-positioned in a 32px left gutter so the checkbox column stays
|
|
102
|
+
* visually centered. The drag overlay renders an `outerHTML` clone of the
|
|
103
|
+
* source `<tr>`; the index is suppressed inside the clone so the row
|
|
104
|
+
* being moved doesn't display a stale ordinal.
|
|
105
|
+
*
|
|
106
|
+
* Use this in place of antd `Table` for any settings-page list. When
|
|
107
|
+
* `isDraggable` is false the component is a thin pass-through to antd Table
|
|
108
|
+
* plus the index swap, so it is safe as the default table on any page.
|
|
109
|
+
*/
|
|
110
|
+
export function Table<RecordType extends object = any>(props: TableProps<RecordType>) {
|
|
111
|
+
const {
|
|
112
|
+
rowKey,
|
|
113
|
+
showIndex = true,
|
|
114
|
+
isDraggable = false,
|
|
115
|
+
onSortEnd,
|
|
116
|
+
showSortHandle = true,
|
|
117
|
+
sortHandleColumnWidth = 40,
|
|
118
|
+
components,
|
|
119
|
+
columns,
|
|
120
|
+
dataSource,
|
|
121
|
+
rowSelection,
|
|
122
|
+
className,
|
|
123
|
+
pagination,
|
|
124
|
+
...rest
|
|
125
|
+
} = props;
|
|
126
|
+
|
|
127
|
+
// Apply opinionated pagination defaults (showSizeChanger + a v1-aligned set
|
|
128
|
+
// of pageSizeOptions). `pagination === false` is preserved verbatim so
|
|
129
|
+
// callers can still disable pagination outright; for any other value (object
|
|
130
|
+
// or undefined) we spread caller-provided keys last so explicit overrides
|
|
131
|
+
// win.
|
|
132
|
+
const mergedPagination = useMemo<AntdTableProps<RecordType>['pagination']>(() => {
|
|
133
|
+
if (pagination === false) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
showSizeChanger: true,
|
|
138
|
+
pageSizeOptions: [...PAGE_SIZE_OPTIONS],
|
|
139
|
+
...(pagination ?? {}),
|
|
140
|
+
};
|
|
141
|
+
}, [pagination]);
|
|
142
|
+
|
|
143
|
+
const showHandleInSelection = isDraggable && showSortHandle && !!rowSelection;
|
|
144
|
+
const showStandaloneHandleColumn = isDraggable && showSortHandle && !rowSelection;
|
|
145
|
+
|
|
146
|
+
const itemKeys = useMemo<string[]>(() => {
|
|
147
|
+
if (!isDraggable || !dataSource) return [];
|
|
148
|
+
return dataSource
|
|
149
|
+
.map((record, index) => readRowKey(record, rowKey, index))
|
|
150
|
+
.filter((key): key is React.Key => key != null)
|
|
151
|
+
.map((key) => String(key));
|
|
152
|
+
}, [dataSource, isDraggable, rowKey]);
|
|
153
|
+
|
|
154
|
+
// Snapshot of the source `<tr>` at drag start. Cleared on drag end / cancel.
|
|
155
|
+
const [activeSnapshot, setActiveSnapshot] = useState<RowSnapshot | null>(null);
|
|
156
|
+
|
|
157
|
+
const handleDragStart = useMemoizedFn((event: DragStartEvent) => {
|
|
158
|
+
setActiveSnapshot(snapshotSourceRow(String(event.active.id)));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const handleDragCancel = useMemoizedFn(() => {
|
|
162
|
+
setActiveSnapshot(null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const handleDragEnd = useMemoizedFn(async (event: DragEndEvent) => {
|
|
166
|
+
setActiveSnapshot(null);
|
|
167
|
+
const { active, over } = event;
|
|
168
|
+
if (!active || !over || !onSortEnd) return;
|
|
169
|
+
if (String(active.id) === String(over.id)) return;
|
|
170
|
+
if (!dataSource) return;
|
|
171
|
+
const fromIndex = dataSource.findIndex(
|
|
172
|
+
(record, index) => String(readRowKey(record, rowKey, index)) === String(active.id),
|
|
173
|
+
);
|
|
174
|
+
const toIndex = dataSource.findIndex(
|
|
175
|
+
(record, index) => String(readRowKey(record, rowKey, index)) === String(over.id),
|
|
176
|
+
);
|
|
177
|
+
if (fromIndex < 0 || toIndex < 0) return;
|
|
178
|
+
const from = dataSource[fromIndex];
|
|
179
|
+
const to = dataSource[toIndex];
|
|
180
|
+
if (!from || !to) return;
|
|
181
|
+
await onSortEnd(from, to);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const tableComponents = useMemo(() => {
|
|
185
|
+
if (!isDraggable) return components;
|
|
186
|
+
return { ...components, body: { ...components?.body, row: SortableRow } };
|
|
187
|
+
}, [components, isDraggable]);
|
|
188
|
+
|
|
189
|
+
// When dragging without rowSelection, prepend a standalone column for the
|
|
190
|
+
// handle. When rowSelection is present, the handle lives inside the
|
|
191
|
+
// selection cell — see `augmentedRowSelection` below.
|
|
192
|
+
const augmentedColumns = useMemo<ColumnsType<RecordType>>(() => {
|
|
193
|
+
const baseColumns: ColumnsType<RecordType> = columns ?? [];
|
|
194
|
+
if (!showStandaloneHandleColumn) return baseColumns;
|
|
195
|
+
const handleColumn: ColumnType<RecordType> = {
|
|
196
|
+
key: '__sort__',
|
|
197
|
+
width: sortHandleColumnWidth,
|
|
198
|
+
align: 'center',
|
|
199
|
+
render: () => <SortHandle />,
|
|
200
|
+
};
|
|
201
|
+
return [handleColumn, ...baseColumns];
|
|
202
|
+
}, [columns, showStandaloneHandleColumn, sortHandleColumnWidth]);
|
|
203
|
+
|
|
204
|
+
const augmentedRowSelection = useMemo(() => {
|
|
205
|
+
if (!rowSelection) return rowSelection;
|
|
206
|
+
if (!showHandleInSelection && !showIndex) return rowSelection;
|
|
207
|
+
const originalRenderCell = rowSelection.renderCell;
|
|
208
|
+
return {
|
|
209
|
+
...rowSelection,
|
|
210
|
+
renderCell: (checked: boolean, record: RecordType, index: number, originalNode: React.ReactNode) => {
|
|
211
|
+
const result: RowSelectionRenderCellResult<RecordType> = originalRenderCell
|
|
212
|
+
? originalRenderCell(checked, record, index, originalNode)
|
|
213
|
+
: originalNode;
|
|
214
|
+
const node: React.ReactNode = isRenderedCell<RecordType>(result) ? result.children ?? null : result;
|
|
215
|
+
return (
|
|
216
|
+
<SelectionCell
|
|
217
|
+
checked={checked}
|
|
218
|
+
index={index}
|
|
219
|
+
showHandle={showHandleInSelection}
|
|
220
|
+
showIndex={showIndex}
|
|
221
|
+
originalNode={node}
|
|
222
|
+
/>
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}, [rowSelection, showHandleInSelection, showIndex]);
|
|
227
|
+
|
|
228
|
+
const tableClassName = cx(
|
|
229
|
+
className,
|
|
230
|
+
showHandleInSelection && selectionGutterClassName,
|
|
231
|
+
showIndex && rowSelection && indexSwapClassName,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// antd's `rowKey` accepts `string | GetRowKey<RecordType>`. Our `RowKey`
|
|
235
|
+
// alias is narrower (keyof RecordType for string keys), but TS doesn't infer
|
|
236
|
+
// that `keyof T & string` is assignable to `string` without help — express
|
|
237
|
+
// the narrowing explicitly at the antd boundary.
|
|
238
|
+
const antdRowKey: string | GetRowKey<RecordType> = typeof rowKey === 'function' ? rowKey : String(rowKey);
|
|
239
|
+
|
|
240
|
+
const tableBody = (
|
|
241
|
+
<AntdTable<RecordType>
|
|
242
|
+
{...rest}
|
|
243
|
+
rowKey={antdRowKey}
|
|
244
|
+
dataSource={dataSource}
|
|
245
|
+
columns={augmentedColumns}
|
|
246
|
+
components={tableComponents}
|
|
247
|
+
rowSelection={augmentedRowSelection}
|
|
248
|
+
className={tableClassName}
|
|
249
|
+
pagination={mergedPagination}
|
|
250
|
+
/>
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (!isDraggable || !onSortEnd || !itemKeys.length) {
|
|
254
|
+
return tableBody;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<DndProvider
|
|
259
|
+
onDragStart={handleDragStart}
|
|
260
|
+
onDragEnd={handleDragEnd}
|
|
261
|
+
onDragCancel={handleDragCancel}
|
|
262
|
+
showDragOverlay={false}
|
|
263
|
+
>
|
|
264
|
+
<SortableContext items={itemKeys} strategy={verticalListSortingStrategy}>
|
|
265
|
+
{tableBody}
|
|
266
|
+
</SortableContext>
|
|
267
|
+
{/* dropAnimation={null}: skip the default tween that snaps the overlay
|
|
268
|
+
back to the source `<tr>` on drop. Our source row hasn't moved yet
|
|
269
|
+
(the server `move` + refetch is async) so the tween reads as the
|
|
270
|
+
row "bouncing back" to its original position, which contradicts the
|
|
271
|
+
successful drop the user just performed. */}
|
|
272
|
+
<DragOverlay dropAnimation={null}>
|
|
273
|
+
{activeSnapshot ? <RowOverlayPreview snapshot={activeSnapshot} /> : null}
|
|
274
|
+
</DragOverlay>
|
|
275
|
+
</DndProvider>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default Table;
|
|
@@ -0,0 +1,80 @@
|
|
|
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 { render } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS, Table } from '../Table';
|
|
14
|
+
|
|
15
|
+
type Row = { id: number; name: string };
|
|
16
|
+
|
|
17
|
+
const columns = [{ title: 'Name', dataIndex: 'name' as const }];
|
|
18
|
+
|
|
19
|
+
function makeRows(count: number): Row[] {
|
|
20
|
+
return Array.from({ length: count }, (_, index) => ({ id: index + 1, name: `row-${index + 1}` }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Table pagination defaults', () => {
|
|
24
|
+
it('exports DEFAULT_PAGE_SIZE=50 and the v1-aligned PAGE_SIZE_OPTIONS list', () => {
|
|
25
|
+
expect(DEFAULT_PAGE_SIZE).toBe(50);
|
|
26
|
+
expect([...PAGE_SIZE_OPTIONS]).toEqual([5, 10, 20, 50, 100, 200]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows the page-size changer by default when pagination is enabled', () => {
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<Table<Row>
|
|
32
|
+
rowKey="id"
|
|
33
|
+
columns={columns}
|
|
34
|
+
dataSource={makeRows(120)}
|
|
35
|
+
pagination={{ current: 1, pageSize: 50, total: 120 }}
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
expect(container.querySelector('.ant-pagination-options-size-changer')).not.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows the page-size changer when caller omits pagination entirely', () => {
|
|
42
|
+
const { container } = render(<Table<Row> rowKey="id" columns={columns} dataSource={makeRows(120)} />);
|
|
43
|
+
expect(container.querySelector('.ant-pagination-options-size-changer')).not.toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('renders no pagination at all when caller passes pagination={false}', () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<Table<Row> rowKey="id" columns={columns} dataSource={makeRows(120)} pagination={false} />,
|
|
49
|
+
);
|
|
50
|
+
expect(container.querySelector('.ant-pagination')).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('lets caller-provided showSizeChanger=false override the default', () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<Table<Row>
|
|
56
|
+
rowKey="id"
|
|
57
|
+
columns={columns}
|
|
58
|
+
dataSource={makeRows(120)}
|
|
59
|
+
pagination={{ current: 1, pageSize: 50, total: 120, showSizeChanger: false }}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
expect(container.querySelector('.ant-pagination-options-size-changer')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('preserves caller-controlled current page when caller passes a pagination object', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<Table<Row>
|
|
68
|
+
rowKey="id"
|
|
69
|
+
columns={columns}
|
|
70
|
+
dataSource={makeRows(120)}
|
|
71
|
+
pagination={{ current: 2, pageSize: 50, total: 120 }}
|
|
72
|
+
/>,
|
|
73
|
+
);
|
|
74
|
+
// antd marks the active page with `.ant-pagination-item-active` and the
|
|
75
|
+
// page number lives inside an <a> child — checking the rendered text is
|
|
76
|
+
// the most stable assertion across antd versions.
|
|
77
|
+
const active = container.querySelector('.ant-pagination-item-active');
|
|
78
|
+
expect(active?.textContent).toBe('2');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Pixel reservation on the left side of the rowSelection column for the drag
|
|
12
|
+
* handle. Used by the runtime `selectionGutterClassName` (real table) and by
|
|
13
|
+
* the drag overlay `overlayCellStylesClassName` (floating clone) so both
|
|
14
|
+
* variants put the handle in the same gutter position.
|
|
15
|
+
*/
|
|
16
|
+
export const SORT_HANDLE_GUTTER = 32;
|