@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.
@@ -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 uniqBy from 'lodash-es/uniqBy';
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
- 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]);
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
- // 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);
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, queries]);
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,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 { Checkbox, Divider, Grid, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip, Typography } from '@mui/material';
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, useMemo, useState } from 'react';
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 = useMemo(() => analytic?.triage_settings?.valid_assessments ?? config.lookups?.['howler.assessment'] ?? [], [analytic?.triage_settings?.valid_assessments, config.lookups]);
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') }) }) }), _jsx(TableRow, { children: _jsx(TableCell, { colSpan: 3, children: _jsx(Grid, { container: true, spacing: 1, children: config.lookups?.['howler.assessment']?.map(assessment => (_jsx(Grid, { item: true, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, sx: theme => ({
37
- border: 'thin solid',
38
- borderColor: 'divider',
39
- p: 1,
40
- borderRadius: theme.shape.borderRadius
41
- }), children: [_jsx(Tooltip, { title: t(`hit.details.asessments.${assessment}.description`), children: _jsx(Typography, { component: "span", children: assessment.split('-').map(capitalize).join(' ') }) }), _jsx(Checkbox, { checked: selectedAssessments.includes(assessment) ?? true, onChange: (_event, checked) => updateAnalytic({
42
- triage_settings: {
43
- valid_assessments: !checked
44
- ? selectedAssessments.filter(_assessment => assessment !== _assessment)
45
- : uniq([...selectedAssessments, assessment])
46
- }
47
- }), disabled: loading })] }) }, assessment))) }) }) })] })] }) })] }));
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.",
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export interface TriageSettings {
5
5
  dossiers?: string[];
6
+ rationales?: string[];
6
7
  skip_rationale?: boolean;
7
8
  valid_assessments?: string[];
8
9
  }
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.16.0-dev.381",
99
+ "version": "2.16.0-dev.383",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",
@@ -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 };