@cccsaurora/howler-ui 2.13.0-dev.137 → 2.13.0-dev.142

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.
@@ -3,6 +3,6 @@ import type { View } from '@cccsaurora/howler-ui/models/entities/generated/View'
3
3
  export declare const uri: (id?: string) => string;
4
4
  export declare const get: () => Promise<View[]>;
5
5
  export declare const post: (newData: Partial<View>) => Promise<View>;
6
- export declare const put: (id: string, title: string, query: string, sort: string, span: string, advanceOnTriage: boolean) => Promise<View>;
6
+ export declare const put: (id: string, partialView: Partial<Omit<View, "view_id">>) => Promise<View>;
7
7
  export declare const del: (id: string) => Promise<void>;
8
8
  export { favourite };
package/api/view/index.js CHANGED
@@ -9,8 +9,8 @@ export const get = () => {
9
9
  export const post = (newData) => {
10
10
  return hpost(uri(), newData);
11
11
  };
12
- export const put = (id, title, query, sort, span, advanceOnTriage) => {
13
- return hput(uri(id), { title, query, sort, span, settings: { advance_on_triage: advanceOnTriage } });
12
+ export const put = (id, partialView) => {
13
+ return hput(uri(id), partialView);
14
14
  };
15
15
  export const del = (id) => {
16
16
  return hdelete(uri(id));
@@ -10,7 +10,7 @@ export interface ViewContextType {
10
10
  removeFavourite: (id: string) => Promise<void>;
11
11
  fetchViews: (ids?: string[]) => Promise<View[]>;
12
12
  addView: (v: View) => Promise<View>;
13
- editView: (id: string, title: string, query: string, sort: string, span: string, advanceOnTriage: boolean) => Promise<View>;
13
+ editView: (id: string, newView: Partial<Omit<View, 'view_id' | 'owner'>>) => Promise<View>;
14
14
  removeView: (id: string) => Promise<void>;
15
15
  getCurrentView: (lazy?: boolean) => Promise<View>;
16
16
  }
@@ -69,11 +69,11 @@ const ViewProvider = ({ children }) => {
69
69
  }
70
70
  return views[id];
71
71
  }, [defaultView, fetchViews, location.pathname, routeParams.id, views]);
72
- const editView = useCallback(async (id, title, query, sort, span, advanceOnTriage) => {
73
- const result = await dispatchApi(api.view.put(id, title, query, sort, span, advanceOnTriage));
72
+ const editView = useCallback(async (id, partialView) => {
73
+ const result = await dispatchApi(api.view.put(id, partialView));
74
74
  setViews(_views => ({
75
75
  ..._views,
76
- [id]: { ...(_views[id] ?? {}), title, query, sort, span, settings: { advance_on_triage: advanceOnTriage } }
76
+ [id]: { ...(_views[id] ?? {}), ...partialView }
77
77
  }));
78
78
  return result;
79
79
  }, [dispatchApi]);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,150 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import { hget, hpost, hput } from '@cccsaurora/howler-ui/api';
4
+ import MockLocalStorage from '@cccsaurora/howler-ui/tests/MockLocalStorage';
5
+ import { MOCK_RESPONSES } from '@cccsaurora/howler-ui/tests/server-handlers';
6
+ import { useContextSelector } from 'use-context-selector';
7
+ import { MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
8
+ import ViewProvider, { ViewContext } from './ViewProvider';
9
+ let mockUser = {
10
+ favourite_views: ['favourited_view_id']
11
+ };
12
+ vi.mock('@cccsaurora/howler-ui/api', { spy: true });
13
+ vi.mock('react-router-dom', () => ({
14
+ useLocation: vi.fn(() => ({ pathname: '/views/searched_view_id' })),
15
+ useParams: vi.fn(() => ({ id: 'searched_view_id' }))
16
+ }));
17
+ vi.mock('@cccsaurora/howler-ui/commons/components/app/hooks', () => ({
18
+ useAppUser: () => ({
19
+ user: mockUser,
20
+ setUser: _user => (mockUser = _user)
21
+ })
22
+ }));
23
+ const mockLocalStorage = new MockLocalStorage();
24
+ // Replace localStorage in global scope
25
+ Object.defineProperty(window, 'localStorage', {
26
+ value: mockLocalStorage,
27
+ writable: true
28
+ });
29
+ const Wrapper = ({ children }) => {
30
+ return _jsx(ViewProvider, { children: children });
31
+ };
32
+ beforeEach(() => {
33
+ mockLocalStorage.clear();
34
+ });
35
+ describe('ViewContext', () => {
36
+ it('should fetch the defaultView on initialization', async () => {
37
+ mockLocalStorage.setItem(`${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.DEFAULT_VIEW}`, JSON.stringify('searched_view_id'));
38
+ let hook = await act(async () => renderHook(() => useContextSelector(ViewContext, ctx => ctx.views), { wrapper: Wrapper }));
39
+ await waitFor(() => expect(hook.result.current.searched_view_id).not.toBeFalsy());
40
+ expect(hook.result.current.searched_view_id).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items[0]);
41
+ });
42
+ it('should allow the user to add and remove a favourite view', async () => {
43
+ const hook = await act(async () => {
44
+ return renderHook(() => useContextSelector(ViewContext, ctx => ({
45
+ addFavourite: ctx.addFavourite,
46
+ removeFavourite: ctx.removeFavourite
47
+ })), { wrapper: Wrapper });
48
+ });
49
+ await hook.result.current.addFavourite('example_view_id');
50
+ expect(mockUser.favourite_views).toEqual(['favourited_view_id', 'example_view_id']);
51
+ await hook.result.current.removeFavourite('example_view_id');
52
+ expect(mockUser.favourite_views).toEqual(['favourited_view_id']);
53
+ });
54
+ it('should allow the user to add and remove views', async () => {
55
+ const hook = await act(async () => {
56
+ return renderHook(() => useContextSelector(ViewContext, ctx => ({
57
+ addView: ctx.addView,
58
+ removeView: ctx.removeView,
59
+ views: ctx.views
60
+ })), { wrapper: Wrapper });
61
+ });
62
+ const result = await act(async () => hook.result.current.addView({
63
+ owner: 'user',
64
+ settings: {
65
+ advance_on_triage: false
66
+ },
67
+ view_id: 'example_created_view',
68
+ query: 'howler.id:*',
69
+ sort: 'event.created desc',
70
+ title: 'Example View',
71
+ type: 'personal',
72
+ span: 'date.range.1.month'
73
+ }));
74
+ hook.rerender();
75
+ expect(hook.result.current.views[result.view_id]).toEqual(result);
76
+ await act(async () => hook.result.current.removeView(result.view_id));
77
+ hook.rerender();
78
+ expect(hook.result.current.views[result.view_id]).toBeFalsy();
79
+ });
80
+ describe('fetchViews', () => {
81
+ let hook;
82
+ beforeEach(async () => {
83
+ hook = await act(async () => {
84
+ return renderHook(() => useContextSelector(ViewContext, ctx => ctx.fetchViews), { wrapper: Wrapper });
85
+ });
86
+ vi.mocked(hpost).mockClear();
87
+ vi.mocked(hget).mockClear();
88
+ });
89
+ it('Should fetch all views when no ids are provided', async () => {
90
+ const result = await act(async () => hook.result.current());
91
+ expect(result.length).toBe(2);
92
+ expect(result[0].view_id).toBe('example_view_id');
93
+ expect(result[1].view_id).toBe('another_view_id');
94
+ });
95
+ it('Should search for specified views when ids are provided', async () => {
96
+ const result = await act(async () => hook.result.current(['searched_view_id']));
97
+ expect(hpost).toHaveBeenCalledOnce();
98
+ expect(hpost).toBeCalledWith('/api/v1/search/view', { query: 'view_id:(searched_view_id)', rows: 1 });
99
+ expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items);
100
+ });
101
+ it('Should search only for new views when ids are provided', async () => {
102
+ await act(async () => hook.result.current(['searched_view_id']));
103
+ expect(hpost).toHaveBeenCalledOnce();
104
+ expect(hpost).toBeCalledWith('/api/v1/search/view', { query: 'view_id:(searched_view_id)', rows: 1 });
105
+ vi.mocked(hpost).mockClear();
106
+ await act(async () => hook.result.current(['searched_view_id', 'searched_view_id_2']));
107
+ expect(hpost).toHaveBeenCalledOnce();
108
+ expect(hpost).toBeCalledWith('/api/v1/search/view', { query: 'view_id:(searched_view_id_2)', rows: 1 });
109
+ });
110
+ it('Should provide cached instances as a response when the same views are requested', async () => {
111
+ let result = await act(async () => hook.result.current(['searched_view_id']));
112
+ expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items);
113
+ result = await act(async () => hook.result.current(['searched_view_id']));
114
+ expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items);
115
+ expect(hpost).toHaveBeenCalledOnce();
116
+ });
117
+ });
118
+ describe('getCurrentView', () => {
119
+ let hook;
120
+ beforeAll(async () => {
121
+ hook = await act(async () => {
122
+ return renderHook(() => useContextSelector(ViewContext, ctx => ctx.getCurrentView), { wrapper: Wrapper });
123
+ });
124
+ });
125
+ it('should allow the user to fetch their current view based on the location', async () => {
126
+ // lazy load should return nothing
127
+ await expect(hook.result.current(true)).resolves.toBeFalsy();
128
+ const result = await act(async () => hook.result.current());
129
+ expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items[0]);
130
+ });
131
+ });
132
+ describe('editView', () => {
133
+ let hook;
134
+ beforeAll(async () => {
135
+ hook = await act(async () => {
136
+ return renderHook(() => useContextSelector(ViewContext, ctx => ctx.editView), { wrapper: Wrapper });
137
+ });
138
+ });
139
+ beforeEach(() => {
140
+ vi.mocked(hput).mockClear();
141
+ vi.mocked(hpost).mockClear();
142
+ });
143
+ it('should allow users to edit views', async () => {
144
+ const result = await act(async () => hook.result.current('example_view_id', { query: 'howler.id:*' }));
145
+ expect(hput).toHaveBeenCalledOnce();
146
+ expect(hput).toBeCalledWith('/api/v1/view/example_view_id', { query: 'howler.id:*' });
147
+ expect(result).toEqual(MOCK_RESPONSES['/api/v1/view/example_view_id']);
148
+ });
149
+ });
150
+ });
@@ -94,7 +94,11 @@ const HitBanner = ({ hit, layout = HitLayout.NORMAL, showAssigned = true }) => {
94
94
  }, spacing: layout !== HitLayout.COMFY ? 1 : 2, divider: _jsx(Divider, { orientation: "horizontal", sx: [
95
95
  layout !== HitLayout.COMFY && { marginTop: '4px !important' },
96
96
  { mr: `${theme.spacing(-1)} !important` }
97
- ] }), children: [_jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf: 'start', '& a': { color: 'text.primary' } }, children: [analyticId ? (_jsx(Link, { to: `/analytics/${analyticId}`, onClick: e => e.stopPropagation(), children: hit.howler.analytic })) : (hit.howler.analytic), hit.howler.detection && ': ', hit.howler.detection] }), hit.howler?.rationale && (_jsxs(Typography, { flex: 1, variant: textVariant, color: ESCALATION_COLORS[hit.howler.escalation] + '.main', sx: { fontWeight: 'bold' }, children: [t('hit.header.rationale'), ": ", hit.howler.rationale] })), hit.howler?.outline && (_jsxs(_Fragment, { children: [_jsxs(Grid, { container: true, spacing: layout !== HitLayout.COMFY ? 1 : 2, sx: { ml: `${theme.spacing(-1)} !important` }, children: [hit.howler.outline.threat && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.threat", value: hit.howler.outline.threat }) })), hit.howler.outline.target && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.target", value: hit.howler.outline.target }) }))] }), hit.howler.outline.indicators?.length > 0 && (_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Typography, { component: "span", variant: textVariant, children: [t('hit.header.indicators'), ":"] }), _jsx(Grid, { container: true, spacing: 0.5, sx: { mt: `${theme.spacing(-0.5)} !important`, ml: `${theme.spacing(0.25)} !important` }, children: uniq(hit.howler.outline.indicators).map((_indicator, index) => {
97
+ ] }), children: [_jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf: 'start', '& a': { color: 'text.primary' } }, children: [analyticId ? (_jsx(Link, { to: `/analytics/${analyticId}`, onAuxClick: e => {
98
+ e.stopPropagation();
99
+ }, onClick: e => {
100
+ e.stopPropagation();
101
+ }, children: hit.howler.analytic })) : (hit.howler.analytic), hit.howler.detection && ': ', hit.howler.detection] }), hit.howler?.rationale && (_jsxs(Typography, { flex: 1, variant: textVariant, color: ESCALATION_COLORS[hit.howler.escalation] + '.main', sx: { fontWeight: 'bold' }, children: [t('hit.header.rationale'), ": ", hit.howler.rationale] })), hit.howler?.outline && (_jsxs(_Fragment, { children: [_jsxs(Grid, { container: true, spacing: layout !== HitLayout.COMFY ? 1 : 2, sx: { ml: `${theme.spacing(-1)} !important` }, children: [hit.howler.outline.threat && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.threat", value: hit.howler.outline.threat }) })), hit.howler.outline.target && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.target", value: hit.howler.outline.target }) }))] }), hit.howler.outline.indicators?.length > 0 && (_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Typography, { component: "span", variant: textVariant, children: [t('hit.header.indicators'), ":"] }), _jsx(Grid, { container: true, spacing: 0.5, sx: { mt: `${theme.spacing(-0.5)} !important`, ml: `${theme.spacing(0.25)} !important` }, children: uniq(hit.howler.outline.indicators).map((_indicator, index) => {
98
102
  return (_jsx(Grid, { item: true, children: _jsxs(Stack, { direction: "row", children: [_jsx(PluginTypography, { context: "indicators", variant: textVariant, value: _indicator, children: _indicator }), index < hit.howler.outline.indicators.length - 1 && (_jsx(Typography, { variant: textVariant, children: ',' }))] }) }, _indicator));
99
103
  }) })] })), hit.howler.outline.summary && (_jsx(Wrapper, { i18nKey: "hit.header.summary", value: hit.howler.outline.summary, paragraph: true, textOverflow: "wrap", sx: [compressed && { marginTop: `0 !important` }] }))] }))] }), _jsxs(Stack, { direction: "column", spacing: layout !== HitLayout.COMFY ? 0.5 : 1, alignSelf: "stretch", sx: [
100
104
  { minWidth: 0, alignItems: { sm: 'end', md: 'start' }, flex: 1, pl: 1 },
@@ -45,7 +45,7 @@ const Item = memo(({ hit, onClick }) => {
45
45
  }
46
46
  }, []);
47
47
  // Search result list item renderer.
48
- return (_jsx(Box, { id: hit.howler.id, onMouseUp: e => checkMiddleClick(e, hit.howler.id), onClick: ev => onClick(ev, hit), sx: [
48
+ return (_jsx(Box, { id: hit.howler.id, onAuxClick: e => checkMiddleClick(e, hit.howler.id), onClick: ev => onClick(ev, hit), sx: [
49
49
  {
50
50
  mb: 2,
51
51
  cursor: 'pointer',
@@ -72,7 +72,14 @@ const ViewComposer = () => {
72
72
  navigate(`/views/${newView.view_id}`);
73
73
  }
74
74
  else {
75
- await editView(routeParams.id, title, query, sort || null, span || null, advanceOnTriage);
75
+ await editView(routeParams.id, {
76
+ title,
77
+ type,
78
+ query,
79
+ sort,
80
+ span,
81
+ settings: { advance_on_triage: advanceOnTriage }
82
+ });
76
83
  }
77
84
  showSuccessMessage(t(routeParams.id ? 'route.views.update.success' : 'route.views.create.success'));
78
85
  }
package/package.json CHANGED
@@ -96,13 +96,14 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.13.0-dev.137",
99
+ "version": "2.13.0-dev.142",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",
103
103
  "./api/*": "./api/*.js",
104
104
  "./api": "./api/index.js",
105
105
  "./commons/*": "./commons/*.js",
106
+ "./tests/*": "./tests/*.js",
106
107
  "./rest/*": "./rest/*.js",
107
108
  "./rest": "./rest/index.js",
108
109
  "./locales/*.json": "./locales/*.json",
package/setupTests.js CHANGED
@@ -2,7 +2,11 @@
2
2
  import * as matchers from '@testing-library/jest-dom/matchers';
3
3
  import '@testing-library/jest-dom/vitest';
4
4
  import { configure } from '@testing-library/react';
5
+ import { server } from '@cccsaurora/howler-ui/tests/server';
5
6
  // Extend vitest with the dom matchers from jest-dom.
6
7
  expect.extend(matchers);
7
8
  // tell React Testing Library to look for id as the testId.
8
9
  configure({ testIdAttribute: 'id' });
10
+ beforeAll(() => server.listen());
11
+ afterEach(() => server.resetHandlers());
12
+ afterAll(() => server.close());
@@ -0,0 +1,5 @@
1
+ export default class MockLocalStorage {
2
+ constructor();
3
+ get length(): number;
4
+ get __STORE__(): this;
5
+ }
@@ -0,0 +1,44 @@
1
+ export default class MockLocalStorage {
2
+ constructor() {
3
+ Object.defineProperty(this, 'getItem', {
4
+ enumerable: false,
5
+ value: vi.fn(key => (this[key] !== undefined ? this[key] : null))
6
+ });
7
+ Object.defineProperty(this, 'setItem', {
8
+ enumerable: false,
9
+ // not mentioned in the spec, but we must always coerce to a string
10
+ value: vi.fn((key, val) => {
11
+ this[key] = `${val}`;
12
+ })
13
+ });
14
+ Object.defineProperty(this, 'removeItem', {
15
+ enumerable: false,
16
+ value: vi.fn(key => {
17
+ delete this[key];
18
+ })
19
+ });
20
+ Object.defineProperty(this, 'clear', {
21
+ enumerable: false,
22
+ value: vi.fn(() => {
23
+ Object.keys(this).map(key => delete this[key]);
24
+ })
25
+ });
26
+ Object.defineProperty(this, 'toString', {
27
+ enumerable: false,
28
+ value: vi.fn(() => {
29
+ return '[object Storage]';
30
+ })
31
+ });
32
+ Object.defineProperty(this, 'key', {
33
+ enumerable: false,
34
+ value: vi.fn(idx => Object.keys(this)[idx] || null)
35
+ });
36
+ } // end constructor
37
+ get length() {
38
+ return Object.keys(this).length;
39
+ }
40
+ // for backwards compatibility
41
+ get __STORE__() {
42
+ return this;
43
+ }
44
+ }
@@ -0,0 +1,5 @@
1
+ export declare const MOCK_RESPONSES: {
2
+ [path: string]: any;
3
+ };
4
+ declare const handlers: import("msw").HttpHandler[];
5
+ export { handlers };
@@ -0,0 +1,97 @@
1
+ import { http, HttpResponse } from 'msw';
2
+ export const MOCK_RESPONSES = {
3
+ '/api/v1/view/example_view_id': {
4
+ owner: 'user',
5
+ settings: {
6
+ advance_on_triage: false
7
+ },
8
+ view_id: 'example_view_id',
9
+ query: 'howler.id:*',
10
+ sort: 'event.created desc',
11
+ title: 'Example View',
12
+ type: 'personal',
13
+ span: 'date.range.1.month'
14
+ },
15
+ '/api/v1/search/view': {
16
+ items: [
17
+ {
18
+ owner: 'user',
19
+ settings: {
20
+ advance_on_triage: false
21
+ },
22
+ view_id: 'searched_view_id',
23
+ query: 'howler.id:searched',
24
+ sort: 'event.created desc',
25
+ title: 'Searched View',
26
+ type: 'personal',
27
+ span: 'date.range.1.month'
28
+ }
29
+ ],
30
+ total: 1,
31
+ rows: 1
32
+ },
33
+ '/api/v1/view/new_view_id': {
34
+ owner: 'user',
35
+ settings: {
36
+ advance_on_triage: false
37
+ },
38
+ view_id: 'new_view_id',
39
+ query: 'howler.id:new',
40
+ sort: 'event.created desc',
41
+ title: 'New View',
42
+ type: 'personal',
43
+ span: 'date.range.1.month'
44
+ },
45
+ '/api/v1/view/:view_id/favourite': { success: true }
46
+ };
47
+ const handlers = [
48
+ ...Object.entries(MOCK_RESPONSES).map(([path, data]) => http.all(path, async () => HttpResponse.json({ api_response: data }))),
49
+ http.post('/api/v1/view', async () => HttpResponse.json({
50
+ api_response: {
51
+ owner: 'user',
52
+ settings: {
53
+ advance_on_triage: false
54
+ },
55
+ view_id: 'example_created_view',
56
+ query: 'howler.id:*',
57
+ sort: 'event.created desc',
58
+ title: 'Example View',
59
+ type: 'personal',
60
+ span: 'date.range.1.month'
61
+ }
62
+ }, { status: 201 })),
63
+ http.delete('/api/v1/view/:view_id', async () => HttpResponse.json({
64
+ api_response: {
65
+ success: true
66
+ }
67
+ }, { status: 204 })),
68
+ http.get('/api/v1/view', async () => HttpResponse.json({
69
+ api_response: [
70
+ {
71
+ owner: 'user',
72
+ settings: {
73
+ advance_on_triage: false
74
+ },
75
+ view_id: 'example_view_id',
76
+ query: 'howler.id:*',
77
+ sort: 'event.created desc',
78
+ title: 'Example View',
79
+ type: 'personal',
80
+ span: 'date.range.1.month'
81
+ },
82
+ {
83
+ owner: 'user',
84
+ settings: {
85
+ advance_on_triage: true
86
+ },
87
+ view_id: 'another_view_id',
88
+ query: 'howler.status:open',
89
+ sort: 'event.created asc',
90
+ title: 'Another View',
91
+ type: 'global',
92
+ span: 'date.range.1.week'
93
+ }
94
+ ]
95
+ }))
96
+ ];
97
+ export { handlers };
@@ -0,0 +1,3 @@
1
+ import { http } from 'msw';
2
+ declare const server: import("msw/node").SetupServerApi;
3
+ export { http, server };
@@ -0,0 +1,5 @@
1
+ import { http } from 'msw';
2
+ import { setupServer } from 'msw/node';
3
+ import { handlers } from './server-handlers';
4
+ const server = setupServer(...handlers);
5
+ export { http, server };