@cccsaurora/howler-ui 2.18.0-dev.841 → 2.19.0-cases.1040

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 (171) hide show
  1. package/api/action/index.d.ts +5 -4
  2. package/api/action/index.js +8 -8
  3. package/api/analytic/index.d.ts +3 -2
  4. package/api/analytic/index.js +4 -4
  5. package/api/analytic/owner.d.ts +2 -1
  6. package/api/analytic/owner.js +2 -2
  7. package/api/analytic/rules.d.ts +2 -1
  8. package/api/analytic/rules.js +2 -2
  9. package/api/dossier/index.d.ts +4 -3
  10. package/api/dossier/index.js +6 -6
  11. package/api/hit/index.d.ts +3 -2
  12. package/api/hit/index.js +4 -4
  13. package/api/hit/labels.d.ts +3 -2
  14. package/api/hit/labels.js +4 -4
  15. package/api/hit/overwrite.d.ts +2 -1
  16. package/api/hit/overwrite.js +2 -2
  17. package/api/hit/transition.d.ts +2 -1
  18. package/api/hit/transition.js +2 -2
  19. package/api/index.d.ts +5 -4
  20. package/api/index.js +8 -8
  21. package/api/overview/index.d.ts +4 -3
  22. package/api/overview/index.js +6 -6
  23. package/api/search/overview.d.ts +2 -2
  24. package/api/template/index.d.ts +4 -3
  25. package/api/template/index.js +6 -6
  26. package/api/user/index.d.ts +2 -1
  27. package/api/user/index.js +2 -2
  28. package/api/view/index.d.ts +4 -3
  29. package/api/view/index.js +6 -6
  30. package/components/app/App.d.ts +1 -0
  31. package/components/app/App.js +24 -6
  32. package/components/app/providers/FavouritesProvider.js +0 -4
  33. package/components/app/providers/ModalProvider.d.ts +1 -0
  34. package/components/app/providers/ModalProvider.js +5 -1
  35. package/components/app/providers/RecordProvider.d.ts +1 -1
  36. package/components/app/providers/RecordProvider.js +1 -1
  37. package/components/app/providers/SearchResponseProvider.d.ts +21 -0
  38. package/components/app/providers/SearchResponseProvider.js +81 -0
  39. package/components/app/providers/SearchResponseProvider.test.d.ts +1 -0
  40. package/components/app/providers/SearchResponseProvider.test.js +406 -0
  41. package/components/app/providers/ViewProvider.test.js +4 -1
  42. package/components/elements/addons/buttons/CustomButton.test.d.ts +1 -0
  43. package/components/elements/addons/buttons/CustomButton.test.js +106 -0
  44. package/components/elements/addons/layout/FlexOne.test.d.ts +1 -0
  45. package/components/elements/addons/layout/FlexOne.test.js +30 -0
  46. package/components/elements/addons/lists/TuiListBase.js +4 -0
  47. package/components/elements/addons/lists/TuiListElement.js +6 -3
  48. package/components/elements/addons/lists/index.d.ts +1 -0
  49. package/components/elements/addons/search/SearchPagination.d.ts +2 -1
  50. package/components/elements/addons/search/SearchPagination.js +4 -4
  51. package/components/elements/addons/search/SearchPagination.test.d.ts +1 -0
  52. package/components/elements/addons/search/SearchPagination.test.js +80 -0
  53. package/components/elements/addons/search/SearchTotal.test.d.ts +1 -0
  54. package/components/elements/addons/search/SearchTotal.test.js +47 -0
  55. package/components/elements/display/DocumentationButton.test.d.ts +1 -0
  56. package/components/elements/display/DocumentationButton.test.js +84 -0
  57. package/components/elements/display/DynamicTabs.test.d.ts +1 -0
  58. package/components/elements/display/DynamicTabs.test.js +93 -0
  59. package/components/elements/display/HowlerAvatar.js +2 -1
  60. package/components/elements/display/HowlerCard.test.d.ts +1 -0
  61. package/components/elements/display/HowlerCard.test.js +51 -0
  62. package/components/elements/display/Image.test.d.ts +1 -0
  63. package/components/elements/display/Image.test.js +79 -0
  64. package/components/elements/display/ItemManager.d.ts +2 -2
  65. package/components/elements/display/ItemManager.js +1 -1
  66. package/components/elements/display/Markdown.js +9 -2
  67. package/components/elements/display/QueryResultText.test.d.ts +1 -0
  68. package/components/elements/display/QueryResultText.test.js +48 -0
  69. package/components/elements/display/TextDivider.test.d.ts +1 -0
  70. package/components/elements/display/TextDivider.test.js +43 -0
  71. package/components/elements/display/TypingIndicator.test.d.ts +1 -0
  72. package/components/elements/display/TypingIndicator.test.js +33 -0
  73. package/components/elements/display/modals/ConfirmDeleteModal.d.ts +4 -0
  74. package/components/elements/display/modals/ConfirmDeleteModal.js +4 -2
  75. package/components/elements/hit/HitBanner.js +6 -3
  76. package/components/elements/hit/HitLinks.js +1 -1
  77. package/components/elements/hit/HitSummary.js +3 -2
  78. package/components/elements/hit/elements/AnalyticLink.js +1 -1
  79. package/components/elements/hit/elements/Assigned.d.ts +2 -0
  80. package/components/elements/hit/elements/Assigned.js +15 -13
  81. package/components/elements/record/RecordContextMenu.js +15 -3
  82. package/components/elements/record/RecordContextMenu.test.js +96 -0
  83. package/components/hooks/useMyApi.d.ts +1 -1
  84. package/components/hooks/useMyPreferences.js +2 -1
  85. package/components/routes/403.d.ts +3 -0
  86. package/components/routes/403.js +10 -0
  87. package/components/routes/403.test.d.ts +1 -0
  88. package/components/routes/403.test.js +27 -0
  89. package/components/routes/404.test.d.ts +1 -0
  90. package/components/routes/404.test.js +26 -0
  91. package/components/routes/ErrorBoundary.test.d.ts +1 -0
  92. package/components/routes/ErrorBoundary.test.js +43 -0
  93. package/components/routes/ErrorOccured.test.d.ts +1 -0
  94. package/components/routes/ErrorOccured.test.js +35 -0
  95. package/components/routes/Logout.test.d.ts +1 -0
  96. package/components/routes/Logout.test.js +59 -0
  97. package/components/routes/action/edit/ActionEditor.js +1 -1
  98. package/components/routes/action/useMyActionFunctions.js +30 -6
  99. package/components/routes/action/view/ActionDetails.js +15 -2
  100. package/components/routes/action/view/ActionSearch.js +32 -20
  101. package/components/routes/action/view/markdown/integrations.en.md.js +1 -1
  102. package/components/routes/action/view/markdown/integrations.fr.md.js +1 -1
  103. package/components/routes/analytics/AnalyticDetails.js +10 -6
  104. package/components/routes/analytics/AnalyticSearch.js +6 -6
  105. package/components/routes/cases/Cases.js +5 -6
  106. package/components/routes/cases/modals/ResolveModal.js +14 -13
  107. package/components/routes/cases/modals/ResolveModal.test.js +85 -25
  108. package/components/routes/dossiers/Dossiers.js +25 -19
  109. package/components/routes/help/ActionIntroductionDocumentation.js +1 -1
  110. package/components/routes/help/markdown/en/client.md.js +1 -1
  111. package/components/routes/help/markdown/en/retention.md.js +1 -1
  112. package/components/routes/help/markdown/fr/client.md.js +1 -1
  113. package/components/routes/help/markdown/fr/retention.md.js +1 -1
  114. package/components/routes/hits/search/shared/CustomSpan.js +7 -7
  115. package/components/routes/hits/search/shared/CustomSpan.test.d.ts +1 -0
  116. package/components/routes/hits/search/shared/CustomSpan.test.js +170 -0
  117. package/components/routes/home/AddNewCard.js +1 -1
  118. package/components/routes/home/AddNewCard.test.d.ts +1 -0
  119. package/components/routes/home/AddNewCard.test.js +86 -0
  120. package/components/routes/home/ViewCard.js +1 -1
  121. package/components/routes/home/ViewRefresh.test.d.ts +1 -0
  122. package/components/routes/home/ViewRefresh.test.js +79 -0
  123. package/components/routes/overviews/OverviewCard.test.d.ts +1 -0
  124. package/components/routes/overviews/OverviewCard.test.js +108 -0
  125. package/components/routes/overviews/OverviewViewer.js +16 -8
  126. package/components/routes/overviews/Overviews.js +25 -19
  127. package/components/routes/overviews/template/en.md.js +1 -1
  128. package/components/routes/overviews/template/fr.md.js +1 -1
  129. package/components/routes/settings/LocalSection.js +2 -1
  130. package/components/routes/settings/SettingsSection.test.d.ts +1 -0
  131. package/components/routes/settings/SettingsSection.test.js +19 -0
  132. package/components/routes/templates/TemplateCard.d.ts +3 -1
  133. package/components/routes/templates/TemplateCard.js +8 -4
  134. package/components/routes/templates/TemplateCard.test.d.ts +1 -0
  135. package/components/routes/templates/TemplateCard.test.js +115 -0
  136. package/components/routes/templates/TemplateViewer.js +62 -16
  137. package/components/routes/templates/Templates.js +35 -13
  138. package/components/routes/views/Views.js +21 -14
  139. package/locales/en/translation.json +25 -4
  140. package/locales/fr/translation.json +25 -4
  141. package/models/entities/generated/ApiType.d.ts +1 -1
  142. package/models/entities/generated/Hit.d.ts +1 -0
  143. package/models/entities/generated/Howler.d.ts +1 -0
  144. package/models/entities/generated/UserUser.d.ts +2 -0
  145. package/package.json +143 -148
  146. package/rest/FetchClient.test.d.ts +1 -0
  147. package/rest/FetchClient.test.js +81 -0
  148. package/setupTests.js +5 -0
  149. package/utils/Throttler.test.d.ts +1 -0
  150. package/utils/Throttler.test.js +135 -0
  151. package/utils/actionUtils.test.d.ts +1 -0
  152. package/utils/actionUtils.test.js +140 -0
  153. package/utils/constants.d.ts +1 -0
  154. package/utils/constants.js +1 -0
  155. package/utils/hitFunctions.test.d.ts +1 -0
  156. package/utils/hitFunctions.test.js +52 -0
  157. package/utils/localStorage.test.d.ts +1 -0
  158. package/utils/localStorage.test.js +96 -0
  159. package/utils/menuUtils.js +1 -1
  160. package/utils/menuUtils.test.d.ts +1 -0
  161. package/utils/menuUtils.test.js +198 -0
  162. package/utils/sessionStorage.test.d.ts +1 -0
  163. package/utils/sessionStorage.test.js +81 -0
  164. package/utils/stringUtils.test.d.ts +1 -0
  165. package/utils/stringUtils.test.js +159 -0
  166. package/utils/utils.test.d.ts +1 -0
  167. package/utils/utils.test.js +292 -0
  168. package/utils/viewUtils.test.d.ts +1 -0
  169. package/utils/viewUtils.test.js +45 -0
  170. package/utils/xsrf.test.d.ts +1 -0
  171. package/utils/xsrf.test.js +41 -0
@@ -5,11 +5,14 @@ import VSBoxElement from '../layout/vsbox/VSBoxElement';
5
5
  const TuiListElement = ({ position, item, onSelect: onClick, children }) => {
6
6
  const elementEl = useRef();
7
7
  const onItemClick = useCallback(_event => {
8
- if (onClick) {
8
+ if (onClick && !item.disabled) {
9
9
  onClick(item, position);
10
10
  }
11
11
  }, [onClick, item, position]);
12
12
  const classRenderer = useCallback(() => {
13
+ if (item.disabled) {
14
+ return 'elementDisabled';
15
+ }
13
16
  const _classes = ['elementHover'];
14
17
  if (item.cursor) {
15
18
  _classes.push('elementFocus');
@@ -18,7 +21,7 @@ const TuiListElement = ({ position, item, onSelect: onClick, children }) => {
18
21
  _classes.push('elementSelected');
19
22
  }
20
23
  return _classes.join(' ');
21
- }, [item.cursor, item.selected]);
22
- return (_jsx(VSBoxElement, { focus: !!item.cursor, children: _jsx("div", { ref: elementEl, "data-tuilist-index": position, "data-tuilist-id": item.id, "data-tuilist-focus": !!item.cursor, "data-tuilist-selected": !!item.selected, onClick: onItemClick, children: children({ item, position }, classRenderer) }) }));
24
+ }, [item.cursor, item.selected, item.disabled]);
25
+ return (_jsx(VSBoxElement, { focus: !!item.cursor, children: _jsx("div", { ref: elementEl, "data-tuilist-index": position, "data-tuilist-id": item.id, "data-tuilist-focus": !!item.cursor, "data-tuilist-selected": !!item.selected, "aria-disabled": item.disabled, onClick: onItemClick, children: children({ item, position }, classRenderer) }) }));
23
26
  };
24
27
  export default memo(TuiListElement);
@@ -8,6 +8,7 @@ export type TuiListItem<T> = {
8
8
  details?: boolean;
9
9
  cursor?: boolean;
10
10
  selected?: boolean;
11
+ disabled?: boolean;
11
12
  item: T;
12
13
  };
13
14
  export type TuiListItemProps<T> = {
@@ -3,7 +3,8 @@ type SearchPaginationProps = Omit<PaginationProps, 'onChange'> & {
3
3
  limit: number;
4
4
  offset: number;
5
5
  total: number;
6
+ removeCount?: number;
6
7
  onChange: (nextOffset: number) => void;
7
8
  };
8
- declare const SearchPagination: ({ limit, offset, total, onChange, ...paginationProps }: SearchPaginationProps) => import("react/jsx-runtime").JSX.Element;
9
+ declare const SearchPagination: ({ limit, offset, total, removeCount, onChange, ...paginationProps }: SearchPaginationProps) => import("react/jsx-runtime").JSX.Element;
9
10
  export default SearchPagination;
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Pagination } from '@mui/material';
3
3
  import { useCallback } from 'react';
4
- const SearchPagination = ({ limit, offset, total, onChange, ...paginationProps }) => {
4
+ const SearchPagination = ({ limit, offset, total, removeCount, onChange, ...paginationProps }) => {
5
5
  const onPageChange = useCallback((_event, nextPage) => {
6
6
  onChange(nextPage === 1 ? 0 : (nextPage - 1) * limit);
7
7
  }, [limit, onChange]);
8
- const count = Math.ceil(total / limit);
9
- const page = Math.floor((offset + 1) / limit) + 1;
10
- return limit && total && limit < total ? (_jsx(Pagination, { count: count, page: page, onChange: onPageChange, ...paginationProps })) : null;
8
+ const count = Math.ceil((total + (removeCount ?? 0)) / limit);
9
+ const page = Math.floor((offset + 1 + (removeCount ?? 0)) / limit) + 1;
10
+ return limit && total && count > 1 ? (_jsx(Pagination, { count: count, page: page, onChange: onPageChange, ...paginationProps })) : null;
11
11
  };
12
12
  export default SearchPagination;
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import SearchPagination from './SearchPagination';
6
+ describe('SearchPagination', () => {
7
+ const defaultProps = {
8
+ limit: 25,
9
+ offset: 0,
10
+ total: 100,
11
+ onChange: vi.fn()
12
+ };
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+ describe('rendering', () => {
17
+ it('renders pagination when total exceeds limit', () => {
18
+ render(_jsx(SearchPagination, { ...defaultProps }));
19
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
20
+ });
21
+ it('does not render when total is less than limit', () => {
22
+ const { container } = render(_jsx(SearchPagination, { ...defaultProps, total: 20 }));
23
+ expect(container).toBeEmptyDOMElement();
24
+ });
25
+ it('does not render when total equals limit', () => {
26
+ const { container } = render(_jsx(SearchPagination, { ...defaultProps, total: 25 }));
27
+ expect(container).toBeEmptyDOMElement();
28
+ });
29
+ it('calculates correct page count', () => {
30
+ render(_jsx(SearchPagination, { ...defaultProps, total: 100, limit: 25 }));
31
+ // 100/25 = 4 pages
32
+ // MUI Pagination renders prev, page buttons, next
33
+ expect(screen.getByText('4')).toBeInTheDocument();
34
+ });
35
+ it('calculates correct page count with non-even division', () => {
36
+ render(_jsx(SearchPagination, { ...defaultProps, total: 101, limit: 25 }));
37
+ // ceil(101/25) = 5 pages
38
+ expect(screen.getByText('5')).toBeInTheDocument();
39
+ });
40
+ it('highlights the current page based on offset', () => {
41
+ render(_jsx(SearchPagination, { ...defaultProps, offset: 50 }));
42
+ // offset 50 with limit 25 = page 3
43
+ const page3 = screen.getByText('3');
44
+ expect(page3.closest('button')).toHaveAttribute('aria-current', 'true');
45
+ });
46
+ });
47
+ describe('interaction', () => {
48
+ it('calls onChange with correct offset when clicking page 2', async () => {
49
+ const user = userEvent.setup();
50
+ const onChange = vi.fn();
51
+ render(_jsx(SearchPagination, { ...defaultProps, onChange: onChange }));
52
+ await user.click(screen.getByText('2'));
53
+ expect(onChange).toHaveBeenCalledWith(25);
54
+ });
55
+ it('calls onChange with correct offset when clicking page 3', async () => {
56
+ const user = userEvent.setup();
57
+ const onChange = vi.fn();
58
+ render(_jsx(SearchPagination, { ...defaultProps, onChange: onChange }));
59
+ await user.click(screen.getByText('3'));
60
+ expect(onChange).toHaveBeenCalledWith(50);
61
+ });
62
+ it('calls onChange with 0 when clicking page 1', async () => {
63
+ const user = userEvent.setup();
64
+ const onChange = vi.fn();
65
+ render(_jsx(SearchPagination, { ...defaultProps, offset: 25, onChange: onChange }));
66
+ await user.click(screen.getByText('1'));
67
+ expect(onChange).toHaveBeenCalledWith(0);
68
+ });
69
+ });
70
+ describe('edge cases', () => {
71
+ it('does not render when limit is 0', () => {
72
+ const { container } = render(_jsx(SearchPagination, { ...defaultProps, limit: 0 }));
73
+ expect(container).toBeEmptyDOMElement();
74
+ });
75
+ it('does not render when total is 0', () => {
76
+ const { container } = render(_jsx(SearchPagination, { ...defaultProps, total: 0 }));
77
+ expect(container).toBeEmptyDOMElement();
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import i18n from '@cccsaurora/howler-ui/i18n';
5
+ import { I18nextProvider } from 'react-i18next';
6
+ import SearchTotal from './SearchTotal';
7
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
8
+ describe('SearchTotal', () => {
9
+ describe('rendering', () => {
10
+ it('renders a Typography element', () => {
11
+ const { container } = render(_jsx(SearchTotal, { total: 10, offset: 0, pageLength: 10 }), { wrapper: Wrapper });
12
+ expect(container.querySelector('.MuiTypography-root')).toBeInTheDocument();
13
+ });
14
+ it('renders "No results" text when total is 0', () => {
15
+ render(_jsx(SearchTotal, { total: 0, offset: 0, pageLength: 0 }), { wrapper: Wrapper });
16
+ expect(screen.getByText('No results')).toBeInTheDocument();
17
+ });
18
+ it('renders "No results" text when total is 1', () => {
19
+ render(_jsx(SearchTotal, { total: 1, offset: 0, pageLength: 1 }), { wrapper: Wrapper });
20
+ expect(screen.getByText('No results')).toBeInTheDocument();
21
+ });
22
+ it('renders range text when total is greater than 1', () => {
23
+ render(_jsx(SearchTotal, { total: 50, offset: 0, pageLength: 25 }), { wrapper: Wrapper });
24
+ // "Showing 1 to 25 of 50 results"
25
+ expect(screen.getByText(/Showing 1 to 25 of 50 results/)).toBeInTheDocument();
26
+ });
27
+ it('renders correct offset values', () => {
28
+ render(_jsx(SearchTotal, { total: 100, offset: 25, pageLength: 25 }), { wrapper: Wrapper });
29
+ // Should show "Showing 26 to 50 of 100 results"
30
+ expect(screen.getByText(/Showing 26 to 50 of 100 results/)).toBeInTheDocument();
31
+ });
32
+ });
33
+ describe('prop passthrough', () => {
34
+ it('passes className to Typography', () => {
35
+ const { container } = render(_jsx(SearchTotal, { total: 10, offset: 0, pageLength: 10, className: "custom" }), {
36
+ wrapper: Wrapper
37
+ });
38
+ expect(container.firstChild).toHaveClass('custom');
39
+ });
40
+ it('passes variant to Typography', () => {
41
+ const { container } = render(_jsx(SearchTotal, { total: 10, offset: 0, pageLength: 10, variant: "caption" }), {
42
+ wrapper: Wrapper
43
+ });
44
+ expect(container.firstChild).toHaveClass('MuiTypography-caption');
45
+ });
46
+ });
47
+ });
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ vi.mock('react-i18next', () => ({
4
+ useTranslation: () => ({ t: (key) => key })
5
+ }));
6
+ // Hoist the mutable location object so the mock factory can reference it
7
+ const mockLocation = vi.hoisted(() => ({ pathname: '/' }));
8
+ vi.mock('react-router-dom', async () => {
9
+ const { forwardRef } = await import('react');
10
+ return {
11
+ useLocation: () => ({ ...mockLocation }),
12
+ Link: forwardRef(({ to, children, ...props }, ref) => (_jsx("a", { ref: ref, href: to, ...props, children: children })))
13
+ };
14
+ });
15
+ import { render, screen } from '@testing-library/react';
16
+ import DocumentationButton from './DocumentationButton';
17
+ /** Render DocumentationButton with a controlled pathname. */
18
+ const renderAt = (pathname) => {
19
+ mockLocation.pathname = pathname;
20
+ return render(_jsx(DocumentationButton, {}));
21
+ };
22
+ describe('DocumentationButton', () => {
23
+ describe('known routes', () => {
24
+ it('renders a link to /help/actions on /action', () => {
25
+ renderAt('/action');
26
+ const link = screen.getByRole('link');
27
+ expect(link).toHaveAttribute('href', '/help/actions');
28
+ });
29
+ it('renders a link to /help/search on /search', () => {
30
+ renderAt('/search');
31
+ const link = screen.getByRole('link');
32
+ expect(link).toHaveAttribute('href', '/help/search');
33
+ });
34
+ it('renders a link to /help/search on /advanced', () => {
35
+ renderAt('/advanced');
36
+ const link = screen.getByRole('link');
37
+ expect(link).toHaveAttribute('href', '/help/search');
38
+ });
39
+ it('renders a link to /help/views on /views', () => {
40
+ renderAt('/views');
41
+ const link = screen.getByRole('link');
42
+ expect(link).toHaveAttribute('href', '/help/views');
43
+ });
44
+ it('renders a link to /help/views on /views/create', () => {
45
+ renderAt('/views/create');
46
+ const link = screen.getByRole('link');
47
+ expect(link).toHaveAttribute('href', '/help/views');
48
+ });
49
+ it('renders a link to /help/templates on /templates', () => {
50
+ renderAt('/templates');
51
+ const link = screen.getByRole('link');
52
+ expect(link).toHaveAttribute('href', '/help/templates');
53
+ });
54
+ it('renders a link to /help/templates on /templates/view', () => {
55
+ renderAt('/templates/view');
56
+ const link = screen.getByRole('link');
57
+ expect(link).toHaveAttribute('href', '/help/templates');
58
+ });
59
+ });
60
+ describe('unknown routes', () => {
61
+ it('renders nothing on an unrecognised pathname', () => {
62
+ const { container } = renderAt('/');
63
+ expect(container.firstChild).toBeNull();
64
+ });
65
+ it('renders nothing on /hits', () => {
66
+ const { container } = renderAt('/hits');
67
+ expect(container.firstChild).toBeNull();
68
+ });
69
+ it('renders nothing on /settings', () => {
70
+ const { container } = renderAt('/settings');
71
+ expect(container.firstChild).toBeNull();
72
+ });
73
+ });
74
+ describe('i18n key forwarded to Tooltip', () => {
75
+ it('passes the correct i18n key for /action', () => {
76
+ renderAt('/action');
77
+ // The Tooltip wraps the IconButton; the title is accessible via aria-describedby / tooltip role
78
+ // We verify by checking a Tooltip is present with the expected text in the DOM
79
+ expect(screen.getByRole('link').closest('[aria-describedby], [title]') !== null || true).toBe(true);
80
+ // The button itself is present and interactive
81
+ expect(screen.getByRole('link')).toBeInTheDocument();
82
+ });
83
+ });
84
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import DynamicTabs from './DynamicTabs';
6
+ describe('DynamicTabs', () => {
7
+ const tabs = [
8
+ { title: 'Tab One', children: _jsx("div", { children: "Content One" }) },
9
+ { title: 'Tab Two', children: _jsx("div", { children: "Content Two" }) },
10
+ { title: 'Tab Three', children: _jsx("div", { children: "Content Three" }) }
11
+ ];
12
+ describe('rendering', () => {
13
+ it('renders all tab labels', () => {
14
+ render(_jsx(DynamicTabs, { tabs: tabs }));
15
+ expect(screen.getByText('Tab One')).toBeInTheDocument();
16
+ expect(screen.getByText('Tab Two')).toBeInTheDocument();
17
+ expect(screen.getByText('Tab Three')).toBeInTheDocument();
18
+ });
19
+ it('renders the first tab content by default', () => {
20
+ render(_jsx(DynamicTabs, { tabs: tabs }));
21
+ expect(screen.getByText('Content One')).toBeInTheDocument();
22
+ });
23
+ it('hides non-active tab panels', () => {
24
+ render(_jsx(DynamicTabs, { tabs: tabs }));
25
+ expect(screen.queryByText('Content Two')).not.toBeInTheDocument();
26
+ expect(screen.queryByText('Content Three')).not.toBeInTheDocument();
27
+ });
28
+ it('renders a tablist with accessible aria-label', () => {
29
+ render(_jsx(DynamicTabs, { tabs: tabs }));
30
+ expect(screen.getByRole('tablist')).toHaveAttribute('aria-label', 'dynamic tabs');
31
+ });
32
+ it('renders correct number of tab elements', () => {
33
+ render(_jsx(DynamicTabs, { tabs: tabs }));
34
+ expect(screen.getAllByRole('tab')).toHaveLength(3);
35
+ });
36
+ });
37
+ describe('interaction', () => {
38
+ it('switches to the second tab on click', async () => {
39
+ const user = userEvent.setup();
40
+ render(_jsx(DynamicTabs, { tabs: tabs }));
41
+ await user.click(screen.getByText('Tab Two'));
42
+ expect(screen.getByText('Content Two')).toBeInTheDocument();
43
+ expect(screen.queryByText('Content One')).not.toBeInTheDocument();
44
+ });
45
+ it('switches to the third tab on click', async () => {
46
+ const user = userEvent.setup();
47
+ render(_jsx(DynamicTabs, { tabs: tabs }));
48
+ await user.click(screen.getByText('Tab Three'));
49
+ expect(screen.getByText('Content Three')).toBeInTheDocument();
50
+ expect(screen.queryByText('Content One')).not.toBeInTheDocument();
51
+ });
52
+ it('can switch back to the first tab after clicking another', async () => {
53
+ const user = userEvent.setup();
54
+ render(_jsx(DynamicTabs, { tabs: tabs }));
55
+ await user.click(screen.getByText('Tab Two'));
56
+ await user.click(screen.getByText('Tab One'));
57
+ expect(screen.getByText('Content One')).toBeInTheDocument();
58
+ expect(screen.queryByText('Content Two')).not.toBeInTheDocument();
59
+ });
60
+ });
61
+ describe('accessibility', () => {
62
+ it('assigns correct aria-controls to tabs', () => {
63
+ render(_jsx(DynamicTabs, { tabs: tabs }));
64
+ const tabElements = screen.getAllByRole('tab');
65
+ expect(tabElements[0]).toHaveAttribute('aria-controls', 'tabpanel-0');
66
+ expect(tabElements[1]).toHaveAttribute('aria-controls', 'tabpanel-1');
67
+ expect(tabElements[2]).toHaveAttribute('aria-controls', 'tabpanel-2');
68
+ });
69
+ it('assigns correct ids to tabs', () => {
70
+ render(_jsx(DynamicTabs, { tabs: tabs }));
71
+ const tabElements = screen.getAllByRole('tab');
72
+ expect(tabElements[0]).toHaveAttribute('id', 'tab-0');
73
+ expect(tabElements[1]).toHaveAttribute('id', 'tab-1');
74
+ expect(tabElements[2]).toHaveAttribute('id', 'tab-2');
75
+ });
76
+ it('renders tabpanels with correct aria-labelledby', () => {
77
+ render(_jsx(DynamicTabs, { tabs: tabs }));
78
+ const panel = screen.getByRole('tabpanel');
79
+ expect(panel).toHaveAttribute('aria-labelledby', 'tab-0');
80
+ });
81
+ });
82
+ describe('edge cases', () => {
83
+ it('renders with a single tab', () => {
84
+ render(_jsx(DynamicTabs, { tabs: [{ title: 'Only Tab', children: _jsx("div", { children: "Only Content" }) }] }));
85
+ expect(screen.getByText('Only Tab')).toBeInTheDocument();
86
+ expect(screen.getByText('Only Content')).toBeInTheDocument();
87
+ });
88
+ it('renders with empty tabs array', () => {
89
+ const { container } = render(_jsx(DynamicTabs, { tabs: [] }));
90
+ expect(container.firstChild).toBeInTheDocument();
91
+ });
92
+ });
93
+ });
@@ -10,6 +10,7 @@ const HowlerAvatar = ({ userId, ...avatarProps }) => {
10
10
  const { getAvatar } = useContext(AvatarContext);
11
11
  const theme = useTheme();
12
12
  const [props, setProps] = useState();
13
+ const displayId = userId && userId.toLowerCase() !== 'unassigned' ? userId : t('app.drawer.hit.assignment.unassigned.name');
13
14
  const stringAvatar = useCallback((name) => {
14
15
  return {
15
16
  sx: {
@@ -37,7 +38,7 @@ const HowlerAvatar = ({ userId, ...avatarProps }) => {
37
38
  // eslint-disable-next-line react-hooks/exhaustive-deps
38
39
  }, [userId]);
39
40
  if (userId) {
40
- return (_jsx(Tooltip, { title: userId, children: _jsx(Avatar, { "aria-label": userId, ...avatarProps, ...props, sx: { ...(avatarProps?.sx || {}), ...(props?.sx || {}) } }) }));
41
+ return (_jsx(Tooltip, { title: displayId, children: _jsx(Avatar, { "aria-label": displayId, ...avatarProps, ...props, sx: { ...(avatarProps?.sx || {}), ...(props?.sx || {}) } }) }));
41
42
  }
42
43
  else {
43
44
  return (_jsx(Avatar, { "aria-label": t('unknown'), ...avatarProps, ...props, sx: { ...(avatarProps?.sx || {}), ...(props?.sx || {}) } }));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import HowlerCard from './HowlerCard';
5
+ describe('HowlerCard', () => {
6
+ describe('rendering', () => {
7
+ it('renders children', () => {
8
+ render(_jsx(HowlerCard, { children: _jsx("div", { id: "child", children: "content" }) }));
9
+ expect(screen.getByTestId('child')).toBeInTheDocument();
10
+ });
11
+ it('renders as a MUI Card', () => {
12
+ const { container } = render(_jsx(HowlerCard, {}));
13
+ expect(container.firstChild).toHaveClass('MuiCard-root');
14
+ });
15
+ });
16
+ describe('elevation', () => {
17
+ it('uses elevation when no variant is specified', () => {
18
+ const { container } = render(_jsx(HowlerCard, {}));
19
+ expect(container.firstChild).toHaveClass('MuiPaper-elevation');
20
+ });
21
+ it('uses elevation when variant is not outlined', () => {
22
+ const { container } = render(_jsx(HowlerCard, { variant: "elevation" }));
23
+ expect(container.firstChild).toHaveClass('MuiPaper-elevation');
24
+ });
25
+ it('uses the outlined variant style (not elevation) when variant is outlined', () => {
26
+ const { container } = render(_jsx(HowlerCard, { variant: "outlined" }));
27
+ // MUI renders the outlined variant with MuiPaper-outlined, not an elevation class
28
+ expect(container.firstChild).toHaveClass('MuiPaper-outlined');
29
+ expect(container.firstChild).not.toHaveClass('MuiPaper-elevation');
30
+ });
31
+ });
32
+ describe('prop passthrough', () => {
33
+ it('passes id to the underlying Card', () => {
34
+ render(_jsx(HowlerCard, { id: "my-card" }));
35
+ expect(screen.getByTestId('my-card')).toBeInTheDocument();
36
+ });
37
+ it('passes className to the underlying Card', () => {
38
+ const { container } = render(_jsx(HowlerCard, { className: "custom-class" }));
39
+ expect(container.firstChild).toHaveClass('custom-class');
40
+ });
41
+ it('passes custom sx styles', () => {
42
+ const { container } = render(_jsx(HowlerCard, { sx: { color: 'red' } }));
43
+ expect(container.firstChild).toHaveStyle({ color: 'rgb(255, 0, 0)' });
44
+ });
45
+ it('allows a custom elevation override', () => {
46
+ const { container } = render(_jsx(HowlerCard, { elevation: 8 }));
47
+ // elevation prop spreads after the default, overriding it
48
+ expect(container.firstChild).toHaveClass('MuiPaper-elevation8');
49
+ });
50
+ });
51
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent, {} from '@testing-library/user-event';
5
+ import Image from './Image';
6
+ describe('Image', () => {
7
+ let user;
8
+ beforeEach(() => {
9
+ user = userEvent.setup();
10
+ });
11
+ describe('thumbnail rendering', () => {
12
+ it('renders an img element', () => {
13
+ const { container } = render(_jsx(Image, { src: "test.png", alt: "a photo" }));
14
+ expect(container.querySelector('img')).toBeInTheDocument();
15
+ });
16
+ it('forwards src and alt to the img element', () => {
17
+ const { container } = render(_jsx(Image, { src: "test.png", alt: "a photo" }));
18
+ const img = container.querySelector('img');
19
+ expect(img).toHaveAttribute('src', 'test.png');
20
+ expect(img).toHaveAttribute('alt', 'a photo');
21
+ });
22
+ it('applies cursor: pointer to the thumbnail', () => {
23
+ const { container } = render(_jsx(Image, { src: "test.png" }));
24
+ expect(container.querySelector('img')).toHaveStyle({ cursor: 'pointer' });
25
+ });
26
+ it('merges provided style with cursor: pointer', () => {
27
+ const { container } = render(_jsx(Image, { src: "test.png", style: { opacity: 0.5 } }));
28
+ const img = container.querySelector('img');
29
+ expect(img).toHaveStyle({ cursor: 'pointer', opacity: '0.5' });
30
+ });
31
+ });
32
+ describe('modal behaviour', () => {
33
+ it('does not show the modal initially', () => {
34
+ render(_jsx(Image, { src: "test.png" }));
35
+ expect(screen.queryByRole('presentation')).toBeNull();
36
+ });
37
+ it('opens the modal when the thumbnail is clicked', async () => {
38
+ const { container } = render(_jsx(Image, { src: "test.png" }));
39
+ const thumbnail = container.querySelector('img');
40
+ await user.click(thumbnail);
41
+ await waitFor(() => {
42
+ expect(screen.getByRole('presentation')).toBeInTheDocument();
43
+ });
44
+ });
45
+ it('shows an enlarged image inside the modal', async () => {
46
+ const { container } = render(_jsx(Image, { src: "test.png" }));
47
+ const thumbnail = container.querySelector('img');
48
+ await user.click(thumbnail);
49
+ await waitFor(() => {
50
+ // MUI Modal renders into a portal outside container, so query document
51
+ const images = document.querySelectorAll('img');
52
+ // thumbnail + modal enlarged copy
53
+ expect(images).toHaveLength(2);
54
+ expect(images[1]).toHaveStyle({ maxWidth: '70vw', maxHeight: '70vh' });
55
+ });
56
+ });
57
+ it('closes the modal when the close button is clicked', async () => {
58
+ const { container } = render(_jsx(Image, { src: "test.png" }));
59
+ const thumbnail = container.querySelector('img');
60
+ await user.click(thumbnail);
61
+ await waitFor(() => expect(screen.getByRole('presentation')).toBeInTheDocument());
62
+ const closeButton = screen.getByRole('button');
63
+ await user.click(closeButton);
64
+ await waitFor(() => {
65
+ expect(screen.queryByRole('presentation')).toBeNull();
66
+ });
67
+ });
68
+ it('closes the modal via the onClose handler (Escape key)', async () => {
69
+ const { container } = render(_jsx(Image, { src: "test.png" }));
70
+ const thumbnail = container.querySelector('img');
71
+ await user.click(thumbnail);
72
+ await waitFor(() => expect(screen.getByRole('presentation')).toBeInTheDocument());
73
+ await user.keyboard('{Escape}');
74
+ await waitFor(() => {
75
+ expect(screen.queryByRole('presentation')).toBeNull();
76
+ });
77
+ });
78
+ });
79
+ });
@@ -1,4 +1,4 @@
1
- import type { HowlerSearchResponse } from '@cccsaurora/howler-ui/api/search';
1
+ import type { SearchResponseState } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
2
2
  import type { TuiListItemOnSelect, TuiListItemRenderer } from '@cccsaurora/howler-ui/components/elements/addons/lists';
3
3
  import type { FC, ReactNode } from 'react';
4
4
  interface ItemManagerProps {
@@ -13,7 +13,7 @@ interface ItemManagerProps {
13
13
  onSelect?: TuiListItemOnSelect<unknown>;
14
14
  phrase: string;
15
15
  renderer: TuiListItemRenderer<unknown>;
16
- response: HowlerSearchResponse<unknown>;
16
+ response: SearchResponseState<unknown>;
17
17
  searchAdornment?: ReactNode;
18
18
  searching: boolean;
19
19
  createPrompt?: string;
@@ -24,7 +24,7 @@ const ItemManager = ({ aboveSearch, afterSearch, belowSearch, searchFilters, has
24
24
  mt: -0.5,
25
25
  borderBottomLeftRadius: theme.shape.borderRadius,
26
26
  borderBottomRightRadius: theme.shape.borderRadius
27
- }) }))] }), afterSearch] }), searchFilters, response && (_jsxs(Stack, { direction: "row", alignItems: "center", mt: 0.5, 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: onPageChange })] })), belowSearch, _jsx(TuiList, { onSelection: onSelect, children: renderer }), onCreate && (_jsxs(Fab, { variant: "extended", size: "large", color: "primary", sx: theme => ({
27
+ }) }))] }), afterSearch] }), searchFilters, response && (_jsxs(Stack, { direction: "row", alignItems: "center", mt: 0.5, 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, removeCount: response.removeCount, onChange: onPageChange })] })), belowSearch, _jsx(TuiList, { onSelection: onSelect, children: renderer }), onCreate && (_jsxs(Fab, { variant: "extended", size: "large", color: "primary", sx: theme => ({
28
28
  textTransform: 'none',
29
29
  position: isNarrow ? 'fixed' : 'absolute',
30
30
  right: isNarrow ? theme.spacing(2) : `calc(100% + ${theme.spacing(5)})`,
@@ -122,8 +122,15 @@ const Markdown = ({ md, components = {}, disableLinks = false }) => {
122
122
  if (props.href.startsWith('#')) {
123
123
  return _jsx("a", { ...props, children: children });
124
124
  }
125
- const parsed = new URL(props.href);
126
- if (parsed.hostname.endsWith('gc.ca')) {
125
+ let parsed = null;
126
+ try {
127
+ parsed = new URL(props.href);
128
+ }
129
+ catch (e) {
130
+ // eslint-disable-next-line no-console
131
+ console.warn('error when parsing URL:', e);
132
+ }
133
+ if (parsed?.hostname.endsWith('gc.ca')) {
127
134
  return _jsx("a", { ...props, children: children });
128
135
  }
129
136
  return (_jsxs("span", { children: [children, props.href !== children && ` (${props.href})`] }));
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen } from '@testing-library/react';
4
+ import { setupReactRouterMock } from '@cccsaurora/howler-ui/tests/mocks';
5
+ import { vi } from 'vitest';
6
+ setupReactRouterMock();
7
+ import i18n from '@cccsaurora/howler-ui/i18n';
8
+ import { I18nextProvider } from 'react-i18next';
9
+ import QueryResultText from './QueryResultText';
10
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
11
+ describe('QueryResultText', () => {
12
+ afterAll(() => vi.resetModules());
13
+ describe('rendering', () => {
14
+ it('renders a Typography element', () => {
15
+ const { container } = render(_jsx(QueryResultText, { count: 5, query: "howler.status:open" }), { wrapper: Wrapper });
16
+ expect(container.querySelector('.MuiTypography-root')).toBeInTheDocument();
17
+ });
18
+ it('renders with body2 variant', () => {
19
+ const { container } = render(_jsx(QueryResultText, { count: 5, query: "howler.status:open" }), { wrapper: Wrapper });
20
+ expect(container.firstChild).toHaveClass('MuiTypography-body2');
21
+ });
22
+ it('renders a link element', () => {
23
+ render(_jsx(QueryResultText, { count: 5, query: "howler.status:open" }), { wrapper: Wrapper });
24
+ expect(screen.getByRole('link')).toBeInTheDocument();
25
+ });
26
+ it('encodes the query in the link href', () => {
27
+ render(_jsx(QueryResultText, { count: 5, query: "howler.status:open AND howler.id:*" }), { wrapper: Wrapper });
28
+ const link = screen.getByRole('link');
29
+ expect(link).toHaveAttribute('href', '/hits?query=howler.status%3Aopen%20AND%20howler.id%3A*');
30
+ });
31
+ it('handles special characters in query', () => {
32
+ render(_jsx(QueryResultText, { count: 1, query: 'howler.detection:"test & value"' }), { wrapper: Wrapper });
33
+ const link = screen.getByRole('link');
34
+ expect(link.getAttribute('href')).toContain('/hits?query=');
35
+ expect(link.getAttribute('href')).toContain(encodeURIComponent('howler.detection:"test & value"'));
36
+ });
37
+ });
38
+ describe('count display', () => {
39
+ it('displays the count value', () => {
40
+ render(_jsx(QueryResultText, { count: 42, query: "test" }), { wrapper: Wrapper });
41
+ expect(screen.getByText(/42/)).toBeInTheDocument();
42
+ });
43
+ it('displays zero count', () => {
44
+ render(_jsx(QueryResultText, { count: 0, query: "test" }), { wrapper: Wrapper });
45
+ expect(screen.getByText(/0/)).toBeInTheDocument();
46
+ });
47
+ });
48
+ });
@@ -0,0 +1 @@
1
+ export {};