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