@cccsaurora/howler-ui 3.1.0-dev.1708 → 3.1.0-dev.1712
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/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
package/api/index.d.ts
CHANGED
|
@@ -92,7 +92,7 @@ export declare const setHeaders: (ifMatch?: string) => HeadersInit;
|
|
|
92
92
|
* @param body - the body of the request.
|
|
93
93
|
* @returns the `api_response` object of the returned {@link HowlerResponse}.
|
|
94
94
|
*/
|
|
95
|
-
export declare const hfetch: <R>(_uri: string, method?: 'get' | 'post' | 'put' | 'delete' | 'patch', body?: unknown, searchParams?: URLSearchParams, requestHeaders?: HeadersInit) => Promise<R | undefined>;
|
|
95
|
+
export declare const hfetch: <R>(_uri: string, method?: 'get' | 'post' | 'put' | 'delete' | 'patch', body?: unknown, searchParams?: URLSearchParams, requestHeaders?: HeadersInit, signal?: AbortSignal) => Promise<R | undefined>;
|
|
96
96
|
/**
|
|
97
97
|
* Perform an HTTP GET for the specified uri.
|
|
98
98
|
*
|
|
@@ -101,7 +101,7 @@ export declare const hfetch: <R>(_uri: string, method?: 'get' | 'post' | 'put' |
|
|
|
101
101
|
* @param _uri - the uri to fetch.
|
|
102
102
|
* @returns the `api_response` object of the returned {@link HowlerResponse}.
|
|
103
103
|
*/
|
|
104
|
-
export declare const hget: <R = any>(_uri: string, searchParams?: URLSearchParams, headers?: HeadersInit) => Promise<R | undefined>;
|
|
104
|
+
export declare const hget: <R = any>(_uri: string, searchParams?: URLSearchParams, headers?: HeadersInit, signal?: AbortSignal) => Promise<R | undefined>;
|
|
105
105
|
/**
|
|
106
106
|
* Perform an HTTP POST for the specified uri and body data.
|
|
107
107
|
*
|
|
@@ -111,7 +111,7 @@ export declare const hget: <R = any>(_uri: string, searchParams?: URLSearchParam
|
|
|
111
111
|
* @param body - the body of the request.
|
|
112
112
|
* @returns the `api_response` object of the returned {@link HowlerResponse}.
|
|
113
113
|
*/
|
|
114
|
-
export declare const hpost: <R = any>(_uri: string, body: unknown, headers?: HeadersInit, searchParams?: URLSearchParams) => Promise<R | undefined>;
|
|
114
|
+
export declare const hpost: <R = any>(_uri: string, body: unknown, headers?: HeadersInit, searchParams?: URLSearchParams, signal?: AbortSignal) => Promise<R | undefined>;
|
|
115
115
|
/**
|
|
116
116
|
* Peform an HTTP PUT for the specified uri and body data.
|
|
117
117
|
*
|
package/api/index.js
CHANGED
|
@@ -129,7 +129,7 @@ const getEtagUrl = (_uri) => {
|
|
|
129
129
|
* @param body - the body of the request.
|
|
130
130
|
* @returns the `api_response` object of the returned {@link HowlerResponse}.
|
|
131
131
|
*/
|
|
132
|
-
export const hfetch = async (_uri, method = 'get', body, searchParams, requestHeaders) => {
|
|
132
|
+
export const hfetch = async (_uri, method = 'get', body, searchParams, requestHeaders, signal) => {
|
|
133
133
|
const authToken = getLocalStored(StorageKey.APP_TOKEN);
|
|
134
134
|
const xsrfToken = getXSRFCookie();
|
|
135
135
|
const etags = getSessionStored(StorageKey.ETAG) || {};
|
|
@@ -144,7 +144,7 @@ export const hfetch = async (_uri, method = 'get', body, searchParams, requestHe
|
|
|
144
144
|
requestHeaders = { ...requestHeaders, Authorization: `Bearer ${authToken}` };
|
|
145
145
|
}
|
|
146
146
|
// Wait for it ....
|
|
147
|
-
const [json, statusCode, responseHeader] = await client.fetch(format(_uri), method, body, searchParams, requestHeaders);
|
|
147
|
+
const [json, statusCode, responseHeader] = await client.fetch(format(_uri), method, body, searchParams, requestHeaders, signal);
|
|
148
148
|
if (responseHeader.etag) {
|
|
149
149
|
setSessionStored(StorageKey.ETAG, { ...etags, [getEtagUrl(_uri)]: responseHeader.etag });
|
|
150
150
|
}
|
|
@@ -168,7 +168,7 @@ export const hfetch = async (_uri, method = 'get', body, searchParams, requestHe
|
|
|
168
168
|
const refreshResponse = await api.auth.login.post({ refresh_token: refreshToken, provider: provider });
|
|
169
169
|
if (refreshResponse) {
|
|
170
170
|
saveLoginCredential(refreshResponse);
|
|
171
|
-
const result = await hfetch(_uri, method, body, searchParams);
|
|
171
|
+
const result = await hfetch(_uri, method, body, searchParams, undefined, signal);
|
|
172
172
|
removeLocalStored(StorageKey.NEXT_LOCATION);
|
|
173
173
|
removeLocalStored(StorageKey.NEXT_SEARCH);
|
|
174
174
|
return result;
|
|
@@ -193,8 +193,8 @@ export const hfetch = async (_uri, method = 'get', body, searchParams, requestHe
|
|
|
193
193
|
* @param _uri - the uri to fetch.
|
|
194
194
|
* @returns the `api_response` object of the returned {@link HowlerResponse}.
|
|
195
195
|
*/
|
|
196
|
-
export const hget = (_uri, searchParams, headers = {}) => {
|
|
197
|
-
return hfetch(_uri, 'get', null, searchParams, headers);
|
|
196
|
+
export const hget = (_uri, searchParams, headers = {}, signal) => {
|
|
197
|
+
return hfetch(_uri, 'get', null, searchParams, headers, signal);
|
|
198
198
|
};
|
|
199
199
|
/**
|
|
200
200
|
* Perform an HTTP POST for the specified uri and body data.
|
|
@@ -205,8 +205,8 @@ export const hget = (_uri, searchParams, headers = {}) => {
|
|
|
205
205
|
* @param body - the body of the request.
|
|
206
206
|
* @returns the `api_response` object of the returned {@link HowlerResponse}.
|
|
207
207
|
*/
|
|
208
|
-
export const hpost = (_uri, body, headers = {}, searchParams) => {
|
|
209
|
-
return hfetch(_uri, 'post', body, searchParams, headers);
|
|
208
|
+
export const hpost = (_uri, body, headers = {}, searchParams, signal) => {
|
|
209
|
+
return hfetch(_uri, 'post', body, searchParams, headers, signal);
|
|
210
210
|
};
|
|
211
211
|
/**
|
|
212
212
|
* Peform an HTTP PUT for the specified uri and body data.
|
package/api/index.test.js
CHANGED
|
@@ -81,7 +81,7 @@ describe('api root module', () => {
|
|
|
81
81
|
'If-Match': '"old-etag"',
|
|
82
82
|
'X-XSRF-TOKEN': 'csrf',
|
|
83
83
|
Authorization: expect.any(String)
|
|
84
|
-
}));
|
|
84
|
+
}), undefined);
|
|
85
85
|
expect(mockSetSessionStored).toHaveBeenCalledWith('etag', { 'hit/hit123': '"new-etag"' });
|
|
86
86
|
});
|
|
87
87
|
it('returns undefined for empty payloads and accepts 304 responses', async () => {
|
|
@@ -128,10 +128,10 @@ describe('api root module', () => {
|
|
|
128
128
|
await hput('/x', { ok: true }, { A: '1' }, new URLSearchParams({ b: '2' }));
|
|
129
129
|
await hpatch('/x', { ok: true }, { A: '1' }, new URLSearchParams({ b: '2' }));
|
|
130
130
|
await hdelete('/x', { ok: true }, { A: '1' }, new URLSearchParams({ b: '2' }));
|
|
131
|
-
expect(mockClientFetch).toHaveBeenNthCalledWith(1, '/api/v1//x', 'get', null, new URLSearchParams({ a: '1' }), expect.objectContaining({ Accept: 'json', 'Content-Type': 'application/json' }));
|
|
132
|
-
expect(mockClientFetch).toHaveBeenNthCalledWith(2, '/api/v1//x', 'post', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }));
|
|
133
|
-
expect(mockClientFetch).toHaveBeenNthCalledWith(3, '/api/v1//x', 'put', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }));
|
|
134
|
-
expect(mockClientFetch).toHaveBeenNthCalledWith(4, '/api/v1//x', 'patch', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }));
|
|
135
|
-
expect(mockClientFetch).toHaveBeenNthCalledWith(5, '/api/v1//x', 'delete', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }));
|
|
131
|
+
expect(mockClientFetch).toHaveBeenNthCalledWith(1, '/api/v1//x', 'get', null, new URLSearchParams({ a: '1' }), expect.objectContaining({ Accept: 'json', 'Content-Type': 'application/json' }), undefined);
|
|
132
|
+
expect(mockClientFetch).toHaveBeenNthCalledWith(2, '/api/v1//x', 'post', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }), undefined);
|
|
133
|
+
expect(mockClientFetch).toHaveBeenNthCalledWith(3, '/api/v1//x', 'put', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }), undefined);
|
|
134
|
+
expect(mockClientFetch).toHaveBeenNthCalledWith(4, '/api/v1//x', 'patch', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }), undefined);
|
|
135
|
+
expect(mockClientFetch).toHaveBeenNthCalledWith(5, '/api/v1//x', 'delete', { ok: true }, new URLSearchParams({ b: '2' }), expect.objectContaining({ A: '1', 'Content-Type': 'application/json' }), undefined);
|
|
136
136
|
});
|
|
137
137
|
});
|
package/api/search/user.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export declare const post: (request?: HowlerSearchRequest) => Promise<{
|
|
|
22
22
|
favourite_analytics?: string[];
|
|
23
23
|
dashboard?: {
|
|
24
24
|
entry_id: string;
|
|
25
|
-
type: 'view' | 'analytic';
|
|
25
|
+
type: 'view' | 'analytic' | 'case' | 'tasks';
|
|
26
26
|
config: string;
|
|
27
27
|
}[];
|
|
28
28
|
refresh_rate?: number;
|
package/api/v2/index.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
|
|
|
2
2
|
import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
|
|
3
3
|
import * as ingest from '@cccsaurora/howler-ui/api/v2/ingest';
|
|
4
4
|
import * as search from '@cccsaurora/howler-ui/api/v2/search';
|
|
5
|
+
import * as task from '@cccsaurora/howler-ui/api/v2/task';
|
|
5
6
|
export declare const uri: () => string;
|
|
6
|
-
export { case_ as case, fuzzy, ingest, search };
|
|
7
|
+
export { case_ as case, fuzzy, ingest, search, task };
|
package/api/v2/index.js
CHANGED
|
@@ -2,7 +2,8 @@ import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
|
|
|
2
2
|
import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
|
|
3
3
|
import * as ingest from '@cccsaurora/howler-ui/api/v2/ingest';
|
|
4
4
|
import * as search from '@cccsaurora/howler-ui/api/v2/search';
|
|
5
|
+
import * as task from '@cccsaurora/howler-ui/api/v2/task';
|
|
5
6
|
export const uri = () => {
|
|
6
7
|
return '/api/v2';
|
|
7
8
|
};
|
|
8
|
-
export { case_ as case, fuzzy, ingest, search };
|
|
9
|
+
export { case_ as case, fuzzy, ingest, search, task };
|
package/api/v2/index.test.js
CHANGED
|
@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
vi.mock('api/v2/case', () => ({ sentinel: 'case' }));
|
|
3
3
|
vi.mock('api/v2/fuzzy', () => ({ sentinel: 'fuzzy' }));
|
|
4
4
|
vi.mock('api/v2/search', () => ({ sentinel: 'search' }));
|
|
5
|
-
|
|
5
|
+
vi.mock('api/v2/task', () => ({ sentinel: 'task' }));
|
|
6
|
+
import { case as caseApi, fuzzy, search, task, uri } from './index';
|
|
6
7
|
describe('v2 API', () => {
|
|
7
8
|
it('builds the v2 root URI and re-exports v2 modules', () => {
|
|
8
9
|
expect(uri()).toBe('/api/v2');
|
|
9
10
|
expect(caseApi).toEqual({ sentinel: 'case' });
|
|
10
11
|
expect(fuzzy).toEqual({ sentinel: 'fuzzy' });
|
|
11
12
|
expect(search).toEqual({ sentinel: 'search' });
|
|
13
|
+
expect(task).toEqual({ sentinel: 'task' });
|
|
12
14
|
});
|
|
13
15
|
});
|
package/api/v2/search/index.d.ts
CHANGED
|
@@ -2,5 +2,5 @@ import type { HowlerSearchRequest, HowlerSearchResponse } from '@cccsaurora/howl
|
|
|
2
2
|
import * as facet from './facet';
|
|
3
3
|
export type SearchIndex = 'hit' | 'event' | 'case';
|
|
4
4
|
export declare const uri: (indexes: string[]) => string;
|
|
5
|
-
export declare const post: <T>(indexes: SearchIndex | SearchIndex[], request?: HowlerSearchRequest) => Promise<HowlerSearchResponse<T> | undefined>;
|
|
5
|
+
export declare const post: <T>(indexes: SearchIndex | SearchIndex[], request?: HowlerSearchRequest, signal?: AbortSignal) => Promise<HowlerSearchResponse<T> | undefined>;
|
|
6
6
|
export { facet };
|
package/api/v2/search/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import * as facet from './facet';
|
|
|
6
6
|
export const uri = (indexes) => {
|
|
7
7
|
return joinAllUri(parentUri(), 'search', indexes.join(','));
|
|
8
8
|
};
|
|
9
|
-
export const post = (indexes, request) => {
|
|
9
|
+
export const post = (indexes, request, signal) => {
|
|
10
10
|
if (isNil(indexes)) {
|
|
11
11
|
throw new Error('Indexes cannot be null or undefined.');
|
|
12
12
|
}
|
|
@@ -19,6 +19,6 @@ export const post = (indexes, request) => {
|
|
|
19
19
|
if (indexes.length < 1) {
|
|
20
20
|
throw new Error('indexes must have length of at least 1.');
|
|
21
21
|
}
|
|
22
|
-
return hpost(uri(indexes), { ...request, query: request?.query || 'howler.id:*' });
|
|
22
|
+
return hpost(uri(indexes), { ...request, query: request?.query || 'howler.id:*' }, undefined, undefined, signal);
|
|
23
23
|
};
|
|
24
24
|
export { facet };
|
|
@@ -24,12 +24,12 @@ describe('v2 search API', () => {
|
|
|
24
24
|
it('posts a search request for a single index string', async () => {
|
|
25
25
|
mockHpost.mockResolvedValue({ items: [], total: 0 });
|
|
26
26
|
await post('hit', { rows: 25 });
|
|
27
|
-
expect(mockHpost).toHaveBeenCalledWith('/api/v2/search/hit', { rows: 25, query: 'howler.id:*' });
|
|
27
|
+
expect(mockHpost).toHaveBeenCalledWith('/api/v2/search/hit', { rows: 25, query: 'howler.id:*' }, undefined, undefined, undefined);
|
|
28
28
|
});
|
|
29
29
|
it('preserves a provided query for multiple indexes', async () => {
|
|
30
30
|
mockHpost.mockResolvedValue({ items: [], total: 0 });
|
|
31
31
|
await post(['hit', 'case'], { query: 'status:open' });
|
|
32
|
-
expect(mockHpost).toHaveBeenCalledWith('/api/v2/search/hit,case', { query: 'status:open' });
|
|
32
|
+
expect(mockHpost).toHaveBeenCalledWith('/api/v2/search/hit,case', { query: 'status:open' }, undefined, undefined, undefined);
|
|
33
33
|
});
|
|
34
34
|
it('rejects nullish, invalid, or empty indexes', async () => {
|
|
35
35
|
expect(() => post(undefined, {})).toThrow('Indexes cannot be null or undefined.');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
+
import type { Task } from '@cccsaurora/howler-ui/models/entities/generated/Task';
|
|
3
|
+
export type TaskSearchFilter = 'all' | 'complete' | 'incomplete';
|
|
4
|
+
export interface TaskSearchRequest {
|
|
5
|
+
offset: number;
|
|
6
|
+
rows: number;
|
|
7
|
+
filter: TaskSearchFilter;
|
|
8
|
+
}
|
|
9
|
+
export interface TaskSearchItem {
|
|
10
|
+
task: Task;
|
|
11
|
+
case: Case;
|
|
12
|
+
}
|
|
13
|
+
export interface TaskSearchResponse {
|
|
14
|
+
items: TaskSearchItem[];
|
|
15
|
+
offset: number;
|
|
16
|
+
rows: number;
|
|
17
|
+
has_more: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare const uri: () => string;
|
|
20
|
+
export declare const search: (request: TaskSearchRequest, signal?: AbortSignal) => Promise<TaskSearchResponse | undefined>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// eslint-disable-next-line import/no-cycle
|
|
2
|
+
import { hget, joinUri } from '@cccsaurora/howler-ui/api';
|
|
3
|
+
import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2';
|
|
4
|
+
export const uri = () => joinUri(joinUri(parentUri(), 'task'), 'search');
|
|
5
|
+
export const search = (request, signal) => {
|
|
6
|
+
const params = new URLSearchParams({
|
|
7
|
+
offset: String(request.offset),
|
|
8
|
+
rows: String(request.rows),
|
|
9
|
+
filter: request.filter
|
|
10
|
+
});
|
|
11
|
+
return hget(uri(), params, undefined, signal);
|
|
12
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const mockHget = vi.hoisted(() => vi.fn());
|
|
3
|
+
const mockJoinUri = vi.hoisted(() => vi.fn((...parts) => parts.join('/').replace(/\/+/g, '/')));
|
|
4
|
+
vi.mock('api', () => ({
|
|
5
|
+
hget: mockHget,
|
|
6
|
+
joinUri: mockJoinUri
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('api/v2', () => ({
|
|
9
|
+
uri: () => '/api/v2'
|
|
10
|
+
}));
|
|
11
|
+
import { search, uri } from './index';
|
|
12
|
+
describe('v2 task API', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockHget.mockReset();
|
|
15
|
+
mockJoinUri.mockClear();
|
|
16
|
+
});
|
|
17
|
+
it('builds the task search URI', () => {
|
|
18
|
+
expect(uri()).toBe('/api/v2/task/search');
|
|
19
|
+
});
|
|
20
|
+
it('sends offset, page size, filter, and abort signal as query parameters', async () => {
|
|
21
|
+
const signal = new AbortController().signal;
|
|
22
|
+
const request = { offset: 25, rows: 25, filter: 'complete' };
|
|
23
|
+
await search(request, signal);
|
|
24
|
+
expect(mockHget).toHaveBeenCalledWith('/api/v2/task/search', new URLSearchParams({ offset: '25', rows: '25', filter: 'complete' }), undefined, signal);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import api from '@cccsaurora/howler-ui/api';
|
|
3
3
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
4
|
-
import { createContext, useCallback, useState } from 'react';
|
|
4
|
+
import { createContext, useCallback, useRef, useState } from 'react';
|
|
5
5
|
export const FieldContext = createContext(null);
|
|
6
6
|
const FieldProvider = ({ children }) => {
|
|
7
7
|
const { dispatchApi } = useMyApi();
|
|
8
8
|
const [hitFields, setHitFields] = useState([]);
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const hitFieldsCache = useRef(null);
|
|
10
|
+
const hitFieldsRequest = useRef(null);
|
|
11
|
+
const getHitFields = useCallback(() => {
|
|
12
|
+
if (hitFieldsCache.current) {
|
|
13
|
+
return Promise.resolve(hitFieldsCache.current);
|
|
12
14
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
if (hitFieldsRequest.current) {
|
|
16
|
+
return hitFieldsRequest.current;
|
|
17
|
+
}
|
|
18
|
+
const request = dispatchApi(api.search.fields.hit.get())
|
|
19
|
+
.then(fields => {
|
|
20
|
+
const resolvedFields = fields ?? [];
|
|
21
|
+
hitFieldsCache.current = resolvedFields;
|
|
22
|
+
setHitFields(resolvedFields);
|
|
23
|
+
return resolvedFields;
|
|
24
|
+
})
|
|
25
|
+
.finally(() => {
|
|
26
|
+
hitFieldsRequest.current = null;
|
|
27
|
+
});
|
|
28
|
+
hitFieldsRequest.current = request;
|
|
29
|
+
return request;
|
|
30
|
+
}, [dispatchApi]);
|
|
17
31
|
return _jsx(FieldContext.Provider, { value: { hitFields, getHitFields }, children: children });
|
|
18
32
|
};
|
|
19
33
|
export default FieldProvider;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/// <reference types="vitest" />
|
|
3
|
-
import { act, render, screen } from '@testing-library/react';
|
|
3
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
4
4
|
import { useContext } from 'react';
|
|
5
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
7
7
|
vi.mock('api', () => ({
|
|
8
8
|
default: { search: { fields: { hit: { get: () => ({}) } } } }
|
|
@@ -16,6 +16,9 @@ const Consumer = () => {
|
|
|
16
16
|
return (_jsxs("div", { children: [_jsx("button", { onClick: () => void ctx.getHitFields(), children: "load" }), _jsx("span", { children: ctx.hitFields.length })] }));
|
|
17
17
|
};
|
|
18
18
|
describe('FieldProvider', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
mockDispatchApi.mockReset();
|
|
21
|
+
});
|
|
19
22
|
it('loads hit fields once and reuses the cached value', async () => {
|
|
20
23
|
mockDispatchApi.mockResolvedValue([{ name: 'field-a' }]);
|
|
21
24
|
render(_jsx(FieldProvider, { children: _jsx(Consumer, {}) }));
|
|
@@ -25,4 +28,19 @@ describe('FieldProvider', () => {
|
|
|
25
28
|
await act(async () => screen.getByText('load').click());
|
|
26
29
|
expect(mockDispatchApi).toHaveBeenCalledTimes(1);
|
|
27
30
|
});
|
|
31
|
+
it('coalesces concurrent hit-field requests', async () => {
|
|
32
|
+
let resolveFields;
|
|
33
|
+
mockDispatchApi.mockReturnValue(new Promise(resolve => {
|
|
34
|
+
resolveFields = resolve;
|
|
35
|
+
}));
|
|
36
|
+
render(_jsx(FieldProvider, { children: _jsx(Consumer, {}) }));
|
|
37
|
+
fireEvent.click(screen.getByText('load'));
|
|
38
|
+
fireEvent.click(screen.getByText('load'));
|
|
39
|
+
expect(mockDispatchApi).toHaveBeenCalledTimes(1);
|
|
40
|
+
await act(async () => {
|
|
41
|
+
resolveFields?.([{ name: 'field-a' }]);
|
|
42
|
+
await Promise.resolve();
|
|
43
|
+
});
|
|
44
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
45
|
+
});
|
|
28
46
|
});
|
|
@@ -59,6 +59,13 @@ beforeEach(() => {
|
|
|
59
59
|
let mockSearchParams = new URLSearchParams();
|
|
60
60
|
vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
|
|
61
61
|
});
|
|
62
|
+
const expectSearchRequest = request => {
|
|
63
|
+
const requests = vi
|
|
64
|
+
.mocked(hpost)
|
|
65
|
+
.mock.calls.filter(([url]) => url === '/api/v2/search/hit')
|
|
66
|
+
.map(([, body]) => body);
|
|
67
|
+
expect(requests).toEqual(expect.arrayContaining([expect.objectContaining(request)]));
|
|
68
|
+
};
|
|
62
69
|
describe('RecordSearchContext', () => {
|
|
63
70
|
it('should initialize with default values', async () => {
|
|
64
71
|
const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
|
|
@@ -142,9 +149,7 @@ describe('RecordSearchContext', () => {
|
|
|
142
149
|
hook.result.current.search('test query');
|
|
143
150
|
});
|
|
144
151
|
await waitFor(() => {
|
|
145
|
-
expect
|
|
146
|
-
query: expect.stringContaining('test query')
|
|
147
|
-
}));
|
|
152
|
+
expectSearchRequest({ query: expect.stringContaining('test query') });
|
|
148
153
|
});
|
|
149
154
|
});
|
|
150
155
|
it('should set searching state during search', async () => {
|
|
@@ -254,9 +259,9 @@ describe('RecordSearchContext', () => {
|
|
|
254
259
|
hook.result.current('test query');
|
|
255
260
|
});
|
|
256
261
|
await waitFor(() => {
|
|
257
|
-
|
|
262
|
+
expectSearchRequest({
|
|
258
263
|
filters: expect.arrayContaining([expect.stringContaining('event.created:')])
|
|
259
|
-
})
|
|
264
|
+
});
|
|
260
265
|
});
|
|
261
266
|
});
|
|
262
267
|
it('should apply custom date range when span is custom', async () => {
|
|
@@ -268,9 +273,9 @@ describe('RecordSearchContext', () => {
|
|
|
268
273
|
hook.result.current('test query');
|
|
269
274
|
});
|
|
270
275
|
await waitFor(() => {
|
|
271
|
-
|
|
276
|
+
expectSearchRequest({
|
|
272
277
|
filters: expect.arrayContaining([expect.stringContaining('event.created:')])
|
|
273
|
-
})
|
|
278
|
+
});
|
|
274
279
|
});
|
|
275
280
|
});
|
|
276
281
|
it('should exclude filters ending with * from search', async () => {
|
|
@@ -280,9 +285,9 @@ describe('RecordSearchContext', () => {
|
|
|
280
285
|
hook.result.current('test query');
|
|
281
286
|
});
|
|
282
287
|
await waitFor(() => {
|
|
283
|
-
|
|
288
|
+
expectSearchRequest({
|
|
284
289
|
filters: expect.not.arrayContaining([expect.stringContaining('howler.escalation:*')])
|
|
285
|
-
})
|
|
290
|
+
});
|
|
286
291
|
});
|
|
287
292
|
});
|
|
288
293
|
it('should forward positive and negative grouped filters without changing their clauses', async () => {
|
|
@@ -420,10 +425,10 @@ describe('RecordSearchContext', () => {
|
|
|
420
425
|
hook.result.current('test query');
|
|
421
426
|
});
|
|
422
427
|
await waitFor(() => {
|
|
423
|
-
|
|
428
|
+
expectSearchRequest({
|
|
424
429
|
query: 'test query',
|
|
425
430
|
filters: expect.arrayContaining(['howler.status:open', 'howler.priority:high'])
|
|
426
|
-
})
|
|
431
|
+
});
|
|
427
432
|
});
|
|
428
433
|
});
|
|
429
434
|
it('should combine three view queries with AND logic', async () => {
|
|
@@ -438,7 +443,7 @@ describe('RecordSearchContext', () => {
|
|
|
438
443
|
hook.result.current('test query');
|
|
439
444
|
});
|
|
440
445
|
await waitFor(() => {
|
|
441
|
-
|
|
446
|
+
expectSearchRequest({
|
|
442
447
|
query: 'test query',
|
|
443
448
|
filters: [
|
|
444
449
|
'event.created:[now-1w TO now]',
|
|
@@ -446,7 +451,7 @@ describe('RecordSearchContext', () => {
|
|
|
446
451
|
'howler.priority:high',
|
|
447
452
|
'howler.analytic:sigma'
|
|
448
453
|
]
|
|
449
|
-
})
|
|
454
|
+
});
|
|
450
455
|
});
|
|
451
456
|
});
|
|
452
457
|
});
|
|
@@ -495,10 +500,10 @@ describe('RecordSearchContext', () => {
|
|
|
495
500
|
hook.result.current('test query');
|
|
496
501
|
});
|
|
497
502
|
await waitFor(() => {
|
|
498
|
-
|
|
503
|
+
expectSearchRequest({
|
|
499
504
|
query: expect.stringContaining('test query'),
|
|
500
505
|
filters: ['event.created:[now-1w TO now]']
|
|
501
|
-
})
|
|
506
|
+
});
|
|
502
507
|
}, { timeout: 2000 });
|
|
503
508
|
});
|
|
504
509
|
it('should skip null views and combine valid ones', async () => {
|
|
@@ -512,10 +517,10 @@ describe('RecordSearchContext', () => {
|
|
|
512
517
|
hook.result.current('test query');
|
|
513
518
|
});
|
|
514
519
|
await waitFor(() => {
|
|
515
|
-
|
|
520
|
+
expectSearchRequest({
|
|
516
521
|
query: 'test query',
|
|
517
522
|
filters: ['event.created:[now-1w TO now]', 'howler.status:open', 'howler.priority:high']
|
|
518
|
-
})
|
|
523
|
+
});
|
|
519
524
|
});
|
|
520
525
|
});
|
|
521
526
|
});
|
|
@@ -17,6 +17,9 @@ const useMyApi = () => {
|
|
|
17
17
|
return response;
|
|
18
18
|
}
|
|
19
19
|
catch (error) {
|
|
20
|
+
if (error instanceof Error && ['AbortError', 'CanceledError'].includes(error.name)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
20
23
|
if (error instanceof Error) {
|
|
21
24
|
if (onConflict && [409, 412].includes(error.cause?.api_status_code)) {
|
|
22
25
|
void onConflict();
|
|
@@ -17,7 +17,7 @@ declare const useMyUserFunctions: () => {
|
|
|
17
17
|
favourite_analytics?: string[];
|
|
18
18
|
dashboard?: {
|
|
19
19
|
entry_id: string;
|
|
20
|
-
type: 'view' | 'analytic';
|
|
20
|
+
type: 'view' | 'analytic' | 'case' | 'tasks';
|
|
21
21
|
config: string;
|
|
22
22
|
}[];
|
|
23
23
|
refresh_rate?: number;
|
|
@@ -40,7 +40,7 @@ declare const useMyUserFunctions: () => {
|
|
|
40
40
|
favourite_analytics?: string[];
|
|
41
41
|
dashboard?: {
|
|
42
42
|
entry_id: string;
|
|
43
|
-
type: 'view' | 'analytic';
|
|
43
|
+
type: 'view' | 'analytic' | 'case' | 'tasks';
|
|
44
44
|
config: string;
|
|
45
45
|
}[];
|
|
46
46
|
refresh_rate?: number;
|
|
@@ -64,7 +64,7 @@ declare const useMyUserFunctions: () => {
|
|
|
64
64
|
favourite_analytics?: string[];
|
|
65
65
|
dashboard?: {
|
|
66
66
|
entry_id: string;
|
|
67
|
-
type: 'view' | 'analytic';
|
|
67
|
+
type: 'view' | 'analytic' | 'case' | 'tasks';
|
|
68
68
|
config: string;
|
|
69
69
|
}[];
|
|
70
70
|
refresh_rate?: number;
|
|
@@ -88,7 +88,7 @@ declare const useMyUserFunctions: () => {
|
|
|
88
88
|
favourite_analytics?: string[];
|
|
89
89
|
dashboard?: {
|
|
90
90
|
entry_id: string;
|
|
91
|
-
type: 'view' | 'analytic';
|
|
91
|
+
type: 'view' | 'analytic' | 'case' | 'tasks';
|
|
92
92
|
config: string;
|
|
93
93
|
}[];
|
|
94
94
|
refresh_rate?: number;
|
|
@@ -111,7 +111,7 @@ declare const useMyUserFunctions: () => {
|
|
|
111
111
|
favourite_analytics?: string[];
|
|
112
112
|
dashboard?: {
|
|
113
113
|
entry_id: string;
|
|
114
|
-
type: 'view' | 'analytic';
|
|
114
|
+
type: 'view' | 'analytic' | 'case' | 'tasks';
|
|
115
115
|
config: string;
|
|
116
116
|
}[];
|
|
117
117
|
refresh_rate?: number;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { AddCircleOutline, FilterList, RemoveCircleOutline } from '@mui/icons-material';
|
|
3
|
-
import { Autocomplete, Checkbox, FormControlLabel, Stack, TextField, Tooltip, Typography } from '@mui/material';
|
|
3
|
+
import { Autocomplete, Checkbox, createFilterOptions, FormControlLabel, Stack, TextField, Tooltip, Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
|
+
import { FieldContext } from '@cccsaurora/howler-ui/components/app/providers/FieldProvider';
|
|
6
7
|
import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
|
|
7
8
|
import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
|
|
8
9
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
@@ -19,6 +20,13 @@ const ACCEPTED_LOOKUPS = [
|
|
|
19
20
|
'event.provider',
|
|
20
21
|
'organization.name'
|
|
21
22
|
];
|
|
23
|
+
const DEFAULT_FILTER_FIELDS = [
|
|
24
|
+
'howler.assessment',
|
|
25
|
+
'howler.escalation',
|
|
26
|
+
'howler.analytic',
|
|
27
|
+
'howler.detection',
|
|
28
|
+
'event.provider'
|
|
29
|
+
];
|
|
22
30
|
const parseFilter = (value) => {
|
|
23
31
|
const negated = value.startsWith('-');
|
|
24
32
|
const positiveValue = negated ? value.slice(1) : value;
|
|
@@ -72,9 +80,11 @@ const serializeFilter = (category, values, negated) => {
|
|
|
72
80
|
}
|
|
73
81
|
return filter.join('');
|
|
74
82
|
};
|
|
83
|
+
const filterFields = createFilterOptions();
|
|
75
84
|
const HitFilter = ({ size, id, value }) => {
|
|
76
85
|
const { t } = useTranslation();
|
|
77
86
|
const { config } = useContext(ApiConfigContext);
|
|
87
|
+
const { hitFields, getHitFields } = useContext(FieldContext);
|
|
78
88
|
const { dispatchApi } = useMyApi();
|
|
79
89
|
const setSavedFilter = useContextSelector(ParameterContext, ctx => ctx.setFilter);
|
|
80
90
|
const removeSavedFilter = useContextSelector(ParameterContext, ctx => ctx.removeFilter);
|
|
@@ -89,10 +99,15 @@ const HitFilter = ({ size, id, value }) => {
|
|
|
89
99
|
category: null,
|
|
90
100
|
options: []
|
|
91
101
|
});
|
|
102
|
+
const [categorySearchInput, setCategorySearchInput] = useState('');
|
|
92
103
|
const { category, values: filterValues, editable, negated } = parsedFilter;
|
|
93
104
|
const configuredLookup = category ? config.lookups?.[category] : undefined;
|
|
94
105
|
const needsLookups = !!category && editable && !Array.isArray(configuredLookup);
|
|
95
106
|
const loading = needsLookups && customLookups.category !== category;
|
|
107
|
+
const categoryOptions = hitFields.map(field => field.key).filter((field) => !!field);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
void getHitFields();
|
|
110
|
+
}, [getHitFields]);
|
|
96
111
|
useEffect(() => {
|
|
97
112
|
if (value) {
|
|
98
113
|
setSavedFilter(id, value);
|
|
@@ -142,6 +157,8 @@ const HitFilter = ({ size, id, value }) => {
|
|
|
142
157
|
}, [commitFilter, parsedFilter.raw]);
|
|
143
158
|
const lookupOptions = configuredLookup ?? (customLookups.category === category ? customLookups.options : []);
|
|
144
159
|
const chipLabel = `${parsedFilter.category ? `${parsedFilter.category}:` : ''}${parsedFilter.rawClause}`;
|
|
145
|
-
return (_jsx(Stack, { direction: "row", alignItems: "center", spacing: 0.5, children: _jsx(ChipPopper, { icon: _jsxs(Stack, { direction: "row", spacing: 0.25, alignItems: "center", children: [_jsx(FilterList, { fontSize: "small" }), parsedFilter.negated ? (_jsx(Tooltip, { title: t('hit.search.filter.excluded'), children: _jsx(RemoveCircleOutline, { fontSize: "small", color: "error" }) })) : (_jsx(Tooltip, { title: t('hit.search.filter.included'), children: _jsx(AddCircleOutline, { fontSize: "small", color: "success" }) }))] }), label: _jsx(Typography, { variant: "body2", children: chipLabel }), minWidth: "250px", onDelete: () => removeSavedFilter(value), slotProps: { chip: { size: 'small', color: parsedFilter.wildcard ? 'warning' : 'default' } }, children: _jsxs(Stack, { spacing: 1, sx: { minWidth: '225px' }, children: [_jsx(FormControlLabel, { label: t('hit.search.filter.exclude'), control: _jsx(Checkbox, { size: "small", checked: negated, onChange: toggleNegation }) }), _jsx(Autocomplete, { fullWidth: true, disableClearable: true, disabled: !editable, size: size ?? 'small', value: category ?? ACCEPTED_LOOKUPS[0], options:
|
|
160
|
+
return (_jsx(Stack, { direction: "row", alignItems: "center", spacing: 0.5, children: _jsx(ChipPopper, { icon: _jsxs(Stack, { direction: "row", spacing: 0.25, alignItems: "center", children: [_jsx(FilterList, { fontSize: "small" }), parsedFilter.negated ? (_jsx(Tooltip, { title: t('hit.search.filter.excluded'), children: _jsx(RemoveCircleOutline, { fontSize: "small", color: "error" }) })) : (_jsx(Tooltip, { title: t('hit.search.filter.included'), children: _jsx(AddCircleOutline, { fontSize: "small", color: "success" }) }))] }), label: _jsx(Typography, { variant: "body2", children: chipLabel }), minWidth: "250px", onDelete: () => removeSavedFilter(value), slotProps: { chip: { size: 'small', color: parsedFilter.wildcard ? 'warning' : 'default' } }, children: _jsxs(Stack, { spacing: 1, sx: { minWidth: '225px' }, children: [_jsx(FormControlLabel, { label: t('hit.search.filter.exclude'), control: _jsx(Checkbox, { size: "small", checked: negated, onChange: toggleNegation }) }), _jsx(Autocomplete, { fullWidth: true, disableClearable: true, disabled: !editable, size: size ?? 'small', value: category ?? ACCEPTED_LOOKUPS[0], options: categoryOptions, filterOptions: (options, state) => categorySearchInput
|
|
161
|
+
? filterFields(options, { ...state, inputValue: categorySearchInput })
|
|
162
|
+
: options.filter(option => DEFAULT_FILTER_FIELDS.includes(option)), renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.fields') }), onChange: onCategoryChange, onInputChange: (_event, inputValue, reason) => setCategorySearchInput(reason === 'input' ? inputValue : '') }), _jsx(Autocomplete, { fullWidth: true, freeSolo: true, multiple: true, disabled: !category || !editable, loading: loading, size: size ?? 'small', value: filterValues, options: lookupOptions, renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.values') }), getOptionLabel: option => t(option), onChange: onValuesChange })] }) }) }));
|
|
146
163
|
};
|
|
147
164
|
export default memo(HitFilter);
|