@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
@@ -0,0 +1,173 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import i18n from '@cccsaurora/howler-ui/i18n';
5
+ import { I18nextProvider } from 'react-i18next';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import TasksPanel from './TasksPanel';
8
+ const mockTaskSearch = vi.hoisted(() => vi.fn());
9
+ const mockDispatchApi = vi.hoisted(() => vi.fn());
10
+ vi.mock('@tui/core', async () => {
11
+ const actual = await vi.importActual('@tui/core');
12
+ return {
13
+ ...actual,
14
+ useAppUser: () => ({ user: { username: 'alice' } })
15
+ };
16
+ });
17
+ vi.mock('api', () => ({
18
+ default: {
19
+ v2: {
20
+ task: {
21
+ search: (...args) => mockTaskSearch(...args)
22
+ }
23
+ }
24
+ }
25
+ }));
26
+ vi.mock('components/hooks/useMyApi', () => ({
27
+ default: () => ({ dispatchApi: mockDispatchApi })
28
+ }));
29
+ vi.mock('components/routes/cases/detail/CaseTask', () => ({
30
+ default: ({ task }) => _jsx("div", { children: task.summary })
31
+ }));
32
+ vi.mock('react-router', async () => {
33
+ const actual = await vi.importActual('react-router');
34
+ return {
35
+ ...actual,
36
+ Link: ({ children, to, ...props }) => (_jsx("a", { href: to, ...props, children: children }))
37
+ };
38
+ });
39
+ const Wrapper = ({ children }) => _jsx(I18nextProvider, { i18n: i18n, children: children });
40
+ const taskItem = (summary, caseId = 'case-1') => ({
41
+ task: { id: `task-${summary}`, assignment: 'alice', summary, complete: false },
42
+ case: {
43
+ __index: 'case',
44
+ case_id: caseId,
45
+ title: `Case ${caseId}`,
46
+ status: 'open',
47
+ updated: '2024-01-01T00:00:00Z'
48
+ }
49
+ });
50
+ const page = (items, offset = 0, hasMore = false) => ({
51
+ items,
52
+ offset,
53
+ rows: 25,
54
+ has_more: hasMore
55
+ });
56
+ describe('TasksPanel', () => {
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ mockTaskSearch.mockReturnValue('task-request');
60
+ mockDispatchApi.mockResolvedValue(page([taskItem('Open task')], 0, true));
61
+ });
62
+ it('loads one page of 25 incomplete tasks by default', async () => {
63
+ render(_jsx(TasksPanel, {}), { wrapper: Wrapper });
64
+ expect(await screen.findByText('Open task')).toBeInTheDocument();
65
+ expect(mockTaskSearch).toHaveBeenCalledTimes(1);
66
+ expect(mockTaskSearch).toHaveBeenCalledWith({ offset: 0, rows: 25, filter: 'incomplete' }, expect.any(AbortSignal));
67
+ expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
68
+ expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled();
69
+ });
70
+ it('requests one page at a time and paginates according to has_more', async () => {
71
+ mockDispatchApi
72
+ .mockResolvedValueOnce(page([taskItem('First page')], 0, true))
73
+ .mockResolvedValueOnce(page([taskItem('Second page')], 25, false))
74
+ .mockResolvedValueOnce(page([taskItem('First page again')], 0, false));
75
+ render(_jsx(TasksPanel, {}), { wrapper: Wrapper });
76
+ expect(await screen.findByText('First page')).toBeInTheDocument();
77
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
78
+ expect(await screen.findByText('Second page')).toBeInTheDocument();
79
+ expect(mockTaskSearch.mock.calls.map(([request]) => request.offset)).toEqual([0, 25]);
80
+ expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
81
+ expect(screen.getByRole('button', { name: 'Previous' })).toBeEnabled();
82
+ fireEvent.click(screen.getByRole('button', { name: 'Previous' }));
83
+ expect(await screen.findByText('First page again')).toBeInTheDocument();
84
+ expect(mockTaskSearch.mock.calls.map(([request]) => request.offset)).toEqual([0, 25, 0]);
85
+ });
86
+ it('maps each status filter to the API and resets pagination on filter changes', async () => {
87
+ const user = userEvent.setup();
88
+ render(_jsx(TasksPanel, {}), { wrapper: Wrapper });
89
+ expect(await screen.findByText('Open task')).toBeInTheDocument();
90
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
91
+ await waitFor(() => expect(mockTaskSearch).toHaveBeenCalledTimes(2));
92
+ expect(mockTaskSearch.mock.calls[1][0]).toMatchObject({ offset: 25, filter: 'incomplete' });
93
+ const filterSelect = screen.getByRole('combobox');
94
+ await user.click(filterSelect);
95
+ await user.click(screen.getByRole('option', { name: 'Completed' }));
96
+ await waitFor(() => expect(mockTaskSearch).toHaveBeenCalledTimes(3));
97
+ expect(mockTaskSearch.mock.calls[2][0]).toMatchObject({ offset: 0, rows: 25, filter: 'complete' });
98
+ await user.click(filterSelect);
99
+ await user.click(screen.getByRole('option', { name: 'All tasks' }));
100
+ await waitFor(() => expect(mockTaskSearch).toHaveBeenCalledTimes(4));
101
+ expect(mockTaskSearch.mock.calls[3][0]).toMatchObject({ offset: 0, filter: 'all' });
102
+ await user.click(filterSelect);
103
+ await user.click(screen.getByRole('option', { name: 'Not completed' }));
104
+ await waitFor(() => expect(mockTaskSearch).toHaveBeenCalledTimes(5));
105
+ expect(mockTaskSearch.mock.calls[4][0]).toMatchObject({ offset: 0, filter: 'incomplete' });
106
+ });
107
+ it('keeps the latest results and reports only the current refresh after requests overlap', async () => {
108
+ let resolveFirst;
109
+ let resolveSecond;
110
+ mockDispatchApi
111
+ .mockReturnValueOnce(new Promise(resolve => (resolveFirst = resolve)))
112
+ .mockReturnValueOnce(new Promise(resolve => (resolveSecond = resolve)));
113
+ const firstTick = Symbol('first');
114
+ const secondTick = Symbol('second');
115
+ const onRefreshComplete = vi.fn();
116
+ const { rerender } = render(_jsx(TasksPanel, { refreshTick: firstTick, onRefreshComplete: onRefreshComplete }), {
117
+ wrapper: Wrapper
118
+ });
119
+ const firstSignal = mockTaskSearch.mock.calls[0][1];
120
+ rerender(_jsx(TasksPanel, { refreshTick: secondTick, onRefreshComplete: onRefreshComplete }));
121
+ expect(firstSignal.aborted).toBe(true);
122
+ resolveSecond?.(page([taskItem('Latest task')], 0, false));
123
+ expect(await screen.findByText('Latest task')).toBeInTheDocument();
124
+ await waitFor(() => expect(onRefreshComplete).toHaveBeenCalledTimes(1));
125
+ expect(onRefreshComplete).toHaveBeenCalledWith('tasks-panel', secondTick);
126
+ resolveFirst?.(page([taskItem('Stale task')], 0, false));
127
+ await waitFor(() => expect(screen.queryByText('Stale task')).not.toBeInTheDocument());
128
+ expect(screen.getByText('Latest task')).toBeInTheDocument();
129
+ expect(onRefreshComplete).toHaveBeenCalledTimes(1);
130
+ });
131
+ it('backs up to the last non-empty page when a refresh finds that data has shrunk', async () => {
132
+ mockDispatchApi
133
+ .mockResolvedValueOnce(page(Array.from({ length: 25 }, (_, i) => taskItem(`Task ${i}`)), 0, true))
134
+ .mockResolvedValueOnce(page(Array.from({ length: 25 }, (_, i) => taskItem(`Task ${i + 25}`)), 25, true))
135
+ .mockResolvedValueOnce(page([taskItem('Last page task')], 50, false))
136
+ .mockResolvedValueOnce(page([], 50, false))
137
+ .mockResolvedValueOnce(page([taskItem('Remaining task')], 25, false));
138
+ const refreshTick = Symbol('refresh');
139
+ const onRefreshComplete = vi.fn();
140
+ const { rerender } = render(_jsx(TasksPanel, { onRefreshComplete: onRefreshComplete }), { wrapper: Wrapper });
141
+ await screen.findByText('Task 0');
142
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
143
+ await screen.findByText('Task 25');
144
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }));
145
+ expect(await screen.findByText('Last page task')).toBeInTheDocument();
146
+ rerender(_jsx(TasksPanel, { refreshTick: refreshTick, onRefreshComplete: onRefreshComplete }));
147
+ expect(await screen.findByText('Remaining task')).toBeInTheDocument();
148
+ await waitFor(() => expect(mockTaskSearch).toHaveBeenCalledTimes(5));
149
+ expect(mockTaskSearch.mock.calls.map(([request]) => request.offset)).toEqual([0, 25, 50, 50, 25]);
150
+ await waitFor(() => expect(onRefreshComplete).toHaveBeenCalledTimes(1));
151
+ expect(onRefreshComplete).toHaveBeenCalledWith('tasks-panel', refreshTick);
152
+ });
153
+ it('shows an error for failed requests and an empty state when there are no tasks', async () => {
154
+ mockDispatchApi.mockRejectedValueOnce(new Error('request failed'));
155
+ const { unmount } = render(_jsx(TasksPanel, {}), { wrapper: Wrapper });
156
+ expect(await screen.findByText('Unable to load tasks.')).toBeInTheDocument();
157
+ unmount();
158
+ mockDispatchApi.mockReset().mockResolvedValue(page([]));
159
+ render(_jsx(TasksPanel, { taskFilter: "complete" }), { wrapper: Wrapper });
160
+ expect(await screen.findByText('No Results')).toBeInTheDocument();
161
+ });
162
+ it('aborts the request and reports a pending refresh when unmounted', () => {
163
+ const refreshTick = Symbol('refresh');
164
+ const onRefreshComplete = vi.fn();
165
+ mockDispatchApi.mockReturnValue(new Promise(() => { }));
166
+ const { unmount } = render(_jsx(TasksPanel, { panelId: "tasks-panel-1", refreshTick: refreshTick, onRefreshComplete: onRefreshComplete }), { wrapper: Wrapper });
167
+ const signal = mockTaskSearch.mock.calls[0][1];
168
+ unmount();
169
+ expect(onRefreshComplete).toHaveBeenCalledTimes(1);
170
+ expect(onRefreshComplete).toHaveBeenCalledWith('tasks-panel-1', refreshTick);
171
+ expect(signal.aborted).toBe(true);
172
+ });
173
+ });
@@ -2,8 +2,9 @@ import { type FC } from 'react';
2
2
  export interface ViewSettings {
3
3
  viewId: string;
4
4
  limit: number;
5
+ panelId?: string;
5
6
  refreshTick?: symbol;
6
- onRefreshComplete?: () => void;
7
+ onRefreshComplete?: (panelId: string, refreshTick: symbol) => void;
7
8
  }
8
9
  declare const ViewCard: FC<ViewSettings>;
9
10
  export default ViewCard;
@@ -62,14 +62,15 @@ const createSignatureFromRecords = (records) => {
62
62
  return records.map(createRecordSignature).join('|');
63
63
  };
64
64
  const DEBOUNCE_TIME = 1000; // 1 second debounce for signature changes
65
- const ViewCard = ({ viewId, limit, refreshTick, onRefreshComplete }) => {
65
+ const ViewCard = ({ viewId, limit, panelId = viewId, refreshTick, onRefreshComplete }) => {
66
66
  const navigate = useNavigate();
67
67
  const { t } = useTranslation();
68
68
  const { dispatchApi } = useMyApi();
69
69
  const [recordIds, setRecordIds] = useState([]);
70
70
  const [loading, setLoading] = useState(false);
71
+ const [signatureRefresh, setSignatureRefresh] = useState(0);
71
72
  const debounceTimerRef = useRef(null);
72
- const isRefreshing = useRef(false);
73
+ const activeRefreshTick = useRef(undefined);
73
74
  const lastSignature = useRef('');
74
75
  const view = useContextSelector(ViewContext, ctx => ctx.views[viewId]);
75
76
  const fetchViews = useContextSelector(ViewContext, ctx => ctx.fetchViews);
@@ -79,63 +80,72 @@ const ViewCard = ({ viewId, limit, refreshTick, onRefreshComplete }) => {
79
80
  const records = useSelectRecordsByIds(recordIds);
80
81
  // Create a stable signature that only changes when relevant fields change
81
82
  const recordsSignature = useMemo(() => createSignatureFromRecords(records), [records]);
82
- const refreshView = useCallback(async () => {
83
- if (!view?.query || isRefreshing.current) {
84
- onRefreshComplete?.();
85
- return '';
86
- }
87
- isRefreshing.current = true;
88
- try {
89
- const res = await dispatchApi(api.v2.search.post((view.indexes ?? ['hit']), {
90
- query: view.query,
91
- rows: limit,
92
- sort: view.sort,
93
- filters: view.span ? [`event.created:${convertDateToLucene(view.span)}`] : [],
94
- metadata: ['analytic']
95
- }));
96
- if (!res) {
97
- return;
98
- }
99
- const fetchedRecords = res.items ?? [];
100
- loadRecords(fetchedRecords);
101
- setRecordIds(fetchedRecords.map(r => r.howler.id));
102
- lastSignature.current = createSignatureFromRecords(fetchedRecords);
103
- }
104
- finally {
105
- isRefreshing.current = false;
106
- onRefreshComplete?.();
107
- }
108
- }, [dispatchApi, limit, view?.query, view?.indexes, view?.sort, view?.span, loadRecords, onRefreshComplete]);
109
83
  const debouncedRefresh = useCallback(() => {
110
84
  if (debounceTimerRef.current) {
111
85
  clearTimeout(debounceTimerRef.current);
112
86
  }
113
87
  debounceTimerRef.current = setTimeout(() => {
114
- void refreshView();
88
+ setSignatureRefresh(previous => previous + 1);
115
89
  }, DEBOUNCE_TIME);
116
- }, [refreshView]);
117
- useEffect(() => {
118
- if (refreshTick) {
119
- void refreshView();
120
- }
121
- // eslint-disable-next-line react-hooks/exhaustive-deps
122
- }, [refreshTick]);
90
+ }, []);
123
91
  useEffect(() => {
124
92
  void fetchViews([viewId]);
125
93
  }, [fetchViews, viewId]);
126
94
  useEffect(() => {
95
+ const controller = new AbortController();
96
+ let refreshComplete = false;
97
+ const completeRefresh = () => {
98
+ if (refreshTick && !refreshComplete) {
99
+ refreshComplete = true;
100
+ if (activeRefreshTick.current === refreshTick) {
101
+ activeRefreshTick.current = undefined;
102
+ }
103
+ onRefreshComplete?.(panelId, refreshTick);
104
+ }
105
+ };
106
+ if (refreshTick) {
107
+ activeRefreshTick.current = refreshTick;
108
+ }
127
109
  if (!view?.query) {
128
- return;
110
+ completeRefresh();
111
+ return () => controller.abort();
129
112
  }
130
113
  const loadingTimeout = setTimeout(() => setLoading(true), 200);
131
- void refreshView().finally(() => {
114
+ const query = view.query;
115
+ const indexes = view.indexes;
116
+ const sort = view.sort;
117
+ const span = view.span;
118
+ void dispatchApi(api.v2.search.post((indexes ?? ['hit']), {
119
+ query,
120
+ rows: limit,
121
+ sort,
122
+ filters: span ? [`event.created:${convertDateToLucene(span)}`] : [],
123
+ metadata: ['analytic']
124
+ }, controller.signal))
125
+ .then(response => {
126
+ if (controller.signal.aborted || !response) {
127
+ return;
128
+ }
129
+ const fetchedRecords = response.items ?? [];
130
+ loadRecords(fetchedRecords);
131
+ setRecordIds(fetchedRecords.map(record => record.howler.id));
132
+ lastSignature.current = createSignatureFromRecords(fetchedRecords);
133
+ })
134
+ .catch(() => {
135
+ // Aborted requests are expected when newer view inputs supersede this search.
136
+ })
137
+ .finally(() => {
132
138
  clearTimeout(loadingTimeout);
133
- setLoading(false);
139
+ if (!controller.signal.aborted) {
140
+ setLoading(false);
141
+ completeRefresh();
142
+ }
134
143
  });
135
144
  return () => {
145
+ controller.abort();
136
146
  clearTimeout(loadingTimeout);
137
147
  };
138
- }, [view?.query, limit, refreshView]);
148
+ }, [dispatchApi, limit, loadRecords, onRefreshComplete, panelId, refreshTick, signatureRefresh, view]);
139
149
  // Monitor hits currently in the view for changes that might affect query results
140
150
  useEffect(() => {
141
151
  if (!recordsSignature || recordIds.length === 0 || !lastSignature.current) {
@@ -148,13 +158,15 @@ const ViewCard = ({ viewId, limit, refreshTick, onRefreshComplete }) => {
148
158
  }
149
159
  debouncedRefresh();
150
160
  }, [recordsSignature, recordIds, debouncedRefresh]);
151
- useEffect(() => {
152
- return () => {
153
- if (debounceTimerRef.current) {
154
- clearTimeout(debounceTimerRef.current);
155
- }
156
- };
157
- }, []);
161
+ useEffect(() => () => {
162
+ if (debounceTimerRef.current) {
163
+ clearTimeout(debounceTimerRef.current);
164
+ }
165
+ if (activeRefreshTick.current) {
166
+ onRefreshComplete?.(panelId, activeRefreshTick.current);
167
+ activeRefreshTick.current = undefined;
168
+ }
169
+ }, [onRefreshComplete, panelId]);
158
170
  const onClick = useCallback((query) => navigate('/hits?query=' + query), [navigate]);
159
171
  const getSelectedId = useCallback((event) => {
160
172
  const target = event.target;
@@ -113,7 +113,7 @@ describe('ViewCard', () => {
113
113
  sort: mockView.sort,
114
114
  filters: [`event.created:[now-1M TO now]`],
115
115
  metadata: ['analytic']
116
- });
116
+ }, expect.any(AbortSignal));
117
117
  });
118
118
  it('renders the empty state when the search has no records', async () => {
119
119
  mockDispatchApi.mockResolvedValue({ items: [] });
@@ -138,9 +138,35 @@ describe('ViewCard', () => {
138
138
  mockDispatchApi.mockResolvedValue({ items: [] });
139
139
  const { rerender } = render(_jsx(ViewCard, { viewId: "view-1", limit: 3, refreshTick: Symbol('first'), onRefreshComplete: onRefreshComplete }), { wrapper: Wrapper });
140
140
  await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledTimes(1));
141
- expect(onRefreshComplete).toHaveBeenCalledTimes(2);
141
+ expect(onRefreshComplete).toHaveBeenCalledTimes(1);
142
142
  rerender(_jsx(ViewCard, { viewId: "view-1", limit: 3, refreshTick: Symbol('second'), onRefreshComplete: onRefreshComplete }));
143
143
  await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledTimes(2));
144
- expect(onRefreshComplete).toHaveBeenCalledTimes(3);
144
+ expect(onRefreshComplete).toHaveBeenCalledTimes(2);
145
+ });
146
+ it('aborts a superseded search and ignores its late response', async () => {
147
+ let resolveFirst;
148
+ let resolveSecond;
149
+ mockDispatchApi
150
+ .mockReturnValueOnce(new Promise(resolve => (resolveFirst = resolve)))
151
+ .mockReturnValueOnce(new Promise(resolve => (resolveSecond = resolve)));
152
+ const { rerender } = render(_jsx(ViewCard, { viewId: "view-1", limit: 3 }), { wrapper: Wrapper });
153
+ await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledTimes(1));
154
+ const firstSignal = mockSearchPost.mock.calls[0][2];
155
+ rerender(_jsx(ViewCard, { viewId: "view-1", limit: 4 }));
156
+ await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledTimes(2));
157
+ expect(firstSignal.aborted).toBe(true);
158
+ resolveSecond?.({ items: [{ ...hit, howler: { ...hit.howler, id: 'latest-hit' } }] });
159
+ expect(await screen.findByText('Hit: latest-hit')).toBeInTheDocument();
160
+ resolveFirst?.({ items: [hit] });
161
+ await waitFor(() => expect(screen.queryByText('Hit: hit-1')).not.toBeInTheDocument());
162
+ expect(screen.getByText('Hit: latest-hit')).toBeInTheDocument();
163
+ });
164
+ it('aborts its search when unmounted', async () => {
165
+ mockDispatchApi.mockReturnValue(new Promise(() => { }));
166
+ const { unmount } = render(_jsx(ViewCard, { viewId: "view-1", limit: 3 }), { wrapper: Wrapper });
167
+ await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledTimes(1));
168
+ const signal = mockSearchPost.mock.calls[0][2];
169
+ unmount();
170
+ expect(signal.aborted).toBe(true);
145
171
  });
146
172
  });
@@ -1,18 +1,23 @@
1
1
  /**
2
2
  * Imperative handle exposed to the parent via ref.
3
- * The parent calls `handleRefreshComplete` once each ViewCard finishes its data fetch,
4
- * allowing ViewRefresh to track how many cards are still in flight.
3
+ * The parent calls `handleRefreshComplete` once each refreshable panel finishes its data fetch,
4
+ * allowing ViewRefresh to track how many panels are still in flight.
5
5
  */
6
6
  export interface ViewRefreshHandle {
7
- handleRefreshComplete: () => void;
7
+ handleRefreshComplete: (panelId: string, refreshTick: symbol) => void;
8
+ updateViewCardIds: (viewCardIds: string[]) => void;
9
+ updateRefreshRate: () => void;
8
10
  }
9
11
  interface ViewRefreshProps {
10
- /** Auto-refresh interval in seconds (e.g. 15, 30, 60, 300). */
11
- refreshRate: number;
12
- /** Number of ViewCards currently on the dashboard. Used to track pending fetches. */
13
- viewCardCount: number;
14
- /** Called when a refresh cycle begins. Should update `refreshTick` in the parent to signal ViewCards. */
15
- onRefresh: () => void;
12
+ /** Stable refs let the AppBar-registered node read current Home dashboard values. */
13
+ refreshRateRef: {
14
+ current: number;
15
+ };
16
+ viewCardIdsRef: {
17
+ current: string[];
18
+ };
19
+ /** Called when a refresh cycle begins. Should update `refreshTick` in the parent to signal panels. */
20
+ onRefresh: (refreshTick: symbol) => void;
16
21
  }
17
22
  /**
18
23
  * Self-contained refresh control that owns the countdown timer and refreshing state.
@@ -8,50 +8,93 @@ import { useTranslation } from 'react-i18next';
8
8
  * Isolating this state here prevents the progress ticker (which fires every `refreshRate * 10`ms)
9
9
  * from causing unnecessary re-renders in the parent Home component.
10
10
  */
11
- const ViewRefresh = forwardRef(({ refreshRate, viewCardCount, onRefresh }, ref) => {
11
+ const ViewRefresh = forwardRef(({ refreshRateRef, viewCardIdsRef, onRefresh }, ref) => {
12
12
  const { t } = useTranslation();
13
13
  const [progress, setProgress] = useState(0);
14
14
  const [isRefreshing, setIsRefreshing] = useState(false);
15
- const pendingRefreshes = useRef(0);
15
+ const [refreshRateVersion, setRefreshRateVersion] = useState(0);
16
+ const pendingRefreshes = useRef(new Set());
17
+ const registeredCardIds = useRef(new Set());
18
+ const activeRefreshTick = useRef(undefined);
16
19
  const timerRef = useRef(null);
20
+ useEffect(() => {
21
+ registeredCardIds.current = new Set(viewCardIdsRef.current);
22
+ }, [viewCardIdsRef]);
17
23
  /**
18
- * Called by the parent (via ref) each time a ViewCard finishes fetching.
19
- * Clears the refreshing state once all cards have reported back.
24
+ * Called by the parent (via ref) when a refreshable dashboard panel finishes fetching.
25
+ * Clears the refreshing state once all pending panels have reported back.
20
26
  */
21
- const handleRefreshComplete = useCallback(() => {
22
- pendingRefreshes.current -= 1;
23
- if (pendingRefreshes.current <= 0) {
27
+ const handleRefreshComplete = useCallback((panelId, refreshTick) => {
28
+ if (refreshTick !== activeRefreshTick.current || !pendingRefreshes.current.delete(panelId)) {
29
+ return;
30
+ }
31
+ if (pendingRefreshes.current.size === 0) {
32
+ activeRefreshTick.current = undefined;
24
33
  setIsRefreshing(false);
25
34
  setProgress(0);
26
35
  }
27
36
  }, []);
28
- // Expose handleRefreshComplete to the parent without leaking the rest of the component's state.
29
- useImperativeHandle(ref, () => ({ handleRefreshComplete }), [handleRefreshComplete]);
37
+ const updateViewCardIds = useCallback((viewCardIds) => {
38
+ const nextCardIds = new Set(viewCardIds);
39
+ if (activeRefreshTick.current) {
40
+ for (const pendingPanelId of pendingRefreshes.current) {
41
+ if (!nextCardIds.has(pendingPanelId)) {
42
+ pendingRefreshes.current.delete(pendingPanelId);
43
+ }
44
+ }
45
+ for (const panelId of nextCardIds) {
46
+ if (!registeredCardIds.current.has(panelId)) {
47
+ pendingRefreshes.current.add(panelId);
48
+ }
49
+ }
50
+ if (pendingRefreshes.current.size === 0) {
51
+ activeRefreshTick.current = undefined;
52
+ setIsRefreshing(false);
53
+ setProgress(0);
54
+ }
55
+ }
56
+ registeredCardIds.current = nextCardIds;
57
+ }, []);
58
+ const updateRefreshRate = useCallback(() => {
59
+ setRefreshRateVersion(version => version + 1);
60
+ }, []);
61
+ // Expose completion and synchronization methods without leaking the rest of the component's state.
62
+ useImperativeHandle(ref, () => ({ handleRefreshComplete, updateViewCardIds, updateRefreshRate }), [
63
+ handleRefreshComplete,
64
+ updateRefreshRate,
65
+ updateViewCardIds
66
+ ]);
30
67
  const triggerRefresh = useCallback(() => {
68
+ const refreshTick = Symbol();
69
+ const viewCardIds = viewCardIdsRef.current;
31
70
  setIsRefreshing(true);
32
- pendingRefreshes.current = viewCardCount;
33
- if (viewCardCount === 0) {
71
+ activeRefreshTick.current = refreshTick;
72
+ pendingRefreshes.current = new Set(viewCardIds);
73
+ registeredCardIds.current = new Set(viewCardIds);
74
+ if (viewCardIds.length === 0) {
75
+ activeRefreshTick.current = undefined;
34
76
  setIsRefreshing(false);
35
77
  setProgress(0);
36
78
  return;
37
79
  }
38
- onRefresh();
39
- }, [viewCardCount, onRefresh]);
80
+ onRefresh(refreshTick);
81
+ }, [onRefresh, viewCardIdsRef]);
40
82
  useEffect(() => {
41
83
  if (isRefreshing)
42
84
  return;
43
- if (progress >= 100) {
44
- triggerRefresh();
45
- return;
46
- }
47
85
  timerRef.current = setTimeout(() => {
48
- setProgress(prev => prev + 1);
49
- }, refreshRate * 10);
86
+ if (progress >= 99) {
87
+ triggerRefresh();
88
+ }
89
+ else {
90
+ setProgress(currentProgress => currentProgress + 1);
91
+ }
92
+ }, refreshRateRef.current * 10);
50
93
  return () => {
51
94
  if (timerRef.current)
52
95
  clearTimeout(timerRef.current);
53
96
  };
54
- }, [progress, isRefreshing, refreshRate, triggerRefresh]);
97
+ }, [progress, isRefreshing, refreshRateRef, refreshRateVersion, triggerRefresh]);
55
98
  return (_jsxs(Box, { sx: { position: 'relative', display: 'inline-flex' }, children: [isRefreshing ? (_jsx(CircularProgress, { variant: "indeterminate", size: 32 })) : (_jsx(CircularProgress, { variant: "determinate", value: progress, size: 32 })), _jsx(Box, { sx: {
56
99
  top: 0,
57
100
  left: 0,