@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.
Files changed (50) hide show
  1. package/api/index.d.ts +3 -3
  2. package/api/index.js +7 -7
  3. package/api/index.test.js +6 -6
  4. package/api/search/user.d.ts +1 -1
  5. package/api/v2/index.d.ts +2 -1
  6. package/api/v2/index.js +2 -1
  7. package/api/v2/index.test.js +3 -1
  8. package/api/v2/search/index.d.ts +1 -1
  9. package/api/v2/search/index.js +2 -2
  10. package/api/v2/search/index.test.js +2 -2
  11. package/api/v2/task/index.d.ts +20 -0
  12. package/api/v2/task/index.js +12 -0
  13. package/api/v2/task/index.test.d.ts +1 -0
  14. package/api/v2/task/index.test.js +26 -0
  15. package/components/app/providers/FieldProvider.js +22 -8
  16. package/components/app/providers/FieldProvider.test.js +20 -2
  17. package/components/app/providers/RecordSearchProvider.test.js +22 -17
  18. package/components/hooks/useMyApi.js +3 -0
  19. package/components/hooks/useMyUserFunctions.d.ts +5 -5
  20. package/components/routes/hits/search/shared/HitFilter.js +19 -2
  21. package/components/routes/hits/search/shared/HitFilter.test.js +87 -20
  22. package/components/routes/home/AddNewCard.js +15 -2
  23. package/components/routes/home/CasePanel.d.ts +16 -0
  24. package/components/routes/home/CasePanel.js +105 -0
  25. package/components/routes/home/CasePanel.test.d.ts +1 -0
  26. package/components/routes/home/CasePanel.test.js +86 -0
  27. package/components/routes/home/TasksPanel.d.ts +13 -0
  28. package/components/routes/home/TasksPanel.js +129 -0
  29. package/components/routes/home/TasksPanel.test.d.ts +1 -0
  30. package/components/routes/home/TasksPanel.test.js +173 -0
  31. package/components/routes/home/ViewCard.d.ts +2 -1
  32. package/components/routes/home/ViewCard.js +60 -48
  33. package/components/routes/home/ViewCard.test.js +29 -3
  34. package/components/routes/home/ViewRefresh.d.ts +14 -9
  35. package/components/routes/home/ViewRefresh.js +63 -20
  36. package/components/routes/home/ViewRefresh.test.js +69 -22
  37. package/components/routes/home/index.js +39 -10
  38. package/components/routes/home/index.test.js +9 -1
  39. package/components/routes/views/ViewComposer.test.js +1 -0
  40. package/locales/en/translation.json +16 -0
  41. package/locales/fr/translation.json +16 -0
  42. package/models/entities/HowlerUser.d.ts +1 -1
  43. package/package.json +3 -1
  44. package/rest/AxiosClient.d.ts +1 -1
  45. package/rest/AxiosClient.js +3 -2
  46. package/rest/AxiosClient.test.js +6 -0
  47. package/rest/FetchClient.d.ts +1 -1
  48. package/rest/FetchClient.js +3 -2
  49. package/rest/FetchClient.test.js +6 -0
  50. 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
  });
@@ -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 };
@@ -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
- import { case as caseApi, fuzzy, search, uri } from './index';
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
  });
@@ -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 };
@@ -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 getHitFields = useCallback(async () => {
10
- if (hitFields.length) {
11
- return hitFields;
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
- const fields = (await dispatchApi(api.search.fields.hit.get())) ?? [];
14
- setHitFields(fields);
15
- return fields;
16
- }, [dispatchApi, hitFields]);
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(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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
- expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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
- expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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
- expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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
- expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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
- expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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
- expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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
- expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
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: ACCEPTED_LOOKUPS, renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.fields') }), onChange: onCategoryChange }), _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 })] }) }) }));
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);