@cccsaurora/howler-ui 2.18.0-dev.762 → 2.18.0-dev.766

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.
@@ -1,17 +1,30 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Topic } from '@mui/icons-material';
3
- import { Typography } from '@mui/material';
3
+ import { Stack, Typography } from '@mui/material';
4
4
  import api from '@cccsaurora/howler-ui/api';
5
5
  import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
6
6
  import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
7
7
  import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
8
8
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
9
9
  import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
10
- import { useCallback, useContext, useEffect, useState } from 'react';
10
+ import dayjs from 'dayjs';
11
+ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
11
12
  import { useTranslation } from 'react-i18next';
12
13
  import { useNavigate, useSearchParams } from 'react-router-dom';
13
- import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
14
+ import { DATE_RANGE_LUCENE, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
15
+ import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
14
16
  import CaseCard from '../../elements/case/CaseCard';
17
+ import CaseAssigneeFilter from './search/CaseAssigneeFilter';
18
+ import CaseDateFilter, {} from './search/CaseDateFilter';
19
+ import CaseStatusFilter from './search/CaseStatusFilter';
20
+ const buildPhraseQuery = (phrase) => {
21
+ const sanitized = sanitizeLuceneQuery(phrase);
22
+ if (!phrase) {
23
+ return '(title:* OR summary:* OR overview:* OR participants:* OR tasks.summary:* OR tasks.assignment:*)';
24
+ }
25
+ return (`(title:*${sanitized}* OR summary:*${sanitized}* OR overview:*${sanitized}* OR participants:*${sanitized}* ` +
26
+ `OR tasks.summary:*${sanitized}* OR tasks.assignment:*${sanitized}*)`);
27
+ };
15
28
  const CasesBase = () => {
16
29
  const { t } = useTranslation();
17
30
  const navigate = useNavigate();
@@ -24,6 +37,31 @@ const CasesBase = () => {
24
37
  const [response, setResponse] = useState(null);
25
38
  const [hasError, setHasError] = useState(false);
26
39
  const [loading, setLoading] = useState(false);
40
+ const [statusFilter, setStatusFilter] = useState([]);
41
+ const [assigneeFilter, setAssigneeFilter] = useState([]);
42
+ const [dateRange, setDateRange] = useState('date.range.all');
43
+ const [customStart, setCustomStart] = useState(dayjs().subtract(2, 'days'));
44
+ const [customEnd, setCustomEnd] = useState(dayjs());
45
+ const filtersReady = useRef(false);
46
+ const buildFilters = useCallback(() => {
47
+ const filters = [];
48
+ if (statusFilter.length > 0) {
49
+ filters.push(`status:(${statusFilter.map(status => `"${status}"`).join(' OR ')})`);
50
+ }
51
+ if (assigneeFilter.length > 0) {
52
+ filters.push(assigneeFilter
53
+ .map(assignee => `(participants:"${sanitizeLuceneQuery(assignee)}" OR tasks.assignment:"${sanitizeLuceneQuery(assignee)}")`)
54
+ .join(' OR '));
55
+ }
56
+ const lucene = DATE_RANGE_LUCENE[dateRange];
57
+ if (lucene) {
58
+ filters.push(`created:[${lucene} TO now]`);
59
+ }
60
+ else if (dateRange === 'date.range.custom') {
61
+ filters.push(`created:[${customStart.toISOString()} TO ${customEnd.toISOString()}]`);
62
+ }
63
+ return filters;
64
+ }, [statusFilter, assigneeFilter, dateRange, customStart, customEnd]);
27
65
  const onSearch = useCallback(async () => {
28
66
  try {
29
67
  setLoading(true);
@@ -35,11 +73,10 @@ const CasesBase = () => {
35
73
  searchParams.delete('phrase');
36
74
  }
37
75
  setSearchParams(searchParams, { replace: true });
38
- // Check for the actual search query
39
- const query = phrase ? `*:*${phrase}*` : '*:*';
40
- // Ensure the overview should be visible and/or matches the type we are filtering for
76
+ const filters = buildFilters();
41
77
  setResponse(await dispatchApi(api.search.case.post({
42
- query,
78
+ query: buildPhraseQuery(phrase),
79
+ filters,
43
80
  rows: pageCount,
44
81
  offset
45
82
  })));
@@ -50,9 +87,8 @@ const CasesBase = () => {
50
87
  finally {
51
88
  setLoading(false);
52
89
  }
53
- }, [phrase, setSearchParams, searchParams, dispatchApi, pageCount, offset]);
90
+ }, [buildFilters, phrase, setSearchParams, searchParams, dispatchApi, pageCount, offset]);
54
91
  // Load the items into list when response changes.
55
- // This hook should only trigger when the 'response' changes.
56
92
  useEffect(() => {
57
93
  if (response) {
58
94
  load(response.items.map((item) => ({
@@ -92,8 +128,19 @@ const CasesBase = () => {
92
128
  }
93
129
  // eslint-disable-next-line react-hooks/exhaustive-deps
94
130
  }, [offset]);
131
+ // Re-search when filter chips change, but skip the initial render.
132
+ useEffect(() => {
133
+ if (!filtersReady.current) {
134
+ filtersReady.current = true;
135
+ return;
136
+ }
137
+ if (!loading) {
138
+ onSearch();
139
+ }
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ }, [statusFilter, assigneeFilter, dateRange, customStart, customEnd]);
95
142
  const renderer = useCallback((item, className) => _jsx(CaseCard, { case: item, className: className }), []);
96
- return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: loading, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.cases.search.prompt') }), renderer: ({ item }, classRenderer) => renderer(item.item, classRenderer()), response: response, onSelect: (item) => navigate(`/cases/${item.id}`), onCreate: () => navigate('/cases/create'), createPrompt: "route.cases.create", searchPrompt: "route.cases.manager.search", createIcon: _jsx(Topic, { sx: { mr: 1 } }) }));
143
+ return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: loading, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.cases.search.prompt') }), searchFilters: _jsxs(Stack, { direction: "row", spacing: 1, useFlexGap: true, sx: { mt: 0.5, 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 })] }), renderer: ({ item }, classRenderer) => renderer(item.item, classRenderer()), response: response, onSelect: (item) => navigate(`/cases/${item.id}`), onCreate: () => navigate('/cases/create'), createPrompt: "route.cases.create", searchPrompt: "route.cases.manager.search", createIcon: _jsx(Topic, { sx: { mr: 1 } }) }));
97
144
  };
98
145
  const Cases = () => {
99
146
  return (_jsx(TuiListProvider, { children: _jsx(CasesBase, {}) }));
@@ -3,3 +3,4 @@ export declare const ESCALATION_COLOR_MAP: {
3
3
  focus: string;
4
4
  crisis: string;
5
5
  };
6
+ export declare const CASE_STATUSES: readonly ["open", "in-progress", "on-hold", "resolved"];
@@ -3,3 +3,4 @@ export const ESCALATION_COLOR_MAP = {
3
3
  focus: 'warning',
4
4
  crisis: 'error'
5
5
  };
6
+ export const CASE_STATUSES = ['open', 'in-progress', 'on-hold', 'resolved'];
@@ -0,0 +1,6 @@
1
+ import { type FC } from 'react';
2
+ declare const CaseAssigneeFilter: FC<{
3
+ assigneeFilter: string[];
4
+ onChange: (v: string[]) => void;
5
+ }>;
6
+ export default CaseAssigneeFilter;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Person } from '@mui/icons-material';
3
+ import { AvatarGroup, Checkbox, Divider, FormControlLabel, Stack, Typography } from '@mui/material';
4
+ import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
5
+ import { UserListContext } from '@cccsaurora/howler-ui/components/app/providers/UserListProvider';
6
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
7
+ import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
8
+ import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
9
+ import { useCallback, useContext, useEffect } from 'react';
10
+ import { useTranslation } from 'react-i18next';
11
+ const CaseAssigneeFilter = ({ assigneeFilter, onChange }) => {
12
+ const { t } = useTranslation();
13
+ const { user: currentUser } = useAppUser();
14
+ const { users, searchUsers } = useContext(UserListContext);
15
+ useEffect(() => {
16
+ searchUsers('uname:*');
17
+ // eslint-disable-next-line react-hooks/exhaustive-deps
18
+ }, []);
19
+ const toggleMyself = useCallback((_, checked) => {
20
+ if (!currentUser) {
21
+ return;
22
+ }
23
+ onChange(checked
24
+ ? [...assigneeFilter.filter(a => a !== currentUser.username), currentUser.username]
25
+ : assigneeFilter.filter(a => a !== currentUser.username));
26
+ }, [currentUser, assigneeFilter, onChange]);
27
+ return (_jsx(ChipPopper, { icon: assigneeFilter.length > 0 ? (_jsx(AvatarGroup, { sx: { '& .MuiAvatar-root': { height: 18, width: 18, fontSize: '0.6rem' } }, children: assigneeFilter.map(u => (_jsx(HowlerAvatar, { userId: u, sx: { height: 18, width: 18 } }, u))) })) : (_jsx(Person, { fontSize: "small" })), label: _jsx(Typography, { variant: "body2", children: assigneeFilter.length === 0
28
+ ? t('route.cases.filter.assignee')
29
+ : assigneeFilter.length === 1
30
+ ? (users[assigneeFilter[0]]?.name ?? assigneeFilter[0])
31
+ : `${assigneeFilter.length} ${t('route.cases.filter.assignees')}` }), minWidth: "260px", slotProps: { chip: { size: 'small', color: assigneeFilter.length > 0 ? 'primary' : 'default' } }, children: _jsxs(Stack, { direction: "row", divider: _jsx(Divider, { orientation: "vertical", flexItem: true }), spacing: 1, children: [_jsx(UserList, { userIds: assigneeFilter, onChange: onChange, i18nLabel: "route.cases.filter.assignee", multiple: true }), _jsx(FormControlLabel, { control: _jsx(Checkbox, { size: "small", checked: currentUser ? assigneeFilter.includes(currentUser.username) : false, onChange: toggleMyself }), label: t('route.cases.filter.myself') })] }) }));
32
+ };
33
+ export default CaseAssigneeFilter;
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent, {} from '@testing-library/user-event';
5
+ import { UserListContext } from '@cccsaurora/howler-ui/components/app/providers/UserListProvider';
6
+ import i18n from '@cccsaurora/howler-ui/i18n';
7
+ import { I18nextProvider } from 'react-i18next';
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import CaseAssigneeFilter from './CaseAssigneeFilter';
10
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
11
+ // ---------------------------------------------------------------------------
12
+ // Hoisted mocks
13
+ // ---------------------------------------------------------------------------
14
+ vi.mock('commons/components/app/hooks/useAppUser', () => ({
15
+ useAppUser: () => ({ user: { username: 'alice', name: 'Alice Smith' } })
16
+ }));
17
+ // Stub HowlerAvatar to avoid avatar API calls
18
+ vi.mock('components/elements/display/HowlerAvatar', () => ({
19
+ default: ({ userId }) => _jsx("div", { id: `avatar-${userId}`, children: userId })
20
+ }));
21
+ // Stub UserList to a simple multi-select so we can test the onChange wire-up
22
+ // without pulling in the full component and its popover.
23
+ vi.mock('components/elements/UserList', () => ({
24
+ default: ({ userIds, onChange, multiple }) => (_jsxs("div", { id: "user-list", children: [_jsx("button", { id: "user-list-add", onClick: () => onChange([...userIds, 'bob']), children: "Add bob" }), multiple &&
25
+ userIds.map(id => (_jsxs("button", { id: `remove-${id}`, onClick: () => onChange(userIds.filter(u => u !== id)), children: ["Remove ", id] }, id)))] }))
26
+ }));
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+ const mockSearchUsers = vi.fn();
31
+ const USERS = {
32
+ alice: { username: 'alice', name: 'Alice Smith' },
33
+ bob: { username: 'bob', name: 'Bob Jones' }
34
+ };
35
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(UserListContext.Provider, { value: { users: USERS, searchUsers: mockSearchUsers, fetchUsers: vi.fn() }, children: children }) }));
36
+ const renderFilter = (assigneeFilter, onChange = vi.fn()) => render(_jsx(CaseAssigneeFilter, { assigneeFilter: assigneeFilter, onChange: onChange }), { wrapper: Wrapper });
37
+ const openPopper = async (user, labelText) => {
38
+ const chip = screen.getByText(labelText).closest('.MuiChip-root');
39
+ await user.click(chip);
40
+ await waitFor(() => {
41
+ expect(screen.getByTestId('user-list')).toBeInTheDocument();
42
+ });
43
+ };
44
+ // ---------------------------------------------------------------------------
45
+ // Tests
46
+ // ---------------------------------------------------------------------------
47
+ describe('CaseAssigneeFilter', () => {
48
+ let user;
49
+ beforeEach(() => {
50
+ user = userEvent.setup();
51
+ vi.clearAllMocks();
52
+ });
53
+ describe('searchUsers on mount', () => {
54
+ it('calls searchUsers with "uname:*" on mount', () => {
55
+ renderFilter([]);
56
+ expect(mockSearchUsers).toHaveBeenCalledWith('uname:*');
57
+ });
58
+ });
59
+ describe('label', () => {
60
+ it('shows the default "Assignee" label when no assignees are selected', () => {
61
+ renderFilter([]);
62
+ expect(screen.getByText(i18n.t('route.cases.filter.assignee'))).toBeInTheDocument();
63
+ });
64
+ it('shows the user display name when one assignee is selected', () => {
65
+ renderFilter(['alice']);
66
+ expect(screen.getByText('Alice Smith')).toBeInTheDocument();
67
+ });
68
+ it('shows count + "Assignees" when multiple assignees are selected', () => {
69
+ renderFilter(['alice', 'bob']);
70
+ expect(screen.getByText(`2 ${i18n.t('route.cases.filter.assignees')}`)).toBeInTheDocument();
71
+ });
72
+ it('falls back to username when user is not in the user map', () => {
73
+ renderFilter(['unknown-user']);
74
+ const chipLabel = document.querySelector('.MuiChip-label');
75
+ expect(chipLabel?.textContent).toContain('unknown-user');
76
+ });
77
+ });
78
+ describe('chip color', () => {
79
+ it('uses default color when no assignees are selected', () => {
80
+ renderFilter([]);
81
+ const chip = screen.getByText(i18n.t('route.cases.filter.assignee')).closest('.MuiChip-root');
82
+ expect(chip).not.toHaveClass('MuiChip-colorPrimary');
83
+ });
84
+ it('uses primary color when assignees are selected', () => {
85
+ renderFilter(['alice']);
86
+ const chip = screen.getByText('Alice Smith').closest('.MuiChip-root');
87
+ expect(chip).toHaveClass('MuiChip-colorPrimary');
88
+ });
89
+ });
90
+ describe('"Myself" checkbox', () => {
91
+ it('is unchecked when the current user is not in the filter', async () => {
92
+ renderFilter([]);
93
+ await openPopper(user, i18n.t('route.cases.filter.assignee'));
94
+ const checkbox = screen.getByRole('checkbox', { name: i18n.t('route.cases.filter.myself') });
95
+ expect(checkbox).not.toBeChecked();
96
+ });
97
+ it('is checked when the current user is already in the filter', async () => {
98
+ renderFilter(['alice']);
99
+ await openPopper(user, 'Alice Smith');
100
+ const checkbox = screen.getByRole('checkbox', { name: i18n.t('route.cases.filter.myself') });
101
+ expect(checkbox).toBeChecked();
102
+ });
103
+ it('adds the current user to the filter when the checkbox is checked', async () => {
104
+ const onChange = vi.fn();
105
+ renderFilter([], onChange);
106
+ await openPopper(user, i18n.t('route.cases.filter.assignee'));
107
+ await user.click(screen.getByRole('checkbox', { name: i18n.t('route.cases.filter.myself') }));
108
+ expect(onChange).toHaveBeenCalledWith(['alice']);
109
+ });
110
+ it('removes the current user from the filter when the checkbox is unchecked', async () => {
111
+ const onChange = vi.fn();
112
+ renderFilter(['alice', 'bob'], onChange);
113
+ await openPopper(user, `2 ${i18n.t('route.cases.filter.assignees')}`);
114
+ await user.click(screen.getByRole('checkbox', { name: i18n.t('route.cases.filter.myself') }));
115
+ expect(onChange).toHaveBeenCalledWith(['bob']);
116
+ });
117
+ });
118
+ describe('UserList onChange passthrough', () => {
119
+ it('passes onChange directly to UserList', async () => {
120
+ const onChange = vi.fn();
121
+ renderFilter(['alice'], onChange);
122
+ await openPopper(user, 'Alice Smith');
123
+ await user.click(screen.getByTestId('user-list-add'));
124
+ expect(onChange).toHaveBeenCalledWith(['alice', 'bob']);
125
+ });
126
+ });
127
+ });
@@ -0,0 +1,13 @@
1
+ import type { Dayjs } from 'dayjs';
2
+ import type { FC } from 'react';
3
+ import { DATE_RANGES } from '@cccsaurora/howler-ui/utils/constants';
4
+ export type DateRangeOption = (typeof DATE_RANGES)[number];
5
+ declare const CaseDateFilter: FC<{
6
+ dateRange: DateRangeOption;
7
+ onChange: (v: DateRangeOption) => void;
8
+ customStart: Dayjs;
9
+ customEnd: Dayjs;
10
+ onCustomStartChange: (v: Dayjs) => void;
11
+ onCustomEndChange: (v: Dayjs) => void;
12
+ }>;
13
+ export default CaseDateFilter;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AvTimer } from '@mui/icons-material';
3
+ import { Autocomplete, Stack, TextField, Typography } from '@mui/material';
4
+ import { LocalizationProvider } from '@mui/x-date-pickers';
5
+ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
6
+ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
7
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
8
+ import { useTranslation } from 'react-i18next';
9
+ import { DATE_RANGES } from '@cccsaurora/howler-ui/utils/constants';
10
+ const CaseDateFilter = ({ dateRange, onChange, customStart, customEnd, onCustomStartChange, onCustomEndChange }) => {
11
+ const { t } = useTranslation();
12
+ return (_jsx(ChipPopper, { icon: _jsx(AvTimer, { fontSize: "small" }), label: _jsx(Typography, { variant: "body2", children: dateRange === 'date.range.all'
13
+ ? t('route.cases.filter.date')
14
+ : dateRange === 'date.range.custom'
15
+ ? `${customStart.format('YYYY-MM-DD')} ${t('to')} ${customEnd.format('YYYY-MM-DD')}`
16
+ : t(dateRange) }), minWidth: "225px", slotProps: { chip: { size: 'small', color: dateRange !== 'date.range.all' ? 'primary' : 'default' } }, children: _jsxs(Stack, { spacing: 1, children: [_jsx(Autocomplete, { size: "small", value: dateRange, options: [...DATE_RANGES], getOptionLabel: o => t(o), disableClearable: true, onChange: (_, nv) => onChange(nv), renderInput: params => _jsx(TextField, { ...params, label: t('route.cases.filter.date') }) }), dateRange === 'date.range.custom' && (_jsx(LocalizationProvider, { dateAdapter: AdapterDayjs, children: _jsxs(Stack, { direction: "row", spacing: 1, useFlexGap: true, flexWrap: "wrap", children: [_jsx(DateTimePicker, { sx: { minWidth: '175px', flexGrow: 1 }, slotProps: { textField: { size: 'small' } }, label: t('date.select.start'), value: customStart, maxDate: customEnd, onChange: nv => {
17
+ if (nv) {
18
+ onCustomStartChange(nv);
19
+ }
20
+ }, ampm: false, disableFuture: true }), _jsx(DateTimePicker, { sx: { minWidth: '175px', flexGrow: 1 }, slotProps: { textField: { size: 'small' } }, label: t('date.select.end'), value: customEnd, minDate: customStart, onChange: nv => {
21
+ if (nv) {
22
+ onCustomEndChange(nv);
23
+ }
24
+ }, ampm: false, disableFuture: true })] }) }))] }) }));
25
+ };
26
+ export default CaseDateFilter;
@@ -0,0 +1,115 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent, {} from '@testing-library/user-event';
5
+ import dayjs from 'dayjs';
6
+ import i18n from '@cccsaurora/howler-ui/i18n';
7
+ import { I18nextProvider } from 'react-i18next';
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import CaseDateFilter from './CaseDateFilter';
10
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
11
+ // ---------------------------------------------------------------------------
12
+ // Stub the date pickers so jsdom doesn't choke on them
13
+ // ---------------------------------------------------------------------------
14
+ vi.mock('@mui/x-date-pickers/DateTimePicker', () => ({
15
+ DateTimePicker: ({ label, onChange }) => (_jsx("button", { id: `picker-${label}`, onClick: () => onChange(dayjs('2025-06-01')), children: label }))
16
+ }));
17
+ // ---------------------------------------------------------------------------
18
+ // Wrapper / render helper
19
+ // ---------------------------------------------------------------------------
20
+ const FIXED_START = dayjs('2025-01-01');
21
+ const FIXED_END = dayjs('2025-03-01');
22
+ const Wrapper = ({ children }) => _jsx(I18nextProvider, { i18n: i18n, children: children });
23
+ const renderFilter = ({ dateRange = 'date.range.all', onChange = vi.fn(), onCustomStartChange = vi.fn(), onCustomEndChange = vi.fn() } = {}) => render(_jsx(CaseDateFilter, { dateRange: dateRange, onChange: onChange, customStart: FIXED_START, customEnd: FIXED_END, onCustomStartChange: onCustomStartChange, onCustomEndChange: onCustomEndChange }), { wrapper: Wrapper });
24
+ const openPopper = async (user) => {
25
+ // The chip label varies; find the chip by the AvTimer icon's sibling
26
+ const chip = document.querySelector('.MuiChip-root');
27
+ await user.click(chip);
28
+ await waitFor(() => {
29
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
30
+ });
31
+ };
32
+ // ---------------------------------------------------------------------------
33
+ // Tests
34
+ // ---------------------------------------------------------------------------
35
+ describe('CaseDateFilter', () => {
36
+ let user;
37
+ beforeEach(() => {
38
+ user = userEvent.setup();
39
+ vi.clearAllMocks();
40
+ });
41
+ describe('label', () => {
42
+ it('shows "Date Range" label when date.range.all is selected', () => {
43
+ renderFilter({ dateRange: 'date.range.all' });
44
+ expect(screen.getByText(i18n.t('route.cases.filter.date'))).toBeInTheDocument();
45
+ });
46
+ it('shows the translated range label for a non-all preset', () => {
47
+ renderFilter({ dateRange: 'date.range.1.week' });
48
+ expect(screen.getByText(i18n.t('date.range.1.week'))).toBeInTheDocument();
49
+ });
50
+ it('shows formatted start/end dates for the custom range', () => {
51
+ renderFilter({ dateRange: 'date.range.custom' });
52
+ const expected = `${FIXED_START.format('YYYY-MM-DD')} ${i18n.t('to')} ${FIXED_END.format('YYYY-MM-DD')}`;
53
+ expect(screen.getByText(expected)).toBeInTheDocument();
54
+ });
55
+ });
56
+ describe('chip color', () => {
57
+ it('uses default color for date.range.all', () => {
58
+ renderFilter({ dateRange: 'date.range.all' });
59
+ const chip = document.querySelector('.MuiChip-root');
60
+ expect(chip).not.toHaveClass('MuiChip-colorPrimary');
61
+ });
62
+ it('uses primary color for any non-all range', () => {
63
+ renderFilter({ dateRange: 'date.range.1.day' });
64
+ const chip = document.querySelector('.MuiChip-root');
65
+ expect(chip).toHaveClass('MuiChip-colorPrimary');
66
+ });
67
+ });
68
+ describe('popper content', () => {
69
+ it('does not show the autocomplete before the chip is clicked', () => {
70
+ renderFilter();
71
+ expect(screen.queryByRole('combobox')).toBeNull();
72
+ });
73
+ it('shows the range autocomplete after the chip is clicked', async () => {
74
+ renderFilter();
75
+ await openPopper(user);
76
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
77
+ });
78
+ it('does not show date pickers when a preset (non-custom) range is active', async () => {
79
+ renderFilter({ dateRange: 'date.range.1.day' });
80
+ await openPopper(user);
81
+ expect(screen.queryByTestId(`picker-${i18n.t('date.select.start')}`)).toBeNull();
82
+ });
83
+ it('shows start and end date pickers when custom range is active', async () => {
84
+ renderFilter({ dateRange: 'date.range.custom' });
85
+ await openPopper(user);
86
+ expect(screen.getByTestId(`picker-${i18n.t('date.select.start')}`)).toBeInTheDocument();
87
+ expect(screen.getByTestId(`picker-${i18n.t('date.select.end')}`)).toBeInTheDocument();
88
+ });
89
+ });
90
+ describe('interactions', () => {
91
+ it('calls onChange when a range option is selected from the autocomplete', async () => {
92
+ const onChange = vi.fn();
93
+ renderFilter({ dateRange: 'date.range.all', onChange });
94
+ await openPopper(user);
95
+ await user.click(screen.getByRole('combobox'));
96
+ const option = await screen.findByRole('option', { name: i18n.t('date.range.1.week') });
97
+ await user.click(option);
98
+ expect(onChange).toHaveBeenCalledWith('date.range.1.week');
99
+ });
100
+ it('calls onCustomStartChange when the start date picker fires', async () => {
101
+ const onCustomStartChange = vi.fn();
102
+ renderFilter({ dateRange: 'date.range.custom', onCustomStartChange });
103
+ await openPopper(user);
104
+ await user.click(screen.getByTestId(`picker-${i18n.t('date.select.start')}`));
105
+ expect(onCustomStartChange).toHaveBeenCalledWith(dayjs('2025-06-01'));
106
+ });
107
+ it('calls onCustomEndChange when the end date picker fires', async () => {
108
+ const onCustomEndChange = vi.fn();
109
+ renderFilter({ dateRange: 'date.range.custom', onCustomEndChange });
110
+ await openPopper(user);
111
+ await user.click(screen.getByTestId(`picker-${i18n.t('date.select.end')}`));
112
+ expect(onCustomEndChange).toHaveBeenCalledWith(dayjs('2025-06-01'));
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,6 @@
1
+ import type { FC } from 'react';
2
+ declare const CaseStatusFilter: FC<{
3
+ statusFilter: string[];
4
+ onChange: (v: string[]) => void;
5
+ }>;
6
+ export default CaseStatusFilter;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { FilterList } from '@mui/icons-material';
3
+ import { ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
4
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { CASE_STATUSES } from '../constants';
7
+ const CaseStatusFilter = ({ statusFilter, onChange }) => {
8
+ const { t } = useTranslation();
9
+ return (_jsx(ChipPopper, { icon: _jsx(FilterList, { fontSize: "small" }), label: _jsx(Typography, { variant: "body2", children: statusFilter.length === 0
10
+ ? t('route.cases.filter.status')
11
+ : statusFilter.map(s => t(`page.cases.status.${s}`)).join(', ') }), minWidth: "200px", slotProps: { chip: { size: 'small', color: statusFilter.length > 0 ? 'primary' : 'default' } }, children: _jsx(ToggleButtonGroup, { value: statusFilter, onChange: (_, nv) => onChange(nv), size: "small", orientation: "vertical", sx: { width: '100%' }, children: CASE_STATUSES.map(s => (_jsx(ToggleButton, { value: s, sx: { justifyContent: 'flex-start' }, children: t(`page.cases.status.${s}`) }, s))) }) }));
12
+ };
13
+ export default CaseStatusFilter;
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent, {} from '@testing-library/user-event';
5
+ import i18n from '@cccsaurora/howler-ui/i18n';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import CaseStatusFilter from './CaseStatusFilter';
9
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
10
+ const Wrapper = ({ children }) => _jsx(I18nextProvider, { i18n: i18n, children: children });
11
+ const renderFilter = (statusFilter, onChange = vi.fn()) => render(_jsx(CaseStatusFilter, { statusFilter: statusFilter, onChange: onChange }), { wrapper: Wrapper });
12
+ const openPopper = async (user) => {
13
+ const chip = screen.getByText(i18n.t('route.cases.filter.status')).closest('.MuiChip-root');
14
+ await user.click(chip);
15
+ await waitFor(() => {
16
+ expect(screen.getByRole('group')).toBeInTheDocument();
17
+ });
18
+ };
19
+ describe('CaseStatusFilter', () => {
20
+ let user;
21
+ beforeEach(() => {
22
+ user = userEvent.setup();
23
+ vi.clearAllMocks();
24
+ });
25
+ describe('label', () => {
26
+ it('shows the default "Status" label when no statuses are selected', () => {
27
+ renderFilter([]);
28
+ expect(screen.getByText(i18n.t('route.cases.filter.status'))).toBeInTheDocument();
29
+ });
30
+ it('shows a comma-separated list of status labels when statuses are selected', () => {
31
+ renderFilter(['open', 'on-hold']);
32
+ const expected = [i18n.t('page.cases.status.open'), i18n.t('page.cases.status.on-hold')].join(', ');
33
+ expect(screen.getByText(expected)).toBeInTheDocument();
34
+ });
35
+ it('shows a single status label when one status is selected', () => {
36
+ renderFilter(['resolved']);
37
+ expect(screen.getByText(i18n.t('page.cases.status.resolved'))).toBeInTheDocument();
38
+ });
39
+ });
40
+ describe('chip color', () => {
41
+ it('uses default color when no statuses are selected', () => {
42
+ renderFilter([]);
43
+ const chip = screen.getByText(i18n.t('route.cases.filter.status')).closest('.MuiChip-root');
44
+ expect(chip).not.toHaveClass('MuiChip-colorPrimary');
45
+ });
46
+ it('uses primary color when statuses are selected', () => {
47
+ renderFilter(['open']);
48
+ const chip = screen.getByText(i18n.t('page.cases.status.open')).closest('.MuiChip-root');
49
+ expect(chip).toHaveClass('MuiChip-colorPrimary');
50
+ });
51
+ });
52
+ describe('popper content', () => {
53
+ it('does not show toggle buttons before the chip is clicked', () => {
54
+ renderFilter([]);
55
+ expect(screen.queryByRole('group')).toBeNull();
56
+ });
57
+ it('shows all four status toggle buttons when the chip is clicked', async () => {
58
+ renderFilter([]);
59
+ await openPopper(user);
60
+ expect(screen.getByText(i18n.t('page.cases.status.open'))).toBeInTheDocument();
61
+ expect(screen.getByText(i18n.t('page.cases.status.in-progress'))).toBeInTheDocument();
62
+ expect(screen.getByText(i18n.t('page.cases.status.on-hold'))).toBeInTheDocument();
63
+ expect(screen.getByText(i18n.t('page.cases.status.resolved'))).toBeInTheDocument();
64
+ });
65
+ });
66
+ describe('interactions', () => {
67
+ it('calls onChange when a toggle button is clicked', async () => {
68
+ const onChange = vi.fn();
69
+ renderFilter([], onChange);
70
+ await openPopper(user);
71
+ await user.click(screen.getByText(i18n.t('page.cases.status.open')));
72
+ expect(onChange).toHaveBeenCalledWith(['open']);
73
+ });
74
+ it('calls onChange with empty array when the only selected status is deselected', async () => {
75
+ const onChange = vi.fn();
76
+ renderFilter(['open'], onChange);
77
+ const chip = screen.getByText(i18n.t('page.cases.status.open')).closest('.MuiChip-root');
78
+ await user.click(chip);
79
+ await waitFor(() => {
80
+ expect(screen.getByRole('group')).toBeInTheDocument();
81
+ });
82
+ await user.click(screen.getAllByText(i18n.t('page.cases.status.open'))[1]);
83
+ expect(onChange).toHaveBeenCalledWith([]);
84
+ });
85
+ });
86
+ });
@@ -408,6 +408,10 @@
408
408
  "page.cases.sidebar.item.remove": "Remove item",
409
409
  "page.cases.sidebar.item.rename": "Rename item",
410
410
  "page.cases.sources": "Sources",
411
+ "page.cases.status.in-progress": "In Progress",
412
+ "page.cases.status.on-hold": "On Hold",
413
+ "page.cases.status.open": "Open",
414
+ "page.cases.status.resolved": "Resolved",
411
415
  "page.cases.timeline": "Timeline",
412
416
  "page.cases.timeline.empty": "No events match the selected filters.",
413
417
  "page.cases.timeline.filter.escalation": "Escalation",
@@ -620,8 +624,13 @@
620
624
  "route.analytics.view": "View Analytic",
621
625
  "route.cases": "Cases",
622
626
  "route.cases.create": "Create Case",
627
+ "route.cases.filter.assignee": "Assignee",
628
+ "route.cases.filter.assignees": "Assignees",
629
+ "route.cases.filter.date": "Date Range",
630
+ "route.cases.filter.myself": "Myself",
631
+ "route.cases.filter.status": "Status",
623
632
  "route.cases.manager.search": "Search Cases",
624
- "route.cases.search.prompt": "Search Cases via title, summary or indicators",
633
+ "route.cases.search.prompt": "Search cases by title, summary, description, participants or task details",
625
634
  "route.cases.view": "View Case",
626
635
  "route.clear": "Clear query",
627
636
  "route.dossiers": "Dossiers",
@@ -408,6 +408,10 @@
408
408
  "page.cases.sidebar.item.remove": "Supprimer l'élément",
409
409
  "page.cases.sidebar.item.rename": "Renommer l'élément",
410
410
  "page.cases.sources": "Sources",
411
+ "page.cases.status.in-progress": "En cours",
412
+ "page.cases.status.on-hold": "En attente",
413
+ "page.cases.status.open": "Ouvert",
414
+ "page.cases.status.resolved": "Résolu",
411
415
  "page.cases.timeline": "Chronologie",
412
416
  "page.cases.timeline.empty": "Aucun événement ne correspond aux filtres sélectionnés.",
413
417
  "page.cases.timeline.filter.escalation": "Escalade",
@@ -620,8 +624,13 @@
620
624
  "route.analytics.view": "Voir l'analyse",
621
625
  "route.cases": "Cas",
622
626
  "route.cases.create": "Créer un cas",
627
+ "route.cases.filter.assignee": "Assigné",
628
+ "route.cases.filter.assignees": "Assignés",
629
+ "route.cases.filter.date": "Plage de dates",
630
+ "route.cases.filter.myself": "Moi-même",
631
+ "route.cases.filter.status": "Statut",
623
632
  "route.cases.manager.search": "Rechercher des cas",
624
- "route.cases.search.prompt": "Rechercher des cas par titre, résumé ou indicateurs",
633
+ "route.cases.search.prompt": "Rechercher des cas par titre, résumé, description, participants ou détails des tâches",
625
634
  "route.cases.view": "Voir le cas",
626
635
  "route.clear": "Effacer la requête",
627
636
  "route.dossiers": "Dossiers",
package/package.json CHANGED
@@ -101,7 +101,7 @@
101
101
  "internal-slot": "1.0.7"
102
102
  },
103
103
  "type": "module",
104
- "version": "2.18.0-dev.762",
104
+ "version": "2.18.0-dev.766",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",
@@ -172,6 +172,7 @@
172
172
  "./components/routes/action/view/markdown/*.md": "./components/routes/action/view/markdown/*.md.js",
173
173
  "./components/routes/cases/modals/*": "./components/routes/cases/modals/*.js",
174
174
  "./components/routes/cases/detail/*": "./components/routes/cases/detail/*.js",
175
+ "./components/routes/cases/search/*": "./components/routes/cases/search/*.js",
175
176
  "./components/routes/cases/hooks/*": "./components/routes/cases/hooks/*.js",
176
177
  "./components/routes/cases/detail/assets/*": "./components/routes/cases/detail/assets/*.js",
177
178
  "./components/routes/cases/detail/sidebar/*": "./components/routes/cases/detail/sidebar/*.js",
@@ -74,6 +74,7 @@ export declare const RULE_INTERVALS: {
74
74
  crontab: string;
75
75
  }[];
76
76
  export declare const DATE_RANGES: string[];
77
+ export declare const DATE_RANGE_LUCENE: Partial<Record<(typeof DATE_RANGES)[number], string>>;
77
78
  interface LabelData {
78
79
  icon?: ReactElement;
79
80
  color?: string;
@@ -104,6 +104,12 @@ export const DATE_RANGES = [
104
104
  'date.range.all',
105
105
  'date.range.custom'
106
106
  ];
107
+ export const DATE_RANGE_LUCENE = {
108
+ 'date.range.1.day': 'now-1d/d',
109
+ 'date.range.3.day': 'now-3d/d',
110
+ 'date.range.1.week': 'now-7d/d',
111
+ 'date.range.1.month': 'now-1M/M'
112
+ };
107
113
  export const LABEL_TYPES = {
108
114
  insight: { icon: _jsx(PsychologyAlt, { fontSize: "small" }), color: '#FFFFFF' }, //brain icon
109
115
  mitigation: { icon: _jsx(LocalPolice, { fontSize: "small" }), color: blue[600] }, //police badge