@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.21
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/RouteRepository.d.ts +6 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
- package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
- package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
- package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
- package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
- package/es/index.mjs +100 -100
- package/lib/index.js +102 -102
- package/package.json +9 -8
- package/src/BaseApplication.tsx +2 -0
- package/src/RouteRepository.ts +14 -0
- package/src/__tests__/RouteRepository.test.ts +51 -0
- package/src/__tests__/app.test.tsx +13 -0
- package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
- package/src/collection-manager/field-configure.ts +2 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/actions/__tests__/validation.test.ts +48 -0
- package/src/flow/admin-shell/admin-layout/MobileTopbarActionsContext.ts +13 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
- package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
- package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
- package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
- package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
- package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
- package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
- package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
- package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
- package/src/flow/models/actions/LinkActionModel.tsx +11 -9
- package/src/flow/models/actions/LinkActionUtils.ts +4 -2
- package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
- package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
- package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
- package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
- package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +11 -2
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +13 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -0
- package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
- package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
- package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
- package/src/flow/models/fields/InputFieldModel.tsx +1 -1
- package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
- package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
- package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
- package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
- package/src/flow/models/topbar/UserCenterTopbarActionModel.tsx +8 -4
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>
|
|
@@ -209,7 +232,7 @@ function hasFormValueDrivenDataScopeField(fieldModel: any) {
|
|
|
209
232
|
}
|
|
210
233
|
|
|
211
234
|
const MemoFieldRenderer = React.memo(FieldModelRenderer, (prev, next) => {
|
|
212
|
-
return prev.value === next.value && prev.model === next.model;
|
|
235
|
+
return prev.value === next.value && prev.model === next.model && prev.disabled === next.disabled;
|
|
213
236
|
});
|
|
214
237
|
|
|
215
238
|
export function buildRowPathFromFieldIndex(fieldIndex: unknown): Array<string | number> | null {
|
|
@@ -345,11 +368,14 @@ interface CellProps {
|
|
|
345
368
|
memoKey?: string;
|
|
346
369
|
width?: number;
|
|
347
370
|
commitOnChange?: boolean;
|
|
371
|
+
disabled?: boolean;
|
|
348
372
|
}
|
|
349
373
|
|
|
350
374
|
const MemoCell: React.FC<CellProps> = React.memo(
|
|
351
|
-
({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange }) => {
|
|
375
|
+
({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange, disabled }) => {
|
|
352
376
|
const isNew = record?.__is_new__;
|
|
377
|
+
// 子表格整体被禁用时,列上的显示模式可能仍是“可编辑”,这里需要把父级的禁用状态一并合并进来,否则单元格仍可编辑。
|
|
378
|
+
const columnDisabled = disabled || parent.props.disabled;
|
|
353
379
|
return (
|
|
354
380
|
<div
|
|
355
381
|
style={{
|
|
@@ -442,9 +468,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
|
|
|
442
468
|
style={{ marginBottom: 0 }}
|
|
443
469
|
showLabel={false}
|
|
444
470
|
disabled={
|
|
445
|
-
parent.props.
|
|
446
|
-
(!isNew && parent.props.aclDisabled) ||
|
|
447
|
-
(isNew && parent.props.aclCreateDisabled)
|
|
471
|
+
columnDisabled || (!isNew && parent.props.aclDisabled) || (isNew && parent.props.aclCreateDisabled)
|
|
448
472
|
}
|
|
449
473
|
>
|
|
450
474
|
{fork.constructor.isLargeField ? (
|
|
@@ -456,9 +480,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
|
|
|
456
480
|
}}
|
|
457
481
|
defaultValue={value}
|
|
458
482
|
disabled={
|
|
459
|
-
parent.props.
|
|
460
|
-
(!isNew && parent.props.aclDisabled) ||
|
|
461
|
-
(isNew && parent.props.aclCreateDisabled)
|
|
483
|
+
columnDisabled || (!isNew && parent.props.aclDisabled) || (isNew && parent.props.aclCreateDisabled)
|
|
462
484
|
}
|
|
463
485
|
/>
|
|
464
486
|
) : (
|
|
@@ -481,7 +503,8 @@ const MemoCell: React.FC<CellProps> = React.memo(
|
|
|
481
503
|
prev.memoKey === next.memoKey &&
|
|
482
504
|
prev.width === next.width &&
|
|
483
505
|
prev.commitOnChange === next.commitOnChange &&
|
|
484
|
-
prev.rowIdx === next.rowIdx
|
|
506
|
+
prev.rowIdx === next.rowIdx &&
|
|
507
|
+
prev.disabled === next.disabled
|
|
485
508
|
);
|
|
486
509
|
},
|
|
487
510
|
);
|
|
@@ -620,6 +643,11 @@ export class SubTableColumnModel<
|
|
|
620
643
|
|
|
621
644
|
onInit(options: any): void {
|
|
622
645
|
super.onInit(options);
|
|
646
|
+
const resource = this.context.blockModel?.context?.resource;
|
|
647
|
+
const fieldPath = this.context.fieldPath;
|
|
648
|
+
if (resource?.addUpdateAssociationValues && fieldPath && this.collectionField?.isAssociationField?.()) {
|
|
649
|
+
resource.addUpdateAssociationValues(fieldPath);
|
|
650
|
+
}
|
|
623
651
|
this.context.defineProperty('resourceName', {
|
|
624
652
|
get: () => {
|
|
625
653
|
return this.context.collectionField.collection.name;
|
|
@@ -740,7 +768,7 @@ export class SubTableColumnModel<
|
|
|
740
768
|
}
|
|
741
769
|
renderItem(): any {
|
|
742
770
|
return (props) => {
|
|
743
|
-
const { value, id, rowIdx, record, parentFieldIndex, parentItem } = props || {};
|
|
771
|
+
const { value, id, rowIdx, record, parentFieldIndex, parentItem, disabled: subTableDisabled } = props || {};
|
|
744
772
|
// 子表格列模型本身没有行级 fieldIndex,上下文中无法把 `roles.name` 解析成 `roles[0].name`,
|
|
745
773
|
// 导致“默认值/赋值规则”在对多关系字段下无法生效。
|
|
746
774
|
// 这里为每一行创建一个 column fork,并注入 fieldIndex,让规则引擎能够按行解析与写入。
|
|
@@ -754,6 +782,7 @@ export class SubTableColumnModel<
|
|
|
754
782
|
const fieldModel: any = this.subModels.field;
|
|
755
783
|
const cellModeKey = [
|
|
756
784
|
rowForkKey,
|
|
785
|
+
subTableDisabled ? 'disabled' : 'enabled',
|
|
757
786
|
this.props.pattern,
|
|
758
787
|
this.props.readPretty,
|
|
759
788
|
this.props.titleField,
|
|
@@ -828,6 +857,7 @@ export class SubTableColumnModel<
|
|
|
828
857
|
parentItem={parentItem}
|
|
829
858
|
rowFork={rowFork}
|
|
830
859
|
memoKey={cellModeKey}
|
|
860
|
+
disabled={subTableDisabled}
|
|
831
861
|
width={this.props.width}
|
|
832
862
|
commitOnChange={this.hasFormulaColumn || this.hasFormValueDrivenDataScopeColumn}
|
|
833
863
|
/>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { CloseOutlined, ZoomInOutlined } from '@ant-design/icons';
|
|
11
|
-
import { Table, Form, Space, Button } from 'antd';
|
|
11
|
+
import { Table, Form, Space, Button, theme } from 'antd';
|
|
12
12
|
import { css } from '@emotion/css';
|
|
13
13
|
import { useTranslation } from 'react-i18next';
|
|
14
14
|
import { PlusOutlined } from '@ant-design/icons';
|
|
@@ -51,6 +51,7 @@ function shouldRefreshForChangedPaths(fieldPath: unknown, changedPaths: unknown)
|
|
|
51
51
|
|
|
52
52
|
export function SubTableField(props) {
|
|
53
53
|
const { t } = useTranslation();
|
|
54
|
+
const { token } = theme.useToken();
|
|
54
55
|
const {
|
|
55
56
|
onChange,
|
|
56
57
|
columns,
|
|
@@ -180,6 +181,9 @@ export function SubTableField(props) {
|
|
|
180
181
|
// the current array index so Form.Item can rebind after reordering/removal.
|
|
181
182
|
const rowBindingKey = `${rowIdentity}:${pageRowIdx}`;
|
|
182
183
|
const columnKey = col.dataIndex ?? col.key ?? 'cell';
|
|
184
|
+
if (col.key === '_empty') {
|
|
185
|
+
return <div style={{ minHeight: token.controlHeight }} />;
|
|
186
|
+
}
|
|
183
187
|
if (!col.render) {
|
|
184
188
|
return;
|
|
185
189
|
}
|
|
@@ -188,6 +192,7 @@ export function SubTableField(props) {
|
|
|
188
192
|
rowIdx: pageRowIdx,
|
|
189
193
|
id: `field-${String(columnKey)}-${rowBindingKey}`,
|
|
190
194
|
value: text,
|
|
195
|
+
disabled,
|
|
191
196
|
parentFieldIndex,
|
|
192
197
|
parentItem,
|
|
193
198
|
onChange: (value) => {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
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 { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { render, screen } from '@nocobase/test/client';
|
|
13
|
+
import { SubTableField } from '../SubTableField';
|
|
14
|
+
|
|
15
|
+
vi.mock('react-i18next', async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<any>()),
|
|
17
|
+
useTranslation: () => ({
|
|
18
|
+
t: (value: string) => value,
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('antd', async () => {
|
|
23
|
+
const actual = await vi.importActual<any>('antd');
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
Table: ({ dataSource = [], columns = [] }: any) => (
|
|
27
|
+
<div data-testid="subtable">
|
|
28
|
+
<div data-testid="column-keys">{columns.map((column: any) => String(column.key)).join(',')}</div>
|
|
29
|
+
{dataSource.map((record: any, rowIdx: number) => (
|
|
30
|
+
<div data-testid={`row-${rowIdx}`} key={record.__index__ || rowIdx}>
|
|
31
|
+
{columns.map((column: any) => (
|
|
32
|
+
<div data-testid={`cell-${rowIdx}-${String(column.dataIndex || column.key)}`} key={column.key}>
|
|
33
|
+
{column.render?.(record[column.dataIndex], record, rowIdx)}
|
|
34
|
+
</div>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function renderSubTableField(disabled?: boolean) {
|
|
44
|
+
const renderArgs: any[] = [];
|
|
45
|
+
const columns = [
|
|
46
|
+
{
|
|
47
|
+
key: 'name',
|
|
48
|
+
dataIndex: 'name',
|
|
49
|
+
render: (args: any) => {
|
|
50
|
+
renderArgs.push(args);
|
|
51
|
+
return <span>{args.value || 'empty'}</span>;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
render(
|
|
57
|
+
<SubTableField
|
|
58
|
+
columns={columns}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
pageSize={10}
|
|
61
|
+
filterTargetKey="id"
|
|
62
|
+
fieldPathArray={['roles']}
|
|
63
|
+
getCurrentValue={() => [{ __index__: 'row-1', id: 1, name: 'admin' }]}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return renderArgs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('SubTableField disabled', () => {
|
|
71
|
+
it('passes the disabled state down to every column cell', () => {
|
|
72
|
+
const renderArgs = renderSubTableField(true);
|
|
73
|
+
|
|
74
|
+
expect(renderArgs).toHaveLength(1);
|
|
75
|
+
expect(renderArgs[0].disabled).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('keeps cells editable when the sub-table is not disabled', () => {
|
|
79
|
+
const renderArgs = renderSubTableField(false);
|
|
80
|
+
|
|
81
|
+
expect(renderArgs).toHaveLength(1);
|
|
82
|
+
expect(renderArgs[0].disabled).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('hides the remove column when the sub-table is disabled', () => {
|
|
86
|
+
renderSubTableField(true);
|
|
87
|
+
|
|
88
|
+
expect(screen.getByTestId('column-keys')).not.toHaveTextContent('delete');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { FlowContext, FlowEngine, FlowModel, FlowModelProvider } from '@nocobase/flow-engine';
|
|
12
|
+
import { render, renderHook, waitFor } from '@testing-library/react';
|
|
12
13
|
import { describe, expect, it, vi } from 'vitest';
|
|
13
14
|
import {
|
|
14
15
|
buildCurrentItemTitle,
|
|
@@ -84,6 +85,29 @@ describe('RecordPickerFieldModel item context', () => {
|
|
|
84
85
|
expect(normalizeRecordPickerValue(undefined, fieldNames, false)).toBeUndefined();
|
|
85
86
|
});
|
|
86
87
|
|
|
88
|
+
it('renders existing values before field names are initialized', () => {
|
|
89
|
+
const engine = new FlowEngine();
|
|
90
|
+
engine.registerModels({ RecordPickerFieldModel });
|
|
91
|
+
const field = engine.createModel<RecordPickerFieldModel>({
|
|
92
|
+
use: 'RecordPickerFieldModel',
|
|
93
|
+
uid: 'record-picker-without-field-names',
|
|
94
|
+
});
|
|
95
|
+
field.context.defineProperty('collectionField', {
|
|
96
|
+
value: {
|
|
97
|
+
type: 'hasMany',
|
|
98
|
+
targetCollection: {
|
|
99
|
+
filterTargetKey: 'id',
|
|
100
|
+
titleCollectionField: { name: 'name' },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
field.setProps({ value: [{ id: 1, name: 'Alice' }], onClick: vi.fn(), onChange: vi.fn() });
|
|
105
|
+
|
|
106
|
+
const result = render(React.createElement(FlowModelProvider, { model: field }, field.render()));
|
|
107
|
+
|
|
108
|
+
expect(result.getByRole('combobox')).toBeTruthy();
|
|
109
|
+
});
|
|
110
|
+
|
|
87
111
|
it('queues ID-only picker values for association label hydration', () => {
|
|
88
112
|
const statusMap = new Map<string, 'pending' | 'done'>();
|
|
89
113
|
const fieldNames = { label: 'name', value: 'id' };
|