@cccsaurora/howler-ui 3.1.0-dev.1708 → 3.1.0-dev.1712
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/index.d.ts +3 -3
- package/api/index.js +7 -7
- package/api/index.test.js +6 -6
- package/api/search/user.d.ts +1 -1
- package/api/v2/index.d.ts +2 -1
- package/api/v2/index.js +2 -1
- package/api/v2/index.test.js +3 -1
- package/api/v2/search/index.d.ts +1 -1
- package/api/v2/search/index.js +2 -2
- package/api/v2/search/index.test.js +2 -2
- package/api/v2/task/index.d.ts +20 -0
- package/api/v2/task/index.js +12 -0
- package/api/v2/task/index.test.d.ts +1 -0
- package/api/v2/task/index.test.js +26 -0
- package/components/app/providers/FieldProvider.js +22 -8
- package/components/app/providers/FieldProvider.test.js +20 -2
- package/components/app/providers/RecordSearchProvider.test.js +22 -17
- package/components/hooks/useMyApi.js +3 -0
- package/components/hooks/useMyUserFunctions.d.ts +5 -5
- package/components/routes/hits/search/shared/HitFilter.js +19 -2
- package/components/routes/hits/search/shared/HitFilter.test.js +87 -20
- package/components/routes/home/AddNewCard.js +15 -2
- package/components/routes/home/CasePanel.d.ts +16 -0
- package/components/routes/home/CasePanel.js +105 -0
- package/components/routes/home/CasePanel.test.d.ts +1 -0
- package/components/routes/home/CasePanel.test.js +86 -0
- package/components/routes/home/TasksPanel.d.ts +13 -0
- package/components/routes/home/TasksPanel.js +129 -0
- package/components/routes/home/TasksPanel.test.d.ts +1 -0
- package/components/routes/home/TasksPanel.test.js +173 -0
- package/components/routes/home/ViewCard.d.ts +2 -1
- package/components/routes/home/ViewCard.js +60 -48
- package/components/routes/home/ViewCard.test.js +29 -3
- package/components/routes/home/ViewRefresh.d.ts +14 -9
- package/components/routes/home/ViewRefresh.js +63 -20
- package/components/routes/home/ViewRefresh.test.js +69 -22
- package/components/routes/home/index.js +39 -10
- package/components/routes/home/index.test.js +9 -1
- package/components/routes/views/ViewComposer.test.js +1 -0
- package/locales/en/translation.json +16 -0
- package/locales/fr/translation.json +16 -0
- package/models/entities/HowlerUser.d.ts +1 -1
- package/package.json +3 -1
- package/rest/AxiosClient.d.ts +1 -1
- package/rest/AxiosClient.js +3 -2
- package/rest/AxiosClient.test.js +6 -0
- package/rest/FetchClient.d.ts +1 -1
- package/rest/FetchClient.js +3 -2
- package/rest/FetchClient.test.js +6 -0
- package/rest/index.d.ts +1 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/// <reference types="vitest" />
|
|
3
3
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { useState } from 'react';
|
|
4
5
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
6
|
const apiConfigContextToken = vi.hoisted(() => ({ name: 'api-config-context' }));
|
|
7
|
+
const fieldContextToken = vi.hoisted(() => ({ name: 'field-context' }));
|
|
6
8
|
const parameterContextToken = vi.hoisted(() => ({ name: 'parameter-context' }));
|
|
7
9
|
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
8
10
|
const mockSetSavedFilter = vi.hoisted(() => vi.fn());
|
|
9
11
|
const mockRemoveSavedFilter = vi.hoisted(() => vi.fn());
|
|
12
|
+
const autocompleteProps = vi.hoisted(() => []);
|
|
10
13
|
const mockAutocompleteSelection = vi.hoisted(() => ({
|
|
11
14
|
value: 'normal'
|
|
12
15
|
}));
|
|
@@ -19,25 +22,32 @@ vi.mock('@mui/material', async (importOriginal) => {
|
|
|
19
22
|
const actual = await importOriginal();
|
|
20
23
|
return {
|
|
21
24
|
...actual,
|
|
22
|
-
Autocomplete: (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
25
|
+
Autocomplete: (props) => {
|
|
26
|
+
autocompleteProps.push(props);
|
|
27
|
+
const { onChange, options, disabled, multiple, value, loading } = props;
|
|
28
|
+
return (_jsx("button", { disabled: disabled, "data-loading": Boolean(loading), "data-options": JSON.stringify(options), onClick: () => onChange(null, mockAutocompleteSelection.value === 'clear'
|
|
29
|
+
? multiple
|
|
30
|
+
? []
|
|
31
|
+
: null
|
|
32
|
+
: multiple
|
|
33
|
+
? mockAutocompleteSelection.value === 'unchanged'
|
|
34
|
+
? value
|
|
35
|
+
: mockAutocompleteSelection.value === 'add'
|
|
36
|
+
? [...(value ?? []), 'added value']
|
|
37
|
+
: mockAutocompleteSelection.value === 'single'
|
|
38
|
+
? (value ?? []).slice(0, 1)
|
|
39
|
+
: (value?.length ?? 0) > 1
|
|
40
|
+
? [value[0], value[0]?.startsWith('C:') ? 'new\\path' : 'changed two']
|
|
41
|
+
: [value?.[0] ?? options[0] ?? 'changed one']
|
|
42
|
+
: value === '*'
|
|
43
|
+
? '*'
|
|
44
|
+
: options.includes('event.provider')
|
|
45
|
+
? 'event.provider'
|
|
46
|
+
: options[0]), children: "change-filter" }));
|
|
47
|
+
},
|
|
48
|
+
Stack: ({ children }) => _jsx("div", { children: children }),
|
|
49
|
+
TextField: ({ label }) => _jsx("div", { children: label }),
|
|
50
|
+
Typography: ({ children }) => _jsx("div", { children: children })
|
|
41
51
|
};
|
|
42
52
|
});
|
|
43
53
|
vi.mock('api', () => ({
|
|
@@ -54,9 +64,21 @@ vi.mock('api', () => ({
|
|
|
54
64
|
vi.mock('components/app/providers/ApiConfigProvider', () => ({
|
|
55
65
|
ApiConfigContext: apiConfigContextToken
|
|
56
66
|
}));
|
|
67
|
+
vi.mock('components/app/providers/FieldProvider', () => ({
|
|
68
|
+
FieldContext: fieldContextToken
|
|
69
|
+
}));
|
|
57
70
|
vi.mock('components/app/providers/ParameterProvider', () => ({
|
|
58
71
|
ParameterContext: parameterContextToken
|
|
59
72
|
}));
|
|
73
|
+
vi.mock('components/elements/display/ChipPopper', () => ({
|
|
74
|
+
default: ({ children, icon, label, onDelete }) => {
|
|
75
|
+
const [open, setOpen] = useState(false);
|
|
76
|
+
return (_jsxs("div", { children: [_jsxs("button", { className: "MuiChip-root", onClick: () => setOpen(current => !current), children: [icon && _jsx("span", { className: "MuiChip-icon", children: icon }), label, onDelete && (_jsx("span", { className: "MuiChip-deleteIcon", onClick: event => {
|
|
77
|
+
event.stopPropagation();
|
|
78
|
+
onDelete(event);
|
|
79
|
+
}, children: "delete-filter" }))] }), open && children] }));
|
|
80
|
+
}
|
|
81
|
+
}));
|
|
60
82
|
vi.mock('components/hooks/useMyApi', () => ({
|
|
61
83
|
default: () => ({ dispatchApi: mockDispatchApi })
|
|
62
84
|
}));
|
|
@@ -73,6 +95,19 @@ vi.mock('react', async (importOriginal) => {
|
|
|
73
95
|
useContext: (context) => {
|
|
74
96
|
if (context === apiConfigContextToken)
|
|
75
97
|
return { config: configValue };
|
|
98
|
+
if (context === fieldContextToken) {
|
|
99
|
+
return {
|
|
100
|
+
hitFields: [
|
|
101
|
+
{ key: 'howler.assessment' },
|
|
102
|
+
{ key: 'howler.escalation' },
|
|
103
|
+
{ key: 'howler.analytic' },
|
|
104
|
+
{ key: 'howler.detection' },
|
|
105
|
+
{ key: 'event.provider' },
|
|
106
|
+
{ key: 'organization.name' }
|
|
107
|
+
],
|
|
108
|
+
getHitFields: vi.fn()
|
|
109
|
+
};
|
|
110
|
+
}
|
|
76
111
|
return actual.useContext(context);
|
|
77
112
|
}
|
|
78
113
|
};
|
|
@@ -84,6 +119,7 @@ describe('HitFilter', () => {
|
|
|
84
119
|
mockDispatchApi.mockReset().mockImplementation(async (_value) => ({ 'event.provider': { azure: 2 } }));
|
|
85
120
|
mockSetSavedFilter.mockReset();
|
|
86
121
|
mockRemoveSavedFilter.mockReset();
|
|
122
|
+
autocompleteProps.length = 0;
|
|
87
123
|
mockAutocompleteSelection.value = 'normal';
|
|
88
124
|
});
|
|
89
125
|
it('initializes from value, fetches custom lookups, updates values, and removes filters', async () => {
|
|
@@ -121,6 +157,37 @@ describe('HitFilter', () => {
|
|
|
121
157
|
render(_jsx(HitFilter, { id: 2, value: 'howler.assessment:*' }));
|
|
122
158
|
await waitFor(() => expect(mockSetSavedFilter).toHaveBeenCalledWith(2, 'howler.assessment:*'));
|
|
123
159
|
});
|
|
160
|
+
it('shows only default fields until the user searches for another indexed field', () => {
|
|
161
|
+
render(_jsx(HitFilter, { id: 3, value: "howler.assessment:*" }));
|
|
162
|
+
fireEvent.click(screen.getByRole('button', { name: /howler.assessment:\*/ }));
|
|
163
|
+
const getFieldAutocomplete = () => [...autocompleteProps].reverse().find(props => props.options.includes('organization.name'));
|
|
164
|
+
const autocompleteState = {
|
|
165
|
+
inputValue: 'howler.assessment',
|
|
166
|
+
getOptionLabel: (option) => option
|
|
167
|
+
};
|
|
168
|
+
let fieldAutocomplete = getFieldAutocomplete();
|
|
169
|
+
expect(fieldAutocomplete.filterOptions(fieldAutocomplete.options, autocompleteState)).toEqual([
|
|
170
|
+
'howler.assessment',
|
|
171
|
+
'howler.escalation',
|
|
172
|
+
'howler.analytic',
|
|
173
|
+
'howler.detection',
|
|
174
|
+
'event.provider'
|
|
175
|
+
]);
|
|
176
|
+
act(() => fieldAutocomplete.onInputChange(null, 'organization', 'input'));
|
|
177
|
+
fieldAutocomplete = getFieldAutocomplete();
|
|
178
|
+
expect(fieldAutocomplete.filterOptions(fieldAutocomplete.options, autocompleteState)).toEqual([
|
|
179
|
+
'organization.name'
|
|
180
|
+
]);
|
|
181
|
+
act(() => fieldAutocomplete.onInputChange(null, 'howler.assessment', 'reset'));
|
|
182
|
+
fieldAutocomplete = getFieldAutocomplete();
|
|
183
|
+
expect(fieldAutocomplete.filterOptions(fieldAutocomplete.options, autocompleteState)).toEqual([
|
|
184
|
+
'howler.assessment',
|
|
185
|
+
'howler.escalation',
|
|
186
|
+
'howler.analytic',
|
|
187
|
+
'howler.detection',
|
|
188
|
+
'event.provider'
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
124
191
|
it('deduplicates decoded literals while preserving quoted OR text', () => {
|
|
125
192
|
const clause = String.raw `event.provider:("a" OR "\a" OR "text OR more" OR "escaped\"quote")`;
|
|
126
193
|
render(_jsx(HitFilter, { id: 15, value: clause }));
|
|
@@ -7,10 +7,13 @@ import { ViewContext } from '@cccsaurora/howler-ui/components/app/providers/View
|
|
|
7
7
|
import CustomButton from '@cccsaurora/howler-ui/components/elements/addons/buttons/CustomButton';
|
|
8
8
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
9
9
|
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import { v4 as uuid } from 'uuid';
|
|
10
11
|
import { useContextSelector } from 'use-context-selector';
|
|
11
12
|
const TYPES = {
|
|
12
13
|
view: ['viewId', 'limit'],
|
|
13
|
-
analytic: ['analyticId', 'type']
|
|
14
|
+
analytic: ['analyticId', 'type'],
|
|
15
|
+
case: [],
|
|
16
|
+
tasks: []
|
|
14
17
|
};
|
|
15
18
|
const VISUALIZATIONS = ['assessment', 'created', 'escalation', 'status', 'detection'];
|
|
16
19
|
const AddNewCard = ({ dashboard, addCard }) => {
|
|
@@ -29,7 +32,11 @@ const AddNewCard = ({ dashboard, addCard }) => {
|
|
|
29
32
|
return;
|
|
30
33
|
}
|
|
31
34
|
addCard({
|
|
32
|
-
entry_id: selectedType === 'view'
|
|
35
|
+
entry_id: selectedType === 'view'
|
|
36
|
+
? config.viewId
|
|
37
|
+
: selectedType === 'analytic'
|
|
38
|
+
? `${config.analyticId}-${config.type}`
|
|
39
|
+
: `${selectedType}-${uuid()}`,
|
|
33
40
|
type: selectedType,
|
|
34
41
|
config: JSON.stringify(config)
|
|
35
42
|
});
|
|
@@ -81,6 +88,12 @@ const AddNewCard = ({ dashboard, addCard }) => {
|
|
|
81
88
|
limit: 3
|
|
82
89
|
});
|
|
83
90
|
}
|
|
91
|
+
else if (selectedType === 'case') {
|
|
92
|
+
_setConfig({ dateRange: 'date.range.all', statusFilter: [], assigneeFilter: [] });
|
|
93
|
+
}
|
|
94
|
+
else if (selectedType === 'tasks') {
|
|
95
|
+
_setConfig({ taskFilter: 'incomplete' });
|
|
96
|
+
}
|
|
84
97
|
else {
|
|
85
98
|
_setConfig({});
|
|
86
99
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DateRangeOption } from '@cccsaurora/howler-ui/components/routes/cases/search/CaseDateFilter';
|
|
2
|
+
import { type FC } from 'react';
|
|
3
|
+
export interface CaseSettings {
|
|
4
|
+
statusFilter?: string[];
|
|
5
|
+
assigneeFilter?: string[];
|
|
6
|
+
dateRange?: DateRangeOption;
|
|
7
|
+
customStart?: string;
|
|
8
|
+
customEnd?: string;
|
|
9
|
+
}
|
|
10
|
+
interface CasePanelProps extends CaseSettings {
|
|
11
|
+
panelId?: string;
|
|
12
|
+
refreshTick?: symbol;
|
|
13
|
+
onRefreshComplete?: (panelId: string, refreshTick: symbol) => void;
|
|
14
|
+
}
|
|
15
|
+
declare const CasePanel: FC<CasePanelProps>;
|
|
16
|
+
export default CasePanel;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { OpenInNew } from '@mui/icons-material';
|
|
3
|
+
import { Alert, Card, CardContent, CircularProgress, IconButton, Stack, Tooltip, Typography } from '@mui/material';
|
|
4
|
+
import { AppListEmpty } from '@tui/core';
|
|
5
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
6
|
+
import CaseCard from '@cccsaurora/howler-ui/components/elements/case/CaseCard';
|
|
7
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
|
+
import CaseAssigneeFilter from '@cccsaurora/howler-ui/components/routes/cases/search/CaseAssigneeFilter';
|
|
9
|
+
import CaseDateFilter, {} from '@cccsaurora/howler-ui/components/routes/cases/search/CaseDateFilter';
|
|
10
|
+
import CaseStatusFilter from '@cccsaurora/howler-ui/components/routes/cases/search/CaseStatusFilter';
|
|
11
|
+
import dayjs, {} from 'dayjs';
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
13
|
+
import { useTranslation } from 'react-i18next';
|
|
14
|
+
import { Link } from 'react-router';
|
|
15
|
+
import { DATE_RANGE_LUCENE } from '@cccsaurora/howler-ui/utils/constants';
|
|
16
|
+
import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
17
|
+
const CASE_LIMIT = 10;
|
|
18
|
+
const getDate = (value, fallback) => {
|
|
19
|
+
const date = value ? dayjs(value) : fallback;
|
|
20
|
+
return date.isValid() ? date : fallback;
|
|
21
|
+
};
|
|
22
|
+
const CasePanel = ({ statusFilter: initialStatusFilter = [], assigneeFilter: initialAssigneeFilter = [], dateRange: initialDateRange = 'date.range.all', customStart: initialCustomStart, customEnd: initialCustomEnd, panelId = 'case-panel', refreshTick, onRefreshComplete }) => {
|
|
23
|
+
const { t } = useTranslation();
|
|
24
|
+
const { dispatchApi } = useMyApi();
|
|
25
|
+
const [cases, setCases] = useState([]);
|
|
26
|
+
const [loading, setLoading] = useState(false);
|
|
27
|
+
const [error, setError] = useState(false);
|
|
28
|
+
const [statusFilter, setStatusFilter] = useState(initialStatusFilter);
|
|
29
|
+
const [assigneeFilter, setAssigneeFilter] = useState(initialAssigneeFilter);
|
|
30
|
+
const [dateRange, setDateRange] = useState(initialDateRange);
|
|
31
|
+
const [customStart, setCustomStart] = useState(() => getDate(initialCustomStart, dayjs().subtract(2, 'days')));
|
|
32
|
+
const [customEnd, setCustomEnd] = useState(() => getDate(initialCustomEnd, dayjs()));
|
|
33
|
+
const activeRefreshTick = useRef(undefined);
|
|
34
|
+
const filters = useMemo(() => {
|
|
35
|
+
const nextFilters = [];
|
|
36
|
+
if (statusFilter.length > 0) {
|
|
37
|
+
nextFilters.push(`status:(${statusFilter.map(status => `"${status}"`).join(' OR ')})`);
|
|
38
|
+
}
|
|
39
|
+
if (assigneeFilter.length > 0) {
|
|
40
|
+
nextFilters.push(assigneeFilter
|
|
41
|
+
.map(assignee => `(participants:"${sanitizeLuceneQuery(assignee)}" OR tasks.assignment:"${sanitizeLuceneQuery(assignee)}")`)
|
|
42
|
+
.join(' OR '));
|
|
43
|
+
}
|
|
44
|
+
const luceneDate = DATE_RANGE_LUCENE[dateRange];
|
|
45
|
+
if (luceneDate) {
|
|
46
|
+
nextFilters.push(`created:[${luceneDate} TO now]`);
|
|
47
|
+
}
|
|
48
|
+
else if (dateRange === 'date.range.custom') {
|
|
49
|
+
nextFilters.push(`created:[${customStart.toISOString()} TO ${customEnd.toISOString()}]`);
|
|
50
|
+
}
|
|
51
|
+
return nextFilters;
|
|
52
|
+
}, [assigneeFilter, customEnd, customStart, dateRange, statusFilter]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
let refreshComplete = false;
|
|
56
|
+
const completeRefresh = () => {
|
|
57
|
+
if (refreshTick && !refreshComplete) {
|
|
58
|
+
refreshComplete = true;
|
|
59
|
+
if (activeRefreshTick.current === refreshTick) {
|
|
60
|
+
activeRefreshTick.current = undefined;
|
|
61
|
+
}
|
|
62
|
+
onRefreshComplete?.(panelId, refreshTick);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
if (refreshTick) {
|
|
66
|
+
activeRefreshTick.current = refreshTick;
|
|
67
|
+
}
|
|
68
|
+
setLoading(true);
|
|
69
|
+
setError(false);
|
|
70
|
+
void dispatchApi(api.v2.search.post('case', {
|
|
71
|
+
query: 'case_id:*',
|
|
72
|
+
filters,
|
|
73
|
+
rows: CASE_LIMIT,
|
|
74
|
+
sort: 'created desc'
|
|
75
|
+
}, controller.signal), { showError: false })
|
|
76
|
+
.then(response => {
|
|
77
|
+
if (!controller.signal.aborted) {
|
|
78
|
+
setCases(response?.items ?? []);
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.catch(() => {
|
|
82
|
+
if (!controller.signal.aborted) {
|
|
83
|
+
setError(true);
|
|
84
|
+
setCases([]);
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
.finally(() => {
|
|
88
|
+
if (!controller.signal.aborted) {
|
|
89
|
+
setLoading(false);
|
|
90
|
+
completeRefresh();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return () => {
|
|
94
|
+
controller.abort();
|
|
95
|
+
};
|
|
96
|
+
}, [dispatchApi, filters, onRefreshComplete, panelId, refreshTick]);
|
|
97
|
+
useEffect(() => () => {
|
|
98
|
+
if (activeRefreshTick.current) {
|
|
99
|
+
onRefreshComplete?.(panelId, activeRefreshTick.current);
|
|
100
|
+
activeRefreshTick.current = undefined;
|
|
101
|
+
}
|
|
102
|
+
}, [onRefreshComplete, panelId]);
|
|
103
|
+
return (_jsx(Card, { variant: "outlined", sx: { height: '100%' }, children: _jsx(CardContent, { sx: { height: '100%' }, children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h6", children: t('route.home.case.title') }), _jsx(Tooltip, { title: t('route.home.case.open'), children: _jsx(IconButton, { component: Link, to: "/cases", children: _jsx(OpenInNew, { fontSize: "small" }) }) })] }), _jsxs(Stack, { direction: "row", spacing: 1, useFlexGap: true, sx: { flexWrap: 'wrap' }, children: [_jsx(CaseStatusFilter, { statusFilter: statusFilter, onChange: setStatusFilter }), _jsx(CaseAssigneeFilter, { assigneeFilter: assigneeFilter, onChange: setAssigneeFilter }), _jsx(CaseDateFilter, { dateRange: dateRange, onChange: setDateRange, customStart: customStart, customEnd: customEnd, onCustomStartChange: setCustomStart, onCustomEndChange: setCustomEnd })] }), error ? (_jsx(Alert, { severity: "error", children: t('route.home.case.error') })) : loading ? (_jsx(Stack, { alignItems: "center", py: 3, children: _jsx(CircularProgress, { size: 28 }) })) : cases.length > 0 ? (cases.map(_case => (_jsx(Link, { to: `/cases/${_case.case_id}`, style: { color: 'inherit', textDecoration: 'none' }, children: _jsx(CaseCard, { case: _case }) }, _case.case_id)))) : (_jsx(AppListEmpty, {}))] }) }) }));
|
|
104
|
+
};
|
|
105
|
+
export default CasePanel;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
4
|
+
import { I18nextProvider } from 'react-i18next';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import CasePanel from './CasePanel';
|
|
7
|
+
const mockSearchPost = vi.hoisted(() => vi.fn());
|
|
8
|
+
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
9
|
+
vi.mock('api', () => ({
|
|
10
|
+
default: {
|
|
11
|
+
v2: {
|
|
12
|
+
search: {
|
|
13
|
+
post: (...args) => mockSearchPost(...args)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
19
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('components/routes/cases/search/CaseStatusFilter', () => ({
|
|
22
|
+
default: () => _jsx("div", { children: "status-filter" })
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('components/routes/cases/search/CaseAssigneeFilter', () => ({
|
|
25
|
+
default: () => _jsx("div", { children: "assignee-filter" })
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('components/routes/cases/search/CaseDateFilter', () => ({
|
|
28
|
+
default: () => _jsx("div", { children: "date-filter" })
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('components/elements/case/CaseCard', () => ({
|
|
31
|
+
default: ({ case: _case }) => _jsx("div", { children: _case.title })
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('react-router', async () => {
|
|
34
|
+
const actual = await vi.importActual('react-router');
|
|
35
|
+
return {
|
|
36
|
+
...actual,
|
|
37
|
+
Link: ({ children, to, ...props }) => (_jsx("a", { href: to, ...props, children: children }))
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
const Wrapper = ({ children }) => _jsx(I18nextProvider, { i18n: i18n, children: children });
|
|
41
|
+
describe('CasePanel', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
mockSearchPost.mockReturnValue('case-request');
|
|
45
|
+
mockDispatchApi.mockResolvedValue({
|
|
46
|
+
items: [{ case_id: 'case-1', title: 'Case One', __index: 'case' }]
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it('loads cases with the configured filters', async () => {
|
|
50
|
+
render(_jsx(CasePanel, { statusFilter: ['open'], assigneeFilter: ['alice'], dateRange: "date.range.1.day" }), {
|
|
51
|
+
wrapper: Wrapper
|
|
52
|
+
});
|
|
53
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
54
|
+
expect(mockSearchPost).toHaveBeenCalledWith('case', {
|
|
55
|
+
query: 'case_id:*',
|
|
56
|
+
filters: ['status:("open")', '(participants:"alice" OR tasks.assignment:"alice")', 'created:[now-1d/d TO now]'],
|
|
57
|
+
rows: 10,
|
|
58
|
+
sort: 'created desc'
|
|
59
|
+
}, expect.any(AbortSignal));
|
|
60
|
+
expect(await screen.findByText('Case One')).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
it('renders all three case filters', async () => {
|
|
63
|
+
render(_jsx(CasePanel, {}), { wrapper: Wrapper });
|
|
64
|
+
expect(screen.getByText('status-filter')).toBeInTheDocument();
|
|
65
|
+
expect(screen.getByText('assignee-filter')).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText('date-filter')).toBeInTheDocument();
|
|
67
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
68
|
+
});
|
|
69
|
+
it('reports a pending refresh when the panel unmounts', () => {
|
|
70
|
+
const refreshTick = Symbol('refresh');
|
|
71
|
+
const onRefreshComplete = vi.fn();
|
|
72
|
+
mockDispatchApi.mockReturnValue(new Promise(() => { }));
|
|
73
|
+
const { unmount } = render(_jsx(CasePanel, { panelId: "case-panel-1", refreshTick: refreshTick, onRefreshComplete: onRefreshComplete }), { wrapper: Wrapper });
|
|
74
|
+
unmount();
|
|
75
|
+
expect(onRefreshComplete).toHaveBeenCalledWith('case-panel-1', refreshTick);
|
|
76
|
+
expect(mockSearchPost.mock.calls[0][2].aborted).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('aborts an earlier refresh when a newer refresh begins', () => {
|
|
79
|
+
mockDispatchApi.mockReturnValue(new Promise(() => { }));
|
|
80
|
+
const { rerender } = render(_jsx(CasePanel, { refreshTick: Symbol('first') }), { wrapper: Wrapper });
|
|
81
|
+
const firstSignal = mockSearchPost.mock.calls[0][2];
|
|
82
|
+
rerender(_jsx(CasePanel, { refreshTick: Symbol('second') }));
|
|
83
|
+
expect(firstSignal.aborted).toBe(true);
|
|
84
|
+
expect(mockSearchPost).toHaveBeenCalledTimes(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TaskSearchFilter } from '@cccsaurora/howler-ui/api/v2/task';
|
|
2
|
+
import { type FC } from 'react';
|
|
3
|
+
type TaskFilter = TaskSearchFilter;
|
|
4
|
+
export interface TasksSettings {
|
|
5
|
+
taskFilter?: TaskFilter;
|
|
6
|
+
}
|
|
7
|
+
interface TasksPanelProps extends TasksSettings {
|
|
8
|
+
panelId?: string;
|
|
9
|
+
refreshTick?: symbol;
|
|
10
|
+
onRefreshComplete?: (panelId: string, refreshTick: symbol) => void;
|
|
11
|
+
}
|
|
12
|
+
declare const TasksPanel: FC<TasksPanelProps>;
|
|
13
|
+
export default TasksPanel;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { HourglassBottom, UpdateOutlined } from '@mui/icons-material';
|
|
3
|
+
import { Alert, Button, Card, CardContent, Chip, CircularProgress, FormControl, InputLabel, MenuItem, Select, Stack, Tooltip, Typography } from '@mui/material';
|
|
4
|
+
import { AppListEmpty, useAppUser } from '@tui/core';
|
|
5
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
6
|
+
import StatusIcon from '@cccsaurora/howler-ui/components/elements/case/StatusIcon';
|
|
7
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
|
+
import CaseTask from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseTask';
|
|
9
|
+
import dayjs from 'dayjs';
|
|
10
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { useTranslation } from 'react-i18next';
|
|
12
|
+
import { Link } from 'react-router';
|
|
13
|
+
import { twitterShort } from '@cccsaurora/howler-ui/utils/utils';
|
|
14
|
+
const TASKS_PER_PAGE = 25;
|
|
15
|
+
const API_FILTER = {
|
|
16
|
+
all: 'all',
|
|
17
|
+
complete: 'complete',
|
|
18
|
+
incomplete: 'incomplete'
|
|
19
|
+
};
|
|
20
|
+
const groupTasksByCase = (items) => {
|
|
21
|
+
const groups = [];
|
|
22
|
+
const groupsByCaseId = new Map();
|
|
23
|
+
items.forEach(({ task, case: _case }, index) => {
|
|
24
|
+
const caseId = _case.case_id ?? `missing-case-id-${index}`;
|
|
25
|
+
let group = groupsByCaseId.get(caseId);
|
|
26
|
+
if (!group) {
|
|
27
|
+
group = { case: _case, tasks: [] };
|
|
28
|
+
groupsByCaseId.set(caseId, group);
|
|
29
|
+
groups.push(group);
|
|
30
|
+
}
|
|
31
|
+
group.tasks.push(task);
|
|
32
|
+
});
|
|
33
|
+
return groups;
|
|
34
|
+
};
|
|
35
|
+
const TasksPanel = ({ taskFilter: initialTaskFilter = 'incomplete', panelId = 'tasks-panel', refreshTick, onRefreshComplete }) => {
|
|
36
|
+
const { t } = useTranslation();
|
|
37
|
+
const { user } = useAppUser();
|
|
38
|
+
const username = user?.username;
|
|
39
|
+
const { dispatchApi } = useMyApi();
|
|
40
|
+
const filterId = useId();
|
|
41
|
+
const [items, setItems] = useState([]);
|
|
42
|
+
const [taskFilter, setTaskFilter] = useState(initialTaskFilter);
|
|
43
|
+
const [offset, setOffset] = useState(0);
|
|
44
|
+
const [hasMore, setHasMore] = useState(false);
|
|
45
|
+
const [loading, setLoading] = useState(false);
|
|
46
|
+
const [error, setError] = useState(false);
|
|
47
|
+
const activeRefreshTick = useRef(undefined);
|
|
48
|
+
const lastReceivedRefreshTick = useRef(undefined);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
let refreshComplete = false;
|
|
52
|
+
let retryingPreviousPage = false;
|
|
53
|
+
if (refreshTick && lastReceivedRefreshTick.current !== refreshTick) {
|
|
54
|
+
activeRefreshTick.current = refreshTick;
|
|
55
|
+
lastReceivedRefreshTick.current = refreshTick;
|
|
56
|
+
}
|
|
57
|
+
const completeRefresh = () => {
|
|
58
|
+
if (refreshTick && !refreshComplete && activeRefreshTick.current === refreshTick) {
|
|
59
|
+
refreshComplete = true;
|
|
60
|
+
activeRefreshTick.current = undefined;
|
|
61
|
+
onRefreshComplete?.(panelId, refreshTick);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (!username) {
|
|
65
|
+
completeRefresh();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
setLoading(true);
|
|
69
|
+
setError(false);
|
|
70
|
+
const loadTasks = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const response = await dispatchApi(api.v2.task.search({
|
|
73
|
+
offset,
|
|
74
|
+
rows: TASKS_PER_PAGE,
|
|
75
|
+
filter: API_FILTER[taskFilter]
|
|
76
|
+
}, controller.signal), { showError: false });
|
|
77
|
+
if (controller.signal.aborted) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (offset > 0 && (response?.items.length ?? 0) === 0) {
|
|
81
|
+
retryingPreviousPage = true;
|
|
82
|
+
setOffset(currentOffset => Math.max(0, currentOffset - TASKS_PER_PAGE));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setItems(response?.items ?? []);
|
|
86
|
+
setHasMore(response?.has_more ?? false);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
if (!controller.signal.aborted) {
|
|
90
|
+
setError(true);
|
|
91
|
+
setItems([]);
|
|
92
|
+
setHasMore(false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (!controller.signal.aborted && !retryingPreviousPage) {
|
|
97
|
+
setLoading(false);
|
|
98
|
+
completeRefresh();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
void loadTasks();
|
|
103
|
+
return () => {
|
|
104
|
+
controller.abort();
|
|
105
|
+
};
|
|
106
|
+
}, [dispatchApi, offset, onRefreshComplete, panelId, refreshTick, taskFilter, username]);
|
|
107
|
+
useEffect(() => () => {
|
|
108
|
+
if (activeRefreshTick.current) {
|
|
109
|
+
const pendingRefreshTick = activeRefreshTick.current;
|
|
110
|
+
activeRefreshTick.current = undefined;
|
|
111
|
+
onRefreshComplete?.(panelId, pendingRefreshTick);
|
|
112
|
+
}
|
|
113
|
+
}, [onRefreshComplete, panelId]);
|
|
114
|
+
const groupedTasks = useMemo(() => (username ? groupTasksByCase(items) : []), [items, username]);
|
|
115
|
+
const isLoading = Boolean(username) && loading;
|
|
116
|
+
return (_jsx(Card, { variant: "outlined", sx: { height: '100%' }, children: _jsx(CardContent, { sx: { height: '100%' }, children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h6", children: t('route.home.tasks.title') }), _jsx("div", { style: { flex: 1 } }), _jsxs(FormControl, { size: "small", sx: { minWidth: 150 }, children: [_jsx(InputLabel, { id: filterId, children: t('route.home.tasks.filter') }), _jsxs(Select, { labelId: filterId, value: taskFilter, label: t('route.home.tasks.filter'), onChange: event => {
|
|
117
|
+
setTaskFilter(event.target.value);
|
|
118
|
+
setOffset(0);
|
|
119
|
+
}, children: [_jsx(MenuItem, { value: "incomplete", children: t('route.home.tasks.filter.incomplete') }), _jsx(MenuItem, { value: "complete", children: t('route.home.tasks.filter.complete') }), _jsx(MenuItem, { value: "all", children: t('route.home.tasks.filter.all') })] })] })] }), !username ? (_jsx(AppListEmpty, {})) : error ? (_jsx(Alert, { severity: "error", children: t('route.home.tasks.error') })) : isLoading ? (_jsx(Stack, { alignItems: "center", py: 3, children: _jsx(CircularProgress, { size: 28 }) })) : groupedTasks.length > 0 ? (groupedTasks.map(({ case: _case, tasks }) => {
|
|
120
|
+
const status = _case.status ?? 'open';
|
|
121
|
+
return (_jsxs(Stack, { spacing: 0.5, children: [_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Typography, { component: Link, to: `/cases/${_case.case_id}`, color: "text.primary", sx: {
|
|
122
|
+
alignItems: 'center',
|
|
123
|
+
display: 'flex',
|
|
124
|
+
gap: 0.5,
|
|
125
|
+
textDecoration: 'none'
|
|
126
|
+
}, children: [_case.title ?? _case.case_id, _jsx(StatusIcon, { status: status })] }), _jsx("div", { style: { flex: 1 } }), _case.start && _case.end && (_jsx(Tooltip, { title: dayjs(_case.updated).toString(), children: _jsx(Chip, { icon: _jsx(HourglassBottom, { fontSize: "small" }), size: "small", label: twitterShort(_case.start) + ' - ' + twitterShort(_case.end) }) })), _jsx(Tooltip, { title: dayjs(_case.updated).toString(), children: _jsx(Chip, { icon: _jsx(UpdateOutlined, { fontSize: "small" }), size: "small", label: twitterShort(_case.updated) }) })] }), tasks.map(task => (_jsx(CaseTask, { case: _case, task: task, readOnly: true }, `${_case.case_id}-${task.id ?? task.summary}`)))] }, _case.case_id));
|
|
127
|
+
})) : (_jsx(AppListEmpty, {})), _jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Button, { disabled: !username || isLoading || offset === 0, onClick: () => setOffset(currentOffset => Math.max(0, currentOffset - TASKS_PER_PAGE)), children: t('route.home.tasks.previous') }), _jsx(Button, { disabled: !username || isLoading || !hasMore, onClick: () => setOffset(currentOffset => currentOffset + TASKS_PER_PAGE), children: t('route.home.tasks.next') })] })] }) }) }));
|
|
128
|
+
};
|
|
129
|
+
export default TasksPanel;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|