@cccsaurora/howler-ui 2.16.0-dev.381 → 2.16.0-dev.383
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/components/app/providers/HitSearchProvider.js +0 -1
- package/components/elements/display/modals/RationaleModal.js +66 -26
- package/components/elements/display/modals/RationaleModal.test.d.ts +1 -0
- package/components/elements/display/modals/RationaleModal.test.js +538 -0
- package/components/routes/analytics/TriageSettings.js +39 -16
- package/locales/en/translation.json +3 -0
- package/locales/fr/translation.json +3 -0
- package/models/entities/generated/TriageSettings.d.ts +1 -0
- package/package.json +1 -1
- package/tests/server-handlers.js +14 -1
|
@@ -85,7 +85,6 @@ const HitSearchProvider = ({ children }) => {
|
|
|
85
85
|
.forEach(viewQuery => _filters.push(viewQuery));
|
|
86
86
|
}
|
|
87
87
|
return _filters;
|
|
88
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
88
|
}, [endDate, filters, getCurrentViews, location.pathname, routeParams.id, span, startDate, views]);
|
|
90
89
|
const search = useCallback(async (_query, appendResults) => {
|
|
91
90
|
THROTTLER.debounce(async () => {
|
|
@@ -3,39 +3,37 @@ import { Autocomplete, Button, CircularProgress, ListItemText, Stack, TextField,
|
|
|
3
3
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
4
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks/useAppUser';
|
|
5
5
|
import { parseEvent } from '@cccsaurora/howler-ui/commons/components/utils/keyboard';
|
|
6
|
+
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
6
7
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
7
8
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
|
-
import { isEqual } from 'lodash-es';
|
|
9
|
-
import flatten from 'lodash-es/flatten';
|
|
9
|
+
import { isEqual, uniqBy } from 'lodash-es';
|
|
10
10
|
import isString from 'lodash-es/isString';
|
|
11
|
-
import
|
|
12
|
-
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
11
|
+
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
13
12
|
import { useTranslation } from 'react-i18next';
|
|
14
13
|
import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
15
14
|
const RationaleModal = ({ hits, onSubmit }) => {
|
|
15
|
+
// Hooks for translations, API calls, modal control, user data, and analytic matching
|
|
16
16
|
const { t } = useTranslation();
|
|
17
17
|
const { dispatchApi } = useMyApi();
|
|
18
18
|
const { close } = useContext(ModalContext);
|
|
19
19
|
const { user } = useAppUser();
|
|
20
|
+
const { getMatchingAnalytic } = useMatchers();
|
|
21
|
+
// State management for loading status, user input, and suggested options
|
|
20
22
|
const [loading, setLoading] = useState(false);
|
|
21
23
|
const [rationale, setRationale] = useState('');
|
|
22
24
|
const [suggestedRationales, setSuggestedRationales] = useState([]);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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]);
|
|
25
|
+
/**
|
|
26
|
+
* Submits the rationale and closes the modal
|
|
27
|
+
*/
|
|
35
28
|
const handleSubmit = useCallback(() => {
|
|
36
29
|
onSubmit(rationale);
|
|
37
30
|
close();
|
|
38
31
|
}, [onSubmit, rationale, close]);
|
|
32
|
+
/**
|
|
33
|
+
* Handles keyboard shortcuts:
|
|
34
|
+
* - Ctrl+Enter: Submit the rationale
|
|
35
|
+
* - Escape: Close the modal
|
|
36
|
+
*/
|
|
39
37
|
const handleKeydown = useCallback(e => {
|
|
40
38
|
const parsedEvent = parseEvent(e);
|
|
41
39
|
if (parsedEvent.isCtrl && parsedEvent.isEnter) {
|
|
@@ -45,21 +43,63 @@ const RationaleModal = ({ hits, onSubmit }) => {
|
|
|
45
43
|
close();
|
|
46
44
|
}
|
|
47
45
|
}, [close, handleSubmit]);
|
|
46
|
+
/**
|
|
47
|
+
* Executes a facet search to retrieve rationales from existing hits
|
|
48
|
+
* @param request - The facet search request parameters
|
|
49
|
+
* @param type - The type of rationale source (assignment/analytic)
|
|
50
|
+
* @returns Array of rationale options with their type
|
|
51
|
+
*/
|
|
52
|
+
const runFacet = useCallback(async (request, type) => {
|
|
53
|
+
const result = await dispatchApi(api.search.facet.hit.post(request), { throwError: false });
|
|
54
|
+
if (!result?.['howler.rationale']) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return Object.keys(result['howler.rationale'] ?? {}).map(_rationale => ({
|
|
58
|
+
rationale: _rationale,
|
|
59
|
+
type
|
|
60
|
+
}));
|
|
61
|
+
}, [dispatchApi]);
|
|
62
|
+
/**
|
|
63
|
+
* Fetches preset rationales from the analytic configurations.
|
|
64
|
+
* Retrieves matching analytics for the provided hits and extracts their predefined rationales.
|
|
65
|
+
*/
|
|
66
|
+
const fetchPresetRationales = useCallback(async () => {
|
|
67
|
+
const analytics = await Promise.all(hits.flatMap(hit => getMatchingAnalytic(hit)));
|
|
68
|
+
const uniqueAnalytics = uniqBy(analytics, 'analytic_id');
|
|
69
|
+
const rationales = uniqueAnalytics.flatMap(_analytic => _analytic?.triage_settings?.rationales ?? []);
|
|
70
|
+
return rationales.map(_rationale => ({ rationale: _rationale, type: 'preset' }));
|
|
71
|
+
}, [hits, getMatchingAnalytic]);
|
|
48
72
|
useEffect(() => {
|
|
49
73
|
(async () => {
|
|
50
74
|
setLoading(true);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
try {
|
|
76
|
+
const options = await Promise.all([
|
|
77
|
+
fetchPresetRationales(),
|
|
78
|
+
// Rationales by other users for the current analytic
|
|
79
|
+
runFacet({
|
|
80
|
+
query: 'howler.rationale:*',
|
|
81
|
+
rows: 10,
|
|
82
|
+
fields: ['howler.rationale'],
|
|
83
|
+
filters: hits.map(hit => `howler.analytic:"${sanitizeLuceneQuery(hit.howler.analytic)}")`)
|
|
84
|
+
}, 'analytic'),
|
|
85
|
+
// Rationales provided by this user
|
|
86
|
+
runFacet({
|
|
87
|
+
query: `howler.rationale:* AND howler.assignment:${user.username} AND timestamp:[now-14d TO now]`,
|
|
88
|
+
rows: 25,
|
|
89
|
+
fields: ['howler.rationale']
|
|
90
|
+
}, 'assignment')
|
|
91
|
+
]);
|
|
92
|
+
setSuggestedRationales(options.flat());
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setLoading(false);
|
|
96
|
+
}
|
|
58
97
|
})();
|
|
59
|
-
}, [dispatchApi,
|
|
98
|
+
}, [dispatchApi, fetchPresetRationales, getMatchingAnalytic, hits, runFacet, user.username]);
|
|
99
|
+
// Render the modal with title, description, autocomplete input, and action buttons
|
|
60
100
|
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
101
|
...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') })] })] }));
|
|
102
|
+
endAdornment: (_jsxs(_Fragment, { children: [loading ? _jsx(CircularProgress, { "aria-label": t('loading'), color: "inherit", size: 20 }) : null, params.InputProps.endAdornment] }))
|
|
103
|
+
} })), renderOption: ({ key, ...props }, option) => (_jsx(ListItemText, { ...props, sx: { flexDirection: 'column', alignItems: 'start !important' }, primary: option.rationale, secondary: t(`modal.rationale.type.${option.type}`) }, key)) }), _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') })] })] }));
|
|
64
104
|
};
|
|
65
105
|
export default RationaleModal;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/// <reference types="vitest" />
|
|
3
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import userEvent, {} from '@testing-library/user-event';
|
|
5
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
|
+
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
7
|
+
import { I18nextProvider } from 'react-i18next';
|
|
8
|
+
import { createMockHit } from '@cccsaurora/howler-ui/tests/utils';
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import RationaleModal from './RationaleModal';
|
|
11
|
+
vi.mock('api', { spy: true });
|
|
12
|
+
vi.mock('commons/components/app/hooks/useAppUser', () => ({
|
|
13
|
+
useAppUser: () => ({
|
|
14
|
+
user: { username: 'test-user' }
|
|
15
|
+
})
|
|
16
|
+
}));
|
|
17
|
+
// Mock functions
|
|
18
|
+
let mockGetMatchingAnalytic = vi.fn();
|
|
19
|
+
let mockOnSubmit = vi.fn();
|
|
20
|
+
import { hpost } from '@cccsaurora/howler-ui/api';
|
|
21
|
+
const mockHpost = vi.mocked(hpost);
|
|
22
|
+
vi.mock('components/app/hooks/useMatchers', () => ({
|
|
23
|
+
default: () => ({
|
|
24
|
+
getMatchingAnalytic: mockGetMatchingAnalytic
|
|
25
|
+
})
|
|
26
|
+
}));
|
|
27
|
+
// Mock modal context
|
|
28
|
+
const mockModalContext = {
|
|
29
|
+
close: vi.fn(),
|
|
30
|
+
open: vi.fn()
|
|
31
|
+
};
|
|
32
|
+
// Test wrapper
|
|
33
|
+
const Wrapper = ({ children }) => {
|
|
34
|
+
return (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(ModalContext.Provider, { value: mockModalContext, children: children }) }));
|
|
35
|
+
};
|
|
36
|
+
describe('RationaleModal', () => {
|
|
37
|
+
let user;
|
|
38
|
+
let defaultHits;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
user = userEvent.setup();
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
defaultHits = [
|
|
43
|
+
createMockHit({
|
|
44
|
+
howler: {
|
|
45
|
+
id: 'hit-1',
|
|
46
|
+
analytic: 'test-analytic-1'
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
];
|
|
50
|
+
// Reset mock functions
|
|
51
|
+
mockGetMatchingAnalytic.mockResolvedValue({
|
|
52
|
+
analytic_id: 'test-analytic-1',
|
|
53
|
+
triage_settings: {
|
|
54
|
+
rationales: ['Preset Rationale 1', 'Preset Rationale 2']
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
mockOnSubmit.mockClear();
|
|
58
|
+
mockModalContext.close.mockClear();
|
|
59
|
+
mockHpost.mockReset();
|
|
60
|
+
});
|
|
61
|
+
describe('Initial Rendering', () => {
|
|
62
|
+
it('should render modal title', () => {
|
|
63
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
64
|
+
expect(screen.getByText(i18n.t('modal.rationale.title'))).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
it('should render modal description', () => {
|
|
67
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
68
|
+
expect(screen.getByText(i18n.t('modal.rationale.description'))).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
it('should render autocomplete input field', () => {
|
|
71
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
72
|
+
expect(screen.getByLabelText(i18n.t('modal.rationale.label'))).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
it('should render cancel button', () => {
|
|
75
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
76
|
+
expect(screen.getByText(i18n.t('cancel'))).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
it('should render submit button', () => {
|
|
79
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
80
|
+
expect(screen.getByText(i18n.t('submit'))).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
it('should render with minimum width', () => {
|
|
83
|
+
const { container } = render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
84
|
+
const stack = container.querySelector('.MuiStack-root');
|
|
85
|
+
expect(stack).toHaveStyle({ minWidth: '500px' });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('Loading State', () => {
|
|
89
|
+
it('should show loading spinner in input field', async () => {
|
|
90
|
+
mockHpost.mockImplementationOnce(() => new Promise(() => { })); // Never resolves
|
|
91
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
92
|
+
expect(screen.getByLabelText(i18n.t('loading'))).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
it('should hide loading indicator after rationales are fetched', async () => {
|
|
96
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(mockHpost).toBeCalledTimes(2);
|
|
99
|
+
});
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('Suggested Rationales', () => {
|
|
106
|
+
it('should fetch preset rationales from analytics', async () => {
|
|
107
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
108
|
+
await waitFor(() => {
|
|
109
|
+
expect(mockGetMatchingAnalytic).toHaveBeenCalledWith(defaultHits[0]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
it('should fetch rationales used by other users for same analytic', async () => {
|
|
113
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(mockHpost).toHaveBeenCalledWith('/api/v1/search/facet/hit', {
|
|
116
|
+
fields: ['howler.rationale'],
|
|
117
|
+
query: 'howler.rationale:* AND howler.assignment:test-user AND timestamp:[now-14d TO now]',
|
|
118
|
+
rows: 25
|
|
119
|
+
});
|
|
120
|
+
expect(mockHpost).toHaveBeenCalledWith('/api/v1/search/facet/hit', {
|
|
121
|
+
fields: ['howler.rationale'],
|
|
122
|
+
filters: ['howler.analytic:"test\\-analytic\\-1")'],
|
|
123
|
+
query: 'howler.rationale:*',
|
|
124
|
+
rows: 10
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it('should fetch rationales from current user (last 14 days)', async () => {
|
|
129
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
const calls = mockHpost.mock.calls;
|
|
132
|
+
const userRationaleCall = calls.find(call => JSON.stringify(call).includes('test-user'));
|
|
133
|
+
expect(userRationaleCall).toBeTruthy();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
it('should display preset rationales in autocomplete options', async () => {
|
|
137
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
138
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
139
|
+
await user.click(input);
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(screen.getByText('Preset Rationale 1')).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
it('should display analytic rationales in autocomplete options', async () => {
|
|
145
|
+
mockHpost
|
|
146
|
+
.mockResolvedValueOnce({
|
|
147
|
+
'howler.rationale': { 'Analytic Rationale 1': 3 }
|
|
148
|
+
})
|
|
149
|
+
.mockResolvedValueOnce({ 'howler.rationale': {} });
|
|
150
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
151
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
152
|
+
await user.click(input);
|
|
153
|
+
await waitFor(() => {
|
|
154
|
+
expect(screen.getByText('Analytic Rationale 1')).toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
it('should display assignment rationales in autocomplete options', async () => {
|
|
158
|
+
mockHpost
|
|
159
|
+
.mockResolvedValueOnce({ 'howler.rationale': {} })
|
|
160
|
+
.mockResolvedValueOnce({ 'howler.rationale': { 'My Previous Rationale': 1 } });
|
|
161
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
162
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
163
|
+
await user.click(input);
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(screen.getByText('My Previous Rationale')).toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
it('should show rationale type for each option', async () => {
|
|
169
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
170
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
171
|
+
await user.click(input);
|
|
172
|
+
await waitFor(() => {
|
|
173
|
+
const typeLabel = screen.queryAllByText(i18n.t('modal.rationale.type.preset'));
|
|
174
|
+
expect(typeLabel.length).toBe(2);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
it('should handle empty rationale results', async () => {
|
|
178
|
+
mockHpost.mockResolvedValue({});
|
|
179
|
+
mockGetMatchingAnalytic.mockResolvedValue({ analytic_id: 'test', triage_settings: {} });
|
|
180
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
185
|
+
await user.click(input);
|
|
186
|
+
// Should not crash, no options displayed
|
|
187
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
it('should deduplicate rationales from multiple analytics', async () => {
|
|
190
|
+
const multipleHits = [
|
|
191
|
+
createMockHit({ howler: { id: 'hit-1', analytic: 'analytic-1' } }),
|
|
192
|
+
createMockHit({ howler: { id: 'hit-2', analytic: 'analytic-1' } })
|
|
193
|
+
];
|
|
194
|
+
mockGetMatchingAnalytic.mockResolvedValue({
|
|
195
|
+
analytic_id: 'analytic-1',
|
|
196
|
+
triage_settings: { rationales: ['Shared Rationale'] }
|
|
197
|
+
});
|
|
198
|
+
render(_jsx(RationaleModal, { hits: multipleHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
199
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
200
|
+
await user.click(input);
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
const options = screen.getAllByText('Shared Rationale');
|
|
203
|
+
// Should appear only once despite multiple hits
|
|
204
|
+
expect(options).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('User Input', () => {
|
|
209
|
+
it('should allow typing free text in autocomplete', async () => {
|
|
210
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
211
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
212
|
+
await user.type(input, 'Custom rationale text');
|
|
213
|
+
expect(input).toHaveValue('Custom rationale text');
|
|
214
|
+
});
|
|
215
|
+
it('should update rationale state when typing', async () => {
|
|
216
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
217
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
218
|
+
await user.type(input, 'Test rationale');
|
|
219
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
220
|
+
await user.click(submitButton);
|
|
221
|
+
expect(mockOnSubmit).toHaveBeenCalledWith('Test rationale');
|
|
222
|
+
});
|
|
223
|
+
it('should allow selecting a suggested rationale', async () => {
|
|
224
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
225
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
226
|
+
await user.click(input);
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(screen.getByText('Preset Rationale 1')).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
await user.click(screen.getByText('Preset Rationale 1'));
|
|
231
|
+
expect(input).toHaveValue('Preset Rationale 1');
|
|
232
|
+
});
|
|
233
|
+
it('should clear input when text is deleted', async () => {
|
|
234
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
235
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
236
|
+
await user.type(input, 'Test');
|
|
237
|
+
await user.clear(input);
|
|
238
|
+
expect(input).toHaveValue('');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
describe('Submit Functionality', () => {
|
|
242
|
+
it('should call onSubmit with rationale when submit button clicked', async () => {
|
|
243
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
244
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
245
|
+
await user.type(input, 'Test rationale');
|
|
246
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
247
|
+
await user.click(submitButton);
|
|
248
|
+
expect(mockOnSubmit).toHaveBeenCalledWith('Test rationale');
|
|
249
|
+
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
|
|
250
|
+
});
|
|
251
|
+
it('should close modal after submit', async () => {
|
|
252
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
253
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
254
|
+
await user.type(input, 'Test rationale');
|
|
255
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
256
|
+
await user.click(submitButton);
|
|
257
|
+
expect(mockModalContext.close).toHaveBeenCalledTimes(1);
|
|
258
|
+
});
|
|
259
|
+
it('should submit with empty rationale if no text entered', async () => {
|
|
260
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
261
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
262
|
+
await user.click(submitButton);
|
|
263
|
+
expect(mockOnSubmit).toHaveBeenCalledWith('');
|
|
264
|
+
});
|
|
265
|
+
it('should submit with selected rationale from suggestions', async () => {
|
|
266
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
267
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
268
|
+
await user.click(input);
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(screen.getByText('Preset Rationale 1')).toBeInTheDocument();
|
|
271
|
+
});
|
|
272
|
+
await user.click(screen.getByText('Preset Rationale 1'));
|
|
273
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
274
|
+
await user.click(submitButton);
|
|
275
|
+
expect(mockOnSubmit).toHaveBeenCalledWith('Preset Rationale 1');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
describe('Cancel Functionality', () => {
|
|
279
|
+
it('should close modal when cancel button clicked', async () => {
|
|
280
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
281
|
+
const cancelButton = screen.getByText(i18n.t('cancel'));
|
|
282
|
+
await user.click(cancelButton);
|
|
283
|
+
expect(mockModalContext.close).toHaveBeenCalledTimes(1);
|
|
284
|
+
});
|
|
285
|
+
it('should not call onSubmit when cancel button clicked', async () => {
|
|
286
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
287
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
288
|
+
await user.type(input, 'Test rationale');
|
|
289
|
+
const cancelButton = screen.getByText(i18n.t('cancel'));
|
|
290
|
+
await user.click(cancelButton);
|
|
291
|
+
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
describe('Keyboard Shortcuts', () => {
|
|
295
|
+
it('should submit when Ctrl+Enter is pressed', async () => {
|
|
296
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
297
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
298
|
+
await user.type(input, 'Test rationale');
|
|
299
|
+
await user.keyboard('{Control>}{Enter}{/Control}');
|
|
300
|
+
expect(mockOnSubmit).toHaveBeenCalledWith('Test rationale');
|
|
301
|
+
expect(mockModalContext.close).toHaveBeenCalledTimes(1);
|
|
302
|
+
});
|
|
303
|
+
it('should close modal when Escape is pressed', async () => {
|
|
304
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
305
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
306
|
+
await user.type(input, 'Test rationale');
|
|
307
|
+
await user.keyboard('{Escape}');
|
|
308
|
+
expect(mockModalContext.close).toHaveBeenCalledTimes(1);
|
|
309
|
+
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
it('should not submit when only Enter is pressed without Ctrl', async () => {
|
|
312
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
313
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
314
|
+
await user.type(input, 'Test rationale');
|
|
315
|
+
await user.keyboard('{Enter}');
|
|
316
|
+
// Enter alone in autocomplete shouldn't submit the form
|
|
317
|
+
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
it('should handle multiple keyboard shortcuts correctly', async () => {
|
|
320
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
321
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
322
|
+
// Press Escape first - should close
|
|
323
|
+
await user.type(input, 'First');
|
|
324
|
+
await user.keyboard('{Escape}');
|
|
325
|
+
expect(mockModalContext.close).toHaveBeenCalledTimes(1);
|
|
326
|
+
expect(mockOnSubmit).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
describe('Multiple Hits', () => {
|
|
330
|
+
it('should handle multiple hits with different analytics', async () => {
|
|
331
|
+
const multipleHits = [
|
|
332
|
+
createMockHit({ howler: { id: 'hit-1', analytic: 'analytic-1' } }),
|
|
333
|
+
createMockHit({ howler: { id: 'hit-2', analytic: 'analytic-2' } })
|
|
334
|
+
];
|
|
335
|
+
mockGetMatchingAnalytic
|
|
336
|
+
.mockResolvedValueOnce({
|
|
337
|
+
analytic_id: 'analytic-1',
|
|
338
|
+
triage_settings: { rationales: ['Rationale A'] }
|
|
339
|
+
})
|
|
340
|
+
.mockResolvedValueOnce({
|
|
341
|
+
analytic_id: 'analytic-2',
|
|
342
|
+
triage_settings: { rationales: ['Rationale B'] }
|
|
343
|
+
});
|
|
344
|
+
render(_jsx(RationaleModal, { hits: multipleHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
345
|
+
await waitFor(() => {
|
|
346
|
+
expect(mockGetMatchingAnalytic).toHaveBeenCalledTimes(2);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
it('should sanitize lucene queries in filters', async () => {
|
|
350
|
+
const hitWithSpecialChars = createMockHit({
|
|
351
|
+
howler: { id: 'hit-1', analytic: 'test:analytic+special' }
|
|
352
|
+
});
|
|
353
|
+
render(_jsx(RationaleModal, { hits: [hitWithSpecialChars], onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
354
|
+
await waitFor(() => {
|
|
355
|
+
expect(mockHpost).toHaveBeenCalled();
|
|
356
|
+
});
|
|
357
|
+
// Verify that sanitizeLuceneQuery was used (analytic should be in quotes)
|
|
358
|
+
const dispatchCalls = mockHpost.mock.calls;
|
|
359
|
+
const hasQuotedAnalytic = dispatchCalls.some(call => JSON.stringify(call).includes('howler.analytic:'));
|
|
360
|
+
expect(hasQuotedAnalytic).toBeTruthy();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
describe('Error Handling', () => {
|
|
364
|
+
it('should handle API errors gracefully', async () => {
|
|
365
|
+
mockHpost.mockRejectedValueOnce(new Error('API Error'));
|
|
366
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
367
|
+
await waitFor(() => {
|
|
368
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
369
|
+
});
|
|
370
|
+
// Component should still be functional
|
|
371
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
372
|
+
expect(input).toBeInTheDocument();
|
|
373
|
+
});
|
|
374
|
+
it('should handle missing analytic data', async () => {
|
|
375
|
+
mockGetMatchingAnalytic.mockResolvedValue(null);
|
|
376
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
377
|
+
await waitFor(() => {
|
|
378
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
379
|
+
});
|
|
380
|
+
// Should not crash
|
|
381
|
+
expect(screen.getByLabelText(i18n.t('modal.rationale.label'))).toBeInTheDocument();
|
|
382
|
+
});
|
|
383
|
+
it('should handle missing triage_settings in analytic', async () => {
|
|
384
|
+
mockGetMatchingAnalytic.mockResolvedValue({
|
|
385
|
+
analytic_id: 'test-analytic',
|
|
386
|
+
triage_settings: null
|
|
387
|
+
});
|
|
388
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
389
|
+
await waitFor(() => {
|
|
390
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
391
|
+
});
|
|
392
|
+
expect(screen.getByLabelText(i18n.t('modal.rationale.label'))).toBeInTheDocument();
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
describe('Edge Cases', () => {
|
|
396
|
+
it('should handle empty hits array', async () => {
|
|
397
|
+
render(_jsx(RationaleModal, { hits: [], onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
398
|
+
await waitFor(() => {
|
|
399
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
400
|
+
});
|
|
401
|
+
expect(screen.getByLabelText(i18n.t('modal.rationale.label'))).toBeInTheDocument();
|
|
402
|
+
});
|
|
403
|
+
it('should handle special characters in rationale', async () => {
|
|
404
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
405
|
+
const specialText = 'Test <>&"\'{}[]()';
|
|
406
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
407
|
+
await user.type(input, specialText.replace(/([{\[])/g, '$1$1'));
|
|
408
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
409
|
+
await user.click(submitButton);
|
|
410
|
+
expect(mockOnSubmit).toHaveBeenCalledWith(specialText);
|
|
411
|
+
});
|
|
412
|
+
it('should handle rapid successive submissions', async () => {
|
|
413
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
414
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
415
|
+
await user.type(input, 'Test');
|
|
416
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
417
|
+
await act(async () => {
|
|
418
|
+
await user.click(submitButton);
|
|
419
|
+
await user.click(submitButton);
|
|
420
|
+
await user.click(submitButton);
|
|
421
|
+
});
|
|
422
|
+
expect(mockOnSubmit).toHaveBeenCalledTimes(3);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
describe('Accessibility', () => {
|
|
426
|
+
it('should have accessible labels for all interactive elements', async () => {
|
|
427
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
428
|
+
expect(screen.getByLabelText(i18n.t('modal.rationale.label'))).toBeInTheDocument();
|
|
429
|
+
expect(screen.getByRole('button', { name: i18n.t('cancel') })).toBeInTheDocument();
|
|
430
|
+
expect(screen.getByRole('button', { name: i18n.t('submit') })).toBeInTheDocument();
|
|
431
|
+
});
|
|
432
|
+
it('should support keyboard navigation', async () => {
|
|
433
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
434
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
435
|
+
const cancelButton = screen.getByText(i18n.t('cancel'));
|
|
436
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
437
|
+
// Tab through elements
|
|
438
|
+
await user.tab();
|
|
439
|
+
expect(input).toHaveFocus();
|
|
440
|
+
await user.tab();
|
|
441
|
+
expect(cancelButton).toHaveFocus();
|
|
442
|
+
await user.tab();
|
|
443
|
+
expect(submitButton).toHaveFocus();
|
|
444
|
+
});
|
|
445
|
+
it('should have proper ARIA attributes on autocomplete', async () => {
|
|
446
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
447
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
448
|
+
expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
|
449
|
+
expect(input).toHaveAttribute('role', 'combobox');
|
|
450
|
+
});
|
|
451
|
+
it('should announce loading state to screen readers', async () => {
|
|
452
|
+
mockHpost.mockImplementation(() => new Promise(() => { }));
|
|
453
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
454
|
+
const progressbar = screen.getByRole('progressbar');
|
|
455
|
+
expect(progressbar).toBeInTheDocument();
|
|
456
|
+
});
|
|
457
|
+
it('should have semantic heading structure', () => {
|
|
458
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
459
|
+
const heading = screen.getByRole('heading', { level: 4 });
|
|
460
|
+
expect(heading).toHaveTextContent(i18n.t('modal.rationale.title'));
|
|
461
|
+
});
|
|
462
|
+
it('should provide accessible descriptions for rationale types', async () => {
|
|
463
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
464
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
465
|
+
await user.click(input);
|
|
466
|
+
await waitFor(() => {
|
|
467
|
+
const typeLabel = screen.queryAllByText(i18n.t('modal.rationale.type.preset'));
|
|
468
|
+
expect(typeLabel.length).toBe(2);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
it('should maintain focus when opening autocomplete', async () => {
|
|
472
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
473
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
474
|
+
await user.click(input);
|
|
475
|
+
expect(input).toHaveFocus();
|
|
476
|
+
});
|
|
477
|
+
it('should support screen reader navigation through options', async () => {
|
|
478
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
479
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
480
|
+
await user.click(input);
|
|
481
|
+
await waitFor(() => {
|
|
482
|
+
const listbox = screen.getByRole('listbox');
|
|
483
|
+
expect(listbox).toBeInTheDocument();
|
|
484
|
+
});
|
|
485
|
+
const options = screen.getAllByRole('option');
|
|
486
|
+
expect(options.length).toBeGreaterThan(0);
|
|
487
|
+
});
|
|
488
|
+
it('should announce selected option to screen readers', async () => {
|
|
489
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
490
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
491
|
+
await user.click(input);
|
|
492
|
+
await waitFor(() => {
|
|
493
|
+
expect(screen.getByText('Preset Rationale 1')).toBeInTheDocument();
|
|
494
|
+
});
|
|
495
|
+
await user.click(screen.getByText('Preset Rationale 1'));
|
|
496
|
+
expect(input).toHaveValue('Preset Rationale 1');
|
|
497
|
+
});
|
|
498
|
+
it('should have sufficient color contrast for buttons', () => {
|
|
499
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
500
|
+
const cancelButton = screen.getByRole('button', { name: i18n.t('cancel') });
|
|
501
|
+
const submitButton = screen.getByRole('button', { name: i18n.t('submit') });
|
|
502
|
+
expect(cancelButton).toBeVisible();
|
|
503
|
+
expect(submitButton).toBeVisible();
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
describe('Integration', () => {
|
|
507
|
+
it('should work with all context providers', async () => {
|
|
508
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
509
|
+
await waitFor(() => {
|
|
510
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
511
|
+
});
|
|
512
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
513
|
+
await user.type(input, 'Integration test');
|
|
514
|
+
const submitButton = screen.getByText(i18n.t('submit'));
|
|
515
|
+
await user.click(submitButton);
|
|
516
|
+
expect(mockOnSubmit).toHaveBeenCalledWith('Integration test');
|
|
517
|
+
expect(mockModalContext.close).toHaveBeenCalledTimes(1);
|
|
518
|
+
});
|
|
519
|
+
it('should combine rationales from all sources', async () => {
|
|
520
|
+
mockGetMatchingAnalytic.mockResolvedValue({
|
|
521
|
+
analytic_id: 'test',
|
|
522
|
+
triage_settings: { rationales: ['Preset 1'] }
|
|
523
|
+
});
|
|
524
|
+
mockHpost
|
|
525
|
+
.mockResolvedValueOnce({ 'howler.rationale': { 'Analytic 1': 2 } })
|
|
526
|
+
.mockResolvedValueOnce({ 'howler.rationale': { 'Assignment 1': 1 } });
|
|
527
|
+
render(_jsx(RationaleModal, { hits: defaultHits, onSubmit: mockOnSubmit }), { wrapper: Wrapper });
|
|
528
|
+
const input = screen.getByLabelText(i18n.t('modal.rationale.label'));
|
|
529
|
+
await user.click(input);
|
|
530
|
+
await waitFor(() => {
|
|
531
|
+
expect(mockHpost).toHaveBeenCalledTimes(2);
|
|
532
|
+
expect(screen.getByText('Preset 1')).toBeInTheDocument();
|
|
533
|
+
expect(screen.getByText('Analytic 1')).toBeInTheDocument();
|
|
534
|
+
expect(screen.getByText('Assignment 1')).toBeInTheDocument();
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
});
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { Check, Delete, Remove } from '@mui/icons-material';
|
|
3
|
+
import { Card, Chip, Divider, Grid, IconButton, InputAdornment, LinearProgress, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Tooltip, Typography } from '@mui/material';
|
|
3
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
5
|
import 'chartjs-adapter-dayjs-4';
|
|
5
6
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
7
|
import EditRow from '@cccsaurora/howler-ui/components/elements/EditRow';
|
|
7
8
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
|
-
import { capitalize, uniq } from 'lodash-es';
|
|
9
|
-
import { useCallback, useContext,
|
|
9
|
+
import { capitalize, uniq, without } from 'lodash-es';
|
|
10
|
+
import { useCallback, useContext, useState } from 'react';
|
|
10
11
|
import { useTranslation } from 'react-i18next';
|
|
11
12
|
const TriageSettings = ({ analytic, setAnalytic }) => {
|
|
12
13
|
const { dispatchApi } = useMyApi();
|
|
13
14
|
const { t } = useTranslation();
|
|
14
15
|
const { config } = useContext(ApiConfigContext);
|
|
15
16
|
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [rationale, setRationale] = useState('');
|
|
16
18
|
const updateAnalytic = useCallback(async (changes) => {
|
|
17
19
|
try {
|
|
18
20
|
setLoading(true);
|
|
@@ -26,24 +28,45 @@ const TriageSettings = ({ analytic, setAnalytic }) => {
|
|
|
26
28
|
setLoading(false);
|
|
27
29
|
}
|
|
28
30
|
}, [analytic?.analytic_id, dispatchApi, setAnalytic]);
|
|
29
|
-
const selectedAssessments =
|
|
31
|
+
const selectedAssessments = analytic?.triage_settings?.valid_assessments ?? config.lookups?.['howler.assessment'] ?? [];
|
|
32
|
+
const rationales = analytic?.triage_settings?.rationales ?? [];
|
|
30
33
|
return (_jsxs(Stack, { spacing: 2, pt: 1, children: [_jsx(Divider, { flexItem: true }), _jsx(TableContainer, { sx: {
|
|
31
34
|
'& table tr:last-child td': {
|
|
32
35
|
borderBottom: 0
|
|
33
36
|
}
|
|
34
37
|
}, component: Paper, children: _jsxs(Table, { children: [_jsx(TableHead, { children: _jsx(TableRow, { children: _jsx(TableCell, { colSpan: 3, children: _jsx(Typography, { variant: "h6", children: t('route.analytics.triage.title') }) }) }) }), _jsxs(TableBody, { children: [_jsx(EditRow, { titleKey: "route.analytics.triage.rationale", descriptionKey: "route.analytics.triage.rationale.description", value: analytic?.triage_settings?.skip_rationale ?? false, type: "checkbox", onEdit: async (value) => updateAnalytic({
|
|
35
38
|
triage_settings: { skip_rationale: JSON.parse(value) }
|
|
36
|
-
}) }), _jsx(TableRow, { children: _jsx(TableCell, { sx: { width: '100%', borderBottom: 0, paddingBottom: '0 !important', whiteSpace: 'nowrap' }, children: t('route.analytics.triage.assessments') }) }), _jsx(TableRow, { children: _jsx(TableCell, { colSpan: 3, sx: { borderBottom: 0, paddingTop: '0 !important' }, children: _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('route.analytics.triage.assessments.description') })
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
}) }), _jsx(TableRow, { children: _jsx(TableCell, { sx: { width: '100%', borderBottom: 0, paddingBottom: '0 !important', whiteSpace: 'nowrap' }, children: t('route.analytics.triage.assessments') }) }), _jsx(TableRow, { children: _jsx(TableCell, { colSpan: 3, sx: { borderBottom: 0, paddingTop: '0 !important' }, children: _jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('route.analytics.triage.assessments.description') }), _jsx(Grid, { container: true, spacing: 1, sx: theme => ({ marginLeft: `${theme.spacing(-1)} !important`, marginTop: `0 !important` }), children: config.lookups?.['howler.assessment']?.map(assessment => {
|
|
40
|
+
const checked = selectedAssessments.includes(assessment) ?? true;
|
|
41
|
+
return (_jsx(Grid, { item: true, children: _jsx(Chip, { variant: "outlined", icon: checked ? (_jsx(Check, { fontSize: "small", color: "success" })) : (_jsx(Remove, { fontSize: "small", color: "error" })), color: checked ? 'success' : 'error', label: _jsx(Tooltip, { title: t(`hit.details.asessments.${assessment}.description`), children: _jsx("span", { children: assessment.split('-').map(capitalize).join(' ') }) }), onClick: () => updateAnalytic({
|
|
42
|
+
triage_settings: {
|
|
43
|
+
valid_assessments: checked
|
|
44
|
+
? without(selectedAssessments, assessment)
|
|
45
|
+
: uniq([...selectedAssessments, assessment])
|
|
46
|
+
}
|
|
47
|
+
}), disabled: loading }) }, assessment));
|
|
48
|
+
}) })] }) }) }), _jsx(TableRow, { children: _jsx(TableCell, { sx: { width: '100%', borderBottom: 0, paddingBottom: '0 !important', whiteSpace: 'nowrap' }, children: t('route.analytics.triage.rationales') }) }), _jsx(TableRow, { children: _jsx(TableCell, { colSpan: 3, sx: { borderBottom: 0, paddingTop: '0 !important' }, children: _jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('route.analytics.triage.rationales.description') }), rationales.map(_rationale => (_jsx(Card, { variant: "outlined", children: _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", px: 1, children: [_jsx(Typography, { flex: 1, children: _rationale }), _jsx(IconButton, { onClick: () => updateAnalytic({
|
|
49
|
+
triage_settings: {
|
|
50
|
+
rationales: without(rationales, _rationale)
|
|
51
|
+
}
|
|
52
|
+
}), children: _jsx(Delete, {}) })] }) }, _rationale))), _jsx(Card, { variant: "outlined", sx: { p: 1 }, children: _jsx(TextField, { label: t('route.analytics.rationales.new'), value: rationale, onChange: e => setRationale(e.target.value), onKeyDown: e => {
|
|
53
|
+
if (e.key === 'Enter' && !!rationale) {
|
|
54
|
+
updateAnalytic({
|
|
55
|
+
triage_settings: {
|
|
56
|
+
rationales: uniq([...rationales, rationale])
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
setRationale('');
|
|
60
|
+
}
|
|
61
|
+
}, fullWidth: true, size: "small", InputProps: {
|
|
62
|
+
endAdornment: (_jsx(InputAdornment, { position: "end", children: _jsx(IconButton, { size: "small", disabled: !rationale, onClick: () => {
|
|
63
|
+
updateAnalytic({
|
|
64
|
+
triage_settings: {
|
|
65
|
+
rationales: uniq([...rationales, rationale])
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
setRationale('');
|
|
69
|
+
}, children: _jsx(Check, { fontSize: "small" }) }) }))
|
|
70
|
+
} }) })] }) }) }), _jsx(TableRow, { children: _jsx(TableCell, { sx: { padding: '0 !important' }, children: _jsx(LinearProgress, { sx: { opacity: loading ? 1 : 0 } }) }) })] })] }) })] }));
|
|
48
71
|
};
|
|
49
72
|
export default TriageSettings;
|
|
@@ -493,6 +493,9 @@
|
|
|
493
493
|
"route.analytics.triage.rationale.description": "When triaging alerts from this analytic, the rationale is not prompted, and is instead auto-generated.",
|
|
494
494
|
"route.analytics.triage.assessments": "Permitted Assessment Outcomes",
|
|
495
495
|
"route.analytics.triage.assessments.description": "When triaging alerts from this analytic, what assessments are valid?",
|
|
496
|
+
"route.analytics.triage.rationales": "Preset Rationales",
|
|
497
|
+
"route.analytics.triage.rationales.description": "Define preset rationales that will be available when triaging alerts from this analytic.",
|
|
498
|
+
"route.analytics.rationales.new": "New Rationale",
|
|
496
499
|
"route.actions": "Actions",
|
|
497
500
|
"route.actions.operation.add": "Add New Operation",
|
|
498
501
|
"route.actions.alert.error": "There are {{count}} hits in your query for which this operation may fail.",
|
|
@@ -496,6 +496,9 @@
|
|
|
496
496
|
"route.analytics.triage.rationale.description": "Lors du triage des alertes provenant de cette analyse, la justification n'est pas demandée et est générée automatiquement.",
|
|
497
497
|
"route.analytics.triage.assessments": "Résultats d'évaluation autorisés",
|
|
498
498
|
"route.analytics.triage.assessments.description": "Lors du triage des alertes provenant de cette analyse, quelles sont les évaluations valides?",
|
|
499
|
+
"route.analytics.triage.rationales": "Justifications prédéfinies",
|
|
500
|
+
"route.analytics.triage.rationales.description": "Définir les justifications prédéfinies qui seront disponibles lors du triage des alertes de cette analyse.",
|
|
501
|
+
"route.analytics.rationales.new": "Nouvelle justification",
|
|
499
502
|
"route.actions": "Actions",
|
|
500
503
|
"route.actions.operation.add": "Ajouter une nouvelle opération",
|
|
501
504
|
"route.actions.alert.error": "Il y a {{count}} hits dans votre requête pour lesquels cette opération peut échouer.",
|
package/package.json
CHANGED
package/tests/server-handlers.js
CHANGED
|
@@ -80,6 +80,19 @@ const handlers = [
|
|
|
80
80
|
span: 'date.range.1.week'
|
|
81
81
|
}
|
|
82
82
|
]
|
|
83
|
-
}))
|
|
83
|
+
})),
|
|
84
|
+
http.post('/api/v1/search/facet/hit', async ({ request }) => {
|
|
85
|
+
const payload = await request.json();
|
|
86
|
+
let facetResponse = Object.fromEntries(payload.fields.map(field => [field, { 'facet 1': 1, 'facet 2': 2 }]));
|
|
87
|
+
if (payload.filters?.[0]?.includes('analytic')) {
|
|
88
|
+
facetResponse = Object.fromEntries(payload.fields.map(field => [field, { 'Analytic 1': 1, 'Analytic 2': 2 }]));
|
|
89
|
+
}
|
|
90
|
+
else if (payload.query.includes('test-user')) {
|
|
91
|
+
facetResponse = Object.fromEntries(payload.fields.map(field => [field, { 'Assignment 1': 1, 'Assignment 2': 2 }]));
|
|
92
|
+
}
|
|
93
|
+
return HttpResponse.json({
|
|
94
|
+
api_response: facetResponse
|
|
95
|
+
});
|
|
96
|
+
})
|
|
84
97
|
];
|
|
85
98
|
export { handlers };
|