@nocobase/client-v2 2.3.0-beta.14 → 2.3.0-beta.16
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/flow/models/actions/LinkActionUtils.d.ts +1 -0
- package/es/index.mjs +31 -31
- package/lib/index.js +31 -31
- package/package.json +7 -7
- package/src/flow/models/actions/LinkActionModel.tsx +11 -9
- package/src/flow/models/actions/LinkActionUtils.ts +4 -2
- package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +30 -7
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.3.0-beta.
|
|
3
|
+
"version": "2.3.0-beta.16",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.3.0-beta.
|
|
31
|
-
"@nocobase/flow-engine": "2.3.0-beta.
|
|
32
|
-
"@nocobase/sdk": "2.3.0-beta.
|
|
33
|
-
"@nocobase/shared": "2.3.0-beta.
|
|
34
|
-
"@nocobase/utils": "2.3.0-beta.
|
|
30
|
+
"@nocobase/evaluators": "2.3.0-beta.16",
|
|
31
|
+
"@nocobase/flow-engine": "2.3.0-beta.16",
|
|
32
|
+
"@nocobase/sdk": "2.3.0-beta.16",
|
|
33
|
+
"@nocobase/shared": "2.3.0-beta.16",
|
|
34
|
+
"@nocobase/utils": "2.3.0-beta.16",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -49,5 +49,5 @@
|
|
|
49
49
|
"react-router-dom": "^6.30.1",
|
|
50
50
|
"zxing-wasm": "^3.1.3"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "b5fbabe2b7466c04f89621345d2270201a9f0fcf"
|
|
53
53
|
}
|
|
@@ -46,21 +46,23 @@ LinkActionModel.registerFlow({
|
|
|
46
46
|
|
|
47
47
|
if (link) {
|
|
48
48
|
const isExternalLink = isURL(link);
|
|
49
|
+
// embed 是页面容器,不应销毁;仅弹窗视图在站内同窗口跳转后销毁。
|
|
50
|
+
const shouldDestroyView =
|
|
51
|
+
!!ctx.view &&
|
|
52
|
+
shouldDestroyViewAfterLinkNavigation({
|
|
53
|
+
openInNewWindow,
|
|
54
|
+
isExternalLink,
|
|
55
|
+
viewType: ctx.view.type,
|
|
56
|
+
});
|
|
49
57
|
handleLinkNavigation({
|
|
50
58
|
link,
|
|
51
59
|
openInNewWindow,
|
|
52
60
|
router: ctx.router,
|
|
53
61
|
isExternalLink,
|
|
62
|
+
// 弹窗会被销毁,用 replace 丢弃弹窗路由;页面(含移动端 embed)跳转需保留历史,以便逐级返回。
|
|
63
|
+
replace: shouldDestroyView,
|
|
54
64
|
});
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
ctx.view &&
|
|
58
|
-
shouldDestroyViewAfterLinkNavigation({
|
|
59
|
-
openInNewWindow,
|
|
60
|
-
isExternalLink,
|
|
61
|
-
viewType: ctx.view.type,
|
|
62
|
-
})
|
|
63
|
-
) {
|
|
65
|
+
if (shouldDestroyView) {
|
|
64
66
|
ctx.view.destroy();
|
|
65
67
|
}
|
|
66
68
|
} else {
|
|
@@ -25,10 +25,11 @@ export function handleLinkNavigation(options: {
|
|
|
25
25
|
openInNewWindow?: boolean;
|
|
26
26
|
router: { navigate: (to: string, options?: { replace?: boolean }) => void };
|
|
27
27
|
isExternalLink: boolean;
|
|
28
|
+
replace?: boolean;
|
|
28
29
|
setLocationHref?: (url: string) => void;
|
|
29
30
|
openWindow?: (url?: string, target?: string) => Window | null;
|
|
30
31
|
}) {
|
|
31
|
-
const { link, openInNewWindow, router, isExternalLink } = options;
|
|
32
|
+
const { link, openInNewWindow, router, isExternalLink, replace = false } = options;
|
|
32
33
|
const setLocationHref = options.setLocationHref ?? ((url: string) => (window.location.href = url));
|
|
33
34
|
const openWindow = options.openWindow ?? ((url?: string, target?: string) => window.open(url, target));
|
|
34
35
|
|
|
@@ -44,7 +45,8 @@ export function handleLinkNavigation(options: {
|
|
|
44
45
|
|
|
45
46
|
// 该辅助方法仅负责导航,是否关闭弹窗由调用方按场景决定;
|
|
46
47
|
// 在弹窗上下文中,错误的关闭时机会把新路由回滚为原页面。
|
|
47
|
-
|
|
48
|
+
// 默认 push 新的历史记录,保证浏览器/手机右滑返回能逐级回退;仅在需要丢弃当前弹窗路由时由调用方传 replace。
|
|
49
|
+
router.navigate(link, { replace });
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -22,6 +22,20 @@ describe('handleLinkNavigation', () => {
|
|
|
22
22
|
isExternalLink: false,
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
expect(navigate).toHaveBeenCalledWith('/target-page', { replace: false });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should replace history entry when caller requests replace', () => {
|
|
29
|
+
const navigate = vi.fn();
|
|
30
|
+
|
|
31
|
+
handleLinkNavigation({
|
|
32
|
+
link: '/target-page',
|
|
33
|
+
openInNewWindow: false,
|
|
34
|
+
router: { navigate },
|
|
35
|
+
isExternalLink: false,
|
|
36
|
+
replace: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
25
39
|
expect(navigate).toHaveBeenCalledWith('/target-page', { replace: true });
|
|
26
40
|
});
|
|
27
41
|
|
package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx
CHANGED
|
@@ -94,6 +94,10 @@ export function FieldWithoutPermissionPlaceholder({ targetModel }) {
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
const LARGE_FIELD_TEXT_INTERFACES = ['textarea', 'richText', 'json', 'markdown', 'vditor'];
|
|
98
|
+
|
|
99
|
+
const LARGE_FIELD_INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="button"], [role="link"]';
|
|
100
|
+
|
|
97
101
|
const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultValue, disabled, ...others }: any) => {
|
|
98
102
|
const flowEngine = useFlowEngine();
|
|
99
103
|
const ref = useRef(null);
|
|
@@ -116,17 +120,32 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
|
|
|
116
120
|
|
|
117
121
|
return <FieldModelRenderer model={model} {...rest} onChange={handleChange} />;
|
|
118
122
|
};
|
|
119
|
-
const handleClick = async (e) => {
|
|
123
|
+
const handleClick = async (e: React.MouseEvent<HTMLDivElement>) => {
|
|
120
124
|
if (disabled) {
|
|
121
125
|
return;
|
|
122
126
|
}
|
|
127
|
+
const target = e.target as HTMLElement;
|
|
128
|
+
// 预览弹窗等通过 portal 渲染的内容,其点击事件也会沿 React 树冒泡到这里,不应触发编辑
|
|
129
|
+
if (!ref.current?.contains(target)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// 只读组件自身可交互的部分(如附件缩略图点击预览)交给只读组件处理,不打开编辑弹窗
|
|
133
|
+
if (target.closest(LARGE_FIELD_INTERACTIVE_SELECTOR)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
123
136
|
e.preventDefault();
|
|
124
137
|
e.stopPropagation();
|
|
138
|
+
// 默认挂在 #nocobase-app-container 上,它带 transform 会形成独立层叠上下文;当子表格位于挂在 body 上的弹窗(如审批处理弹窗)里时,
|
|
139
|
+
// 编辑弹层会被弹窗遮住无法操作,此时改挂到 body
|
|
140
|
+
const appContainer = document.querySelector('#nocobase-app-container');
|
|
141
|
+
const getLargeFieldPopupContainer = () =>
|
|
142
|
+
appContainer && appContainer.contains(ref.current) ? (appContainer as HTMLElement) : document.body;
|
|
125
143
|
try {
|
|
126
144
|
await flowEngine.context.viewer.open({
|
|
127
145
|
type: 'popover',
|
|
128
|
-
target
|
|
146
|
+
target,
|
|
129
147
|
placement: 'rightTop',
|
|
148
|
+
getPopupContainer: getLargeFieldPopupContainer,
|
|
130
149
|
styles: {
|
|
131
150
|
body: {
|
|
132
151
|
minWidth: 400,
|
|
@@ -141,9 +160,10 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
|
|
|
141
160
|
}
|
|
142
161
|
};
|
|
143
162
|
const collectionField = model.context.collectionField;
|
|
163
|
+
const isTextLike = LARGE_FIELD_TEXT_INTERFACES.includes(collectionField.interface);
|
|
144
164
|
|
|
145
165
|
const content = useMemo(() => {
|
|
146
|
-
if (
|
|
166
|
+
if (isTextLike) {
|
|
147
167
|
const inputValue =
|
|
148
168
|
collectionField.interface === 'json' && defaultValue
|
|
149
169
|
? JSON.stringify(defaultValue, null, 2)
|
|
@@ -151,13 +171,15 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
|
|
|
151
171
|
|
|
152
172
|
return <Input value={inputValue} disabled={disabled} style={{ width: '100%' }} />;
|
|
153
173
|
} else {
|
|
174
|
+
// 只读组件保持可交互:禁用时仍可点击附件缩略图预览,与 client v1 一致
|
|
154
175
|
return (
|
|
155
176
|
<Space>
|
|
156
|
-
<FlowModelRenderer model={fieldModel} uid={fieldModel?.uid} />
|
|
177
|
+
<FlowModelRenderer model={fieldModel} uid={fieldModel?.uid} />
|
|
178
|
+
{disabled ? null : <EditOutlined className="edit-icon" />}
|
|
157
179
|
</Space>
|
|
158
180
|
);
|
|
159
181
|
}
|
|
160
|
-
}, [collectionField.interface, defaultValue, disabled, fieldModel]);
|
|
182
|
+
}, [collectionField.interface, defaultValue, disabled, fieldModel, isTextLike]);
|
|
161
183
|
return (
|
|
162
184
|
<div
|
|
163
185
|
ref={ref}
|
|
@@ -170,11 +192,12 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
|
|
|
170
192
|
width: '100%',
|
|
171
193
|
maxHeight: 300,
|
|
172
194
|
overflowY: 'auto',
|
|
173
|
-
cursor: 'pointer',
|
|
195
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
174
196
|
}}
|
|
175
197
|
>
|
|
176
198
|
<span
|
|
177
|
-
|
|
199
|
+
// 文本类大字段只是占位展示,不拦截点击,由外层统一打开编辑弹窗
|
|
200
|
+
style={{ pointerEvents: isTextLike ? 'none' : undefined, display: 'block', width: '100%' }}
|
|
178
201
|
>
|
|
179
202
|
{content}
|
|
180
203
|
</span>
|
|
@@ -0,0 +1,178 @@
|
|
|
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 React from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { render, fireEvent } from '@nocobase/test/client';
|
|
14
|
+
import { FlowEngine, FlowModel, FlowEngineProvider, largeField } from '@nocobase/flow-engine';
|
|
15
|
+
import { SubTableColumnModel } from '../SubTableColumnModel';
|
|
16
|
+
|
|
17
|
+
const previewClicked = vi.fn();
|
|
18
|
+
|
|
19
|
+
class FakePreviewModel extends FlowModel {
|
|
20
|
+
render() {
|
|
21
|
+
return (
|
|
22
|
+
<span>
|
|
23
|
+
<div role="button" tabIndex={0} data-testid="thumb" onClick={previewClicked}>
|
|
24
|
+
file.png
|
|
25
|
+
</div>
|
|
26
|
+
{createPortal(<div data-testid="portal">preview modal</div>, document.body)}
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@largeField()
|
|
33
|
+
class FakeUploadModel extends FlowModel {
|
|
34
|
+
render() {
|
|
35
|
+
return <div data-testid="uploader">uploader</div>;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type CellRenderer = React.FC<Record<string, unknown>>;
|
|
40
|
+
|
|
41
|
+
function setup({
|
|
42
|
+
disabled,
|
|
43
|
+
fieldInterface = 'attachment',
|
|
44
|
+
container,
|
|
45
|
+
}: {
|
|
46
|
+
disabled: boolean;
|
|
47
|
+
fieldInterface?: string;
|
|
48
|
+
container?: HTMLElement;
|
|
49
|
+
}) {
|
|
50
|
+
previewClicked.mockClear();
|
|
51
|
+
const engine = new FlowEngine();
|
|
52
|
+
engine.registerModels({ FakePreviewModel, FakeUploadModel });
|
|
53
|
+
const open = vi.fn();
|
|
54
|
+
engine.context.defineProperty('viewer', { value: { open } });
|
|
55
|
+
|
|
56
|
+
const field = engine.createModel<FakeUploadModel>({ use: 'FakeUploadModel', uid: 'f1' });
|
|
57
|
+
field.context.defineProperty('fieldPath', { value: 'items.files' });
|
|
58
|
+
field.context.defineProperty('collectionField', { value: { interface: fieldInterface } });
|
|
59
|
+
field.setSubModel('readPrettyField', { use: 'FakePreviewModel', uid: 'p1' });
|
|
60
|
+
|
|
61
|
+
const column = Object.create(SubTableColumnModel.prototype);
|
|
62
|
+
const ctx: Record<string, unknown> = {};
|
|
63
|
+
ctx.defineProperty = (key: string, options: PropertyDescriptor) =>
|
|
64
|
+
Object.defineProperty(ctx, key, { ...options, configurable: true });
|
|
65
|
+
Object.defineProperties(column, {
|
|
66
|
+
parent: {
|
|
67
|
+
value: {
|
|
68
|
+
context: { fieldIndex: [], collectionField: { name: 'items' } },
|
|
69
|
+
collection: { filterTargetKey: 'id' },
|
|
70
|
+
props: { value: [{ id: 1 }] },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
subModels: { value: { field } },
|
|
74
|
+
props: { value: { disabled: false } },
|
|
75
|
+
context: { value: { fieldIndex: [], t: (s: string) => s } },
|
|
76
|
+
hasFormulaColumn: { value: false },
|
|
77
|
+
hasFormValueDrivenDataScopeColumn: { value: false },
|
|
78
|
+
});
|
|
79
|
+
column.mapSubModels = (_key: string, fn: (m: FlowModel) => React.ReactNode) => [fn(field)];
|
|
80
|
+
column.createFork = () => ({ context: ctx, uid: 'rowfork', flowEngine: undefined });
|
|
81
|
+
column.createRowItemContextPropertyOptions = () => ({});
|
|
82
|
+
|
|
83
|
+
const Cell: CellRenderer = SubTableColumnModel.prototype.renderItem.call(column);
|
|
84
|
+
const utils = render(
|
|
85
|
+
<FlowEngineProvider engine={engine}>
|
|
86
|
+
<Cell value={[{ id: 1, url: '/file.png' }]} id={1} rowIdx={0} record={{ id: 1 }} disabled={disabled} />
|
|
87
|
+
</FlowEngineProvider>,
|
|
88
|
+
container ? { container } : undefined,
|
|
89
|
+
);
|
|
90
|
+
return { ...utils, open };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getCellTrigger(element: HTMLElement) {
|
|
94
|
+
return element.closest('div[style*="cursor"]') as HTMLElement;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('SubTableColumnModel large field cell', () => {
|
|
98
|
+
it('previews the attachment instead of opening the editor when the thumbnail is clicked', async () => {
|
|
99
|
+
const { findByTestId, open } = setup({ disabled: false });
|
|
100
|
+
const thumb = await findByTestId('thumb');
|
|
101
|
+
|
|
102
|
+
expect(thumb.closest('span[style*="pointer-events: none"]')).toBeNull();
|
|
103
|
+
fireEvent.click(thumb);
|
|
104
|
+
|
|
105
|
+
expect(previewClicked).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(open).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('opens the editor when the editable cell itself is clicked', async () => {
|
|
110
|
+
const { findByTestId, open, container } = setup({ disabled: false });
|
|
111
|
+
const thumb = await findByTestId('thumb');
|
|
112
|
+
|
|
113
|
+
expect(container.querySelector('.edit-icon')).not.toBeNull();
|
|
114
|
+
fireEvent.click(getCellTrigger(thumb));
|
|
115
|
+
|
|
116
|
+
expect(open).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('ignores clicks bubbling from portal content such as the preview modal', async () => {
|
|
120
|
+
const { findByTestId, open } = setup({ disabled: false });
|
|
121
|
+
fireEvent.click(await findByTestId('portal'));
|
|
122
|
+
|
|
123
|
+
expect(open).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('still allows previewing but never editing when the cell is disabled', async () => {
|
|
127
|
+
const { findByTestId, open, container } = setup({ disabled: true });
|
|
128
|
+
const thumb = await findByTestId('thumb');
|
|
129
|
+
const trigger = getCellTrigger(thumb);
|
|
130
|
+
|
|
131
|
+
expect(trigger.style.cursor).toBe('default');
|
|
132
|
+
expect(container.querySelector('.edit-icon')).toBeNull();
|
|
133
|
+
|
|
134
|
+
fireEvent.click(thumb);
|
|
135
|
+
fireEvent.click(trigger);
|
|
136
|
+
|
|
137
|
+
expect(previewClicked).toHaveBeenCalledTimes(1);
|
|
138
|
+
expect(open).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('mounts the editor popover on body when the cell is rendered outside the app container (e.g. in a body-level modal)', async () => {
|
|
142
|
+
const appContainer = document.createElement('div');
|
|
143
|
+
appContainer.id = 'nocobase-app-container';
|
|
144
|
+
document.body.appendChild(appContainer);
|
|
145
|
+
try {
|
|
146
|
+
const { findByTestId, open } = setup({ disabled: false });
|
|
147
|
+
fireEvent.click(getCellTrigger(await findByTestId('thumb')));
|
|
148
|
+
|
|
149
|
+
expect(open.mock.calls[0][0].getPopupContainer()).toBe(document.body);
|
|
150
|
+
} finally {
|
|
151
|
+
appContainer.remove();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('keeps the editor popover in the app container when the cell is rendered inside it', async () => {
|
|
156
|
+
const appContainer = document.createElement('div');
|
|
157
|
+
appContainer.id = 'nocobase-app-container';
|
|
158
|
+
document.body.appendChild(appContainer);
|
|
159
|
+
try {
|
|
160
|
+
const { findByTestId, open } = setup({ disabled: false, container: appContainer });
|
|
161
|
+
fireEvent.click(getCellTrigger(await findByTestId('thumb')));
|
|
162
|
+
|
|
163
|
+
expect(open.mock.calls[0][0].getPopupContainer()).toBe(appContainer);
|
|
164
|
+
} finally {
|
|
165
|
+
appContainer.remove();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('keeps text-like large fields as a click-through placeholder that opens the editor', async () => {
|
|
170
|
+
const { container, open } = setup({ disabled: false, fieldInterface: 'textarea' });
|
|
171
|
+
const input = container.querySelector('input') as HTMLInputElement;
|
|
172
|
+
|
|
173
|
+
expect(input.closest('span[style*="pointer-events: none"]')).not.toBeNull();
|
|
174
|
+
fireEvent.click(getCellTrigger(input));
|
|
175
|
+
|
|
176
|
+
expect(open).toHaveBeenCalledTimes(1);
|
|
177
|
+
});
|
|
178
|
+
});
|