@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
@@ -0,0 +1,170 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
3
+ vi.mock('use-context-selector', async () => {
4
+ const actual = await vi.importActual('use-context-selector');
5
+ return {
6
+ ...actual,
7
+ createContext,
8
+ useContextSelector: (_context, selector) => selector(useContext(_context))
9
+ };
10
+ });
11
+ vi.mock('react-i18next', () => ({
12
+ useTranslation: () => ({ t: (key) => key })
13
+ }));
14
+ import { render, screen, waitFor } from '@testing-library/react';
15
+ import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
16
+ import dayjs from 'dayjs';
17
+ import minMax from 'dayjs/plugin/minMax';
18
+ import {} from 'react';
19
+ dayjs.extend(minMax);
20
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
21
+ // Import after mocks
22
+ import CustomSpan from './CustomSpan';
23
+ const mockSetCustomSpan = vi.fn();
24
+ const defaultCtx = {
25
+ span: 'date.range.custom',
26
+ startDate: undefined,
27
+ endDate: undefined,
28
+ setCustomSpan: mockSetCustomSpan
29
+ };
30
+ const makeWrapper = (ctx) => function ({ children }) {
31
+ return (_jsx(ParameterContext.Provider, { value: { ...defaultCtx, ...ctx }, children: children }));
32
+ };
33
+ describe('CustomSpan', () => {
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ });
37
+ describe('useEffect – date initialisation', () => {
38
+ it('should set both dates to defaults when both are null', async () => {
39
+ render(_jsx(CustomSpan, {}), {
40
+ wrapper: makeWrapper({ span: 'date.range.custom', startDate: undefined, endDate: undefined })
41
+ });
42
+ await waitFor(() => expect(mockSetCustomSpan).toHaveBeenCalledOnce());
43
+ const [start, end] = mockSetCustomSpan.mock.calls[0];
44
+ const startDate = dayjs(start);
45
+ const endDate = dayjs(end);
46
+ expect(startDate.isBefore(endDate)).toBe(true);
47
+ expect(dayjs().diff(startDate, 'hour')).toBeGreaterThanOrEqual(47);
48
+ expect(dayjs().diff(startDate, 'hour')).toBeLessThanOrEqual(49);
49
+ expect(dayjs().diff(endDate, 'hour')).toBeGreaterThanOrEqual(23);
50
+ expect(dayjs().diff(endDate, 'hour')).toBeLessThanOrEqual(25);
51
+ });
52
+ it('should set default start date when only endDate is provided', async () => {
53
+ // Use a recent end date that's after the default start (now - 2 days)
54
+ const existingEnd = dayjs().subtract(1, 'hour').toISOString();
55
+ render(_jsx(CustomSpan, {}), {
56
+ wrapper: makeWrapper({ span: 'date.range.custom', startDate: undefined, endDate: existingEnd })
57
+ });
58
+ await waitFor(() => expect(mockSetCustomSpan).toHaveBeenCalledOnce());
59
+ const [start, end] = mockSetCustomSpan.mock.calls[0];
60
+ expect(end).toBe(existingEnd);
61
+ expect(dayjs(start).isBefore(dayjs(end))).toBe(true);
62
+ });
63
+ it('should set default end date when only startDate is provided', async () => {
64
+ // Use a recent start date that's before the default end (now - 1 day)
65
+ const existingStart = dayjs().subtract(3, 'day').toISOString();
66
+ render(_jsx(CustomSpan, {}), {
67
+ wrapper: makeWrapper({ span: 'date.range.custom', startDate: existingStart, endDate: undefined })
68
+ });
69
+ await waitFor(() => expect(mockSetCustomSpan).toHaveBeenCalledOnce());
70
+ const [start, end] = mockSetCustomSpan.mock.calls[0];
71
+ expect(start).toBe(existingStart);
72
+ expect(dayjs(end).isAfter(dayjs(start))).toBe(true);
73
+ });
74
+ it('should NOT call setCustomSpan when both dates are already set', () => {
75
+ render(_jsx(CustomSpan, {}), {
76
+ wrapper: makeWrapper({
77
+ span: 'date.range.custom',
78
+ startDate: dayjs().subtract(3, 'day').toISOString(),
79
+ endDate: dayjs().subtract(1, 'hour').toISOString()
80
+ })
81
+ });
82
+ expect(mockSetCustomSpan).not.toHaveBeenCalled();
83
+ });
84
+ it('should NOT call setCustomSpan when span is not custom', () => {
85
+ render(_jsx(CustomSpan, {}), {
86
+ wrapper: makeWrapper({ span: 'date.range.1.month', startDate: undefined, endDate: undefined })
87
+ });
88
+ expect(mockSetCustomSpan).not.toHaveBeenCalled();
89
+ });
90
+ it('should NOT call setCustomSpan when span is undefined', () => {
91
+ render(_jsx(CustomSpan, {}), {
92
+ wrapper: makeWrapper({ span: undefined, startDate: undefined, endDate: undefined })
93
+ });
94
+ expect(mockSetCustomSpan).not.toHaveBeenCalled();
95
+ });
96
+ it('should ensure startDate is before endDate even if existing startDate is after default endDate', async () => {
97
+ const futureStart = dayjs().add(1, 'hour').toISOString();
98
+ render(_jsx(CustomSpan, {}), {
99
+ wrapper: makeWrapper({ span: 'date.range.custom', startDate: futureStart, endDate: undefined })
100
+ });
101
+ await waitFor(() => expect(mockSetCustomSpan).toHaveBeenCalledOnce());
102
+ const [start, end] = mockSetCustomSpan.mock.calls[0];
103
+ expect(dayjs(start).isBefore(dayjs(end))).toBe(true);
104
+ });
105
+ });
106
+ describe('rendering', () => {
107
+ it('should render nothing when span is not custom', () => {
108
+ const { container } = render(_jsx(CustomSpan, {}), {
109
+ wrapper: makeWrapper({ span: 'date.range.1.month' })
110
+ });
111
+ expect(container.innerHTML).toBe('');
112
+ });
113
+ it('should render nothing when span is undefined', () => {
114
+ const { container } = render(_jsx(CustomSpan, {}), {
115
+ wrapper: makeWrapper({ span: undefined })
116
+ });
117
+ expect(container.innerHTML).toBe('');
118
+ });
119
+ it('should render two date pickers when span is custom', () => {
120
+ render(_jsx(CustomSpan, {}), {
121
+ wrapper: makeWrapper({
122
+ span: 'date.range.custom',
123
+ startDate: dayjs().subtract(3, 'day').toISOString(),
124
+ endDate: dayjs().subtract(1, 'hour').toISOString()
125
+ })
126
+ });
127
+ expect(screen.getByLabelText('date.select.start')).toBeInTheDocument();
128
+ expect(screen.getByLabelText('date.select.end')).toBeInTheDocument();
129
+ });
130
+ it('should render pickers for any span ending with "custom"', () => {
131
+ render(_jsx(CustomSpan, {}), {
132
+ wrapper: makeWrapper({
133
+ span: 'my.prefix.custom',
134
+ startDate: dayjs().subtract(3, 'day').toISOString(),
135
+ endDate: dayjs().subtract(1, 'hour').toISOString()
136
+ })
137
+ });
138
+ expect(screen.getByLabelText('date.select.start')).toBeInTheDocument();
139
+ expect(screen.getByLabelText('date.select.end')).toBeInTheDocument();
140
+ });
141
+ });
142
+ describe('onChange handlers', () => {
143
+ it('should not call setCustomSpan when both dates are present on render', () => {
144
+ render(_jsx(CustomSpan, {}), {
145
+ wrapper: makeWrapper({
146
+ span: 'date.range.custom',
147
+ startDate: dayjs().subtract(3, 'day').toISOString(),
148
+ endDate: dayjs().subtract(1, 'hour').toISOString()
149
+ })
150
+ });
151
+ const startPicker = screen.getByLabelText('date.select.start');
152
+ expect(startPicker).toBeInTheDocument();
153
+ expect(mockSetCustomSpan).not.toHaveBeenCalled();
154
+ });
155
+ it('should render start picker with fallback value when startDate is null', async () => {
156
+ render(_jsx(CustomSpan, {}), {
157
+ wrapper: makeWrapper({
158
+ span: 'date.range.custom',
159
+ startDate: undefined,
160
+ endDate: dayjs().subtract(1, 'hour').toISOString()
161
+ })
162
+ });
163
+ // When startDate is null, the picker's value falls back to dayjs().subtract(1, 'days')
164
+ const startInput = screen.getByLabelText('date.select.start');
165
+ expect(startInput).toBeInTheDocument();
166
+ // The input should have a value (the fallback), not be empty
167
+ expect(startInput.value).not.toBe('');
168
+ });
169
+ });
170
+ });
@@ -64,7 +64,7 @@ const AddNewCard = ({ dashboard, addCard }) => {
64
64
  fetchAllAnalytics();
65
65
  }, []);
66
66
  const filteredAnalyticVisualizations = useMemo(() => {
67
- const existingAnalyticCards = dashboard.filter(_card => _card.type === "analytic");
67
+ const existingAnalyticCards = dashboard.filter(_card => _card.type === 'analytic');
68
68
  return VISUALIZATIONS.filter(viz => {
69
69
  return !existingAnalyticCards.some(_card => {
70
70
  const parsedConfig = JSON.parse(_card.config);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import i18n from '@cccsaurora/howler-ui/i18n';
5
+ import React from 'react';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { setupContextSelectorMock } from '@cccsaurora/howler-ui/tests/mocks';
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import AddNewCard from './AddNewCard';
10
+ setupContextSelectorMock();
11
+ const mockApiSearchAnalyticPost = vi.fn();
12
+ vi.mock('api', () => ({
13
+ default: {
14
+ search: {
15
+ analytic: {
16
+ post: (...args) => mockApiSearchAnalyticPost(...args)
17
+ }
18
+ }
19
+ }
20
+ }));
21
+ vi.mock('components/elements/addons/buttons/CustomButton', () => ({
22
+ default: ({ children, disabled, onClick, ...props }) => (_jsx("button", { disabled: disabled, onClick: onClick, ...props, children: children }))
23
+ }));
24
+ const mockFetchViews = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
25
+ vi.mock('components/app/providers/ViewProvider', () => ({
26
+ ViewContext: React.createContext({
27
+ views: {
28
+ 'view-1': {
29
+ view_id: 'view-1',
30
+ title: 'My View',
31
+ query: 'howler.status:open',
32
+ sort: 'event.created desc',
33
+ span: 'date.range.1.month',
34
+ type: 'personal',
35
+ owner: 'testuser',
36
+ settings: { advance_on_triage: false }
37
+ },
38
+ 'view-2': {
39
+ view_id: 'view-2',
40
+ title: 'Second View',
41
+ query: 'howler.status:open',
42
+ sort: 'event.created desc',
43
+ span: 'date.range.1.month',
44
+ type: 'personal',
45
+ owner: 'testuser',
46
+ settings: { advance_on_triage: false }
47
+ }
48
+ },
49
+ fetchViews: mockFetchViews
50
+ })
51
+ }));
52
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
53
+ describe('AddNewCard', () => {
54
+ const mockAddCard = vi.fn();
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ mockApiSearchAnalyticPost.mockResolvedValue({
58
+ items: [{ analytic_id: 'analytic-1', name: 'Test Analytic', description: 'Test description' }],
59
+ total: 1
60
+ });
61
+ });
62
+ it('should render the add card title', async () => {
63
+ render(_jsx(AddNewCard, { dashboard: [], addCard: mockAddCard }), { wrapper: Wrapper });
64
+ await waitFor(() => {
65
+ expect(screen.getByText('Add New Panel')).toBeInTheDocument();
66
+ });
67
+ });
68
+ it('should render the description', async () => {
69
+ render(_jsx(AddNewCard, { dashboard: [], addCard: mockAddCard }), { wrapper: Wrapper });
70
+ await waitFor(() => {
71
+ expect(screen.getByText(/Add an additional panel/)).toBeInTheDocument();
72
+ });
73
+ });
74
+ it('should have the create button disabled initially', async () => {
75
+ render(_jsx(AddNewCard, { dashboard: [], addCard: mockAddCard }), { wrapper: Wrapper });
76
+ await waitFor(() => {
77
+ expect(screen.getByRole('button', { name: /Create/i })).toBeDisabled();
78
+ });
79
+ });
80
+ it('should fetch analytics on mount', async () => {
81
+ render(_jsx(AddNewCard, { dashboard: [], addCard: mockAddCard }), { wrapper: Wrapper });
82
+ await waitFor(() => {
83
+ expect(mockApiSearchAnalyticPost).toHaveBeenCalledWith(expect.objectContaining({ query: '*:*' }));
84
+ });
85
+ });
86
+ });
@@ -3,7 +3,7 @@ import { OpenInNew } from '@mui/icons-material';
3
3
  import { Card, CardContent, IconButton, Skeleton, Stack, Typography } from '@mui/material';
4
4
  import api from '@cccsaurora/howler-ui/api';
5
5
  import AppListEmpty from '@cccsaurora/howler-ui/commons/components/display/AppListEmpty';
6
- import { useHitContextSelector as useRecordContextSelector } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
6
+ import { useRecordContextSelector } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
7
7
  import { ViewContext } from '@cccsaurora/howler-ui/components/app/providers/ViewProvider';
8
8
  import HitBanner from '@cccsaurora/howler-ui/components/elements/hit/HitBanner';
9
9
  import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { act, render, screen } from '@testing-library/react';
4
+ import i18n from '@cccsaurora/howler-ui/i18n';
5
+ import React from 'react';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import ViewRefresh, {} from './ViewRefresh';
9
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
10
+ describe('ViewRefresh', () => {
11
+ beforeEach(() => {
12
+ vi.useFakeTimers();
13
+ });
14
+ afterEach(() => {
15
+ vi.useRealTimers();
16
+ });
17
+ it('should render the refresh button', () => {
18
+ const onRefresh = vi.fn();
19
+ render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
20
+ expect(screen.getByRole('button')).toBeInTheDocument();
21
+ });
22
+ it('should show a progress indicator initially', () => {
23
+ const onRefresh = vi.fn();
24
+ render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
25
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
26
+ });
27
+ it('should trigger refresh when progress reaches 100%', async () => {
28
+ const onRefresh = vi.fn();
29
+ render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
30
+ // Progress increments by 1 every refreshRate*10ms = 300ms.
31
+ // Each increment triggers a re-render and a new setTimeout.
32
+ // We advance one tick at a time inside act to let React process each state update.
33
+ for (let i = 0; i < 101; i++) {
34
+ await act(async () => {
35
+ vi.advanceTimersByTime(300);
36
+ });
37
+ }
38
+ expect(onRefresh).toHaveBeenCalled();
39
+ });
40
+ it('should clear refreshing state when all cards report back via ref', async () => {
41
+ const onRefresh = vi.fn();
42
+ const ref = React.createRef();
43
+ render(_jsx(ViewRefresh, { ref: ref, refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
44
+ // Advance to 100% to trigger refresh
45
+ for (let i = 0; i < 101; i++) {
46
+ await act(async () => {
47
+ vi.advanceTimersByTime(300);
48
+ });
49
+ }
50
+ expect(onRefresh).toHaveBeenCalled();
51
+ // Simulate both cards completing
52
+ act(() => {
53
+ ref.current?.handleRefreshComplete();
54
+ ref.current?.handleRefreshComplete();
55
+ });
56
+ // After all cards complete, the progress should reset (button re-enabled)
57
+ expect(screen.getByRole('button')).not.toBeDisabled();
58
+ });
59
+ it('should trigger refresh via manual click', () => {
60
+ const onRefresh = vi.fn();
61
+ render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 2, onRefresh: onRefresh }), { wrapper: Wrapper });
62
+ // Click the refresh button directly using fireEvent (avoids userEvent timer issues)
63
+ const button = screen.getByRole('button');
64
+ act(() => {
65
+ button.click();
66
+ });
67
+ expect(onRefresh).toHaveBeenCalled();
68
+ });
69
+ it('should not call onRefresh when viewCardCount is 0 and button is clicked', () => {
70
+ const onRefresh = vi.fn();
71
+ render(_jsx(ViewRefresh, { refreshRate: 30, viewCardCount: 0, onRefresh: onRefresh }), { wrapper: Wrapper });
72
+ const button = screen.getByRole('button');
73
+ act(() => {
74
+ button.click();
75
+ });
76
+ // onRefresh should not be called because viewCardCount is 0
77
+ expect(onRefresh).not.toHaveBeenCalled();
78
+ });
79
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
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 i18n from '@cccsaurora/howler-ui/i18n';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import OverviewCard from './OverviewCard';
9
+ vi.mock('components/elements/display/HowlerAvatar', () => ({
10
+ default: ({ userId }) => _jsx("div", { id: "howler-avatar", children: userId })
11
+ }));
12
+ vi.mock('components/elements/addons/layout/FlexOne', () => ({
13
+ default: () => _jsx("div", { id: "flex-one" })
14
+ }));
15
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
16
+ describe('OverviewCard', () => {
17
+ let user;
18
+ beforeEach(() => {
19
+ user = userEvent.setup();
20
+ vi.clearAllMocks();
21
+ });
22
+ it('should render overview analytic and detection', () => {
23
+ const overview = {
24
+ overview_id: 'ov-1',
25
+ analytic: 'Test Analytic',
26
+ detection: 'Test Detection',
27
+ content: 'line 1\nline 2\nline 3',
28
+ owner: 'testuser'
29
+ };
30
+ render(_jsx(OverviewCard, { overview: overview }), { wrapper: Wrapper });
31
+ expect(screen.getByText(/Test Analytic/)).toBeInTheDocument();
32
+ expect(screen.getByText(/Test Detection/)).toBeInTheDocument();
33
+ });
34
+ it('should render "All" when detection is null', () => {
35
+ const overview = {
36
+ overview_id: 'ov-1',
37
+ analytic: 'Test Analytic',
38
+ detection: null,
39
+ content: 'line 1',
40
+ owner: 'testuser'
41
+ };
42
+ render(_jsx(OverviewCard, { overview: overview }), { wrapper: Wrapper });
43
+ expect(screen.getByText(/All/)).toBeInTheDocument();
44
+ });
45
+ it('should display content preview (max 3 lines)', () => {
46
+ const overview = {
47
+ overview_id: 'ov-1',
48
+ analytic: 'Test Analytic',
49
+ detection: null,
50
+ content: 'first line\nsecond line\nthird line\nfourth line\nfifth line',
51
+ owner: 'testuser'
52
+ };
53
+ render(_jsx(OverviewCard, { overview: overview }), { wrapper: Wrapper });
54
+ expect(screen.getByText(/first line/)).toBeInTheDocument();
55
+ expect(screen.getByText(/second line/)).toBeInTheDocument();
56
+ expect(screen.getByText(/third line/)).toBeInTheDocument();
57
+ });
58
+ it('should render the owner avatar', () => {
59
+ const overview = {
60
+ overview_id: 'ov-1',
61
+ analytic: 'Test Analytic',
62
+ detection: null,
63
+ content: 'content',
64
+ owner: 'testuser'
65
+ };
66
+ render(_jsx(OverviewCard, { overview: overview }), { wrapper: Wrapper });
67
+ expect(screen.getByTestId('howler-avatar')).toBeInTheDocument();
68
+ expect(screen.getByText('testuser')).toBeInTheDocument();
69
+ });
70
+ it('should not show delete button when onDelete is not provided', () => {
71
+ const overview = {
72
+ overview_id: 'ov-1',
73
+ analytic: 'Test Analytic',
74
+ detection: null,
75
+ content: 'content',
76
+ owner: 'testuser'
77
+ };
78
+ render(_jsx(OverviewCard, { overview: overview }), { wrapper: Wrapper });
79
+ expect(document.querySelector('[data-testid="DeleteIcon"]')).not.toBeInTheDocument();
80
+ });
81
+ it('should show delete button and call onDelete when clicked', async () => {
82
+ const mockOnDelete = vi.fn();
83
+ const overview = {
84
+ overview_id: 'ov-1',
85
+ analytic: 'Test Analytic',
86
+ detection: null,
87
+ content: 'content',
88
+ owner: 'testuser'
89
+ };
90
+ render(_jsx(OverviewCard, { overview: overview, onDelete: mockOnDelete }), { wrapper: Wrapper });
91
+ const deleteButton = document.querySelector('[data-testid="DeleteIcon"]').closest('button');
92
+ await user.click(deleteButton);
93
+ expect(mockOnDelete).toHaveBeenCalledWith(expect.anything(), 'ov-1');
94
+ });
95
+ it('should apply custom className', () => {
96
+ const overview = {
97
+ overview_id: 'ov-1',
98
+ analytic: 'Test Analytic',
99
+ detection: null,
100
+ content: 'content',
101
+ owner: 'testuser'
102
+ };
103
+ const { container } = render(_jsx(OverviewCard, { overview: overview, className: "custom-class" }), {
104
+ wrapper: Wrapper
105
+ });
106
+ expect(container.querySelector('.custom-class')).toBeInTheDocument();
107
+ });
108
+ });
@@ -8,9 +8,11 @@ import { Check, DarkMode, Delete, SsidChart, WbSunny } from '@mui/icons-material
8
8
  import { useApp } from '@cccsaurora/howler-ui/commons/components/app/hooks';
9
9
  import AppInfoPanel from '@cccsaurora/howler-ui/commons/components/display/AppInfoPanel';
10
10
  import useThemeBuilder from '@cccsaurora/howler-ui/commons/components/utils/hooks/useThemeBuilder';
11
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
11
12
  import { OverviewContext } from '@cccsaurora/howler-ui/components/app/providers/OverviewProvider';
12
13
  import HitOverview from '@cccsaurora/howler-ui/components/elements/hit/HitOverview';
13
14
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
15
+ import useMySnackbar from '@cccsaurora/howler-ui/components/hooks/useMySnackbar';
14
16
  import useMyTheme from '@cccsaurora/howler-ui/components/hooks/useMyTheme';
15
17
  import { useSearchParams } from 'react-router-dom';
16
18
  import hitsData from '@cccsaurora/howler-ui/utils/hit.json';
@@ -25,6 +27,8 @@ const OverviewViewer = () => {
25
27
  const [params, setParams] = useSearchParams();
26
28
  const { getOverviews } = useContext(OverviewContext);
27
29
  const { dispatchApi } = useMyApi();
30
+ const { withConfirmDeleteModal } = useContext(ModalContext);
31
+ const { showSuccessMessage } = useMySnackbar();
28
32
  const [overviewList, setOverviewList] = useState([]);
29
33
  const [selectedOverview, setSelectedOverview] = useState(null);
30
34
  const [content, setContent] = useState('');
@@ -124,15 +128,19 @@ const OverviewViewer = () => {
124
128
  replace: true
125
129
  });
126
130
  }, [analytic, detection, params, setParams]);
127
- const onDelete = useCallback(async () => {
128
- await dispatchApi(api.overview.del(selectedOverview.overview_id), {
129
- logError: false,
130
- showError: true,
131
- throwError: false
131
+ const onDelete = useCallback(() => {
132
+ withConfirmDeleteModal(async () => {
133
+ await dispatchApi(api.overview.del(selectedOverview.overview_id), {
134
+ logError: false,
135
+ showError: true,
136
+ throwError: true
137
+ });
138
+ setOverviewList(l => l.filter(v => v.overview_id !== selectedOverview.overview_id));
139
+ setSelectedOverview(null);
140
+ setContent('');
141
+ showSuccessMessage(t('route.overviews.manager.delete.success'));
132
142
  });
133
- setSelectedOverview(null);
134
- setContent('');
135
- }, [dispatchApi, selectedOverview?.overview_id]);
143
+ }, [dispatchApi, selectedOverview?.overview_id, withConfirmDeleteModal, showSuccessMessage, t]);
136
144
  const onSave = useCallback(async () => {
137
145
  if (analytic && detection) {
138
146
  try {
@@ -2,6 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Article } from '@mui/icons-material';
3
3
  import { Typography } from '@mui/material';
4
4
  import api from '@cccsaurora/howler-ui/api';
5
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
+ import SearchResponseProvider, { SearchResponseContext } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
5
7
  import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
6
8
  import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
7
9
  import ItemManager from '@cccsaurora/howler-ui/components/elements/display/ItemManager';
@@ -18,14 +20,15 @@ const OverviewsBase = () => {
18
20
  const navigate = useNavigate();
19
21
  const { dispatchApi } = useMyApi();
20
22
  const { showSuccessMessage } = useMySnackbar();
23
+ const { withConfirmDeleteModal } = useContext(ModalContext);
21
24
  const [searchParams, setSearchParams] = useSearchParams();
22
25
  const { load } = useContext(TuiListMethodContext);
23
26
  const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
24
27
  const [phrase, setPhrase] = useState('');
25
28
  const [offset, setOffset] = useState(parseInt(searchParams.get('offset')) || 0);
26
- const [response, setResponse] = useState(null);
27
29
  const [hasError, setHasError] = useState(false);
28
30
  const [loading, setLoading] = useState(false);
31
+ const { response, request, remove, getSearchRequestData } = useContext(SearchResponseContext);
29
32
  const onSearch = useCallback(async () => {
30
33
  try {
31
34
  setLoading(true);
@@ -40,11 +43,11 @@ const OverviewsBase = () => {
40
43
  // Check for the actual search query
41
44
  const query = phrase ? `*:*${phrase}*` : '*:*';
42
45
  // Ensure the overview should be visible and/or matches the type we are filtering for
43
- setResponse(await dispatchApi(api.search.overview.post({
46
+ await request(api.search.overview.post, {
44
47
  query,
45
48
  rows: pageCount,
46
49
  offset
47
- })));
50
+ });
48
51
  }
49
52
  catch (e) {
50
53
  setHasError(true);
@@ -52,7 +55,7 @@ const OverviewsBase = () => {
52
55
  finally {
53
56
  setLoading(false);
54
57
  }
55
- }, [phrase, setSearchParams, searchParams, dispatchApi, pageCount, offset]);
58
+ }, [phrase, setSearchParams, searchParams, request, pageCount, offset]);
56
59
  // Load the items into list when response changes.
57
60
  // This hook should only trigger when the 'response' changes.
58
61
  useEffect(() => {
@@ -68,24 +71,27 @@ const OverviewsBase = () => {
68
71
  }, [response, load]);
69
72
  const onPageChange = useCallback((_offset) => {
70
73
  if (_offset !== offset) {
71
- searchParams.set('offset', _offset.toString());
74
+ const modifiedRequest = getSearchRequestData({ offset: _offset });
75
+ searchParams.set('offset', modifiedRequest.offset.toString());
72
76
  setSearchParams(searchParams, { replace: true });
73
- setOffset(_offset);
77
+ setOffset(modifiedRequest.offset);
74
78
  }
75
- }, [offset, searchParams, setSearchParams]);
76
- const onDelete = useCallback(async (e, id) => {
79
+ }, [offset, searchParams, setSearchParams, getSearchRequestData]);
80
+ const onDelete = useCallback((e, id) => {
77
81
  e.preventDefault();
78
82
  e.stopPropagation();
79
- try {
80
- await dispatchApi(api.overview.del(id), { throwError: false, showError: true });
81
- await onSearch();
82
- showSuccessMessage(t('route.overviews.manager.delete.success'));
83
- }
84
- catch (_err) {
85
- // eslint-disable-next-line no-console
86
- console.warn(_err);
87
- }
88
- }, [dispatchApi, onSearch, showSuccessMessage, t]);
83
+ withConfirmDeleteModal(async () => {
84
+ try {
85
+ await dispatchApi(api.overview.del(id), { throwError: true, showError: true });
86
+ remove(id);
87
+ showSuccessMessage(t('route.overviews.manager.delete.success'));
88
+ }
89
+ catch (_err) {
90
+ // eslint-disable-next-line no-console
91
+ console.warn(_err);
92
+ }
93
+ });
94
+ }, [dispatchApi, remove, withConfirmDeleteModal, showSuccessMessage, t]);
89
95
  useEffect(() => {
90
96
  onSearch();
91
97
  if (!searchParams.has('offset')) {
@@ -111,6 +117,6 @@ const OverviewsBase = () => {
111
117
  return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: loading, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.overviews.search.prompt') }), renderer: ({ item }, classRenderer) => renderer(item.item, classRenderer()), response: response, onSelect: (item) => navigate(`/overviews/view?analytic=${item.item.analytic}${item.item.detection ? '&detection=' + item.item.detection : ''}`), onCreate: () => navigate('/overviews/view'), createPrompt: "route.overviews.create", searchPrompt: "route.overviews.manager.search", createIcon: _jsx(Article, { sx: { mr: 1 } }) }));
112
118
  };
113
119
  const Overviews = () => {
114
- return (_jsx(TuiListProvider, { children: _jsx(OverviewsBase, {}) }));
120
+ return (_jsx(TuiListProvider, { children: _jsx(SearchResponseProvider, { idField: "overview_id", children: _jsx(OverviewsBase, {}) }) }));
115
121
  };
116
122
  export default Overviews;
@@ -1 +1 @@
1
- export default "# Creating an Overview\n\nOverviews can be used to modify the way data is presented on alerts that match the overview's settings. Overviews are, by design, easy to create and quite flexible.\n\n## Getting Started\n\nThe basic building blocks of overviews are:\n\n1. Markdown\n2. Handlebars\n\nWe will quickly explain these.\n\n### Markdown\n\nQuoting from the excellent [markdownguide.org](https://www.markdownguide.org/getting-started/):\n\n> Markdown is a lightweight markup language that you can use to add formatting elements to plaintext text documents. Created by [John Gruber](https://daringfireball.net/projects/markdown/) in 2004, Markdown is now one of the world's most popular markup languages.\n>\n> Using Markdown is different than using a [WYSIWYG](https://en.wikipedia.org/wiki/WYSIWYG) editor. In an application like Microsoft Word, you click buttons to format words and phrases, and the changes are visible immediately. Markdown isn't like that. When you create a Markdown-formatted file, you add Markdown syntax to the text to indicate which words and phrases should look different.\n>\n> For example, to denote a heading, you add a number sign before it (e.g., `# Heading One`). Or to make a phrase bold, you add two asterisks before and after it (e.g., `**this text is bold**`). It may take a while to get used to seeing Markdown syntax in your text, especially if you're accustomed to WYSIWYG applications.\n\n---\n\n### Handlebars\n\nQuoting from [handlebarsjs.com](https://handlebarsjs.com/guide/):\n\n> Handlebars is a simple templating language.\n>\n> It uses a template and an input object to generate HTML or other text formats. Handlebars templates look like regular text with embedded Handlebars expressions.\n>\n>```html\n> <p>{{curly \"firstname\"}} {{curly \"lastname\"}}</p>\n>```\n>\n> A handlebars expression is a double curly bracket, some contents, followed by a set of closing double curly brackets. When the template is executed, these expressions are replaced with values from an input object.\n\n---\n\nFor our cases, we use handlebars to replace specific parts of markdown with the values included in a given howler hit. For example:\n\n```markdown\nThis analytic is **{{curly \"howler.analytic\"}}**\n```\n\nbecomes:\n\n> This analytic is **{{howler.analytic}}**.\n\nFor more information on handlebars, check out:\n\n- [What is Handlebars?](https://handlebarsjs.com/guide/#what-is-handlebars)\n- [Handlebars Expressions](https://handlebarsjs.com/guide/expressions.html)\n\n## Combining Markdown and Handlebars\n\nYou can use handlebars for template replacement throughout your markdown. Below is an example table using handlebars and markdown:\n\n```markdown\n\n| Source IP | Destination IP |\n| --- | --- |\n| {{curly \"source.ip\"}} |{{curly \"destination.ip\"}} |\n```\n\nrenders as:\n\n| Source IP | Destination IP |\n| --- | --- |\n| {{source.ip}} |{{destination.ip}} |\n\n## Advanced Handlebars\n\nHowler integrates a number of helper functions for you to work with.\n\n### Control Expressions\n\nFor use as subexpressions, we expose a number of conditional checks:\n\n**Equality:**\n\nGiven `howler.status` is {{howler.status}}:\n\n```markdown\n{{curly '#if (equals howler.status \"open\")'}}\nHit is open!\n{{curly \"/if\"}}\n{{curly '#if (equals howler.status \"resolved\")'}}\nHit is resolved!\n{{curly \"/if\"}}\n```\n\n{{#if (equals howler.status \"open\")}}\nHit is open!\n{{/if}}\n{{#if (equals howler.status \"resolved\")}}\nHit is resolved!\n{{/if}}\n\n**AND/OR/NOT:**\n\nGiven `howler.status` is {{howler.status}}, and `howler.escalation` is {{howler.escalation}}:\n\n```markdown\n{{curly '#if (and (equals howler.status \"open\") (equals howler.escalation \"alert\"))'}}\nThis is correct!\n{{curly \"/if\"}}\n{{curly '#if (and (equals howler.status \"resolved\") (equals howler.escalation \"hit\"))'}}\nThis is wrong!\n{{curly \"/if\"}}\n```\n\n{{#if (and (equals howler.status \"open\") (equals howler.escalation \"alert\"))}}\nThis is correct!\n{{/if}}\n{{#if (and (equals howler.status \"resolved\") (equals howler.escalation \"hit\"))}}\nThis is wrong!\n{{/if}}\n\n```markdown\n{{curly '#if (or howler.is_bundle (not howler.is_bundle))'}}\nAlways shows!\n{{curly \"/if\"}}\n```\n\n{{#if (or howler.is_bundle (not howler.is_bundle))}}\nAlways shows!\n{{/if}}\n\n---\n\n### String Operations\n\n**String Concatenation:**\n\n```markdown\n{{curly 'join \"string one \" \"string two\"'}}\n```\n\n{{join \"string one \" \"string two\"}}\n\n**Uppercase/Lowercase:**\n\n```markdown\n{{curly 'upper \"make this uppercase\"'}}\n{{curly 'lower \"MAKE THIS LOWERCASE\"'}}\n```\n\n{{upper \"make this uppercase\"}}\n\n{{lower \"MAKE THIS LOWERCASE\"}}\n\n---\n\n### Fetching Data\n\nYou can also make basic fetch requests for, and parse, JSON data from external sources:\n\n```markdown\n{{curly 'fetch \"/api/v1/configs\" \"api_response.c12nDef.UNRESTRICTED\"'}}\n```\n\n{{fetch \"/api/v1/configs\" \"api_response.c12nDef.UNRESTRICTED\"}}\n\n## Full Helper List\n"
1
+ export default "# Creating an Overview\n\nOverviews can be used to modify the way data is presented on alerts that match the overview's settings. Overviews are, by design, easy to create and quite flexible.\n\n## Getting Started\n\nThe basic building blocks of overviews are:\n\n1. Markdown\n2. Handlebars\n\nWe will quickly explain these.\n\n### Markdown\n\nQuoting from the excellent [markdownguide.org](https://www.markdownguide.org/getting-started/):\n\n> Markdown is a lightweight markup language that you can use to add formatting elements to plaintext text documents. Created by [John Gruber](https://daringfireball.net/projects/markdown/) in 2004, Markdown is now one of the world's most popular markup languages.\n>\n> Using Markdown is different than using a [WYSIWYG](https://en.wikipedia.org/wiki/WYSIWYG) editor. In an application like Microsoft Word, you click buttons to format words and phrases, and the changes are visible immediately. Markdown isn't like that. When you create a Markdown-formatted file, you add Markdown syntax to the text to indicate which words and phrases should look different.\n>\n> For example, to denote a heading, you add a number sign before it (e.g., `# Heading One`). Or to make a phrase bold, you add two asterisks before and after it (e.g., `**this text is bold**`). It may take a while to get used to seeing Markdown syntax in your text, especially if you're accustomed to WYSIWYG applications.\n\n---\n\n### Handlebars\n\nQuoting from [handlebarsjs.com](https://handlebarsjs.com/guide/):\n\n> Handlebars is a simple templating language.\n>\n> It uses a template and an input object to generate HTML or other text formats. Handlebars templates look like regular text with embedded Handlebars expressions.\n>\n> ```html\n> <p>{{curly \"firstname\"}} {{curly \"lastname\"}}</p>\n> ```\n>\n> A handlebars expression is a double curly bracket, some contents, followed by a set of closing double curly brackets. When the template is executed, these expressions are replaced with values from an input object.\n\n---\n\nFor our cases, we use handlebars to replace specific parts of markdown with the values included in a given howler hit. For example:\n\n```markdown\nThis analytic is **{{curly \"howler.analytic\"}}**\n```\n\nbecomes:\n\n> This analytic is **{{howler.analytic}}**.\n\nFor more information on handlebars, check out:\n\n- [What is Handlebars?](https://handlebarsjs.com/guide/#what-is-handlebars)\n- [Handlebars Expressions](https://handlebarsjs.com/guide/expressions.html)\n\n## Combining Markdown and Handlebars\n\nYou can use handlebars for template replacement throughout your markdown. Below is an example table using handlebars and markdown:\n\n```markdown\n| Source IP | Destination IP |\n| --------------------- | -------------------------- |\n| {{curly \"source.ip\"}} | {{curly \"destination.ip\"}} |\n```\n\nrenders as:\n\n| Source IP | Destination IP |\n| ------------- | ------------------ |\n| {{source.ip}} | {{destination.ip}} |\n\n## Advanced Handlebars\n\nHowler integrates a number of helper functions for you to work with.\n\n### Control Expressions\n\nFor use as subexpressions, we expose a number of conditional checks:\n\n**Equality:**\n\nGiven `howler.status` is {{howler.status}}:\n\n```markdown\n{{curly '#if (equals howler.status \"open\")'}}\nHit is open!\n{{curly \"/if\"}}\n{{curly '#if (equals howler.status \"resolved\")'}}\nHit is resolved!\n{{curly \"/if\"}}\n```\n\n{{#if (equals howler.status \"open\")}}\nHit is open!\n{{/if}}\n{{#if (equals howler.status \"resolved\")}}\nHit is resolved!\n{{/if}}\n\n**AND/OR/NOT:**\n\nGiven `howler.status` is {{howler.status}}, and `howler.escalation` is {{howler.escalation}}:\n\n```markdown\n{{curly '#if (and (equals howler.status \"open\") (equals howler.escalation \"alert\"))'}}\nThis is correct!\n{{curly \"/if\"}}\n{{curly '#if (and (equals howler.status \"resolved\") (equals howler.escalation \"hit\"))'}}\nThis is wrong!\n{{curly \"/if\"}}\n```\n\n{{#if (and (equals howler.status \"open\") (equals howler.escalation \"alert\"))}}\nThis is correct!\n{{/if}}\n{{#if (and (equals howler.status \"resolved\") (equals howler.escalation \"hit\"))}}\nThis is wrong!\n{{/if}}\n\n```markdown\n{{curly '#if (or howler.is_bundle (not howler.is_bundle))'}}\nAlways shows!\n{{curly \"/if\"}}\n```\n\n{{#if (or howler.is_bundle (not howler.is_bundle))}}\nAlways shows!\n{{/if}}\n\n---\n\n### String Operations\n\n**String Concatenation:**\n\n```markdown\n{{curly 'join \"string one \" \"string two\"'}}\n```\n\n{{join \"string one \" \"string two\"}}\n\n**Uppercase/Lowercase:**\n\n```markdown\n{{curly 'upper \"make this uppercase\"'}}\n{{curly 'lower \"MAKE THIS LOWERCASE\"'}}\n```\n\n{{upper \"make this uppercase\"}}\n\n{{lower \"MAKE THIS LOWERCASE\"}}\n\n---\n\n### Fetching Data\n\nYou can also make basic fetch requests for, and parse, JSON data from external sources:\n\n```markdown\n{{curly 'fetch \"/api/v1/configs\" \"api_response.c12nDef.UNRESTRICTED\"'}}\n```\n\n{{fetch \"/api/v1/configs\" \"api_response.c12nDef.UNRESTRICTED\"}}\n\n## Full Helper List\n"