@cccsaurora/howler-ui 2.14.0-dev.275 → 2.14.0-dev.280
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.
|
@@ -7,6 +7,7 @@ import useMyUserList from '@cccsaurora/howler-ui/components/hooks/useMyUserList'
|
|
|
7
7
|
import { useEffect, useMemo, useState } from 'react';
|
|
8
8
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
9
9
|
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
10
|
+
import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
10
11
|
import { compareTimestamp } from '@cccsaurora/howler-ui/utils/utils';
|
|
11
12
|
const AnalyticHitComments = ({ analytic }) => {
|
|
12
13
|
const [searchParams] = useSearchParams();
|
|
@@ -26,7 +27,7 @@ const AnalyticHitComments = ({ analytic }) => {
|
|
|
26
27
|
setLoading(true);
|
|
27
28
|
api.search.hit
|
|
28
29
|
.post({
|
|
29
|
-
query: `howler.analytic
|
|
30
|
+
query: `howler.analytic:"${sanitizeLuceneQuery(analytic.name)}" AND _exists_:howler.comment`,
|
|
30
31
|
rows: pageCount
|
|
31
32
|
})
|
|
32
33
|
.then(response => {
|
|
@@ -41,7 +42,7 @@ const AnalyticHitComments = ({ analytic }) => {
|
|
|
41
42
|
});
|
|
42
43
|
}, [analytic, pageCount]);
|
|
43
44
|
const commentEls = useMemo(() => loading ? (_jsx(LinearProgress, {})) : (comments
|
|
44
|
-
.filter(c => !searchParams.
|
|
45
|
+
.filter(c => !searchParams.has('filter') || c.detection === searchParams.get('filter'))
|
|
45
46
|
.sort((a, b) => compareTimestamp(b.comment.timestamp, a.comment.timestamp))
|
|
46
47
|
.map(c => (_jsx(Comment, { comment: c.comment, users: users, onClick: () => navigate(`/hits/${c.hitId}`), extra: !searchParams.get('filter') && (_jsx(Chip, { size: "small", sx: theme => ({ marginLeft: '0 !important', mr: `${theme.spacing(2)} !important` }), label: c.detection ?? 'Analytic' })) }, c.comment.id)))), [loading, comments, searchParams, users, navigate]);
|
|
47
48
|
return (_jsxs(Stack, { direction: "column", py: 2, spacing: 1, children: [_jsx(Divider, { orientation: "horizontal", flexItem: true }), commentEls] }));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { omit } from 'lodash-es';
|
|
4
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
5
|
+
import MockLocalStorage from '@cccsaurora/howler-ui/tests/MockLocalStorage';
|
|
6
|
+
import { MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
7
|
+
import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
8
|
+
import AnalyticHitComments from './AnalyticHitComments';
|
|
9
|
+
// Mock the API
|
|
10
|
+
const mockApiSearchHitPost = vi.fn();
|
|
11
|
+
vi.mock('@cccsaurora/howler-ui/api', () => ({
|
|
12
|
+
default: {
|
|
13
|
+
search: {
|
|
14
|
+
hit: {
|
|
15
|
+
post: (...args) => mockApiSearchHitPost(...args)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
// Mock local storage
|
|
21
|
+
const mockLocalStorage = new MockLocalStorage();
|
|
22
|
+
Object.defineProperty(window, 'localStorage', {
|
|
23
|
+
value: mockLocalStorage,
|
|
24
|
+
writable: true
|
|
25
|
+
});
|
|
26
|
+
// Mock hooks
|
|
27
|
+
vi.mock('@cccsaurora/howler-ui/components/hooks/useMyLocalStorage', () => ({
|
|
28
|
+
useMyLocalStorageItem: (key, defaultValue) => {
|
|
29
|
+
const storageKey = `${MY_LOCAL_STORAGE_PREFIX}.${key}`;
|
|
30
|
+
const storedValue = mockLocalStorage.getItem(storageKey);
|
|
31
|
+
const value = storedValue ? JSON.parse(storedValue) : defaultValue;
|
|
32
|
+
return [value, vi.fn()];
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
vi.mock('@cccsaurora/howler-ui/components/hooks/useMyUserList', () => ({
|
|
36
|
+
default: (userIds) => {
|
|
37
|
+
const userMap = {};
|
|
38
|
+
Array.from(userIds).forEach(id => {
|
|
39
|
+
userMap[id] = { username: `user_${id}`, name: `User ${id}` };
|
|
40
|
+
});
|
|
41
|
+
return userMap;
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
// Mock Comment component
|
|
45
|
+
vi.mock('@cccsaurora/howler-ui/components/elements/Comment', () => ({
|
|
46
|
+
default: ({ comment, onClick, extra }) => (_jsxs("div", { id: `comment-${comment.id}`, onClick: onClick, children: [_jsxs("span", { id: `comment-user-${comment.id}`, children: ['Comment by ', comment.user] }), _jsx("span", { id: `comment-text-${comment.id}`, children: comment.text }), extra] }))
|
|
47
|
+
}));
|
|
48
|
+
// Mock MUI components
|
|
49
|
+
vi.mock('@mui/material', () => ({
|
|
50
|
+
Chip: ({ label, ...props }) => (_jsx("span", { role: "detection-chip", ...omit(props, ['flexItem', 'sx']), children: label })),
|
|
51
|
+
Divider: ({ ...props }) => _jsx("hr", { id: "divider", ...omit(props, ['flexItem', 'sx']) }),
|
|
52
|
+
LinearProgress: ({ ...props }) => _jsx("div", { role: "progressbar", id: "loading", ...omit(props, ['flexItem', 'sx']) }),
|
|
53
|
+
Stack: ({ children, ...props }) => (_jsx("div", { id: "stack", ...omit(props, ['flexItem', 'sx']), children: children }))
|
|
54
|
+
}));
|
|
55
|
+
// Mock utils
|
|
56
|
+
vi.mock('@cccsaurora/howler-ui/utils/utils', () => ({
|
|
57
|
+
compareTimestamp: (a, b) => new Date(b).getTime() - new Date(a).getTime()
|
|
58
|
+
}));
|
|
59
|
+
// Mock react-router-dom
|
|
60
|
+
const mockNavigate = vi.fn();
|
|
61
|
+
vi.mock('react-router-dom', async () => {
|
|
62
|
+
const actual = await vi.importActual('react-router-dom');
|
|
63
|
+
return {
|
|
64
|
+
...actual,
|
|
65
|
+
useNavigate: () => mockNavigate
|
|
66
|
+
// useSearchParams: () => [new URLSearchParams()]
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
// Mock data
|
|
70
|
+
const mockAnalytic = {
|
|
71
|
+
name: 'test-analytic',
|
|
72
|
+
description: 'Test analytic description'
|
|
73
|
+
};
|
|
74
|
+
const mockHitResponse = {
|
|
75
|
+
items: [
|
|
76
|
+
{
|
|
77
|
+
howler: {
|
|
78
|
+
id: 'hit1',
|
|
79
|
+
detection: 'Detection 1',
|
|
80
|
+
comment: [
|
|
81
|
+
{
|
|
82
|
+
id: 'comment1',
|
|
83
|
+
user: 'user1',
|
|
84
|
+
text: 'First comment',
|
|
85
|
+
timestamp: '2023-11-01T10:00:00Z'
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'comment2',
|
|
89
|
+
user: 'user2',
|
|
90
|
+
text: 'Second comment',
|
|
91
|
+
timestamp: '2023-11-01T11:00:00Z'
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
howler: {
|
|
98
|
+
id: 'hit2',
|
|
99
|
+
detection: 'Detection 2',
|
|
100
|
+
comment: [
|
|
101
|
+
{
|
|
102
|
+
id: 'comment3',
|
|
103
|
+
user: 'user1',
|
|
104
|
+
text: 'Third comment',
|
|
105
|
+
timestamp: '2023-11-01T12:00:00Z'
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
};
|
|
112
|
+
const Wrapper = ({ children, searchParams = '' }) => (_jsx(MemoryRouter, { initialEntries: [`/analytics/test?${searchParams}`], children: children }));
|
|
113
|
+
describe('AnalyticHitComments', () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
mockLocalStorage.clear();
|
|
116
|
+
mockApiSearchHitPost.mockClear();
|
|
117
|
+
mockApiSearchHitPost.mockResolvedValue(mockHitResponse);
|
|
118
|
+
mockNavigate.mockClear();
|
|
119
|
+
// Set default page count
|
|
120
|
+
mockLocalStorage.setItem(`${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.PAGE_COUNT}`, JSON.stringify(25));
|
|
121
|
+
});
|
|
122
|
+
describe('Lucene query usage', () => {
|
|
123
|
+
it('should use the correct Lucene query with analytic name', async () => {
|
|
124
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(mockApiSearchHitPost).toHaveBeenCalledWith({
|
|
127
|
+
query: `howler.analytic:"${sanitizeLuceneQuery(mockAnalytic.name)}" AND _exists_:howler.comment`,
|
|
128
|
+
rows: 25
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
it('should use the page count from local storage in the query', async () => {
|
|
133
|
+
// Set a custom page count
|
|
134
|
+
mockLocalStorage.setItem(`${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.PAGE_COUNT}`, JSON.stringify(50));
|
|
135
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(mockApiSearchHitPost).toHaveBeenCalledWith({
|
|
138
|
+
query: `howler.analytic:"${sanitizeLuceneQuery(mockAnalytic.name)}" AND _exists_:howler.comment`,
|
|
139
|
+
rows: 50
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
it('should not make API call when no analytic is provided', async () => {
|
|
144
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: null }) }));
|
|
145
|
+
// Wait a bit to ensure no call is made
|
|
146
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
147
|
+
expect(mockApiSearchHitPost).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
it('should show loading state while fetching data', async () => {
|
|
150
|
+
// Make the API call take longer
|
|
151
|
+
let resolvePromise;
|
|
152
|
+
const delayedPromise = new Promise(resolve => {
|
|
153
|
+
resolvePromise = resolve;
|
|
154
|
+
});
|
|
155
|
+
mockApiSearchHitPost.mockReturnValue(delayedPromise);
|
|
156
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
157
|
+
// Should show loading indicator
|
|
158
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
|
159
|
+
// Resolve the promise
|
|
160
|
+
resolvePromise(mockHitResponse);
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('Comment rendering', () => {
|
|
167
|
+
it('should render all comments from the API response', async () => {
|
|
168
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
await waitFor(() => {
|
|
173
|
+
expect(screen.getByTestId('comment-comment1')).toBeInTheDocument();
|
|
174
|
+
expect(screen.getByTestId('comment-comment2')).toBeInTheDocument();
|
|
175
|
+
expect(screen.getByTestId('comment-comment3')).toBeInTheDocument();
|
|
176
|
+
});
|
|
177
|
+
// Check comment content using more specific selectors
|
|
178
|
+
expect(screen.getByTestId('comment-user-comment1')).toHaveTextContent('Comment by user1');
|
|
179
|
+
expect(screen.getByTestId('comment-text-comment1')).toHaveTextContent('First comment');
|
|
180
|
+
expect(screen.getByTestId('comment-user-comment2')).toHaveTextContent('Comment by user2');
|
|
181
|
+
expect(screen.getByTestId('comment-text-comment2')).toHaveTextContent('Second comment');
|
|
182
|
+
expect(screen.getByTestId('comment-text-comment3')).toHaveTextContent('Third comment');
|
|
183
|
+
});
|
|
184
|
+
it('should render comments sorted by timestamp in descending order', async () => {
|
|
185
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(screen.getByTestId('comment-comment1')).toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
const comments = screen.getAllByTestId(/comment-comment/);
|
|
190
|
+
// Based on timestamps, comment3 (12:00) should be first, then comment2 (11:00), then comment1 (10:00)
|
|
191
|
+
expect(comments[0]).toHaveAttribute('id', 'comment-comment1');
|
|
192
|
+
expect(comments[1]).toHaveAttribute('id', 'comment-comment2');
|
|
193
|
+
expect(comments[2]).toHaveAttribute('id', 'comment-comment3');
|
|
194
|
+
});
|
|
195
|
+
it('should render detection chips when no filter is applied', async () => {
|
|
196
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
197
|
+
await waitFor(() => {
|
|
198
|
+
expect(screen.getByTestId('comment-comment1')).toBeInTheDocument();
|
|
199
|
+
});
|
|
200
|
+
// Look for detection chips
|
|
201
|
+
const detectionChips = screen.getAllByRole('detection-chip');
|
|
202
|
+
expect(detectionChips.length).toEqual(3);
|
|
203
|
+
});
|
|
204
|
+
it('should filter comments by detection when filter parameter is present', async () => {
|
|
205
|
+
render(_jsx(Wrapper, { searchParams: "filter=Detection 1", children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
206
|
+
await waitFor(() => {
|
|
207
|
+
// Should show comments from Detection 1 only
|
|
208
|
+
expect(screen.getByTestId('comment-comment1')).toBeInTheDocument();
|
|
209
|
+
expect(screen.getByTestId('comment-comment2')).toBeInTheDocument();
|
|
210
|
+
expect(screen.queryByTestId('comment-comment3')).not.toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
it('should not render detection chips when filter is applied', async () => {
|
|
214
|
+
render(_jsx(Wrapper, { searchParams: "filter=Detection 1", children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(screen.getByTestId('comment-comment1')).toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
// When filter is applied, no detection chips should be rendered
|
|
219
|
+
expect(screen.queryAllByTestId('detection-chip')).toHaveLength(0);
|
|
220
|
+
// Only comments from Detection 1 should be shown
|
|
221
|
+
const comments = screen.getAllByTestId(/comment-comment/);
|
|
222
|
+
expect(comments).toHaveLength(2); // Only comments from Detection 1
|
|
223
|
+
});
|
|
224
|
+
it('should handle empty response gracefully', async () => {
|
|
225
|
+
mockApiSearchHitPost.mockResolvedValue({ items: [] });
|
|
226
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(mockApiSearchHitPost).toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
// Should not render any comments
|
|
231
|
+
expect(screen.queryByTestId(/comment-comment/)).not.toBeInTheDocument();
|
|
232
|
+
// Should not show loading indicator
|
|
233
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
234
|
+
});
|
|
235
|
+
it('should navigate to hit page when comment is clicked', async () => {
|
|
236
|
+
render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
237
|
+
await waitFor(() => {
|
|
238
|
+
expect(screen.getByTestId('comment-comment1')).toBeInTheDocument();
|
|
239
|
+
});
|
|
240
|
+
// Click on a comment
|
|
241
|
+
screen.getByTestId('comment-comment1').click();
|
|
242
|
+
expect(mockNavigate).toHaveBeenCalledWith('/hits/hit1');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
describe('Component lifecycle', () => {
|
|
246
|
+
it('should refetch data when analytic changes', async () => {
|
|
247
|
+
const { rerender } = render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
248
|
+
await waitFor(() => {
|
|
249
|
+
expect(mockApiSearchHitPost).toHaveBeenCalledTimes(1);
|
|
250
|
+
});
|
|
251
|
+
// Change analytic
|
|
252
|
+
const newAnalytic = { name: 'new-analytic', description: 'New analytic' };
|
|
253
|
+
rerender(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: newAnalytic }) }));
|
|
254
|
+
await waitFor(() => {
|
|
255
|
+
expect(mockApiSearchHitPost).toHaveBeenCalledTimes(2);
|
|
256
|
+
expect(mockApiSearchHitPost).toHaveBeenLastCalledWith({
|
|
257
|
+
query: `howler.analytic:"${sanitizeLuceneQuery(newAnalytic.name)}" AND _exists_:howler.comment`,
|
|
258
|
+
rows: 25
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
it('should refetch data when page count changes', async () => {
|
|
263
|
+
const { rerender } = render(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
264
|
+
await waitFor(() => {
|
|
265
|
+
expect(mockApiSearchHitPost).toHaveBeenCalledTimes(1);
|
|
266
|
+
});
|
|
267
|
+
// Change page count in localStorage
|
|
268
|
+
mockLocalStorage.setItem(`${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.PAGE_COUNT}`, JSON.stringify(100));
|
|
269
|
+
// Force rerender to trigger useEffect
|
|
270
|
+
rerender(_jsx(Wrapper, { children: _jsx(AnalyticHitComments, { analytic: mockAnalytic }) }));
|
|
271
|
+
await waitFor(() => {
|
|
272
|
+
expect(mockApiSearchHitPost).toHaveBeenCalledTimes(2);
|
|
273
|
+
expect(mockApiSearchHitPost).toHaveBeenLastCalledWith({
|
|
274
|
+
query: `howler.analytic:"${sanitizeLuceneQuery(mockAnalytic.name)}" AND _exists_:howler.comment`,
|
|
275
|
+
rows: 100
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|