@cccsaurora/howler-ui 2.14.0-dev.274 → 2.14.0-dev.277

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.
@@ -101,11 +101,11 @@ const ViewProvider = ({ children }) => {
101
101
  });
102
102
  }, [appUser, dispatchApi]);
103
103
  const removeView = useCallback(async (id) => {
104
- const result = await dispatchApi(api.view.del(id));
105
- setViews(_views => omit(_views, id));
106
104
  if (appUser.user?.favourite_views.includes(id)) {
107
- removeFavourite(id);
105
+ await removeFavourite(id);
108
106
  }
107
+ const result = await dispatchApi(api.view.del(id));
108
+ setViews(_views => omit(_views, id));
109
109
  return result;
110
110
  }, [appUser.user?.favourite_views, dispatchApi, removeFavourite]);
111
111
  return (_jsx(ViewContext.Provider, { value: {
@@ -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:${analytic.name} AND _exists_:howler.comment`,
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.get('filter') || c.detection === searchParams.get('filter'))
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,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
+ });
@@ -113,7 +113,8 @@ const ViewComposer = () => {
113
113
  rows: pageCount,
114
114
  query: _query,
115
115
  sort,
116
- filters: span ? [`event.created:${convertDateToLucene(span)}`] : []
116
+ filters: span ? [`event.created:${convertDateToLucene(span)}`] : [],
117
+ metadata: ['template', 'analytic']
117
118
  }), { showError: false, throwError: true });
118
119
  loadHits(_response.items);
119
120
  setResponse(_response);
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.14.0-dev.274",
99
+ "version": "2.14.0-dev.277",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",