@nocobase/flow-engine 2.1.0-alpha.40 → 2.1.0-alpha.46
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/lib/FlowContextProvider.d.ts +5 -1
- package/lib/FlowContextProvider.js +9 -2
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +84 -32
- package/lib/components/subModel/LazyDropdown.js +208 -16
- package/lib/components/subModel/utils.d.ts +1 -0
- package/lib/components/subModel/utils.js +6 -2
- package/lib/data-source/index.d.ts +9 -0
- package/lib/data-source/index.js +12 -0
- package/lib/executor/FlowExecutor.js +0 -3
- package/lib/flowContext.d.ts +6 -1
- package/lib/flowContext.js +38 -6
- package/lib/flowEngine.d.ts +4 -3
- package/lib/flowEngine.js +72 -40
- package/lib/models/flowModel.js +48 -16
- package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
- package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
- package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
- package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/base.js +464 -29
- package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
- package/lib/runjs-context/contexts/elementDoc.js +152 -0
- package/lib/utils/loadedPageCache.d.ts +24 -0
- package/lib/utils/loadedPageCache.js +139 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
- package/lib/utils/parsePathnameToViewParams.js +28 -4
- package/lib/views/ViewNavigation.d.ts +12 -2
- package/lib/views/ViewNavigation.js +22 -7
- package/lib/views/createViewMeta.js +114 -50
- package/lib/views/inheritLayoutContext.d.ts +10 -0
- package/lib/views/inheritLayoutContext.js +50 -0
- package/lib/views/useDialog.js +2 -0
- package/lib/views/useDrawer.js +2 -0
- package/lib/views/usePage.js +2 -0
- package/package.json +4 -4
- package/src/FlowContextProvider.tsx +9 -1
- package/src/__tests__/createViewMeta.popup.test.ts +115 -1
- package/src/__tests__/flowContext.test.ts +23 -0
- package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
- package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
- package/src/__tests__/runjsContext.test.ts +18 -0
- package/src/__tests__/runjsContextImplementations.test.ts +9 -2
- package/src/__tests__/runjsLocales.test.ts +6 -5
- package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +90 -38
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +155 -5
- package/src/components/subModel/LazyDropdown.tsx +237 -16
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +254 -1
- package/src/components/subModel/utils.ts +6 -1
- package/src/data-source/index.ts +18 -0
- package/src/executor/FlowExecutor.ts +0 -3
- package/src/executor/__tests__/flowExecutor.test.ts +26 -0
- package/src/flowContext.ts +43 -6
- package/src/flowEngine.ts +75 -38
- package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
- package/src/models/__tests__/flowModel.test.ts +46 -62
- package/src/models/flowModel.tsx +65 -32
- package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
- package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
- package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
- package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/base.ts +467 -31
- package/src/runjs-context/contexts/elementDoc.ts +130 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
- package/src/utils/loadedPageCache.ts +147 -0
- package/src/utils/parsePathnameToViewParams.ts +45 -5
- package/src/views/ViewNavigation.ts +40 -7
- package/src/views/__tests__/ViewNavigation.test.ts +52 -0
- package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
- package/src/views/createViewMeta.ts +106 -34
- package/src/views/inheritLayoutContext.ts +26 -0
- package/src/views/useDialog.tsx +2 -0
- package/src/views/useDrawer.tsx +2 -0
- package/src/views/usePage.tsx +2 -0
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
-
import { render, cleanup, waitFor, act } from '@testing-library/react';
|
|
10
|
+
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
|
13
11
|
import { App, ConfigProvider } from 'antd';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
14
|
|
|
15
15
|
import { FlowEngine } from '../../../../../flowEngine';
|
|
16
16
|
import { FlowModel } from '../../../../../models/flowModel';
|
|
@@ -141,6 +141,86 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
141
141
|
vi.clearAllMocks();
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
it('defers nested configurable step resolution and clears stale config while closed', async () => {
|
|
145
|
+
class TestFlowModel extends FlowModel {}
|
|
146
|
+
|
|
147
|
+
const engine = new FlowEngine();
|
|
148
|
+
const model = new TestFlowModel({ uid: 'model-lazy-settings', flowEngine: engine });
|
|
149
|
+
const hideInSettings = vi.fn((ctx) => !!ctx.getStepParams('general')?.hidden);
|
|
150
|
+
const uiSchema = vi.fn(() => ({
|
|
151
|
+
field: { type: 'string', 'x-component': 'Input' },
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
TestFlowModel.registerFlow({
|
|
155
|
+
key: 'lazyFlow',
|
|
156
|
+
title: 'Lazy Flow',
|
|
157
|
+
steps: {
|
|
158
|
+
general: {
|
|
159
|
+
title: 'General',
|
|
160
|
+
hideInSettings,
|
|
161
|
+
uiSchema,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const { getByLabelText } = render(
|
|
167
|
+
React.createElement(
|
|
168
|
+
ConfigProvider as any,
|
|
169
|
+
null,
|
|
170
|
+
React.createElement(
|
|
171
|
+
App as any,
|
|
172
|
+
null,
|
|
173
|
+
React.createElement(DefaultSettingsIcon as any, { model, menuLevels: 2 }),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(getByLabelText('flows-settings')).toBeTruthy();
|
|
179
|
+
expect(hideInSettings).not.toHaveBeenCalled();
|
|
180
|
+
expect(uiSchema).not.toHaveBeenCalled();
|
|
181
|
+
|
|
182
|
+
await act(async () => {
|
|
183
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(hideInSettings).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(uiSchema).toHaveBeenCalledTimes(1);
|
|
189
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
190
|
+
const items = (menu?.items || []) as any[];
|
|
191
|
+
expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await act(async () => {
|
|
195
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(false, { source: 'trigger' });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
200
|
+
const items = (menu?.items || []) as any[];
|
|
201
|
+
expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await act(async () => {
|
|
205
|
+
model.setStepParams('lazyFlow', 'general', { hidden: true });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(hideInSettings).toHaveBeenCalledTimes(1);
|
|
209
|
+
expect(uiSchema).toHaveBeenCalledTimes(1);
|
|
210
|
+
|
|
211
|
+
await act(async () => {
|
|
212
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(hideInSettings).toHaveBeenCalledTimes(2);
|
|
217
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
218
|
+
const items = (menu?.items || []) as any[];
|
|
219
|
+
expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
expect(uiSchema).toHaveBeenCalledTimes(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
144
224
|
it('excludes instance (dynamic) flows from the settings menu', async () => {
|
|
145
225
|
class TestFlowModel extends FlowModel {}
|
|
146
226
|
|
|
@@ -720,6 +800,10 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
720
800
|
),
|
|
721
801
|
);
|
|
722
802
|
|
|
803
|
+
await act(async () => {
|
|
804
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
|
|
805
|
+
});
|
|
806
|
+
|
|
723
807
|
await waitFor(() => {
|
|
724
808
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
725
809
|
expect(menu).toBeTruthy();
|
|
@@ -800,14 +884,16 @@ describe('DefaultSettingsIcon - extra menu items', () => {
|
|
|
800
884
|
await waitFor(() => {
|
|
801
885
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
802
886
|
const items = (menu?.items || []) as any[];
|
|
803
|
-
|
|
887
|
+
const extraActionItem = items.find((it) => String(it.key || '') === 'extra-action');
|
|
888
|
+
expect(extraActionItem).toBeTruthy();
|
|
889
|
+
expect(extraActionItem.onClick).toBeUndefined();
|
|
804
890
|
});
|
|
805
891
|
|
|
806
892
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
807
893
|
await act(async () => {
|
|
808
894
|
menu.onClick?.({ key: 'extra-action' });
|
|
809
895
|
});
|
|
810
|
-
expect(onClick).
|
|
896
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
811
897
|
expect((globalThis as any).__lastDropdownOpen).toBe(false);
|
|
812
898
|
} finally {
|
|
813
899
|
dispose?.();
|
|
@@ -880,6 +966,7 @@ describe('DefaultSettingsIcon - extra menu items', () => {
|
|
|
880
966
|
'insert-after',
|
|
881
967
|
'insert-inner',
|
|
882
968
|
]);
|
|
969
|
+
expect((nested.children || []).find((it) => String(it.key || '') === 'insert-before')?.onClick).toBeUndefined();
|
|
883
970
|
expect((nested.children || []).find((it) => String(it.key || '') === 'insert-inner')?.disabled).toBe(true);
|
|
884
971
|
});
|
|
885
972
|
|
|
@@ -900,4 +987,67 @@ describe('DefaultSettingsIcon - extra menu items', () => {
|
|
|
900
987
|
dispose?.();
|
|
901
988
|
}
|
|
902
989
|
});
|
|
990
|
+
|
|
991
|
+
it('uses common extra actions to defer nested configurable step resolution', async () => {
|
|
992
|
+
const onClick = vi.fn();
|
|
993
|
+
|
|
994
|
+
class TestFlowModel extends FlowModel {}
|
|
995
|
+
const dispose = TestFlowModel.registerExtraMenuItems({
|
|
996
|
+
group: 'common-actions',
|
|
997
|
+
sort: 10,
|
|
998
|
+
items: [{ key: 'extra-action', label: 'Extra Action', onClick }],
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
const engine = new FlowEngine();
|
|
1002
|
+
const model = new TestFlowModel({ uid: 'm-extra-lazy', flowEngine: engine });
|
|
1003
|
+
const uiSchema = vi.fn(() => ({
|
|
1004
|
+
f: { type: 'string', 'x-component': 'Input' },
|
|
1005
|
+
}));
|
|
1006
|
+
|
|
1007
|
+
TestFlowModel.registerFlow({
|
|
1008
|
+
key: 'flow',
|
|
1009
|
+
title: 'Flow',
|
|
1010
|
+
steps: { s: { title: 'S', uiSchema } },
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
const { getByLabelText } = render(
|
|
1015
|
+
React.createElement(
|
|
1016
|
+
ConfigProvider as any,
|
|
1017
|
+
null,
|
|
1018
|
+
React.createElement(
|
|
1019
|
+
App as any,
|
|
1020
|
+
null,
|
|
1021
|
+
React.createElement(DefaultSettingsIcon as any, {
|
|
1022
|
+
model,
|
|
1023
|
+
menuLevels: 2,
|
|
1024
|
+
showCopyUidButton: false,
|
|
1025
|
+
showDeleteButton: false,
|
|
1026
|
+
}),
|
|
1027
|
+
),
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
await waitFor(() => {
|
|
1032
|
+
expect(getByLabelText('flows-settings')).toBeTruthy();
|
|
1033
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
1034
|
+
const items = (menu?.items || []) as any[];
|
|
1035
|
+
expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
|
|
1036
|
+
});
|
|
1037
|
+
expect(uiSchema).not.toHaveBeenCalled();
|
|
1038
|
+
|
|
1039
|
+
await act(async () => {
|
|
1040
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
await waitFor(() => {
|
|
1044
|
+
expect(uiSchema).toHaveBeenCalledTimes(1);
|
|
1045
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
1046
|
+
const items = (menu?.items || []) as any[];
|
|
1047
|
+
expect(items.some((it) => String(it.key || '') === 'flow:s')).toBe(true);
|
|
1048
|
+
});
|
|
1049
|
+
} finally {
|
|
1050
|
+
dispose?.();
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
903
1053
|
});
|
|
@@ -247,18 +247,75 @@ const useKeepDropdownOpen = () => {
|
|
|
247
247
|
*/
|
|
248
248
|
const useMenuSearch = () => {
|
|
249
249
|
const [searchValues, setSearchValues] = useState<Record<string, string>>({});
|
|
250
|
+
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
250
251
|
const [isSearching, setIsSearching] = useState(false);
|
|
252
|
+
const [composingCount, setComposingCount] = useState(0);
|
|
253
|
+
const composingKeysRef = useRef<Set<string>>(new Set());
|
|
251
254
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
252
255
|
|
|
253
|
-
const updateSearchValue = (key: string, value: string) => {
|
|
256
|
+
const updateSearchValue = useCallback((key: string, value: string) => {
|
|
254
257
|
setIsSearching(true);
|
|
258
|
+
setInputValues((prev) => ({ ...prev, [key]: value }));
|
|
255
259
|
setSearchValues((prev) => ({ ...prev, [key]: value }));
|
|
256
260
|
|
|
257
261
|
if (searchTimeoutRef.current) {
|
|
258
262
|
clearTimeout(searchTimeoutRef.current);
|
|
259
263
|
}
|
|
260
264
|
searchTimeoutRef.current = setTimeout(() => setIsSearching(false), 300);
|
|
261
|
-
};
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const startComposition = useCallback((key: string) => {
|
|
268
|
+
composingKeysRef.current.add(key);
|
|
269
|
+
setIsSearching(true);
|
|
270
|
+
setComposingCount(composingKeysRef.current.size);
|
|
271
|
+
if (searchTimeoutRef.current) {
|
|
272
|
+
clearTimeout(searchTimeoutRef.current);
|
|
273
|
+
searchTimeoutRef.current = null;
|
|
274
|
+
}
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
const endComposition = useCallback(
|
|
278
|
+
(key: string, value: string) => {
|
|
279
|
+
composingKeysRef.current.delete(key);
|
|
280
|
+
setComposingCount(composingKeysRef.current.size);
|
|
281
|
+
updateSearchValue(key, value);
|
|
282
|
+
},
|
|
283
|
+
[updateSearchValue],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const updateInputValue = useCallback((key: string, value: string) => {
|
|
287
|
+
setInputValues((prev) => ({ ...prev, [key]: value }));
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
290
|
+
const clearSearchValue = useCallback((key: string) => {
|
|
291
|
+
composingKeysRef.current.delete(key);
|
|
292
|
+
setComposingCount(composingKeysRef.current.size);
|
|
293
|
+
|
|
294
|
+
setInputValues((prev) => {
|
|
295
|
+
if (!(key in prev)) return prev;
|
|
296
|
+
const next = { ...prev };
|
|
297
|
+
delete next[key];
|
|
298
|
+
return next;
|
|
299
|
+
});
|
|
300
|
+
setSearchValues((prev) => {
|
|
301
|
+
if (!(key in prev)) return prev;
|
|
302
|
+
const next = { ...prev };
|
|
303
|
+
delete next[key];
|
|
304
|
+
return next;
|
|
305
|
+
});
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
const clearAllSearchValues = useCallback(() => {
|
|
309
|
+
composingKeysRef.current.clear();
|
|
310
|
+
setComposingCount(0);
|
|
311
|
+
setInputValues({});
|
|
312
|
+
setSearchValues({});
|
|
313
|
+
setIsSearching(false);
|
|
314
|
+
}, []);
|
|
315
|
+
|
|
316
|
+
const isComposing = useCallback((key?: string) => {
|
|
317
|
+
return key ? composingKeysRef.current.has(key) : composingKeysRef.current.size > 0;
|
|
318
|
+
}, []);
|
|
262
319
|
|
|
263
320
|
useEffect(() => {
|
|
264
321
|
return () => {
|
|
@@ -270,8 +327,15 @@ const useMenuSearch = () => {
|
|
|
270
327
|
|
|
271
328
|
return {
|
|
272
329
|
searchValues,
|
|
273
|
-
|
|
330
|
+
inputValues,
|
|
331
|
+
isSearching: isSearching || composingCount > 0,
|
|
274
332
|
updateSearchValue,
|
|
333
|
+
updateInputValue,
|
|
334
|
+
startComposition,
|
|
335
|
+
endComposition,
|
|
336
|
+
clearSearchValue,
|
|
337
|
+
clearAllSearchValues,
|
|
338
|
+
isComposing,
|
|
275
339
|
};
|
|
276
340
|
};
|
|
277
341
|
|
|
@@ -390,23 +454,70 @@ const createSearchItem = (
|
|
|
390
454
|
currentSearchValue: string,
|
|
391
455
|
menuVisible: boolean,
|
|
392
456
|
t: (key: string) => string,
|
|
393
|
-
|
|
457
|
+
searchHandlers: {
|
|
458
|
+
updateSearchValue: (key: string, value: string) => void;
|
|
459
|
+
updateInputValue: (key: string, value: string) => void;
|
|
460
|
+
startComposition: (key: string) => void;
|
|
461
|
+
endComposition: (key: string, value: string) => void;
|
|
462
|
+
isComposing: (key: string) => boolean;
|
|
463
|
+
},
|
|
464
|
+
activateSearchSubmenu: (key: string) => void,
|
|
465
|
+
deactivateSearchSubmenu: (key: string) => void,
|
|
466
|
+
shouldActivateSearchSubmenu: boolean,
|
|
394
467
|
) => ({
|
|
395
468
|
key: `${searchKey}-search`,
|
|
396
469
|
type: 'group' as const,
|
|
397
470
|
label: (
|
|
398
|
-
<div>
|
|
471
|
+
<div onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
|
399
472
|
<SearchInputWithAutoFocus
|
|
400
473
|
visible={menuVisible}
|
|
401
474
|
variant="borderless"
|
|
402
475
|
allowClear
|
|
403
476
|
placeholder={t(item.searchPlaceholder || 'Search')}
|
|
404
477
|
value={currentSearchValue}
|
|
478
|
+
onFocus={(e) => {
|
|
479
|
+
e.stopPropagation();
|
|
480
|
+
}}
|
|
405
481
|
onChange={(e) => {
|
|
406
482
|
e.stopPropagation();
|
|
407
|
-
|
|
483
|
+
const value = e.target.value;
|
|
484
|
+
if (shouldActivateSearchSubmenu) {
|
|
485
|
+
activateSearchSubmenu(searchKey);
|
|
486
|
+
}
|
|
487
|
+
if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
|
|
488
|
+
searchHandlers.updateInputValue(searchKey, value);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (!value && shouldActivateSearchSubmenu) {
|
|
492
|
+
deactivateSearchSubmenu(searchKey);
|
|
493
|
+
}
|
|
494
|
+
searchHandlers.updateSearchValue(searchKey, value);
|
|
495
|
+
}}
|
|
496
|
+
onCompositionStart={(e) => {
|
|
497
|
+
e.stopPropagation();
|
|
498
|
+
if (shouldActivateSearchSubmenu) {
|
|
499
|
+
activateSearchSubmenu(searchKey);
|
|
500
|
+
}
|
|
501
|
+
searchHandlers.startComposition(searchKey);
|
|
502
|
+
}}
|
|
503
|
+
onCompositionEnd={(e) => {
|
|
504
|
+
e.stopPropagation();
|
|
505
|
+
const value = e.currentTarget.value;
|
|
506
|
+
if (shouldActivateSearchSubmenu) {
|
|
507
|
+
if (value) {
|
|
508
|
+
activateSearchSubmenu(searchKey);
|
|
509
|
+
} else {
|
|
510
|
+
deactivateSearchSubmenu(searchKey);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
searchHandlers.endComposition(searchKey, value);
|
|
514
|
+
}}
|
|
515
|
+
onClick={(e) => {
|
|
516
|
+
e.stopPropagation();
|
|
517
|
+
}}
|
|
518
|
+
onKeyDown={(e) => {
|
|
519
|
+
e.stopPropagation();
|
|
408
520
|
}}
|
|
409
|
-
onClick={(e) => e.stopPropagation()}
|
|
410
521
|
onMouseDown={(e) => {
|
|
411
522
|
// 防止菜单聚焦丢失或页面滚动
|
|
412
523
|
e.stopPropagation();
|
|
@@ -441,14 +552,22 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
|
|
|
441
552
|
|
|
442
553
|
// 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
|
|
443
554
|
const DROPDOWN_PERSIST_TTL_MS = 350;
|
|
555
|
+
const SUBMENU_CLOSE_DELAY = 0.05;
|
|
556
|
+
const SUBMENU_MOTION_DISABLED = {
|
|
557
|
+
motionEnter: false,
|
|
558
|
+
motionLeave: false,
|
|
559
|
+
};
|
|
444
560
|
const dropdownPersistRegistry: Map<string, number> = new Map();
|
|
445
561
|
|
|
446
562
|
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
|
|
447
563
|
const engine = useFlowEngine();
|
|
448
564
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
449
565
|
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
|
|
566
|
+
const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
|
|
450
567
|
const [rootItems, setRootItems] = useState<Item[]>([]);
|
|
451
568
|
const [rootLoading, setRootLoading] = useState(false);
|
|
569
|
+
const closeByOutsideClickRef = useRef(false);
|
|
570
|
+
const skipPreserveActiveSearchRef = useRef(false);
|
|
452
571
|
const dropdownMaxHeight = useNiceDropdownMaxHeight();
|
|
453
572
|
const t = engine.translate.bind(engine);
|
|
454
573
|
|
|
@@ -463,23 +582,105 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
463
582
|
openKeys,
|
|
464
583
|
refreshKeys,
|
|
465
584
|
);
|
|
466
|
-
const
|
|
585
|
+
const searchHandlers = useMenuSearch();
|
|
586
|
+
const { searchValues, inputValues, clearSearchValue, clearAllSearchValues } = searchHandlers;
|
|
467
587
|
const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
|
|
468
588
|
useSubmenuStyles(menuVisible, dropdownMaxHeight);
|
|
589
|
+
|
|
590
|
+
const closeMenu = useCallback(() => {
|
|
591
|
+
setMenuVisible(false);
|
|
592
|
+
setActiveSearchKey(null);
|
|
593
|
+
setOpenKeys(new Set());
|
|
594
|
+
clearAllSearchValues();
|
|
595
|
+
}, [clearAllSearchValues]);
|
|
596
|
+
|
|
597
|
+
const activateSearchSubmenu = useCallback((key: string) => {
|
|
598
|
+
setActiveSearchKey(key);
|
|
599
|
+
setOpenKeys((prev) => {
|
|
600
|
+
if (prev.has(key)) return prev;
|
|
601
|
+
const next = new Set(prev);
|
|
602
|
+
next.add(key);
|
|
603
|
+
return next;
|
|
604
|
+
});
|
|
605
|
+
}, []);
|
|
606
|
+
|
|
607
|
+
const deactivateSearchSubmenu = useCallback((key: string) => {
|
|
608
|
+
setActiveSearchKey((prev) => (prev === key ? null : prev));
|
|
609
|
+
}, []);
|
|
610
|
+
|
|
611
|
+
const closeActiveSearchForPath = useCallback(
|
|
612
|
+
(keyPath: string) => {
|
|
613
|
+
if (
|
|
614
|
+
!activeSearchKey ||
|
|
615
|
+
keyPath === activeSearchKey ||
|
|
616
|
+
keyPath.startsWith(`${activeSearchKey}/`) ||
|
|
617
|
+
activeSearchKey.startsWith(`${keyPath}/`)
|
|
618
|
+
) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
skipPreserveActiveSearchRef.current = true;
|
|
623
|
+
clearSearchValue(activeSearchKey);
|
|
624
|
+
setActiveSearchKey(null);
|
|
625
|
+
setOpenKeys((prev) => {
|
|
626
|
+
const next = new Set(prev);
|
|
627
|
+
next.delete(activeSearchKey);
|
|
628
|
+
return next;
|
|
629
|
+
});
|
|
630
|
+
},
|
|
631
|
+
[activeSearchKey, clearSearchValue],
|
|
632
|
+
);
|
|
633
|
+
|
|
469
634
|
const handleMenuOpenChange = useCallback(
|
|
470
635
|
(nextOpenKeys: string[]) => {
|
|
471
|
-
|
|
636
|
+
let normalized = normalizeOpenKeys(nextOpenKeys);
|
|
637
|
+
if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
|
|
638
|
+
if (normalized.length || skipPreserveActiveSearchRef.current) {
|
|
639
|
+
clearSearchValue(activeSearchKey);
|
|
640
|
+
setActiveSearchKey(null);
|
|
641
|
+
} else {
|
|
642
|
+
normalized = [activeSearchKey];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!normalized.length && shouldPreventClose()) {
|
|
472
647
|
dropdownMenuProps.onOpenChange?.(Array.from(openKeys));
|
|
648
|
+
skipPreserveActiveSearchRef.current = false;
|
|
473
649
|
return;
|
|
474
650
|
}
|
|
475
651
|
|
|
476
|
-
|
|
652
|
+
Array.from(openKeys).forEach((key) => {
|
|
653
|
+
if (!normalized.includes(key)) {
|
|
654
|
+
clearSearchValue(key);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
477
657
|
setOpenKeys(new Set(normalized));
|
|
478
658
|
dropdownMenuProps.onOpenChange?.(normalized);
|
|
659
|
+
skipPreserveActiveSearchRef.current = false;
|
|
479
660
|
},
|
|
480
|
-
[dropdownMenuProps, openKeys, shouldPreventClose],
|
|
661
|
+
[activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
|
|
481
662
|
);
|
|
482
663
|
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
if (!menuVisible) return;
|
|
666
|
+
|
|
667
|
+
const markOutsideClick = (event: MouseEvent | PointerEvent) => {
|
|
668
|
+
const target = event.target as HTMLElement | null;
|
|
669
|
+
const isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
|
|
670
|
+
closeByOutsideClickRef.current = isOutside;
|
|
671
|
+
if (isOutside) {
|
|
672
|
+
closeMenu();
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
document.addEventListener('pointerdown', markOutsideClick, true);
|
|
677
|
+
document.addEventListener('mousedown', markOutsideClick, true);
|
|
678
|
+
return () => {
|
|
679
|
+
document.removeEventListener('pointerdown', markOutsideClick, true);
|
|
680
|
+
document.removeEventListener('mousedown', markOutsideClick, true);
|
|
681
|
+
};
|
|
682
|
+
}, [closeMenu, menuVisible]);
|
|
683
|
+
|
|
483
684
|
// 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
|
|
484
685
|
useEffect(() => {
|
|
485
686
|
if (!persistKey) return;
|
|
@@ -542,6 +743,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
542
743
|
): any[] {
|
|
543
744
|
const searchKey = keyPath;
|
|
544
745
|
const currentSearchValue = searchValues[searchKey] || '';
|
|
746
|
+
const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
|
|
747
|
+
const shouldActivateSearchSubmenu = !(item.type === 'group' && path.length === 0);
|
|
545
748
|
|
|
546
749
|
// 递归过滤:当 child 为分组时,会继续向下过滤其 children;
|
|
547
750
|
// 仅保留自身匹配或存在匹配子项的分组。
|
|
@@ -566,7 +769,17 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
566
769
|
: children;
|
|
567
770
|
|
|
568
771
|
const resolvedFiltered = resolve(filteredChildren, [...path, item.key]);
|
|
569
|
-
const searchItem = createSearchItem(
|
|
772
|
+
const searchItem = createSearchItem(
|
|
773
|
+
item,
|
|
774
|
+
searchKey,
|
|
775
|
+
currentInputValue,
|
|
776
|
+
menuVisible,
|
|
777
|
+
t,
|
|
778
|
+
searchHandlers,
|
|
779
|
+
activateSearchSubmenu,
|
|
780
|
+
deactivateSearchSubmenu,
|
|
781
|
+
shouldActivateSearchSubmenu,
|
|
782
|
+
);
|
|
570
783
|
const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
|
|
571
784
|
|
|
572
785
|
if (currentSearchValue && resolvedFiltered.length === 0) {
|
|
@@ -641,6 +854,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
641
854
|
key: keyPath,
|
|
642
855
|
label,
|
|
643
856
|
onClick: (info: any) => {},
|
|
857
|
+
onMouseEnter: () => closeActiveSearchForPath(keyPath),
|
|
644
858
|
children: buildSearchChildren(children, item, keyPath, path, menuVisible, resolveItems),
|
|
645
859
|
};
|
|
646
860
|
}
|
|
@@ -694,6 +908,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
694
908
|
onClick: (info: any) => {
|
|
695
909
|
if (!itemShouldKeepOpen) handleLeafClick(info);
|
|
696
910
|
},
|
|
911
|
+
onMouseEnter: () => closeActiveSearchForPath(keyPath),
|
|
697
912
|
onMouseDown: () => {
|
|
698
913
|
if (!itemShouldKeepOpen) {
|
|
699
914
|
return;
|
|
@@ -748,6 +963,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
748
963
|
...dropdownMenuProps,
|
|
749
964
|
openKeys: Array.from(openKeys),
|
|
750
965
|
items: items,
|
|
966
|
+
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
|
|
967
|
+
motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
|
|
751
968
|
onClick: () => {},
|
|
752
969
|
onOpenChange: handleMenuOpenChange,
|
|
753
970
|
style: {
|
|
@@ -756,9 +973,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
756
973
|
...dropdownMenuProps?.style,
|
|
757
974
|
},
|
|
758
975
|
}}
|
|
759
|
-
onOpenChange={(visible) => {
|
|
760
|
-
|
|
761
|
-
if (!visible && isSearching) {
|
|
976
|
+
onOpenChange={(visible, info) => {
|
|
977
|
+
if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
|
|
762
978
|
return;
|
|
763
979
|
}
|
|
764
980
|
|
|
@@ -767,7 +983,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
767
983
|
return;
|
|
768
984
|
}
|
|
769
985
|
|
|
770
|
-
|
|
986
|
+
if (!visible) {
|
|
987
|
+
closeMenu();
|
|
988
|
+
} else {
|
|
989
|
+
setMenuVisible(visible);
|
|
990
|
+
}
|
|
991
|
+
closeByOutsideClickRef.current = false;
|
|
771
992
|
}}
|
|
772
993
|
>
|
|
773
994
|
{props.children}
|