@cccsaurora/howler-ui 2.19.0-cases.1111 → 2.19.0-cases.1129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/api/v2/fuzzy.d.ts +17 -0
  2. package/api/v2/fuzzy.js +11 -0
  3. package/api/v2/index.d.ts +2 -1
  4. package/api/v2/index.js +2 -1
  5. package/components/app/App.js +6 -1
  6. package/components/app/providers/ParameterProvider.d.ts +16 -1
  7. package/components/app/providers/ParameterProvider.js +23 -22
  8. package/components/app/providers/ParameterProvider.test.js +12 -0
  9. package/components/elements/addons/search/SearchTotal.js +1 -1
  10. package/components/elements/addons/search/SearchTotal.test.js +2 -2
  11. package/components/elements/display/QueryResultText.d.ts +1 -0
  12. package/components/elements/display/QueryResultText.js +2 -2
  13. package/components/elements/search/FuzzySearchBar.d.ts +7 -0
  14. package/components/elements/search/FuzzySearchBar.js +33 -0
  15. package/components/elements/search/FuzzySearchBar.test.d.ts +1 -0
  16. package/components/elements/search/FuzzySearchBar.test.js +87 -0
  17. package/components/routes/cases/detail/CaseRules.js +5 -7
  18. package/components/routes/cases/detail/CaseRules.test.js +18 -1
  19. package/components/routes/cases/detail/CaseSearch.d.ts +3 -0
  20. package/components/routes/cases/detail/CaseSearch.js +78 -0
  21. package/components/routes/cases/detail/CaseSidebar.js +2 -2
  22. package/components/routes/cases/detail/CreateRuleDialog.js +37 -55
  23. package/components/routes/cases/detail/CreateRuleDialog.test.js +47 -22
  24. package/components/routes/cases/detail/sidebar/CaseFolder.test.js +1 -0
  25. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +3 -2
  26. package/components/routes/hits/search/shared/IndexPicker.d.ts +7 -1
  27. package/components/routes/hits/search/shared/IndexPicker.js +4 -3
  28. package/locales/en/translation.json +33 -19
  29. package/locales/fr/translation.json +33 -19
  30. package/models/entities/generated/Case.d.ts +1 -0
  31. package/models/entities/generated/Rule.d.ts +3 -1
  32. package/package.json +2 -1
  33. package/tests/utils.js +1 -0
@@ -0,0 +1,17 @@
1
+ import type { HowlerSearchResponse } from '@cccsaurora/howler-ui/api/search';
2
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
3
+ import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
4
+ import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
5
+ export type FuzzySearchRequest = {
6
+ query: string;
7
+ indexes?: string[];
8
+ filters?: string[];
9
+ offset?: number;
10
+ rows?: number;
11
+ track_total_hits?: boolean;
12
+ };
13
+ export type FuzzySearchItem<T = Hit | Event | Case> = T & {
14
+ _score: number;
15
+ };
16
+ export declare const uri: () => string;
17
+ export declare const post: (request: FuzzySearchRequest) => Promise<HowlerSearchResponse<FuzzySearchItem>>;
@@ -0,0 +1,11 @@
1
+ import { hpost, joinAllUri } from '@cccsaurora/howler-ui/api';
2
+ import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2';
3
+ export const uri = () => {
4
+ return joinAllUri(parentUri(), 'fuzzy');
5
+ };
6
+ export const post = (request) => {
7
+ if (!request.query || !request.query.trim()) {
8
+ throw new Error('Search query is required.');
9
+ }
10
+ return hpost(joinAllUri(uri(), 'search'), request);
11
+ };
package/api/v2/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
2
+ import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
2
3
  import * as search from '@cccsaurora/howler-ui/api/v2/search';
3
4
  export declare const uri: () => string;
4
- export { case_ as case, search };
5
+ export { case_ as case, fuzzy, search };
package/api/v2/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as case_ from '@cccsaurora/howler-ui/api/v2/case';
2
+ import * as fuzzy from '@cccsaurora/howler-ui/api/v2/fuzzy';
2
3
  import * as search from '@cccsaurora/howler-ui/api/v2/search';
3
4
  export const uri = () => {
4
5
  return '/api/v2';
5
6
  };
6
- export { case_ as case, search };
7
+ export { case_ as case, fuzzy, search };
@@ -31,6 +31,7 @@ import Cases from '@cccsaurora/howler-ui/components/routes/cases/Cases';
31
31
  import CaseDashboard from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseDashboard';
32
32
  import CaseObservables from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseObservables';
33
33
  import CaseRules from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseRules';
34
+ import CaseSearch from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseSearch';
34
35
  import CaseTimeline from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseTimeline';
35
36
  import ItemPage from '@cccsaurora/howler-ui/components/routes/cases/detail/ItemPage';
36
37
  import DossierEditor from '@cccsaurora/howler-ui/components/routes/dossiers/DossierEditor';
@@ -207,7 +208,7 @@ const createRouter = () => createBrowserRouter([
207
208
  },
208
209
  {
209
210
  path: 'cases/:id',
210
- element: (_jsx(ParameterProvider, { children: _jsx(CaseViewer, {}) })),
211
+ element: (_jsx(ParameterProvider, { defaults: { query: '', indexes: ['hit', 'event', 'case'] }, children: _jsx(CaseViewer, {}) })),
211
212
  children: [
212
213
  {
213
214
  index: true,
@@ -225,6 +226,10 @@ const createRouter = () => createBrowserRouter([
225
226
  path: 'rules',
226
227
  element: _jsx(CaseRules, {})
227
228
  },
229
+ {
230
+ path: 'search',
231
+ element: _jsx(CaseSearch, {})
232
+ },
228
233
  {
229
234
  path: '*',
230
235
  element: _jsx(ItemPage, {})
@@ -32,10 +32,25 @@ export interface ParameterContextType {
32
32
  setView: (index: number, view: string) => void;
33
33
  resetViews: () => void;
34
34
  }
35
+ interface SearchValues {
36
+ selected: string;
37
+ query: string;
38
+ sort: string;
39
+ span: string;
40
+ indexes: SearchIndex[];
41
+ filters: string[];
42
+ views: string[];
43
+ startDate: string;
44
+ endDate: string;
45
+ offset: number;
46
+ trackTotalHits: boolean;
47
+ }
35
48
  export declare const ParameterContext: import("use-context-selector").Context<ParameterContextType>;
36
49
  /**
37
50
  * Context responsible for tracking updates to query operations in hit and view search.
38
51
  */
39
- declare const ParameterProvider: FC<PropsWithChildren>;
52
+ declare const ParameterProvider: FC<PropsWithChildren<{
53
+ defaults?: Partial<SearchValues>;
54
+ }>>;
40
55
  export declare const useParameterContextSelector: <Selected>(selector: (value: ParameterContextType) => Selected) => Selected;
41
56
  export default ParameterProvider;
@@ -24,7 +24,7 @@ const PARAM_MAPPINGS = [
24
24
  const ARRAY_PARAMS = [
25
25
  { urlKey: 'filter', stateKey: 'filters' },
26
26
  { urlKey: 'view', stateKey: 'views' },
27
- { urlKey: 'index', stateKey: 'indexes', default: DEFAULT_VALUES.indexes }
27
+ { urlKey: 'index', stateKey: 'indexes' }
28
28
  ];
29
29
  const ARRAY_URL_KEYS = new Set(ARRAY_PARAMS.map(p => p.urlKey));
30
30
  const WRITE_THROTTLER = new Throttler(100);
@@ -85,7 +85,7 @@ const useListHandlers = (key, _setValues) => {
85
85
  /**
86
86
  * Synchronizes SearchValues state with the URL search string, and vice-versa.
87
87
  */
88
- const useUrlSync = (values, _setValues, params, setParams, pathname, search, routeId) => {
88
+ const useUrlSync = (values, defaults, _setValues, params, setParams, pathname, search, routeId) => {
89
89
  const getUrlFromState = useCallback(() => {
90
90
  const changes = {};
91
91
  // Scalar params: write if changed from URL, remove if back to default
@@ -94,20 +94,21 @@ const useUrlSync = (values, _setValues, params, setParams, pathname, search, rou
94
94
  const urlValue = params.get(urlKey);
95
95
  if (stateValue === urlValue)
96
96
  return;
97
- if (params.has(urlKey) && stateValue === DEFAULT_VALUES[stateKey]) {
97
+ if (params.has(urlKey) && stateValue === defaults[stateKey]) {
98
98
  changes[urlKey] = null; // remove
99
99
  }
100
- else if (stateValue !== DEFAULT_VALUES[stateKey]) {
100
+ else if (stateValue !== defaults[stateKey]) {
101
101
  changes[urlKey] = stateValue; // write
102
102
  }
103
103
  });
104
104
  // Array params: skip when state equals default and URL is already empty
105
- ARRAY_PARAMS.forEach(({ urlKey, stateKey, default: def }) => {
105
+ ARRAY_PARAMS.forEach(({ urlKey, stateKey }) => {
106
106
  const stateArr = values[stateKey];
107
107
  const urlArr = params.getAll(urlKey);
108
+ const defaultValue = stateKey === 'indexes' ? defaults.indexes : undefined;
108
109
  if (isEqual(stateArr, urlArr))
109
110
  return;
110
- const isDefault = def ? isEqual(stateArr, def) : stateArr.length === 0;
111
+ const isDefault = defaultValue ? isEqual(stateArr, defaultValue) : stateArr.length === 0;
111
112
  if (!isDefault) {
112
113
  changes[urlKey] = stateArr.length === 0 ? null : stateArr;
113
114
  }
@@ -128,20 +129,21 @@ const useUrlSync = (values, _setValues, params, setParams, pathname, search, rou
128
129
  }
129
130
  // Drop scalar entries that already match the URL
130
131
  return omitBy(changes, (val, key) => !ARRAY_URL_KEYS.has(key) && val == params.get(key));
131
- }, [values, params, pathname]);
132
+ }, [values, defaults, params, pathname]);
132
133
  const getStateFromUrl = useCallback(() => {
133
134
  const changes = {};
134
135
  // Scalar params: fall back to default when absent from URL
135
136
  PARAM_MAPPINGS.forEach(([urlKey, stateKey]) => {
136
- const urlValue = params.has(urlKey) ? params.get(urlKey) : (DEFAULT_VALUES[stateKey] ?? undefined);
137
+ const urlValue = params.has(urlKey) ? params.get(urlKey) : (defaults[stateKey] ?? undefined);
137
138
  if (urlValue !== values[stateKey]) {
138
139
  changes[stateKey] = urlValue;
139
140
  }
140
141
  });
141
142
  // Array params: fall back to their declared default when absent from URL
142
- ARRAY_PARAMS.forEach(({ urlKey, stateKey, default: def }) => {
143
+ ARRAY_PARAMS.forEach(({ urlKey, stateKey }) => {
143
144
  const raw = params.getAll(urlKey);
144
- const resolved = (isEmpty(raw) && def ? def : uniq(raw));
145
+ const defaultValue = stateKey === 'indexes' ? defaults.indexes : undefined;
146
+ const resolved = (isEmpty(raw) && defaultValue ? defaultValue : uniq(raw));
145
147
  if (!isEqual(resolved, values[stateKey])) {
146
148
  changes[stateKey] = resolved;
147
149
  }
@@ -155,7 +157,7 @@ const useUrlSync = (values, _setValues, params, setParams, pathname, search, rou
155
157
  if (urlOffset !== values.offset)
156
158
  changes.offset = urlOffset;
157
159
  return omitBy(omitBy(changes, isUndefined), (val, key) => val == values[key]);
158
- }, [values, params, pathname, routeId]);
160
+ }, [values, defaults, params, pathname, routeId]);
159
161
  // State → URL
160
162
  useEffect(() => {
161
163
  const changes = getUrlFromState();
@@ -191,19 +193,18 @@ const useUrlSync = (values, _setValues, params, setParams, pathname, search, rou
191
193
  /**
192
194
  * Context responsible for tracking updates to query operations in hit and view search.
193
195
  */
194
- const ParameterProvider = ({ children }) => {
196
+ const ParameterProvider = ({ children, defaults: _defaults = {} }) => {
195
197
  const location = useLocation();
196
198
  const routeParams = useParams();
197
199
  const [params, setParams] = useSearchParams();
200
+ const defaults = useMemo(() => ({ ...DEFAULT_VALUES, ..._defaults }), [_defaults]);
198
201
  const pendingChanges = useRef({});
199
202
  const [values, _setValues] = useState({
200
203
  selected: getSelectedValue(params, location.pathname, routeParams.id),
201
- query: params.get('query') ?? DEFAULT_VALUES.query,
202
- sort: params.get('sort') ?? DEFAULT_VALUES.sort,
203
- span: params.get('span') ?? DEFAULT_VALUES.span,
204
- indexes: params.has('index')
205
- ? uniq(params.getAll('index')).filter(identity)
206
- : DEFAULT_VALUES.indexes,
204
+ query: params.get('query') ?? defaults.query,
205
+ sort: params.get('sort') ?? defaults.sort,
206
+ span: params.get('span') ?? defaults.span,
207
+ indexes: params.has('index') ? uniq(params.getAll('index')).filter(identity) : defaults.indexes,
207
208
  filters: params.getAll('filter'),
208
209
  views: params.getAll('view'),
209
210
  startDate: params.get('start_date'),
@@ -212,7 +213,7 @@ const ParameterProvider = ({ children }) => {
212
213
  trackTotalHits: (params.get('track_total_hits') ?? 'false') !== 'false'
213
214
  });
214
215
  // TODO: SELECTING A BUNDLE STILL CAUSES A FREAKOUT
215
- useUrlSync(values, _setValues, params, setParams, location.pathname, location.search, routeParams.id);
216
+ useUrlSync(values, defaults, _setValues, params, setParams, location.pathname, location.search, routeParams.id);
216
217
  const set = useCallback((key) => (value) => {
217
218
  if (value === values[key])
218
219
  return;
@@ -220,7 +221,7 @@ const ParameterProvider = ({ children }) => {
220
221
  pendingChanges.current.selected = value;
221
222
  }
222
223
  else {
223
- pendingChanges.current[key] = value ?? DEFAULT_VALUES[key] ?? null;
224
+ pendingChanges.current[key] = value ?? defaults[key] ?? null;
224
225
  }
225
226
  if (key === 'span' && typeof value === 'string' && !value.endsWith('custom')) {
226
227
  pendingChanges.current.startDate = null;
@@ -230,7 +231,7 @@ const ParameterProvider = ({ children }) => {
230
231
  _setValues(c => ({ ...c, ...pendingChanges.current }));
231
232
  pendingChanges.current = {};
232
233
  });
233
- }, [values]);
234
+ }, [values, defaults]);
234
235
  const setOffset = useCallback((_offset) => _setValues(c => ({ ...c, offset: parseOffset(_offset) })), []);
235
236
  const setCustomSpan = useCallback((startDate, endDate) => _setValues(c => ({ ...c, startDate, endDate })), []);
236
237
  const filters = useListHandlers('filters', _setValues);
@@ -252,7 +253,7 @@ const ParameterProvider = ({ children }) => {
252
253
  removeIndex: indexes.remove,
253
254
  setIndex: indexes.setAt,
254
255
  setIndexes: indexes.setAll,
255
- resetIndexes: useCallback(() => indexes.reset(DEFAULT_VALUES.indexes), [indexes]),
256
+ resetIndexes: useCallback(() => indexes.reset(defaults.indexes), [indexes, defaults]),
256
257
  addView: views.add,
257
258
  removeView: views.remove,
258
259
  setView: views.setAt,
@@ -579,6 +579,18 @@ describe('ParameterContext', () => {
579
579
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.selected), { wrapper: Wrapper });
580
580
  expect(hook.result.current).toBe('different_hit_id');
581
581
  });
582
+ it('should not sync query when default query differs from global default', async () => {
583
+ const CustomDefaultWrapper = ({ children }) => {
584
+ return _jsx(ParameterProvider, { defaults: { query: '' }, children: children });
585
+ };
586
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.query), {
587
+ wrapper: CustomDefaultWrapper
588
+ });
589
+ expect(hook.result.current).toBe('');
590
+ await waitFor(() => {
591
+ expect(mockSetParams).not.toHaveBeenCalled();
592
+ });
593
+ });
582
594
  });
583
595
  describe('useParameterContextSelector', () => {
584
596
  it('should allow selecting specific values from context', async () => {
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Typography } from '@mui/material';
3
3
  import { Trans } from 'react-i18next';
4
4
  const SearchTotal = ({ total, offset, pageLength, ...typographyProps }) => {
5
- return (_jsx(Typography, { ...typographyProps, children: total <= 1 ? (_jsx(Trans, { i18nKey: "search.result.showing.single", values: {
5
+ return (_jsx(Typography, { ...typographyProps, children: total < 1 ? (_jsx(Trans, { i18nKey: "search.result.showing.single", values: {
6
6
  total: total
7
7
  } })) : (_jsx(Trans, { i18nKey: "search.result.showing", values: {
8
8
  total: total,
@@ -15,9 +15,9 @@ describe('SearchTotal', () => {
15
15
  render(_jsx(SearchTotal, { total: 0, offset: 0, pageLength: 0 }), { wrapper: Wrapper });
16
16
  expect(screen.getByText('No results')).toBeInTheDocument();
17
17
  });
18
- it('renders "No results" text when total is 1', () => {
18
+ it('renders "Showing 1 to 1 of 1 results" text when total is 1', () => {
19
19
  render(_jsx(SearchTotal, { total: 1, offset: 0, pageLength: 1 }), { wrapper: Wrapper });
20
- expect(screen.getByText('No results')).toBeInTheDocument();
20
+ expect(screen.getByText('Showing 1 to 1 of 1 results')).toBeInTheDocument();
21
21
  });
22
22
  it('renders range text when total is greater than 1', () => {
23
23
  render(_jsx(SearchTotal, { total: 50, offset: 0, pageLength: 25 }), { wrapper: Wrapper });
@@ -2,5 +2,6 @@ import type { FC } from 'react';
2
2
  declare const QueryResultText: FC<{
3
3
  count: number;
4
4
  query: string;
5
+ mb?: number;
5
6
  }>;
6
7
  export default QueryResultText;
@@ -2,13 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Typography } from '@mui/material';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Link } from 'react-router-dom';
5
- const QueryResultText = ({ count, query }) => {
5
+ const QueryResultText = ({ count, query, mb = 0.5 }) => {
6
6
  const { t } = useTranslation();
7
7
  return (_jsxs(Typography, { sx: theme => ({
8
8
  color: theme.palette.text.secondary,
9
9
  fontSize: '0.9em',
10
10
  fontStyle: 'italic',
11
- mb: 0.5,
11
+ mb,
12
12
  '& a': { color: theme.palette.text.secondary }
13
13
  }), variant: "body2", children: [t('search.total', { count }), " ", _jsx(Link, { to: `/hits?query=${encodeURIComponent(query)}`, children: t('search.open') })] }));
14
14
  };
@@ -0,0 +1,7 @@
1
+ import type { FC } from 'react';
2
+ export type FuzzySearchBarProps = {
3
+ onSearch: (query: string, indexes: string[]) => void;
4
+ loading?: boolean;
5
+ };
6
+ declare const FuzzySearchBar: FC<FuzzySearchBarProps>;
7
+ export default FuzzySearchBar;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Search } from '@mui/icons-material';
3
+ import { CircularProgress, IconButton, InputAdornment, Stack, TextField } from '@mui/material';
4
+ import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
5
+ import IndexPicker from '@cccsaurora/howler-ui/components/routes/hits/search/shared/IndexPicker';
6
+ import { useCallback, useState } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+ import { useContextSelector } from 'use-context-selector';
9
+ const FuzzySearchBar = ({ onSearch, loading = false }) => {
10
+ const { t } = useTranslation();
11
+ const indexes = useContextSelector(ParameterContext, ctx => ctx.indexes);
12
+ const defaultQuery = useContextSelector(ParameterContext, ctx => ctx.query);
13
+ const [query, _setQuery] = useState(defaultQuery ?? '');
14
+ const setQuery = useContextSelector(ParameterContext, ctx => ctx.setQuery);
15
+ const handleQueryChange = useCallback((e) => {
16
+ _setQuery(e.target.value);
17
+ }, [_setQuery]);
18
+ const handleSearch = useCallback(() => {
19
+ if (query.trim()) {
20
+ setQuery(query.trim());
21
+ onSearch(query.trim(), indexes);
22
+ }
23
+ }, [query, setQuery, onSearch, indexes]);
24
+ const handleKeyDown = useCallback((e) => {
25
+ if (e.key === 'Enter') {
26
+ handleSearch();
27
+ }
28
+ }, [handleSearch]);
29
+ return (_jsxs(Stack, { spacing: 1, children: [_jsx(TextField, { id: "fuzzy-search-input", size: "small", fullWidth: true, variant: "outlined", placeholder: t('search.fuzzy.placeholder'), value: query, onChange: handleQueryChange, onKeyDown: handleKeyDown, InputProps: {
30
+ endAdornment: (_jsx(InputAdornment, { position: "end", children: loading ? (_jsx(CircularProgress, { size: 24 })) : (_jsx(IconButton, { id: "fuzzy-search-button", onClick: handleSearch, edge: "end", disabled: !query.trim(), children: _jsx(Search, {}) })) }))
31
+ } }), _jsx(Stack, { direction: "row", children: _jsx(IndexPicker, { additionalOptions: [{ label: 'hit.search.index.case', value: 'case' }] }) })] }));
32
+ };
33
+ export default FuzzySearchBar;
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent, {} from '@testing-library/user-event';
4
+ import { setupContextSelectorMock } from '@cccsaurora/howler-ui/tests/mocks';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ setupContextSelectorMock();
7
+ const mockIndexPickerProps = vi.hoisted(() => ({
8
+ last: null
9
+ }));
10
+ vi.mock('react-i18next', () => ({
11
+ useTranslation: () => ({
12
+ t: (key) => key
13
+ })
14
+ }));
15
+ vi.mock('components/routes/hits/search/shared/IndexPicker', () => ({
16
+ default: (props) => {
17
+ mockIndexPickerProps.last = props;
18
+ return _jsx("div", { id: "index-picker" });
19
+ }
20
+ }));
21
+ import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
22
+ import FuzzySearchBar from './FuzzySearchBar';
23
+ const mockOnSearch = vi.fn();
24
+ const mockSetQuery = vi.fn();
25
+ const parameterContextValue = {
26
+ indexes: ['hit', 'case'],
27
+ query: '',
28
+ setQuery: mockSetQuery
29
+ };
30
+ const Wrapper = ({ children }) => {
31
+ return _jsx(ParameterContext.Provider, { value: parameterContextValue, children: children });
32
+ };
33
+ describe('FuzzySearchBar', () => {
34
+ let user;
35
+ beforeEach(() => {
36
+ user = userEvent.setup();
37
+ vi.clearAllMocks();
38
+ parameterContextValue.indexes = ['hit', 'case'];
39
+ parameterContextValue.query = '';
40
+ mockIndexPickerProps.last = null;
41
+ });
42
+ it('renders search input and index picker', () => {
43
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch }), { wrapper: Wrapper });
44
+ expect(screen.getByTestId('fuzzy-search-input')).toBeInTheDocument();
45
+ expect(screen.getByTestId('index-picker')).toBeInTheDocument();
46
+ });
47
+ it('disables search button when query is empty', () => {
48
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch }), { wrapper: Wrapper });
49
+ expect(screen.getByTestId('fuzzy-search-button')).toBeDisabled();
50
+ });
51
+ it('calls setQuery and onSearch with trimmed query and selected indexes on click', async () => {
52
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch }), { wrapper: Wrapper });
53
+ await user.type(screen.getByRole('textbox'), ' malware triage ');
54
+ await user.click(screen.getByTestId('fuzzy-search-button'));
55
+ expect(mockSetQuery).toHaveBeenCalledWith('malware triage');
56
+ expect(mockOnSearch).toHaveBeenCalledWith('malware triage', ['hit', 'case']);
57
+ });
58
+ it('triggers search on Enter key', async () => {
59
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch }), { wrapper: Wrapper });
60
+ await user.type(screen.getByRole('textbox'), 'suspicious ip{enter}');
61
+ expect(mockSetQuery).toHaveBeenCalledWith('suspicious ip');
62
+ expect(mockOnSearch).toHaveBeenCalledWith('suspicious ip', ['hit', 'case']);
63
+ });
64
+ it('does not call search for whitespace-only input', async () => {
65
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch }), { wrapper: Wrapper });
66
+ await user.type(screen.getByRole('textbox'), ' ');
67
+ expect(screen.getByTestId('fuzzy-search-button')).toBeDisabled();
68
+ expect(mockSetQuery).not.toHaveBeenCalled();
69
+ expect(mockOnSearch).not.toHaveBeenCalled();
70
+ });
71
+ it('shows loading spinner and hides search button when loading', () => {
72
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch, loading: true }), { wrapper: Wrapper });
73
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
74
+ expect(screen.queryByTestId('fuzzy-search-button')).not.toBeInTheDocument();
75
+ });
76
+ it('passes expected options to IndexPicker', () => {
77
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch }), { wrapper: Wrapper });
78
+ expect(mockIndexPickerProps.last).toEqual({
79
+ additionalOptions: [{ label: 'hit.search.index.case', value: 'case' }]
80
+ });
81
+ });
82
+ it('initializes input with default query from context', () => {
83
+ parameterContextValue.query = 'existing search';
84
+ render(_jsx(FuzzySearchBar, { onSearch: mockOnSearch }), { wrapper: Wrapper });
85
+ expect(screen.getByRole('textbox')).toHaveValue('existing search');
86
+ });
87
+ });
@@ -5,7 +5,6 @@ import api from '@cccsaurora/howler-ui/api';
5
5
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
6
  import ConfirmDeleteModal from '@cccsaurora/howler-ui/components/elements/display/modals/ConfirmDeleteModal';
7
7
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
8
- import dayjs from 'dayjs';
9
8
  import { useCallback, useContext, useState } from 'react';
10
9
  import { useTranslation } from 'react-i18next';
11
10
  import { useOutletContext } from 'react-router-dom';
@@ -47,11 +46,10 @@ const CaseRules = ({ case: providedCase, caseId }) => {
47
46
  return null;
48
47
  }
49
48
  const rules = _case.rules ?? [];
50
- return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => {
51
- const _timeframe = rule.timeframe ? dayjs(rule.timeframe) : null;
52
- return (_jsxs(TableRow, { id: `rule-row-${rule.rule_id}`, children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: { fontFamily: 'monospace' }, children: rule.destination }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: {
53
- fontFamily: 'monospace'
54
- }, children: rule.query }) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", spacing: 0.5, children: (rule.indexes ?? ['hit']).map(idx => (_jsx(Chip, { size: "small", label: idx, variant: "outlined" }, idx))) }) }), _jsx(TableCell, { children: _timeframe ? (_jsx(Chip, { size: "small", label: _timeframe.format('YYYY-MM-DD HH:mm'), color: _timeframe.isBefore(dayjs()) ? 'error' : 'default' })) : (_jsx(Chip, { size: "small", label: t('page.cases.rules.no_expiry'), color: "success" })) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", children: rule.author }) }), _jsx(TableCell, { align: "center", children: _jsx(Switch, { id: `rule-toggle-${rule.rule_id}`, checked: rule.enabled ?? true, onChange: (_e, checked) => handleToggleEnabled(rule.rule_id, checked), size: "small" }) }), _jsx(TableCell, { align: "right", children: _jsx(Tooltip, { title: t('delete'), children: _jsx(IconButton, { id: `rule-delete-${rule.rule_id}`, size: "small", onClick: () => handleDeleteRule(rule.rule_id), children: _jsx(Delete, { fontSize: "small" }) }) }) })] }, rule.rule_id));
55
- }) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
49
+ return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => (_jsxs(TableRow, { id: `rule-row-${rule.rule_id}`, children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: { fontFamily: 'monospace' }, children: rule.destination }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: {
50
+ fontFamily: 'monospace'
51
+ }, children: rule.query }) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", spacing: 0.5, children: (rule.indexes ?? ['hit']).map(idx => (_jsx(Chip, { size: "small", label: idx, variant: "outlined" }, idx))) }) }), _jsx(TableCell, { children: rule.timeframe != null ? (_jsx(Chip, { size: "small", label: rule.expire_after_resolved
52
+ ? t('page.cases.rules.timeframe.after_resolved', { days: rule.timeframe })
53
+ : t('page.cases.rules.timeframe.days', { days: rule.timeframe }), color: rule.expire_after_resolved ? 'info' : 'default' })) : (_jsx(Chip, { size: "small", label: t('page.cases.rules.no_expiry'), color: "success" })) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", children: rule.author }) }), _jsx(TableCell, { align: "center", children: _jsx(Switch, { id: `rule-toggle-${rule.rule_id}`, checked: rule.enabled ?? true, onChange: (_e, checked) => handleToggleEnabled(rule.rule_id, checked), size: "small" }) }), _jsx(TableCell, { align: "right", children: _jsx(Tooltip, { title: t('delete'), children: _jsx(IconButton, { id: `rule-delete-${rule.rule_id}`, size: "small", onClick: () => handleDeleteRule(rule.rule_id), children: _jsx(Delete, { fontSize: "small" }) }) }) })] }, rule.rule_id))) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
56
54
  };
57
55
  export default CaseRules;
@@ -68,7 +68,8 @@ const makeRule = (overrides) => ({
68
68
  destination: 'alerts/{{howler.analytic}}',
69
69
  author: 'analyst1',
70
70
  enabled: true,
71
- timeframe: '2026-06-01T00:00:00.000Z',
71
+ timeframe: 14,
72
+ expire_after_resolved: false,
72
73
  indexes: ['hit'],
73
74
  ...overrides
74
75
  });
@@ -104,6 +105,22 @@ describe('CaseRules', () => {
104
105
  render(_jsx(CaseRules, {}));
105
106
  expect(screen.getByText('page.cases.rules.no_expiry')).toBeInTheDocument();
106
107
  });
108
+ it('shows after-resolved chip when expire_after_resolved is true', () => {
109
+ mockCase.current = createMockCase({
110
+ case_id: 'case-001',
111
+ rules: [makeRule({ timeframe: 7, expire_after_resolved: true })]
112
+ });
113
+ render(_jsx(CaseRules, {}));
114
+ expect(screen.getByText('page.cases.rules.timeframe.after_resolved')).toBeInTheDocument();
115
+ });
116
+ it('shows days chip when expire_after_resolved is false', () => {
117
+ mockCase.current = createMockCase({
118
+ case_id: 'case-001',
119
+ rules: [makeRule({ timeframe: 14, expire_after_resolved: false })]
120
+ });
121
+ render(_jsx(CaseRules, {}));
122
+ expect(screen.getByText('page.cases.rules.timeframe.days')).toBeInTheDocument();
123
+ });
107
124
  it('renders enabled toggle for each rule', () => {
108
125
  mockCase.current = createMockCase({
109
126
  case_id: 'case-001',
@@ -0,0 +1,3 @@
1
+ import type { FC } from 'react';
2
+ declare const CaseSearch: FC;
3
+ export default CaseSearch;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Skeleton, Stack, Typography } from '@mui/material';
3
+ import api from '@cccsaurora/howler-ui/api';
4
+ import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
5
+ import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
6
+ import SearchPagination from '@cccsaurora/howler-ui/components/elements/addons/search/SearchPagination';
7
+ import SearchTotal from '@cccsaurora/howler-ui/components/elements/addons/search/SearchTotal';
8
+ import CaseCard from '@cccsaurora/howler-ui/components/elements/case/CaseCard';
9
+ import EventCard from '@cccsaurora/howler-ui/components/elements/event/EventCard';
10
+ import HitCard from '@cccsaurora/howler-ui/components/elements/hit/HitCard';
11
+ import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
12
+ import FuzzySearchBar from '@cccsaurora/howler-ui/components/elements/search/FuzzySearchBar';
13
+ import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
14
+ import { useCallback, useEffect, useMemo, useState } from 'react';
15
+ import { useOutletContext } from 'react-router-dom';
16
+ import { useContextSelector } from 'use-context-selector';
17
+ import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
18
+ import { isCase, isEvent, isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
19
+ const CaseSearch = () => {
20
+ const parentCase = useOutletContext();
21
+ const indexes = useContextSelector(ParameterContext, ctx => ctx.indexes);
22
+ const query = useContextSelector(ParameterContext, ctx => ctx.query);
23
+ const [hitLayout] = useMyLocalStorageItem(StorageKey.HIT_LAYOUT, HitLayout.NORMAL);
24
+ const [loading, setLoading] = useState(false);
25
+ const [response, setResponse] = useState(null);
26
+ const [error, setError] = useState(null);
27
+ const [offset, setOffset] = useState(0);
28
+ const caseIds = useMemo(() => parentCase
29
+ ? [
30
+ parentCase.case_id,
31
+ ...(parentCase.items ?? []).filter(item => item.type === 'case').map(item => item.value)
32
+ ].filter((id) => !!id)
33
+ : [], [parentCase]);
34
+ const handleSearch = useCallback(async (_query, _indexes) => {
35
+ setLoading(true);
36
+ setError(null);
37
+ try {
38
+ // If no indexes specified, search across all types
39
+ const searchIndexes = _indexes.length > 0 ? _indexes : ['case', 'hit', 'event'];
40
+ // Add case_id to the filters to scope the search to this case and its sub-cases
41
+ const filters = parentCase?.case_id
42
+ ? [`case_id:(${caseIds.join(' OR ')}) OR howler.related:(${caseIds.join(' OR ')})`]
43
+ : [];
44
+ setResponse(await api.v2.fuzzy.post({
45
+ query: _query,
46
+ indexes: searchIndexes,
47
+ rows: 25,
48
+ filters,
49
+ offset
50
+ }));
51
+ }
52
+ catch (err) {
53
+ setError(err.message || 'An error occurred while searching.');
54
+ setResponse(null);
55
+ }
56
+ finally {
57
+ setLoading(false);
58
+ }
59
+ }, [caseIds, offset, parentCase?.case_id]);
60
+ useEffect(() => {
61
+ if (query) {
62
+ handleSearch(query, indexes);
63
+ }
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [indexes, offset]);
66
+ return (_jsx(PageCenter, { maxWidth: "lg", textAlign: "left", children: _jsxs(Stack, { spacing: 1, children: [_jsx(FuzzySearchBar, { onSearch: handleSearch, loading: loading }), response && (_jsxs(Stack, { direction: "row", alignItems: "center", sx: { pt: 1 }, children: [_jsx(SearchTotal, { total: response.total, pageLength: response.items.length, offset: response.offset, sx: theme => ({ color: theme.palette.text.secondary, fontSize: '0.9em', fontStyle: 'italic' }) }), _jsx(Box, { flex: 1 }), _jsx(SearchPagination, { total: response.total, limit: response.rows, offset: response.offset, onChange: nextOffset => setOffset(nextOffset) })] })), error && (_jsx(Typography, { color: "error", sx: { mb: 2 }, children: error })), loading ? (_jsx(_Fragment, { children: _jsx(Skeleton, { variant: "rounded", height: 430 }) })) : ((response?.items ?? []).map(item => {
67
+ if (isHit(item)) {
68
+ return _jsx(HitCard, { id: item.howler.id, layout: hitLayout }, item.howler.id);
69
+ }
70
+ else if (isEvent(item)) {
71
+ return _jsx(EventCard, { id: item.howler.id, event: item }, item.howler.id);
72
+ }
73
+ else if (isCase(item)) {
74
+ return _jsx(CaseCard, { caseId: item.case_id, case: item }, item.case_id);
75
+ }
76
+ }))] }) }));
77
+ };
78
+ export default CaseSearch;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { DndContext, DragOverlay, MouseSensor, pointerWithin, TouchSensor, useSensor, useSensors } from '@dnd-kit/core';
3
- import { CalendarMonth, Circle, Dashboard, Dataset, Rule } from '@mui/icons-material';
3
+ import { CalendarMonth, Circle, Dashboard, Dataset, Rule, Search } from '@mui/icons-material';
4
4
  import { alpha, Box, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Typography, useTheme } from '@mui/material';
5
5
  import api from '@cccsaurora/howler-ui/api';
6
6
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
@@ -99,7 +99,7 @@ const CaseSidebar = ({ case: _case, update }) => {
99
99
  maxHeight: 'calc(100vh - 64px)',
100
100
  display: 'flex',
101
101
  flexDirection: 'column'
102
- }, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith(_case?.case_id)), component: Link, to: `/cases/${_case?.case_id}`, children: [_jsx(Dashboard, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { pl: 1, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('observables')), component: Link, to: `/cases/${_case?.case_id}/observables`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.observables') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('timeline')), component: Link, to: `/cases/${_case?.case_id}/timeline`, children: [_jsx(CalendarMonth, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('rules')), component: Link, to: `/cases/${_case?.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.rules') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
102
+ }, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith(_case?.case_id)), component: Link, to: `/cases/${_case?.case_id}`, children: [_jsx(Dashboard, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { pl: 1, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('search')), component: Link, to: `/cases/${_case?.case_id}/search`, children: [_jsx(Search, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.search') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('observables')), component: Link, to: `/cases/${_case?.case_id}/observables`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.observables') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('timeline')), component: Link, to: `/cases/${_case?.case_id}/timeline`, children: [_jsx(CalendarMonth, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('rules')), component: Link, to: `/cases/${_case?.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.rules') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
103
103
  position: 'relative',
104
104
  borderRight: `thin solid ${theme.palette.divider}`
105
105
  }, children: _jsxs(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: [_jsx(LinearProgress, { sx: { mb: 0.5, opacity: +loading } }), _jsxs(DndContext, { sensors: sensors, collisionDetection: pointerWithin, onDragStart: handleDragStart, onDragEnd: handleDragEnd, children: [_jsx(CaseFolder, { case: _case, onItemUpdated: update }), _jsx(RootDropZone, { caseId: _case.case_id }), _jsx(DragOverlay, { dropAnimation: null, children: activeDragData && (_jsx(FolderEntry, { caseId: null, path: "", indent: 0, label: activeDragData.label, itemType: activeDragData.type })) })] })] }) }))] }));