@nocobase/client-v2 2.1.0-beta.29 → 2.1.0-beta.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.
- package/es/BaseApplication.d.ts +1 -0
- package/es/PluginManager.d.ts +1 -0
- package/es/components/form/DrawerFormLayout.d.ts +49 -0
- package/es/components/form/EnvVariableInput.d.ts +42 -0
- package/es/components/form/FileSizeInput.d.ts +27 -0
- package/es/components/form/createFormRegistry.d.ts +33 -0
- package/es/components/form/index.d.ts +13 -0
- package/es/components/index.d.ts +1 -1
- package/es/flow/actions/index.d.ts +1 -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/components/FieldAssignRulesEditor.d.ts +1 -0
- package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
- package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
- package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
- package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
- package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +1 -1
- package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.mjs +122 -106
- package/es/utils/remotePlugins.d.ts +0 -4
- package/lib/index.js +121 -105
- package/package.json +6 -5
- package/src/BaseApplication.tsx +14 -8
- package/src/PluginManager.ts +1 -0
- package/src/__tests__/app.test.tsx +28 -1
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
- package/src/__tests__/remotePlugins.test.ts +29 -18
- package/src/__tests__/settings-center.test.tsx +30 -0
- package/src/components/form/DrawerFormLayout.tsx +103 -0
- package/src/components/form/EnvVariableInput.tsx +126 -0
- package/src/components/form/FileSizeInput.tsx +105 -0
- package/src/components/form/createFormRegistry.ts +60 -0
- package/src/components/form/index.tsx +14 -0
- package/src/components/index.ts +1 -1
- 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__/dataScopeFilter.test.ts +92 -13
- 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__/linkageRules.subFormSetFieldProps.test.ts +476 -1
- package/src/flow/actions/index.ts +2 -0
- package/src/flow/actions/linkageRules.tsx +316 -280
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
- package/src/flow/actions/setTargetDataScope.tsx +32 -3
- 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/FieldAssignRulesEditor.tsx +2 -0
- package/src/flow/components/FlowRoute.tsx +17 -4
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
- package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
- package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
- package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
- package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
- package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
- package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
- package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
- package/src/flow/models/blocks/table/TableColumnModel.tsx +3 -0
- package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +96 -1
- package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +7 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +4 -1
- package/src/flow/models/fields/ClickableFieldModel.tsx +9 -4
- package/src/flow/models/fields/DisplayTitleFieldModel.tsx +12 -4
- package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +23 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
- package/src/flow/models/utils/displayValueUtils.ts +57 -0
- package/src/flow/system-settings/useSystemSettings.tsx +36 -1
- package/src/utils/globalDeps.ts +2 -0
- package/src/utils/remotePlugins.ts +7 -27
|
@@ -0,0 +1,105 @@
|
|
|
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 } from '@emotion/css';
|
|
11
|
+
import { InputNumber, Select, Space } from 'antd';
|
|
12
|
+
import React, { useCallback } from 'react';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MIN = 1;
|
|
15
|
+
const DEFAULT_MAX = Number.POSITIVE_INFINITY;
|
|
16
|
+
const DEFAULT_VALUE = 1024 * 1024 * 20;
|
|
17
|
+
|
|
18
|
+
const UNIT_OPTIONS = [
|
|
19
|
+
{ value: 1, label: 'Byte' },
|
|
20
|
+
{ value: 1024, label: 'KB' },
|
|
21
|
+
{ value: 1024 * 1024, label: 'MB' },
|
|
22
|
+
{ value: 1024 * 1024 * 1024, label: 'GB' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Mirrors v1's `.auto-width` rule registered globally on FormItem: shrink the
|
|
26
|
+
// antd control to its content width while keeping a sensible minimum.
|
|
27
|
+
const autoWidthClassName = css`
|
|
28
|
+
&.ant-input-number,
|
|
29
|
+
&.ant-select {
|
|
30
|
+
width: auto;
|
|
31
|
+
min-width: 6em;
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function getUnitOption(value: number, defaultUnit = 1024 * 1024) {
|
|
36
|
+
const size = value || defaultUnit;
|
|
37
|
+
for (let i = UNIT_OPTIONS.length - 1; i >= 0; i -= 1) {
|
|
38
|
+
const option = UNIT_OPTIONS[i];
|
|
39
|
+
if (size % option.value === 0) {
|
|
40
|
+
return option;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return UNIT_OPTIONS[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clampSize(value: number, min: number, max: number) {
|
|
47
|
+
return Math.min(Math.max(min, value), max);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FileSizeInputProps {
|
|
51
|
+
value?: number;
|
|
52
|
+
onChange?: (value?: number) => void;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
/** Minimum byte size. Empty / below-min input snaps to this on blur. Defaults to 1. */
|
|
55
|
+
min?: number;
|
|
56
|
+
/** Maximum byte size. Defaults to `Number.POSITIVE_INFINITY`. */
|
|
57
|
+
max?: number;
|
|
58
|
+
/** Default byte size used to derive the initial unit shown when the field is empty. Defaults to 20 MB. */
|
|
59
|
+
defaultValue?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Byte-valued size input paired with a unit selector (Byte / KB / MB / GB).
|
|
64
|
+
* The persisted value is always normalized to bytes; the displayed number is
|
|
65
|
+
* derived from the picked unit. Useful for fields like file-size limits or
|
|
66
|
+
* memory quotas where the natural input unit varies by magnitude.
|
|
67
|
+
*/
|
|
68
|
+
export function FileSizeInput(props: FileSizeInputProps) {
|
|
69
|
+
const min = props.min ?? DEFAULT_MIN;
|
|
70
|
+
const max = props.max ?? DEFAULT_MAX;
|
|
71
|
+
const defaultValue = props.defaultValue ?? DEFAULT_VALUE;
|
|
72
|
+
const unit = getUnitOption(props.value ?? defaultValue);
|
|
73
|
+
const value = props.value == null ? props.value : props.value / unit.value;
|
|
74
|
+
|
|
75
|
+
const handleBlur = useCallback(() => {
|
|
76
|
+
if (props.value == null || props.value < min) {
|
|
77
|
+
props.onChange?.(min);
|
|
78
|
+
}
|
|
79
|
+
}, [props, min]);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Space.Compact>
|
|
83
|
+
<InputNumber
|
|
84
|
+
value={value}
|
|
85
|
+
disabled={props.disabled}
|
|
86
|
+
defaultValue={defaultValue / getUnitOption(defaultValue).value}
|
|
87
|
+
step={1}
|
|
88
|
+
className={autoWidthClassName}
|
|
89
|
+
onBlur={handleBlur}
|
|
90
|
+
onChange={(nextValue) => {
|
|
91
|
+
props.onChange?.(nextValue == null ? undefined : clampSize(Number(nextValue) * unit.value, min, max));
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
<Select
|
|
95
|
+
disabled={props.disabled}
|
|
96
|
+
options={UNIT_OPTIONS}
|
|
97
|
+
value={unit.value}
|
|
98
|
+
className={autoWidthClassName}
|
|
99
|
+
onChange={(nextUnit) => {
|
|
100
|
+
props.onChange?.(value == null ? undefined : clampSize(Number(value) * nextUnit, min, max));
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
</Space.Compact>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface FormRegistryEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FormRegistry<T extends FormRegistryEntry> {
|
|
15
|
+
readonly namespace: string;
|
|
16
|
+
register(entry: T): void;
|
|
17
|
+
unregister(name: string): boolean;
|
|
18
|
+
get(name: string): T | undefined;
|
|
19
|
+
has(name: string): boolean;
|
|
20
|
+
list(): T[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create an isolated, namespaced registry of form-like entries.
|
|
25
|
+
*
|
|
26
|
+
* Each call returns a fresh registry instance backed by its own closure-scoped
|
|
27
|
+
* `Map`, so plugins do not share state across namespaces. Plugins that need an
|
|
28
|
+
* extension point for form-shaped registrations (e.g. storage configuration
|
|
29
|
+
* forms, data-source connection forms) can build their own typed registry on
|
|
30
|
+
* top of this primitive instead of re-implementing the same boilerplate.
|
|
31
|
+
*
|
|
32
|
+
* Re-registering the same `name` overwrites the previous entry and emits a
|
|
33
|
+
* console warning. This is intentional so HMR / hot reload works without
|
|
34
|
+
* throwing, while still surfacing unintended duplicates during development.
|
|
35
|
+
*/
|
|
36
|
+
export function createFormRegistry<T extends FormRegistryEntry>(namespace: string): FormRegistry<T> {
|
|
37
|
+
const entries = new Map<string, T>();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
namespace,
|
|
41
|
+
register(entry) {
|
|
42
|
+
if (entries.has(entry.name)) {
|
|
43
|
+
console.warn(`[${namespace}] entry "${entry.name}" already registered, overwriting.`);
|
|
44
|
+
}
|
|
45
|
+
entries.set(entry.name, entry);
|
|
46
|
+
},
|
|
47
|
+
unregister(name) {
|
|
48
|
+
return entries.delete(name);
|
|
49
|
+
},
|
|
50
|
+
get(name) {
|
|
51
|
+
return entries.get(name);
|
|
52
|
+
},
|
|
53
|
+
has(name) {
|
|
54
|
+
return entries.has(name);
|
|
55
|
+
},
|
|
56
|
+
list() {
|
|
57
|
+
return Array.from(entries.values());
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from './createFormRegistry';
|
|
11
|
+
export * from './DrawerFormLayout';
|
|
12
|
+
export * from './EnvVariableInput';
|
|
13
|
+
export * from './FileSizeInput';
|
|
14
|
+
export * from './JsonTextArea';
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
275
|
-
|
|
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
|
+
});
|
|
@@ -8,10 +8,31 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
|
|
11
12
|
import { dataScope } from '../dataScope';
|
|
12
13
|
import { normalizeDataScopeFilter } from '../dataScopeFilter';
|
|
13
14
|
import { setTargetDataScope } from '../setTargetDataScope';
|
|
14
15
|
|
|
16
|
+
function createSetTargetDataScopeContext(resource: any, options: { selected?: boolean; resolvedValue?: any } = {}) {
|
|
17
|
+
const targetModel = { resource };
|
|
18
|
+
const inputArgs = options.selected === undefined ? undefined : { selected: options.selected };
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...(inputArgs ? { inputArgs } : {}),
|
|
22
|
+
model: {
|
|
23
|
+
uid: 'action-1',
|
|
24
|
+
scheduleModelOperation: vi.fn((_uid, callback) => callback(targetModel)),
|
|
25
|
+
},
|
|
26
|
+
resolveJsonTemplate: vi.fn(async (template) => ({
|
|
27
|
+
...template,
|
|
28
|
+
filter: {
|
|
29
|
+
...template.filter,
|
|
30
|
+
items: [{ ...template.filter.items[0], value: options.resolvedValue }],
|
|
31
|
+
},
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
describe('normalizeDataScopeFilter', () => {
|
|
16
37
|
it('keeps null when a right-side variable resolves to empty', () => {
|
|
17
38
|
const rawFilter = {
|
|
@@ -125,34 +146,92 @@ describe('normalizeDataScopeFilter', () => {
|
|
|
125
146
|
hasData: vi.fn(() => false),
|
|
126
147
|
refresh: vi.fn(),
|
|
127
148
|
};
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
const ctx = createSetTargetDataScopeContext(resource);
|
|
150
|
+
const params = {
|
|
151
|
+
targetBlockUid: 'target-1',
|
|
152
|
+
filter: {
|
|
153
|
+
logic: '$and',
|
|
154
|
+
items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
|
|
133
155
|
},
|
|
134
|
-
resolveJsonTemplate: vi.fn(async (template) => ({
|
|
135
|
-
...template,
|
|
136
|
-
filter: {
|
|
137
|
-
...template.filter,
|
|
138
|
-
items: [{ ...template.filter.items[0], value: undefined }],
|
|
139
|
-
},
|
|
140
|
-
})),
|
|
141
156
|
};
|
|
157
|
+
|
|
158
|
+
await (setTargetDataScope as any).handler(ctx, params);
|
|
159
|
+
|
|
160
|
+
expect(ctx.model.scheduleModelOperation).toHaveBeenCalledWith('target-1', expect.any(Function));
|
|
161
|
+
expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
|
|
162
|
+
$and: [{ departmentId: { $eq: null } }],
|
|
163
|
+
});
|
|
164
|
+
expect(resource.removeFilterGroup).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('setTargetDataScope handler removes clicked-row data scope when row click deselects', async () => {
|
|
168
|
+
const resource = {
|
|
169
|
+
addFilterGroup: vi.fn(),
|
|
170
|
+
removeFilterGroup: vi.fn(),
|
|
171
|
+
hasData: vi.fn(() => true),
|
|
172
|
+
refresh: vi.fn(),
|
|
173
|
+
};
|
|
174
|
+
const ctx = createSetTargetDataScopeContext(resource, { selected: false, resolvedValue: null });
|
|
142
175
|
const params = {
|
|
143
176
|
targetBlockUid: 'target-1',
|
|
144
177
|
filter: {
|
|
145
178
|
logic: '$and',
|
|
146
|
-
items: [{ path: '
|
|
179
|
+
items: [{ path: 'id', operator: '$eq', value: '{{ ctx.clickedRowRecord.id }}' }],
|
|
147
180
|
},
|
|
148
181
|
};
|
|
149
182
|
|
|
150
183
|
await (setTargetDataScope as any).handler(ctx, params);
|
|
151
184
|
|
|
152
185
|
expect(ctx.model.scheduleModelOperation).toHaveBeenCalledWith('target-1', expect.any(Function));
|
|
186
|
+
expect(resource.removeFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1');
|
|
187
|
+
expect(resource.addFilterGroup).not.toHaveBeenCalled();
|
|
188
|
+
expect(resource.refresh).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('setTargetDataScope handler keeps null clicked-row field values while row is selected', async () => {
|
|
192
|
+
const resource = {
|
|
193
|
+
addFilterGroup: vi.fn(),
|
|
194
|
+
removeFilterGroup: vi.fn(),
|
|
195
|
+
hasData: vi.fn(() => false),
|
|
196
|
+
refresh: vi.fn(),
|
|
197
|
+
};
|
|
198
|
+
const ctx = createSetTargetDataScopeContext(resource, { selected: true, resolvedValue: null });
|
|
199
|
+
const params = {
|
|
200
|
+
targetBlockUid: 'target-1',
|
|
201
|
+
filter: {
|
|
202
|
+
logic: '$and',
|
|
203
|
+
items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.clickedRowRecord.departmentId }}' }],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
await (setTargetDataScope as any).handler(ctx, params);
|
|
208
|
+
|
|
153
209
|
expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
|
|
154
210
|
$and: [{ departmentId: { $eq: null } }],
|
|
155
211
|
});
|
|
156
212
|
expect(resource.removeFilterGroup).not.toHaveBeenCalled();
|
|
157
213
|
});
|
|
214
|
+
|
|
215
|
+
it('setTargetDataScope handler refreshes empty loaded target while preserving its own data scope', async () => {
|
|
216
|
+
const engine = new FlowEngine();
|
|
217
|
+
const resource = engine.createResource(MultiRecordResource);
|
|
218
|
+
resource.addFilterGroup('target-table', { status: { $eq: 'active' } });
|
|
219
|
+
resource.addFilterGroup('setTargetDataScope_action-1', { id: { $eq: 1 } });
|
|
220
|
+
resource.setData([]);
|
|
221
|
+
resource.setMeta({ count: 0, hasNext: false });
|
|
222
|
+
const refresh = vi.spyOn(resource, 'refresh').mockResolvedValue(undefined);
|
|
223
|
+
const ctx = createSetTargetDataScopeContext(resource, { selected: false, resolvedValue: null });
|
|
224
|
+
const params = {
|
|
225
|
+
targetBlockUid: 'target-1',
|
|
226
|
+
filter: {
|
|
227
|
+
logic: '$and',
|
|
228
|
+
items: [{ path: 'id', operator: '$eq', value: '{{ ctx.clickedRowRecord.id }}' }],
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await (setTargetDataScope as any).handler(ctx, params);
|
|
233
|
+
|
|
234
|
+
expect(resource.getRequestParameter('filter')).toBe(JSON.stringify({ $and: [{ status: { $eq: 'active' } }] }));
|
|
235
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
236
|
+
});
|
|
158
237
|
});
|
|
@@ -26,7 +26,12 @@ function createRule(overrides: any = {}) {
|
|
|
26
26
|
|
|
27
27
|
function createRuntime(
|
|
28
28
|
params: any,
|
|
29
|
-
options: {
|
|
29
|
+
options: {
|
|
30
|
+
fieldIndex?: string[];
|
|
31
|
+
fieldIndexRef?: { current: string[] };
|
|
32
|
+
actionHandler?: any;
|
|
33
|
+
engineEmitter?: any;
|
|
34
|
+
} = {},
|
|
30
35
|
) {
|
|
31
36
|
const formEmitter = new EventEmitter();
|
|
32
37
|
const formBlock: any = {
|
|
@@ -0,0 +1,90 @@
|
|
|
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 { ActionScene } from '@nocobase/flow-engine';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import * as registeredActions from '../index';
|
|
14
|
+
import { linkageRunjs, linkageSetMenuItemProps, menuLinkageRules } from '../linkageRules';
|
|
15
|
+
|
|
16
|
+
describe('menu linkage rules', () => {
|
|
17
|
+
it('should register menu linkage actions from the actions entry', () => {
|
|
18
|
+
expect(registeredActions.menuLinkageRules).toBe(menuLinkageRules);
|
|
19
|
+
expect(registeredActions.linkageSetMenuItemProps).toBe(linkageSetMenuItemProps);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should expose menu state and runjs actions in ui schema', () => {
|
|
23
|
+
const schema = menuLinkageRules.uiSchema?.({
|
|
24
|
+
getActions: () =>
|
|
25
|
+
new Map([
|
|
26
|
+
['linkageSetMenuItemProps', linkageSetMenuItemProps],
|
|
27
|
+
['linkageRunjs', linkageRunjs],
|
|
28
|
+
[
|
|
29
|
+
'actionOnly',
|
|
30
|
+
{
|
|
31
|
+
name: 'actionOnly',
|
|
32
|
+
scene: ActionScene.ACTION_LINKAGE_RULES,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
]),
|
|
36
|
+
} as any) as any;
|
|
37
|
+
|
|
38
|
+
expect(schema?.value?.['x-component-props']?.supportedActions).toEqual(['linkageSetMenuItemProps', 'linkageRunjs']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should set menu model hidden through common linkage handler', async () => {
|
|
42
|
+
const setProps = vi.fn(function (this: any, props: any) {
|
|
43
|
+
this.props = {
|
|
44
|
+
...this.props,
|
|
45
|
+
...props,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
const setHidden = vi.fn(function (this: any, value: boolean) {
|
|
49
|
+
this.hidden = value;
|
|
50
|
+
});
|
|
51
|
+
const model: any = {
|
|
52
|
+
uid: 'menu-item-1',
|
|
53
|
+
props: {},
|
|
54
|
+
hidden: false,
|
|
55
|
+
__allModels: [],
|
|
56
|
+
setProps,
|
|
57
|
+
setHidden,
|
|
58
|
+
};
|
|
59
|
+
const ctx: any = {
|
|
60
|
+
app: { jsonLogic: { apply: () => true } },
|
|
61
|
+
model,
|
|
62
|
+
t: (text: string) => text,
|
|
63
|
+
getAction: (name: string) => (name === 'linkageSetMenuItemProps' ? linkageSetMenuItemProps : undefined),
|
|
64
|
+
resolveJsonTemplate: vi.fn(async (value: any) => value),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await menuLinkageRules.handler(ctx, {
|
|
68
|
+
value: [
|
|
69
|
+
{
|
|
70
|
+
key: 'r1',
|
|
71
|
+
title: 'Hide menu item',
|
|
72
|
+
enable: true,
|
|
73
|
+
condition: { logic: '$and', items: [] },
|
|
74
|
+
actions: [
|
|
75
|
+
{
|
|
76
|
+
key: 'a1',
|
|
77
|
+
name: 'linkageSetMenuItemProps',
|
|
78
|
+
params: {
|
|
79
|
+
value: 'hidden',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(setHidden).toHaveBeenCalledWith(true);
|
|
88
|
+
expect(model.hidden).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|