@cccsaurora/howler-ui 3.1.0-dev.1697 → 3.1.0-dev.1705

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.
@@ -47,7 +47,7 @@ const RecordSearchProvider = ({ children }) => {
47
47
  'howler.id: *': new Date().toISOString()
48
48
  });
49
49
  const [fzfSearch, setFzfSearch] = useState(false);
50
- const filters = useMemo(() => (allFilters ?? []).filter(filter => !filter.endsWith('*')), [allFilters]);
50
+ const filters = useMemo(() => (allFilters ?? []).filter(filter => filter.startsWith('-') || !filter.endsWith('*')), [allFilters]);
51
51
  // On load check to filter out any queries older than one month
52
52
  useEffect(() => {
53
53
  const filterQueryTime = dayjs().subtract(1, 'month').toISOString();
@@ -122,6 +122,15 @@ describe('RecordSearchContext', () => {
122
122
  });
123
123
  });
124
124
  describe('search', () => {
125
+ it('should retain negated wildcard filters and omit positive placeholders', async () => {
126
+ mockParameterContext.filters = ['howler.assessment:*', '-howler.assessment:*'];
127
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.getFilters), {
128
+ wrapper: Wrapper
129
+ });
130
+ const filters = await hook.result.current();
131
+ expect(filters).toContain('-howler.assessment:*');
132
+ expect(filters).not.toContain('howler.assessment:*');
133
+ });
125
134
  it('should perform a search and update response', async () => {
126
135
  const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
127
136
  search: ctx.search,
@@ -276,6 +285,29 @@ describe('RecordSearchContext', () => {
276
285
  }));
277
286
  });
278
287
  });
288
+ it('should forward positive and negative grouped filters without changing their clauses', async () => {
289
+ mockParameterContext.filters = [
290
+ 'event.provider:"azure"',
291
+ '-howler.outline.indicators:("a" OR "b")',
292
+ 'event.provider:"\\*"',
293
+ '-event.provider:"\\*"'
294
+ ];
295
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
296
+ act(() => {
297
+ hook.result.current('test query');
298
+ });
299
+ await waitFor(() => {
300
+ const searchRequest = vi.mocked(hpost).mock.calls.find(([url]) => url === '/api/v2/search/hit')?.[1];
301
+ expect(searchRequest).toEqual(expect.objectContaining({
302
+ filters: expect.arrayContaining([
303
+ 'event.provider:"azure"',
304
+ '-howler.outline.indicators:("a" OR "b")',
305
+ 'event.provider:"\\*"',
306
+ '-event.provider:"\\*"'
307
+ ])
308
+ }));
309
+ });
310
+ });
279
311
  it('should reset offset if response total is less than current offset', async () => {
280
312
  mockParameterContext.offset = 100;
281
313
  vi.mocked(hpost).mockResolvedValueOnce({
@@ -20,7 +20,6 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 're
20
20
  import { useTranslation } from 'react-i18next';
21
21
  import { usePluginStore } from 'react-pluggable';
22
22
  import { useContextSelector } from 'use-context-selector';
23
- import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
24
23
  import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
25
24
  import { isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
26
25
  /**
@@ -49,8 +48,7 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
49
48
  const { showModal } = useContext(ModalContext);
50
49
  const pluginStore = usePluginStore();
51
50
  const { getMatchingAnalytic, getMatchingTemplate } = useMatchers();
52
- const query = useContextSelector(ParameterContext, ctx => ctx?.query);
53
- const setQuery = useContextSelector(ParameterContext, ctx => ctx?.setQuery);
51
+ const addFilter = useContextSelector(ParameterContext, ctx => ctx?.addFilter);
54
52
  const [id, setId] = useState(null);
55
53
  const record = useContextSelector(RecordContext, ctx => ctx.records[id]);
56
54
  const selectedRecords = useContextSelector(RecordContext, ctx => ctx.selectedRecords);
@@ -189,10 +187,10 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
189
187
  }))
190
188
  });
191
189
  const filterKeys = uniq([
192
- ...(template?.keys ?? []),
193
- ...Object.keys(record.howler.outline ?? {}).map(key => `howler.outline.${key}`)
190
+ ...Object.keys(record.howler.outline ?? {}).map(key => `howler.outline.${key}`),
191
+ ...(template?.keys ?? [])
194
192
  ]);
195
- if (!isEmpty(filterKeys) && setQuery) {
193
+ if (!isEmpty(filterKeys) && addFilter) {
196
194
  result.push({ kind: 'divider', id: 'filter-divider' });
197
195
  result.push({
198
196
  kind: 'submenu',
@@ -200,28 +198,31 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
200
198
  icon: _jsx(RemoveCircleOutline, {}),
201
199
  label: t('hit.panel.exclude'),
202
200
  items: filterKeys.flatMap(key => {
203
- let newQuery = '';
204
- if (query !== DEFAULT_QUERY) {
205
- newQuery = `(${query}) AND `;
206
- }
207
201
  const value = get(record, key);
208
202
  if (!value) {
209
203
  return [];
210
204
  }
211
- else if (Array.isArray(value)) {
205
+ let filter = '';
206
+ if (Array.isArray(value) && value.length > 0) {
212
207
  const sanitizedValues = value
213
208
  .map(toString)
214
209
  .filter(val => !!val)
215
210
  .map(val => `"${sanitizeLuceneQuery(val)}"`);
216
- if (sanitizedValues.length < 1) {
217
- return [];
218
- }
219
- newQuery += `-${key}:(${sanitizedValues.join(' OR ')})`;
211
+ filter = `-${key}:(${sanitizedValues.join(' OR ')})`;
220
212
  }
221
- else {
222
- newQuery += `-${key}:"${sanitizeLuceneQuery(value.toString())}"`;
213
+ else if (!Array.isArray(value)) {
214
+ filter = `-${key}:"${sanitizeLuceneQuery(value.toString())}"`;
223
215
  }
224
- return [{ key, label: key, onClick: () => setQuery(newQuery) }];
216
+ if (!filter) {
217
+ return [];
218
+ }
219
+ return [
220
+ {
221
+ key,
222
+ label: key,
223
+ onClick: () => addFilter(filter)
224
+ }
225
+ ];
225
226
  })
226
227
  });
227
228
  result.push({
@@ -230,25 +231,31 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
230
231
  icon: _jsx(AddCircleOutline, {}),
231
232
  label: t('hit.panel.include'),
232
233
  items: filterKeys.flatMap(key => {
233
- let newQuery = `(${query}) AND `;
234
234
  const value = get(record, key);
235
235
  if (!value) {
236
236
  return [];
237
237
  }
238
- else if (Array.isArray(value)) {
238
+ let filter = '';
239
+ if (Array.isArray(value) && value.length > 0) {
239
240
  const sanitizedValues = value
240
241
  .map(toString)
241
242
  .filter(val => !!val)
242
243
  .map(val => `"${sanitizeLuceneQuery(val)}"`);
243
- if (sanitizedValues.length < 1) {
244
- return [];
245
- }
246
- newQuery += `${key}:(${sanitizedValues.join(' OR ')})`;
244
+ filter = `${key}:(${sanitizedValues.join(' OR ')})`;
245
+ }
246
+ else if (!Array.isArray(value)) {
247
+ filter = `${key}:"${sanitizeLuceneQuery(value.toString())}"`;
247
248
  }
248
- else {
249
- newQuery += `${key}:"${sanitizeLuceneQuery(value.toString())}"`;
249
+ if (!filter) {
250
+ return [];
250
251
  }
251
- return [{ key, label: key, onClick: () => setQuery(newQuery) }];
252
+ return [
253
+ {
254
+ key,
255
+ label: key,
256
+ onClick: () => addFilter(filter)
257
+ }
258
+ ];
252
259
  })
253
260
  });
254
261
  }
@@ -272,7 +279,7 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
272
279
  });
273
280
  return result;
274
281
  // eslint-disable-next-line react-hooks/exhaustive-deps
275
- }, [record, analytic, template, entries, rowStatus, actions, query, t, setQuery, executeAction, showModal, records]);
282
+ }, [record, analytic, template, entries, rowStatus, actions, t, addFilter, executeAction, showModal, records]);
276
283
  return (_jsx(ContextMenu, { id: "contextMenu", Component: Component, onOpen: onOpen, onClose: () => setAnalytic(undefined), items: items, children: children }));
277
284
  };
278
285
  export default RecordContextMenu;
@@ -128,7 +128,7 @@ const mockRecordContext = {
128
128
  },
129
129
  selectedRecords: []
130
130
  };
131
- const mockParameterContext = { query: DEFAULT_QUERY, setQuery: vi.fn() };
131
+ const mockParameterContext = { query: DEFAULT_QUERY, setQuery: vi.fn(), addFilter: vi.fn() };
132
132
  // Test wrapper
133
133
  const Wrapper = ({ children }) => {
134
134
  return (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(ApiConfigContext.Provider, { value: mockApiContext, children: _jsx(ModalContext.Provider, { value: { showModal: mockShowModal }, children: _jsx(RecordContext.Provider, { value: mockRecordContext, children: _jsx(ParameterContext.Provider, { value: mockParameterContext, children: children }) }) }) }) }));
@@ -145,6 +145,7 @@ describe('HitContextMenu', () => {
145
145
  'test-hit-1': createMockHit()
146
146
  };
147
147
  mockGetSelectedId.mockReturnValue('test-hit-1');
148
+ mockParameterContext.query = DEFAULT_QUERY;
148
149
  mockGetMatchingAnalytic.mockResolvedValue(createMockAnalytic());
149
150
  mockGetMatchingTemplate.mockResolvedValue(createMockTemplate());
150
151
  rerender = render(_jsx(Wrapper, { children: _jsx(RecordContextMenu, { getSelectedId: mockGetSelectedId, children: _jsx("div", { children: "Test Content" }) }) })).rerender;
@@ -602,7 +603,8 @@ describe('HitContextMenu', () => {
602
603
  await user.click(detectionKey);
603
604
  });
604
605
  await waitFor(() => {
605
- expect(mockParameterContext.setQuery).toHaveBeenCalledWith('-howler.detection:"Test Detection"');
606
+ expect(mockParameterContext.addFilter).toHaveBeenCalledWith('-howler.detection:"Test Detection"');
607
+ expect(mockParameterContext.setQuery).not.toHaveBeenCalled();
606
608
  });
607
609
  });
608
610
  it('should generate exclusion query for array values', async () => {
@@ -626,7 +628,7 @@ describe('HitContextMenu', () => {
626
628
  await user.click(tagsKey);
627
629
  });
628
630
  await waitFor(() => {
629
- expect(mockParameterContext.setQuery).toHaveBeenCalledWith('-howler.outline.indicators:("a" OR "b" OR "c")');
631
+ expect(mockParameterContext.addFilter).toHaveBeenCalledWith('-howler.outline.indicators:("a" OR "b" OR "c")');
630
632
  });
631
633
  });
632
634
  it('should preserve existing query when adding exclusion', async () => {
@@ -645,7 +647,9 @@ describe('HitContextMenu', () => {
645
647
  await user.click(detectionKey);
646
648
  });
647
649
  await waitFor(() => {
648
- expect(mockParameterContext.setQuery).toHaveBeenCalledWith('(howler.status:open) AND -howler.detection:"Test Detection"');
650
+ expect(mockParameterContext.addFilter).toHaveBeenCalledWith('-howler.detection:"Test Detection"');
651
+ expect(mockParameterContext.query).toBe('howler.status:open');
652
+ expect(mockParameterContext.setQuery).not.toHaveBeenCalled();
649
653
  });
650
654
  });
651
655
  it('should render outline fields in the exclusion menu when template has no keys', async () => {
@@ -718,6 +722,8 @@ describe('HitContextMenu', () => {
718
722
  });
719
723
  });
720
724
  it('should generate inclusion query for single value', async () => {
725
+ mockParameterContext.query = 'howler.status:open';
726
+ mockRecordContext.records['test-hit-1'].howler.detection = 'Test "Detection"';
721
727
  act(() => {
722
728
  const contextMenuWrapper = screen.getByText('Test Content').parentElement;
723
729
  fireEvent.contextMenu(contextMenuWrapper);
@@ -737,7 +743,9 @@ describe('HitContextMenu', () => {
737
743
  await user.click(detectionKey);
738
744
  });
739
745
  await waitFor(() => {
740
- expect(mockParameterContext.setQuery).toHaveBeenCalledWith('(howler.status:open) AND howler.detection:"Test Detection"');
746
+ expect(mockParameterContext.addFilter).toHaveBeenCalledWith('howler.detection:"Test \\"Detection\\""');
747
+ expect(mockParameterContext.query).toBe('howler.status:open');
748
+ expect(mockParameterContext.setQuery).not.toHaveBeenCalled();
741
749
  });
742
750
  });
743
751
  it('should generate inclusion query for array values', async () => {
@@ -761,7 +769,7 @@ describe('HitContextMenu', () => {
761
769
  await user.click(tagsKey);
762
770
  });
763
771
  await waitFor(() => {
764
- expect(mockParameterContext.setQuery).toHaveBeenCalledWith('(howler.status:open) AND howler.outline.indicators:("a" OR "b" OR "c")');
772
+ expect(mockParameterContext.addFilter).toHaveBeenCalledWith('howler.outline.indicators:("a" OR "b" OR "c")');
765
773
  });
766
774
  });
767
775
  it('should preserve existing query when adding inclusion', async () => {
@@ -780,7 +788,9 @@ describe('HitContextMenu', () => {
780
788
  await user.click(detectionKey);
781
789
  });
782
790
  await waitFor(() => {
783
- expect(mockParameterContext.setQuery).toHaveBeenCalledWith('(howler.status:open) AND howler.detection:"Test Detection"');
791
+ expect(mockParameterContext.addFilter).toHaveBeenCalledWith('howler.detection:"Test Detection"');
792
+ expect(mockParameterContext.query).toBe('howler.status:open');
793
+ expect(mockParameterContext.setQuery).not.toHaveBeenCalled();
784
794
  });
785
795
  });
786
796
  it('should render outline fields in the inclusion menu when template has no keys', async () => {
@@ -804,7 +814,7 @@ describe('HitContextMenu', () => {
804
814
  await act(async () => {
805
815
  await user.click(screen.getByText('howler.outline.indicators'));
806
816
  });
807
- expect(mockParameterContext.setQuery).toHaveBeenCalledWith(expect.stringContaining('howler.outline.indicators:("a" OR "b" OR "c")'));
817
+ expect(mockParameterContext.addFilter).toHaveBeenCalledWith(expect.stringContaining('howler.outline.indicators:("a" OR "b" OR "c")'));
808
818
  });
809
819
  it('should skip null field values in inclusion menu', async () => {
810
820
  act(() => {
@@ -26,6 +26,6 @@ const QuerySettings = ({ boxSx }) => {
26
26
  await fetchViews();
27
27
  addView('');
28
28
  };
29
- return (_jsx(Box, { sx: boxSx ?? { position: 'relative', maxWidth: '1200px' }, children: _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Grid, { container: true, spacing: 1, children: [_jsx(Grid, { children: _jsx(IndexPicker, {}) }), _jsx(Grid, { children: _jsx(HitSort, {}) }), _jsx(Grid, { children: _jsx(SearchSpan, {}) }), currentViews?.map((view, id) => (_jsx(Grid, { children: _jsx(ViewLink, { id: id, viewId: view }) }, view))), filters?.map((filter, id) => (_jsx(Grid, { children: _jsx(HitFilter, { id: id, value: filter }) }, filter)))] }), _jsx(ChipPopper, { icon: _jsx(Add, {}), deleteIcon: _jsx(ArrowDropDown, {}), toggleOnDelete: true, closeOnClick: true, slotProps: { chip: { size: 'small', color: 'primary' }, paper: { sx: { p: 1 } } }, children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Button, { id: "add-filter", "aria-label": t('hit.search.filter.add'), variant: "outlined", onClick: () => addFilter('howler.assessment:*'), disabled: filters?.some(filter => filter.endsWith('*')), sx: { display: 'flex', pl: 1 }, children: [_jsx(Add, { fontSize: "small", sx: { mr: 1 } }), _jsx("div", { style: { flex: 1 } }), _jsx("span", { children: t('hit.search.filter.add') })] }), _jsxs(Button, { id: "add-view", "aria-label": t('hit.search.view.add'), variant: "outlined", onClick: onAddView, disabled: !allowAddViews, sx: { display: 'flex', pl: 1 }, children: [_jsx(Add, { fontSize: "small", sx: { mr: 1 } }), _jsx("div", { style: { flex: 1 } }), _jsx("span", { children: t('hit.search.view.add') })] })] }) })] }) }));
29
+ return (_jsx(Box, { sx: boxSx ?? { position: 'relative', maxWidth: '1200px' }, children: _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Grid, { container: true, spacing: 1, children: [_jsx(Grid, { children: _jsx(IndexPicker, {}) }), _jsx(Grid, { children: _jsx(HitSort, {}) }), _jsx(Grid, { children: _jsx(SearchSpan, {}) }), currentViews?.map((view, id) => (_jsx(Grid, { children: _jsx(ViewLink, { id: id, viewId: view }) }, view))), filters?.map((filter, id) => (_jsx(Grid, { children: _jsx(HitFilter, { id: id, value: filter }) }, filter)))] }), _jsx(ChipPopper, { icon: _jsx(Add, {}), deleteIcon: _jsx(ArrowDropDown, {}), toggleOnDelete: true, closeOnClick: true, slotProps: { chip: { size: 'small', color: 'primary' }, paper: { sx: { p: 1 } } }, children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Button, { id: "add-filter", "aria-label": t('hit.search.filter.add'), variant: "outlined", onClick: () => addFilter('howler.assessment:*'), disabled: filters?.some(filter => !filter.startsWith('-') && filter.endsWith('*')), sx: { display: 'flex', pl: 1 }, children: [_jsx(Add, { fontSize: "small", sx: { mr: 1 } }), _jsx("div", { style: { flex: 1 } }), _jsx("span", { children: t('hit.search.filter.add') })] }), _jsxs(Button, { id: "add-view", "aria-label": t('hit.search.view.add'), variant: "outlined", onClick: onAddView, disabled: !allowAddViews, sx: { display: 'flex', pl: 1 }, children: [_jsx(Add, { fontSize: "small", sx: { mr: 1 } }), _jsx("div", { style: { flex: 1 } }), _jsx("span", { children: t('hit.search.view.add') })] })] }) })] }) }));
30
30
  };
31
31
  export default memo(QuerySettings);
@@ -185,6 +185,16 @@ describe('QuerySettings', () => {
185
185
  expect(mockAddFilter).toHaveBeenCalledWith('howler.assessment:*');
186
186
  expect(mockAddFilter).toHaveBeenCalledTimes(1);
187
187
  });
188
+ it('should allow adding a filter when only negated wildcard filters exist', async () => {
189
+ mockParameterContext.filters = ['-howler.assessment:*'];
190
+ const { rerender } = render(_jsx(QuerySettings, {}), { wrapper: Wrapper });
191
+ await user.click(screen.getByRole('button'));
192
+ const addFilterButton = screen.getByLabelText(i18n.t('hit.search.filter.add'));
193
+ expect(addFilterButton).toBeEnabled();
194
+ mockParameterContext = { ...mockParameterContext, filters: ['howler.assessment:*'] };
195
+ rerender(_jsx(QuerySettings, {}));
196
+ expect(addFilterButton).toBeDisabled();
197
+ });
188
198
  it('should call fetchViews and addView when Add View button clicked', async () => {
189
199
  mockViewContext.views = {
190
200
  'view-1': createMockView({ view_id: 'view-1' })
@@ -1,15 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { FilterList } from '@mui/icons-material';
3
- import { Autocomplete, Stack, TextField, Typography } from '@mui/material';
2
+ import { AddCircleOutline, FilterList, RemoveCircleOutline } from '@mui/icons-material';
3
+ import { Autocomplete, Checkbox, 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
6
  import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
7
7
  import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
8
8
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
9
+ import { isEmpty, isNil, uniq } from 'lodash-es';
9
10
  import { memo, useCallback, useContext, useEffect, useState } from 'react';
10
11
  import { useTranslation } from 'react-i18next';
11
12
  import { useContextSelector } from 'use-context-selector';
12
- import { sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
13
+ import { parseQuotedValues, sanitizeLuceneQuery } from '@cccsaurora/howler-ui/utils/stringUtils';
13
14
  const ACCEPTED_LOOKUPS = [
14
15
  'howler.assessment',
15
16
  'howler.escalation',
@@ -18,60 +19,129 @@ const ACCEPTED_LOOKUPS = [
18
19
  'event.provider',
19
20
  'organization.name'
20
21
  ];
22
+ const parseFilter = (value) => {
23
+ const negated = value.startsWith('-');
24
+ const positiveValue = negated ? value.slice(1) : value;
25
+ const separator = positiveValue.indexOf(':');
26
+ const category = separator > 0 ? positiveValue.slice(0, separator) : null;
27
+ const rawClause = category ? positiveValue.slice(separator + 1) : positiveValue;
28
+ const parsed = {
29
+ raw: value,
30
+ category,
31
+ rawClause,
32
+ values: [],
33
+ negated,
34
+ editable: false,
35
+ wildcard: false
36
+ };
37
+ // Only the editor's literal subset is safe to rewrite; leave arbitrary Lucene clauses opaque.
38
+ if (!category || !/^[A-Za-z_][A-Za-z0-9_.]*$/.test(category)) {
39
+ return parsed;
40
+ }
41
+ parsed.editable = true;
42
+ if (rawClause === '*') {
43
+ parsed.wildcard = true;
44
+ return parsed;
45
+ }
46
+ if (/^[A-Za-z_][A-Za-z0-9_.]*$/.test(rawClause) && !/^(true|false|AND|OR|NOT)$/i.test(rawClause)) {
47
+ parsed.values = [rawClause];
48
+ return parsed;
49
+ }
50
+ const parts = parseQuotedValues(rawClause);
51
+ if (!parts) {
52
+ parsed.editable = false;
53
+ return parsed;
54
+ }
55
+ parsed.values = parts;
56
+ return parsed;
57
+ };
58
+ const serializeFilter = (category, values, negated) => {
59
+ const filter = [];
60
+ if (negated) {
61
+ filter.push('-');
62
+ }
63
+ filter.push(category + ':');
64
+ const serializedValues = uniq(values)
65
+ .map(item => `"${sanitizeLuceneQuery(item)}"`)
66
+ .filter(item => !isEmpty(item));
67
+ if (serializedValues.length > 1) {
68
+ filter.push(`(${serializedValues.join(' OR ')})`);
69
+ }
70
+ else {
71
+ filter.push(serializedValues[0] ?? '*');
72
+ }
73
+ return filter.join('');
74
+ };
21
75
  const HitFilter = ({ size, id, value }) => {
22
76
  const { t } = useTranslation();
23
77
  const { config } = useContext(ApiConfigContext);
24
78
  const { dispatchApi } = useMyApi();
25
79
  const setSavedFilter = useContextSelector(ParameterContext, ctx => ctx.setFilter);
26
80
  const removeSavedFilter = useContextSelector(ParameterContext, ctx => ctx.removeFilter);
27
- const [category, setCategory] = useState(value?.split(':')[0] ?? ACCEPTED_LOOKUPS[0]);
28
- const [filter, setFilter] = useState(value?.split(':')[1] ?? null);
29
- const [loading, setLoading] = useState(false);
30
- const [customLookups, setCustomLookups] = useState([]);
81
+ const [parsedFilter, setParsedFilter] = useState(() => parseFilter(value));
82
+ const [previousValue, setPreviousValue] = useState(value);
83
+ // Synchronize external replacements without resetting edits when only lookup config changes.
84
+ if (previousValue !== value) {
85
+ setPreviousValue(value);
86
+ setParsedFilter(parseFilter(value));
87
+ }
88
+ const [customLookups, setCustomLookups] = useState({
89
+ category: null,
90
+ options: []
91
+ });
92
+ const { category, values: filterValues, editable, negated } = parsedFilter;
93
+ const configuredLookup = category ? config.lookups?.[category] : undefined;
94
+ const needsLookups = !!category && editable && !Array.isArray(configuredLookup);
95
+ const loading = needsLookups && customLookups.category !== category;
31
96
  useEffect(() => {
32
97
  if (value) {
33
- const [_category, _filter] = (value || ':').split(':');
34
- if (_category) {
35
- setCategory(_category);
36
- }
37
- if (_filter && _filter !== '*') {
38
- setFilter(_filter);
39
- }
40
- if (_category && _filter) {
41
- setSavedFilter(id, `${_category}:${_filter}`);
42
- }
98
+ setSavedFilter(id, value);
43
99
  }
44
100
  }, [id, setSavedFilter, value]);
45
- const onCategoryChange = useCallback(async (_, _category) => {
46
- setCategory(_category);
47
- setFilter(null);
48
- if (!_category) {
101
+ useEffect(() => {
102
+ if (!category || !editable || !isNil(configuredLookup)) {
49
103
  return;
50
104
  }
51
- if (!config.lookups?.[_category]) {
52
- setLoading(true);
53
- const facets = await dispatchApi(api.search.facet.hit.post({ query: 'howler.id:*', fields: [_category], rows: 100 }), {
54
- throwError: false
55
- });
56
- setCustomLookups(Object.keys((facets ?? {})[_category] ?? {}));
57
- setLoading(false);
58
- }
59
- else {
60
- setCustomLookups([]);
61
- }
62
- }, [config.lookups, dispatchApi]);
63
- const onValueChange = useCallback((_, newValue) => {
64
- setFilter(newValue);
65
- if (newValue && newValue !== '*') {
66
- setSavedFilter(id, `${category}:"${sanitizeLuceneQuery(newValue)}"`);
67
- }
68
- else {
69
- setSavedFilter(id, `${category}:*`);
105
+ // Ignore responses from a previous field or an unmounted editor.
106
+ let active = true;
107
+ void (async () => {
108
+ try {
109
+ const facets = await dispatchApi(api.search.facet.hit.post({ query: `${category}:*`, fields: [category], rows: 100 }), { throwError: false });
110
+ if (!active) {
111
+ return;
112
+ }
113
+ setCustomLookups({ category, options: Object.keys((facets ?? {})[category] ?? {}) });
114
+ }
115
+ catch {
116
+ if (active) {
117
+ setCustomLookups({ category, options: [] });
118
+ }
119
+ }
120
+ })();
121
+ return () => {
122
+ active = false;
123
+ };
124
+ }, [category, configuredLookup, dispatchApi, editable]);
125
+ const commitFilter = useCallback((nextValue) => {
126
+ setParsedFilter(parseFilter(nextValue));
127
+ setSavedFilter(id, nextValue);
128
+ }, [id, setSavedFilter]);
129
+ const onCategoryChange = useCallback((_, nextCategory) => {
130
+ commitFilter(`${negated ? '-' : ''}${nextCategory}:*`);
131
+ }, [commitFilter, negated]);
132
+ const onValuesChange = useCallback((_, newValues) => {
133
+ if (!category) {
134
+ return;
70
135
  }
71
- }, [category, id, setSavedFilter]);
72
- const filterValue = filter?.replaceAll('"', '').replaceAll('\\-', '-') || '';
73
- const configuredLookup = category && category in (config.lookups ?? {}) ? config.lookups?.[category] : undefined;
74
- const lookupOptions = Array.isArray(configuredLookup) ? configuredLookup : customLookups;
75
- return (_jsx(ChipPopper, { icon: _jsx(FilterList, { fontSize: "small" }), label: category && _jsx(Typography, { variant: "body2", children: `${category}:${filterValue || '*'}` }), minWidth: "250px", onDelete: () => removeSavedFilter(value), slotProps: { chip: { size: 'small', color: value?.endsWith('*') ? 'warning' : 'default' } }, children: _jsxs(Stack, { spacing: 1, sx: { minWidth: '225px' }, children: [_jsx(Autocomplete, { fullWidth: true, size: size ?? 'small', value: category, options: ACCEPTED_LOOKUPS, renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.fields') }), onChange: onCategoryChange }), _jsx(Autocomplete, { fullWidth: true, freeSolo: true, disabled: !category, loading: loading, size: size ?? 'small', value: filter?.replaceAll('"', '').replaceAll('\\-', '-') || '', options: [...lookupOptions, '*'], renderInput: _params => _jsx(TextField, { ..._params, label: t('hit.search.filter.values') }), getOptionLabel: option => t(option), onChange: onValueChange })] }) }));
136
+ commitFilter(serializeFilter(category, newValues, negated));
137
+ }, [category, commitFilter, negated]);
138
+ const toggleNegation = useCallback((event, nextNegated) => {
139
+ event.stopPropagation();
140
+ // Toggle only the leading sign, including for opaque clauses the editor cannot parse.
141
+ commitFilter(parsedFilter.raw.replace(/^-?/, nextNegated ? '-' : ''));
142
+ }, [commitFilter, parsedFilter.raw]);
143
+ const lookupOptions = configuredLookup ?? (customLookups.category === category ? customLookups.options : []);
144
+ 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 })] }) }) }));
76
146
  };
77
147
  export default memo(HitFilter);
@@ -1,23 +1,45 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /// <reference types="vitest" />
3
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  const apiConfigContextToken = vi.hoisted(() => ({ name: 'api-config-context' }));
6
6
  const parameterContextToken = vi.hoisted(() => ({ name: 'parameter-context' }));
7
7
  const mockDispatchApi = vi.hoisted(() => vi.fn());
8
8
  const mockSetSavedFilter = vi.hoisted(() => vi.fn());
9
9
  const mockRemoveSavedFilter = vi.hoisted(() => vi.fn());
10
+ const mockAutocompleteSelection = vi.hoisted(() => ({
11
+ value: 'normal'
12
+ }));
10
13
  let configValue = { lookups: { 'howler.assessment': ['malicious'] } };
11
14
  vi.mock('@mui/icons-material', async (importOriginal) => {
12
15
  const actual = await importOriginal();
13
16
  return { ...actual, FilterList: () => _jsx("div", { children: "filter-icon" }) };
14
17
  });
15
- vi.mock('@mui/material', () => ({
16
- Autocomplete: ({ onChange, options, disabled }) => (_jsx("button", { disabled: disabled, onClick: () => onChange(null, options.includes('event.provider') ? 'event.provider' : options[0]), children: "change-filter" })),
17
- Stack: ({ children }) => _jsx("div", { children: children }),
18
- TextField: ({ label }) => _jsx("div", { children: label }),
19
- Typography: ({ children }) => _jsx("div", { children: children })
20
- }));
18
+ vi.mock('@mui/material', async (importOriginal) => {
19
+ const actual = await importOriginal();
20
+ return {
21
+ ...actual,
22
+ Autocomplete: ({ onChange, options, disabled, multiple, value, loading }) => (_jsx("button", { disabled: disabled, "data-loading": Boolean(loading), "data-options": JSON.stringify(options), onClick: () => onChange(null, mockAutocompleteSelection.value === 'clear'
23
+ ? multiple
24
+ ? []
25
+ : null
26
+ : multiple
27
+ ? mockAutocompleteSelection.value === 'unchanged'
28
+ ? value
29
+ : mockAutocompleteSelection.value === 'add'
30
+ ? [...(value ?? []), 'added value']
31
+ : mockAutocompleteSelection.value === 'single'
32
+ ? (value ?? []).slice(0, 1)
33
+ : (value?.length ?? 0) > 1
34
+ ? [value[0], value[0]?.startsWith('C:') ? 'new\\path' : 'changed two']
35
+ : [value?.[0] ?? options[0] ?? 'changed one']
36
+ : value === '*'
37
+ ? '*'
38
+ : options.includes('event.provider')
39
+ ? 'event.provider'
40
+ : options[0]), children: "change-filter" }))
41
+ };
42
+ });
21
43
  vi.mock('api', () => ({
22
44
  default: {
23
45
  search: {
@@ -35,9 +57,6 @@ vi.mock('components/app/providers/ApiConfigProvider', () => ({
35
57
  vi.mock('components/app/providers/ParameterProvider', () => ({
36
58
  ParameterContext: parameterContextToken
37
59
  }));
38
- vi.mock('components/elements/display/ChipPopper', () => ({
39
- default: ({ children, label, onDelete }) => (_jsxs("div", { children: [_jsx("div", { children: label }), _jsx("button", { onClick: onDelete, children: "delete-filter" }), children] }))
40
- }));
41
60
  vi.mock('components/hooks/useMyApi', () => ({
42
61
  default: () => ({ dispatchApi: mockDispatchApi })
43
62
  }));
@@ -65,19 +84,192 @@ describe('HitFilter', () => {
65
84
  mockDispatchApi.mockReset().mockImplementation(async (_value) => ({ 'event.provider': { azure: 2 } }));
66
85
  mockSetSavedFilter.mockReset();
67
86
  mockRemoveSavedFilter.mockReset();
87
+ mockAutocompleteSelection.value = 'normal';
68
88
  });
69
89
  it('initializes from value, fetches custom lookups, updates values, and removes filters', async () => {
70
90
  render(_jsx(HitFilter, { id: 1, value: 'howler.assessment:*' }));
71
91
  await waitFor(() => expect(mockSetSavedFilter).toHaveBeenCalledWith(1, 'howler.assessment:*'));
92
+ fireEvent.click(screen.getByRole('button', { name: /howler.assessment:\*/ }));
72
93
  fireEvent.click(screen.getAllByText('change-filter')[0]);
73
- await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledWith({ query: 'howler.id:*', fields: ['event.provider'], rows: 100 }, { throwError: false }));
94
+ await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledWith({ query: 'event.provider:*', fields: ['event.provider'], rows: 100 }, { throwError: false }));
74
95
  fireEvent.click(screen.getAllByText('change-filter')[1]);
75
96
  expect(mockSetSavedFilter).toHaveBeenCalledWith(1, 'event.provider:"azure"');
76
- fireEvent.click(screen.getByText('delete-filter'));
97
+ const chip = screen.getByRole('button', { name: /event.provider:"azure"/ });
98
+ const deleteIcon = chip.querySelector('.MuiChip-deleteIcon');
99
+ expect(deleteIcon).not.toBeNull();
100
+ fireEvent.click(deleteIcon);
77
101
  expect(mockRemoveSavedFilter).toHaveBeenCalledWith('howler.assessment:*');
78
102
  });
103
+ it('commits a field change immediately while retaining negation', () => {
104
+ render(_jsx(HitFilter, { id: 13, value: '-howler.assessment:"malicious"' }));
105
+ fireEvent.click(screen.getByRole('button', { name: /howler.assessment:/ }));
106
+ fireEvent.click(screen.getAllByText('change-filter')[0]);
107
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(13, '-event.provider:*');
108
+ expect(screen.getByRole('button', { name: /event.provider:\*/ })).toBeInTheDocument();
109
+ });
110
+ it('does not reset an edited filter when lookup configuration changes', () => {
111
+ const { rerender } = render(_jsx(HitFilter, { id: 14, value: 'event.provider:"a"' }));
112
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
113
+ fireEvent.click(screen.getByRole('checkbox', { name: 'hit.search.filter.exclude' }));
114
+ mockSetSavedFilter.mockClear();
115
+ configValue = { lookups: { 'event.provider': ['a'] } };
116
+ rerender(_jsx(HitFilter, { id: 14, value: 'event.provider:"a"', size: "medium" }));
117
+ expect(screen.getByRole('checkbox', { name: 'hit.search.filter.exclude' })).toBeChecked();
118
+ expect(mockSetSavedFilter).not.toHaveBeenCalled();
119
+ });
79
120
  it('uses configured lookups and wildcard values', async () => {
80
121
  render(_jsx(HitFilter, { id: 2, value: 'howler.assessment:*' }));
81
122
  await waitFor(() => expect(mockSetSavedFilter).toHaveBeenCalledWith(2, 'howler.assessment:*'));
82
123
  });
124
+ it('deduplicates decoded literals while preserving quoted OR text', () => {
125
+ const clause = String.raw `event.provider:("a" OR "\a" OR "text OR more" OR "escaped\"quote")`;
126
+ render(_jsx(HitFilter, { id: 15, value: clause }));
127
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
128
+ mockAutocompleteSelection.value = 'unchanged';
129
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
130
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(15, String.raw `event.provider:("a" OR "text OR more" OR "escaped\"quote")`);
131
+ });
132
+ it('collapses duplicate values to a canonical scalar while preserving negation', () => {
133
+ render(_jsx(HitFilter, { id: 20, value: String.raw `-event.provider:("\a" OR "a" OR "a")` }));
134
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
135
+ mockAutocompleteSelection.value = 'unchanged';
136
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
137
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(20, '-event.provider:"a"');
138
+ });
139
+ it.each(['plain', String.raw `"\plain"`, '"constructor"', '"__proto__"'])('canonically quotes a decoded scalar from %s', literal => {
140
+ render(_jsx(HitFilter, { id: 21, value: `event.provider:${literal}` }));
141
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
142
+ mockAutocompleteSelection.value = 'unchanged';
143
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
144
+ const expected = literal.includes('plain') ? 'plain' : literal.slice(1, -1);
145
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(21, `event.provider:"${expected}"`);
146
+ });
147
+ it('accepts whitespace-separated quoted OR values', () => {
148
+ render(_jsx(HitFilter, { id: 16, value: 'event.provider:( "a"\tOR\n"b" )' }));
149
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
150
+ mockAutocompleteSelection.value = 'unchanged';
151
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
152
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(16, 'event.provider:("a" OR "b")');
153
+ });
154
+ it('synchronizes externally replaced filters', () => {
155
+ const { rerender } = render(_jsx(HitFilter, { id: 17, value: 'event.provider:"a"' }));
156
+ rerender(_jsx(HitFilter, { id: 17, value: '-howler.assessment:*' }));
157
+ fireEvent.click(screen.getByRole('button', { name: /howler.assessment:/ }));
158
+ expect(screen.getByRole('checkbox', { name: 'hit.search.filter.exclude' })).toBeChecked();
159
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(17, '-howler.assessment:*');
160
+ });
161
+ it('settles failed lookup requests without disabling free-text editing', async () => {
162
+ mockDispatchApi.mockRejectedValueOnce(new Error('offline'));
163
+ render(_jsx(HitFilter, { id: 18, value: 'event.provider:*' }));
164
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
165
+ const values = screen.getAllByText('change-filter')[1];
166
+ await waitFor(() => expect(values).toHaveAttribute('data-loading', 'false'));
167
+ expect(values).not.toBeDisabled();
168
+ expect(values).toHaveAttribute('data-options', '[]');
169
+ expect(mockSetSavedFilter).toHaveBeenCalledWith(18, 'event.provider:*');
170
+ });
171
+ it('ignores stale lookup responses after switching to a configured field', async () => {
172
+ let resolveFacets;
173
+ mockDispatchApi.mockImplementationOnce(() => new Promise(resolve => {
174
+ resolveFacets = resolve;
175
+ }));
176
+ const { rerender } = render(_jsx(HitFilter, { id: 19, value: 'event.provider:*' }));
177
+ await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
178
+ rerender(_jsx(HitFilter, { id: 19, value: 'howler.assessment:*' }));
179
+ fireEvent.click(screen.getByRole('button', { name: /howler.assessment:/ }));
180
+ await act(async () => resolveFacets({ 'event.provider': { stale: 1 } }));
181
+ const values = screen.getAllByText('change-filter')[1];
182
+ expect(values).toHaveAttribute('data-options', '["malicious"]');
183
+ expect(values).toHaveAttribute('data-loading', 'false');
184
+ });
185
+ it('loads custom lookups when initialized with a custom category', async () => {
186
+ render(_jsx(HitFilter, { id: 11, value: 'event.provider:"azure"' }));
187
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
188
+ await waitFor(() => expect(mockDispatchApi).toHaveBeenCalledWith({ query: 'event.provider:*', fields: ['event.provider'], rows: 100 }, { throwError: false }));
189
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
190
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(11, 'event.provider:"azure"');
191
+ });
192
+ it('uses the clear action to set a scalar filter to wildcard', () => {
193
+ render(_jsx(HitFilter, { id: 9, value: 'howler.assessment:"malicious"' }));
194
+ fireEvent.click(screen.getByRole('button', { name: /howler.assessment:/ }));
195
+ mockAutocompleteSelection.value = 'clear';
196
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
197
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(9, 'howler.assessment:*');
198
+ });
199
+ it('uses the clear action to set grouped filters to wildcard', () => {
200
+ render(_jsx(HitFilter, { id: 10, value: 'event.provider:("a" OR "b")' }));
201
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
202
+ mockAutocompleteSelection.value = 'clear';
203
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
204
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(10, 'event.provider:*');
205
+ });
206
+ it('serializes selections by their current cardinality', () => {
207
+ render(_jsx(HitFilter, { id: 12, value: 'event.provider:"a"' }));
208
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
209
+ mockAutocompleteSelection.value = 'add';
210
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
211
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(12, 'event.provider:("a" OR "added value")');
212
+ mockAutocompleteSelection.value = 'single';
213
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
214
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(12, 'event.provider:"a"');
215
+ });
216
+ it('toggles negation without losing a grouped OR clause', async () => {
217
+ const { container } = render(_jsx(HitFilter, { id: 3, value: 'event.provider:("a" OR "b")' }));
218
+ const chip = screen.getByRole('button', { name: /event.provider:/ });
219
+ fireEvent.click(chip);
220
+ const toggle = screen.getByRole('checkbox', { name: 'hit.search.filter.exclude' });
221
+ expect(chip).toHaveClass('MuiChip-root');
222
+ expect(toggle).not.toBeChecked();
223
+ fireEvent.click(toggle);
224
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(3, '-event.provider:("a" OR "b")');
225
+ expect(screen.getByRole('button', { name: /event.provider:/ })).toBeInTheDocument();
226
+ expect(container.querySelector('.MuiChip-icon [data-testid="RemoveCircleOutlineIcon"]')).not.toBeNull();
227
+ expect(toggle).toBeChecked();
228
+ fireEvent.click(toggle);
229
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(3, 'event.provider:("a" OR "b")');
230
+ });
231
+ it.each([
232
+ 'event.created:[a TO z]',
233
+ 'event.enabled:true',
234
+ 'other OR event.provider:"a"',
235
+ 'event.provider:NOT',
236
+ 'event.provider:("a" OR )',
237
+ 'event.provider:("a" AND "b")',
238
+ 'event.provider:"unterminated',
239
+ 'free text'
240
+ ])('toggles unsupported clauses without rewriting %s', clause => {
241
+ render(_jsx(HitFilter, { id: 5, value: clause }));
242
+ expect(screen.getByText(clause)).toBeInTheDocument();
243
+ fireEvent.click(screen.getByText(clause).closest('.MuiChip-root'));
244
+ expect(screen.getAllByText('change-filter').every(button => button.disabled)).toBe(true);
245
+ fireEvent.click(screen.getByRole('checkbox', { name: 'hit.search.filter.exclude' }));
246
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(5, `-${clause}`);
247
+ });
248
+ it('preserves an escaped literal star when toggling negation', () => {
249
+ const clause = 'event.provider:"\\*"';
250
+ render(_jsx(HitFilter, { id: 6, value: clause }));
251
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
252
+ fireEvent.click(screen.getByRole('checkbox', { name: 'hit.search.filter.exclude' }));
253
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(6, `-${clause}`);
254
+ });
255
+ it('keeps an escaped literal star when recommitting a negative scalar filter', () => {
256
+ const clause = '-event.provider:"\\*"';
257
+ render(_jsx(HitFilter, { id: 7, value: clause }));
258
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
259
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
260
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(7, clause);
261
+ });
262
+ it('retains negation and grouped values when editing a grouped filter', async () => {
263
+ render(_jsx(HitFilter, { id: 4, value: '-event.provider:("a" OR "b")' }));
264
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
265
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
266
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(4, '-event.provider:("a" OR "changed two")');
267
+ });
268
+ it('canonically escapes paths without losing literal backslashes when editing a grouped filter', () => {
269
+ const clause = String.raw `-event.provider:("C:\\logs" OR "other")`;
270
+ render(_jsx(HitFilter, { id: 8, value: clause }));
271
+ fireEvent.click(screen.getByRole('button', { name: /event.provider:/ }));
272
+ fireEvent.click(screen.getAllByText('change-filter')[1]);
273
+ expect(mockSetSavedFilter).toHaveBeenLastCalledWith(8, String.raw `-event.provider:("C\:\\logs" OR "new\\path")`);
274
+ });
83
275
  });
@@ -242,7 +242,11 @@
242
242
  "hit.search.custom": "Custom Sort",
243
243
  "hit.search.filter.add": "Add filter",
244
244
  "hit.search.filter.button": "Use lookup filters with predefined values",
245
+ "hit.search.filter.exclude": "Omit by this filter",
246
+ "hit.search.filter.excluded": "Omitting by",
245
247
  "hit.search.filter.fields": "Fields",
248
+ "hit.search.filter.include": "Include this filter",
249
+ "hit.search.filter.included": "Including by",
246
250
  "hit.search.filter.label": "Lookup Filters",
247
251
  "hit.search.filter.values": "Values",
248
252
  "hit.search.index": "Indexes",
@@ -323,11 +327,11 @@
323
327
  "membership.message.error": "There was an error when updating permissions.",
324
328
  "membership.message.success": "Permissions updated.",
325
329
  "membership.message.warning": "No users selected.",
326
- "membership.role": "Role",
327
330
  "membership.privilege.admins": "Administrator",
328
331
  "membership.privilege.manage": "Manage Privileges",
329
332
  "membership.privilege.members": "Member",
330
333
  "membership.privilege.owner": "Owner",
334
+ "membership.role": "Role",
331
335
  "modal.action.description": "Provide a name that allows other users to identify the purpose of this action.",
332
336
  "modal.action.empty": "Action Name cannot be empty.",
333
337
  "modal.action.label": "Action Name",
@@ -243,6 +243,10 @@
243
243
  "hit.search.filter.add": "Ajouter un filtre",
244
244
  "hit.search.filter.button": "Utiliser des filtres de recherche avec des valeurs prédéfinies",
245
245
  "hit.search.filter.fields": "Paramètres",
246
+ "hit.search.filter.exclude": "Exclure les résultats correspondant à ce filtre",
247
+ "hit.search.filter.excluded": "Filtre exclu",
248
+ "hit.search.filter.include": "Inclure ce filtre",
249
+ "hit.search.filter.included": "Filtre inclus",
246
250
  "hit.search.filter.label": "Filtres de recherche",
247
251
  "hit.search.filter.values": "Valeurs",
248
252
  "hit.search.index": "Indexes",
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.1697",
100
+ "version": "3.1.0-dev.1705",
101
101
  "exports": {
102
102
  "./i18n": "./i18n.js",
103
103
  "./index.css": "./index.css",
@@ -3,6 +3,8 @@ export declare const maxLenStr: (str: string, len: number) => string;
3
3
  export declare const safeFieldValue: (data: string | number | boolean) => string;
4
4
  export declare const safeFieldValueURI: (data: string | number | boolean) => string;
5
5
  export declare const sanitizeLuceneQuery: (query: string) => string;
6
+ export declare const unescapeLucene: (value: string) => string;
7
+ export declare const parseQuotedValues: (value: string) => string[] | null;
6
8
  export declare const safeStringPropertyCompare: (propertyPath: string) => (a: unknown, b: unknown) => any;
7
9
  export declare const sanitizeMultilineLucene: (query: string) => string;
8
10
  export declare const validateRegex: (regex: string) => boolean;
@@ -25,6 +25,38 @@ export const sanitizeLuceneQuery = (query) => {
25
25
  .replace('&&', '\\&&')
26
26
  .replace('||', '\\||');
27
27
  };
28
+ // Decode escaped literals, not full Lucene expressions; original escape spelling is not retained.
29
+ export const unescapeLucene = (value) => value.replace(/\\(.)/gs, '$1');
30
+ // Sticky matching consumes every character without repeatedly slicing the remaining clause.
31
+ export const parseQuotedValues = (value) => {
32
+ const unwrappedClause = value.replace(/^\((.*)\)$/s, '$1');
33
+ const clause = unwrappedClause.trim();
34
+ const parts = [];
35
+ const quoted = /"((?:\\.|[^"\\])*)"/sy;
36
+ const separator = /\s+OR\s+/y;
37
+ let offset = 0;
38
+ while (offset < clause.length) {
39
+ quoted.lastIndex = offset;
40
+ const match = quoted.exec(clause);
41
+ if (!match) {
42
+ return null;
43
+ }
44
+ parts.push(unescapeLucene(match[1] ?? ''));
45
+ offset = quoted.lastIndex;
46
+ if (offset === clause.length) {
47
+ return parts;
48
+ }
49
+ if (unwrappedClause === value) {
50
+ return null;
51
+ }
52
+ separator.lastIndex = offset;
53
+ if (!separator.exec(clause)) {
54
+ return null;
55
+ }
56
+ offset = separator.lastIndex;
57
+ }
58
+ return null;
59
+ };
28
60
  // Supports : prop or any form of nested object.. prop.object.prop2, prop.object[0].prop2
29
61
  export const safeStringPropertyCompare = (propertyPath) => {
30
62
  return (a, b) => {
@@ -1,6 +1,6 @@
1
1
  /// <reference types="vitest" />
2
2
  import { describe, expect, it } from 'vitest';
3
- import { maxLenStr, nameToInitials, parsePixelSizeStringToInt, safeFieldValue, safeFieldValueURI, safeStringPropertyCompare, sanitizeLuceneQuery, sanitizeMultilineLucene, validateRegex } from './stringUtils';
3
+ import { maxLenStr, nameToInitials, parsePixelSizeStringToInt, parseQuotedValues, safeFieldValue, safeFieldValueURI, safeStringPropertyCompare, sanitizeLuceneQuery, sanitizeMultilineLucene, unescapeLucene, validateRegex } from './stringUtils';
4
4
  describe('nameToInitials', () => {
5
5
  it('returns initials from a standard first-last name', () => {
6
6
  expect(nameToInitials('John Doe')).toEqual(['J', 'D']);
@@ -98,6 +98,43 @@ describe('sanitizeLuceneQuery', () => {
98
98
  expect(sanitizeLuceneQuery('plainterm')).toBe('plainterm');
99
99
  });
100
100
  });
101
+ describe('unescapeLucene', () => {
102
+ it.each([
103
+ '',
104
+ 'plain text',
105
+ '^"~*?:\\/()[]{}-!',
106
+ 'a && b && c || d || e',
107
+ String.raw `C:\logs\file`,
108
+ String.raw `backslash before quote: \"`,
109
+ 'line\nbreak',
110
+ 'trailing\\'
111
+ ])('reverses sanitizeLuceneQuery for %j', value => {
112
+ expect(unescapeLucene(sanitizeLuceneQuery(value))).toBe(value);
113
+ });
114
+ it('decodes noncanonical escapes to their literal value', () => {
115
+ expect(unescapeLucene(String.raw `\a\*`)).toBe('a*');
116
+ });
117
+ it('decodes an escaped newline', () => {
118
+ expect(unescapeLucene('\\\n')).toBe('\n');
119
+ });
120
+ it('leaves a trailing unmatched backslash unchanged', () => {
121
+ expect(unescapeLucene('trailing\\')).toBe('trailing\\');
122
+ });
123
+ });
124
+ describe('parseQuotedValues', () => {
125
+ it('parses and unescapes a quoted scalar', () => {
126
+ expect(parseQuotedValues(String.raw `"escaped\"quote"`)).toEqual(['escaped"quote']);
127
+ });
128
+ it('parses grouped alternatives without splitting OR inside a quoted value', () => {
129
+ expect(parseQuotedValues('( "a"\tOR\n"text OR more" )')).toEqual(['a', 'text OR more']);
130
+ });
131
+ it.each(['"a" OR "b"', '"a" OR "b" '])('rejects ungrouped alternatives: %j', clause => {
132
+ expect(parseQuotedValues(clause)).toBeNull();
133
+ });
134
+ it.each(['()', '("a" OR)', '("a" "b")', '("unterminated)'])('rejects malformed groups: %j', clause => {
135
+ expect(parseQuotedValues(clause)).toBeNull();
136
+ });
137
+ });
101
138
  describe('safeStringPropertyCompare', () => {
102
139
  const compare = safeStringPropertyCompare('name');
103
140
  it('returns a negative number when a sorts before b', () => {