@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
@@ -7,6 +7,10 @@ import { I18nextProvider } from 'react-i18next';
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import ViewRefresh, {} from './ViewRefresh';
9
9
  const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
10
+ const createRefs = (refreshRate = 30, viewCardIds = ['one', 'two']) => ({
11
+ refreshRateRef: { current: refreshRate },
12
+ viewCardIdsRef: { current: viewCardIds }
13
+ });
10
14
  describe('ViewRefresh', () => {
11
15
  beforeEach(() => {
12
16
  vi.useFakeTimers();
@@ -15,21 +19,36 @@ describe('ViewRefresh', () => {
15
19
  vi.useRealTimers();
16
20
  });
17
21
  it('should render the refresh button', () => {
18
- const onRefresh = vi.fn();
19
- render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
22
+ render(_jsx(ViewRefresh, { ...createRefs(), onRefresh: vi.fn() }), { wrapper: Wrapper });
20
23
  expect(screen.getByRole('button')).toBeInTheDocument();
21
24
  });
22
25
  it('should show a progress indicator initially', () => {
23
- const onRefresh = vi.fn();
24
- render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
26
+ render(_jsx(ViewRefresh, { ...createRefs(), onRefresh: vi.fn() }), { wrapper: Wrapper });
25
27
  expect(screen.getByRole('progressbar')).toBeInTheDocument();
26
28
  });
29
+ it('reschedules the countdown using an updated refresh rate', () => {
30
+ const ref = React.createRef();
31
+ const refs = createRefs(30);
32
+ render(_jsx(ViewRefresh, { ref: ref, ...refs, onRefresh: vi.fn() }), { wrapper: Wrapper });
33
+ const progress = screen.getByRole('progressbar');
34
+ expect(progress).toHaveAttribute('aria-valuenow', '0');
35
+ act(() => {
36
+ refs.refreshRateRef.current = 60;
37
+ ref.current?.updateRefreshRate();
38
+ });
39
+ act(() => {
40
+ vi.advanceTimersByTime(300);
41
+ });
42
+ expect(progress).toHaveAttribute('aria-valuenow', '0');
43
+ act(() => {
44
+ vi.advanceTimersByTime(300);
45
+ });
46
+ expect(progress).toHaveAttribute('aria-valuenow', '1');
47
+ });
27
48
  it('should trigger refresh when progress reaches 100%', async () => {
28
49
  const onRefresh = vi.fn();
29
- render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
50
+ render(_jsx(ViewRefresh, { ...createRefs(), onRefresh: onRefresh }), { wrapper: Wrapper });
30
51
  // Progress increments by 1 every refreshRate*10ms = 300ms.
31
- // Each increment triggers a re-render and a new setTimeout.
32
- // We advance one tick at a time inside act to let React process each state update.
33
52
  for (let i = 0; i < 101; i++) {
34
53
  await act(async () => {
35
54
  vi.advanceTimersByTime(300);
@@ -40,40 +59,68 @@ describe('ViewRefresh', () => {
40
59
  it('should clear refreshing state when all cards report back via ref', async () => {
41
60
  const onRefresh = vi.fn();
42
61
  const ref = React.createRef();
43
- render(_jsx(ViewRefresh, { ref: ref, refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
44
- // Advance to 100% to trigger refresh
62
+ render(_jsx(ViewRefresh, { ref: ref, ...createRefs(), onRefresh: onRefresh }), { wrapper: Wrapper });
45
63
  for (let i = 0; i < 101; i++) {
46
64
  await act(async () => {
47
65
  vi.advanceTimersByTime(300);
48
66
  });
49
67
  }
50
68
  expect(onRefresh).toHaveBeenCalled();
51
- // Simulate both cards completing
52
69
  act(() => {
53
- ref.current?.handleRefreshComplete();
54
- ref.current?.handleRefreshComplete();
70
+ ref.current?.handleRefreshComplete('one', onRefresh.mock.calls[0][0]);
71
+ ref.current?.handleRefreshComplete('two', onRefresh.mock.calls[0][0]);
55
72
  });
56
- // After all cards complete, the progress should reset (button re-enabled)
57
73
  expect(screen.getByRole('button')).not.toBeDisabled();
58
74
  });
59
75
  it('should trigger refresh via manual click', () => {
60
76
  const onRefresh = vi.fn();
61
- render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
62
- // Click the refresh button directly using fireEvent (avoids userEvent timer issues)
63
- const button = screen.getByRole('button');
77
+ render(_jsx(ViewRefresh, { ...createRefs(), onRefresh: onRefresh }), { wrapper: Wrapper });
64
78
  act(() => {
65
- button.click();
79
+ screen.getByRole('button').click();
66
80
  });
67
81
  expect(onRefresh).toHaveBeenCalled();
68
82
  });
69
- it('should not call onRefresh when viewCardCount is 0 and button is clicked', () => {
83
+ it('should not call onRefresh when there are no refreshable panels and the button is clicked', () => {
70
84
  const onRefresh = vi.fn();
71
- render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 0, onRefresh: onRefresh }), { wrapper: Wrapper });
72
- const button = screen.getByRole('button');
85
+ render(_jsx(ViewRefresh, { ...createRefs(30, []), onRefresh: onRefresh }), { wrapper: Wrapper });
73
86
  act(() => {
74
- button.click();
87
+ screen.getByRole('button').click();
75
88
  });
76
- // onRefresh should not be called because viewCardCount is 0
77
89
  expect(onRefresh).not.toHaveBeenCalled();
78
90
  });
91
+ it('keeps active refresh tracking in sync with dashboard panel edits', () => {
92
+ const onRefresh = vi.fn();
93
+ const ref = React.createRef();
94
+ const refs = createRefs();
95
+ render(_jsx(ViewRefresh, { ref: ref, ...refs, onRefresh: onRefresh }), { wrapper: Wrapper });
96
+ act(() => {
97
+ screen.getByRole('button').click();
98
+ });
99
+ const refreshTick = onRefresh.mock.calls[0][0];
100
+ act(() => {
101
+ refs.viewCardIdsRef.current = ['one', 'two', 'three'];
102
+ ref.current?.updateViewCardIds(refs.viewCardIdsRef.current);
103
+ ref.current?.handleRefreshComplete('one', refreshTick);
104
+ ref.current?.handleRefreshComplete('two', refreshTick);
105
+ });
106
+ expect(screen.getByRole('button')).toBeDisabled();
107
+ act(() => {
108
+ ref.current?.handleRefreshComplete('three', refreshTick);
109
+ });
110
+ expect(screen.getByRole('button')).not.toBeDisabled();
111
+ });
112
+ it('ends an active refresh when dashboard edits remove all pending panels', () => {
113
+ const onRefresh = vi.fn();
114
+ const ref = React.createRef();
115
+ const refs = createRefs();
116
+ render(_jsx(ViewRefresh, { ref: ref, ...refs, onRefresh: onRefresh }), { wrapper: Wrapper });
117
+ act(() => {
118
+ screen.getByRole('button').click();
119
+ });
120
+ act(() => {
121
+ refs.viewCardIdsRef.current = [];
122
+ ref.current?.updateViewCardIds(refs.viewCardIdsRef.current);
123
+ });
124
+ expect(screen.getByRole('button')).not.toBeDisabled();
125
+ });
79
126
  });
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
3
3
  import { SortableContext, arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
4
4
  import { Cancel, Check, Close, OpenInNew } from '@mui/icons-material';
5
- import { Alert, AlertTitle, CircularProgress, Grid, IconButton, Stack, Typography } from '@mui/material';
5
+ import { Alert, AlertTitle, Box, CircularProgress, Grid, IconButton, Stack, Typography } from '@mui/material';
6
6
  import { PageCenter, useAppUser } from '@tui/core';
7
7
  import api from '@cccsaurora/howler-ui/api';
8
8
  import { AppBrand } from '@cccsaurora/howler-ui/branding/AppBrand';
@@ -20,8 +20,10 @@ import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
20
20
  import ErrorBoundary from '../ErrorBoundary';
21
21
  import AddNewCard from './AddNewCard';
22
22
  import AnalyticCard, {} from './AnalyticCard';
23
+ import CasePanel, {} from './CasePanel';
23
24
  import EntryWrapper from './EntryWrapper';
24
25
  import HomeSettings from './HomeSettings';
26
+ import TasksPanel, {} from './TasksPanel';
25
27
  import ViewCard, {} from './ViewCard';
26
28
  import ViewRefresh, {} from './ViewRefresh';
27
29
  const LUCENE_DATE_FMT = 'YYYY-MM-DD[T]HH:mm:ss';
@@ -40,6 +42,8 @@ const Home = () => {
40
42
  const [refreshRate, setRefreshRate] = useState(user.refresh_rate ?? 15);
41
43
  const [refreshTick, setRefreshTick] = useState();
42
44
  const viewRefreshRef = useRef(null);
45
+ const refreshRateRef = useRef(refreshRate);
46
+ const refreshableCardIdsRef = useRef([]);
43
47
  const debounceTimerRef = useRef(null);
44
48
  const updateQuery = useMemo(() => `(howler.log.user:${user.username} OR howler.assignment:${user.username}) AND howler.log.timestamp:{${lastViewed} TO now} AND -howler.status:resolved`, [lastViewed, user.username]);
45
49
  const getIdFromEntry = useCallback((entry) => {
@@ -50,6 +54,9 @@ const Home = () => {
50
54
  else if (entry.type === 'view') {
51
55
  return settings.viewId;
52
56
  }
57
+ else if (entry.type === 'case' || entry.type === 'tasks') {
58
+ return entry.entry_id;
59
+ }
53
60
  else {
54
61
  return 'unknown';
55
62
  }
@@ -57,8 +64,8 @@ const Home = () => {
57
64
  const setLocalDashboard = useCallback((_dashboard) => {
58
65
  setStateDashboard(_dashboard ?? []);
59
66
  }, []);
60
- const handleRefreshComplete = useCallback(() => {
61
- viewRefreshRef.current?.handleRefreshComplete();
67
+ const handleRefreshComplete = useCallback((panelId, completedRefreshTick) => {
68
+ viewRefreshRef.current?.handleRefreshComplete(panelId, completedRefreshTick);
62
69
  }, []);
63
70
  const handleRefreshRateChange = useCallback((newRate) => {
64
71
  setRefreshRate(newRate);
@@ -74,8 +81,8 @@ const Home = () => {
74
81
  void setRefreshRateBackend(newRate);
75
82
  }, 500);
76
83
  }, [setRefreshRateBackend, setUser, user]);
77
- const handleRefresh = useCallback(() => {
78
- setRefreshTick(Symbol());
84
+ const handleRefresh = useCallback((nextRefreshTick) => {
85
+ setRefreshTick(nextRefreshTick);
79
86
  }, []);
80
87
  const saveChanges = useCallback(async () => {
81
88
  setLoading(true);
@@ -119,15 +126,29 @@ const Home = () => {
119
126
  clearTimeout(debounceTimerRef.current);
120
127
  };
121
128
  }, []);
122
- const viewCardCount = useMemo(() => (dashboard ?? []).filter(e => e.type === 'view').length, [dashboard]);
129
+ const refreshableCardIds = useMemo(() => (dashboard ?? []).filter(e => ['view', 'case', 'tasks'].includes(e.type)).map(e => e.entry_id), [dashboard]);
123
130
  useEffect(() => {
124
- addToAppBar('left', 'view_refresh', _jsx(ViewRefresh, { ref: viewRefreshRef, refreshRate: refreshRate, viewCardCount: viewCardCount, onRefresh: handleRefresh }));
125
- addToAppBar('left', 'home_settings', _jsx(HomeSettings, { isEditing: isEditing, refreshRate: refreshRate, onRefreshRateChange: handleRefreshRateChange, onEdit: () => setIsEditing(true) }));
131
+ refreshableCardIdsRef.current = refreshableCardIds;
132
+ viewRefreshRef.current?.updateViewCardIds(refreshableCardIds);
133
+ }, [refreshableCardIds]);
134
+ useEffect(() => {
135
+ refreshRateRef.current = refreshRate;
136
+ viewRefreshRef.current?.updateRefreshRate();
137
+ }, [refreshRate]);
138
+ useEffect(() => {
139
+ addToAppBar('left', 'view_spacer', _jsx(Box, { px: 0.25 }));
140
+ addToAppBar('left', 'view_refresh', _jsx(ViewRefresh, { ref: viewRefreshRef, refreshRateRef: refreshRateRef, viewCardIdsRef: refreshableCardIdsRef, onRefresh: handleRefresh }));
126
141
  return () => {
142
+ removeFromAppBar('view_spacer');
127
143
  removeFromAppBar('view_refresh');
144
+ };
145
+ }, [addToAppBar, handleRefresh, removeFromAppBar]);
146
+ useEffect(() => {
147
+ addToAppBar('left', 'home_settings', _jsx(HomeSettings, { isEditing: isEditing, refreshRate: refreshRate, onRefreshRateChange: handleRefreshRateChange, onEdit: () => setIsEditing(true) }));
148
+ return () => {
128
149
  removeFromAppBar('home_settings');
129
150
  };
130
- }, [addToAppBar, handleRefresh, handleRefreshRateChange, isEditing, refreshRate, removeFromAppBar, viewCardCount]);
151
+ }, [addToAppBar, handleRefreshRateChange, isEditing, refreshRate, removeFromAppBar]);
131
152
  return (_jsx(PageCenter, { maxWidth: "100%", textAlign: "left", height: "100%", children: _jsx(ErrorBoundary, { children: _jsxs(Stack, { direction: "column", spacing: 1, sx: { height: '100%' }, children: [_jsxs(Stack, { direction: "row", justifyContent: "end", spacing: 1, children: [isEditing && (_jsx(CustomButton, { variant: "outlined", size: "small", color: "error", startIcon: _jsx(Cancel, {}), onClick: discardChanges, children: t('cancel') })), isEditing && (_jsx(CustomButton, { variant: "outlined", size: "small", disabled: isEqual(dashboard, user.dashboard), color: 'success', startIcon: loading ? _jsx(CircularProgress, { size: 20 }) : _jsx(Check, {}), onClick: saveChanges, children: t('save') }))] }), updatedHitTotal > 0 && (_jsxs(Alert, { severity: "info", variant: "outlined", action: _jsxs(Stack, { spacing: 1, direction: "row", children: [_jsx(IconButton, { color: "info", component: Link, to: `/hits?query=${encodeURIComponent(updateQuery)}`, onClick: () => setLastViewed(dayjs().utc().format(LUCENE_DATE_FMT)), children: _jsx(OpenInNew, {}) }), _jsx(IconButton, { color: "info", onClick: () => {
132
153
  setLastViewed(dayjs().utc().format(LUCENE_DATE_FMT));
133
154
  setUpdatedHitTotal(0);
@@ -144,12 +165,20 @@ const Home = () => {
144
165
  ], children: [(dashboard ?? []).map(entry => {
145
166
  if (entry.type === 'view') {
146
167
  const settings = JSON.parse(entry.config);
147
- return (_jsx(EntryWrapper, { editing: isEditing, id: settings.viewId, onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(ViewCard, { refreshTick: refreshTick, onRefreshComplete: handleRefreshComplete, ...settings }, entry.config) }, entry.entry_id));
168
+ return (_jsx(EntryWrapper, { editing: isEditing, id: settings.viewId, onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(ViewCard, { panelId: entry.entry_id, refreshTick: refreshTick, onRefreshComplete: handleRefreshComplete, ...settings }, entry.config) }, entry.entry_id));
148
169
  }
149
170
  else if (entry.type === 'analytic') {
150
171
  const settings = JSON.parse(entry.config);
151
172
  return (_jsx(EntryWrapper, { editing: isEditing, id: getIdFromEntry(entry), onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(AnalyticCard, { ...settings }, entry.config) }, entry.entry_id));
152
173
  }
174
+ else if (entry.type === 'case') {
175
+ const settings = JSON.parse(entry.config);
176
+ return (_jsx(EntryWrapper, { editing: isEditing, id: getIdFromEntry(entry), onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(CasePanel, { panelId: entry.entry_id, refreshTick: refreshTick, onRefreshComplete: handleRefreshComplete, ...settings }) }, entry.entry_id));
177
+ }
178
+ else if (entry.type === 'tasks') {
179
+ const settings = JSON.parse(entry.config);
180
+ return (_jsx(EntryWrapper, { editing: isEditing, id: getIdFromEntry(entry), onDelete: () => setLocalDashboard((dashboard ?? []).filter(_entry => _entry.entry_id !== getIdFromEntry(entry))), children: _jsx(TasksPanel, { panelId: entry.entry_id, refreshTick: refreshTick, onRefreshComplete: handleRefreshComplete, ...settings }) }, entry.entry_id));
181
+ }
153
182
  else {
154
183
  return null;
155
184
  }
@@ -1,5 +1,4 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /// <reference types="vitest" />
3
2
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
4
  const mockDispatchApi = vi.hoisted(() => vi.fn());
@@ -47,6 +46,7 @@ vi.mock('@mui/material', () => ({
47
46
  Grid: ({ children }) => _jsx("div", { children: children }),
48
47
  IconButton: ({ children, onClick, component, to }) => (_jsx("button", { "data-component": String(!!component), "data-to": to, onClick: onClick, children: children })),
49
48
  Stack: ({ children }) => _jsx("div", { children: children }),
49
+ Box: ({ children }) => _jsx("div", { children: children }),
50
50
  Typography: ({ children }) => _jsx("div", { children: children })
51
51
  }));
52
52
  vi.mock('@tui/core', () => ({
@@ -170,6 +170,8 @@ describe('Home', () => {
170
170
  expect(screen.getByText('analytic:a1:created')).toBeInTheDocument();
171
171
  expect(mockAddToAppBar).toHaveBeenCalledWith('left', 'view_refresh', expect.anything());
172
172
  expect(mockAddToAppBar).toHaveBeenCalledWith('left', 'home_settings', expect.anything());
173
+ const viewRefreshNode = mockAddToAppBar.mock.calls.find(([, key]) => key === 'view_refresh')[2];
174
+ expect(viewRefreshNode.props.viewCardIdsRef.current).toEqual(['view-entry']);
173
175
  const homeSettings = mockAddToAppBar.mock.calls.find(([, key]) => key === 'home_settings')[2];
174
176
  await act(async () => {
175
177
  homeSettings.props.onEdit();
@@ -180,7 +182,13 @@ describe('Home', () => {
180
182
  await new Promise(resolve => setTimeout(resolve, 550));
181
183
  });
182
184
  expect(mockSetRefreshRateBackend).toHaveBeenCalledWith(30);
185
+ expect(viewRefreshNode.props.refreshRateRef.current).toBe(30);
186
+ expect(mockRemoveFromAppBar).not.toHaveBeenCalledWith('view_refresh');
187
+ expect(mockAddToAppBar.mock.calls.filter(([, key]) => key === 'view_refresh')).toHaveLength(1);
183
188
  fireEvent.click(screen.getByText('add-card'));
189
+ await waitFor(() => expect(viewRefreshNode.props.viewCardIdsRef.current).toEqual(['view-entry', 'new-entry']));
190
+ expect(mockRemoveFromAppBar).not.toHaveBeenCalledWith('view_refresh');
191
+ expect(mockAddToAppBar.mock.calls.filter(([, key]) => key === 'view_refresh')).toHaveLength(1);
184
192
  const saveButton = screen.getByText('save');
185
193
  expect(saveButton).not.toBeDisabled();
186
194
  fireEvent.click(saveButton);
@@ -300,6 +300,7 @@ describe('ViewComposer', () => {
300
300
  expect(parameterContextValue.setSpan).toHaveBeenCalledWith('7d');
301
301
  expect(screen.getByText('event:event-1')).toBeInTheDocument();
302
302
  fireEvent.change(screen.getByLabelText('route.views.name'), { target: { value: 'Updated View' } });
303
+ fireEvent.click(screen.getByText('record-query-clean'));
303
304
  fireEvent.click(screen.getByText('checked'));
304
305
  fireEvent.click(screen.getByText('save'));
305
306
  await waitFor(() => expect(mockEditView).toHaveBeenCalledWith('view-1', {
@@ -825,6 +825,10 @@
825
825
  "route.home.add.type": "Panel Type",
826
826
  "route.home.add.type.analytic": "Analytic Panel",
827
827
  "route.home.add.type.analytic.description": "Present statistics on a given analytic = the resulting assessments, number of hits created, or escalation of created hits",
828
+ "route.home.add.type.case": "Case Panel",
829
+ "route.home.add.type.case.description": "Present cases with filters for status, assignee, and creation date.",
830
+ "route.home.add.type.tasks": "Tasks Panel",
831
+ "route.home.add.type.tasks.description": "Present your tasks across all cases, filtered by completion.",
828
832
  "route.home.add.type.view": "View Panel",
829
833
  "route.home.add.type.view.description": "Present a saved view, limited to a certain number of hits.",
830
834
  "route.home.add.view": "View to present",
@@ -842,7 +846,19 @@
842
846
  "route.home.add.visualization.unavailable": "No visualization options available.",
843
847
  "route.home.alert.updated.description": "There have been updates to alerts you are involved in. Since you last viewed, {{count}} alert(s) have updates.",
844
848
  "route.home.alert.updated.title": "Alert Updates",
849
+ "route.home.case.error": "Unable to load cases.",
850
+ "route.home.case.open": "Open cases",
851
+ "route.home.case.title": "Cases",
845
852
  "route.home.description": "Welcome to Howler. You can add items to your dashboard using the buttons above.",
853
+ "route.home.tasks.error": "Unable to load tasks.",
854
+ "route.home.tasks.filter": "Task status",
855
+ "route.home.tasks.filter.all": "All tasks",
856
+ "route.home.tasks.filter.complete": "Completed",
857
+ "route.home.tasks.filter.incomplete": "Not completed",
858
+ "route.home.tasks.next": "Next",
859
+ "route.home.tasks.open": "Open cases",
860
+ "route.home.tasks.previous": "Previous",
861
+ "route.home.tasks.title": "My Tasks",
846
862
  "route.home.title": "Howler",
847
863
  "route.integrations": "Integrations",
848
864
  "route.login.button.oauth": "Sign in with",
@@ -825,6 +825,10 @@
825
825
  "route.home.add.type": "Type de panneau",
826
826
  "route.home.add.type.analytic": "Panneau analytique",
827
827
  "route.home.add.type.analytic.description": "Présenter les statistiques d'une analyse donnée = les évaluations résultantes, le nombre de résultats créés ou l'escalade des résultats créés",
828
+ "route.home.add.type.case": "Panneau des cas",
829
+ "route.home.add.type.case.description": "Présenter les cas avec des filtres pour le statut, l'assigné et la date de création.",
830
+ "route.home.add.type.tasks": "Panneau des tâches",
831
+ "route.home.add.type.tasks.description": "Présenter vos tâches dans tous les cas, filtrées selon leur achèvement.",
828
832
  "route.home.add.type.view": "Panneau d'affichage",
829
833
  "route.home.add.type.view.description": "Présenter une vue sauvegardée, limitée à un certain nombre de résultats",
830
834
  "route.home.add.view": "Vue à présenter",
@@ -842,7 +846,19 @@
842
846
  "route.home.add.visualization.unavailable": "Aucune option de visualisation disponible.",
843
847
  "route.home.alert.updated.description": "Des mises à jour ont été apportées aux alertes auxquelles vous participez. Depuis votre dernière consultation, {{count}} alertes ont été mises à jour",
844
848
  "route.home.alert.updated.title": "Mises à jour d'alert",
849
+ "route.home.case.error": "Impossible de charger les cas.",
850
+ "route.home.case.open": "Ouvrir les cas",
851
+ "route.home.case.title": "Cas",
845
852
  "route.home.description": "Bienvenue sur Howler. Vous pouvez ajouter des éléments à votre tableau de bord en utilisant les boutons ci-dessus.",
853
+ "route.home.tasks.error": "Impossible de charger les tâches.",
854
+ "route.home.tasks.filter": "État des tâches",
855
+ "route.home.tasks.filter.all": "Toutes les tâches",
856
+ "route.home.tasks.filter.complete": "Terminées",
857
+ "route.home.tasks.filter.incomplete": "Non terminées",
858
+ "route.home.tasks.next": "Suivant",
859
+ "route.home.tasks.open": "Ouvrir les cas",
860
+ "route.home.tasks.previous": "Précédent",
861
+ "route.home.tasks.title": "Mes tâches",
846
862
  "route.home.title": "Howler",
847
863
  "route.integrations": "Intégrations",
848
864
  "route.login.button.oauth": "Se connecter avec",
@@ -14,6 +14,6 @@ export interface HowlerUser extends AppUser {
14
14
  is_active?: boolean;
15
15
  favourite_views?: string[];
16
16
  favourite_analytics?: string[];
17
- dashboard?: { entry_id: string; type: 'view' | 'analytic'; config: string }[];
17
+ dashboard?: { entry_id: string; type: 'view' | 'analytic' | 'case' | 'tasks'; config: string }[];
18
18
  refresh_rate?: number;
19
19
  }
package/package.json CHANGED
@@ -97,7 +97,7 @@
97
97
  "url": "https://github.com/CybercentreCanada/howler"
98
98
  },
99
99
  "type": "module",
100
- "version": "3.1.0-dev.1708",
100
+ "version": "3.1.0-dev.1716",
101
101
  "exports": {
102
102
  "./i18n": "./i18n.js",
103
103
  "./index.css": "./index.css",
@@ -243,6 +243,8 @@
243
243
  "./api/v2/case": "./api/v2/case/index.js",
244
244
  "./api/v2/search/*": "./api/v2/search/*.js",
245
245
  "./api/v2/search": "./api/v2/search/index.js",
246
+ "./api/v2/task/*": "./api/v2/task/*.js",
247
+ "./api/v2/task": "./api/v2/task/index.js",
246
248
  "./api/v2/case/rules/*": "./api/v2/case/rules/*.js",
247
249
  "./api/v2/case/rules": "./api/v2/case/rules/index.js",
248
250
  "./api/v2/case/rules/backfill/*": "./api/v2/case/rules/backfill/*.js",
@@ -4,7 +4,7 @@ export default class AxiosClient implements RestClient {
4
4
  private cache;
5
5
  private client;
6
6
  constructor();
7
- fetch<R>(url: string, method?: 'get' | 'post' | 'put' | 'delete' | 'patch', body?: any, params?: URLSearchParams, headers?: HeadersInit): Promise<[HowlerResponse<R>, number, {
7
+ fetch<R>(url: string, method?: 'get' | 'post' | 'put' | 'delete' | 'patch', body?: any, params?: URLSearchParams, headers?: HeadersInit, signal?: AbortSignal): Promise<[HowlerResponse<R>, number, {
8
8
  [index: string]: any;
9
9
  }]>;
10
10
  }
@@ -37,14 +37,15 @@ export default class AxiosClient {
37
37
  retryDelay: exponentialDelay
38
38
  });
39
39
  }
40
- async fetch(url, method = 'get', body, params, headers) {
40
+ async fetch(url, method = 'get', body, params, headers, signal) {
41
41
  const config = {
42
42
  url,
43
43
  params,
44
44
  method,
45
45
  withCredentials: true,
46
46
  data: JSON.stringify(body),
47
- headers: headers
47
+ headers: headers,
48
+ signal
48
49
  };
49
50
  try {
50
51
  const response = await this.client(config);
@@ -103,6 +103,12 @@ describe('AxiosClient', () => {
103
103
  const config = mockAxiosInstance.mock.calls[0][0];
104
104
  expect(config.headers).toEqual(headers);
105
105
  });
106
+ it('forwards an abort signal', async () => {
107
+ mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
108
+ const controller = new AbortController();
109
+ await client.fetch('/api/v1/hit', 'get', undefined, undefined, undefined, controller.signal);
110
+ expect(mockAxiosInstance.mock.calls[0][0].signal).toBe(controller.signal);
111
+ });
106
112
  it('sets withCredentials to true', async () => {
107
113
  mockAxiosInstance.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
108
114
  await client.fetch('/api/v1/hit');
@@ -1,7 +1,7 @@
1
1
  import type { HowlerResponse } from '@cccsaurora/howler-ui/api';
2
2
  import type RestClient from '@cccsaurora/howler-ui/rest';
3
3
  export default class FetchClient implements RestClient {
4
- fetch<R>(url: string, method?: 'get' | 'post' | 'put' | 'delete', body?: any, params?: URLSearchParams, headers?: HeadersInit): Promise<[HowlerResponse<R>, number, {
4
+ fetch<R>(url: string, method?: 'get' | 'post' | 'put' | 'delete' | 'patch', body?: any, params?: URLSearchParams, headers?: HeadersInit, signal?: AbortSignal): Promise<[HowlerResponse<R>, number, {
5
5
  [index: string]: any;
6
6
  }] | undefined>;
7
7
  }
@@ -1,10 +1,11 @@
1
1
  export default class FetchClient {
2
- async fetch(url, method = 'get', body, params, headers) {
2
+ async fetch(url, method = 'get', body, params, headers, signal) {
3
3
  const _url = `${url}${params ? `?${params.toString()}` : ''}`;
4
4
  const request = {
5
5
  method,
6
6
  credentials: 'same-origin',
7
- headers: headers
7
+ headers,
8
+ signal
8
9
  };
9
10
  if (method !== 'get' && body) {
10
11
  request.body = JSON.stringify(body);
@@ -62,6 +62,12 @@ describe('FetchClient', () => {
62
62
  const callArgs = spy.mock.calls[0][1];
63
63
  expect(callArgs.headers.Authorization).toBe('******');
64
64
  });
65
+ it('forwards an abort signal to fetch', async () => {
66
+ const spy = mockFetch(200, {});
67
+ const controller = new AbortController();
68
+ await client.fetch('/api/v1/hit', 'get', undefined, undefined, undefined, controller.signal);
69
+ expect(spy.mock.calls[0][1]?.signal).toBe(controller.signal);
70
+ });
65
71
  it('returns undefined for a 204 No Content response', async () => {
66
72
  mockFetch(204, null);
67
73
  const result = await client.fetch('/api/v1/hit', 'delete');
package/rest/index.d.ts CHANGED
@@ -3,5 +3,5 @@ export type RestResponse<R> = [HowlerResponse<R>, number, {
3
3
  [index: string]: any;
4
4
  }];
5
5
  export default interface RestClient {
6
- fetch<R>(url: string, method: 'get' | 'post' | 'put' | 'delete', body?: any, params?: URLSearchParams, headers?: HeadersInit): Promise<RestResponse<R> | undefined>;
6
+ fetch<R>(url: string, method: 'get' | 'post' | 'put' | 'delete' | 'patch', body?: any, params?: URLSearchParams, headers?: HeadersInit, signal?: AbortSignal): Promise<RestResponse<R> | undefined>;
7
7
  }