@cccsaurora/howler-ui 2.18.0-dev.740 → 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} +12 -17
- package/components/app/providers/{HitSearchProvider.test.js → RecordSearchProvider.test.js} +52 -71
- 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 +18 -1
- 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 });
|
|
@@ -229,7 +224,7 @@ describe('HitSearchContext', () => {
|
|
|
229
224
|
total: 10
|
|
230
225
|
};
|
|
231
226
|
vi.mocked(hpost).mockResolvedValueOnce(mockResponse);
|
|
232
|
-
const hook = renderHook(() => useContextSelector(
|
|
227
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
233
228
|
search: ctx.search,
|
|
234
229
|
response: ctx.response
|
|
235
230
|
})), { wrapper: Wrapper });
|
|
@@ -243,27 +238,13 @@ describe('HitSearchContext', () => {
|
|
|
243
238
|
expect(hook.result.current.response?.items[0].howler.id).toBe('hit1');
|
|
244
239
|
});
|
|
245
240
|
});
|
|
246
|
-
it('should include bundle filter when on bundles route', async () => {
|
|
247
|
-
mockLocation.pathname = '/bundles/test_bundle_id';
|
|
248
|
-
mockParams.mockReturnValue({ id: 'test_bundle_id' });
|
|
249
|
-
const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
250
|
-
act(() => {
|
|
251
|
-
hook.result.current('test query');
|
|
252
|
-
});
|
|
253
|
-
await waitFor(() => {
|
|
254
|
-
expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
|
|
255
|
-
query: 'test query',
|
|
256
|
-
filters: ['event.created:[now-1w TO now]', 'howler.bundles:test_bundle_id']
|
|
257
|
-
}));
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
241
|
it('should apply date range filter from span', async () => {
|
|
261
|
-
const hook = renderHook(() => useContextSelector(
|
|
242
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
262
243
|
act(() => {
|
|
263
244
|
hook.result.current('test query');
|
|
264
245
|
});
|
|
265
246
|
await waitFor(() => {
|
|
266
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
247
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
267
248
|
filters: expect.arrayContaining([expect.stringContaining('event.created:')])
|
|
268
249
|
}));
|
|
269
250
|
});
|
|
@@ -272,24 +253,24 @@ describe('HitSearchContext', () => {
|
|
|
272
253
|
mockParameterContext.span = 'date.range.custom';
|
|
273
254
|
mockParameterContext.startDate = '2025-01-01';
|
|
274
255
|
mockParameterContext.endDate = '2025-12-31';
|
|
275
|
-
const hook = renderHook(() => useContextSelector(
|
|
256
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
276
257
|
act(() => {
|
|
277
258
|
hook.result.current('test query');
|
|
278
259
|
});
|
|
279
260
|
await waitFor(() => {
|
|
280
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
261
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
281
262
|
filters: expect.arrayContaining([expect.stringContaining('event.created:')])
|
|
282
263
|
}));
|
|
283
264
|
});
|
|
284
265
|
});
|
|
285
266
|
it('should exclude filters ending with * from search', async () => {
|
|
286
267
|
mockParameterContext.filters = ['status:open', 'howler.escalation:*'];
|
|
287
|
-
const hook = renderHook(() => useContextSelector(
|
|
268
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
288
269
|
act(() => {
|
|
289
270
|
hook.result.current('test query');
|
|
290
271
|
});
|
|
291
272
|
await waitFor(() => {
|
|
292
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
273
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
293
274
|
filters: expect.not.arrayContaining([expect.stringContaining('howler.escalation:*')])
|
|
294
275
|
}));
|
|
295
276
|
});
|
|
@@ -302,7 +283,7 @@ describe('HitSearchContext', () => {
|
|
|
302
283
|
rows: 0,
|
|
303
284
|
total: 50
|
|
304
285
|
});
|
|
305
|
-
const hook = renderHook(() => useContextSelector(
|
|
286
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
306
287
|
search: ctx.search
|
|
307
288
|
})), { wrapper: Wrapper });
|
|
308
289
|
act(() => {
|
|
@@ -316,7 +297,7 @@ describe('HitSearchContext', () => {
|
|
|
316
297
|
it('should not search when sort or span is null', async () => {
|
|
317
298
|
mockParameterContext.sort = null;
|
|
318
299
|
mockParameterContext.span = null;
|
|
319
|
-
const hook = renderHook(() => useContextSelector(
|
|
300
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
320
301
|
act(() => {
|
|
321
302
|
hook.result.current('test query');
|
|
322
303
|
});
|
|
@@ -328,7 +309,7 @@ describe('HitSearchContext', () => {
|
|
|
328
309
|
});
|
|
329
310
|
describe('automatic search on parameter changes', () => {
|
|
330
311
|
it('should trigger search when filters change', async () => {
|
|
331
|
-
const hook = renderHook(() => useContextSelector(
|
|
312
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
332
313
|
response: ctx.response
|
|
333
314
|
})), { wrapper: Wrapper });
|
|
334
315
|
await waitFor(() => {
|
|
@@ -342,23 +323,23 @@ describe('HitSearchContext', () => {
|
|
|
342
323
|
expect(hpost).toHaveBeenCalled();
|
|
343
324
|
}, { timeout: 2000 });
|
|
344
325
|
});
|
|
345
|
-
it('should not trigger search when query is DEFAULT_QUERY
|
|
326
|
+
it('should not trigger search when query is DEFAULT_QUERY', async () => {
|
|
346
327
|
mockParameterContext.query = DEFAULT_QUERY;
|
|
347
|
-
renderHook(() => useContextSelector(
|
|
328
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
348
329
|
await waitFor(() => {
|
|
349
330
|
expect(hpost).not.toHaveBeenCalled();
|
|
350
331
|
});
|
|
351
332
|
});
|
|
352
333
|
it('should not trigger search when span is custom but dates are missing', async () => {
|
|
353
|
-
renderHook(() => useContextSelector(
|
|
334
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
354
335
|
await waitFor(() => {
|
|
355
336
|
expect(hpost).not.toHaveBeenCalled();
|
|
356
337
|
});
|
|
357
338
|
});
|
|
358
339
|
});
|
|
359
|
-
describe('
|
|
340
|
+
describe('useRecordSearchContextSelector', () => {
|
|
360
341
|
it('should allow selecting specific values from context', async () => {
|
|
361
|
-
const hook = renderHook(() => useContextSelector(
|
|
342
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
362
343
|
searching: ctx.searching,
|
|
363
344
|
error: ctx.error
|
|
364
345
|
})), { wrapper: Wrapper });
|
|
@@ -368,7 +349,7 @@ describe('HitSearchContext', () => {
|
|
|
368
349
|
});
|
|
369
350
|
describe('edge cases', () => {
|
|
370
351
|
it('should handle concurrent search calls with throttling', async () => {
|
|
371
|
-
const hook = renderHook(() => useContextSelector(
|
|
352
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
372
353
|
// Make multiple rapid calls
|
|
373
354
|
act(() => {
|
|
374
355
|
hook.result.current('query1');
|
|
@@ -380,8 +361,8 @@ describe('HitSearchContext', () => {
|
|
|
380
361
|
expect(hpost).toHaveBeenCalledTimes(1);
|
|
381
362
|
}, { timeout: 2000 });
|
|
382
363
|
});
|
|
383
|
-
it('should clear response when query becomes DEFAULT_QUERY without viewId
|
|
384
|
-
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 });
|
|
385
366
|
await waitFor(() => {
|
|
386
367
|
expect(hook.result.current).toBeDefined();
|
|
387
368
|
}, { timeout: 2000 });
|
|
@@ -401,12 +382,12 @@ describe('HitSearchContext', () => {
|
|
|
401
382
|
{ view_id: 'view_1', query: 'howler.status:open' },
|
|
402
383
|
{ view_id: 'view_2', query: 'howler.priority:high' }
|
|
403
384
|
]);
|
|
404
|
-
const hook = renderHook(() => useContextSelector(
|
|
385
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
405
386
|
act(() => {
|
|
406
387
|
hook.result.current('test query');
|
|
407
388
|
});
|
|
408
389
|
await waitFor(() => {
|
|
409
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
390
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
410
391
|
query: 'test query',
|
|
411
392
|
filters: expect.arrayContaining(['howler.status:open', 'howler.priority:high'])
|
|
412
393
|
}));
|
|
@@ -419,12 +400,12 @@ describe('HitSearchContext', () => {
|
|
|
419
400
|
{ view_id: 'view_2', query: 'howler.priority:high' },
|
|
420
401
|
{ view_id: 'view_3', query: 'howler.analytic:sigma' }
|
|
421
402
|
]);
|
|
422
|
-
const hook = renderHook(() => useContextSelector(
|
|
403
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
423
404
|
act(() => {
|
|
424
405
|
hook.result.current('test query');
|
|
425
406
|
});
|
|
426
407
|
await waitFor(() => {
|
|
427
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
408
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
428
409
|
query: 'test query',
|
|
429
410
|
filters: [
|
|
430
411
|
'event.created:[now-1w TO now]',
|
|
@@ -443,7 +424,7 @@ describe('HitSearchContext', () => {
|
|
|
443
424
|
mockParameterContext.views = [];
|
|
444
425
|
const mockSearchParams = new URLSearchParams();
|
|
445
426
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
446
|
-
renderHook(() => useContextSelector(
|
|
427
|
+
renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
|
|
447
428
|
await waitFor(() => {
|
|
448
429
|
expect(mockParameterContext.addView).toBeCalledWith('default_view_id');
|
|
449
430
|
});
|
|
@@ -455,7 +436,7 @@ describe('HitSearchContext', () => {
|
|
|
455
436
|
const mockSearchParams = new URLSearchParams();
|
|
456
437
|
mockSearchParams.append('view', 'existing_view');
|
|
457
438
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
458
|
-
renderHook(() => useContextSelector(
|
|
439
|
+
renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
|
|
459
440
|
await waitFor(() => {
|
|
460
441
|
expect(mockParameterContext.addView).not.toBeCalled();
|
|
461
442
|
});
|
|
@@ -466,7 +447,7 @@ describe('HitSearchContext', () => {
|
|
|
466
447
|
mockParameterContext.views = [];
|
|
467
448
|
const mockSearchParams = new URLSearchParams();
|
|
468
449
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
469
|
-
renderHook(() => useContextSelector(
|
|
450
|
+
renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
|
|
470
451
|
await waitFor(() => {
|
|
471
452
|
expect(mockSetParams).not.toHaveBeenCalled();
|
|
472
453
|
});
|
|
@@ -476,12 +457,12 @@ describe('HitSearchContext', () => {
|
|
|
476
457
|
it('should not break when view ID does not exist', async () => {
|
|
477
458
|
mockParameterContext.views = ['non_existent_view'];
|
|
478
459
|
mockViewContext.getCurrentViews = vi.fn(() => Promise.resolve([null]));
|
|
479
|
-
const hook = renderHook(() => useContextSelector(
|
|
460
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
480
461
|
act(() => {
|
|
481
462
|
hook.result.current('test query');
|
|
482
463
|
});
|
|
483
464
|
await waitFor(() => {
|
|
484
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
465
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
485
466
|
query: expect.stringContaining('test query'),
|
|
486
467
|
filters: ['event.created:[now-1w TO now]']
|
|
487
468
|
}));
|
|
@@ -493,12 +474,12 @@ describe('HitSearchContext', () => {
|
|
|
493
474
|
{ view_id: 'view_1', query: 'howler.status:open' },
|
|
494
475
|
{ view_id: 'view_2', query: 'howler.priority:high' }
|
|
495
476
|
]);
|
|
496
|
-
const hook = renderHook(() => useContextSelector(
|
|
477
|
+
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
|
|
497
478
|
act(() => {
|
|
498
479
|
hook.result.current('test query');
|
|
499
480
|
});
|
|
500
481
|
await waitFor(() => {
|
|
501
|
-
expect(hpost).toHaveBeenCalledWith('/api/
|
|
482
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
502
483
|
query: 'test query',
|
|
503
484
|
filters: ['event.created:[now-1w TO now]', 'howler.status:open', 'howler.priority:high']
|
|
504
485
|
}));
|
|
@@ -509,7 +490,7 @@ describe('HitSearchContext', () => {
|
|
|
509
490
|
it('should not trigger search when views is empty and query is DEFAULT_QUERY', async () => {
|
|
510
491
|
mockParameterContext.query = DEFAULT_QUERY;
|
|
511
492
|
mockParameterContext.views = [];
|
|
512
|
-
renderHook(() => useContextSelector(
|
|
493
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
513
494
|
await waitFor(() => {
|
|
514
495
|
expect(hpost).not.toHaveBeenCalled();
|
|
515
496
|
});
|
|
@@ -517,7 +498,7 @@ describe('HitSearchContext', () => {
|
|
|
517
498
|
it('should trigger search when views.length > 0 even with DEFAULT_QUERY', async () => {
|
|
518
499
|
mockParameterContext.query = DEFAULT_QUERY;
|
|
519
500
|
mockParameterContext.views = ['view_1'];
|
|
520
|
-
renderHook(() => useContextSelector(
|
|
501
|
+
renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
|
|
521
502
|
await waitFor(() => {
|
|
522
503
|
expect(hpost).toHaveBeenCalled();
|
|
523
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;
|