@cccsaurora/howler-ui 2.15.0-dev.318 → 2.15.0-dev.326
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/search/count/hit.js +2 -1
- package/api/search/explain/hit.js +2 -1
- package/api/search/facet/hit.js +2 -1
- package/api/search/grouped/hit.js +2 -1
- package/api/search/histogram/hit.js +2 -1
- package/api/search/hit.d.ts +1 -1
- package/api/search/hit.js +2 -1
- package/components/app/providers/HitSearchProvider.js +4 -4
- package/components/app/providers/ParameterProvider.js +2 -1
- package/components/app/providers/ViewProvider.test.js +4 -4
- package/components/elements/display/modals/RationaleModal.d.ts +2 -0
- package/components/elements/display/modals/RationaleModal.js +44 -7
- package/components/elements/hit/aggregate/HitGraph.js +2 -1
- package/components/hooks/useHitActions.js +1 -1
- package/components/routes/advanced/luceneCompletionProvider.js +2 -1
- package/components/routes/dossiers/DossierEditor.test.js +3 -2
- package/components/routes/help/ActionIntroductionDocumentation.js +3 -3
- package/components/routes/hits/search/HitBrowser.js +2 -2
- package/components/routes/hits/search/HitContextMenu.d.ts +15 -0
- package/components/routes/hits/search/HitContextMenu.js +100 -12
- package/components/routes/hits/search/HitContextMenu.test.d.ts +1 -0
- package/components/routes/hits/search/HitContextMenu.test.js +774 -0
- package/components/routes/hits/search/HitQuery.js +4 -3
- package/components/routes/views/ViewComposer.js +2 -2
- package/locales/en/translation.json +3 -0
- package/locales/fr/translation.json +3 -0
- package/package.json +1 -1
- package/setupTests.js +1 -0
- package/tests/server-handlers.js +7 -1
- package/tests/utils.d.ts +12 -0
- package/tests/utils.js +41 -0
- package/utils/constants.d.ts +1 -0
- package/utils/constants.js +1 -0
package/api/search/count/hit.js
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
8
|
+
return hpost(uri(), { ...(request || {}), eql_query: request?.query || DEFAULT_QUERY });
|
|
8
9
|
};
|
package/api/search/facet/hit.js
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
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 ||
|
|
8
|
+
return hpost(uri(field), { ...(request || {}), query: request?.query || DEFAULT_QUERY });
|
|
8
9
|
};
|
package/api/search/hit.d.ts
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
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 ||
|
|
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 !==
|
|
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:
|
|
10
|
+
query: DEFAULT_QUERY,
|
|
10
11
|
sort: 'event.created desc',
|
|
11
12
|
span: 'date.range.1.month'
|
|
12
13
|
};
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
});
|
|
@@ -1,13 +1,37 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Button, Stack, TextField, Typography } from '@mui/material';
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Autocomplete, Button, CircularProgress, ListItemText, Stack, TextField, Typography } from '@mui/material';
|
|
3
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
4
|
+
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks/useAppUser';
|
|
3
5
|
import { parseEvent } from '@cccsaurora/howler-ui/commons/components/utils/keyboard';
|
|
4
6
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
5
|
-
import
|
|
7
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
|
+
import { isEqual } from 'lodash-es';
|
|
9
|
+
import flatten from 'lodash-es/flatten';
|
|
10
|
+
import isString from 'lodash-es/isString';
|
|
11
|
+
import uniqBy from 'lodash-es/uniqBy';
|
|
12
|
+
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
6
13
|
import { useTranslation } from 'react-i18next';
|
|
7
|
-
|
|
14
|
+
import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
15
|
+
const RationaleModal = ({ hits, onSubmit }) => {
|
|
8
16
|
const { t } = useTranslation();
|
|
17
|
+
const { dispatchApi } = useMyApi();
|
|
9
18
|
const { close } = useContext(ModalContext);
|
|
19
|
+
const { user } = useAppUser();
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
10
21
|
const [rationale, setRationale] = useState('');
|
|
22
|
+
const [suggestedRationales, setSuggestedRationales] = useState([]);
|
|
23
|
+
const queries = useMemo(() => [
|
|
24
|
+
{
|
|
25
|
+
type: 'analytic',
|
|
26
|
+
query: hits
|
|
27
|
+
.map(hit => `(howler.rationale:* AND howler.analytic:"${sanitizeLuceneQuery(hit.howler.analytic)}")`)
|
|
28
|
+
.join(' OR ')
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: 'assignment',
|
|
32
|
+
query: `howler.rationale:* AND howler.assignment:${user.username} AND howler.timestamp:[now-14d TO now]`
|
|
33
|
+
}
|
|
34
|
+
], [hits, user.username]);
|
|
11
35
|
const handleSubmit = useCallback(() => {
|
|
12
36
|
onSubmit(rationale);
|
|
13
37
|
close();
|
|
@@ -21,8 +45,21 @@ const RationaleModal = ({ onSubmit }) => {
|
|
|
21
45
|
close();
|
|
22
46
|
}
|
|
23
47
|
}, [close, handleSubmit]);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
(async () => {
|
|
50
|
+
setLoading(true);
|
|
51
|
+
// TODO: Eventually switch a a facet call once the elasticsearch refactor is complete
|
|
52
|
+
const results = flatten(await Promise.all(queries.map(async ({ query, type }) => {
|
|
53
|
+
const result = await dispatchApi(api.search.hit.post({ query, rows: 250, fl: 'howler.rationale,howler.assignment' }), { throwError: false });
|
|
54
|
+
return uniqBy((result?.items ?? []).map(_hit => ({ rationale: _hit.howler.rationale, type })), 'rationale');
|
|
55
|
+
})));
|
|
56
|
+
setSuggestedRationales(results);
|
|
57
|
+
setLoading(false);
|
|
58
|
+
})();
|
|
59
|
+
}, [dispatchApi, queries]);
|
|
60
|
+
return (_jsxs(Stack, { spacing: 2, p: 2, alignItems: "start", sx: { minWidth: '500px' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.rationale.title') }), _jsx(Typography, { children: t('modal.rationale.description') }), _jsx(Autocomplete, { loading: loading, loadingText: t('loading'), freeSolo: true, value: rationale, onChange: (_, newValue) => setRationale(isString(newValue) ? newValue : (newValue?.rationale ?? '')), options: suggestedRationales, getOptionLabel: suggestion => (isString(suggestion) ? suggestion : suggestion.rationale), isOptionEqualToValue: (option, value) => isString(value) ? option.rationale === value : isEqual(option, value), fullWidth: true, disablePortal: true, renderInput: params => (_jsx(TextField, { ...params, label: t('modal.rationale.label'), onChange: e => setRationale(e.target.value), onKeyDown: handleKeydown, InputProps: {
|
|
61
|
+
...params.InputProps,
|
|
62
|
+
endAdornment: (_jsxs(_Fragment, { children: [loading ? _jsx(CircularProgress, { color: "inherit", size: 20 }) : null, params.InputProps.endAdornment] }))
|
|
63
|
+
} })), renderOption: (props, option) => (_jsx(ListItemText, { ...props, sx: { flexDirection: 'column', alignItems: 'start !important' }, primary: option.rationale, secondary: t(`modal.rationale.type.${option.type}`) })) }), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", onClick: close, children: t('cancel') }), _jsx(Button, { variant: "outlined", onClick: handleSubmit, children: t('submit') })] })] }));
|
|
27
64
|
};
|
|
28
65
|
export default RationaleModal;
|
|
@@ -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 ||
|
|
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
|
|
@@ -94,7 +94,7 @@ const useHitActions = (_hits) => {
|
|
|
94
94
|
const rationale = skipRationale
|
|
95
95
|
? t('rationale.default', { assessment })
|
|
96
96
|
: await new Promise(res => {
|
|
97
|
-
showModal(_jsx(RationaleModal, { onSubmit: _rationale => {
|
|
97
|
+
showModal(_jsx(RationaleModal, { hits: hits, onSubmit: _rationale => {
|
|
98
98
|
res(_rationale);
|
|
99
99
|
} }));
|
|
100
100
|
});
|
|
@@ -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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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 ||
|
|
63
|
+
_fullQuery = `(${views[viewId]?.query || DEFAULT_QUERY}) AND (${_fullQuery})`;
|
|
64
64
|
}
|
|
65
65
|
return _fullQuery;
|
|
66
66
|
}, [location.pathname, query, routeParams.id, views, 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 [
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|