@cccsaurora/howler-ui 3.1.0-dev.1708 → 3.1.0-dev.1716

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/api/index.d.ts +3 -3
  2. package/api/index.js +7 -7
  3. package/api/index.test.js +6 -6
  4. package/api/search/user.d.ts +1 -1
  5. package/api/v2/index.d.ts +2 -1
  6. package/api/v2/index.js +2 -1
  7. package/api/v2/index.test.js +3 -1
  8. package/api/v2/search/index.d.ts +1 -1
  9. package/api/v2/search/index.js +2 -2
  10. package/api/v2/search/index.test.js +2 -2
  11. package/api/v2/task/index.d.ts +20 -0
  12. package/api/v2/task/index.js +12 -0
  13. package/api/v2/task/index.test.d.ts +1 -0
  14. package/api/v2/task/index.test.js +26 -0
  15. package/components/app/providers/FieldProvider.js +22 -8
  16. package/components/app/providers/FieldProvider.test.js +20 -2
  17. package/components/app/providers/RecordSearchProvider.test.js +22 -17
  18. package/components/elements/hit/HitSummary.js +7 -6
  19. package/components/elements/hit/HitSummary.test.js +19 -5
  20. package/components/hooks/useMyApi.js +3 -0
  21. package/components/hooks/useMyUserFunctions.d.ts +5 -5
  22. package/components/routes/hits/search/shared/HitFilter.js +19 -2
  23. package/components/routes/hits/search/shared/HitFilter.test.js +87 -20
  24. package/components/routes/home/AddNewCard.js +15 -2
  25. package/components/routes/home/CasePanel.d.ts +16 -0
  26. package/components/routes/home/CasePanel.js +105 -0
  27. package/components/routes/home/CasePanel.test.d.ts +1 -0
  28. package/components/routes/home/CasePanel.test.js +86 -0
  29. package/components/routes/home/TasksPanel.d.ts +13 -0
  30. package/components/routes/home/TasksPanel.js +129 -0
  31. package/components/routes/home/TasksPanel.test.d.ts +1 -0
  32. package/components/routes/home/TasksPanel.test.js +173 -0
  33. package/components/routes/home/ViewCard.d.ts +2 -1
  34. package/components/routes/home/ViewCard.js +60 -48
  35. package/components/routes/home/ViewCard.test.js +29 -3
  36. package/components/routes/home/ViewRefresh.d.ts +14 -9
  37. package/components/routes/home/ViewRefresh.js +63 -20
  38. package/components/routes/home/ViewRefresh.test.js +69 -22
  39. package/components/routes/home/index.js +39 -10
  40. package/components/routes/home/index.test.js +9 -1
  41. package/components/routes/views/ViewComposer.test.js +1 -0
  42. package/locales/en/translation.json +16 -0
  43. package/locales/fr/translation.json +16 -0
  44. package/models/entities/HowlerUser.d.ts +1 -1
  45. package/package.json +3 -1
  46. package/rest/AxiosClient.d.ts +1 -1
  47. package/rest/AxiosClient.js +3 -2
  48. package/rest/AxiosClient.test.js +6 -0
  49. package/rest/FetchClient.d.ts +1 -1
  50. package/rest/FetchClient.js +3 -2
  51. package/rest/FetchClient.test.js +6 -0
  52. package/rest/index.d.ts +1 -1
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AddCircleOutline, FilterList, RemoveCircleOutline } from '@mui/icons-material';
3
- import { Autocomplete, Checkbox, FormControlLabel, Stack, TextField, Tooltip, Typography } from '@mui/material';
3
+ import { Autocomplete, Checkbox, createFilterOptions, FormControlLabel, Stack, TextField, Tooltip, Typography } from '@mui/material';
4
4
  import api from '@cccsaurora/howler-ui/api';
5
5
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
6
+ import { FieldContext } from '@cccsaurora/howler-ui/components/app/providers/FieldProvider';
6
7
  import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
7
8
  import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
8
9
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
@@ -19,6 +20,13 @@ const ACCEPTED_LOOKUPS = [
19
20
  'event.provider',
20
21
  'organization.name'
21
22
  ];
23
+ const DEFAULT_FILTER_FIELDS = [
24
+ 'howler.assessment',
25
+ 'howler.escalation',
26
+ 'howler.analytic',
27
+ 'howler.detection',
28
+ 'event.provider'
29
+ ];
22
30
  const parseFilter = (value) => {
23
31
  const negated = value.startsWith('-');
24
32
  const positiveValue = negated ? value.slice(1) : value;
@@ -72,9 +80,11 @@ const serializeFilter = (category, values, negated) => {
72
80
  }
73
81
  return filter.join('');
74
82
  };
83
+ const filterFields = createFilterOptions();
75
84
  const HitFilter = ({ size, id, value }) => {
76
85
  const { t } = useTranslation();
77
86
  const { config } = useContext(ApiConfigContext);
87
+ const { hitFields, getHitFields } = useContext(FieldContext);
78
88
  const { dispatchApi } = useMyApi();
79
89
  const setSavedFilter = useContextSelector(ParameterContext, ctx => ctx.setFilter);
80
90
  const removeSavedFilter = useContextSelector(ParameterContext, ctx => ctx.removeFilter);
@@ -89,10 +99,15 @@ const HitFilter = ({ size, id, value }) => {
89
99
  category: null,
90
100
  options: []
91
101
  });
102
+ const [categorySearchInput, setCategorySearchInput] = useState('');
92
103
  const { category, values: filterValues, editable, negated } = parsedFilter;
93
104
  const configuredLookup = category ? config.lookups?.[category] : undefined;
94
105
  const needsLookups = !!category && editable && !Array.isArray(configuredLookup);
95
106
  const loading = needsLookups && customLookups.category !== category;
107
+ const categoryOptions = hitFields.map(field => field.key).filter((field) => !!field);
108
+ useEffect(() => {
109
+ void getHitFields();
110
+ }, [getHitFields]);
96
111
  useEffect(() => {
97
112
  if (value) {
98
113
  setSavedFilter(id, value);
@@ -142,6 +157,8 @@ const HitFilter = ({ size, id, value }) => {
142
157
  }, [commitFilter, parsedFilter.raw]);
143
158
  const lookupOptions = configuredLookup ?? (customLookups.category === category ? customLookups.options : []);
144
159
  const chipLabel = `${parsedFilter.category ? `${parsedFilter.category}:` : ''}${parsedFilter.rawClause}`;
145
- return (_jsx(Stack, { direction: "row", alignItems: "center", spacing: 0.5, children: _jsx(ChipPopper, { icon: _jsxs(Stack, { direction: "row", spacing: 0.25, alignItems: "center", children: [_jsx(FilterList, { fontSize: "small" }), parsedFilter.negated ? (_jsx(Tooltip, { title: t('hit.search.filter.excluded'), children: _jsx(RemoveCircleOutline, { fontSize: "small", color: "error" }) })) : (_jsx(Tooltip, { title: t('hit.search.filter.included'), children: _jsx(AddCircleOutline, { fontSize: "small", color: "success" }) }))] }), label: _jsx(Typography, { variant: "body2", children: chipLabel }), minWidth: "250px", onDelete: () => removeSavedFilter(value), slotProps: { chip: { size: 'small', color: parsedFilter.wildcard ? 'warning' : 'default' } }, children: _jsxs(Stack, { spacing: 1, sx: { minWidth: '225px' }, children: [_jsx(FormControlLabel, { label: t('hit.search.filter.exclude'), control: _jsx(Checkbox, { size: "small", checked: negated, onChange: toggleNegation }) }), _jsx(Autocomplete, { fullWidth: true, disableClearable: true, disabled: !editable, size: size ?? 'small', value: category ?? ACCEPTED_LOOKUPS[0], options: ACCEPTED_LOOKUPS, renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.fields') }), onChange: onCategoryChange }), _jsx(Autocomplete, { fullWidth: true, freeSolo: true, multiple: true, disabled: !category || !editable, loading: loading, size: size ?? 'small', value: filterValues, options: lookupOptions, renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.values') }), getOptionLabel: option => t(option), onChange: onValuesChange })] }) }) }));
160
+ return (_jsx(Stack, { direction: "row", alignItems: "center", spacing: 0.5, children: _jsx(ChipPopper, { icon: _jsxs(Stack, { direction: "row", spacing: 0.25, alignItems: "center", children: [_jsx(FilterList, { fontSize: "small" }), parsedFilter.negated ? (_jsx(Tooltip, { title: t('hit.search.filter.excluded'), children: _jsx(RemoveCircleOutline, { fontSize: "small", color: "error" }) })) : (_jsx(Tooltip, { title: t('hit.search.filter.included'), children: _jsx(AddCircleOutline, { fontSize: "small", color: "success" }) }))] }), label: _jsx(Typography, { variant: "body2", children: chipLabel }), minWidth: "250px", onDelete: () => removeSavedFilter(value), slotProps: { chip: { size: 'small', color: parsedFilter.wildcard ? 'warning' : 'default' } }, children: _jsxs(Stack, { spacing: 1, sx: { minWidth: '225px' }, children: [_jsx(FormControlLabel, { label: t('hit.search.filter.exclude'), control: _jsx(Checkbox, { size: "small", checked: negated, onChange: toggleNegation }) }), _jsx(Autocomplete, { fullWidth: true, disableClearable: true, disabled: !editable, size: size ?? 'small', value: category ?? ACCEPTED_LOOKUPS[0], options: categoryOptions, filterOptions: (options, state) => categorySearchInput
161
+ ? filterFields(options, { ...state, inputValue: categorySearchInput })
162
+ : options.filter(option => DEFAULT_FILTER_FIELDS.includes(option)), renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.fields') }), onChange: onCategoryChange, onInputChange: (_event, inputValue, reason) => setCategorySearchInput(reason === 'input' ? inputValue : '') }), _jsx(Autocomplete, { fullWidth: true, freeSolo: true, multiple: true, disabled: !category || !editable, loading: loading, size: size ?? 'small', value: filterValues, options: lookupOptions, renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.values') }), getOptionLabel: option => t(option), onChange: onValuesChange })] }) }) }));
146
163
  };
147
164
  export default memo(HitFilter);
@@ -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: ({ onChange, options, disabled, multiple, value, loading }) => (_jsx("button", { disabled: disabled, "data-loading": Boolean(loading), "data-options": JSON.stringify(options), onClick: () => onChange(null, mockAutocompleteSelection.value === 'clear'
23
- ? multiple
24
- ? []
25
- : null
26
- : multiple
27
- ? mockAutocompleteSelection.value === 'unchanged'
28
- ? value
29
- : mockAutocompleteSelection.value === 'add'
30
- ? [...(value ?? []), 'added value']
31
- : mockAutocompleteSelection.value === 'single'
32
- ? (value ?? []).slice(0, 1)
33
- : (value?.length ?? 0) > 1
34
- ? [value[0], value[0]?.startsWith('C:') ? 'new\\path' : 'changed two']
35
- : [value?.[0] ?? options[0] ?? 'changed one']
36
- : value === '*'
37
- ? '*'
38
- : options.includes('event.provider')
39
- ? 'event.provider'
40
- : options[0]), children: "change-filter" }))
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' ? config.viewId : `${config.analyticId}-${config.type}`,
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 {};