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

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.12",
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.12",
31
+ "@nocobase/flow-engine": "2.4.0-alpha.12",
32
+ "@nocobase/sdk": "2.4.0-alpha.12",
33
+ "@nocobase/shared": "2.4.0-alpha.12",
34
+ "@nocobase/utils": "2.4.0-alpha.12",
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": "de0ec0596d238752ed56bae95600a59395c920ea"
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
- // embed 是页面容器,不应销毁;仅弹窗视图在站内同窗口跳转后销毁。
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
- router.navigate(link, { replace: true });
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
 
@@ -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
+ });
@@ -58,7 +58,7 @@ export class InputFieldModel extends FieldModel {
58
58
  return <ScanInput {...this.props} />;
59
59
  }
60
60
  const { enableScan, disableManualInput, ...inputProps } = this.props;
61
- return <IMESafeInput {...inputProps} />;
61
+ return <IMESafeInput {...inputProps} style={{ verticalAlign: 'top', ...inputProps.style }} />;
62
62
  }
63
63
  }
64
64
 
@@ -27,6 +27,22 @@ function createInputFieldModel(props?: Record<string, unknown>) {
27
27
  }
28
28
 
29
29
  describe('InputFieldModel', () => {
30
+ it('aligns plain inputs with the top of other filter form controls', () => {
31
+ const model = createInputFieldModel();
32
+
33
+ render(<>{model.render()}</>);
34
+
35
+ expect(screen.getByRole('textbox')).toHaveStyle({ verticalAlign: 'top' });
36
+ });
37
+
38
+ it('preserves explicitly configured input styles', () => {
39
+ const model = createInputFieldModel({ style: { verticalAlign: 'middle', width: 200 } });
40
+
41
+ render(<>{model.render()}</>);
42
+
43
+ expect(screen.getByRole('textbox')).toHaveStyle({ verticalAlign: 'middle', width: '200px' });
44
+ });
45
+
30
46
  it('renders the normal input when scan input is disabled', () => {
31
47
  const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
32
48
  const model = createInputFieldModel({ disableManualInput: true, enableScan: false });
@@ -263,6 +263,7 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
263
263
  <>
264
264
  <Select
265
265
  {...restProps}
266
+ disabled={disabled}
266
267
  dropdownStyle={buildDropdownStyle(dropdownStyle)}
267
268
  fieldNames={fieldNames}
268
269
  options={realOptions}
@@ -388,6 +388,26 @@ describe('MobileLazySelect', () => {
388
388
  resetMockState();
389
389
  });
390
390
 
391
+ it.each([false, true])('updates the disabled trigger state in %s multiple mode', (multiple) => {
392
+ const onDropdownVisibleChange = vi.fn();
393
+ const { rerender } = renderMobileLazySelect({ multiple, onDropdownVisibleChange });
394
+
395
+ rerender({ disabled: true });
396
+
397
+ expect(mockState.selectProps?.disabled).toBe(true);
398
+ act(() => {
399
+ mockState.selectProps?.onClick?.();
400
+ });
401
+ expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
402
+ expect(onDropdownVisibleChange).not.toHaveBeenCalled();
403
+
404
+ rerender({ disabled: false });
405
+
406
+ expect(mockState.selectProps?.disabled).toBe(false);
407
+ openLazyPopup();
408
+ expect(onDropdownVisibleChange).toHaveBeenCalledWith(true);
409
+ });
410
+
391
411
  it('renders a translated cancel action after searching', () => {
392
412
  renderMobileLazySelect();
393
413