@nocobase/client-v2 2.1.0-beta.24 → 2.1.0-beta.26
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/BaseApplication.d.ts +1 -0
- package/es/flow/actions/dataScopeFilter.d.ts +9 -0
- package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
- package/es/flow/index.d.ts +1 -0
- package/es/flow/internal/utils/rebuildFieldSubModel.d.ts +2 -1
- package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
- package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
- package/es/flow/models/actions/index.d.ts +3 -0
- package/es/flow/models/base/GridModel.d.ts +3 -1
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
- package/es/flow/models/fields/JSFieldModel.d.ts +5 -0
- package/es/index.d.ts +1 -0
- package/es/index.mjs +101 -101
- package/lib/index.js +99 -99
- package/package.json +6 -5
- package/src/BaseApplication.tsx +4 -0
- package/src/__tests__/globalDeps.test.ts +6 -0
- package/src/__tests__/remotePlugins.test.ts +27 -0
- package/src/flow/actions/__tests__/dataScopeFilter.test.ts +158 -0
- package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
- package/src/flow/actions/dataScope.tsx +6 -4
- package/src/flow/actions/dataScopeFilter.ts +70 -0
- package/src/flow/actions/linkageRules.tsx +8 -1
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
- package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
- package/src/flow/actions/setTargetDataScope.tsx +6 -5
- package/src/flow/index.ts +1 -0
- package/src/flow/internal/utils/__tests__/rebuildFieldSubModel.test.ts +77 -2
- package/src/flow/internal/utils/rebuildFieldSubModel.ts +21 -5
- package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
- package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
- package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
- package/src/flow/models/actions/index.ts +3 -0
- package/src/flow/models/base/GridModel.tsx +21 -1
- package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +9 -5
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormBlockModel.cleanup.test.ts +138 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +22 -0
- package/src/flow/models/blocks/table/JSColumnModel.tsx +30 -2
- package/src/flow/models/blocks/table/TableBlockModel.tsx +8 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +1 -0
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +51 -0
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +49 -0
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
- package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
- package/src/flow/models/fields/JSFieldModel.tsx +54 -14
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
- package/src/index.ts +1 -0
- package/src/utils/globalDeps.ts +10 -0
- package/src/utils/requirejs.ts +1 -1
|
@@ -314,7 +314,14 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
|
|
|
314
314
|
onSuccess: (values) => {
|
|
315
315
|
const collectionField = this.collection.getField(dataIndex);
|
|
316
316
|
record[dataIndex] = values[dataIndex];
|
|
317
|
-
|
|
317
|
+
if (typeof recordIndex === 'number') {
|
|
318
|
+
this.resource.setItem(recordIndex, record);
|
|
319
|
+
} else {
|
|
320
|
+
const nextData = _.cloneDeep(this.resource.getData());
|
|
321
|
+
setNestedValue(nextData, recordIndex, record);
|
|
322
|
+
this.resource.setData(nextData);
|
|
323
|
+
}
|
|
324
|
+
this.resource.emit('refresh');
|
|
318
325
|
// 仅重渲染单元格
|
|
319
326
|
const fork: ForkFlowModel = model.subModels.field.createFork({}, `${recordIndex}`);
|
|
320
327
|
// Provide expandable meta for current row record based on the collection in context
|
|
@@ -313,6 +313,7 @@ export class TableColumnModel extends DisplayItemModel {
|
|
|
313
313
|
? this.fieldPath.replace(`${this.context.prefixFieldPath}.`, '')
|
|
314
314
|
: this.fieldPath;
|
|
315
315
|
const value = get(record, namePath);
|
|
316
|
+
fork.setProps({ value });
|
|
316
317
|
return (
|
|
317
318
|
<FormItem key={field.uid} {...omit(this.props, 'title')} value={value} noStyle={true}>
|
|
318
319
|
<FieldModelRenderer model={fork} />
|
|
@@ -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 { FlowEngine } from '@nocobase/flow-engine';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { JSColumnModel } from '../JSColumnModel';
|
|
13
|
+
|
|
14
|
+
describe('JSColumnModel row refresh', () => {
|
|
15
|
+
it('changes renderer key and invalidates beforeRender cache when row content changes', () => {
|
|
16
|
+
const engine = new FlowEngine();
|
|
17
|
+
const model = new JSColumnModel({
|
|
18
|
+
uid: 'js-column-row-refresh',
|
|
19
|
+
flowEngine: engine,
|
|
20
|
+
props: {
|
|
21
|
+
width: 200,
|
|
22
|
+
title: 'JS column',
|
|
23
|
+
},
|
|
24
|
+
} as any);
|
|
25
|
+
|
|
26
|
+
engine.context.dataSourceManager.getDataSource('main').addCollection({
|
|
27
|
+
name: 'users',
|
|
28
|
+
filterTargetKey: 'id',
|
|
29
|
+
fields: [
|
|
30
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
31
|
+
{ name: 'age', type: 'integer', interface: 'integer' },
|
|
32
|
+
{ name: 'workyears', type: 'float', interface: 'number' },
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const collection = engine.context.dataSourceManager.getCollection('main', 'users');
|
|
37
|
+
model.context.defineProperty('collection', {
|
|
38
|
+
value: collection,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const column = model.getColumnProps();
|
|
42
|
+
const first = column.render(null, { id: 1, age: 3, workyears: 39.2 }, 0) as any;
|
|
43
|
+
const fork = model.getFork('1') as any;
|
|
44
|
+
const invalidateFlowCache = vi.fn();
|
|
45
|
+
fork.invalidateFlowCache = invalidateFlowCache;
|
|
46
|
+
const second = column.render(null, { id: 1, age: 37, workyears: 39.2 }, 0) as any;
|
|
47
|
+
|
|
48
|
+
expect(first.key).not.toBe(second.key);
|
|
49
|
+
expect(invalidateFlowCache).toHaveBeenCalledWith('beforeRender');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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 { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
describe('TableBlockModel quick edit refresh', () => {
|
|
14
|
+
it('updates table data through a new array reference after quick editing a flat row', () => {
|
|
15
|
+
const engine = new FlowEngine();
|
|
16
|
+
const resource = engine.context.createResource(MultiRecordResource);
|
|
17
|
+
const original = [
|
|
18
|
+
{ id: 1, title: 'old title', status: 'draft' },
|
|
19
|
+
{ id: 2, title: 'other title', status: 'published' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
resource.setData(original);
|
|
23
|
+
const before = resource.getData();
|
|
24
|
+
const editedRecord = { ...before[0], title: 'new title' };
|
|
25
|
+
|
|
26
|
+
resource.setItem(0, editedRecord);
|
|
27
|
+
|
|
28
|
+
expect(resource.getData()).not.toBe(before);
|
|
29
|
+
expect(resource.getData()[0]).toEqual(editedRecord);
|
|
30
|
+
expect(resource.getData()[1]).toEqual(original[1]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('notifies mounted JS fields after quick editing local table data', () => {
|
|
34
|
+
const engine = new FlowEngine();
|
|
35
|
+
const resource = engine.context.createResource(MultiRecordResource);
|
|
36
|
+
const original = [{ id: 1, title: 'old title', status: 'draft' }];
|
|
37
|
+
let refreshCount = 0;
|
|
38
|
+
resource.on('refresh', () => {
|
|
39
|
+
refreshCount += 1;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
resource.setData(original);
|
|
43
|
+
const editedRecord = { ...resource.getData()[0], title: 'new title' };
|
|
44
|
+
resource.setItem(0, editedRecord);
|
|
45
|
+
resource.emit('refresh');
|
|
46
|
+
|
|
47
|
+
expect(refreshCount).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -38,7 +38,8 @@ import {
|
|
|
38
38
|
import { MobileLazySelect } from '../mobile-components/MobileLazySelect';
|
|
39
39
|
import { BlockSceneEnum } from '../../base/BlockModel';
|
|
40
40
|
import { ActionWithoutPermission } from '../../base/ActionModel';
|
|
41
|
-
import { EditFormModel } from '../../blocks
|
|
41
|
+
import { EditFormModel } from '../../blocks';
|
|
42
|
+
import { hasAncestorModel } from './recordSelectSettingsUtils';
|
|
42
43
|
|
|
43
44
|
function isPlainObject(val: unknown): val is Record<string, any> {
|
|
44
45
|
return !!val && typeof val === 'object' && !Array.isArray(val);
|
|
@@ -818,6 +819,9 @@ RecordSelectFieldModel.registerFlow({
|
|
|
818
819
|
if (ctx?.blockModel?.constructor?.scene === BlockSceneEnum.filter) {
|
|
819
820
|
return true;
|
|
820
821
|
}
|
|
822
|
+
if (hasAncestorModel(ctx?.model, ['SubTableColumnModel', 'SubTableFieldModel'])) {
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
821
825
|
return false;
|
|
822
826
|
},
|
|
823
827
|
defaultParams: {
|
package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx
CHANGED
|
@@ -194,7 +194,7 @@ function shouldCommitImmediately(value: any) {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
const FieldModelRendererOptimize = React.memo((props: any) => {
|
|
197
|
-
const { model, onChange, value, ...rest } = props;
|
|
197
|
+
const { model, onChange, value, commitOnChange, ...rest } = props;
|
|
198
198
|
const pendingValueRef = React.useRef<any>(props?.value);
|
|
199
199
|
|
|
200
200
|
useEffect(() => {
|
|
@@ -204,11 +204,11 @@ const FieldModelRendererOptimize = React.memo((props: any) => {
|
|
|
204
204
|
const handleChange = React.useCallback(
|
|
205
205
|
(value: any) => {
|
|
206
206
|
pendingValueRef.current = value;
|
|
207
|
-
if (shouldCommitImmediately(value)) {
|
|
207
|
+
if (commitOnChange || shouldCommitImmediately(value)) {
|
|
208
208
|
onChange?.(value);
|
|
209
209
|
}
|
|
210
210
|
},
|
|
211
|
-
[onChange],
|
|
211
|
+
[commitOnChange, onChange],
|
|
212
212
|
);
|
|
213
213
|
|
|
214
214
|
const handleCommit = React.useCallback(() => {
|
|
@@ -241,10 +241,11 @@ interface CellProps {
|
|
|
241
241
|
rowFork?: any;
|
|
242
242
|
memoKey?: string;
|
|
243
243
|
width?: number;
|
|
244
|
+
commitOnChange?: boolean;
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
const MemoCell: React.FC<CellProps> = React.memo(
|
|
247
|
-
({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width }) => {
|
|
248
|
+
({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange }) => {
|
|
248
249
|
const isNew = record?.__is_new__;
|
|
249
250
|
return (
|
|
250
251
|
<div
|
|
@@ -346,7 +347,11 @@ const MemoCell: React.FC<CellProps> = React.memo(
|
|
|
346
347
|
}
|
|
347
348
|
/>
|
|
348
349
|
) : (
|
|
349
|
-
<FieldModelRendererOptimize
|
|
350
|
+
<FieldModelRendererOptimize
|
|
351
|
+
model={fork}
|
|
352
|
+
id={[(parent as any).context.fieldPath, rowIdx]}
|
|
353
|
+
commitOnChange={commitOnChange}
|
|
354
|
+
/>
|
|
350
355
|
)}
|
|
351
356
|
</FormItem>
|
|
352
357
|
);
|
|
@@ -360,6 +365,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
|
|
|
360
365
|
prev.id === next.id &&
|
|
361
366
|
prev.memoKey === next.memoKey &&
|
|
362
367
|
prev.width === next.width &&
|
|
368
|
+
prev.commitOnChange === next.commitOnChange &&
|
|
363
369
|
prev.rowIdx === next.rowIdx
|
|
364
370
|
);
|
|
365
371
|
},
|
|
@@ -428,6 +434,15 @@ export class SubTableColumnModel<
|
|
|
428
434
|
return this.parent.collection;
|
|
429
435
|
}
|
|
430
436
|
|
|
437
|
+
get hasFormulaColumn() {
|
|
438
|
+
return (
|
|
439
|
+
this.parent?.mapSubModels('columns', (column: SubTableColumnModel) => {
|
|
440
|
+
const field = column.collectionField;
|
|
441
|
+
return field?.interface === 'formula' || field?.type === 'formula';
|
|
442
|
+
}) || []
|
|
443
|
+
).some(Boolean);
|
|
444
|
+
}
|
|
445
|
+
|
|
431
446
|
onInit(options: any): void {
|
|
432
447
|
super.onInit(options);
|
|
433
448
|
this.context.defineProperty('resourceName', {
|
|
@@ -607,6 +622,7 @@ export class SubTableColumnModel<
|
|
|
607
622
|
rowFork={rowFork}
|
|
608
623
|
memoKey={rowForkKey}
|
|
609
624
|
width={this.props.width}
|
|
625
|
+
commitOnChange={this.hasFormulaColumn}
|
|
610
626
|
/>
|
|
611
627
|
);
|
|
612
628
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export function hasAncestorModel(model: any, modelNames: string[]) {
|
|
11
|
+
let cursor = model;
|
|
12
|
+
while (cursor) {
|
|
13
|
+
const modelName = cursor?.constructor?.name;
|
|
14
|
+
if (modelName && modelNames.includes(modelName)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
cursor = cursor?.parent;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ElementProxy, tExpr, createSafeWindow, createSafeDocument, createSafeNavigator } from '@nocobase/flow-engine';
|
|
11
|
-
import React, { useEffect
|
|
11
|
+
import React, { useEffect } from 'react';
|
|
12
12
|
import { FieldModel } from '../base/FieldModel';
|
|
13
13
|
import { resolveRunJsParams } from '../utils/resolveRunJsParams';
|
|
14
14
|
import { CodeEditor } from '../../components/code-editor';
|
|
@@ -41,6 +41,10 @@ ctx.render(<JsReadonlyField />);
|
|
|
41
41
|
*/
|
|
42
42
|
export class JSFieldModel extends FieldModel {
|
|
43
43
|
private _mountedOnce = false; // prevent first-mount double-run
|
|
44
|
+
private _lastRenderedElement?: HTMLSpanElement | null;
|
|
45
|
+
private _pendingRenderedElement?: HTMLSpanElement | null;
|
|
46
|
+
private _lastRunJs?: { code: string; value: any; element: HTMLSpanElement | null };
|
|
47
|
+
|
|
44
48
|
getInputArgs() {
|
|
45
49
|
const field = this.context.collectionField;
|
|
46
50
|
if (field?.isAssociationField?.()) {
|
|
@@ -75,19 +79,10 @@ export class JSFieldModel extends FieldModel {
|
|
|
75
79
|
* 说明:fork 实例在表格逐行渲染时会复用该逻辑,确保按值更新。
|
|
76
80
|
*/
|
|
77
81
|
useHooksBeforeRender() {
|
|
78
|
-
// 单一副作用:当 code 或 value 变化,且二者都已就绪时执行一次
|
|
79
|
-
// 通过记忆上次运行的输入,避免相同输入导致的重复执行
|
|
80
82
|
const codeParam = this.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
|
|
81
83
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
82
|
-
const lastRunRef = useRef<{ code: string; value: any } | null>(null);
|
|
83
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
84
84
|
useEffect(() => {
|
|
85
|
-
|
|
86
|
-
const codeNow = (typeof codeParam === 'string' && codeParam.trim().length ? codeParam : DEFAULT_CODE).trim();
|
|
87
|
-
const last = lastRunRef.current;
|
|
88
|
-
if (last && last.code === codeNow && last.value === valueNow) return;
|
|
89
|
-
lastRunRef.current = { code: codeNow, value: valueNow };
|
|
90
|
-
this.applyFlow('jsSettings');
|
|
85
|
+
this.refreshRenderedElement(this.context.ref?.current as HTMLSpanElement | null);
|
|
91
86
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
87
|
}, [codeParam, this.props.value]);
|
|
93
88
|
}
|
|
@@ -95,8 +90,52 @@ export class JSFieldModel extends FieldModel {
|
|
|
95
90
|
/**
|
|
96
91
|
* 渲染一个占位容器,供 JS 脚本写入内容
|
|
97
92
|
*/
|
|
93
|
+
private getRunJsCode() {
|
|
94
|
+
const codeParam = this.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
|
|
95
|
+
return (typeof codeParam === 'string' && codeParam.trim().length ? codeParam : DEFAULT_CODE).trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private refreshRenderedElement(element: HTMLSpanElement | null) {
|
|
99
|
+
if (!element || this._pendingRenderedElement === element) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this._pendingRenderedElement = element;
|
|
104
|
+
const ref = this.context.ref as React.MutableRefObject<HTMLSpanElement | null>;
|
|
105
|
+
|
|
106
|
+
queueMicrotask(() => {
|
|
107
|
+
if (this._pendingRenderedElement === element) {
|
|
108
|
+
this._pendingRenderedElement = null;
|
|
109
|
+
}
|
|
110
|
+
if (ref.current !== element) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const code = this.getRunJsCode();
|
|
114
|
+
const value = this.props.value;
|
|
115
|
+
const last = this._lastRunJs;
|
|
116
|
+
if (last && last.element === element && last.code === code && Object.is(last.value, value)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this._lastRunJs = { code, value, element };
|
|
120
|
+
void this.applyFlow('jsSettings');
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
render() {
|
|
99
|
-
|
|
125
|
+
const ref = this.context.ref as React.MutableRefObject<HTMLSpanElement | null>;
|
|
126
|
+
const assignRef = (element: HTMLSpanElement | null) => {
|
|
127
|
+
ref.current = element;
|
|
128
|
+
if (!element) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const elementChanged = this._lastRenderedElement && this._lastRenderedElement !== element;
|
|
133
|
+
this._lastRenderedElement = element;
|
|
134
|
+
if (elementChanged || !this._mountedOnce) {
|
|
135
|
+
this.refreshRenderedElement(element);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
return <span ref={assignRef} style={{ display: 'inline-block', maxWidth: '100%' }} />;
|
|
100
139
|
}
|
|
101
140
|
|
|
102
141
|
/**
|
|
@@ -106,7 +145,7 @@ export class JSFieldModel extends FieldModel {
|
|
|
106
145
|
protected onMount() {
|
|
107
146
|
if (this._mountedOnce) {
|
|
108
147
|
if (this.context.ref?.current) {
|
|
109
|
-
this.
|
|
148
|
+
this.refreshRenderedElement(this.context.ref.current as HTMLSpanElement);
|
|
110
149
|
}
|
|
111
150
|
}
|
|
112
151
|
this._mountedOnce = true;
|
|
@@ -165,7 +204,8 @@ JSFieldModel.registerFlow({
|
|
|
165
204
|
// 暴露 element 与 value 到运行上下文
|
|
166
205
|
ctx.onRefReady(ctx.ref, async (element) => {
|
|
167
206
|
ctx.defineProperty('element', {
|
|
168
|
-
get: () => new ElementProxy(element),
|
|
207
|
+
get: () => new ElementProxy((ctx.ref?.current as HTMLElement | null) || element),
|
|
208
|
+
cache: false,
|
|
169
209
|
});
|
|
170
210
|
ctx.defineProperty('value', {
|
|
171
211
|
get: () => ctx.model.props?.value,
|
|
@@ -13,7 +13,7 @@ import { Button, CheckList, Popup, SearchBar } from 'antd-mobile';
|
|
|
13
13
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
14
14
|
|
|
15
15
|
export function MobileSelect(props) {
|
|
16
|
-
const { value, onChange, disabled, options = [], mode } = props;
|
|
16
|
+
const { value, onChange, onChangeComplete, disabled, options = [], mode } = props;
|
|
17
17
|
const ctx = useFlowModelContext();
|
|
18
18
|
const t = ctx.t;
|
|
19
19
|
const [visible, setVisible] = useState(false);
|
|
@@ -28,6 +28,7 @@ export function MobileSelect(props) {
|
|
|
28
28
|
|
|
29
29
|
const handleConfirm = () => {
|
|
30
30
|
onChange(selected);
|
|
31
|
+
onChangeComplete?.();
|
|
31
32
|
setVisible(false);
|
|
32
33
|
};
|
|
33
34
|
useEffect(() => {
|
|
@@ -36,12 +37,18 @@ export function MobileSelect(props) {
|
|
|
36
37
|
} else {
|
|
37
38
|
setSearchText(null);
|
|
38
39
|
}
|
|
39
|
-
}, [visible]);
|
|
40
|
+
}, [visible, value]);
|
|
40
41
|
|
|
41
42
|
return (
|
|
42
43
|
<>
|
|
43
44
|
<div onClick={() => !disabled && setVisible(true)}>
|
|
44
|
-
<Select
|
|
45
|
+
<Select
|
|
46
|
+
{...props}
|
|
47
|
+
open={false}
|
|
48
|
+
dropdownStyle={{ display: 'none' }}
|
|
49
|
+
showSearch={false}
|
|
50
|
+
style={{ pointerEvents: 'none', width: '100%' }}
|
|
51
|
+
/>
|
|
45
52
|
</div>
|
|
46
53
|
<Popup
|
|
47
54
|
visible={visible}
|
|
@@ -71,6 +78,7 @@ export function MobileSelect(props) {
|
|
|
71
78
|
} else {
|
|
72
79
|
setSelected(val[0]);
|
|
73
80
|
onChange(val[0]);
|
|
81
|
+
onChangeComplete?.();
|
|
74
82
|
setVisible(false);
|
|
75
83
|
}
|
|
76
84
|
}}
|
|
@@ -0,0 +1,235 @@
|
|
|
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 { beforeEach, describe, it, expect, vi } from 'vitest';
|
|
12
|
+
import { act, fireEvent, render, screen } from '@nocobase/test/client';
|
|
13
|
+
import { MobileSelect } from '../MobileSelect';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_OPTIONS = [
|
|
16
|
+
{ label: 'Option A', value: 'a' },
|
|
17
|
+
{ label: 'Option B', value: 'b' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const mockState = vi.hoisted(() => ({
|
|
21
|
+
selectProps: undefined as any,
|
|
22
|
+
popupProps: undefined as any,
|
|
23
|
+
checklistProps: undefined as any,
|
|
24
|
+
confirmButtonProps: undefined as any,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
function resetMockState() {
|
|
28
|
+
mockState.selectProps = undefined;
|
|
29
|
+
mockState.popupProps = undefined;
|
|
30
|
+
mockState.checklistProps = undefined;
|
|
31
|
+
mockState.confirmButtonProps = undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clickTrigger() {
|
|
35
|
+
const trigger = screen.getByTestId('antd-select').parentElement as HTMLElement | null;
|
|
36
|
+
expect(trigger).toBeTruthy();
|
|
37
|
+
act(() => {
|
|
38
|
+
fireEvent.click(trigger as HTMLElement);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function openPopup() {
|
|
43
|
+
clickTrigger();
|
|
44
|
+
expect(screen.getByTestId('popup')).toBeInTheDocument();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function selectValues(values: string[]) {
|
|
48
|
+
act(() => {
|
|
49
|
+
mockState.checklistProps?.onChange?.(values);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function confirmSelection() {
|
|
54
|
+
act(() => {
|
|
55
|
+
mockState.confirmButtonProps?.onClick?.();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderMobileSelect(props: Record<string, any> = {}) {
|
|
60
|
+
const onChange = props.onChange ?? vi.fn();
|
|
61
|
+
const onChangeComplete = props.onChangeComplete ?? vi.fn();
|
|
62
|
+
|
|
63
|
+
render(
|
|
64
|
+
<MobileSelect
|
|
65
|
+
value={undefined}
|
|
66
|
+
options={DEFAULT_OPTIONS}
|
|
67
|
+
onChange={onChange}
|
|
68
|
+
onChangeComplete={onChangeComplete}
|
|
69
|
+
{...props}
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return { onChange, onChangeComplete };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
vi.mock('@nocobase/flow-engine', async () => {
|
|
77
|
+
const actual = await vi.importActual<any>('@nocobase/flow-engine');
|
|
78
|
+
return {
|
|
79
|
+
...actual,
|
|
80
|
+
useFlowModelContext: () => ({
|
|
81
|
+
t: (value: string) => value,
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
vi.mock('antd', async () => {
|
|
87
|
+
const actual = await vi.importActual<any>('antd');
|
|
88
|
+
return {
|
|
89
|
+
...actual,
|
|
90
|
+
Select: (props: any) => {
|
|
91
|
+
mockState.selectProps = props;
|
|
92
|
+
return <div data-testid="antd-select" />;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
vi.mock('antd-mobile', () => {
|
|
98
|
+
const MockCheckList: any = (props: any) => {
|
|
99
|
+
mockState.checklistProps = props;
|
|
100
|
+
return <div data-testid="checklist">{props.children}</div>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
MockCheckList.Item = ({ value, children }: any) => <div data-testid={`item-${value}`}>{children}</div>;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
Button: (props: any) => {
|
|
107
|
+
mockState.confirmButtonProps = props;
|
|
108
|
+
return (
|
|
109
|
+
<button type="button" data-testid="confirm" onClick={props.onClick}>
|
|
110
|
+
{props.children}
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
Popup: (props: any) => {
|
|
115
|
+
mockState.popupProps = props;
|
|
116
|
+
return props.visible ? <div data-testid="popup">{props.children}</div> : null;
|
|
117
|
+
},
|
|
118
|
+
SearchBar: ({ value, onChange }: any) => (
|
|
119
|
+
<input data-testid="search" value={value ?? ''} onChange={(e) => onChange?.(e.target.value)} />
|
|
120
|
+
),
|
|
121
|
+
CheckList: MockCheckList,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('MobileSelect', () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
resetMockState();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('commits the selected value immediately in single mode', () => {
|
|
131
|
+
const { onChange, onChangeComplete } = renderMobileSelect();
|
|
132
|
+
|
|
133
|
+
openPopup();
|
|
134
|
+
selectValues(['a']);
|
|
135
|
+
|
|
136
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(onChange).toHaveBeenCalledWith('a');
|
|
138
|
+
expect(onChangeComplete).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('renders filtered options based on search text', () => {
|
|
143
|
+
const { onChange, onChangeComplete } = renderMobileSelect();
|
|
144
|
+
openPopup();
|
|
145
|
+
act(() => {
|
|
146
|
+
fireEvent.change(screen.getByTestId('search'), { target: { value: 'Option A' } });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(screen.getByTestId('item-a')).toBeInTheDocument();
|
|
150
|
+
expect(screen.queryByTestId('item-b')).not.toBeInTheDocument();
|
|
151
|
+
|
|
152
|
+
selectValues(['a']);
|
|
153
|
+
|
|
154
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect(onChange).toHaveBeenCalledWith('a');
|
|
156
|
+
expect(onChangeComplete).toHaveBeenCalledTimes(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('defers commit until confirm in multiple mode', () => {
|
|
160
|
+
const { onChange, onChangeComplete } = renderMobileSelect({ value: [], mode: 'multiple' });
|
|
161
|
+
openPopup();
|
|
162
|
+
|
|
163
|
+
selectValues(['a', 'b']);
|
|
164
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
165
|
+
expect(onChangeComplete).not.toHaveBeenCalled();
|
|
166
|
+
|
|
167
|
+
confirmSelection();
|
|
168
|
+
|
|
169
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(onChange).toHaveBeenCalledWith(['a', 'b']);
|
|
171
|
+
expect(onChangeComplete).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not open popup when disabled', () => {
|
|
176
|
+
renderMobileSelect({ disabled: true });
|
|
177
|
+
|
|
178
|
+
clickTrigger();
|
|
179
|
+
expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function SubTableCellHarness({ value, onCommit, mode }: { value: any; onCommit: (value: any) => void; mode?: string }) {
|
|
184
|
+
const pendingValueRef = React.useRef<any>(value);
|
|
185
|
+
return (
|
|
186
|
+
<div>
|
|
187
|
+
<MobileSelect
|
|
188
|
+
value={value}
|
|
189
|
+
mode={mode}
|
|
190
|
+
options={DEFAULT_OPTIONS}
|
|
191
|
+
onChange={(next) => {
|
|
192
|
+
pendingValueRef.current = next;
|
|
193
|
+
if (Array.isArray(next)) {
|
|
194
|
+
onCommit(next);
|
|
195
|
+
}
|
|
196
|
+
}}
|
|
197
|
+
onChangeComplete={() => {
|
|
198
|
+
onCommit(pendingValueRef.current);
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe('MobileSelect in SubForm/SubTable containers', () => {
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
resetMockState();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('SubTable: single selection commits final value via onChangeComplete', () => {
|
|
211
|
+
const onCommit = vi.fn();
|
|
212
|
+
|
|
213
|
+
render(<SubTableCellHarness value={undefined} onCommit={onCommit} />);
|
|
214
|
+
|
|
215
|
+
openPopup();
|
|
216
|
+
selectValues(['b']);
|
|
217
|
+
|
|
218
|
+
expect(onCommit).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(onCommit).toHaveBeenCalledWith('b');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('SubTable: multiple mode only commits after confirm, and commit receives the full array', () => {
|
|
223
|
+
const onCommit = vi.fn();
|
|
224
|
+
|
|
225
|
+
render(<SubTableCellHarness value={[]} onCommit={onCommit} mode="multiple" />);
|
|
226
|
+
|
|
227
|
+
openPopup();
|
|
228
|
+
selectValues(['a', 'b']);
|
|
229
|
+
confirmSelection();
|
|
230
|
+
|
|
231
|
+
expect(onCommit).toHaveBeenCalledTimes(2);
|
|
232
|
+
expect(onCommit).toHaveBeenNthCalledWith(1, ['a', 'b']);
|
|
233
|
+
expect(onCommit).toHaveBeenNthCalledWith(2, ['a', 'b']);
|
|
234
|
+
});
|
|
235
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -32,4 +32,5 @@ export * from './collection-field-interface/CollectionFieldInterface';
|
|
|
32
32
|
export * from './collection-field-interface/CollectionFieldInterfaceManager';
|
|
33
33
|
export * from './collection-manager/interfaces';
|
|
34
34
|
export * from './flow';
|
|
35
|
+
export { DEFAULT_DATA_SOURCE_KEY, isTitleField } from './flow-compat';
|
|
35
36
|
export { default as AntdAppProvider } from './theme/AntdAppProvider';
|
package/src/utils/globalDeps.ts
CHANGED
|
@@ -9,14 +9,18 @@
|
|
|
9
9
|
|
|
10
10
|
import * as antdCssinjs from '@ant-design/cssinjs';
|
|
11
11
|
import * as antdIcons from '@ant-design/icons';
|
|
12
|
+
import * as emotionCss from '@emotion/css';
|
|
12
13
|
import * as formilyCore from '@formily/core';
|
|
13
14
|
import * as formilyReact from '@formily/react';
|
|
14
15
|
import * as formilyReactive from '@formily/reactive';
|
|
15
16
|
import * as formilyShared from '@formily/shared';
|
|
16
17
|
import * as nocobaseClientUtils from '@nocobase/utils/client';
|
|
18
|
+
import { dayjs } from '@nocobase/utils/client';
|
|
17
19
|
import * as nocobaseFlowEngine from '@nocobase/flow-engine';
|
|
20
|
+
import * as ahooks from 'ahooks';
|
|
18
21
|
import * as antd from 'antd';
|
|
19
22
|
import * as i18next from 'i18next';
|
|
23
|
+
import lodash from 'lodash';
|
|
20
24
|
import React from 'react';
|
|
21
25
|
import ReactDOM from 'react-dom';
|
|
22
26
|
import * as reactI18next from 'react-i18next';
|
|
@@ -61,4 +65,10 @@ export function defineGlobalDeps(requirejs: RequireJS) {
|
|
|
61
65
|
requirejs.define('@nocobase/client-v2', () => nocobaseClientV2);
|
|
62
66
|
requirejs.define('@nocobase/client-v2/client-v2', () => nocobaseClientV2);
|
|
63
67
|
requirejs.define('@nocobase/flow-engine', () => nocobaseFlowEngine);
|
|
68
|
+
|
|
69
|
+
// utils
|
|
70
|
+
requirejs.define('ahooks', () => ahooks);
|
|
71
|
+
requirejs.define('dayjs', () => dayjs);
|
|
72
|
+
requirejs.define('lodash', () => lodash);
|
|
73
|
+
requirejs.define('@emotion/css', () => emotionCss);
|
|
64
74
|
}
|