@cccsaurora/howler-ui 2.15.0-dev.335 → 2.15.0-dev.341

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.
@@ -1,8 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Skeleton } from '@mui/material';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ErrorOutline } from '@mui/icons-material';
3
+ import { Card, Tooltip } from '@mui/material';
3
4
  import Handlebars from 'handlebars';
4
5
  import { isEmpty } from 'lodash-es';
5
- import { useMemo } from 'react';
6
+ import React, { useMemo } from 'react';
6
7
  import { useTranslation } from 'react-i18next';
7
8
  import { usePluginStore } from 'react-pluggable';
8
9
  import { flattenDeep } from '@cccsaurora/howler-ui/utils/utils';
@@ -33,17 +34,30 @@ const PivotLink = ({ pivot, hit, compact = false }) => {
33
34
  if (href) {
34
35
  return _jsx(RelatedLink, { title: pivot.label[i18n.language], href: href, compact: compact, icon: pivot.icon });
35
36
  }
37
+ // Hide a relatively useless console error, we'll show a UI component instead
38
+ // eslint-disable-next-line no-console
39
+ const oldError = console.error;
40
+ let pluginPivot = null;
36
41
  try {
37
- const pluginPivot = pluginStore.executeFunction(`pivot.${pivot.format}`, { pivot, hit, compact });
38
- if (pluginPivot) {
39
- return pluginPivot;
40
- }
42
+ // eslint-disable-next-line no-console
43
+ console.error = () => { };
44
+ pluginPivot = pluginStore.executeFunction(`pivot.${pivot.format}`, { pivot, hit, compact });
41
45
  }
42
- catch (e) {
46
+ finally {
43
47
  // eslint-disable-next-line no-console
44
- console.warn(`Pivot plugin for format ${pivot.format} does not exist, not rendering`);
45
- return null;
48
+ console.error = oldError;
49
+ }
50
+ if (pluginPivot) {
51
+ return pluginPivot;
46
52
  }
47
- return _jsx(Skeleton, { variant: "rounded" });
53
+ return (_jsx(Card, { variant: "outlined", sx: { display: 'flex', alignItems: 'center', px: 1 }, children: _jsx(Tooltip, { title: _jsxs(_Fragment, { children: [_jsx("span", { children: `Missing Pivot Implementation ${pivot.format}` }), _jsx("code", { children: _jsx("pre", { children: JSON.stringify(pivot, null, 4) }) })] }), slotProps: {
54
+ popper: {
55
+ sx: {
56
+ '& > .MuiTooltip-tooltip': {
57
+ maxWidth: '90vw !important'
58
+ }
59
+ }
60
+ }
61
+ }, children: _jsx(ErrorOutline, { color: "error" }) }) }));
48
62
  };
49
63
  export default PivotLink;
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Delete, Language, Person } from '@mui/icons-material';
3
- import { Box, Card, IconButton, Stack, Tooltip, Typography } from '@mui/material';
4
- import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
2
+ import { Delete, Language, ManageSearch, Person } from '@mui/icons-material';
3
+ import { Box, Card, Chip, Divider, IconButton, Stack, Tooltip, Typography } from '@mui/material';
5
4
  import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
5
+ import { isEmpty } from 'lodash-es';
6
6
  import { useTranslation } from 'react-i18next';
7
+ import { Link } from 'react-router-dom';
7
8
  const DossierCard = ({ dossier, className, onDelete }) => {
8
- const { t } = useTranslation();
9
- return (_jsx(Card, { variant: "outlined", sx: { p: 1, mb: 1 }, className: className, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsxs(Stack, { children: [_jsxs(Typography, { variant: "body1", display: "flex", alignItems: "start", children: [_jsx(Tooltip, { title: t(`route.dossiers.manager.${dossier.type}`), children: dossier.type === 'personal' ? _jsx(Person, { fontSize: "small" }) : _jsx(Language, { fontSize: "small" }) }), _jsx(Box, { component: "span", ml: 1, children: dossier.title })] }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: _jsx("code", { children: dossier.query }) })] }), _jsx(FlexOne, {}), _jsx(HowlerAvatar, { sx: { height: '24px', width: '24px' }, userId: dossier.owner }), onDelete && (_jsx(Tooltip, { title: t('route.dossiers.manager.delete'), children: _jsx(IconButton, { onClick: e => onDelete(e, dossier.dossier_id), children: _jsx(Delete, {}) }) }))] }) }, dossier.dossier_id));
9
+ const { t, i18n } = useTranslation();
10
+ return (_jsx(Card, { variant: "outlined", sx: { p: 1, mb: 1 }, className: className, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsxs(Stack, { sx: { flex: 1 }, children: [_jsxs(Typography, { variant: "body1", display: "flex", alignItems: "start", children: [_jsx(Tooltip, { title: t(`route.dossiers.manager.${dossier.type}`), children: dossier.type === 'personal' ? _jsx(Person, { fontSize: "small" }) : _jsx(Language, { fontSize: "small" }) }), _jsx(Box, { component: "span", ml: 1, children: dossier.title })] }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: _jsx("code", { children: dossier.query }) }), _jsxs(Stack, { spacing: 1, direction: "row", sx: { mt: 1 }, children: [dossier.leads?.map((lead, index) => (_jsx(Chip, { clickable: true, label: `${lead.label?.[i18n.language] ?? t('unknown')} (${lead.format})`, size: "small", component: Link, to: `/dossiers/${dossier.dossier_id}/edit?tab=leads&lead=${index}`, onClick: e => e.stopPropagation() }, lead.format + lead.label?.en))), !isEmpty(dossier.leads) && !isEmpty(dossier.pivots) && _jsx(Divider, { flexItem: true, orientation: "vertical" }), dossier.pivots?.map((pivot, index) => (_jsx(Chip, { clickable: true, label: `${pivot.label?.[i18n.language] ?? t('unknown')} (${pivot.format})`, size: "small", component: Link, to: `/dossiers/${dossier.dossier_id}/edit?tab=pivots&pivot=${index}`, onClick: e => e.stopPropagation() }, pivot.format + pivot.label?.en)))] })] }), _jsx(HowlerAvatar, { sx: { height: '28px', width: '28px' }, userId: dossier.owner }), _jsx(Tooltip, { title: t('route.dossiers.manager.openinsearch'), children: _jsx(IconButton, { component: Link, to: `/search?query=${dossier.query}`, onClick: e => e.stopPropagation(), children: _jsx(ManageSearch, {}) }) }), onDelete && (_jsx(Tooltip, { title: t('route.dossiers.manager.delete'), children: _jsx(IconButton, { onClick: e => onDelete(e, dossier.dossier_id), children: _jsx(Delete, {}) }) }))] }) }, dossier.dossier_id));
10
11
  };
11
12
  export default DossierCard;
@@ -6,11 +6,21 @@ import { render, screen, waitFor } from '@testing-library/react';
6
6
  import userEvent, {} from '@testing-library/user-event';
7
7
  import { AvatarContext } from '@cccsaurora/howler-ui/components/app/providers/AvatarProvider';
8
8
  import i18n from '@cccsaurora/howler-ui/i18n';
9
- import { act } from 'react';
9
+ import React, { act } from 'react';
10
10
  import { I18nextProvider } from 'react-i18next';
11
11
  import { createMockDossier } from '@cccsaurora/howler-ui/tests/utils';
12
12
  import { beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import DossierCard from './DossierCard';
14
+ // Mock react-router-dom
15
+ const mockNavigate = vi.hoisted(() => vi.fn());
16
+ vi.mock('react-router-dom', async () => {
17
+ const actual = await vi.importActual('react-router-dom');
18
+ return {
19
+ ...actual,
20
+ useNavigate: () => mockNavigate,
21
+ Link: React.forwardRef(({ to, children, ...props }, ref) => (_jsx("a", { ref: ref, "data-to": to, ...props, children: children })))
22
+ };
23
+ });
14
24
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
15
25
  const mockAvatarContext = {
16
26
  getAvatar: vi.fn(userId => Promise.resolve('https://images.example.com/' + userId))
@@ -131,6 +141,21 @@ describe('DossierCard', () => {
131
141
  expect(screen.getByLabelText(/delete/i)).toBeInTheDocument();
132
142
  });
133
143
  });
144
+ it('should display "Open in Search" button', async () => {
145
+ const dossier = createMockDossier();
146
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
147
+ await waitFor(() => {
148
+ expect(screen.getByLabelText(/open in search/i)).toBeInTheDocument();
149
+ });
150
+ });
151
+ it('should have correct href for "Open in Search" button', async () => {
152
+ const dossier = createMockDossier({ query: 'howler.status:open' });
153
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
154
+ await waitFor(() => {
155
+ const openButton = screen.getByLabelText(/open in search/i);
156
+ expect(openButton).toHaveAttribute('data-to', '/search?query=howler.status:open');
157
+ });
158
+ });
134
159
  });
135
160
  describe('Button States & Interactions', () => {
136
161
  it('should call onDelete with correct parameters when delete button is clicked', async () => {
@@ -174,6 +199,49 @@ describe('DossierCard', () => {
174
199
  expect(mockOnDelete).toHaveBeenCalledTimes(3);
175
200
  });
176
201
  });
202
+ it('should stop event propagation when Open in Search button is clicked', async () => {
203
+ const dossier = createMockDossier();
204
+ const mockParentClick = vi.fn();
205
+ render(_jsx("div", { onClick: mockParentClick, children: _jsx(DossierCard, { dossier: dossier }) }), { wrapper: Wrapper });
206
+ await waitFor(() => {
207
+ expect(screen.getByLabelText(/open in search/i)).toBeInTheDocument();
208
+ });
209
+ const openButton = screen.getByLabelText(/open in search/i);
210
+ await user.click(openButton);
211
+ await waitFor(() => {
212
+ expect(mockParentClick).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+ it('should stop event propagation when lead chip is clicked', async () => {
216
+ const dossier = createMockDossier({
217
+ leads: [{ format: 'lead1', label: { en: 'Lead 1', fr: 'Piste 1' } }]
218
+ });
219
+ const mockParentClick = vi.fn();
220
+ render(_jsx("div", { onClick: mockParentClick, children: _jsx(DossierCard, { dossier: dossier }) }), { wrapper: Wrapper });
221
+ await waitFor(() => {
222
+ expect(screen.getByText(/Lead 1/)).toBeInTheDocument();
223
+ });
224
+ const chip = screen.getByText(/Lead 1/);
225
+ await user.click(chip);
226
+ await waitFor(() => {
227
+ expect(mockParentClick).not.toHaveBeenCalled();
228
+ });
229
+ });
230
+ it('should stop event propagation when pivot chip is clicked', async () => {
231
+ const dossier = createMockDossier({
232
+ pivots: [{ format: 'pivot1', label: { en: 'Pivot 1', fr: 'Pivot 1' } }]
233
+ });
234
+ const mockParentClick = vi.fn();
235
+ render(_jsx("div", { onClick: mockParentClick, children: _jsx(DossierCard, { dossier: dossier }) }), { wrapper: Wrapper });
236
+ await waitFor(() => {
237
+ expect(screen.getByText(/Pivot 1/)).toBeInTheDocument();
238
+ });
239
+ const chip = screen.getByText(/Pivot 1/);
240
+ await user.click(chip);
241
+ await waitFor(() => {
242
+ expect(mockParentClick).not.toHaveBeenCalled();
243
+ });
244
+ });
177
245
  });
178
246
  describe('Edge Cases', () => {
179
247
  it('should handle empty title', async () => {
@@ -236,6 +304,114 @@ describe('DossierCard', () => {
236
304
  });
237
305
  });
238
306
  });
307
+ describe('Leads and Pivots Display', () => {
308
+ it('should display lead chips when leads are present', async () => {
309
+ const dossier = createMockDossier({
310
+ leads: [
311
+ { format: 'lead1', label: { en: 'Lead 1', fr: 'Piste 1' } },
312
+ { format: 'lead2', label: { en: 'Lead 2', fr: 'Piste 2' } }
313
+ ]
314
+ });
315
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
316
+ await waitFor(() => {
317
+ expect(screen.getByText(/Lead 1/)).toBeInTheDocument();
318
+ expect(screen.getByText(/Lead 2/)).toBeInTheDocument();
319
+ });
320
+ });
321
+ it('should display pivot chips when pivots are present', async () => {
322
+ const dossier = createMockDossier({
323
+ pivots: [
324
+ { format: 'pivot1', label: { en: 'Pivot 1', fr: 'Pivot 1' } },
325
+ { format: 'pivot2', label: { en: 'Pivot 2', fr: 'Pivot 2' } }
326
+ ]
327
+ });
328
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
329
+ await waitFor(() => {
330
+ expect(screen.getByText(/Pivot 1/)).toBeInTheDocument();
331
+ expect(screen.getByText(/Pivot 2/)).toBeInTheDocument();
332
+ });
333
+ });
334
+ it('should not display chips when leads and pivots are empty', async () => {
335
+ const dossier = createMockDossier({
336
+ leads: [],
337
+ pivots: []
338
+ });
339
+ const { container } = render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
340
+ await waitFor(() => {
341
+ expect(container.querySelectorAll('.MuiChip-root')).toHaveLength(0);
342
+ });
343
+ });
344
+ it('should display divider when both leads and pivots are present', async () => {
345
+ const dossier = createMockDossier({
346
+ leads: [{ format: 'lead1', label: { en: 'Lead 1', fr: 'Piste 1' } }],
347
+ pivots: [{ format: 'pivot1', label: { en: 'Pivot 1', fr: 'Pivot 1' } }]
348
+ });
349
+ const { container } = render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
350
+ await waitFor(() => {
351
+ expect(container.querySelector('.MuiDivider-root')).toBeInTheDocument();
352
+ });
353
+ });
354
+ it('should not display divider when only leads are present', async () => {
355
+ const dossier = createMockDossier({
356
+ leads: [{ format: 'lead1', label: { en: 'Lead 1', fr: 'Piste 1' } }],
357
+ pivots: []
358
+ });
359
+ const { container } = render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
360
+ await waitFor(() => {
361
+ expect(container.querySelector('.MuiDivider-root')).not.toBeInTheDocument();
362
+ });
363
+ });
364
+ it('should not display divider when only pivots are present', async () => {
365
+ const dossier = createMockDossier({
366
+ leads: [],
367
+ pivots: [{ format: 'pivot1', label: { en: 'Pivot 1', fr: 'Pivot 1' } }]
368
+ });
369
+ const { container } = render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
370
+ await waitFor(() => {
371
+ expect(container.querySelector('.MuiDivider-root')).not.toBeInTheDocument();
372
+ });
373
+ });
374
+ it('should have correct link for lead chips', async () => {
375
+ const dossier = createMockDossier({
376
+ dossier_id: 'dossier-123',
377
+ leads: [{ format: 'lead1', label: { en: 'Lead 1', fr: 'Piste 1' } }]
378
+ });
379
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
380
+ await waitFor(() => {
381
+ const chip = screen.getByText(/Lead 1/).closest('a');
382
+ expect(chip).toHaveAttribute('data-to', '/dossiers/dossier-123/edit?tab=leads&lead=0');
383
+ });
384
+ });
385
+ it('should have correct link for pivot chips', async () => {
386
+ const dossier = createMockDossier({
387
+ dossier_id: 'dossier-456',
388
+ pivots: [{ format: 'pivot1', label: { en: 'Pivot 1', fr: 'Pivot 1' } }]
389
+ });
390
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
391
+ await waitFor(() => {
392
+ const chip = screen.getByText(/Pivot 1/).closest('a');
393
+ expect(chip).toHaveAttribute('data-to', '/dossiers/dossier-456/edit?tab=pivots&pivot=0');
394
+ });
395
+ });
396
+ it('should display lead format in chip label', async () => {
397
+ const dossier = createMockDossier({
398
+ leads: [{ format: 'custom-format', label: { en: 'Custom Lead', fr: 'Piste personnalisée' } }]
399
+ });
400
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
401
+ await waitFor(() => {
402
+ expect(screen.getByText(/Custom Lead \(custom-format\)/)).toBeInTheDocument();
403
+ });
404
+ });
405
+ it('should display pivot format in chip label', async () => {
406
+ const dossier = createMockDossier({
407
+ pivots: [{ format: 'custom-pivot', label: { en: 'Custom Pivot', fr: 'Pivot personnalisé' } }]
408
+ });
409
+ render(_jsx(DossierCard, { dossier: dossier }), { wrapper: Wrapper });
410
+ await waitFor(() => {
411
+ expect(screen.getByText(/Custom Pivot \(custom-pivot\)/)).toBeInTheDocument();
412
+ });
413
+ });
414
+ });
239
415
  describe('Dossier Types', () => {
240
416
  it('should render correctly for personal type', async () => {
241
417
  const dossier = createMockDossier({ type: 'personal' });
@@ -271,7 +447,9 @@ describe('DossierCard', () => {
271
447
  title: 'Complete Dossier',
272
448
  query: 'howler.status:open AND howler.assigned:me',
273
449
  type: 'personal',
274
- owner: 'admin'
450
+ owner: 'admin',
451
+ leads: [{ format: 'lead1', label: { en: 'Lead 1', fr: 'Piste 1' } }],
452
+ pivots: [{ format: 'pivot1', label: { en: 'Pivot 1', fr: 'Pivot 1' } }]
275
453
  });
276
454
  render(_jsx(DossierCard, { dossier: dossier, className: "test-class", onDelete: mockOnDelete }), {
277
455
  wrapper: Wrapper
@@ -283,6 +461,9 @@ describe('DossierCard', () => {
283
461
  expect(screen.getByLabelText(/personal/i)).toBeInTheDocument();
284
462
  expect(screen.getByLabelText('admin')).toBeInTheDocument();
285
463
  expect(screen.getByRole('button')).toBeInTheDocument();
464
+ expect(screen.getByText(/Lead 1/)).toBeInTheDocument();
465
+ expect(screen.getByText(/Pivot 1/)).toBeInTheDocument();
466
+ expect(screen.getByLabelText(/open in search/i)).toBeInTheDocument();
286
467
  });
287
468
  });
288
469
  it('should handle multiple dossier cards with different data', async () => {
@@ -330,6 +511,8 @@ describe('DossierCard', () => {
330
511
  expect(screen.getByLabelText(/personal/i)).toBeInTheDocument();
331
512
  // Delete button tooltip
332
513
  expect(screen.getByLabelText(/delete/i)).toBeInTheDocument();
514
+ // Open in Search button tooltip
515
+ expect(screen.getByLabelText(/open in search/i)).toBeInTheDocument();
333
516
  });
334
517
  });
335
518
  it('should have proper semantic HTML structure', async () => {
@@ -9,7 +9,7 @@ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
9
9
  import { isEqual, omit, uniqBy } from 'lodash-es';
10
10
  import { memo, useCallback, useEffect, useMemo, useState } from 'react';
11
11
  import { useTranslation } from 'react-i18next';
12
- import { useNavigate, useParams } from 'react-router-dom';
12
+ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
13
13
  import { useContextSelector } from 'use-context-selector';
14
14
  import QueryResultText from '../../elements/display/QueryResultText';
15
15
  import HitQuery from '../hits/search/HitQuery';
@@ -20,6 +20,7 @@ const DossierEditor = () => {
20
20
  const params = useParams();
21
21
  const { dispatchApi } = useMyApi();
22
22
  const navigate = useNavigate();
23
+ const [searchParams, setSearchParams] = useSearchParams();
23
24
  const setQuery = useContextSelector(ParameterContext, ctx => ctx.setQuery);
24
25
  const isNarrow = useMediaQuery(`(max-width: ${i18n.language === 'en' ? 1275 : 1375}px)`);
25
26
  const [originalDossier, setOriginalDossier] = useState();
@@ -28,7 +29,7 @@ const DossierEditor = () => {
28
29
  leads: [],
29
30
  pivots: []
30
31
  });
31
- const [tab, setTab] = useState('leads');
32
+ const [tab, setTab] = useState(searchParams.get('tab') ?? 'leads');
32
33
  const [searchTotal, setSearchTotal] = useState(-1);
33
34
  const [searchDirty, setSearchDirty] = useState(false);
34
35
  const [loading, setLoading] = useState(false);
@@ -164,6 +165,13 @@ const DossierEditor = () => {
164
165
  }
165
166
  })();
166
167
  }, [dispatchApi, dossier.query, setQuery]);
168
+ useEffect(() => {
169
+ if (searchParams.get('tab') !== tab) {
170
+ searchParams.set('tab', tab);
171
+ }
172
+ setSearchParams(searchParams, { replace: true });
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ }, [setSearchParams, tab]);
167
175
  return (_jsx(PageCenter, { maxWidth: "1000px", width: "100%", textAlign: "left", height: "97%", children: _jsxs(Box, { position: "relative", height: "100%", children: [_jsx(Tooltip, { title: validationError, children: _jsx("span", { children: _jsxs(Fab, { variant: "extended", size: "large", color: "primary", disabled: !dirty || !!validationError || loading, sx: theme => ({
168
176
  textTransform: 'none',
169
177
  position: 'absolute',
@@ -29,6 +29,7 @@ vi.mock('react-router-dom', async () => {
29
29
  return {
30
30
  ...actual,
31
31
  useParams: vi.fn(),
32
+ useSearchParams: vi.fn(() => [new URLSearchParams(), () => { }]),
32
33
  useNavigate: () => vi.fn()
33
34
  };
34
35
  });
@@ -93,9 +94,10 @@ vi.mock('../hits/search/HitQuery', () => ({
93
94
  import ApiConfigProvider from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
94
95
  import i18n from '@cccsaurora/howler-ui/i18n';
95
96
  import { I18nextProvider } from 'react-i18next';
96
- import { useNavigate, useParams } from 'react-router-dom';
97
+ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
97
98
  import DossierEditor from './DossierEditor';
98
99
  const mockUseParams = vi.mocked(useParams);
100
+ const mockUseSearchParams = vi.mocked(useSearchParams);
99
101
  // eslint-disable-next-line react-hooks/rules-of-hooks
100
102
  const mockNavigate = vi.mocked(useNavigate());
101
103
  // Mock data
@@ -357,5 +359,125 @@ describe('DossierEditor', () => {
357
359
  expect(saveButton).toBeDisabled();
358
360
  });
359
361
  });
362
+ describe('URL parameter synchronization', () => {
363
+ it('should initialize tab from URL search params', async () => {
364
+ const searchParams = new URLSearchParams('tab=pivots');
365
+ const mockSetSearchParams = vi.fn();
366
+ mockUseSearchParams.mockReturnValue([searchParams, mockSetSearchParams]);
367
+ mockUseParams.mockReturnValue({ id: null });
368
+ render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
369
+ await waitFor(() => {
370
+ const pivotsTab = screen.getByRole('tab', { name: /pivots/i });
371
+ expect(pivotsTab).toHaveAttribute('aria-selected', 'true');
372
+ });
373
+ });
374
+ it('should default to leads tab when no tab param in URL', async () => {
375
+ const searchParams = new URLSearchParams();
376
+ const mockSetSearchParams = vi.fn();
377
+ mockUseSearchParams.mockReturnValue([searchParams, mockSetSearchParams]);
378
+ mockUseParams.mockReturnValue({ id: null });
379
+ render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
380
+ await waitFor(() => {
381
+ const leadsTab = screen.getByRole('tab', { name: /leads/i });
382
+ expect(leadsTab).toHaveAttribute('aria-selected', 'true');
383
+ });
384
+ });
385
+ it('should update URL params when tab is changed', async () => {
386
+ const user = userEvent.setup();
387
+ const searchParams = new URLSearchParams();
388
+ const mockSetSearchParams = vi.fn();
389
+ mockUseSearchParams.mockReturnValue([searchParams, mockSetSearchParams]);
390
+ mockUseParams.mockReturnValue({ id: null });
391
+ render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
392
+ await waitFor(() => {
393
+ expect(screen.getByRole('tab', { name: /pivots/i })).toBeInTheDocument();
394
+ });
395
+ const pivotsTab = screen.getByRole('tab', { name: /pivots/i });
396
+ await user.click(pivotsTab);
397
+ await waitFor(() => {
398
+ expect(mockSetSearchParams).toHaveBeenCalled();
399
+ const callArgs = mockSetSearchParams.mock.calls[mockSetSearchParams.mock.calls.length - 1];
400
+ const updatedParams = callArgs[0];
401
+ expect(updatedParams.get('tab')).toBe('pivots');
402
+ });
403
+ });
404
+ it('should update search params with replace option', async () => {
405
+ const user = userEvent.setup();
406
+ const searchParams = new URLSearchParams();
407
+ const mockSetSearchParams = vi.fn();
408
+ mockUseSearchParams.mockReturnValue([searchParams, mockSetSearchParams]);
409
+ mockUseParams.mockReturnValue({ id: null });
410
+ render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
411
+ await waitFor(() => {
412
+ expect(screen.getByRole('tab', { name: /pivots/i })).toBeInTheDocument();
413
+ });
414
+ const pivotsTab = screen.getByRole('tab', { name: /pivots/i });
415
+ await user.click(pivotsTab);
416
+ await waitFor(() => {
417
+ expect(mockSetSearchParams).toHaveBeenCalled();
418
+ const callArgs = mockSetSearchParams.mock.calls[mockSetSearchParams.mock.calls.length - 1];
419
+ const options = callArgs[1];
420
+ expect(options).toEqual({ replace: true });
421
+ });
422
+ });
423
+ it('should set tab param when switching from leads to pivots', async () => {
424
+ const user = userEvent.setup();
425
+ const searchParams = new URLSearchParams('tab=leads');
426
+ const mockSetSearchParams = vi.fn();
427
+ mockUseSearchParams.mockReturnValue([searchParams, mockSetSearchParams]);
428
+ mockUseParams.mockReturnValue({ id: null });
429
+ render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
430
+ await waitFor(() => {
431
+ expect(screen.getByRole('tab', { name: /leads/i })).toHaveAttribute('aria-selected', 'true');
432
+ });
433
+ const pivotsTab = screen.getByRole('tab', { name: /pivots/i });
434
+ await user.click(pivotsTab);
435
+ await waitFor(() => {
436
+ expect(mockSetSearchParams).toHaveBeenCalled();
437
+ const callArgs = mockSetSearchParams.mock.calls[mockSetSearchParams.mock.calls.length - 1];
438
+ const updatedParams = callArgs[0];
439
+ expect(updatedParams.get('tab')).toBe('pivots');
440
+ });
441
+ });
442
+ it('should set tab param when switching from pivots to leads', async () => {
443
+ const user = userEvent.setup();
444
+ const searchParams = new URLSearchParams('tab=pivots');
445
+ const mockSetSearchParams = vi.fn();
446
+ mockUseSearchParams.mockReturnValue([searchParams, mockSetSearchParams]);
447
+ mockUseParams.mockReturnValue({ id: null });
448
+ render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
449
+ await waitFor(() => {
450
+ expect(screen.getByRole('tab', { name: /pivots/i })).toHaveAttribute('aria-selected', 'true');
451
+ });
452
+ const leadsTab = screen.getByRole('tab', { name: /leads/i });
453
+ await user.click(leadsTab);
454
+ await waitFor(() => {
455
+ expect(mockSetSearchParams).toHaveBeenCalled();
456
+ const callArgs = mockSetSearchParams.mock.calls[mockSetSearchParams.mock.calls.length - 1];
457
+ const updatedParams = callArgs[0];
458
+ expect(updatedParams.get('tab')).toBe('leads');
459
+ });
460
+ });
461
+ it('should preserve existing URL params when changing tabs', async () => {
462
+ const user = userEvent.setup();
463
+ const searchParams = new URLSearchParams('tab=leads&other=value');
464
+ const mockSetSearchParams = vi.fn();
465
+ mockUseSearchParams.mockReturnValue([searchParams, mockSetSearchParams]);
466
+ mockUseParams.mockReturnValue({ id: null });
467
+ render(_jsx(Wrapper, { children: _jsx(DossierEditor, {}) }));
468
+ await waitFor(() => {
469
+ expect(screen.getByRole('tab', { name: /pivots/i })).toBeInTheDocument();
470
+ });
471
+ const pivotsTab = screen.getByRole('tab', { name: /pivots/i });
472
+ await user.click(pivotsTab);
473
+ await waitFor(() => {
474
+ expect(mockSetSearchParams).toHaveBeenCalled();
475
+ const callArgs = mockSetSearchParams.mock.calls[mockSetSearchParams.mock.calls.length - 1];
476
+ const updatedParams = callArgs[0];
477
+ expect(updatedParams.get('tab')).toBe('pivots');
478
+ expect(updatedParams.get('other')).toBe('value');
479
+ });
480
+ });
481
+ });
360
482
  });
361
483
  });
@@ -4,12 +4,22 @@ import { Add } from '@mui/icons-material';
4
4
  import { Alert, Button, Paper, Stack, Tab, Tabs } from '@mui/material';
5
5
  import isNull from 'lodash-es/isNull';
6
6
  import merge from 'lodash-es/merge';
7
- import { useState } from 'react';
7
+ import { useEffect, useState } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
+ import { useSearchParams } from 'react-router-dom';
9
10
  import LeadEditor from './LeadEditor';
10
11
  const LeadForm = ({ dossier, setDossier, loading }) => {
11
12
  const { t, i18n } = useTranslation();
12
- const [tab, setTab] = useState(0);
13
+ const [searchParams, setSearchParams] = useSearchParams();
14
+ const [tab, setTab] = useState(parseInt(searchParams.get('lead') ?? '0'));
15
+ useEffect(() => {
16
+ searchParams.delete('pivot');
17
+ if (searchParams.get('lead') !== tab.toString()) {
18
+ searchParams.set('lead', tab.toString());
19
+ }
20
+ setSearchParams(searchParams, { replace: true });
21
+ // eslint-disable-next-line react-hooks/exhaustive-deps
22
+ }, [setSearchParams, tab]);
13
23
  return (_jsxs(Paper, { sx: { p: 1, display: 'flex', flexDirection: 'column', flex: 1 }, id: "lead-form", children: [_jsxs(Stack, { direction: "row", children: [!dossier?.leads || dossier.leads.length < 1 ? (_jsx(Alert, { id: "create-lead-alert", variant: "outlined", severity: "warning", sx: {
14
24
  mr: 1,
15
25
  px: 1,
@@ -33,7 +43,7 @@ const LeadForm = ({ dossier, setDossier, loading }) => {
33
43
  { icon: 'material-symbols:add-ad', label: { en: 'New Lead', fr: 'Nouvelle Piste' } }
34
44
  ]
35
45
  }));
36
- }, children: _jsx(Add, {}) })] }), _jsx(LeadEditor, { lead: (dossier.leads ?? [])[tab], update: data => setDossier(_dossier => ({
46
+ }, disabled: !dossier || loading, children: _jsx(Add, {}) })] }), _jsx(LeadEditor, { lead: (dossier.leads ?? [])[tab], update: data => setDossier(_dossier => ({
37
47
  ..._dossier,
38
48
  leads: (_dossier.leads ?? [])
39
49
  .map((lead, index) => {
@@ -6,9 +6,10 @@ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers
6
6
  import isNull from 'lodash-es/isNull';
7
7
  import merge from 'lodash-es/merge';
8
8
  import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
9
- import { Fragment, useCallback, useContext, useMemo, useState } from 'react';
9
+ import { Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react';
10
10
  import { useTranslation } from 'react-i18next';
11
11
  import { usePluginStore } from 'react-pluggable';
12
+ import { useSearchParams } from 'react-router-dom';
12
13
  const LinkForm = ({ pivot, update }) => {
13
14
  const { t } = useTranslation();
14
15
  const { config } = useContext(ApiConfigContext);
@@ -32,7 +33,8 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
32
33
  const theme = useTheme();
33
34
  const { t, i18n } = useTranslation();
34
35
  const pluginStore = usePluginStore();
35
- const [tab, setTab] = useState(0);
36
+ const [searchParams, setSearchParams] = useSearchParams();
37
+ const [tab, setTab] = useState(parseInt(searchParams.get('pivot') ?? '0'));
36
38
  const update = useCallback((data) => setDossier(_dossier => ({
37
39
  ..._dossier,
38
40
  pivots: (_dossier.pivots ?? [])
@@ -53,6 +55,14 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
53
55
  })), [setDossier, tab]);
54
56
  const pivot = useMemo(() => dossier.pivots?.[tab] ?? null, [dossier.pivots, tab]);
55
57
  const icon = useMemo(() => pivot?.icon ?? 'material-symbols:find-in-page', [pivot?.icon]);
58
+ useEffect(() => {
59
+ searchParams.delete('lead');
60
+ if (searchParams.get('pivot') !== tab.toString()) {
61
+ searchParams.set('pivot', tab.toString());
62
+ }
63
+ setSearchParams(searchParams, { replace: true });
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [setSearchParams, tab]);
56
66
  return (_jsx(Paper, { sx: { p: 1, display: 'flex', flexDirection: 'column', flex: 1 }, id: "pivot-form", children: _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { direction: "row", children: [!dossier?.pivots || dossier.pivots.length < 1 ? (_jsx(Alert, { variant: "outlined", severity: "warning", sx: {
57
67
  mr: 1,
58
68
  px: 1,
@@ -76,7 +86,7 @@ const PivotForm = ({ dossier, setDossier, loading }) => {
76
86
  { icon: 'material-symbols:add-ad', label: { en: 'New Pivot', fr: 'Nouvelle pivot' } }
77
87
  ]
78
88
  }));
79
- }, children: _jsx(Add, {}) })] }), _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { direction: "row", alignItems: "center", position: "relative", children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.icon'), value: icon, disabled: !pivot, fullWidth: true, error: !iconExists(icon), sx: { '& input': { paddingLeft: '2.25rem' } }, onChange: ev => update({ icon: ev.target.value }) }), _jsx(Icon, { fontSize: "1.75rem", icon: icon, style: { position: 'absolute', left: '0.5rem' } }), _jsx(Button, { variant: "outlined", color: "error", disabled: !pivot, sx: { minWidth: '0 !important', ml: 1 }, onClick: () => update(null), children: _jsx(Delete, {}) })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", sx: { mt: `${theme.spacing(0.5)} !important` }, children: [_jsx(Typography, { color: "text.secondary", children: t('route.dossiers.manager.icon.description') }), _jsx(IconButton, { size: "small", component: "a", href: "https://icon-sets.iconify.design/", children: _jsx(OpenInNew, { fontSize: "small" }) })] }), _jsxs(Stack, { direction: "row", spacing: 2, children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.label.en'), disabled: !pivot, value: pivot?.label?.en ?? '', fullWidth: true, onChange: ev => update({ label: { en: ev.target.value } }) }), _jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.label.fr'), disabled: !pivot, value: pivot?.label?.fr ?? '', fullWidth: true, onChange: ev => update({ label: { fr: ev.target.value } }) })] }), _jsx(Autocomplete, { disabled: !pivot, options: ['link', ...howlerPluginStore.pivotFormats], renderInput: params => (_jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.pivot.format') })), value: pivot?.format ?? '', onChange: (_ev, format) => update({ format, value: '', mappings: [] }) }), !!pivot?.format &&
89
+ }, disabled: !dossier || loading, children: _jsx(Add, {}) })] }), _jsxs(Stack, { spacing: 2, children: [_jsxs(Stack, { direction: "row", alignItems: "center", position: "relative", children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.icon'), value: icon, disabled: !pivot, fullWidth: true, error: !iconExists(icon), sx: { '& input': { paddingLeft: '2.25rem' } }, onChange: ev => update({ icon: ev.target.value }) }), _jsx(Icon, { fontSize: "1.75rem", icon: icon, style: { position: 'absolute', left: '0.5rem' } }), _jsx(Button, { variant: "outlined", color: "error", disabled: !pivot, sx: { minWidth: '0 !important', ml: 1 }, onClick: () => update(null), children: _jsx(Delete, {}) })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", sx: { mt: `${theme.spacing(0.5)} !important` }, children: [_jsx(Typography, { color: "text.secondary", children: t('route.dossiers.manager.icon.description') }), _jsx(IconButton, { size: "small", component: "a", href: "https://icon-sets.iconify.design/", children: _jsx(OpenInNew, { fontSize: "small" }) })] }), _jsxs(Stack, { direction: "row", spacing: 2, children: [_jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.label.en'), disabled: !pivot, value: pivot?.label?.en ?? '', fullWidth: true, onChange: ev => update({ label: { en: ev.target.value } }) }), _jsx(TextField, { size: "small", label: t('route.dossiers.manager.pivot.label.fr'), disabled: !pivot, value: pivot?.label?.fr ?? '', fullWidth: true, onChange: ev => update({ label: { fr: ev.target.value } }) })] }), _jsx(Autocomplete, { disabled: !pivot, options: ['link', ...howlerPluginStore.pivotFormats], renderInput: params => (_jsx(TextField, { ...params, size: "small", label: t('route.dossiers.manager.pivot.format') })), value: pivot?.format ?? null, onChange: (_ev, format) => update({ format, value: '', mappings: [] }) }), !!pivot?.format &&
80
90
  (pivot.format === 'link' ? (_jsx(LinkForm, { pivot: pivot, update: update })) : (pluginStore.executeFunction(`pivot.${pivot.format}.form`, { pivot, update })))] })] }) }));
81
91
  };
82
92
  export default PivotForm;
@@ -77,6 +77,13 @@ const InformationPane = ({ onClose }) => {
77
77
  setUserIds(getUserList(hit));
78
78
  // eslint-disable-next-line react-hooks/exhaustive-deps
79
79
  }, [getHit, selected]);
80
+ useEffect(() => {
81
+ if (selected) {
82
+ setAnalytic(null);
83
+ setDossiers(null);
84
+ setHasOverview(false);
85
+ }
86
+ }, [selected]);
80
87
  useEffect(() => {
81
88
  if (hit && !analytic) {
82
89
  getMatchingAnalytic(hit).then(setAnalytic);
@@ -628,6 +628,7 @@
628
628
  "route.dossiers.manager.format": "Lead Format",
629
629
  "route.dossiers.manager.tabs.leads": "Leads",
630
630
  "route.dossiers.manager.tabs.pivots": "Pivots",
631
+ "route.dossiers.manager.openinsearch": "Open in Search",
631
632
  "route.dossiers.manager.pivot.create": "You currently have no pivots configured. Press the add button to the right to create a new one.",
632
633
  "route.dossiers.manager.pivot.icon": "Pivot Icon",
633
634
  "route.dossiers.manager.pivot.label.en": "English Title",
@@ -629,6 +629,7 @@
629
629
  "route.dossiers.manager.format": "Format de la piste",
630
630
  "route.dossiers.manager.tabs.leads": "Pistes",
631
631
  "route.dossiers.manager.tabs.pivots": "Pivots",
632
+ "route.dossiers.manager.openinsearch": "Ouvrir en recherche",
632
633
  "route.dossiers.manager.pivot.create": "Vous n'avez actuellement aucun pivot configuré. Appuyez sur le bouton Ajouter à droite pour en créer un nouveau.",
633
634
  "route.dossiers.manager.pivot.icon": "Icône du pivot",
634
635
  "route.dossiers.manager.pivot.label.en": "English Title",
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.15.0-dev.335",
99
+ "version": "2.15.0-dev.341",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",