@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.
- package/api/index.d.ts +3 -3
- package/api/index.js +7 -7
- package/api/index.test.js +6 -6
- package/api/search/user.d.ts +1 -1
- package/api/v2/index.d.ts +2 -1
- package/api/v2/index.js +2 -1
- package/api/v2/index.test.js +3 -1
- package/api/v2/search/index.d.ts +1 -1
- package/api/v2/search/index.js +2 -2
- package/api/v2/search/index.test.js +2 -2
- package/api/v2/task/index.d.ts +20 -0
- package/api/v2/task/index.js +12 -0
- package/api/v2/task/index.test.d.ts +1 -0
- package/api/v2/task/index.test.js +26 -0
- package/components/app/providers/FieldProvider.js +22 -8
- package/components/app/providers/FieldProvider.test.js +20 -2
- package/components/app/providers/RecordSearchProvider.test.js +22 -17
- package/components/elements/hit/HitSummary.js +7 -6
- package/components/elements/hit/HitSummary.test.js +19 -5
- package/components/hooks/useMyApi.js +3 -0
- package/components/hooks/useMyUserFunctions.d.ts +5 -5
- package/components/routes/hits/search/shared/HitFilter.js +19 -2
- package/components/routes/hits/search/shared/HitFilter.test.js +87 -20
- package/components/routes/home/AddNewCard.js +15 -2
- package/components/routes/home/CasePanel.d.ts +16 -0
- package/components/routes/home/CasePanel.js +105 -0
- package/components/routes/home/CasePanel.test.d.ts +1 -0
- package/components/routes/home/CasePanel.test.js +86 -0
- package/components/routes/home/TasksPanel.d.ts +13 -0
- package/components/routes/home/TasksPanel.js +129 -0
- package/components/routes/home/TasksPanel.test.d.ts +1 -0
- package/components/routes/home/TasksPanel.test.js +173 -0
- package/components/routes/home/ViewCard.d.ts +2 -1
- package/components/routes/home/ViewCard.js +60 -48
- package/components/routes/home/ViewCard.test.js +29 -3
- package/components/routes/home/ViewRefresh.d.ts +14 -9
- package/components/routes/home/ViewRefresh.js +63 -20
- package/components/routes/home/ViewRefresh.test.js +69 -22
- package/components/routes/home/index.js +39 -10
- package/components/routes/home/index.test.js +9 -1
- package/components/routes/views/ViewComposer.test.js +1 -0
- package/locales/en/translation.json +16 -0
- package/locales/fr/translation.json +16 -0
- package/models/entities/HowlerUser.d.ts +1 -1
- package/package.json +3 -1
- package/rest/AxiosClient.d.ts +1 -1
- package/rest/AxiosClient.js +3 -2
- package/rest/AxiosClient.test.js +6 -0
- package/rest/FetchClient.d.ts +1 -1
- package/rest/FetchClient.js +3 -2
- package/rest/FetchClient.test.js +6 -0
- package/rest/index.d.ts +1 -1
|
@@ -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
|
|
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
|
-
|
|
88
|
+
setSignatureRefresh(previous => previous + 1);
|
|
115
89
|
}, DEBOUNCE_TIME);
|
|
116
|
-
}, [
|
|
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
|
-
|
|
110
|
+
completeRefresh();
|
|
111
|
+
return () => controller.abort();
|
|
129
112
|
}
|
|
130
113
|
const loadingTimeout = setTimeout(() => setLoading(true), 200);
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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(
|
|
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(
|
|
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
|
|
4
|
-
* allowing ViewRefresh to track how many
|
|
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
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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(({
|
|
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
|
|
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)
|
|
19
|
-
* Clears the refreshing state once all
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
49
|
-
|
|
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,
|
|
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,
|