@cccsaurora/howler-ui 2.15.0-dev.316 → 2.15.0-dev.323

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.
Files changed (31) hide show
  1. package/api/search/count/hit.js +2 -1
  2. package/api/search/explain/hit.js +2 -1
  3. package/api/search/facet/hit.js +2 -1
  4. package/api/search/grouped/hit.js +2 -1
  5. package/api/search/histogram/hit.js +2 -1
  6. package/api/search/hit.d.ts +1 -1
  7. package/api/search/hit.js +2 -1
  8. package/components/app/providers/HitSearchProvider.js +4 -4
  9. package/components/app/providers/ParameterProvider.js +2 -1
  10. package/components/app/providers/ViewProvider.js +1 -1
  11. package/components/app/providers/ViewProvider.test.js +4 -4
  12. package/components/elements/hit/aggregate/HitGraph.js +2 -1
  13. package/components/routes/advanced/luceneCompletionProvider.js +2 -1
  14. package/components/routes/dossiers/DossierEditor.test.js +3 -2
  15. package/components/routes/help/ActionIntroductionDocumentation.js +3 -3
  16. package/components/routes/hits/search/HitBrowser.js +3 -3
  17. package/components/routes/hits/search/HitContextMenu.d.ts +15 -0
  18. package/components/routes/hits/search/HitContextMenu.js +100 -12
  19. package/components/routes/hits/search/HitContextMenu.test.d.ts +1 -0
  20. package/components/routes/hits/search/HitContextMenu.test.js +774 -0
  21. package/components/routes/hits/search/HitQuery.js +4 -3
  22. package/components/routes/views/ViewComposer.js +2 -2
  23. package/locales/en/translation.json +1 -0
  24. package/locales/fr/translation.json +1 -0
  25. package/package.json +1 -1
  26. package/setupTests.js +1 -0
  27. package/tests/server-handlers.js +7 -1
  28. package/tests/utils.d.ts +12 -0
  29. package/tests/utils.js +41 -0
  30. package/utils/constants.d.ts +1 -0
  31. package/utils/constants.js +1 -0
@@ -1,8 +1,9 @@
1
1
  import { hpost, joinUri } from '@cccsaurora/howler-ui/api';
2
2
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/search/count';
3
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
3
4
  export const uri = () => {
4
5
  return joinUri(parentUri(), 'hit');
5
6
  };
6
7
  export const post = (request) => {
7
- return hpost(uri(), { ...(request || {}), query: request?.query || 'howler.id:*' });
8
+ return hpost(uri(), { ...(request || {}), query: request?.query || DEFAULT_QUERY });
8
9
  };
@@ -1,8 +1,9 @@
1
1
  import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
2
2
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/search';
3
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
3
4
  export const uri = () => {
4
5
  return joinAllUri(parentUri(), 'hit', 'explain');
5
6
  };
6
7
  export const post = (request) => {
7
- return hpost(uri(), { ...(request || {}), eql_query: request?.query || 'howler.id:*' });
8
+ return hpost(uri(), { ...(request || {}), eql_query: request?.query || DEFAULT_QUERY });
8
9
  };
@@ -1,8 +1,9 @@
1
1
  import { hpost, joinUri } from '@cccsaurora/howler-ui/api';
2
2
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/search/facet';
3
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
3
4
  export const uri = () => {
4
5
  return joinUri(parentUri(), 'hit');
5
6
  };
6
7
  export const post = (request) => {
7
- return hpost(uri(), { ...(request || {}), query: request?.query || 'howler.id:*' });
8
+ return hpost(uri(), { ...(request || {}), query: request?.query || DEFAULT_QUERY });
8
9
  };
@@ -1,8 +1,9 @@
1
1
  import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
2
2
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/search/grouped';
3
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
3
4
  export const uri = (field) => {
4
5
  return joinAllUri(parentUri(), 'hit', field);
5
6
  };
6
7
  export const post = (field, request) => {
7
- return hpost(uri(field), { ...(request || {}), query: request?.query || 'howler.id:*' });
8
+ return hpost(uri(field), { ...(request || {}), query: request?.query || DEFAULT_QUERY });
8
9
  };
@@ -1,8 +1,9 @@
1
1
  import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
2
2
  import { uri as parentUri } from '@cccsaurora/howler-ui/api/search/histogram';
3
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
3
4
  export const uri = (field) => {
4
5
  return joinAllUri(parentUri(), 'hit', field);
5
6
  };
6
7
  export const post = (field, request) => {
7
- return hpost(uri(field), { ...(request || {}), query: request?.query || 'howler.id:*' });
8
+ return hpost(uri(field), { ...(request || {}), query: request?.query || DEFAULT_QUERY });
8
9
  };
@@ -1,8 +1,8 @@
1
1
  import type { HowlerSearchRequest, HowlerSearchResponse } from '@cccsaurora/howler-ui/api/search';
2
- import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
3
2
  import * as eql from '@cccsaurora/howler-ui/api/search/eql/hit';
4
3
  import * as explain from '@cccsaurora/howler-ui/api/search/explain/hit';
5
4
  import * as sigma from '@cccsaurora/howler-ui/api/search/sigma/hit';
5
+ import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
6
6
  export declare const uri: () => string;
7
7
  export declare const post: (request?: HowlerSearchRequest) => Promise<HowlerSearchResponse<Hit>>;
8
8
  export { eql, explain, sigma };
package/api/search/hit.js CHANGED
@@ -3,10 +3,11 @@ import { uri as parentUri } from '@cccsaurora/howler-ui/api/search';
3
3
  import * as eql from '@cccsaurora/howler-ui/api/search/eql/hit';
4
4
  import * as explain from '@cccsaurora/howler-ui/api/search/explain/hit';
5
5
  import * as sigma from '@cccsaurora/howler-ui/api/search/sigma/hit';
6
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
6
7
  export const uri = () => {
7
8
  return joinUri(parentUri(), 'hit');
8
9
  };
9
10
  export const post = (request) => {
10
- return hpost(uri(), { ...(request || {}), query: request?.query || 'howler.id:*' });
11
+ return hpost(uri(), { ...(request || {}), query: request?.query || DEFAULT_QUERY });
11
12
  };
12
13
  export { eql, explain, sigma };
@@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
10
10
  import { isMobile } from 'react-device-detect';
11
11
  import { useLocation, useParams } from 'react-router-dom';
12
12
  import { createContext, useContextSelector } from 'use-context-selector';
13
- import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
13
+ import { DEFAULT_QUERY, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
14
14
  import { getStored } from '@cccsaurora/howler-ui/utils/localStorage';
15
15
  import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
16
16
  import { convertCustomDateRangeToLucene, convertDateToLucene } from '@cccsaurora/howler-ui/utils/utils';
@@ -73,12 +73,12 @@ const HitSearchProvider = ({ children }) => {
73
73
  }
74
74
  try {
75
75
  const bundle = location.pathname.startsWith('/bundles') && routeParams.id;
76
- let fullQuery = _query || 'howler.id:*';
76
+ let fullQuery = _query || DEFAULT_QUERY;
77
77
  if (bundle) {
78
78
  fullQuery = `(howler.bundles:${bundle}) AND (${fullQuery})`;
79
79
  }
80
80
  else if (viewId) {
81
- fullQuery = `(${(await getCurrentView({ viewId }))?.query || 'howler.id:*'}) AND (${fullQuery})`;
81
+ fullQuery = `(${(await getCurrentView({ viewId }))?.query || DEFAULT_QUERY}) AND (${fullQuery})`;
82
82
  }
83
83
  const _response = await dispatchApi(api.search.hit.post({
84
84
  offset: appendResults ? response.rows : offset,
@@ -139,7 +139,7 @@ const HitSearchProvider = ({ children }) => {
139
139
  if (span.endsWith('custom') && (!startDate || !endDate)) {
140
140
  return;
141
141
  }
142
- if (viewId || bundleId || (query && query !== 'howler.id:*') || offset > 0) {
142
+ if (viewId || bundleId || (query && query !== DEFAULT_QUERY) || offset > 0) {
143
143
  search(query);
144
144
  }
145
145
  else {
@@ -3,10 +3,11 @@ import { isEmpty, isNull, isUndefined, omitBy, pickBy } from 'lodash-es';
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useLocation, useParams, useSearchParams } from 'react-router-dom';
5
5
  import { createContext, useContextSelector } from 'use-context-selector';
6
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
6
7
  import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
7
8
  export const ParameterContext = createContext(null);
8
9
  const DEFAULT_VALUES = {
9
- query: 'howler.id:*',
10
+ query: DEFAULT_QUERY,
10
11
  sort: 'event.created desc',
11
12
  span: 'date.range.1.month'
12
13
  };
@@ -25,7 +25,7 @@ const ViewProvider = ({ children }) => {
25
25
  }));
26
26
  return newViews;
27
27
  }
28
- const missingIds = ids.filter(_id => !has(views, _id));
28
+ const missingIds = ids.filter(_id => !!_id && !has(views, _id));
29
29
  if (missingIds.length < 1) {
30
30
  return ids.map(id => views[id]);
31
31
  }
@@ -4,7 +4,7 @@ import { hget, hpost, hput } from '@cccsaurora/howler-ui/api';
4
4
  import MockLocalStorage from '@cccsaurora/howler-ui/tests/MockLocalStorage';
5
5
  import { MOCK_RESPONSES } from '@cccsaurora/howler-ui/tests/server-handlers';
6
6
  import { useContextSelector } from 'use-context-selector';
7
- import { MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
7
+ import { DEFAULT_QUERY, MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
8
8
  import ViewProvider, { ViewContext } from './ViewProvider';
9
9
  let mockUser = {
10
10
  favourite_views: ['favourited_view_id']
@@ -65,7 +65,7 @@ describe('ViewContext', () => {
65
65
  advance_on_triage: false
66
66
  },
67
67
  view_id: 'example_created_view',
68
- query: 'howler.id:*',
68
+ query: DEFAULT_QUERY,
69
69
  sort: 'event.created desc',
70
70
  title: 'Example View',
71
71
  type: 'personal',
@@ -159,9 +159,9 @@ describe('ViewContext', () => {
159
159
  vi.mocked(hpost).mockClear();
160
160
  });
161
161
  it('should allow users to edit views', async () => {
162
- const result = await act(async () => hook.result.current('example_view_id', { query: 'howler.id:*' }));
162
+ const result = await act(async () => hook.result.current('example_view_id', { query: DEFAULT_QUERY }));
163
163
  expect(hput).toHaveBeenCalledOnce();
164
- expect(hput).toBeCalledWith('/api/v1/view/example_view_id', { query: 'howler.id:*' });
164
+ expect(hput).toBeCalledWith('/api/v1/view/example_view_id', { query: DEFAULT_QUERY });
165
165
  expect(result).toEqual(MOCK_RESPONSES['/api/v1/view/example_view_id']);
166
166
  });
167
167
  });
@@ -15,6 +15,7 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
15
15
  import { Scatter } from 'react-chartjs-2';
16
16
  import { useTranslation } from 'react-i18next';
17
17
  import { useContextSelector } from 'use-context-selector';
18
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
18
19
  import { convertCustomDateRangeToLucene, convertDateToLucene, stringToColor } from '@cccsaurora/howler-ui/utils/utils';
19
20
  const MAX_ROWS = 2500;
20
21
  const OVERRIDE_ROWS = 10000;
@@ -79,7 +80,7 @@ const HitGraph = ({ query }) => {
79
80
  setDisabled(false);
80
81
  }
81
82
  const _data = await dispatchApi(api.search.grouped.hit.post(filterField, {
82
- query: query || 'howler.id:*',
83
+ query: query || DEFAULT_QUERY,
83
84
  fl: 'event.created,howler.assessment,howler.analytic,howler.detection,howler.outline.threat,howler.outline.target,howler.outline.summary,howler.id',
84
85
  // We want a generally random sample across all date ranges, so we use hash.
85
86
  // If we used event.created instead, when 1 million hits/hour are created, you'd only see hits from this past minute
@@ -4,6 +4,7 @@ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers
4
4
  import { FieldContext } from '@cccsaurora/howler-ui/components/app/providers/FieldProvider';
5
5
  import Fuse from 'fuse.js';
6
6
  import { useContext, useEffect, useMemo } from 'react';
7
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
7
8
  const useLuceneCompletionProvider = () => {
8
9
  const { config } = useContext(ApiConfigContext);
9
10
  const monaco = useMonaco();
@@ -42,7 +43,7 @@ const useLuceneCompletionProvider = () => {
42
43
  }
43
44
  else {
44
45
  const options = await api.search.facet.hit
45
- .post({ query: 'howler.id:*', rows: 250, fields: [key] })
46
+ .post({ query: DEFAULT_QUERY, rows: 250, fields: [key] })
46
47
  .catch(() => ({}));
47
48
  const _position = model.getWordUntilPosition(position);
48
49
  return {
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { render, screen, waitFor } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import omit from 'lodash-es/omit';
6
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
6
7
  // Mock the API
7
8
  const mockApiSearchHitPost = vi.fn();
8
9
  const mockApiDossierGet = vi.fn();
@@ -86,7 +87,7 @@ vi.mock('../hits/search/HitQuery', () => ({
86
87
  if (e.key === 'Enter') {
87
88
  triggerSearch(e.target.value);
88
89
  }
89
- } }), _jsx("button", { id: "trigger-search", onClick: () => triggerSearch('howler.id:*'), children: 'search' })] }));
90
+ } }), _jsx("button", { id: "trigger-search", onClick: () => triggerSearch(DEFAULT_QUERY), children: 'search' })] }));
90
91
  }
91
92
  }));
92
93
  import ApiConfigProvider from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
@@ -103,7 +104,7 @@ const mockDossier = {
103
104
  id: 'test-dossier-1',
104
105
  title: 'Test Dossier',
105
106
  type: 'global',
106
- query: 'howler.id:*',
107
+ query: DEFAULT_QUERY,
107
108
  leads: [
108
109
  {
109
110
  label: { en: 'Lead 1', fr: 'Piste 1' },
@@ -8,7 +8,7 @@ import Markdown from '@cccsaurora/howler-ui/components/elements/display/Markdown
8
8
  import { difference } from 'lodash-es';
9
9
  import { useEffect, useMemo, useState } from 'react';
10
10
  import { useTranslation } from 'react-i18next';
11
- import { VALID_ACTION_TRIGGERS } from '@cccsaurora/howler-ui/utils/constants';
11
+ import { DEFAULT_QUERY, VALID_ACTION_TRIGGERS } from '@cccsaurora/howler-ui/utils/constants';
12
12
  import QueryResultText from '../../elements/display/QueryResultText';
13
13
  import ActionReportDisplay from '../action/shared/ActionReportDisplay';
14
14
  import OperationStep from '../action/shared/OperationStep';
@@ -42,13 +42,13 @@ const ActionIntroductionDocumentation = () => {
42
42
  report: (_jsx(ActionReportDisplay, { report: {
43
43
  add_label: [
44
44
  {
45
- query: 'howler.id:*',
45
+ query: DEFAULT_QUERY,
46
46
  outcome: 'skipped',
47
47
  title: 'Skipped Hit with Label',
48
48
  message: `These hits already have the label ${OPERATION_VALUES.label}.`
49
49
  },
50
50
  {
51
- query: 'howler.id:*',
51
+ query: DEFAULT_QUERY,
52
52
  outcome: 'success',
53
53
  title: 'Executed Successfully',
54
54
  message: `Label '${OPERATION_VALUES.label}' added to category '${OPERATION_VALUES.category}' for all matching hits.`
@@ -16,7 +16,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react';
16
16
  import { Trans, useTranslation } from 'react-i18next';
17
17
  import { useLocation, useParams, useSearchParams } from 'react-router-dom';
18
18
  import { useContextSelector } from 'use-context-selector';
19
- import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
19
+ import { DEFAULT_QUERY, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
20
20
  import InformationPane from './InformationPane';
21
21
  import SearchPane from './SearchPane';
22
22
  import HitGrid from './grid/HitGrid';
@@ -60,7 +60,7 @@ const HitBrowser = () => {
60
60
  _fullQuery = `(howler.bundles:${bundle}) AND (${_fullQuery})`;
61
61
  }
62
62
  else if (viewId) {
63
- _fullQuery = `(${views[viewId]?.query || 'howler.id:*'}) AND (${_fullQuery})`;
63
+ _fullQuery = `(${views[viewId]?.query || DEFAULT_QUERY}) AND (${_fullQuery})`;
64
64
  }
65
65
  return _fullQuery;
66
66
  }, [location.pathname, query, routeParams.id, views, viewId]);
@@ -93,7 +93,7 @@ const HitBrowser = () => {
93
93
  });
94
94
  }, [setQueryHistory]);
95
95
  useEffect(() => {
96
- if (!location.pathname.startsWith('/views') || has(views, viewId)) {
96
+ if (!location.pathname.startsWith('/views') || !viewId || has(views, viewId)) {
97
97
  return;
98
98
  }
99
99
  fetchViews([viewId]);
@@ -1,7 +1,22 @@
1
1
  import type { FC, PropsWithChildren } from 'react';
2
+ import React from 'react';
3
+ /**
4
+ * Props for the HitContextMenu component
5
+ */
2
6
  interface HitContextMenuProps {
7
+ /**
8
+ * Function to extract the hit ID from a mouse event
9
+ */
3
10
  getSelectedId: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => string;
11
+ /**
12
+ * Optional component to wrap the children, defaults to Box
13
+ */
4
14
  Component?: React.ElementType;
5
15
  }
16
+ /**
17
+ * Context menu component for hit operations.
18
+ * Provides quick access to common hit actions including assessment, voting,
19
+ * transitions, and exclusion filters based on template fields.
20
+ */
6
21
  declare const HitContextMenu: FC<PropsWithChildren<HitContextMenuProps>>;
7
22
  export default HitContextMenu;
@@ -1,44 +1,71 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Assignment, Edit, HowToVote, KeyboardArrowRight, OpenInNew, QueryStats, SettingsSuggest, Terminal } from '@mui/icons-material';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Assignment, Edit, HowToVote, KeyboardArrowRight, OpenInNew, QueryStats, RemoveCircleOutline, SettingsSuggest, Terminal } from '@mui/icons-material';
3
3
  import { Box, Divider, Fade, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Paper } from '@mui/material';
4
4
  import api from '@cccsaurora/howler-ui/api';
5
5
  import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
6
6
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
7
7
  import { HitContext } from '@cccsaurora/howler-ui/components/app/providers/HitProvider';
8
+ import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
8
9
  import { TOP_ROW, VOTE_OPTIONS } from '@cccsaurora/howler-ui/components/elements/hit/actions/SharedComponents';
9
10
  import useHitActions from '@cccsaurora/howler-ui/components/hooks/useHitActions';
10
11
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
11
12
  import useMyActionFunctions from '@cccsaurora/howler-ui/components/routes/action/useMyActionFunctions';
12
- import { capitalize, groupBy } from 'lodash-es';
13
+ import { capitalize, get, groupBy, isEmpty, toString } from 'lodash-es';
13
14
  import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
14
- import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
15
+ import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
15
16
  import { useTranslation } from 'react-i18next';
16
17
  import { usePluginStore } from 'react-pluggable';
17
18
  import { Link } from 'react-router-dom';
18
19
  import { useContextSelector } from 'use-context-selector';
20
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
21
+ import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
22
+ /**
23
+ * Order in which action types should be displayed in the context menu
24
+ */
19
25
  const ORDER = ['assessment', 'vote', 'action'];
26
+ /**
27
+ * The margin at the bottom of the screen by which the context menu should be inverted.
28
+ * That is, if right clicking within this many pixels of the bottom, render the context menu to the top right
29
+ * of the pointer instead of the bottom right.
30
+ */
31
+ const CONTEXTMENU_MARGIN = 350;
32
+ /**
33
+ * Icon mapping for different action types
34
+ */
20
35
  const ICON_MAP = {
21
36
  assessment: _jsx(Assignment, {}),
22
37
  vote: _jsx(HowToVote, {}),
23
38
  action: _jsx(Edit, {})
24
39
  };
40
+ /**
41
+ * Context menu component for hit operations.
42
+ * Provides quick access to common hit actions including assessment, voting,
43
+ * transitions, and exclusion filters based on template fields.
44
+ */
25
45
  const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
26
46
  const { t } = useTranslation();
27
47
  const { dispatchApi } = useMyApi();
28
48
  const { executeAction } = useMyActionFunctions();
29
49
  const { config } = useContext(ApiConfigContext);
30
50
  const pluginStore = usePluginStore();
31
- const { getMatchingAnalytic } = useMatchers();
51
+ const { getMatchingAnalytic, getMatchingTemplate } = useMatchers();
52
+ const query = useContextSelector(ParameterContext, ctx => ctx.query);
53
+ const setQuery = useContextSelector(ParameterContext, ctx => ctx.setQuery);
32
54
  const [id, setId] = useState(null);
33
55
  const hit = useContextSelector(HitContext, ctx => ctx.hits[id]);
34
56
  const selectedHits = useContextSelector(HitContext, ctx => ctx.selectedHits);
35
57
  const [analytic, setAnalytic] = useState(null);
58
+ const [template, setTemplate] = useState(null);
36
59
  const [anchorEl, setAnchorEl] = useState();
37
- const [clickLocation, setClickLocation] = useState([-1, -1]);
60
+ const [transformProps, setTransformProps] = useState({});
38
61
  const [actions, setActions] = useState([]);
39
62
  const [show, setShow] = useState({});
40
63
  const hits = useMemo(() => (selectedHits.some(_hit => _hit.howler.id === hit?.howler.id) ? selectedHits : [hit]), [hit, selectedHits]);
41
64
  const { availableTransitions, canVote, canAssess, assess, vote } = useHitActions(hits);
65
+ /**
66
+ * Handles right-click context menu events.
67
+ * Opens the context menu at the click location and loads available actions.
68
+ */
42
69
  const onContextMenu = useCallback(async (event) => {
43
70
  if (anchorEl) {
44
71
  event.preventDefault();
@@ -48,8 +75,21 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
48
75
  event.preventDefault();
49
76
  const _id = getSelectedId(event);
50
77
  setId(_id);
51
- const clientRect = event.target.getBoundingClientRect();
52
- setClickLocation([event.clientX - clientRect.x, event.clientY - clientRect.y]);
78
+ if (window.innerHeight - event.clientY < 300) {
79
+ setTransformProps({
80
+ position: 'fixed',
81
+ bottom: `${window.innerHeight - event.clientY}px !important`,
82
+ top: 'unset !important',
83
+ left: `${event.clientX}px !important`
84
+ });
85
+ }
86
+ else {
87
+ setTransformProps({
88
+ position: 'fixed',
89
+ top: `${event.clientY}px !important`,
90
+ left: `${event.clientX}px !important`
91
+ });
92
+ }
53
93
  setAnchorEl(event.target);
54
94
  const _actions = (await dispatchApi(api.search.action.post({ query: 'action_id:*' }), { throwError: false }))
55
95
  ?.items;
@@ -62,6 +102,10 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
62
102
  vote: canVote
63
103
  }), [canAssess, canVote]);
64
104
  const pluginActions = howlerPluginStore.plugins.flatMap(plugin => pluginStore.executeFunction(`${plugin}.actions`, hits));
105
+ /**
106
+ * Generates grouped action entries for the context menu.
107
+ * Combines transitions, plugin actions, votes, and assessments based on permissions.
108
+ */
65
109
  const entries = useMemo(() => {
66
110
  let _actions = [...availableTransitions, ...pluginActions];
67
111
  if (canVote) {
@@ -89,16 +133,34 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
89
133
  }
90
134
  return Object.entries(groupBy(_actions, 'type')).sort(([a], [b]) => ORDER.indexOf(a) - ORDER.indexOf(b));
91
135
  }, [analytic, assess, availableTransitions, canAssess, canVote, config.lookups, vote, pluginActions]);
136
+ /**
137
+ * Calculates appropriate styles for submenu positioning.
138
+ * Adjusts position based on available screen space to prevent overflow.
139
+ */
140
+ const calculateSubMenuStyles = useCallback((parent) => {
141
+ const baseStyles = { position: 'absolute', maxHeight: '300px', overflow: 'auto' };
142
+ const defaultStyles = { ...baseStyles, top: 0, left: '100%' };
143
+ if (!parent) {
144
+ return defaultStyles;
145
+ }
146
+ const parentBounds = parent.getBoundingClientRect();
147
+ if (window.innerHeight - parentBounds.y < CONTEXTMENU_MARGIN) {
148
+ return { ...baseStyles, bottom: 0, left: '100%' };
149
+ }
150
+ return defaultStyles;
151
+ }, []);
152
+ // Load analytic and template data when a hit is selected
92
153
  useEffect(() => {
93
154
  if (!hit?.howler.analytic) {
94
155
  return;
95
156
  }
96
157
  getMatchingAnalytic(hit).then(setAnalytic);
158
+ getMatchingTemplate(hit).then(setTemplate);
97
159
  // eslint-disable-next-line react-hooks/exhaustive-deps
98
- }, [hit?.howler.analytic]);
160
+ }, [hit]);
161
+ // Reset menu state when context menu is closed
99
162
  useEffect(() => {
100
163
  if (!anchorEl) {
101
- setClickLocation([-1, -1]);
102
164
  setShow({});
103
165
  setAnalytic(null);
104
166
  }
@@ -106,10 +168,36 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
106
168
  return (_jsxs(Component, { id: "contextMenu", onContextMenu: onContextMenu, children: [children, _jsxs(Menu, { id: "hit-menu", open: !!anchorEl, anchorEl: anchorEl, onClose: () => setAnchorEl(null), slotProps: {
107
169
  paper: {
108
170
  sx: {
109
- transform: `translate(${clickLocation[0]}px, ${clickLocation[1]}px) !important`,
171
+ ...transformProps,
110
172
  overflow: 'visible !important'
111
173
  }
112
174
  }
113
- }, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { sx: { position: 'relative' }, onMouseEnter: () => setShow(_show => ({ ..._show, [type]: true })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: false })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: show[type], unmountOnExit: true, children: _jsx(Paper, { sx: { position: 'absolute', top: 0, left: '100%', maxHeight: '300px', overflow: 'auto' }, elevation: 8, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, children: items.map(a => (_jsx(MenuItem, { value: a.name, onClick: a.actionFunction, children: a.i18nKey ? t(a.i18nKey) : capitalize(a.name) }, a.name))) }) }) })] }, type))), _jsxs(MenuItem, { sx: { position: 'relative' }, onMouseEnter: () => setShow(_show => ({ ..._show, actions: true })), onMouseLeave: () => setShow(_show => ({ ..._show, actions: false })), disabled: actions.length < 1, children: [_jsx(ListItemIcon, { children: _jsx(SettingsSuggest, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('route.actions.change') }), actions.length > 0 && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: show.actions, unmountOnExit: true, children: _jsx(Paper, { sx: { position: 'absolute', top: 0, left: '100%', maxHeight: '300px', overflow: 'auto' }, elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, children: actions.map(action => (_jsx(MenuItem, { onClick: () => executeAction(action.action_id, `howler.id:${hit?.howler.id}`), children: _jsx(ListItemText, { children: action.name }) }, action.action_id))) }) }) })] })] })] }));
175
+ }, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { id: `${type}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [type]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: null })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: !!show[type], unmountOnExit: true, children: _jsx(Paper, { id: `${type}-submenu`, sx: calculateSubMenuStyles(show[type]), elevation: 8, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: items.map(a => (_jsx(MenuItem, { value: a.name, onClick: a.actionFunction, children: a.i18nKey ? t(a.i18nKey) : capitalize(a.name) }, a.name))) }) }) })] }, type))), _jsxs(MenuItem, { id: "actions-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, actions: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, actions: null })), disabled: actions.length < 1, children: [_jsx(ListItemIcon, { children: _jsx(SettingsSuggest, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('route.actions.change') }), actions.length > 0 && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.actions, unmountOnExit: true, children: _jsx(Paper, { id: "actions-submenu", sx: calculateSubMenuStyles(show.actions), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: actions.map(action => (_jsx(MenuItem, { onClick: () => executeAction(action.action_id, `howler.id:${hit?.howler.id}`), children: _jsx(ListItemText, { children: action.name }) }, action.action_id))) }) }) })] }), !isEmpty(template?.keys ?? []) && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(MenuItem, { id: "excludes-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, excludes: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, excludes: null })), children: [_jsx(ListItemIcon, { children: _jsx(RemoveCircleOutline, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('hit.panel.exclude') }), _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.excludes, unmountOnExit: true, children: _jsx(Paper, { id: "excludes-submenu", sx: calculateSubMenuStyles(show.excludes), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: template?.keys.map(key => {
176
+ // Build exclusion query based on current query and field value
177
+ let newQuery = '';
178
+ if (query !== DEFAULT_QUERY) {
179
+ newQuery = `(${query}) AND `;
180
+ }
181
+ const value = get(hit, key);
182
+ if (!value) {
183
+ return null;
184
+ }
185
+ else if (Array.isArray(value)) {
186
+ // Handle array values by excluding all items
187
+ const sanitizedValues = value
188
+ .map(toString)
189
+ .filter(val => !!val)
190
+ .map(val => `"${sanitizeLuceneQuery(val)}"`);
191
+ if (sanitizedValues.length < 1) {
192
+ return null;
193
+ }
194
+ newQuery += `-${key}:(${sanitizedValues.join(' OR ')})`;
195
+ }
196
+ else {
197
+ // Handle single value
198
+ newQuery += `-${key}:"${sanitizeLuceneQuery(value.toString())}"`;
199
+ }
200
+ return (_jsx(MenuItem, { onClick: () => setQuery(newQuery), children: _jsx(ListItemText, { children: key }) }, key));
201
+ }) }) }) })] })] }))] })] }));
114
202
  };
115
203
  export default HitContextMenu;