@cccsaurora/howler-ui 3.1.0-dev.1697 → 3.1.0-dev.1700
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/app/providers/RecordSearchProvider.js +1 -1
- package/components/app/providers/RecordSearchProvider.test.js +31 -0
- package/components/elements/record/RecordContextMenu.js +35 -28
- package/components/elements/record/RecordContextMenu.test.js +18 -8
- package/components/routes/hits/search/QuerySettings.js +1 -1
- package/components/routes/hits/search/QuerySettings.test.js +10 -0
- package/components/routes/hits/search/shared/HitFilter.js +115 -45
- package/components/routes/hits/search/shared/HitFilter.test.js +205 -13
- package/locales/en/translation.json +5 -1
- package/locales/fr/translation.json +4 -0
- package/package.json +1 -1
- package/utils/stringUtils.d.ts +2 -0
- package/utils/stringUtils.js +32 -0
- package/utils/stringUtils.test.js +38 -1
|
@@ -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,28 @@ 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
|
+
expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
|
|
301
|
+
filters: expect.arrayContaining([
|
|
302
|
+
'event.provider:"azure"',
|
|
303
|
+
'-howler.outline.indicators:("a" OR "b")',
|
|
304
|
+
'event.provider:"\\*"',
|
|
305
|
+
'-event.provider:"\\*"'
|
|
306
|
+
])
|
|
307
|
+
}));
|
|
308
|
+
});
|
|
309
|
+
});
|
|
279
310
|
it('should reset offset if response total is less than current offset', async () => {
|
|
280
311
|
mockParameterContext.offset = 100;
|
|
281
312
|
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
|
|
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
|
-
...(
|
|
193
|
-
...
|
|
190
|
+
...Object.keys(record.howler.outline ?? {}).map(key => `howler.outline.${key}`),
|
|
191
|
+
...(template?.keys ?? [])
|
|
194
192
|
]);
|
|
195
|
-
if (!isEmpty(filterKeys) &&
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
return [];
|
|
218
|
-
}
|
|
219
|
-
newQuery += `-${key}:(${sanitizedValues.join(' OR ')})`;
|
|
211
|
+
filter = `-${key}:(${sanitizedValues.join(' OR ')})`;
|
|
220
212
|
}
|
|
221
|
-
else {
|
|
222
|
-
|
|
213
|
+
else if (!Array.isArray(value)) {
|
|
214
|
+
filter = `-${key}:"${sanitizeLuceneQuery(value.toString())}"`;
|
|
223
215
|
}
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
filter = `${key}:(${sanitizedValues.join(' OR ')})`;
|
|
245
|
+
}
|
|
246
|
+
else if (!Array.isArray(value)) {
|
|
247
|
+
filter = `${key}:"${sanitizeLuceneQuery(value.toString())}"`;
|
|
247
248
|
}
|
|
248
|
-
|
|
249
|
-
|
|
249
|
+
if (!filter) {
|
|
250
|
+
return [];
|
|
250
251
|
}
|
|
251
|
-
return [
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 [
|
|
28
|
-
const [
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
setFilter(null);
|
|
48
|
-
if (!_category) {
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!category || !editable || !isNil(configuredLookup)) {
|
|
49
103
|
return;
|
|
50
104
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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: '
|
|
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
|
-
|
|
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
package/utils/stringUtils.d.ts
CHANGED
|
@@ -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;
|
package/utils/stringUtils.js
CHANGED
|
@@ -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', () => {
|