@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.
Files changed (68) hide show
  1. package/es/components/form/JsonTextArea.d.ts +18 -0
  2. package/es/components/index.d.ts +1 -0
  3. package/es/flow/actions/dateRangeLimit.d.ts +9 -0
  4. package/es/flow/actions/index.d.ts +2 -1
  5. package/es/flow/actions/linkageRules.d.ts +2 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
  8. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
  9. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  10. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  11. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  12. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +5 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  14. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  15. package/es/index.mjs +79 -67
  16. package/lib/index.js +80 -68
  17. package/package.json +6 -5
  18. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
  19. package/src/__tests__/settings-center.test.tsx +30 -0
  20. package/src/components/form/JsonTextArea.tsx +129 -0
  21. package/src/components/index.ts +1 -0
  22. package/src/flow/__tests__/FlowRoute.test.tsx +4 -5
  23. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
  24. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  25. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
  26. package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
  27. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  28. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  29. package/src/flow/actions/index.ts +3 -0
  30. package/src/flow/actions/linkageRules.tsx +194 -42
  31. package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
  32. package/src/flow/actions/openView.tsx +2 -1
  33. package/src/flow/actions/pattern.tsx +25 -2
  34. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  35. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  36. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
  37. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
  38. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
  39. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
  40. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
  41. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
  42. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
  43. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
  44. package/src/flow/components/AdminLayout.tsx +2 -2
  45. package/src/flow/components/FlowRoute.tsx +17 -4
  46. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  47. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  48. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  49. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  50. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  51. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  52. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
  53. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +34 -3
  54. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  55. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  56. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  57. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -1
  58. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  59. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  60. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  61. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  62. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  63. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  64. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  65. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  66. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  67. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
  68. 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
- const raw = codeParam ?? DEFAULT_CODE;
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
- // 无自定义 JS 时默认渲染 Input,保持可用性
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={this.props?.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
- JSEditableFieldModel.define({
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', { get: () => !!ctx.model.props?.readOnly, cache: false });
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 { useEffect, useState } from 'react';
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,