@nocobase/client-v2 2.4.0-alpha.10 → 2.4.0-alpha.11

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": "2.4.0-alpha.10",
3
+ "version": "2.4.0-alpha.11",
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.4.0-alpha.10",
31
- "@nocobase/flow-engine": "2.4.0-alpha.10",
32
- "@nocobase/sdk": "2.4.0-alpha.10",
33
- "@nocobase/shared": "2.4.0-alpha.10",
34
- "@nocobase/utils": "2.4.0-alpha.10",
30
+ "@nocobase/evaluators": "2.4.0-alpha.11",
31
+ "@nocobase/flow-engine": "2.4.0-alpha.11",
32
+ "@nocobase/sdk": "2.4.0-alpha.11",
33
+ "@nocobase/shared": "2.4.0-alpha.11",
34
+ "@nocobase/utils": "2.4.0-alpha.11",
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": "c869865c0413fbe4ef85b26be1f2b8d7078c38df"
52
+ "gitHead": "99aeb1bc859eb31d9710073d761ac72c97341e9d"
53
53
  }
@@ -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: e.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 (['textarea', 'richText', 'json', 'markdown', 'vditor'].includes(collectionField.interface)) {
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} /> <EditOutlined className="edit-icon" />
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
- style={{ pointerEvents: 'none', display: 'block', width: '100%' }} // 不拦截点击
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
+ });