@nocobase/client-v2 2.1.0-alpha.31 → 2.1.0-alpha.32

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 (45) 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 +1 -0
  5. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  6. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  7. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  8. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +2 -0
  9. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  10. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  11. package/es/index.mjs +72 -65
  12. package/lib/index.js +61 -54
  13. package/package.json +6 -5
  14. package/src/components/form/JsonTextArea.tsx +129 -0
  15. package/src/components/index.ts +1 -0
  16. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  17. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  18. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  19. package/src/flow/actions/index.ts +1 -0
  20. package/src/flow/actions/linkageRules.tsx +117 -19
  21. package/src/flow/actions/openView.tsx +2 -1
  22. package/src/flow/actions/pattern.tsx +25 -2
  23. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  24. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  25. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  26. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  27. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  28. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  29. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  30. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  31. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +27 -3
  32. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  33. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  34. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  35. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
  36. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  37. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  38. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  39. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  40. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  41. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  42. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  43. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  44. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  45. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
@@ -0,0 +1,242 @@
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 { Form } from 'antd';
12
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { render, waitFor } from '@nocobase/test/client';
14
+ import { dayjs } from '@nocobase/utils/client';
15
+ import { DateTimeNoTzPicker } from '../DateTimeNoTzFieldModel';
16
+
17
+ let capturedDatePickerProps: any;
18
+ let currentForm: any;
19
+ const mockResolveJsonTemplate = vi.fn();
20
+
21
+ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
23
+ return {
24
+ ...actual,
25
+ useFlowModelContext: () => ({
26
+ isMobileLayout: false,
27
+ model: {
28
+ context: {
29
+ form: currentForm,
30
+ },
31
+ },
32
+ }),
33
+ useFlowContext: () => ({
34
+ resolveJsonTemplate: mockResolveJsonTemplate,
35
+ }),
36
+ };
37
+ });
38
+
39
+ vi.mock('antd', async (importOriginal) => {
40
+ const actual = await importOriginal<typeof import('antd')>();
41
+ return {
42
+ ...actual,
43
+ DatePicker: (props: any) => {
44
+ capturedDatePickerProps = props;
45
+ return <div data-testid="date-picker" />;
46
+ },
47
+ Form: actual.Form,
48
+ };
49
+ });
50
+
51
+ const TestWrapper = (props: any) => {
52
+ const [form] = Form.useForm();
53
+ currentForm = form;
54
+
55
+ return (
56
+ <Form form={form} initialValues={{ b: '2026-05-10 12:34:56' }}>
57
+ <DateTimeNoTzPicker {...props} />
58
+ </Form>
59
+ );
60
+ };
61
+
62
+ describe('DateTimeNoTzPicker date range limit', () => {
63
+ beforeEach(() => {
64
+ currentForm = undefined;
65
+ capturedDatePickerProps = undefined;
66
+ mockResolveJsonTemplate.mockReset();
67
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
68
+ ...params,
69
+ _maxDate: currentForm?.getFieldValue?.('b'),
70
+ }));
71
+ });
72
+
73
+ it('applies maxDate from current form field b', async () => {
74
+ render(<TestWrapper picker="date" showTime _maxDate={'{{ $nForm.b }}'} onChange={vi.fn()} value={null} />);
75
+
76
+ await waitFor(() => {
77
+ expect(mockResolveJsonTemplate).toHaveBeenCalledWith({
78
+ _minDate: undefined,
79
+ _maxDate: '{{ $nForm.b }}',
80
+ });
81
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(true);
82
+ });
83
+
84
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-10 00:00:00'));
85
+ expect(timeConfig?.disabledHours?.()).toContain(13);
86
+ expect(timeConfig?.disabledHours?.()).not.toContain(12);
87
+ expect(timeConfig?.disabledMinutes?.(12)).toContain(35);
88
+ expect(timeConfig?.disabledMinutes?.(12)).not.toContain(34);
89
+ expect(timeConfig?.disabledSeconds?.(12, 34)).toContain(57);
90
+ expect(timeConfig?.disabledSeconds?.(12, 34)).not.toContain(56);
91
+ });
92
+
93
+ it('updates field a maxDate immediately when field b changes', async () => {
94
+ render(<TestWrapper picker="date" showTime _maxDate={'{{ $nForm.b }}'} onChange={vi.fn()} value={null} />);
95
+
96
+ await waitFor(() => {
97
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(true);
98
+ });
99
+
100
+ currentForm.setFieldValue('b', '2026-05-12 08:09:10');
101
+
102
+ await waitFor(() => {
103
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(false);
104
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-13 00:00:00'))).toBe(true);
105
+ });
106
+
107
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-12 00:00:00'));
108
+ expect(timeConfig?.disabledHours?.()).toContain(9);
109
+ expect(timeConfig?.disabledHours?.()).not.toContain(8);
110
+ expect(timeConfig?.disabledMinutes?.(8)).toContain(10);
111
+ expect(timeConfig?.disabledMinutes?.(8)).not.toContain(9);
112
+ expect(timeConfig?.disabledSeconds?.(8, 9)).toContain(11);
113
+ expect(timeConfig?.disabledSeconds?.(8, 9)).not.toContain(10);
114
+ });
115
+
116
+ it('applies minDate from current form field b', async () => {
117
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
118
+ ...params,
119
+ _minDate: currentForm?.getFieldValue?.('b'),
120
+ _maxDate: undefined,
121
+ }));
122
+
123
+ render(<TestWrapper picker="date" showTime _minDate={'{{ $nForm.b }}'} onChange={vi.fn()} value={null} />);
124
+
125
+ await waitFor(() => {
126
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
127
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-10 00:00:00'))).toBe(false);
128
+ });
129
+
130
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-10 00:00:00'));
131
+ expect(timeConfig?.disabledHours?.()).toContain(11);
132
+ expect(timeConfig?.disabledHours?.()).not.toContain(12);
133
+ expect(timeConfig?.disabledMinutes?.(12)).toContain(33);
134
+ expect(timeConfig?.disabledMinutes?.(12)).not.toContain(34);
135
+ expect(timeConfig?.disabledSeconds?.(12, 34)).toContain(55);
136
+ expect(timeConfig?.disabledSeconds?.(12, 34)).not.toContain(56);
137
+ });
138
+
139
+ it('applies both minDate and maxDate together', async () => {
140
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
141
+ ...params,
142
+ _minDate: '2026-05-10 08:00:00',
143
+ _maxDate: '2026-05-12 18:30:40',
144
+ }));
145
+
146
+ render(
147
+ <TestWrapper
148
+ picker="date"
149
+ showTime
150
+ _minDate={'{{ $nForm.min }}'}
151
+ _maxDate={'{{ $nForm.max }}'}
152
+ onChange={vi.fn()}
153
+ value={null}
154
+ />,
155
+ );
156
+
157
+ await waitFor(() => {
158
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
159
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-13 00:00:00'))).toBe(true);
160
+ });
161
+
162
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(false);
163
+
164
+ const minDayTimeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-10 00:00:00'));
165
+ expect(minDayTimeConfig?.disabledHours?.()).toContain(7);
166
+ expect(minDayTimeConfig?.disabledHours?.()).not.toContain(8);
167
+
168
+ const maxDayTimeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-12 00:00:00'));
169
+ expect(maxDayTimeConfig?.disabledHours?.()).toContain(19);
170
+ expect(maxDayTimeConfig?.disabledHours?.()).not.toContain(18);
171
+ expect(maxDayTimeConfig?.disabledMinutes?.(18)).toContain(31);
172
+ expect(maxDayTimeConfig?.disabledMinutes?.(18)).not.toContain(30);
173
+ expect(maxDayTimeConfig?.disabledSeconds?.(18, 30)).toContain(41);
174
+ expect(maxDayTimeConfig?.disabledSeconds?.(18, 30)).not.toContain(40);
175
+ });
176
+
177
+ it('uses first minDate and last maxDate when resolved values are arrays', async () => {
178
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
179
+ ...params,
180
+ _minDate: ['2026-05-10 08:00:00', '2026-05-11 09:00:00'],
181
+ _maxDate: ['2026-05-12 10:00:00', '2026-05-13 11:12:13'],
182
+ }));
183
+
184
+ render(
185
+ <TestWrapper
186
+ picker="date"
187
+ showTime
188
+ _minDate={'{{ $nForm.min }}'}
189
+ _maxDate={'{{ $nForm.max }}'}
190
+ onChange={vi.fn()}
191
+ value={null}
192
+ />,
193
+ );
194
+
195
+ await waitFor(() => {
196
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
197
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-14 00:00:00'))).toBe(true);
198
+ });
199
+
200
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-10 00:00:00'))).toBe(false);
201
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-13 00:00:00'))).toBe(false);
202
+
203
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-13 00:00:00'));
204
+ expect(timeConfig?.disabledHours?.()).toContain(12);
205
+ expect(timeConfig?.disabledHours?.()).not.toContain(11);
206
+ expect(timeConfig?.disabledMinutes?.(11)).toContain(13);
207
+ expect(timeConfig?.disabledMinutes?.(11)).not.toContain(12);
208
+ expect(timeConfig?.disabledSeconds?.(11, 12)).toContain(14);
209
+ expect(timeConfig?.disabledSeconds?.(11, 12)).not.toContain(13);
210
+ });
211
+
212
+ it('clears date and time restrictions when resolved values are empty', async () => {
213
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
214
+ ...params,
215
+ _minDate: undefined,
216
+ _maxDate: undefined,
217
+ }));
218
+
219
+ render(
220
+ <TestWrapper
221
+ picker="date"
222
+ showTime
223
+ _minDate={'{{ $nForm.min }}'}
224
+ _maxDate={'{{ $nForm.max }}'}
225
+ onChange={vi.fn()}
226
+ value={null}
227
+ />,
228
+ );
229
+
230
+ await waitFor(() => {
231
+ expect(capturedDatePickerProps).toBeTruthy();
232
+ });
233
+
234
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-01 00:00:00'))).toBe(false);
235
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-31 00:00:00'))).toBe(false);
236
+
237
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-15 00:00:00'));
238
+ expect(timeConfig?.disabledHours?.()).toEqual([]);
239
+ expect(timeConfig?.disabledMinutes?.(12)).toEqual([]);
240
+ expect(timeConfig?.disabledSeconds?.(12, 34)).toEqual([]);
241
+ });
242
+ });
@@ -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
+ });