@nocobase/client-v2 2.1.0-beta.45 → 2.1.0-beta.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/form/filter/CollectionFilter.d.ts +2 -0
- package/es/components/form/filter/CollectionFilterPanel.d.ts +38 -0
- package/es/components/form/filter/index.d.ts +2 -0
- package/es/components/form/filter/useFilterActionProps.d.ts +3 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +1 -0
- package/es/flow/internal/utils/enumOptionsUtils.d.ts +2 -0
- package/es/flow/models/fields/DisplayJSONFieldModel.d.ts +2 -1
- package/es/index.mjs +49 -49
- package/lib/index.js +34 -34
- package/package.json +7 -7
- package/src/__tests__/browserChecker.test.ts +9 -0
- package/src/components/form/DrawerFormLayout.tsx +1 -1
- package/src/components/form/filter/CollectionFilter.tsx +4 -0
- package/src/components/form/filter/CollectionFilterPanel.tsx +97 -0
- package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +76 -1
- package/src/components/form/filter/index.ts +2 -0
- package/src/components/form/filter/useFilterActionProps.ts +66 -2
- package/src/flow/actions/__tests__/sortingRules.test.ts +22 -0
- package/src/flow/actions/linkageRules.tsx +2 -2
- package/src/flow/actions/sortingRules.tsx +3 -4
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +1 -0
- package/src/flow/components/placeholders/BlockPlaceholder.tsx +76 -15
- package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +105 -0
- package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -9
- package/src/flow/internal/utils/enumOptionsUtils.ts +11 -3
- package/src/flow/models/base/CollectionBlockModel.tsx +10 -6
- package/src/flow/models/base/__tests__/CollectionBlockModel.title.test.ts +33 -1
- package/src/flow/models/fields/DisplayEnumFieldModel.tsx +2 -1
- package/src/flow/models/fields/DisplayJSONFieldModel.tsx +15 -4
- package/src/flow/models/fields/SelectFieldModel.tsx +3 -3
- package/src/flow/models/fields/__tests__/DisplayJSONFieldModel.test.tsx +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.47",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.1.0-beta.
|
|
31
|
-
"@nocobase/flow-engine": "2.1.0-beta.
|
|
32
|
-
"@nocobase/sdk": "2.1.0-beta.
|
|
33
|
-
"@nocobase/shared": "2.1.0-beta.
|
|
34
|
-
"@nocobase/utils": "2.1.0-beta.
|
|
30
|
+
"@nocobase/evaluators": "2.1.0-beta.47",
|
|
31
|
+
"@nocobase/flow-engine": "2.1.0-beta.47",
|
|
32
|
+
"@nocobase/sdk": "2.1.0-beta.47",
|
|
33
|
+
"@nocobase/shared": "2.1.0-beta.47",
|
|
34
|
+
"@nocobase/utils": "2.1.0-beta.47",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -45,5 +45,5 @@
|
|
|
45
45
|
"react-i18next": "^11.15.1",
|
|
46
46
|
"react-router-dom": "^6.30.1"
|
|
47
47
|
},
|
|
48
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "bf8fc3790e3494c901b4d22861c5471a0d27c464"
|
|
49
49
|
}
|
|
@@ -100,4 +100,13 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
|
|
|
100
100
|
|
|
101
101
|
expect(replace).not.toHaveBeenCalled();
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
it('does not redirect the root path when the normalized basename is also root', () => {
|
|
105
|
+
const replace = executeBrowserChecker(scriptPath, {
|
|
106
|
+
pathname: '/',
|
|
107
|
+
publicPath: '/',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(replace).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
103
112
|
});
|
|
@@ -64,7 +64,7 @@ export function DrawerFormLayout(props: DrawerFormLayoutProps) {
|
|
|
64
64
|
}, [props, view]);
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
|
-
<div>
|
|
67
|
+
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
68
68
|
{view.Header ? <view.Header title={props.title} /> : null}
|
|
69
69
|
{props.children}
|
|
70
70
|
{view.Footer ? (
|
|
@@ -19,6 +19,8 @@ const identity = (s: string) => s;
|
|
|
19
19
|
export interface CollectionFilterProps {
|
|
20
20
|
/** Collection whose fields drive the filter row's field picker. */
|
|
21
21
|
collection: Collection | undefined;
|
|
22
|
+
/** Previously compiled filter param used to seed the editable filter group. */
|
|
23
|
+
initialValue?: CompiledFilter;
|
|
22
24
|
/** Called on Submit or Reset with the compiled NocoBase filter param (`undefined` when cleared). */
|
|
23
25
|
onChange: (filter: CompiledFilter) => void;
|
|
24
26
|
/** Translator. Defaults to identity. */
|
|
@@ -55,6 +57,7 @@ export interface CollectionFilterProps {
|
|
|
55
57
|
export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
|
|
56
58
|
const {
|
|
57
59
|
collection,
|
|
60
|
+
initialValue,
|
|
58
61
|
onChange,
|
|
59
62
|
t = identity,
|
|
60
63
|
filterableFieldNames,
|
|
@@ -71,6 +74,7 @@ export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
|
|
|
71
74
|
|
|
72
75
|
const filterAction = useFilterActionProps({
|
|
73
76
|
collection,
|
|
77
|
+
initialValue,
|
|
74
78
|
filterableFieldNames,
|
|
75
79
|
nonfilterableFieldNames,
|
|
76
80
|
noIgnore,
|
|
@@ -0,0 +1,97 @@
|
|
|
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 type { Collection } from '@nocobase/flow-engine';
|
|
11
|
+
import { Empty } from 'antd';
|
|
12
|
+
import React, { forwardRef, useImperativeHandle } from 'react';
|
|
13
|
+
import { FilterGroup } from '../../../flow/components/filter';
|
|
14
|
+
import {
|
|
15
|
+
compileFilterGroup,
|
|
16
|
+
type CompiledFilter,
|
|
17
|
+
type FilterApplyAction,
|
|
18
|
+
useFilterActionProps,
|
|
19
|
+
} from './useFilterActionProps';
|
|
20
|
+
|
|
21
|
+
const identity = (s: string) => s;
|
|
22
|
+
|
|
23
|
+
export interface CollectionFilterPanelRef {
|
|
24
|
+
getFilter: () => CompiledFilter;
|
|
25
|
+
reset: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CollectionFilterPanelProps {
|
|
29
|
+
/** Collection whose fields drive the filter row's field picker. */
|
|
30
|
+
collection: Collection | undefined;
|
|
31
|
+
/** Previously compiled filter param used to seed the editable filter group. */
|
|
32
|
+
initialValue?: CompiledFilter;
|
|
33
|
+
/** Called when the condition group structure changes or `reset()` is invoked. */
|
|
34
|
+
onChange?: (filter: CompiledFilter) => void;
|
|
35
|
+
/** Translator. Defaults to identity. */
|
|
36
|
+
t?: (key: string, options?: Record<string, unknown>) => string;
|
|
37
|
+
/** Whitelist of root-level field names to expose. */
|
|
38
|
+
filterableFieldNames?: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Blacklist of root-level field names to drop. Mirrors v1's `nonfilterable: [...]` on `Filter.Action`.
|
|
41
|
+
*/
|
|
42
|
+
nonfilterableFieldNames?: string[];
|
|
43
|
+
/** Bypass the `filterableFieldNames` whitelist. */
|
|
44
|
+
noIgnore?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inline collection filter editor for forms and drawers. Unlike `CollectionFilter`, this does not render a trigger button, popover, or nested `<form>`, so it can safely live inside an antd form and be submitted by the outer drawer action.
|
|
49
|
+
*/
|
|
50
|
+
export const CollectionFilterPanel = forwardRef<CollectionFilterPanelRef, CollectionFilterPanelProps>((props, ref) => {
|
|
51
|
+
const {
|
|
52
|
+
collection,
|
|
53
|
+
initialValue,
|
|
54
|
+
onChange,
|
|
55
|
+
t = identity,
|
|
56
|
+
filterableFieldNames,
|
|
57
|
+
nonfilterableFieldNames,
|
|
58
|
+
noIgnore,
|
|
59
|
+
} = props;
|
|
60
|
+
|
|
61
|
+
const filterAction = useFilterActionProps({
|
|
62
|
+
collection,
|
|
63
|
+
initialValue,
|
|
64
|
+
filterableFieldNames,
|
|
65
|
+
nonfilterableFieldNames,
|
|
66
|
+
noIgnore,
|
|
67
|
+
t,
|
|
68
|
+
onApply: (filter: CompiledFilter, _action: FilterApplyAction) => {
|
|
69
|
+
onChange?.(filter);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
useImperativeHandle(
|
|
74
|
+
ref,
|
|
75
|
+
() => ({
|
|
76
|
+
getFilter: () => compileFilterGroup(filterAction.value),
|
|
77
|
+
reset: filterAction.onReset,
|
|
78
|
+
}),
|
|
79
|
+
[filterAction.onReset, filterAction.value],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!collection || !filterAction.FilterItem) {
|
|
83
|
+
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No data')} />;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<FilterGroup
|
|
88
|
+
value={filterAction.value}
|
|
89
|
+
FilterItem={filterAction.FilterItem}
|
|
90
|
+
onChange={() => onChange?.(compileFilterGroup(filterAction.value))}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
CollectionFilterPanel.displayName = 'CollectionFilterPanel';
|
|
96
|
+
|
|
97
|
+
export default CollectionFilterPanel;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from 'vitest';
|
|
11
|
-
import { compileFilterGroup } from '../useFilterActionProps';
|
|
11
|
+
import { compileFilterGroup, decompileFilterGroup } from '../useFilterActionProps';
|
|
12
12
|
|
|
13
13
|
describe('compileFilterGroup', () => {
|
|
14
14
|
it('returns undefined for an empty group so callers can drop the filter param', () => {
|
|
@@ -144,3 +144,78 @@ describe('compileFilterGroup', () => {
|
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
describe('decompileFilterGroup', () => {
|
|
149
|
+
it('returns undefined for an empty compiled filter', () => {
|
|
150
|
+
expect(decompileFilterGroup(undefined)).toBeUndefined();
|
|
151
|
+
expect(decompileFilterGroup({})).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('restores a compiled condition into an editable filter row', () => {
|
|
155
|
+
expect(decompileFilterGroup({ $and: [{ lockReason: { $includes: 'abuse' } }] })).toEqual({
|
|
156
|
+
logic: '$and',
|
|
157
|
+
items: [{ path: 'lockReason', operator: '$includes', value: 'abuse' }],
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('restores a direct condition object into an editable filter row', () => {
|
|
162
|
+
expect(decompileFilterGroup({ title: { $includes: 'test' } })).toEqual({
|
|
163
|
+
logic: '$and',
|
|
164
|
+
items: [{ path: 'title', operator: '$includes', value: 'test' }],
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('restores legacy direct field values as equality rows', () => {
|
|
169
|
+
expect(decompileFilterGroup({ createdById: '{{ ctx.state.currentUser.id }}' })).toEqual({
|
|
170
|
+
logic: '$and',
|
|
171
|
+
items: [{ path: 'createdById', operator: '$eq', value: '{{ ctx.state.currentUser.id }}' }],
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('restores multiple direct condition fields', () => {
|
|
176
|
+
expect(
|
|
177
|
+
decompileFilterGroup({
|
|
178
|
+
title: { $includes: 'test' },
|
|
179
|
+
status: 'published',
|
|
180
|
+
}),
|
|
181
|
+
).toEqual({
|
|
182
|
+
logic: '$and',
|
|
183
|
+
items: [
|
|
184
|
+
{ path: 'title', operator: '$includes', value: 'test' },
|
|
185
|
+
{ path: 'status', operator: '$eq', value: 'published' },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('restores nested association paths', () => {
|
|
191
|
+
expect(decompileFilterGroup({ $and: [{ user: { createdBy: { password: { $includes: '123' } } } }] })).toEqual({
|
|
192
|
+
logic: '$and',
|
|
193
|
+
items: [{ path: 'user.createdBy.password', operator: '$includes', value: '123' }],
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('restores nested filter groups', () => {
|
|
198
|
+
expect(
|
|
199
|
+
decompileFilterGroup({
|
|
200
|
+
$and: [
|
|
201
|
+
{ lockReason: { $eq: 'abuse' } },
|
|
202
|
+
{
|
|
203
|
+
$or: [{ lockedTs: { $dateAfter: '2026-01-01' } }, { lockedTs: { $dateBefore: '2026-12-31' } }],
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
}),
|
|
207
|
+
).toEqual({
|
|
208
|
+
logic: '$and',
|
|
209
|
+
items: [
|
|
210
|
+
{ path: 'lockReason', operator: '$eq', value: 'abuse' },
|
|
211
|
+
{
|
|
212
|
+
logic: '$or',
|
|
213
|
+
items: [
|
|
214
|
+
{ path: 'lockedTs', operator: '$dateAfter', value: '2026-01-01' },
|
|
215
|
+
{ path: 'lockedTs', operator: '$dateBefore', value: '2026-12-31' },
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -10,4 +10,6 @@
|
|
|
10
10
|
// Higher-level filter compositions for non-schema surfaces (settings pages, panels, side drawers). The low-level primitives — `FilterContainer`, `FilterGroup`, `FilterItem`, `fieldsToOptions`, `useFilterOptions` — live under `src/flow/components/filter/`; this layer composes them with a `Collection` binding and exposes the hook/component pair callers actually reach for. The dependency direction is form/filter → flow/components/filter only.
|
|
11
11
|
export { CollectionFilter } from './CollectionFilter';
|
|
12
12
|
export type { CollectionFilterProps } from './CollectionFilter';
|
|
13
|
+
export { CollectionFilterPanel } from './CollectionFilterPanel';
|
|
14
|
+
export type { CollectionFilterPanelProps, CollectionFilterPanelRef } from './CollectionFilterPanel';
|
|
13
15
|
export type { CompiledFilter } from './useFilterActionProps';
|
|
@@ -68,6 +68,68 @@ const nestPath = (path: string, leaf: unknown): Record<string, unknown> => {
|
|
|
68
68
|
return result as Record<string, unknown>;
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
const decompileConditions = (value: unknown, path: string[] = []): CollectionFilterItemValue[] => {
|
|
72
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
73
|
+
return path.length ? [{ path: path.join('.'), operator: '$eq', value }] : [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
77
|
+
const operatorEntries = entries.filter(([key]) => key.startsWith('$'));
|
|
78
|
+
if (operatorEntries.length) {
|
|
79
|
+
if (!path.length) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
return operatorEntries.map(([operator, operatorValue]) => ({
|
|
83
|
+
path: path.join('.'),
|
|
84
|
+
operator,
|
|
85
|
+
value: operatorValue,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return entries.flatMap(([fieldName, nextValue]) => decompileConditions(nextValue, [...path, fieldName]));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const getGroupLogic = (record: Record<string, unknown>): FilterGroupValue['logic'] | undefined => {
|
|
93
|
+
if (Array.isArray(record.$or)) {
|
|
94
|
+
return '$or';
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(record.$and)) {
|
|
97
|
+
return '$and';
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const decompileFilterItem = (item: unknown): FilterGroupItem[] => {
|
|
103
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const record = item as Record<string, unknown>;
|
|
107
|
+
if (getGroupLogic(record)) {
|
|
108
|
+
const group = decompileFilterGroup(record);
|
|
109
|
+
return group ? [group] : [];
|
|
110
|
+
}
|
|
111
|
+
return decompileConditions(record);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function decompileFilterGroup(filter: CompiledFilter): FilterGroupValue | undefined {
|
|
115
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const record = filter as Record<string, unknown>;
|
|
120
|
+
const logic = getGroupLogic(record);
|
|
121
|
+
if (!logic) {
|
|
122
|
+
const items = decompileConditions(record);
|
|
123
|
+
return items.length ? { logic: '$and', items } : undefined;
|
|
124
|
+
}
|
|
125
|
+
const sourceItems = record[logic] as unknown[];
|
|
126
|
+
const items = sourceItems
|
|
127
|
+
.flatMap((item) => decompileFilterItem(item))
|
|
128
|
+
.filter((item): item is FilterGroupItem => Boolean(item));
|
|
129
|
+
|
|
130
|
+
return items.length ? { logic, items } : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
71
133
|
/**
|
|
72
134
|
* Compile a reactive filter group into the `{ $and: [{ path: { op: val } }] }` envelope accepted by NocoBase's resource `list` filter param. Returns `undefined` when the group is empty so callers can drop the param.
|
|
73
135
|
*
|
|
@@ -100,6 +162,8 @@ export type FilterApplyAction = 'submit' | 'reset';
|
|
|
100
162
|
export interface UseFilterActionPropsArgs extends UseFilterOptionsArgs {
|
|
101
163
|
/** Collection whose fields populate the filter row's field picker. */
|
|
102
164
|
collection: Collection | undefined;
|
|
165
|
+
/** Previously compiled filter param used to seed the editable filter group. */
|
|
166
|
+
initialValue?: CompiledFilter;
|
|
103
167
|
/**
|
|
104
168
|
* Called when the user submits or resets the filter popover. Receives the compiled filter param (`undefined` when cleared) and which footer button triggered the call. Typical implementation: `(filter, action) => { listRequest.run(filter); if (action === 'submit') closePopover(); }`.
|
|
105
169
|
*/
|
|
@@ -152,12 +216,12 @@ export interface UseFilterActionPropsResult {
|
|
|
152
216
|
* ```
|
|
153
217
|
*/
|
|
154
218
|
export function useFilterActionProps(args: UseFilterActionPropsArgs): UseFilterActionPropsResult {
|
|
155
|
-
const { collection, onApply, filterableFieldNames, nonfilterableFieldNames, noIgnore, t } = args;
|
|
219
|
+
const { collection, initialValue, onApply, filterableFieldNames, nonfilterableFieldNames, noIgnore, t } = args;
|
|
156
220
|
|
|
157
221
|
// Held in a ref so the group object identity is stable for the lifetime of the host component — `<FilterContent>` mutates this object directly (push/splice on `items`, swap `logic`), and a fresh observable on every render would reset that internal state.
|
|
158
222
|
const valueRef = useRef<FilterGroupValue>();
|
|
159
223
|
if (!valueRef.current) {
|
|
160
|
-
valueRef.current = observable(createEmptyGroup()) as FilterGroupValue;
|
|
224
|
+
valueRef.current = observable(decompileFilterGroup(initialValue) || createEmptyGroup()) as FilterGroupValue;
|
|
161
225
|
}
|
|
162
226
|
const value = valueRef.current;
|
|
163
227
|
|
|
@@ -0,0 +1,22 @@
|
|
|
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 { sortingRule } from '../sortingRules';
|
|
11
|
+
|
|
12
|
+
describe('sortingRule', () => {
|
|
13
|
+
it('should not throw when the block collection is unavailable', () => {
|
|
14
|
+
const schema = sortingRule.uiSchema({
|
|
15
|
+
blockModel: {
|
|
16
|
+
collection: undefined,
|
|
17
|
+
},
|
|
18
|
+
} as any);
|
|
19
|
+
|
|
20
|
+
expect(schema.sort.items.properties.space.properties.field.enum).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -46,7 +46,7 @@ import { useAssociationTitleFieldSync } from '../components/useAssociationTitleF
|
|
|
46
46
|
import _ from 'lodash';
|
|
47
47
|
import { SubFormFieldModel, SubFormListFieldModel } from '../models';
|
|
48
48
|
import { coerceForToOneField } from '../internal/utils/associationValueCoercion';
|
|
49
|
-
import { enumToOptions } from '../internal/utils/enumOptionsUtils';
|
|
49
|
+
import { enumToOptions, translateOptionLabel } from '../internal/utils/enumOptionsUtils';
|
|
50
50
|
import {
|
|
51
51
|
findFormItemModelByFieldPath,
|
|
52
52
|
getCollectionFromModel,
|
|
@@ -156,7 +156,7 @@ const getFieldModelOptions = (fieldModel: any, t: (s: string) => string) => {
|
|
|
156
156
|
if (originalOptions) {
|
|
157
157
|
return originalOptions.map((option: any) =>
|
|
158
158
|
option && typeof option === 'object' && typeof option.label === 'string'
|
|
159
|
-
? { ...option, label:
|
|
159
|
+
? { ...option, label: translateOptionLabel(option.label, t) }
|
|
160
160
|
: option,
|
|
161
161
|
);
|
|
162
162
|
}
|
|
@@ -33,9 +33,8 @@ export const sortingRule = defineAction({
|
|
|
33
33
|
name: 'sortingRule',
|
|
34
34
|
title: tExpr('Default sorting'),
|
|
35
35
|
uiSchema: (ctx) => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
: ctx.blockModel.collection.getFields();
|
|
36
|
+
const collection = ctx.collectionField?.targetCollection || ctx.blockModel?.collection;
|
|
37
|
+
const fields = typeof collection?.getFields === 'function' ? collection.getFields() : [];
|
|
39
38
|
const sortFields = getSortFields(fields);
|
|
40
39
|
return {
|
|
41
40
|
sort: {
|
|
@@ -109,7 +108,7 @@ export const sortingRule = defineAction({
|
|
|
109
108
|
};
|
|
110
109
|
},
|
|
111
110
|
async handler(ctx, params) {
|
|
112
|
-
const sortArr = params.sort.map((item) => {
|
|
111
|
+
const sortArr = (params.sort || []).map((item) => {
|
|
113
112
|
return item.direction === 'desc' ? `-${item.field}` : item.field;
|
|
114
113
|
});
|
|
115
114
|
// @ts-ignore
|
|
@@ -15,19 +15,66 @@ import { useTranslation } from 'react-i18next';
|
|
|
15
15
|
import { BlockItemCard } from '../BlockItemCard';
|
|
16
16
|
import { BlockModel } from '../../models/base/BlockModel';
|
|
17
17
|
|
|
18
|
+
function getResourceSettingsInitParams(model: any) {
|
|
19
|
+
if (typeof model?.getResourceSettingsInitParams === 'function') {
|
|
20
|
+
return model.getResourceSettingsInitParams();
|
|
21
|
+
}
|
|
22
|
+
if (typeof model?.getStepParams === 'function') {
|
|
23
|
+
return model.getStepParams('resourceSettings', 'init');
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getBlockResourceInfo(model: any, t: (key: string) => string) {
|
|
29
|
+
const blockModel = model?.context?.blockModel || model;
|
|
30
|
+
const params = getResourceSettingsInitParams(blockModel) || {};
|
|
31
|
+
const collection = blockModel?.context?.collection || blockModel?.collection || model?.context?.collection;
|
|
32
|
+
const dataSource =
|
|
33
|
+
collection?.dataSource || blockModel?.context?.dataSource || blockModel?.dataSource || model?.dataSource;
|
|
34
|
+
const dataSourceKey = dataSource?.key || collection?.dataSourceKey || params.dataSourceKey;
|
|
35
|
+
const collectionName =
|
|
36
|
+
collection?.title ||
|
|
37
|
+
collection?.name ||
|
|
38
|
+
collection?.tableName ||
|
|
39
|
+
model?.resource?.resourceName ||
|
|
40
|
+
model?.resource?.getResourceName?.() ||
|
|
41
|
+
params.associationName ||
|
|
42
|
+
params.collectionName;
|
|
43
|
+
const dataSourceName = dataSource ? t(dataSource.displayName || dataSource.key) : dataSourceKey;
|
|
44
|
+
const collectionLabel = collectionName ? `${t(collectionName) || collectionName}` : '';
|
|
45
|
+
const dataSourceLabel = dataSourceName ? `${t(dataSourceName)} > ` : '';
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
dataSourceName,
|
|
49
|
+
nameValue: `${dataSourceLabel}${collectionLabel}`,
|
|
50
|
+
isDataSourceUnavailable: Boolean(dataSourceKey && !dataSource),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function DataSourceUnavailablePlaceholder({ dataSourceName }: { dataSourceName?: string }) {
|
|
55
|
+
const { t } = useTranslation();
|
|
56
|
+
const subTitle = dataSourceName
|
|
57
|
+
? t(
|
|
58
|
+
'The data source "{{name}}" used by this block is disabled or unavailable. Enable the data source to display this block.',
|
|
59
|
+
{ name: dataSourceName },
|
|
60
|
+
)
|
|
61
|
+
: t('The data source used by this block is disabled or unavailable. Enable the data source to display this block.');
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<BlockItemCard>
|
|
65
|
+
<Result status="403" subTitle={subTitle}></Result>
|
|
66
|
+
</BlockItemCard>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
export const BlockPlaceholder = () => {
|
|
19
71
|
const { t } = useTranslation();
|
|
20
72
|
const model: BlockModel = useFlowModel();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const nameValue = useMemo(() => {
|
|
25
|
-
const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
|
|
26
|
-
const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} ` : '';
|
|
27
|
-
return `${dataSourcePrefix}${collectionPrefix}`;
|
|
28
|
-
}, []);
|
|
73
|
+
const { dataSourceName, isDataSourceUnavailable, nameValue } = useMemo(() => {
|
|
74
|
+
return getBlockResourceInfo(model, t);
|
|
75
|
+
}, [model, t]);
|
|
29
76
|
|
|
30
|
-
const { actionName } = model.forbidden;
|
|
77
|
+
const { actionName } = model.forbidden || {};
|
|
31
78
|
const messageValue = useMemo(() => {
|
|
32
79
|
return t(
|
|
33
80
|
`The current user only has the UI configuration permission, but don't have "{{actionName}}" permission for collection "{{name}}"`,
|
|
@@ -37,6 +84,14 @@ export const BlockPlaceholder = () => {
|
|
|
37
84
|
},
|
|
38
85
|
).replaceAll('>', '>');
|
|
39
86
|
}, [actionName, nameValue, t]);
|
|
87
|
+
|
|
88
|
+
if (isDataSourceUnavailable) {
|
|
89
|
+
if (!model.context.flowSettingsEnabled) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return <DataSourceUnavailablePlaceholder dataSourceName={dataSourceName} />;
|
|
93
|
+
}
|
|
94
|
+
|
|
40
95
|
return (
|
|
41
96
|
<BlockItemCard>
|
|
42
97
|
<Result status="403" subTitle={messageValue}></Result>
|
|
@@ -47,12 +102,10 @@ export const BlockPlaceholder = () => {
|
|
|
47
102
|
export function BlockDeletePlaceholder() {
|
|
48
103
|
const { t } = useTranslation();
|
|
49
104
|
const model: any = useFlowModel();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return `${dataSourcePrefix}${collectionPrefix}`;
|
|
55
|
-
}, []);
|
|
105
|
+
const { dataSourceName, isDataSourceUnavailable, nameValue } = useMemo(() => {
|
|
106
|
+
return getBlockResourceInfo(model, t);
|
|
107
|
+
}, [model, t]);
|
|
108
|
+
|
|
56
109
|
const messageValue = useMemo(() => {
|
|
57
110
|
return t(`The {{type}} "{{name}}" may have been deleted. Please remove this {{blockType}}.`, {
|
|
58
111
|
type: t('Collection'),
|
|
@@ -60,6 +113,14 @@ export function BlockDeletePlaceholder() {
|
|
|
60
113
|
blockType: t('Block'),
|
|
61
114
|
}).replaceAll('>', '>');
|
|
62
115
|
}, [nameValue, t]);
|
|
116
|
+
|
|
117
|
+
if (isDataSourceUnavailable) {
|
|
118
|
+
if (!model.context.flowSettingsEnabled) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return <DataSourceUnavailablePlaceholder dataSourceName={dataSourceName} />;
|
|
122
|
+
}
|
|
123
|
+
|
|
63
124
|
return (
|
|
64
125
|
<BlockItemCard>
|
|
65
126
|
<Result status="404" subTitle={messageValue}></Result>
|
|
@@ -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 { FlowModelProvider } from '@nocobase/flow-engine';
|
|
11
|
+
import { render, screen } from '@testing-library/react';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { BlockDeletePlaceholder } from '../BlockPlaceholder';
|
|
14
|
+
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({
|
|
17
|
+
t: (key: string, values?: Record<string, string>) => {
|
|
18
|
+
return key.replace(/\{\{(\w+)\}\}/g, (_, name) => values?.[name] ?? '');
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
function createModel(options: {
|
|
24
|
+
dataSourceKey?: string;
|
|
25
|
+
collectionName?: string;
|
|
26
|
+
dataSource?: { key: string; displayName?: string };
|
|
27
|
+
flowSettingsEnabled?: boolean;
|
|
28
|
+
}) {
|
|
29
|
+
const model: any = {
|
|
30
|
+
context: {
|
|
31
|
+
flowSettingsEnabled: options.flowSettingsEnabled,
|
|
32
|
+
},
|
|
33
|
+
dataSource: options.dataSource,
|
|
34
|
+
resource: {
|
|
35
|
+
resourceName: options.collectionName,
|
|
36
|
+
},
|
|
37
|
+
getResourceSettingsInitParams: () => ({
|
|
38
|
+
dataSourceKey: options.dataSourceKey,
|
|
39
|
+
collectionName: options.collectionName,
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
model.context.blockModel = model;
|
|
43
|
+
return model;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('BlockPlaceholder', () => {
|
|
47
|
+
it('should render data source unavailable placeholder in configuration mode when the configured data source is missing', () => {
|
|
48
|
+
const model = createModel({
|
|
49
|
+
dataSourceKey: 'external-mysql',
|
|
50
|
+
collectionName: 'orders',
|
|
51
|
+
flowSettingsEnabled: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
render(
|
|
55
|
+
<FlowModelProvider model={model}>
|
|
56
|
+
<BlockDeletePlaceholder />
|
|
57
|
+
</FlowModelProvider>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(
|
|
61
|
+
screen.getByText(
|
|
62
|
+
'The data source "external-mysql" used by this block is disabled or unavailable. Enable the data source to display this block.',
|
|
63
|
+
),
|
|
64
|
+
).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should hide data source unavailable block outside configuration mode', () => {
|
|
68
|
+
const model = createModel({
|
|
69
|
+
dataSourceKey: 'external-mysql',
|
|
70
|
+
collectionName: 'orders',
|
|
71
|
+
flowSettingsEnabled: false,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<FlowModelProvider model={model}>
|
|
76
|
+
<BlockDeletePlaceholder />
|
|
77
|
+
</FlowModelProvider>,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
screen.queryByText(
|
|
82
|
+
'The data source "external-mysql" used by this block is disabled or unavailable. Enable the data source to display this block.',
|
|
83
|
+
),
|
|
84
|
+
).not.toBeInTheDocument();
|
|
85
|
+
expect(container).toBeEmptyDOMElement();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should keep collection deleted placeholder when the data source is available', () => {
|
|
89
|
+
const model = createModel({
|
|
90
|
+
dataSourceKey: 'external-mysql',
|
|
91
|
+
collectionName: 'orders',
|
|
92
|
+
dataSource: { key: 'external-mysql', displayName: 'External MySQL' },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
render(
|
|
96
|
+
<FlowModelProvider model={model}>
|
|
97
|
+
<BlockDeletePlaceholder />
|
|
98
|
+
</FlowModelProvider>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
screen.getByText('The Collection "External MySQL > orders" may have been deleted. Please remove this Block.'),
|
|
103
|
+
).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
});
|