@cccsaurora/howler-ui 3.1.0-dev.1712 → 3.1.0-dev.1716

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.
@@ -14,6 +14,7 @@ import { memo, useCallback, useContext, useEffect, useState } from 'react';
14
14
  import { useTranslation } from 'react-i18next';
15
15
  import { useContextSelector } from 'use-context-selector';
16
16
  import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
17
+ import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
17
18
  import { isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
18
19
  import { getTimeRange, notNil } from '@cccsaurora/howler-ui/utils/utils';
19
20
  import PluginChip from '../PluginChip';
@@ -30,7 +31,7 @@ const HitSummary = ({ response, onStart, onComplete }) => {
30
31
  const error = useContextSelector(RecordSearchContext, ctx => ctx.error);
31
32
  const getFilters = useContextSelector(RecordSearchContext, ctx => ctx.getFilters);
32
33
  const query = useContextSelector(ParameterContext, ctx => ctx.query);
33
- const setQuery = useContextSelector(ParameterContext, ctx => ctx.setQuery);
34
+ const addFilter = useContextSelector(ParameterContext, ctx => ctx.addFilter);
34
35
  const views = useContextSelector(ParameterContext, ctx => ctx.views);
35
36
  const [loading, setLoading] = useState(false);
36
37
  const [customKeys, setCustomKeys] = useState([]);
@@ -122,9 +123,9 @@ const HitSummary = ({ response, onStart, onComplete }) => {
122
123
  response?.items,
123
124
  query
124
125
  ]);
125
- const setSearch = useCallback((key, value) => {
126
- setQuery(`${key}:${value}`);
127
- }, [setQuery]);
126
+ const addAggregationFilter = useCallback((key, value) => {
127
+ addFilter(`${key}:${value}`);
128
+ }, [addFilter]);
128
129
  useEffect(() => {
129
130
  if ((!query && (views?.length ?? 0) < 1) || searching || error) {
130
131
  return;
@@ -136,9 +137,9 @@ const HitSummary = ({ response, onStart, onComplete }) => {
136
137
  .filter(key => !isEmpty(aggregateResults[key]))
137
138
  .flatMap(key => [
138
139
  _jsx(Fade, { in: true, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Typography, { variant: "body1", children: key }, key + '-title'), keyCounts[key]?.count < 0 ? (_jsxs(Typography, { variant: "caption", color: "text.secondary", children: ["(", t('hit.summary.adhoc.custom'), ")"] })) : (_jsxs(Typography, { variant: "caption", color: "text.secondary", children: ["(", keyCounts[key]?.count ?? '?', " ", t('references'), ")"] })), _jsx(Tooltip, { title: _jsxs(Stack, { children: [_jsx(Typography, { variant: "caption", children: t('hit.summary.aggregate.sources') }), keyCounts[key]?.sources.map(source => (_jsx(Typography, { variant: "caption", children: source }, source))) ?? '?'] }), children: _jsx(InfoOutlined, { fontSize: "inherit" }) })] }) }, key + '-refs'),
139
- _jsx(Fade, { in: true, children: hitFields.find(f => f.key === key)?.type !== 'date' ? (_jsx(Box, { sx: theme => ({ ml: `${theme.spacing(1)} !important`, alignSelf: 'start' }), children: _jsx(Grid, { container: true, sx: theme => ({ mr: 1, mt: theme.spacing(-1) }), spacing: 1, children: Object.keys(aggregateResults[key]).map(item => (_jsx(Grid, { size: 'auto', children: _jsx(PluginChip, { context: "summary", size: "small", variant: "filled", value: item, label: `${item} (${aggregateResults[key][item]})`, onClick: () => setSearch(key, `"${item}"`), field: key }) }, item))) }, key + '-list') })) : (_jsx(Chip, { size: "small", sx: theme => ({ ml: `${theme.spacing(1)} !important`, alignSelf: 'start' }), label: getTimeRange(Object.keys(aggregateResults[key]))
140
+ _jsx(Fade, { in: true, children: hitFields.find(f => f.key === key)?.type !== 'date' ? (_jsx(Box, { sx: theme => ({ ml: `${theme.spacing(1)} !important`, alignSelf: 'start' }), children: _jsx(Grid, { container: true, sx: theme => ({ mr: 1, mt: theme.spacing(-1) }), spacing: 1, children: Object.keys(aggregateResults[key]).map(item => (_jsx(Grid, { size: 'auto', children: _jsx(PluginChip, { context: "summary", size: "small", variant: "filled", value: item, label: `${item} (${aggregateResults[key][item]})`, onClick: () => addAggregationFilter(key, `"${sanitizeLuceneQuery(item)}"`), field: key }) }, item))) }, key + '-list') })) : (_jsx(Chip, { size: "small", sx: theme => ({ ml: `${theme.spacing(1)} !important`, alignSelf: 'start' }), label: getTimeRange(Object.keys(aggregateResults[key]))
140
141
  .map(d => new Date(d).toLocaleString())
141
- .join(' - '), onClick: () => setSearch(key, `[${getTimeRange(Object.keys(aggregateResults[key])).join(' TO ')}]`) })) }, key + '-results')
142
+ .join(' - '), onClick: () => addAggregationFilter(key, `[${getTimeRange(Object.keys(aggregateResults[key])).join(' TO ')}]`) })) }, key + '-results')
142
143
  ])] })] }));
143
144
  };
144
145
  export default memo(HitSummary);
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  const mockDispatchApi = vi.hoisted(() => vi.fn());
6
6
  const mockShowErrorMessage = vi.hoisted(() => vi.fn());
7
7
  const mockSetQuery = vi.hoisted(() => vi.fn());
8
+ const mockAddFilter = vi.hoisted(() => vi.fn());
8
9
  const mockGetFilters = vi.hoisted(() => vi.fn().mockResolvedValue(['status:open']));
9
10
  const mockGetMatchingTemplate = vi.hoisted(() => vi.fn());
10
11
  const fieldContextToken = vi.hoisted(() => ({ name: 'field-context' }));
@@ -23,7 +24,7 @@ vi.mock('@mui/material', () => ({
23
24
  Autocomplete: ({ onChange, renderInput }) => (_jsxs("div", { children: [renderInput({}), _jsx("button", { onClick: () => onChange?.(null, ['custom.field']), children: "add-custom-field" })] })),
24
25
  Box: ({ children }) => _jsx("div", { children: children }),
25
26
  Button: ({ children, onClick, disabled }) => (_jsx("button", { onClick: onClick, disabled: disabled, children: children })),
26
- Chip: ({ label, onClick }) => _jsx("button", { onClick: onClick, children: label }),
27
+ Chip: ({ label, onClick }) => (_jsx("button", { "aria-label": "date-aggregation-chip", onClick: onClick, children: label })),
27
28
  CircularProgress: () => _jsx("div", { children: "loading" }),
28
29
  Divider: () => _jsx("div", { children: "divider" }),
29
30
  Fade: ({ children }) => _jsx("div", { children: children }),
@@ -124,6 +125,7 @@ describe('HitSummary', () => {
124
125
  parameterContextValue = {
125
126
  query: 'status:open',
126
127
  setQuery: mockSetQuery,
128
+ addFilter: mockAddFilter,
127
129
  views: []
128
130
  };
129
131
  recordSearchContextValue = {
@@ -134,16 +136,17 @@ describe('HitSummary', () => {
134
136
  mockDispatchApi.mockReset();
135
137
  mockShowErrorMessage.mockReset();
136
138
  mockSetQuery.mockReset();
139
+ mockAddFilter.mockReset();
137
140
  mockGetFilters.mockClear();
138
141
  mockGetMatchingTemplate.mockReset();
139
142
  });
140
- it('aggregates template keys, supports ad hoc fields, and updates the search query from results', async () => {
143
+ it('aggregates template keys and adds aggregation filters without changing the search query', async () => {
141
144
  mockGetMatchingTemplate
142
145
  .mockResolvedValueOnce({ analytic: 'Analytic A', detection: 'Rule A', keys: ['severity', 'howler.id'] })
143
146
  .mockResolvedValueOnce({ analytic: 'Analytic A', detection: 'Rule B', keys: ['event.created'] });
144
147
  mockDispatchApi
145
148
  .mockResolvedValueOnce({
146
- severity: { high: 2, low: 1 },
149
+ severity: { high: 2, low: 1, 'critical:high': 3 },
147
150
  'event.created': { '2026-01-01T00:00:00Z': 1, '2026-01-02T00:00:00Z': 1 }
148
151
  })
149
152
  .mockResolvedValueOnce({
@@ -166,14 +169,25 @@ describe('HitSummary', () => {
166
169
  expect(screen.getByText('hit-graph')).toBeInTheDocument();
167
170
  expect(screen.getByText('severity')).toBeInTheDocument();
168
171
  fireEvent.click(screen.getByText('high (2)'));
169
- expect(mockSetQuery).toHaveBeenCalledWith('severity:"high"');
172
+ expect(mockAddFilter).toHaveBeenCalledWith('severity:"high"');
173
+ fireEvent.click(screen.getByText('critical:high (3)'));
174
+ expect(mockAddFilter).toHaveBeenCalledWith('severity:"critical\\:high"');
175
+ expect(mockSetQuery).not.toHaveBeenCalled();
176
+ fireEvent.click(screen.getByLabelText('date-aggregation-chip'));
177
+ expect(mockAddFilter).toHaveBeenCalledWith('event.created:[2026-01-01T00:00:00Z TO 2026-01-02T00:00:00Z]');
178
+ expect(mockSetQuery).not.toHaveBeenCalled();
179
+ mockGetFilters.mockResolvedValue([
180
+ 'status:open',
181
+ 'severity:"high"',
182
+ 'event.created:[2026-01-01T00:00:00Z TO 2026-01-02T00:00:00Z]'
183
+ ]);
170
184
  fireEvent.click(screen.getByText('add-custom-field'));
171
185
  fireEvent.click(screen.getByText('button.aggregate'));
172
186
  await waitFor(() => expect(mockDispatchApi).toHaveBeenLastCalledWith({
173
187
  fields: ['custom.field'],
174
188
  query: 'status:open',
175
189
  rows: 25,
176
- filters: ['status:open']
190
+ filters: ['status:open', 'severity:"high"', 'event.created:[2026-01-01T00:00:00Z TO 2026-01-02T00:00:00Z]']
177
191
  }, { throwError: false, logError: true, showError: false }));
178
192
  expect(screen.getByText('custom.field')).toBeInTheDocument();
179
193
  });
package/package.json CHANGED
@@ -97,7 +97,7 @@
97
97
  "url": "https://github.com/CybercentreCanada/howler"
98
98
  },
99
99
  "type": "module",
100
- "version": "3.1.0-dev.1712",
100
+ "version": "3.1.0-dev.1716",
101
101
  "exports": {
102
102
  "./i18n": "./i18n.js",
103
103
  "./index.css": "./index.css",