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

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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-alpha.31",
3
+ "version": "2.1.0-alpha.33",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -24,17 +24,18 @@
24
24
  "@formily/antd-v5": "1.2.3",
25
25
  "@formily/react": "^2.2.27",
26
26
  "@formily/shared": "^2.2.27",
27
- "@nocobase/flow-engine": "2.1.0-alpha.31",
28
- "@nocobase/sdk": "2.1.0-alpha.31",
29
- "@nocobase/shared": "2.1.0-alpha.31",
27
+ "@nocobase/flow-engine": "2.1.0-alpha.33",
28
+ "@nocobase/sdk": "2.1.0-alpha.33",
29
+ "@nocobase/shared": "2.1.0-alpha.33",
30
30
  "ahooks": "^3.7.2",
31
31
  "antd": "5.24.2",
32
32
  "classnames": "^2.3.1",
33
33
  "dayjs": "^1.11.10",
34
34
  "i18next": "^22.4.9",
35
+ "json5": "^2.2.3",
35
36
  "lodash": "4.17.21",
36
37
  "react-i18next": "^11.15.1",
37
38
  "react-router-dom": "^6.30.1"
38
39
  },
39
- "gitHead": "e2bc6b461a9bfd336043069c3211c9c5b01ebcc3"
40
+ "gitHead": "64aaff11b9d9cab6890fb4835d7ae6933f2d3081"
40
41
  }
@@ -8,13 +8,29 @@
8
8
  */
9
9
 
10
10
  import { createMockClient } from '@nocobase/client-v2';
11
- import { render, waitFor } from '@testing-library/react';
11
+ import { render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
14
14
 
15
15
  describe('nocobase buildin plugin auth redirect', () => {
16
16
  const originalLocation = globalThis.window.location;
17
17
 
18
+ beforeEach(() => {
19
+ Object.defineProperty(globalThis.window, 'matchMedia', {
20
+ configurable: true,
21
+ value: vi.fn().mockImplementation((query: string) => ({
22
+ matches: false,
23
+ media: query,
24
+ onchange: null,
25
+ addListener: vi.fn(),
26
+ removeListener: vi.fn(),
27
+ addEventListener: vi.fn(),
28
+ removeEventListener: vi.fn(),
29
+ dispatchEvent: vi.fn(),
30
+ })),
31
+ });
32
+ });
33
+
18
34
  afterEach(() => {
19
35
  Object.defineProperty(globalThis.window, 'location', {
20
36
  configurable: true,
@@ -92,7 +108,7 @@ describe('nocobase buildin plugin auth redirect', () => {
92
108
  });
93
109
  });
94
110
 
95
- it('should redirect authenticated v2 admin root to legacy default page before layout render', async () => {
111
+ it('should render v2 admin root without redirecting to legacy default page', async () => {
96
112
  const replace = vi.fn();
97
113
  Object.defineProperty(globalThis.window, 'location', {
98
114
  configurable: true,
@@ -118,6 +134,7 @@ describe('nocobase buildin plugin auth redirect', () => {
118
134
  },
119
135
  });
120
136
  app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
137
+ app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
121
138
  app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, {
122
139
  data: [
123
140
  {
@@ -133,53 +150,57 @@ describe('nocobase buildin plugin auth redirect', () => {
133
150
  const { container } = render(<Root />);
134
151
 
135
152
  await waitFor(() => {
136
- expect(replace).toHaveBeenCalledWith('/admin/legacy-page');
153
+ expect(container.innerHTML).toContain('No pages yet, please configure first');
137
154
  });
155
+ expect(replace).not.toHaveBeenCalled();
138
156
  expect(container.innerHTML).not.toContain('Legacy page');
139
157
  });
140
158
 
141
- it('should redirect authenticated direct legacy v2 page access to v1 path', async () => {
142
- const replace = vi.fn();
143
- Object.defineProperty(globalThis.window, 'location', {
144
- configurable: true,
145
- value: {
146
- ...originalLocation,
147
- pathname: '/v2/admin/legacy-page/tab/tab-1',
148
- search: '?from=direct',
149
- hash: '#dialog',
150
- replace,
151
- },
152
- });
153
-
154
- const app = createMockClient({
155
- publicPath: '/v2/',
156
- plugins: [NocoBaseBuildInPlugin as any],
157
- router: { type: 'memory', initialEntries: ['/v2/admin/legacy-page/tab/tab-1'] },
158
- });
159
- app.apiMock.onGet('app:getLang').reply(200, {
160
- data: {
161
- lang: 'en-US',
162
- resources: { client: {} },
163
- cron: {},
164
- },
165
- });
166
- app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
167
- app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, {
168
- data: [
169
- {
170
- id: 1,
171
- title: 'Legacy page',
172
- schemaUid: 'legacy-page',
173
- type: 'page',
159
+ it.each(['/v2/admin/legacy-page/tab/tab-1', '/v2/admin/legacy-page/view/detail'])(
160
+ 'should show 404 for authenticated direct legacy v2 page access: %s',
161
+ async (pathname) => {
162
+ const replace = vi.fn();
163
+ Object.defineProperty(globalThis.window, 'location', {
164
+ configurable: true,
165
+ value: {
166
+ ...originalLocation,
167
+ pathname,
168
+ search: '?from=direct',
169
+ hash: '#dialog',
170
+ replace,
174
171
  },
175
- ],
176
- });
177
-
178
- const Root = app.getRootComponent();
179
- render(<Root />);
180
-
181
- await waitFor(() => {
182
- expect(replace).toHaveBeenCalledWith('/admin/legacy-page/tabs/tab-1?from=direct#dialog');
183
- });
184
- });
172
+ });
173
+
174
+ const app = createMockClient({
175
+ publicPath: '/v2/',
176
+ plugins: [NocoBaseBuildInPlugin as any],
177
+ router: { type: 'memory', initialEntries: [pathname] },
178
+ });
179
+ app.apiMock.onGet('app:getLang').reply(200, {
180
+ data: {
181
+ lang: 'en-US',
182
+ resources: { client: {} },
183
+ cron: {},
184
+ },
185
+ });
186
+ app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
187
+ app.apiMock.onGet('systemSettings:get').reply(200, { data: {} });
188
+ app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, {
189
+ data: [
190
+ {
191
+ id: 1,
192
+ title: 'Legacy page',
193
+ schemaUid: 'legacy-page',
194
+ type: 'page',
195
+ },
196
+ ],
197
+ });
198
+
199
+ const Root = app.getRootComponent();
200
+ render(<Root />);
201
+
202
+ expect(await screen.findByText('404')).toBeInTheDocument();
203
+ expect(replace).not.toHaveBeenCalled();
204
+ },
205
+ );
185
206
  });
@@ -158,6 +158,36 @@ describe('settings center', () => {
158
158
  expect(await screen.findByDisplayValue('NocoBase')).toBeInTheDocument();
159
159
  });
160
160
 
161
+ it('should expose current language variable as enabled-language selector', async () => {
162
+ const app = createMockClient({
163
+ plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
164
+ router: { type: 'memory', initialEntries: ['/admin/settings/system-settings'] },
165
+ });
166
+ mockAdminRuntime(app, {
167
+ systemSettings: {
168
+ enabledLanguages: ['en-US', 'zh-CN'],
169
+ },
170
+ });
171
+
172
+ await renderApp(app);
173
+ await waitForGetRequests(app, ['/auth:check', 'roles:check', 'systemSettings:get']);
174
+
175
+ await waitFor(() => {
176
+ const localeNode = app.flowEngine.context.getPropertyMetaTree().find((node) => node.name === 'locale');
177
+ expect(localeNode).toMatchObject({
178
+ name: 'locale',
179
+ title: '{{t("Current language")}}',
180
+ interface: 'select',
181
+ uiSchema: {
182
+ enum: [
183
+ { label: 'English', value: 'en-US' },
184
+ { label: '简体中文', value: 'zh-CN' },
185
+ ],
186
+ },
187
+ });
188
+ });
189
+ });
190
+
161
191
  it('should fallback to plugin-manager when system-settings is not allowed', async () => {
162
192
  const app = createMockClient({
163
193
  plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
@@ -0,0 +1,129 @@
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 { css, cx } from '@emotion/css';
11
+ import { Input, Typography } from 'antd';
12
+ import type { TextAreaProps } from 'antd/es/input';
13
+ import JSON5 from 'json5';
14
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
15
+
16
+ export interface JsonTextAreaProps extends Omit<TextAreaProps, 'value' | 'onChange'> {
17
+ value?: unknown;
18
+ onChange?: (value: unknown) => void;
19
+ space?: number;
20
+ json5?: boolean;
21
+ showError?: boolean;
22
+ }
23
+
24
+ const jsonTextAreaClassName = css`
25
+ font-size: 80%;
26
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
27
+ `;
28
+
29
+ function stringifyJsonValue(value: unknown, json: typeof JSON | typeof JSON5, space: number) {
30
+ if (value == null) {
31
+ return '';
32
+ }
33
+
34
+ if (typeof value === 'string') {
35
+ try {
36
+ json.parse(value);
37
+ return value;
38
+ } catch {
39
+ return json.stringify(value, undefined, space);
40
+ }
41
+ }
42
+
43
+ return json.stringify(value, undefined, space);
44
+ }
45
+
46
+ export const JsonTextArea = React.memo((props: JsonTextAreaProps) => {
47
+ const {
48
+ value,
49
+ onChange,
50
+ space = 2,
51
+ json5 = false,
52
+ showError = true,
53
+ className,
54
+ status,
55
+ onBlur,
56
+ ...textAreaProps
57
+ } = props;
58
+ const json = json5 ? JSON5 : JSON;
59
+ const [text, setText] = useState(() => stringifyJsonValue(value, json, space));
60
+ const [error, setError] = useState<string>();
61
+
62
+ useEffect(() => {
63
+ setText(stringifyJsonValue(value, json, space));
64
+ }, [json, space, value]);
65
+
66
+ const parseText = useCallback(
67
+ (nextText: string) => {
68
+ if (nextText.trim() === '') {
69
+ return null;
70
+ }
71
+
72
+ return json.parse(nextText);
73
+ },
74
+ [json],
75
+ );
76
+
77
+ const handleChange = useCallback(
78
+ (event: React.ChangeEvent<HTMLTextAreaElement>) => {
79
+ const nextText = event.target.value;
80
+ setText(nextText);
81
+
82
+ try {
83
+ parseText(nextText);
84
+ setError(undefined);
85
+ } catch (err) {
86
+ setError(err instanceof Error ? err.message : String(err));
87
+ }
88
+ },
89
+ [parseText],
90
+ );
91
+
92
+ const handleBlur = useCallback(
93
+ (event: React.FocusEvent<HTMLTextAreaElement>) => {
94
+ try {
95
+ const parsed = parseText(event.target.value);
96
+ setError(undefined);
97
+ setText(parsed == null ? '' : json.stringify(parsed, undefined, space));
98
+ onChange?.(parsed);
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : String(err));
101
+ }
102
+
103
+ onBlur?.(event);
104
+ },
105
+ [json, onBlur, onChange, parseText, space],
106
+ );
107
+
108
+ const mergedStatus = useMemo(() => status || (error ? 'error' : undefined), [error, status]);
109
+
110
+ return (
111
+ <>
112
+ <Input.TextArea
113
+ {...textAreaProps}
114
+ value={text}
115
+ onChange={handleChange}
116
+ onBlur={handleBlur}
117
+ status={mergedStatus}
118
+ className={cx(jsonTextAreaClassName, className)}
119
+ />
120
+ {showError && error ? (
121
+ <Typography.Text type="danger" style={{ display: 'block', marginTop: 4 }}>
122
+ {error}
123
+ </Typography.Text>
124
+ ) : null}
125
+ </>
126
+ );
127
+ });
128
+
129
+ JsonTextArea.displayName = 'JsonTextArea';
@@ -10,4 +10,5 @@
10
10
  export * from './AppComponents';
11
11
  export * from './BlankComponent';
12
12
  export * from './Icon';
13
+ export * from './form/JsonTextArea';
13
14
  export * from './RouterContextCleaner';
@@ -10,7 +10,7 @@
10
10
  import React from 'react';
11
11
  import { describe, it, expect, vi, beforeEach } from 'vitest';
12
12
  import { MemoryRouter, Route, Routes } from 'react-router-dom';
13
- import { render, waitFor } from '@testing-library/react';
13
+ import { render, screen, waitFor } from '@testing-library/react';
14
14
  import { FlowEngine, FlowEngineProvider, type FlowModel } from '@nocobase/flow-engine';
15
15
  import FlowRoute from '../components/FlowRoute';
16
16
 
@@ -219,7 +219,7 @@ describe('FlowRoute', () => {
219
219
  });
220
220
  });
221
221
 
222
- it('should replace to legacy page when current route is page', async () => {
222
+ it('should show 404 when current route is a legacy page in v2 runtime', async () => {
223
223
  const originalLocation = window.location;
224
224
  const replace = vi.fn();
225
225
  Object.defineProperty(window, 'location', {
@@ -271,9 +271,8 @@ describe('FlowRoute', () => {
271
271
  </FlowEngineProvider>,
272
272
  );
273
273
 
274
- await waitFor(() => {
275
- expect(replace).toHaveBeenCalledWith('/admin/test-page/tabs/tab-1?from=direct#dialog');
276
- });
274
+ expect(await screen.findByText('404')).toBeInTheDocument();
275
+ expect(replace).not.toHaveBeenCalled();
277
276
  expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
278
277
  expect(adminLayoutModel.updateRoutePage).not.toHaveBeenCalled();
279
278
  } finally {
@@ -0,0 +1,199 @@
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 { actionLinkageRules } from '../linkageRules';
12
+
13
+ function createActionModel() {
14
+ const model: any = {
15
+ uid: 'edit-action',
16
+ hidden: false,
17
+ props: {
18
+ title: 'Edit',
19
+ },
20
+ context: {},
21
+ setProps: vi.fn((keyOrProps: string | Record<string, any>, value?: any) => {
22
+ if (typeof keyOrProps === 'string') {
23
+ model.props[keyOrProps] = value;
24
+ return;
25
+ }
26
+ model.props = {
27
+ ...model.props,
28
+ ...keyOrProps,
29
+ };
30
+ }),
31
+ };
32
+
33
+ return model;
34
+ }
35
+
36
+ function createLinkageParams(actions: any[] = []) {
37
+ return {
38
+ value: actions.length
39
+ ? [
40
+ {
41
+ key: 'hide-edit',
42
+ title: 'hide edit',
43
+ enable: true,
44
+ condition: {
45
+ logic: '$and',
46
+ items: [],
47
+ },
48
+ actions,
49
+ },
50
+ ]
51
+ : [],
52
+ };
53
+ }
54
+
55
+ function createRuntime(model: any, options: { pauseHiddenAction?: boolean } = {}) {
56
+ const { pauseHiddenAction = true } = options;
57
+ let releaseHiddenAction: () => void = () => {};
58
+ let hiddenActionCount = 0;
59
+ const hiddenActionWaiters: Array<{ count: number; resolve: () => void }> = [];
60
+ const waitForHiddenActionCount = (count: number) => {
61
+ if (hiddenActionCount >= count) {
62
+ return Promise.resolve();
63
+ }
64
+ return new Promise<void>((resolve) => {
65
+ hiddenActionWaiters.push({ count, resolve });
66
+ });
67
+ };
68
+ const hiddenActionReachedPromise = waitForHiddenActionCount(1);
69
+ const releaseHiddenActionPromise = new Promise<void>((resolve) => {
70
+ releaseHiddenAction = resolve;
71
+ });
72
+ const notifyHiddenActionReached = () => {
73
+ hiddenActionCount += 1;
74
+ for (let i = hiddenActionWaiters.length - 1; i >= 0; i--) {
75
+ const waiter = hiddenActionWaiters[i];
76
+ if (hiddenActionCount < waiter.count) continue;
77
+ hiddenActionWaiters.splice(i, 1);
78
+ waiter.resolve();
79
+ }
80
+ };
81
+
82
+ const ctx: any = {
83
+ flowKey: 'buttonSettings',
84
+ model,
85
+ app: {
86
+ jsonLogic: {
87
+ apply: vi.fn(() => true),
88
+ },
89
+ },
90
+ t: (s: string) => s,
91
+ resolveJsonTemplate: vi.fn(async (value: any) => value),
92
+ getAction: (name: string) => {
93
+ if (name !== 'linkageSetActionProps') return undefined;
94
+
95
+ return {
96
+ handler: async (_ctx: any, params: any) => {
97
+ params.setProps(model, {
98
+ hiddenModel: params.value === 'hidden',
99
+ disabled: false,
100
+ hiddenText: false,
101
+ });
102
+ notifyHiddenActionReached();
103
+ if (pauseHiddenAction) {
104
+ await releaseHiddenActionPromise;
105
+ }
106
+ },
107
+ };
108
+ },
109
+ };
110
+
111
+ return {
112
+ ctx,
113
+ hiddenActionReachedPromise,
114
+ waitForHiddenActionCount,
115
+ releaseHiddenAction,
116
+ };
117
+ }
118
+
119
+ describe('actionLinkageRules props patch isolation', () => {
120
+ async function runReproOnce() {
121
+ const model = createActionModel();
122
+ const { ctx, hiddenActionReachedPromise, releaseHiddenAction } = createRuntime(model);
123
+
124
+ const hideRun = actionLinkageRules.handler(
125
+ ctx,
126
+ createLinkageParams([
127
+ {
128
+ name: 'linkageSetActionProps',
129
+ params: {
130
+ value: 'hidden',
131
+ },
132
+ },
133
+ ]),
134
+ );
135
+
136
+ await hiddenActionReachedPromise;
137
+ expect(model.hidden).toBe(false);
138
+
139
+ await actionLinkageRules.handler(ctx, createLinkageParams());
140
+ expect(model.hidden).toBe(false);
141
+
142
+ releaseHiddenAction();
143
+ await hideRun;
144
+
145
+ expect(model.hidden).toBe(true);
146
+ }
147
+
148
+ it('keeps a concurrent empty run from clearing another run props patch', async () => {
149
+ for (let i = 0; i < 50; i++) {
150
+ await runReproOnce();
151
+ }
152
+ });
153
+
154
+ it('still restores original props when a later completed run has no matched actions', async () => {
155
+ const model = createActionModel();
156
+ const { ctx } = createRuntime(model, { pauseHiddenAction: false });
157
+
158
+ await actionLinkageRules.handler(
159
+ ctx,
160
+ createLinkageParams([
161
+ {
162
+ name: 'linkageSetActionProps',
163
+ params: {
164
+ value: 'hidden',
165
+ },
166
+ },
167
+ ]),
168
+ );
169
+ expect(model.hidden).toBe(true);
170
+
171
+ await actionLinkageRules.handler(ctx, createLinkageParams());
172
+ expect(model.hidden).toBe(false);
173
+ });
174
+
175
+ it('keeps concurrent matched runs from clearing each other props patch', async () => {
176
+ const model = createActionModel();
177
+ const { ctx, waitForHiddenActionCount, releaseHiddenAction } = createRuntime(model);
178
+ const params = createLinkageParams([
179
+ {
180
+ name: 'linkageSetActionProps',
181
+ params: {
182
+ value: 'hidden',
183
+ },
184
+ },
185
+ ]);
186
+
187
+ const firstRun = actionLinkageRules.handler(ctx, params);
188
+ await waitForHiddenActionCount(1);
189
+
190
+ const secondRun = actionLinkageRules.handler(ctx, params);
191
+ await waitForHiddenActionCount(2);
192
+
193
+ releaseHiddenAction();
194
+ await firstRun;
195
+ await secondRun;
196
+
197
+ expect(model.hidden).toBe(true);
198
+ });
199
+ });