@nocobase/client-v2 2.1.0-beta.27 → 2.1.0-beta.30
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/components/form/JsonTextArea.d.ts +18 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/actions/dateRangeLimit.d.ts +9 -0
- package/es/flow/actions/index.d.ts +2 -1
- package/es/flow/actions/linkageRules.d.ts +2 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
- package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
- package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
- package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +5 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
- package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
- package/es/index.mjs +79 -67
- package/lib/index.js +80 -68
- package/package.json +6 -5
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
- package/src/__tests__/settings-center.test.tsx +30 -0
- package/src/components/form/JsonTextArea.tsx +129 -0
- package/src/components/index.ts +1 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +4 -5
- package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
- package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
- package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
- package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
- package/src/flow/actions/__tests__/pattern.test.ts +190 -0
- package/src/flow/actions/dateRangeLimit.tsx +66 -0
- package/src/flow/actions/index.ts +3 -0
- package/src/flow/actions/linkageRules.tsx +194 -42
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
- package/src/flow/actions/openView.tsx +2 -1
- package/src/flow/actions/pattern.tsx +25 -2
- package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
- package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
- package/src/flow/components/AdminLayout.tsx +2 -2
- package/src/flow/components/FlowRoute.tsx +17 -4
- package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
- package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
- package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +34 -3
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -1
- package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
- package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
- package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
- package/src/flow/system-settings/useSystemSettings.tsx +36 -1
|
@@ -0,0 +1,152 @@
|
|
|
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 { autorun } from '@formily/reactive';
|
|
11
|
+
import { Form } from 'antd';
|
|
12
|
+
import { useFlowContext } from '@nocobase/flow-engine';
|
|
13
|
+
import { dayjs } from '@nocobase/utils/client';
|
|
14
|
+
import { first, last } from 'lodash';
|
|
15
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
type DateLimitProps = {
|
|
18
|
+
_minDate?: any;
|
|
19
|
+
_maxDate?: any;
|
|
20
|
+
currentForm?: any;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function useDateLimit(props: DateLimitProps) {
|
|
24
|
+
const ctx = useFlowContext();
|
|
25
|
+
const isAntdFormInstance = typeof props.currentForm?.getFieldsValue === 'function';
|
|
26
|
+
const currentFormValues = Form.useWatch([], isAntdFormInstance ? props.currentForm : undefined);
|
|
27
|
+
const [minDate, setMinDate] = useState<dayjs.Dayjs | null>(null);
|
|
28
|
+
const [maxDate, setMaxDate] = useState<dayjs.Dayjs | null>(null);
|
|
29
|
+
const [disabledDate, setDisabledDate] = useState<any>(null);
|
|
30
|
+
const [disabledTime, setDisabledTime] = useState<any>(null);
|
|
31
|
+
const disposeRef = useRef<any>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (disposeRef.current) {
|
|
35
|
+
disposeRef.current();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
disposeRef.current = autorun(() => {
|
|
39
|
+
void limitDate();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
disposeRef.current?.();
|
|
44
|
+
};
|
|
45
|
+
}, [ctx, currentFormValues, props._maxDate, props._minDate]);
|
|
46
|
+
|
|
47
|
+
const limitDate = async () => {
|
|
48
|
+
const resolvedParams = await ctx.resolveJsonTemplate({
|
|
49
|
+
_minDate: props._minDate,
|
|
50
|
+
_maxDate: props._maxDate,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const nextMinRaw = Array.isArray(resolvedParams?._minDate)
|
|
54
|
+
? first(resolvedParams._minDate)
|
|
55
|
+
: resolvedParams?._minDate;
|
|
56
|
+
const nextMaxRaw = Array.isArray(resolvedParams?._maxDate)
|
|
57
|
+
? last(resolvedParams._maxDate)
|
|
58
|
+
: resolvedParams?._maxDate;
|
|
59
|
+
const nextMinDate = nextMinRaw ? dayjs(nextMinRaw) : null;
|
|
60
|
+
const nextMaxDate = nextMaxRaw ? dayjs(nextMaxRaw) : null;
|
|
61
|
+
|
|
62
|
+
setMinDate(nextMinDate?.isValid?.() ? nextMinDate : null);
|
|
63
|
+
setMaxDate(nextMaxDate?.isValid?.() ? nextMaxDate : null);
|
|
64
|
+
|
|
65
|
+
const fullTimeArr = Array.from({ length: 60 }, (_, i) => i);
|
|
66
|
+
|
|
67
|
+
const nextDisabledDate = (current: dayjs.Dayjs) => {
|
|
68
|
+
if (!dayjs.isDayjs(current)) return false;
|
|
69
|
+
|
|
70
|
+
const min = nextMinDate?.isValid?.() ? nextMinDate.startOf('day') : null;
|
|
71
|
+
const max = nextMaxDate?.isValid?.() ? nextMaxDate.endOf('day') : null;
|
|
72
|
+
|
|
73
|
+
if (min && current.startOf('day').isBefore(min)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (max && current.startOf('day').isAfter(max)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const nextDisabledTime = (current: dayjs.Dayjs) => {
|
|
83
|
+
if (!current || (!nextMinDate?.isValid?.() && !nextMaxDate?.isValid?.())) {
|
|
84
|
+
return { disabledHours: () => [], disabledMinutes: () => [], disabledSeconds: () => [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isCurrentMinDay = !!nextMinDate?.isValid?.() && current.isSame(nextMinDate, 'day');
|
|
88
|
+
const isCurrentMaxDay = !!nextMaxDate?.isValid?.() && current.isSame(nextMaxDate, 'day');
|
|
89
|
+
|
|
90
|
+
const disabledHours = () => {
|
|
91
|
+
const hours = [];
|
|
92
|
+
if (isCurrentMinDay && nextMinDate) {
|
|
93
|
+
for (let hour = 0; hour < nextMinDate.hour(); hour++) {
|
|
94
|
+
hours.push(hour);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (isCurrentMaxDay && nextMaxDate) {
|
|
98
|
+
for (let hour = nextMaxDate.hour() + 1; hour < 24; hour++) {
|
|
99
|
+
hours.push(hour);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return hours;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const disabledMinutes = (selectedHour: number) => {
|
|
106
|
+
if (isCurrentMinDay && nextMinDate && selectedHour === nextMinDate.hour()) {
|
|
107
|
+
return fullTimeArr.filter((minute) => minute < nextMinDate.minute());
|
|
108
|
+
}
|
|
109
|
+
if (isCurrentMaxDay && nextMaxDate && selectedHour === nextMaxDate.hour()) {
|
|
110
|
+
return fullTimeArr.filter((minute) => minute > nextMaxDate.minute());
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const disabledSeconds = (selectedHour: number, selectedMinute: number) => {
|
|
116
|
+
if (
|
|
117
|
+
isCurrentMinDay &&
|
|
118
|
+
nextMinDate &&
|
|
119
|
+
selectedHour === nextMinDate.hour() &&
|
|
120
|
+
selectedMinute === nextMinDate.minute()
|
|
121
|
+
) {
|
|
122
|
+
return fullTimeArr.filter((second) => second < nextMinDate.second());
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
isCurrentMaxDay &&
|
|
126
|
+
nextMaxDate &&
|
|
127
|
+
selectedHour === nextMaxDate.hour() &&
|
|
128
|
+
selectedMinute === nextMaxDate.minute()
|
|
129
|
+
) {
|
|
130
|
+
return fullTimeArr.filter((second) => second > nextMaxDate.second());
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
disabledHours,
|
|
137
|
+
disabledMinutes,
|
|
138
|
+
disabledSeconds,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
setDisabledDate(() => nextDisabledDate);
|
|
143
|
+
setDisabledTime(() => nextDisabledTime);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
minDate,
|
|
148
|
+
maxDate,
|
|
149
|
+
disabledDate,
|
|
150
|
+
disabledTime,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -35,6 +35,10 @@ function JsEditableField() {
|
|
|
35
35
|
ctx.setValue?.(v);
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
if (ctx.readOnly) {
|
|
39
|
+
return <span>{String(value ?? '')}</span>;
|
|
40
|
+
}
|
|
41
|
+
|
|
38
42
|
return (
|
|
39
43
|
<Input
|
|
40
44
|
{...ctx.model.props}
|
|
@@ -48,6 +52,23 @@ function JsEditableField() {
|
|
|
48
52
|
ctx.render(<JsEditableField />);
|
|
49
53
|
`;
|
|
50
54
|
|
|
55
|
+
function getEffectivePattern(model?: JSEditableFieldModel) {
|
|
56
|
+
return (
|
|
57
|
+
model?.props?.pattern ??
|
|
58
|
+
(model?.context as { pattern?: string } | undefined)?.pattern ??
|
|
59
|
+
(model?.parent as { props?: { pattern?: string } } | undefined)?.props?.pattern
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isReadOnlyMode(model?: JSEditableFieldModel) {
|
|
64
|
+
return !!model?.props?.readOnly || getEffectivePattern(model) === 'readPretty';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveScriptCode(codeParam?: string) {
|
|
68
|
+
const raw = codeParam ?? DEFAULT_CODE;
|
|
69
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
const JSFormRuntime: React.FC<{
|
|
52
73
|
model: JSEditableFieldModel;
|
|
53
74
|
value?: any;
|
|
@@ -66,26 +87,27 @@ const JSFormRuntime: React.FC<{
|
|
|
66
87
|
// 统一获取&裁剪脚本代码,直接依赖具体 code 字符串,避免顶层 stepParams 引用未变化导致不更新
|
|
67
88
|
const codeParam = model.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
|
|
68
89
|
const scriptCode = useMemo(() => {
|
|
69
|
-
|
|
70
|
-
return typeof raw === 'string' ? raw.trim() : '';
|
|
90
|
+
return resolveScriptCode(codeParam);
|
|
71
91
|
}, [codeParam]);
|
|
72
92
|
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!containerRef.current || !scriptCode) return;
|
|
95
|
+
model.scheduleApplyJsSettings();
|
|
96
|
+
}, [model, scriptCode]);
|
|
97
|
+
|
|
73
98
|
useEffect(() => {
|
|
74
99
|
if (!containerRef.current || !scriptCode) return;
|
|
75
100
|
const event = new CustomEvent('js-field:value-change', { detail: value });
|
|
76
101
|
containerRef.current.dispatchEvent(event);
|
|
77
102
|
}, [value, scriptCode]);
|
|
78
103
|
|
|
79
|
-
|
|
104
|
+
if (readOnly && !scriptCode) {
|
|
105
|
+
return <span>{String(value ?? '')}</span>;
|
|
106
|
+
}
|
|
107
|
+
|
|
80
108
|
if (!scriptCode) {
|
|
81
109
|
return (
|
|
82
|
-
<Input
|
|
83
|
-
value={value}
|
|
84
|
-
onChange={(e) => onChange?.(e.target.value)}
|
|
85
|
-
disabled={disabled}
|
|
86
|
-
readOnly={readOnly}
|
|
87
|
-
style={{ width: '100%' }}
|
|
88
|
-
/>
|
|
110
|
+
<Input value={value} onChange={(e) => onChange?.(e.target.value)} disabled={disabled} style={{ width: '100%' }} />
|
|
89
111
|
);
|
|
90
112
|
}
|
|
91
113
|
|
|
@@ -100,14 +122,77 @@ const JSFormRuntime: React.FC<{
|
|
|
100
122
|
*/
|
|
101
123
|
export class JSEditableFieldModel extends FieldModel {
|
|
102
124
|
private _mountedOnce = false;
|
|
125
|
+
private _pendingJsSettingsApply = false;
|
|
126
|
+
private _lastAppliedJsSettings?: {
|
|
127
|
+
code: string;
|
|
128
|
+
disabled: boolean;
|
|
129
|
+
readOnly: boolean;
|
|
130
|
+
element: HTMLSpanElement | null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
scheduleApplyJsSettings() {
|
|
134
|
+
const codeParam = this.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
|
|
135
|
+
if (!resolveScriptCode(codeParam)) return;
|
|
136
|
+
|
|
137
|
+
if (this._pendingJsSettingsApply) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this._pendingJsSettingsApply = true;
|
|
142
|
+
queueMicrotask(() => {
|
|
143
|
+
this._pendingJsSettingsApply = false;
|
|
144
|
+
|
|
145
|
+
const nextCodeParam = this.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
|
|
146
|
+
const nextCode = resolveScriptCode(nextCodeParam);
|
|
147
|
+
const nextElement = this.context.ref?.current as HTMLSpanElement | null;
|
|
148
|
+
if (!nextCode || !nextElement) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const nextRun = {
|
|
153
|
+
code: nextCode,
|
|
154
|
+
disabled: !!this.props?.disabled,
|
|
155
|
+
readOnly: isReadOnlyMode(this),
|
|
156
|
+
element: nextElement,
|
|
157
|
+
};
|
|
158
|
+
const lastRun = this._lastAppliedJsSettings;
|
|
159
|
+
if (
|
|
160
|
+
lastRun &&
|
|
161
|
+
lastRun.code === nextRun.code &&
|
|
162
|
+
lastRun.disabled === nextRun.disabled &&
|
|
163
|
+
lastRun.readOnly === nextRun.readOnly &&
|
|
164
|
+
lastRun.element === nextRun.element
|
|
165
|
+
) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this._lastAppliedJsSettings = nextRun;
|
|
170
|
+
void this.applyFlow('jsSettings');
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
useHooksBeforeRender() {
|
|
175
|
+
const codeParam = this.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
|
|
176
|
+
const scriptCode = resolveScriptCode(codeParam);
|
|
177
|
+
const disabled = this.props?.disabled;
|
|
178
|
+
|
|
179
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!scriptCode) return;
|
|
182
|
+
this.scheduleApplyJsSettings();
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
}, [scriptCode, disabled]);
|
|
185
|
+
}
|
|
186
|
+
|
|
103
187
|
render() {
|
|
188
|
+
const readOnly = isReadOnlyMode(this);
|
|
104
189
|
return (
|
|
105
190
|
<JSFormRuntime
|
|
106
191
|
model={this as JSEditableFieldModel}
|
|
107
192
|
value={this.props?.value}
|
|
108
193
|
onChange={this.props?.onChange}
|
|
109
194
|
disabled={this.props?.disabled}
|
|
110
|
-
readOnly={
|
|
195
|
+
readOnly={readOnly}
|
|
111
196
|
/>
|
|
112
197
|
);
|
|
113
198
|
}
|
|
@@ -128,13 +213,17 @@ export class JSEditableFieldModel extends FieldModel {
|
|
|
128
213
|
}
|
|
129
214
|
}
|
|
130
215
|
|
|
131
|
-
|
|
216
|
+
const jsEditableFieldModelMeta = {
|
|
132
217
|
label: tExpr('JS field'),
|
|
133
|
-
|
|
218
|
+
preserveOnPatternChange: true,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
JSEditableFieldModel.define(jsEditableFieldModelMeta);
|
|
134
222
|
|
|
135
223
|
JSEditableFieldModel.registerFlow({
|
|
136
224
|
key: 'jsSettings',
|
|
137
225
|
title: tExpr('JavaScript settings'),
|
|
226
|
+
manual: true,
|
|
138
227
|
steps: {
|
|
139
228
|
runJs: {
|
|
140
229
|
title: tExpr('Write JavaScript'),
|
|
@@ -174,6 +263,10 @@ JSEditableFieldModel.registerFlow({
|
|
|
174
263
|
},
|
|
175
264
|
async handler(ctx, params) {
|
|
176
265
|
const { code, version } = resolveRunJsParams(ctx, params);
|
|
266
|
+
if (!code?.trim()) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
177
270
|
ctx.onRefReady(ctx.ref, async (element) => {
|
|
178
271
|
// 暴露容器与读写能力(使用动态 getter 绑定 ref.current,避免容器变更失效)
|
|
179
272
|
ctx.defineProperty('element', {
|
|
@@ -201,7 +294,10 @@ JSEditableFieldModel.registerFlow({
|
|
|
201
294
|
});
|
|
202
295
|
ctx.defineProperty('namePath', { get: () => ctx.model.props?.name, cache: false });
|
|
203
296
|
ctx.defineProperty('disabled', { get: () => !!ctx.model.props?.disabled, cache: false });
|
|
204
|
-
ctx.defineProperty('readOnly', {
|
|
297
|
+
ctx.defineProperty('readOnly', {
|
|
298
|
+
get: () => isReadOnlyMode(ctx.model),
|
|
299
|
+
cache: false,
|
|
300
|
+
});
|
|
205
301
|
const navigator = createSafeNavigator();
|
|
206
302
|
await ctx.runjs(
|
|
207
303
|
code,
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { FlowEngine } from '@nocobase/flow-engine';
|
|
12
|
+
import { ClickableFieldModel } from '../ClickableFieldModel';
|
|
13
|
+
|
|
14
|
+
function createRolesFieldModel(sourceRecord: Record<string, any>) {
|
|
15
|
+
const engine = new FlowEngine();
|
|
16
|
+
engine.registerModels({ ClickableFieldModel });
|
|
17
|
+
|
|
18
|
+
const usersCollection = {
|
|
19
|
+
name: 'users',
|
|
20
|
+
filterTargetKey: 'id',
|
|
21
|
+
};
|
|
22
|
+
const rolesCollection = {
|
|
23
|
+
name: 'roles',
|
|
24
|
+
filterTargetKey: 'name',
|
|
25
|
+
};
|
|
26
|
+
const rolesField = {
|
|
27
|
+
name: 'roles',
|
|
28
|
+
target: 'roles',
|
|
29
|
+
targetKey: 'name',
|
|
30
|
+
type: 'belongsToMany',
|
|
31
|
+
interface: 'm2m',
|
|
32
|
+
collection: usersCollection,
|
|
33
|
+
targetCollection: rolesCollection,
|
|
34
|
+
isAssociationField: () => true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const model = engine.createModel<ClickableFieldModel>({
|
|
38
|
+
use: ClickableFieldModel,
|
|
39
|
+
uid: `clickable-roles-${sourceRecord?.id ?? 'new'}`,
|
|
40
|
+
});
|
|
41
|
+
model.context.defineProperty('collectionField', { value: rolesField });
|
|
42
|
+
model.context.defineProperty('blockModel', { value: { collection: usersCollection } });
|
|
43
|
+
model.context.defineProperty('record', { value: sourceRecord });
|
|
44
|
+
|
|
45
|
+
const dispatchEvent = vi.spyOn(model, 'dispatchEvent').mockResolvedValue([]);
|
|
46
|
+
return { model, dispatchEvent };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('ClickableFieldModel', () => {
|
|
50
|
+
it('opens an association display value as a normal target record when source record has no id', () => {
|
|
51
|
+
const { model, dispatchEvent } = createRolesFieldModel({});
|
|
52
|
+
const event = { type: 'click' };
|
|
53
|
+
|
|
54
|
+
model.onClick(event, { name: 'admin', title: 'Admin' });
|
|
55
|
+
|
|
56
|
+
expect(dispatchEvent).toHaveBeenCalledWith(
|
|
57
|
+
'click',
|
|
58
|
+
{
|
|
59
|
+
event,
|
|
60
|
+
filterByTk: 'admin',
|
|
61
|
+
collectionName: 'roles',
|
|
62
|
+
associationName: null,
|
|
63
|
+
sourceId: null,
|
|
64
|
+
},
|
|
65
|
+
{ debounce: true },
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('keeps using the association resource when the source record has an id', () => {
|
|
70
|
+
const { model, dispatchEvent } = createRolesFieldModel({ id: 1 });
|
|
71
|
+
const event = { type: 'click' };
|
|
72
|
+
|
|
73
|
+
model.onClick(event, { name: 'admin', title: 'Admin' });
|
|
74
|
+
|
|
75
|
+
expect(dispatchEvent).toHaveBeenCalledWith(
|
|
76
|
+
'click',
|
|
77
|
+
{
|
|
78
|
+
event,
|
|
79
|
+
filterByTk: 'admin',
|
|
80
|
+
collectionName: 'users',
|
|
81
|
+
associationName: 'users.roles',
|
|
82
|
+
sourceId: 1,
|
|
83
|
+
},
|
|
84
|
+
{ debounce: true },
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
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 { act, render, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelRenderer } from '@nocobase/flow-engine';
|
|
14
|
+
import { JSEditableFieldModel } from '../JSEditableFieldModel';
|
|
15
|
+
|
|
16
|
+
function createField(props?: Record<string, any>, code = '') {
|
|
17
|
+
const engine = new FlowEngine();
|
|
18
|
+
engine.registerModels({ JSEditableFieldModel });
|
|
19
|
+
return engine.createModel<JSEditableFieldModel>({
|
|
20
|
+
use: 'JSEditableFieldModel',
|
|
21
|
+
uid: `js-field-${props?.pattern || 'editable'}`,
|
|
22
|
+
props,
|
|
23
|
+
stepParams: {
|
|
24
|
+
jsSettings: {
|
|
25
|
+
runJs: {
|
|
26
|
+
code,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderField(props?: Record<string, any>, code?: string) {
|
|
34
|
+
const field = createField(props, code);
|
|
35
|
+
|
|
36
|
+
render(<>{field.render()}</>);
|
|
37
|
+
|
|
38
|
+
return field;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const EDITABLE_CODE = `
|
|
42
|
+
function JsEditableField() {
|
|
43
|
+
const React = ctx.React;
|
|
44
|
+
const { Input } = ctx.antd;
|
|
45
|
+
const [value, setValue] = React.useState(ctx.getValue?.() ?? '');
|
|
46
|
+
|
|
47
|
+
React.useEffect(() => {
|
|
48
|
+
const handler = (ev) => setValue(ev?.detail ?? '');
|
|
49
|
+
ctx.element?.addEventListener('js-field:value-change', handler);
|
|
50
|
+
return () => ctx.element?.removeEventListener('js-field:value-change', handler);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const onChange = (e) => {
|
|
54
|
+
const v = e?.target?.value ?? '';
|
|
55
|
+
setValue(v);
|
|
56
|
+
ctx.setValue?.(v);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Input
|
|
61
|
+
{...ctx.model.props}
|
|
62
|
+
value={value}
|
|
63
|
+
onChange={onChange}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ctx.render(<JsEditableField />);
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const READONLY_AWARE_CODE = `
|
|
72
|
+
const React = ctx.React;
|
|
73
|
+
ctx.render(<span data-testid="js-readonly-state">{String(ctx.readOnly)}</span>);
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
class ParentModel extends FlowModel<any> {
|
|
77
|
+
render() {
|
|
78
|
+
return <FlowModelRenderer model={this.subModels.field} />;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderParentFieldWithFlowRenderer(
|
|
83
|
+
fieldProps?: Record<string, any>,
|
|
84
|
+
parentProps?: Record<string, any>,
|
|
85
|
+
code = EDITABLE_CODE,
|
|
86
|
+
) {
|
|
87
|
+
const engine = new FlowEngine();
|
|
88
|
+
engine.registerModels({ JSEditableFieldModel, ParentModel });
|
|
89
|
+
const parent = engine.createModel<ParentModel>({
|
|
90
|
+
use: ParentModel,
|
|
91
|
+
uid: 'js-field-parent',
|
|
92
|
+
props: parentProps,
|
|
93
|
+
subModels: {
|
|
94
|
+
field: {
|
|
95
|
+
use: 'JSEditableFieldModel',
|
|
96
|
+
uid: 'js-field-with-parent',
|
|
97
|
+
props: fieldProps,
|
|
98
|
+
stepParams: {
|
|
99
|
+
jsSettings: {
|
|
100
|
+
runJs: {
|
|
101
|
+
code,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
render(
|
|
110
|
+
<FlowEngineProvider engine={engine}>
|
|
111
|
+
<FlowModelRenderer model={parent} />
|
|
112
|
+
</FlowEngineProvider>,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return parent;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('JSEditableFieldModel', () => {
|
|
119
|
+
it('renders configured JavaScript in display only mode', async () => {
|
|
120
|
+
const field = renderParentFieldWithFlowRenderer(
|
|
121
|
+
{ pattern: 'readPretty', value: 'hello' },
|
|
122
|
+
undefined,
|
|
123
|
+
READONLY_AWARE_CODE,
|
|
124
|
+
).subModels.field as JSEditableFieldModel;
|
|
125
|
+
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(screen.getByTestId('js-readonly-state')).toHaveTextContent('true');
|
|
128
|
+
expect(field.context.ref.current).toBeInstanceOf(HTMLSpanElement);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders fallback as text in display only mode when code is empty', () => {
|
|
133
|
+
renderField({ pattern: 'readPretty', value: 'hello' });
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText('hello')).toBeInTheDocument();
|
|
136
|
+
expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('renders fallback input for editable mode', () => {
|
|
140
|
+
renderField({ value: 'hello' });
|
|
141
|
+
|
|
142
|
+
expect(screen.getByDisplayValue('hello')).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('rerenders as text when owner form item switches to display only', async () => {
|
|
146
|
+
const parent = renderParentFieldWithFlowRenderer({ value: 'hello' }, undefined, '');
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
parent.setProps({ pattern: 'readPretty' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(screen.getByText('hello')).toBeInTheDocument();
|
|
154
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('does not rerun JavaScript settings when field value changes', async () => {
|
|
159
|
+
const parent = renderParentFieldWithFlowRenderer({ value: 'hello' });
|
|
160
|
+
const field = parent.subModels.field as JSEditableFieldModel;
|
|
161
|
+
const applyFlowSpy = vi.spyOn(field, 'applyFlow');
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
applyFlowSpy.mockClear();
|
|
168
|
+
|
|
169
|
+
await act(async () => {
|
|
170
|
+
field.setProps({ value: 'hello1' });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(applyFlowSpy).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('coalesces script and pattern changes into one JavaScript settings run', async () => {
|
|
177
|
+
const parent = renderParentFieldWithFlowRenderer({ value: 'hello' }, undefined, '');
|
|
178
|
+
const field = parent.subModels.field as JSEditableFieldModel;
|
|
179
|
+
const applyFlowSpy = vi.spyOn(field, 'applyFlow');
|
|
180
|
+
|
|
181
|
+
await act(async () => {
|
|
182
|
+
field.setStepParams('jsSettings', 'runJs', { code: READONLY_AWARE_CODE });
|
|
183
|
+
parent.setProps({ pattern: 'readPretty' });
|
|
184
|
+
field.scheduleApplyJsSettings();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(screen.getByTestId('js-readonly-state')).toHaveTextContent('true');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(applyFlowSpy).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(applyFlowSpy).toHaveBeenCalledWith('jsSettings');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('applies JavaScript settings once on initial render', async () => {
|
|
196
|
+
const applyFlowSpy = vi.spyOn(FlowModel.prototype, 'applyFlow');
|
|
197
|
+
renderParentFieldWithFlowRenderer({ value: 'hello' });
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(applyFlowSpy).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(applyFlowSpy.mock.calls).toEqual([['jsSettings']]);
|
|
206
|
+
} finally {
|
|
207
|
+
applyFlowSpy.mockRestore();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { useFlowEngine } from '@nocobase/flow-engine';
|
|
10
11
|
import { useApp } from '../../flow-compat';
|
|
11
|
-
import
|
|
12
|
+
import languageCodes from '../../locale/languageCodes';
|
|
13
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* 读取系统设置并兼容旧 hook 的返回结构。
|
|
@@ -25,7 +27,19 @@ import { useEffect, useState } from 'react';
|
|
|
25
27
|
export const useSystemSettings = () => {
|
|
26
28
|
const app = useApp();
|
|
27
29
|
const source = app.systemSettings;
|
|
30
|
+
const flowEngine = useFlowEngine({ throwError: false });
|
|
28
31
|
const [, forceUpdate] = useState(0);
|
|
32
|
+
const enabledLanguages = source.data?.data?.enabledLanguages;
|
|
33
|
+
const languageOptions = useMemo(
|
|
34
|
+
() =>
|
|
35
|
+
(Array.isArray(enabledLanguages) ? enabledLanguages : [])
|
|
36
|
+
.filter((code) => languageCodes[code])
|
|
37
|
+
.map((code) => ({
|
|
38
|
+
label: languageCodes[code].label,
|
|
39
|
+
value: code,
|
|
40
|
+
})),
|
|
41
|
+
[enabledLanguages],
|
|
42
|
+
);
|
|
29
43
|
|
|
30
44
|
useEffect(() => {
|
|
31
45
|
void source.load();
|
|
@@ -34,6 +48,27 @@ export const useSystemSettings = () => {
|
|
|
34
48
|
});
|
|
35
49
|
}, [source]);
|
|
36
50
|
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!flowEngine) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
flowEngine.context.defineProperty('locale', {
|
|
57
|
+
get: (ctx) => ctx.api?.auth?.locale || ctx.i18n?.language,
|
|
58
|
+
cache: false,
|
|
59
|
+
meta: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
title: '{{t("Current language")}}',
|
|
62
|
+
sort: 970,
|
|
63
|
+
interface: 'select',
|
|
64
|
+
uiSchema: {
|
|
65
|
+
enum: languageOptions,
|
|
66
|
+
'x-component': 'Select',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}, [flowEngine, languageOptions]);
|
|
71
|
+
|
|
37
72
|
return {
|
|
38
73
|
loading: source.loading,
|
|
39
74
|
data: source.data,
|