@cccsaurora/howler-ui 2.18.0-dev.739 → 2.18.0-dev.748
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/api/index.d.ts +2 -0
- package/api/index.js +4 -2
- package/api/search/case.d.ts +4 -0
- package/api/search/case.js +8 -0
- package/api/search/facet/hit.d.ts +1 -3
- package/api/search/facet/index.d.ts +3 -1
- package/api/search/index.d.ts +2 -1
- package/api/search/index.js +2 -1
- package/api/v2/case/index.d.ts +8 -0
- package/api/v2/case/index.js +20 -0
- package/api/v2/case/items.d.ts +6 -0
- package/api/v2/case/items.js +18 -0
- package/api/v2/index.d.ts +4 -0
- package/api/v2/index.js +6 -0
- package/api/v2/search/facet.d.ts +3 -0
- package/api/v2/search/facet.js +12 -0
- package/api/v2/search/index.d.ts +5 -0
- package/api/v2/search/index.js +24 -0
- package/commons/components/leftnav/LeftNavDrawer.js +1 -1
- package/components/app/App.js +39 -7
- package/components/app/hooks/useMatchers.d.ts +1 -1
- package/components/app/hooks/useMatchers.js +23 -11
- package/components/app/hooks/useMatchers.test.js +22 -22
- package/components/app/hooks/useTitle.js +3 -3
- package/components/app/providers/FavouritesProvider.js +2 -2
- package/components/app/providers/ModalProvider.d.ts +1 -0
- package/components/app/providers/ParameterProvider.d.ts +9 -2
- package/components/app/providers/ParameterProvider.js +165 -240
- package/components/app/providers/ParameterProvider.test.js +346 -94
- package/components/app/providers/RecordProvider.d.ts +23 -0
- package/components/app/providers/{HitProvider.js → RecordProvider.js} +41 -41
- package/components/app/providers/{HitSearchProvider.d.ts → RecordSearchProvider.d.ts} +6 -6
- package/components/app/providers/{HitSearchProvider.js → RecordSearchProvider.js} +20 -23
- package/components/app/providers/{HitSearchProvider.test.js → RecordSearchProvider.test.js} +68 -65
- package/components/app/providers/UserListProvider.js +28 -8
- package/components/elements/ContextMenu.d.ts +56 -0
- package/components/elements/ContextMenu.js +109 -0
- package/components/elements/ContextMenu.test.js +215 -0
- package/components/{routes/overviews/OverviewEditor.js → elements/MarkdownEditor.js} +3 -3
- package/components/elements/ObjectDetails.d.ts +6 -0
- package/components/elements/{hit/HitDetails.js → ObjectDetails.js} +17 -17
- package/components/elements/PluginTypography.d.ts +2 -1
- package/components/elements/PluginTypography.js +3 -2
- package/components/elements/UserList.d.ts +5 -2
- package/components/elements/UserList.js +18 -8
- package/components/elements/addons/search/phrase/Phrase.js +1 -1
- package/components/elements/case/CaseCard.d.ts +12 -0
- package/components/elements/case/CaseCard.js +42 -0
- package/components/elements/case/CasePreview.d.ts +6 -0
- package/components/elements/case/CasePreview.js +17 -0
- package/components/elements/case/StatusIcon.d.ts +5 -0
- package/components/elements/case/StatusIcon.js +13 -0
- package/components/elements/display/ChipPopper.d.ts +1 -1
- package/components/elements/display/HowlerCard.js +1 -1
- package/components/elements/display/Modal.js +2 -0
- package/components/elements/hit/HitActions.js +4 -4
- package/components/elements/hit/HitBanner.d.ts +1 -0
- package/components/elements/hit/HitBanner.js +29 -49
- package/components/elements/hit/HitCard.d.ts +2 -0
- package/components/elements/hit/HitCard.js +7 -7
- package/components/elements/hit/HitLabels.js +2 -2
- package/components/elements/hit/HitOutline.d.ts +1 -0
- package/components/elements/hit/HitOutline.js +3 -3
- package/components/elements/hit/{HitQuickSearch.d.ts → HitPreview.d.ts} +3 -3
- package/components/elements/hit/{HitQuickSearch.js → HitPreview.js} +10 -4
- package/components/elements/hit/HitSummary.d.ts +2 -1
- package/components/elements/hit/HitSummary.js +6 -5
- package/components/elements/hit/aggregate/HitGraph.js +8 -8
- package/components/elements/hit/elements/AnalyticLink.d.ts +9 -0
- package/components/elements/hit/elements/AnalyticLink.js +22 -0
- package/components/elements/hit/outlines/DefaultOutline.js +1 -1
- package/components/elements/hit/related/RelatedRecords.js +63 -0
- package/components/elements/observable/ObservableCard.d.ts +6 -0
- package/components/elements/observable/ObservableCard.js +22 -0
- package/components/elements/observable/ObservablePreview.d.ts +6 -0
- package/components/elements/observable/ObservablePreview.js +12 -0
- package/components/elements/{hit/HitComments.d.ts → record/RecordComments.d.ts} +5 -4
- package/components/elements/{hit/HitComments.js → record/RecordComments.js} +29 -28
- package/components/{routes/hits/search/HitContextMenu.d.ts → elements/record/RecordContextMenu.d.ts} +3 -3
- package/components/elements/record/RecordContextMenu.js +247 -0
- package/components/elements/record/RecordContextMenu.test.d.ts +1 -0
- package/components/{routes/hits/search/HitContextMenu.test.js → elements/record/RecordContextMenu.test.js} +94 -39
- package/components/elements/record/RecordRelated.d.ts +7 -0
- package/components/elements/record/RecordRelated.js +34 -0
- package/components/elements/{hit/HitWorklog.d.ts → record/RecordWorklog.d.ts} +4 -3
- package/components/elements/{hit/HitWorklog.js → record/RecordWorklog.js} +15 -13
- package/components/elements/view/ViewTitle.d.ts +1 -0
- package/components/elements/view/ViewTitle.js +9 -2
- package/components/hooks/useHitActions.d.ts +1 -1
- package/components/hooks/useHitActions.js +4 -4
- package/components/hooks/useMyPreferences.js +10 -1
- package/components/hooks/useMySearch.js +2 -2
- package/components/hooks/useMySitemap.js +4 -1
- package/components/hooks/useMyTheme.js +9 -2
- package/components/hooks/{useHitSelection.d.ts → useRecordSelection.d.ts} +2 -2
- package/components/hooks/{useHitSelection.js → useRecordSelection.js} +12 -33
- package/components/hooks/useRelatedRecords.d.ts +13 -0
- package/components/hooks/useRelatedRecords.js +32 -0
- package/components/routes/action/edit/ActionEditor.js +2 -2
- package/components/routes/action/view/ActionSearch.js +1 -1
- package/components/routes/advanced/QueryBuilder.js +1 -1
- package/components/routes/advanced/QueryEditor.js +3 -3
- package/components/routes/advanced/historyCompletionProvider.js +3 -3
- package/components/routes/analytics/AnalyticDetails.js +2 -2
- package/components/routes/analytics/AnalyticSearch.js +1 -1
- package/components/routes/cases/CaseViewer.d.ts +2 -0
- package/components/routes/cases/CaseViewer.js +22 -0
- package/components/routes/cases/Cases.d.ts +2 -0
- package/components/routes/cases/Cases.js +101 -0
- package/components/routes/cases/constants.d.ts +5 -0
- package/components/routes/cases/constants.js +5 -0
- package/components/routes/cases/detail/AlertPanel.d.ts +6 -0
- package/components/routes/cases/detail/AlertPanel.js +33 -0
- package/components/routes/cases/detail/CaseAssets.d.ts +11 -0
- package/components/routes/cases/detail/CaseAssets.js +104 -0
- package/components/routes/cases/detail/CaseAssets.test.d.ts +1 -0
- package/components/routes/cases/detail/CaseAssets.test.js +167 -0
- package/components/routes/cases/detail/CaseDashboard.d.ts +7 -0
- package/components/routes/cases/detail/CaseDashboard.js +66 -0
- package/components/routes/cases/detail/CaseDetails.d.ts +6 -0
- package/components/routes/cases/detail/CaseDetails.js +61 -0
- package/components/routes/cases/detail/CaseOverview.d.ts +7 -0
- package/components/routes/cases/detail/CaseOverview.js +43 -0
- package/components/routes/cases/detail/CaseSidebar.d.ts +8 -0
- package/components/routes/cases/detail/CaseSidebar.js +107 -0
- package/components/routes/cases/detail/CaseSidebar.test.d.ts +1 -0
- package/components/routes/cases/detail/CaseSidebar.test.js +246 -0
- package/components/routes/cases/detail/CaseTask.d.ts +11 -0
- package/components/routes/cases/detail/CaseTask.js +57 -0
- package/components/routes/cases/detail/CaseTimeline.d.ts +12 -0
- package/components/routes/cases/detail/CaseTimeline.js +106 -0
- package/components/routes/cases/detail/CaseTimeline.test.d.ts +1 -0
- package/components/routes/cases/detail/CaseTimeline.test.js +227 -0
- package/components/routes/cases/detail/ItemPage.d.ts +6 -0
- package/components/routes/cases/detail/ItemPage.js +99 -0
- package/components/routes/cases/detail/RelatedCasePanel.d.ts +6 -0
- package/components/routes/cases/detail/RelatedCasePanel.js +34 -0
- package/components/routes/cases/detail/TaskPanel.d.ts +7 -0
- package/components/routes/cases/detail/TaskPanel.js +52 -0
- package/components/routes/cases/detail/aggregates/CaseAggregate.d.ts +11 -0
- package/components/routes/cases/detail/aggregates/CaseAggregate.js +24 -0
- package/components/routes/cases/detail/aggregates/SourceAggregate.d.ts +6 -0
- package/components/routes/cases/detail/aggregates/SourceAggregate.js +26 -0
- package/components/routes/cases/detail/assets/Asset.d.ts +14 -0
- package/components/routes/cases/detail/assets/Asset.js +12 -0
- package/components/routes/cases/detail/assets/Asset.test.d.ts +1 -0
- package/components/routes/cases/detail/assets/Asset.test.js +72 -0
- package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +20 -0
- package/components/routes/cases/detail/sidebar/CaseFolder.js +83 -0
- package/components/routes/cases/detail/sidebar/CaseFolder.test.d.ts +1 -0
- package/components/routes/cases/detail/sidebar/CaseFolder.test.js +295 -0
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.d.ts +34 -0
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.js +103 -0
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.d.ts +1 -0
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +363 -0
- package/components/routes/cases/detail/sidebar/FolderEntry.d.ts +25 -0
- package/components/routes/cases/detail/sidebar/FolderEntry.js +88 -0
- package/components/routes/cases/detail/sidebar/FolderEntry.test.d.ts +1 -0
- package/components/routes/cases/detail/sidebar/FolderEntry.test.js +206 -0
- package/components/routes/cases/detail/sidebar/RootDropZone.d.ts +5 -0
- package/components/routes/cases/detail/sidebar/RootDropZone.js +33 -0
- package/components/routes/cases/detail/sidebar/types.d.ts +9 -0
- package/components/routes/cases/detail/sidebar/utils.d.ts +3 -0
- package/components/routes/cases/detail/sidebar/utils.js +29 -0
- package/components/routes/cases/detail/sidebar/utils.test.d.ts +1 -0
- package/components/routes/cases/detail/sidebar/utils.test.js +82 -0
- package/components/routes/cases/hooks/useCase.d.ts +13 -0
- package/components/routes/cases/hooks/useCase.js +51 -0
- package/components/routes/cases/modals/AddToCaseModal.d.ts +7 -0
- package/components/routes/cases/modals/AddToCaseModal.js +62 -0
- package/components/routes/cases/modals/RenameItemModal.d.ts +9 -0
- package/components/routes/cases/modals/RenameItemModal.js +48 -0
- package/components/routes/cases/modals/ResolveModal.d.ts +7 -0
- package/components/routes/cases/modals/ResolveModal.js +115 -0
- package/components/routes/cases/modals/ResolveModal.test.d.ts +1 -0
- package/components/routes/cases/modals/ResolveModal.test.js +384 -0
- package/components/routes/dossiers/DossierEditor.js +2 -2
- package/components/routes/dossiers/DossierEditor.test.js +1 -1
- package/components/routes/help/ApiDocumentation.js +1 -1
- package/components/routes/help/HitBannerDocumentation.js +1 -0
- package/components/routes/help/HitDocumentation.js +1 -3
- package/components/routes/hits/search/InformationPane.d.ts +1 -0
- package/components/routes/hits/search/InformationPane.js +47 -60
- package/components/routes/hits/search/LayoutSettings.js +3 -3
- package/components/routes/hits/search/QuerySettings.js +2 -1
- package/components/routes/hits/search/QuerySettings.test.js +14 -9
- package/components/routes/hits/search/{HitBrowser.js → RecordBrowser.js} +9 -9
- package/components/routes/hits/search/{HitQuery.d.ts → RecordQuery.d.ts} +2 -2
- package/components/routes/hits/search/{HitQuery.js → RecordQuery.js} +6 -6
- package/components/routes/hits/search/SearchPane.js +26 -49
- package/components/routes/hits/search/ViewLink.js +3 -3
- package/components/routes/hits/search/ViewLink.test.js +8 -8
- package/components/routes/hits/search/grid/AddColumnModal.js +5 -4
- package/components/routes/hits/search/grid/EnhancedCell.d.ts +2 -1
- package/components/routes/hits/search/grid/EnhancedCell.js +2 -2
- package/components/routes/hits/search/grid/HitGrid.js +20 -18
- package/components/routes/hits/search/grid/{HitRow.d.ts → RecordRow.d.ts} +3 -2
- package/components/routes/hits/search/grid/{HitRow.js → RecordRow.js} +10 -8
- package/components/routes/hits/search/shared/IndexPicker.d.ts +2 -0
- package/components/routes/hits/search/shared/IndexPicker.js +20 -0
- package/components/routes/hits/view/HitViewer.js +12 -13
- package/components/routes/home/ViewCard.js +47 -41
- package/components/routes/observables/ObservableViewer.d.ts +7 -0
- package/components/routes/observables/ObservableViewer.js +27 -0
- package/components/routes/overviews/OverviewViewer.js +2 -2
- package/components/routes/views/ViewComposer.js +46 -19
- package/locales/en/translation.json +89 -3
- package/locales/fr/translation.json +87 -3
- package/models/WithMetadata.d.ts +2 -1
- package/models/entities/generated/AttachmentsFile.d.ts +12 -0
- package/models/entities/generated/Case.d.ts +28 -0
- package/models/entities/generated/DestinationOriginal.d.ts +19 -0
- package/models/entities/generated/EmailAttachment.d.ts +8 -0
- package/models/entities/generated/EmailParent.d.ts +19 -0
- package/models/entities/generated/Enrichments.d.ts +7 -0
- package/models/entities/generated/EnrichmentsIndicator.d.ts +21 -0
- package/models/entities/generated/Hit.d.ts +1 -0
- package/models/entities/generated/Howler.d.ts +0 -4
- package/models/entities/generated/HttpResponse.d.ts +11 -0
- package/models/entities/generated/Item.d.ts +9 -0
- package/models/entities/generated/Observable.d.ts +85 -0
- package/models/entities/generated/ObservableCloud.d.ts +20 -0
- package/models/entities/generated/ObservableDestination.d.ts +23 -0
- package/models/entities/generated/ObservableEmail.d.ts +30 -0
- package/models/entities/generated/ObservableFile.d.ts +36 -0
- package/models/entities/generated/ObservableHowler.d.ts +43 -0
- package/models/entities/generated/ObservableHttp.d.ts +11 -0
- package/models/entities/generated/ObservableObserver.d.ts +21 -0
- package/models/entities/generated/ObservableOrganization.d.ts +7 -0
- package/models/entities/generated/ObservableProcess.d.ts +34 -0
- package/models/entities/generated/ObservableSource.d.ts +23 -0
- package/models/entities/generated/ObservableThreat.d.ts +21 -0
- package/models/entities/generated/ObservableTls.d.ts +12 -0
- package/models/entities/generated/ObserverIngress.d.ts +9 -0
- package/models/entities/generated/Rule.d.ts +2 -10
- package/models/entities/generated/Task.d.ts +10 -0
- package/models/entities/generated/Threat.d.ts +2 -2
- package/models/entities/generated/{Enrichment.d.ts → ThreatEnrichment.d.ts} +1 -1
- package/models/entities/generated/View.d.ts +1 -0
- package/package.json +114 -97
- package/plugins/clue/components/ClueTypography.js +2 -2
- package/plugins/clue/utils.d.ts +2 -1
- package/tests/mocks.d.ts +11 -1
- package/tests/mocks.js +12 -7
- package/tests/server-handlers.js +6 -1
- package/tests/utils.d.ts +4 -0
- package/tests/utils.js +20 -0
- package/utils/constants.d.ts +3 -3
- package/utils/hitFunctions.d.ts +2 -1
- package/utils/hitFunctions.js +4 -4
- package/utils/typeUtils.d.ts +7 -0
- package/utils/typeUtils.js +27 -0
- package/utils/viewUtils.js +3 -0
- package/components/app/providers/HitProvider.d.ts +0 -22
- package/components/elements/display/icons/BundleButton.d.ts +0 -6
- package/components/elements/display/icons/BundleButton.js +0 -32
- package/components/elements/hit/HitRelated.d.ts +0 -6
- package/components/elements/hit/HitRelated.js +0 -7
- package/components/routes/help/BundleDocumentation.d.ts +0 -3
- package/components/routes/help/BundleDocumentation.js +0 -12
- package/components/routes/help/markdown/en/bundles.md.js +0 -1
- package/components/routes/help/markdown/fr/bundles.md.js +0 -1
- package/components/routes/hits/search/BundleParentMenu.d.ts +0 -6
- package/components/routes/hits/search/BundleParentMenu.js +0 -32
- package/components/routes/hits/search/BundleScroller.d.ts +0 -2
- package/components/routes/hits/search/BundleScroller.js +0 -6
- package/components/routes/hits/search/HitContextMenu.js +0 -227
- /package/components/app/providers/{HitSearchProvider.test.d.ts → RecordSearchProvider.test.d.ts} +0 -0
- /package/components/{routes/hits/search/HitContextMenu.test.d.ts → elements/ContextMenu.test.d.ts} +0 -0
- /package/components/{routes/overviews/OverviewEditor.d.ts → elements/MarkdownEditor.d.ts} +0 -0
- /package/components/elements/hit/{HitDetails.d.ts → related/RelatedRecords.d.ts} +0 -0
- /package/components/routes/hits/search/{HitBrowser.d.ts → RecordBrowser.d.ts} +0 -0
|
@@ -5,9 +5,9 @@ import { cloneDeep } from 'lodash-es';
|
|
|
5
5
|
import { setupContextSelectorMock, setupLocalStorageMock } from '@cccsaurora/howler-ui/tests/mocks';
|
|
6
6
|
import { useContextSelector } from 'use-context-selector';
|
|
7
7
|
import { DEFAULT_QUERY, MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
8
|
-
import { HitContext } from './HitProvider';
|
|
9
|
-
import HitSearchProvider, { HitSearchContext } from './HitSearchProvider';
|
|
10
8
|
import { ParameterContext } from './ParameterProvider';
|
|
9
|
+
import { RecordContext } from './RecordProvider';
|
|
10
|
+
import RecordSearchProvider, { RecordSearchContext } from './RecordSearchProvider';
|
|
11
11
|
import { ViewContext } from './ViewProvider';
|
|
12
12
|
vi.mock('api', { spy: true });
|
|
13
13
|
setupContextSelectorMock();
|
|
@@ -30,20 +30,21 @@ let mockParameterContext = {
|
|
|
30
30
|
mockParameterContext.offset = parseInt(offset);
|
|
31
31
|
},
|
|
32
32
|
views: [],
|
|
33
|
+
indexes: ['hit'],
|
|
33
34
|
addView: vi.fn()
|
|
34
35
|
};
|
|
35
36
|
const originalMockParameterContext = cloneDeep(mockParameterContext);
|
|
36
37
|
const mockHitContext = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
mockHitContext.
|
|
40
|
-
...mockHitContext.
|
|
38
|
+
records: {},
|
|
39
|
+
loadRecords: hits => {
|
|
40
|
+
mockHitContext.records = {
|
|
41
|
+
...mockHitContext.records,
|
|
41
42
|
...Object.fromEntries(hits.map(hit => [hit.howler.id, hit]))
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
const Wrapper = ({ children }) => {
|
|
46
|
-
return (_jsx(ViewContext.Provider, { value: mockViewContext, children: _jsx(ParameterContext.Provider, { value: mockParameterContext, children: _jsx(
|
|
47
|
+
return (_jsx(ViewContext.Provider, { value: mockViewContext, children: _jsx(ParameterContext.Provider, { value: mockParameterContext, children: _jsx(RecordContext.Provider, { value: mockHitContext, children: _jsx(RecordSearchProvider, { children: children }) }) }) }));
|
|
47
48
|
};
|
|
48
49
|
beforeEach(() => {
|
|
49
50
|
mockParameterContext = cloneDeep(originalMockParameterContext);
|
|
@@ -57,38 +58,32 @@ beforeEach(() => {
|
|
|
57
58
|
let mockSearchParams = new URLSearchParams();
|
|
58
59
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
59
60
|
});
|
|
60
|
-
describe('
|
|
61
|
+
describe('RecordSearchContext', () => {
|
|
61
62
|
it('should initialize with default values', async () => {
|
|
62
|
-
const hook = renderHook(() => useContextSelector(
|
|
63
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
63
64
|
displayType: ctx.displayType,
|
|
64
65
|
searching: ctx.searching,
|
|
65
66
|
error: ctx.error,
|
|
66
67
|
response: ctx.response,
|
|
67
|
-
bundleId: ctx.bundleId,
|
|
68
68
|
fzfSearch: ctx.fzfSearch
|
|
69
69
|
})), { wrapper: Wrapper });
|
|
70
70
|
expect(hook.result.current.displayType).toBe('list');
|
|
71
71
|
expect(hook.result.current.searching).toBe(false);
|
|
72
72
|
expect(hook.result.current.error).toBeNull();
|
|
73
73
|
expect(hook.result.current.response).toBeNull();
|
|
74
|
-
expect(hook.result.current.bundleId).toBeNull();
|
|
75
74
|
expect(hook.result.current.fzfSearch).toBe(false);
|
|
76
75
|
});
|
|
77
|
-
it('should set bundleId when on bundles route', () => {
|
|
78
|
-
mockLocation.pathname = '/bundles/test_bundle_id';
|
|
79
|
-
mockParams.mockReturnValue({ id: 'test_bundle_id' });
|
|
80
|
-
const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.bundleId), { wrapper: Wrapper });
|
|
81
|
-
expect(hook.result.current).toBe('test_bundle_id');
|
|
82
|
-
});
|
|
83
76
|
it('should initialize queryHistory from localStorage', () => {
|
|
84
77
|
const mockHistory = { 'test:query': new Date().toISOString() };
|
|
85
78
|
mockLocalStorage.setItem(`${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.QUERY_HISTORY}`, JSON.stringify(mockHistory));
|
|
86
|
-
const hook = renderHook(() => useContextSelector(
|
|
79
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.queryHistory), {
|
|
80
|
+
wrapper: Wrapper
|
|
81
|
+
});
|
|
87
82
|
expect(hook.result.current).toEqual(mockHistory);
|
|
88
83
|
});
|
|
89
84
|
describe('setDisplayType', () => {
|
|
90
85
|
it('should update display type', () => {
|
|
91
|
-
const hook = renderHook(() => useContextSelector(
|
|
86
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
92
87
|
displayType: ctx.displayType,
|
|
93
88
|
setDisplayType: ctx.setDisplayType
|
|
94
89
|
})), { wrapper: Wrapper });
|
|
@@ -101,7 +96,7 @@ describe('HitSearchContext', () => {
|
|
|
101
96
|
});
|
|
102
97
|
describe('setFzfSearch', () => {
|
|
103
98
|
it('should update fzfSearch state', () => {
|
|
104
|
-
const hook = renderHook(() => useContextSelector(
|
|
99
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
105
100
|
fzfSearch: ctx.fzfSearch,
|
|
106
101
|
setFzfSearch: ctx.setFzfSearch
|
|
107
102
|
})), { wrapper: Wrapper });
|
|
@@ -114,7 +109,7 @@ describe('HitSearchContext', () => {
|
|
|
114
109
|
});
|
|
115
110
|
describe('setQueryHistory', () => {
|
|
116
111
|
it('should update query history', () => {
|
|
117
|
-
const hook = renderHook(() => useContextSelector(
|
|
112
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
118
113
|
queryHistory: ctx.queryHistory,
|
|
119
114
|
setQueryHistory: ctx.setQueryHistory
|
|
120
115
|
})), { wrapper: Wrapper });
|
|
@@ -127,7 +122,7 @@ describe('HitSearchContext', () => {
|
|
|
127
122
|
});
|
|
128
123
|
describe('search', () => {
|
|
129
124
|
it('should perform a search and update response', async () => {
|
|
130
|
-
const hook = renderHook(() => useContextSelector(
|
|
125
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
131
126
|
search: ctx.search,
|
|
132
127
|
searching: ctx.searching,
|
|
133
128
|
response: ctx.response,
|
|
@@ -137,13 +132,13 @@ describe('HitSearchContext', () => {
|
|
|
137
132
|
hook.result.current.search('test query');
|
|
138
133
|
});
|
|
139
134
|
await waitFor(() => {
|
|
140
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
135
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
141
136
|
query: expect.stringContaining('test query')
|
|
142
137
|
}));
|
|
143
138
|
});
|
|
144
139
|
});
|
|
145
140
|
it('should set searching state during search', async () => {
|
|
146
|
-
const hook = renderHook(() => useContextSelector(
|
|
141
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
147
142
|
search: ctx.search,
|
|
148
143
|
searching: ctx.searching
|
|
149
144
|
})), { wrapper: Wrapper });
|
|
@@ -172,7 +167,7 @@ describe('HitSearchContext', () => {
|
|
|
172
167
|
});
|
|
173
168
|
it('should handle search errors', async () => {
|
|
174
169
|
vi.mocked(hpost).mockRejectedValueOnce(new Error('Search failed'));
|
|
175
|
-
const hook = renderHook(() => useContextSelector(
|
|
170
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
176
171
|
search: ctx.search,
|
|
177
172
|
error: ctx.error,
|
|
178
173
|
searching: ctx.searching
|
|
@@ -193,7 +188,7 @@ describe('HitSearchContext', () => {
|
|
|
193
188
|
total: 10
|
|
194
189
|
};
|
|
195
190
|
vi.mocked(hpost).mockResolvedValueOnce(mockResponse);
|
|
196
|
-
const hook = renderHook(() => useContextSelector(
|
|
191
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
197
192
|
search: ctx.search,
|
|
198
193
|
response: ctx.response
|
|
199
194
|
})), { wrapper: Wrapper });
|
|
@@ -221,27 +216,35 @@ describe('HitSearchContext', () => {
|
|
|
221
216
|
expect(hook.result.current.response?.items.length).toBe(2);
|
|
222
217
|
});
|
|
223
218
|
});
|
|
224
|
-
it('should
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
219
|
+
it('should not crash when appendResults is true but response is null', async () => {
|
|
220
|
+
const mockResponse = {
|
|
221
|
+
items: [{ howler: { id: 'hit1' } }],
|
|
222
|
+
offset: 0,
|
|
223
|
+
rows: 1,
|
|
224
|
+
total: 10
|
|
225
|
+
};
|
|
226
|
+
vi.mocked(hpost).mockResolvedValueOnce(mockResponse);
|
|
227
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
228
|
+
search: ctx.search,
|
|
229
|
+
response: ctx.response
|
|
230
|
+
})), { wrapper: Wrapper });
|
|
231
|
+
// response is null — call search with appendResults=true directly
|
|
228
232
|
act(() => {
|
|
229
|
-
hook.result.current('test query');
|
|
233
|
+
hook.result.current.search('test query', true);
|
|
230
234
|
});
|
|
231
235
|
await waitFor(() => {
|
|
232
|
-
expect(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}));
|
|
236
|
+
expect(hook.result.current.response).not.toBeNull();
|
|
237
|
+
expect(hook.result.current.response?.items).toHaveLength(1);
|
|
238
|
+
expect(hook.result.current.response?.items[0].howler.id).toBe('hit1');
|
|
236
239
|
});
|
|
237
240
|
});
|
|
238
241
|
it('should apply date range filter from span', async () => {
|
|
239
|
-
const hook = renderHook(() => useContextSelector(
|
|
242
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
240
243
|
act(() => {
|
|
241
244
|
hook.result.current('test query');
|
|
242
245
|
});
|
|
243
246
|
await waitFor(() => {
|
|
244
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
247
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
245
248
|
filters: expect.arrayContaining([expect.stringContaining('event.created:')])
|
|
246
249
|
}));
|
|
247
250
|
});
|
|
@@ -250,24 +253,24 @@ describe('HitSearchContext', () => {
|
|
|
250
253
|
mockParameterContext.span = 'date.range.custom';
|
|
251
254
|
mockParameterContext.startDate = '2025-01-01';
|
|
252
255
|
mockParameterContext.endDate = '2025-12-31';
|
|
253
|
-
const hook = renderHook(() => useContextSelector(
|
|
256
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
254
257
|
act(() => {
|
|
255
258
|
hook.result.current('test query');
|
|
256
259
|
});
|
|
257
260
|
await waitFor(() => {
|
|
258
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
261
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
259
262
|
filters: expect.arrayContaining([expect.stringContaining('event.created:')])
|
|
260
263
|
}));
|
|
261
264
|
});
|
|
262
265
|
});
|
|
263
266
|
it('should exclude filters ending with * from search', async () => {
|
|
264
267
|
mockParameterContext.filters = ['status:open', 'howler.escalation:*'];
|
|
265
|
-
const hook = renderHook(() => useContextSelector(
|
|
268
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
266
269
|
act(() => {
|
|
267
270
|
hook.result.current('test query');
|
|
268
271
|
});
|
|
269
272
|
await waitFor(() => {
|
|
270
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
273
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
271
274
|
filters: expect.not.arrayContaining([expect.stringContaining('howler.escalation:*')])
|
|
272
275
|
}));
|
|
273
276
|
});
|
|
@@ -280,7 +283,7 @@ describe('HitSearchContext', () => {
|
|
|
280
283
|
rows: 0,
|
|
281
284
|
total: 50
|
|
282
285
|
});
|
|
283
|
-
const hook = renderHook(() => useContextSelector(
|
|
286
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
284
287
|
search: ctx.search
|
|
285
288
|
})), { wrapper: Wrapper });
|
|
286
289
|
act(() => {
|
|
@@ -294,7 +297,7 @@ describe('HitSearchContext', () => {
|
|
|
294
297
|
it('should not search when sort or span is null', async () => {
|
|
295
298
|
mockParameterContext.sort = null;
|
|
296
299
|
mockParameterContext.span = null;
|
|
297
|
-
const hook = renderHook(() => useContextSelector(
|
|
300
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
298
301
|
act(() => {
|
|
299
302
|
hook.result.current('test query');
|
|
300
303
|
});
|
|
@@ -306,7 +309,7 @@ describe('HitSearchContext', () => {
|
|
|
306
309
|
});
|
|
307
310
|
describe('automatic search on parameter changes', () => {
|
|
308
311
|
it('should trigger search when filters change', async () => {
|
|
309
|
-
const hook = renderHook(() => useContextSelector(
|
|
312
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
310
313
|
response: ctx.response
|
|
311
314
|
})), { wrapper: Wrapper });
|
|
312
315
|
await waitFor(() => {
|
|
@@ -320,23 +323,23 @@ describe('HitSearchContext', () => {
|
|
|
320
323
|
expect(hpost).toHaveBeenCalled();
|
|
321
324
|
}, { timeout: 2000 });
|
|
322
325
|
});
|
|
323
|
-
it('should not trigger search when query is DEFAULT_QUERY
|
|
326
|
+
it('should not trigger search when query is DEFAULT_QUERY', async () => {
|
|
324
327
|
mockParameterContext.query = DEFAULT_QUERY;
|
|
325
|
-
renderHook(() => useContextSelector(
|
|
328
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
326
329
|
await waitFor(() => {
|
|
327
330
|
expect(hpost).not.toHaveBeenCalled();
|
|
328
331
|
});
|
|
329
332
|
});
|
|
330
333
|
it('should not trigger search when span is custom but dates are missing', async () => {
|
|
331
|
-
renderHook(() => useContextSelector(
|
|
334
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
332
335
|
await waitFor(() => {
|
|
333
336
|
expect(hpost).not.toHaveBeenCalled();
|
|
334
337
|
});
|
|
335
338
|
});
|
|
336
339
|
});
|
|
337
|
-
describe('
|
|
340
|
+
describe('useRecordSearchContextSelector', () => {
|
|
338
341
|
it('should allow selecting specific values from context', async () => {
|
|
339
|
-
const hook = renderHook(() => useContextSelector(
|
|
342
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
340
343
|
searching: ctx.searching,
|
|
341
344
|
error: ctx.error
|
|
342
345
|
})), { wrapper: Wrapper });
|
|
@@ -346,7 +349,7 @@ describe('HitSearchContext', () => {
|
|
|
346
349
|
});
|
|
347
350
|
describe('edge cases', () => {
|
|
348
351
|
it('should handle concurrent search calls with throttling', async () => {
|
|
349
|
-
const hook = renderHook(() => useContextSelector(
|
|
352
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
350
353
|
// Make multiple rapid calls
|
|
351
354
|
act(() => {
|
|
352
355
|
hook.result.current('query1');
|
|
@@ -358,8 +361,8 @@ describe('HitSearchContext', () => {
|
|
|
358
361
|
expect(hpost).toHaveBeenCalledTimes(1);
|
|
359
362
|
}, { timeout: 2000 });
|
|
360
363
|
});
|
|
361
|
-
it('should clear response when query becomes DEFAULT_QUERY without viewId
|
|
362
|
-
const hook = renderHook(() => useContextSelector(
|
|
364
|
+
it('should clear response when query becomes DEFAULT_QUERY without viewId', async () => {
|
|
365
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
363
366
|
await waitFor(() => {
|
|
364
367
|
expect(hook.result.current).toBeDefined();
|
|
365
368
|
}, { timeout: 2000 });
|
|
@@ -379,12 +382,12 @@ describe('HitSearchContext', () => {
|
|
|
379
382
|
{ view_id: 'view_1', query: 'howler.status:open' },
|
|
380
383
|
{ view_id: 'view_2', query: 'howler.priority:high' }
|
|
381
384
|
]);
|
|
382
|
-
const hook = renderHook(() => useContextSelector(
|
|
385
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
383
386
|
act(() => {
|
|
384
387
|
hook.result.current('test query');
|
|
385
388
|
});
|
|
386
389
|
await waitFor(() => {
|
|
387
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
390
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
388
391
|
query: 'test query',
|
|
389
392
|
filters: expect.arrayContaining(['howler.status:open', 'howler.priority:high'])
|
|
390
393
|
}));
|
|
@@ -397,12 +400,12 @@ describe('HitSearchContext', () => {
|
|
|
397
400
|
{ view_id: 'view_2', query: 'howler.priority:high' },
|
|
398
401
|
{ view_id: 'view_3', query: 'howler.analytic:sigma' }
|
|
399
402
|
]);
|
|
400
|
-
const hook = renderHook(() => useContextSelector(
|
|
403
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
401
404
|
act(() => {
|
|
402
405
|
hook.result.current('test query');
|
|
403
406
|
});
|
|
404
407
|
await waitFor(() => {
|
|
405
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
408
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
406
409
|
query: 'test query',
|
|
407
410
|
filters: [
|
|
408
411
|
'event.created:[now-1w TO now]',
|
|
@@ -421,7 +424,7 @@ describe('HitSearchContext', () => {
|
|
|
421
424
|
mockParameterContext.views = [];
|
|
422
425
|
const mockSearchParams = new URLSearchParams();
|
|
423
426
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
424
|
-
renderHook(() => useContextSelector(
|
|
427
|
+
renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
|
|
425
428
|
await waitFor(() => {
|
|
426
429
|
expect(mockParameterContext.addView).toBeCalledWith('default_view_id');
|
|
427
430
|
});
|
|
@@ -433,7 +436,7 @@ describe('HitSearchContext', () => {
|
|
|
433
436
|
const mockSearchParams = new URLSearchParams();
|
|
434
437
|
mockSearchParams.append('view', 'existing_view');
|
|
435
438
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
436
|
-
renderHook(() => useContextSelector(
|
|
439
|
+
renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
|
|
437
440
|
await waitFor(() => {
|
|
438
441
|
expect(mockParameterContext.addView).not.toBeCalled();
|
|
439
442
|
});
|
|
@@ -444,7 +447,7 @@ describe('HitSearchContext', () => {
|
|
|
444
447
|
mockParameterContext.views = [];
|
|
445
448
|
const mockSearchParams = new URLSearchParams();
|
|
446
449
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
447
|
-
renderHook(() => useContextSelector(
|
|
450
|
+
renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
|
|
448
451
|
await waitFor(() => {
|
|
449
452
|
expect(mockSetParams).not.toHaveBeenCalled();
|
|
450
453
|
});
|
|
@@ -454,12 +457,12 @@ describe('HitSearchContext', () => {
|
|
|
454
457
|
it('should not break when view ID does not exist', async () => {
|
|
455
458
|
mockParameterContext.views = ['non_existent_view'];
|
|
456
459
|
mockViewContext.getCurrentViews = vi.fn(() => Promise.resolve([null]));
|
|
457
|
-
const hook = renderHook(() => useContextSelector(
|
|
460
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
458
461
|
act(() => {
|
|
459
462
|
hook.result.current('test query');
|
|
460
463
|
});
|
|
461
464
|
await waitFor(() => {
|
|
462
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
465
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
463
466
|
query: expect.stringContaining('test query'),
|
|
464
467
|
filters: ['event.created:[now-1w TO now]']
|
|
465
468
|
}));
|
|
@@ -471,12 +474,12 @@ describe('HitSearchContext', () => {
|
|
|
471
474
|
{ view_id: 'view_1', query: 'howler.status:open' },
|
|
472
475
|
{ view_id: 'view_2', query: 'howler.priority:high' }
|
|
473
476
|
]);
|
|
474
|
-
const hook = renderHook(() => useContextSelector(
|
|
477
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
475
478
|
act(() => {
|
|
476
479
|
hook.result.current('test query');
|
|
477
480
|
});
|
|
478
481
|
await waitFor(() => {
|
|
479
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
482
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
480
483
|
query: 'test query',
|
|
481
484
|
filters: ['event.created:[now-1w TO now]', 'howler.status:open', 'howler.priority:high']
|
|
482
485
|
}));
|
|
@@ -487,7 +490,7 @@ describe('HitSearchContext', () => {
|
|
|
487
490
|
it('should not trigger search when views is empty and query is DEFAULT_QUERY', async () => {
|
|
488
491
|
mockParameterContext.query = DEFAULT_QUERY;
|
|
489
492
|
mockParameterContext.views = [];
|
|
490
|
-
renderHook(() => useContextSelector(
|
|
493
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
491
494
|
await waitFor(() => {
|
|
492
495
|
expect(hpost).not.toHaveBeenCalled();
|
|
493
496
|
});
|
|
@@ -495,7 +498,7 @@ describe('HitSearchContext', () => {
|
|
|
495
498
|
it('should trigger search when views.length > 0 even with DEFAULT_QUERY', async () => {
|
|
496
499
|
mockParameterContext.query = DEFAULT_QUERY;
|
|
497
500
|
mockParameterContext.views = ['view_1'];
|
|
498
|
-
renderHook(() => useContextSelector(
|
|
501
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
499
502
|
await waitFor(() => {
|
|
500
503
|
expect(hpost).toHaveBeenCalled();
|
|
501
504
|
});
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import api from '@cccsaurora/howler-ui/api';
|
|
3
3
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
4
|
-
import { createContext, useCallback, useState } from 'react';
|
|
4
|
+
import { createContext, useCallback, useEffect, useRef, useState } from 'react';
|
|
5
5
|
export const UserListContext = createContext(null);
|
|
6
6
|
const UserListProvider = ({ children }) => {
|
|
7
7
|
const { dispatchApi } = useMyApi();
|
|
8
8
|
const [users, setUsers] = useState({});
|
|
9
|
+
const usersRef = useRef(users);
|
|
10
|
+
usersRef.current = users;
|
|
11
|
+
const pendingIds = useRef(new Set());
|
|
12
|
+
const debounceTimer = useRef(null);
|
|
9
13
|
const searchUsers = useCallback(async (query) => {
|
|
10
14
|
const newUsers = (await dispatchApi(api.search.user.post({ query, rows: 1000 }), {
|
|
11
15
|
throwError: false,
|
|
@@ -17,14 +21,30 @@ const UserListProvider = ({ children }) => {
|
|
|
17
21
|
...newUsers
|
|
18
22
|
}));
|
|
19
23
|
}, [dispatchApi]);
|
|
20
|
-
const fetchUsers = useCallback(
|
|
21
|
-
ids
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
const fetchUsers = useCallback((ids) => {
|
|
25
|
+
const nextIds = new Set(ids);
|
|
26
|
+
nextIds.delete('Unknown');
|
|
27
|
+
nextIds.forEach(id => pendingIds.current.add(id));
|
|
28
|
+
if (debounceTimer.current) {
|
|
29
|
+
clearTimeout(debounceTimer.current);
|
|
25
30
|
}
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
debounceTimer.current = setTimeout(async () => {
|
|
32
|
+
const idsToGet = Array.from(pendingIds.current).filter(id => !Object.keys(usersRef.current).includes(id));
|
|
33
|
+
pendingIds.current = new Set();
|
|
34
|
+
debounceTimer.current = null;
|
|
35
|
+
if (idsToGet.length <= 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await searchUsers(`id:${idsToGet.join(' OR ')}`);
|
|
39
|
+
}, 200);
|
|
40
|
+
}, [searchUsers]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
return () => {
|
|
43
|
+
if (debounceTimer.current) {
|
|
44
|
+
clearTimeout(debounceTimer.current);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
28
48
|
return _jsx(UserListContext.Provider, { value: { users, fetchUsers, searchUsers }, children: children });
|
|
29
49
|
};
|
|
30
50
|
export default UserListProvider;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type SxProps } from '@mui/material';
|
|
2
|
+
import type { ElementType, FC, MouseEventHandler, PropsWithChildren, ReactNode } from 'react';
|
|
3
|
+
export type ContextMenuDivider = {
|
|
4
|
+
kind: 'divider';
|
|
5
|
+
id: string;
|
|
6
|
+
sx?: SxProps;
|
|
7
|
+
};
|
|
8
|
+
export type ContextMenuLeafItem = {
|
|
9
|
+
kind: 'item';
|
|
10
|
+
id: string;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
label: ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
onClick?: () => void;
|
|
15
|
+
/** When provided the item renders as a router Link instead of a button. */
|
|
16
|
+
to?: string;
|
|
17
|
+
};
|
|
18
|
+
export type ContextMenuSubItem = {
|
|
19
|
+
key: string;
|
|
20
|
+
label: ReactNode;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onClick?: () => void;
|
|
23
|
+
};
|
|
24
|
+
export type ContextMenuSubmenuItem = {
|
|
25
|
+
kind: 'submenu';
|
|
26
|
+
/**
|
|
27
|
+
* Identifier for this submenu. Used to derive:
|
|
28
|
+
* - the MenuItem's DOM id (`${id}-menu-item`)
|
|
29
|
+
* - the submenu Paper's DOM id (`${id}-submenu`)
|
|
30
|
+
*/
|
|
31
|
+
id: string;
|
|
32
|
+
icon?: ReactNode;
|
|
33
|
+
label: ReactNode;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
items: ContextMenuSubItem[];
|
|
36
|
+
};
|
|
37
|
+
export type ContextMenuEntry = ContextMenuDivider | ContextMenuLeafItem | ContextMenuSubmenuItem;
|
|
38
|
+
interface ContextMenuProps {
|
|
39
|
+
items: ContextMenuEntry[];
|
|
40
|
+
/** Called after the menu opens, with the triggering event. */
|
|
41
|
+
onOpen?: MouseEventHandler<HTMLElement>;
|
|
42
|
+
/** Called when the menu closes. */
|
|
43
|
+
onClose?: () => void;
|
|
44
|
+
/** Wraps children + menu in this element. Defaults to Box. */
|
|
45
|
+
Component?: ElementType;
|
|
46
|
+
/** id applied to the wrapper element */
|
|
47
|
+
id?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Generic context menu component that renders a MUI Menu from a declarative
|
|
51
|
+
* items structure supporting leaf items, dividers, and single-level submenus.
|
|
52
|
+
*
|
|
53
|
+
* Submenus appear on hover and are positioned to avoid screen overflow.
|
|
54
|
+
*/
|
|
55
|
+
declare const ContextMenu: FC<PropsWithChildren<ContextMenuProps>>;
|
|
56
|
+
export default ContextMenu;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { KeyboardArrowRight } from '@mui/icons-material';
|
|
3
|
+
import { Box, Divider, Fade, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Paper, useTheme } from '@mui/material';
|
|
4
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
5
|
+
import { Link } from 'react-router-dom';
|
|
6
|
+
/**
|
|
7
|
+
* The margin at the bottom of the screen by which a submenu should be inverted.
|
|
8
|
+
* If hovering within this many pixels of the bottom, the submenu renders upward.
|
|
9
|
+
*/
|
|
10
|
+
const CONTEXTMENU_MARGIN = 350;
|
|
11
|
+
/**
|
|
12
|
+
* Generic context menu component that renders a MUI Menu from a declarative
|
|
13
|
+
* items structure supporting leaf items, dividers, and single-level submenus.
|
|
14
|
+
*
|
|
15
|
+
* Submenus appear on hover and are positioned to avoid screen overflow.
|
|
16
|
+
*/
|
|
17
|
+
const ContextMenu = ({ items, onOpen, onClose, Component = Box, id, children }) => {
|
|
18
|
+
const theme = useTheme();
|
|
19
|
+
const [show, setShow] = useState({});
|
|
20
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
21
|
+
const [transformProps, setTransformProps] = useState({});
|
|
22
|
+
const handleClose = useCallback(() => {
|
|
23
|
+
setAnchorEl(null);
|
|
24
|
+
onClose?.();
|
|
25
|
+
}, [onClose]);
|
|
26
|
+
const handleContextMenu = useCallback(event => {
|
|
27
|
+
if (anchorEl) {
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
handleClose();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
if (window.innerHeight - event.clientY < 300) {
|
|
34
|
+
setTransformProps({
|
|
35
|
+
position: 'fixed',
|
|
36
|
+
bottom: `${window.innerHeight - event.clientY}px !important`,
|
|
37
|
+
top: 'unset !important',
|
|
38
|
+
left: `${event.clientX}px !important`
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
setTransformProps({
|
|
43
|
+
position: 'fixed',
|
|
44
|
+
top: `${event.clientY}px !important`,
|
|
45
|
+
left: `${event.clientX}px !important`
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
setAnchorEl(event.target);
|
|
49
|
+
onOpen?.(event);
|
|
50
|
+
}, [anchorEl, handleClose, onOpen]);
|
|
51
|
+
/**
|
|
52
|
+
* Calculates positioning styles for a submenu based on the parent element's
|
|
53
|
+
* position relative to the viewport bottom.
|
|
54
|
+
*/
|
|
55
|
+
const calculateSubMenuStyles = useCallback((parent) => {
|
|
56
|
+
const baseStyles = { position: 'absolute', maxHeight: '300px', overflow: 'auto' };
|
|
57
|
+
const defaultStyles = { ...baseStyles, top: 0, left: '100%' };
|
|
58
|
+
if (!parent) {
|
|
59
|
+
return defaultStyles;
|
|
60
|
+
}
|
|
61
|
+
const parentBounds = parent.getBoundingClientRect();
|
|
62
|
+
if (window.innerHeight - parentBounds.y < CONTEXTMENU_MARGIN) {
|
|
63
|
+
return { ...baseStyles, bottom: 0, left: '100%' };
|
|
64
|
+
}
|
|
65
|
+
return defaultStyles;
|
|
66
|
+
}, []);
|
|
67
|
+
// Reset submenu visibility whenever the menu is closed
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!anchorEl) {
|
|
70
|
+
setShow({});
|
|
71
|
+
}
|
|
72
|
+
}, [anchorEl]);
|
|
73
|
+
return (_jsxs(Component, { id: id, onContextMenu: handleContextMenu, children: [children, _jsx(Menu, { id: "record-menu", open: !!anchorEl, anchorEl: anchorEl, onClose: handleClose, slotProps: {
|
|
74
|
+
paper: {
|
|
75
|
+
sx: {
|
|
76
|
+
...transformProps,
|
|
77
|
+
overflow: 'visible !important'
|
|
78
|
+
},
|
|
79
|
+
elevation: 2
|
|
80
|
+
}
|
|
81
|
+
}, MenuListProps: {
|
|
82
|
+
dense: true,
|
|
83
|
+
sx: {
|
|
84
|
+
minWidth: '250px',
|
|
85
|
+
paddingY: '0 !important',
|
|
86
|
+
'& > :first-child': {
|
|
87
|
+
borderTopLeftRadius: theme.shape.borderRadius,
|
|
88
|
+
borderTopRightRadius: theme.shape.borderRadius
|
|
89
|
+
},
|
|
90
|
+
'& > :last-child': {
|
|
91
|
+
borderBottomLeftRadius: theme.shape.borderRadius,
|
|
92
|
+
borderBottomRightRadius: theme.shape.borderRadius
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: handleClose, children: items.map(entry => {
|
|
96
|
+
if (entry.kind === 'divider') {
|
|
97
|
+
return _jsx(Divider, { sx: { my: '0 !important' } }, entry.id);
|
|
98
|
+
}
|
|
99
|
+
if (entry.kind === 'item') {
|
|
100
|
+
if (entry.to) {
|
|
101
|
+
return (_jsxs(MenuItem, { component: Link, to: entry.to, disabled: entry.disabled, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
|
|
102
|
+
}
|
|
103
|
+
return (_jsxs(MenuItem, { disabled: entry.disabled, onClick: entry.onClick, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
|
|
104
|
+
}
|
|
105
|
+
const { id: entryId, icon, label, disabled, items: subItems } = entry;
|
|
106
|
+
return (_jsxs(MenuItem, { id: `${entryId}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [entryId]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [entryId]: null })), disabled: disabled, children: [icon && _jsx(ListItemIcon, { children: icon }), _jsx(ListItemText, { sx: { flex: 1 }, children: label }), !disabled && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show[entryId], unmountOnExit: true, children: _jsx(Paper, { id: `${entryId}-submenu`, sx: calculateSubMenuStyles(show[entryId]), elevation: 2, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: subItems.map(subItem => (_jsx(MenuItem, { onClick: subItem.onClick, disabled: subItem.disabled, children: _jsx(ListItemText, { children: subItem.label }) }, subItem.key))) }) }) })] }, entryId));
|
|
107
|
+
}) })] }));
|
|
108
|
+
};
|
|
109
|
+
export default ContextMenu;
|