@oneuptime/common 14.0.0 → 14.0.1

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 (196) hide show
  1. package/Models/DatabaseModels/Index.ts +4 -0
  2. package/Models/DatabaseModels/ServiceLevelObjectiveFeed.ts +2 -0
  3. package/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.ts +522 -0
  4. package/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.ts +38 -4
  5. package/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.ts +603 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.ts +248 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.ts +19 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  9. package/Server/Services/DatabaseService.ts +10 -0
  10. package/Server/Services/Index.ts +4 -0
  11. package/Server/Services/MonitorService.ts +3 -2
  12. package/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.ts +385 -0
  13. package/Server/Services/ServiceLevelObjectiveLabelRuleService.ts +51 -0
  14. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +92 -16
  15. package/Server/Services/ServiceLevelObjectiveMonitorRuleService.ts +36 -1
  16. package/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.ts +521 -0
  17. package/Server/Services/ServiceLevelObjectiveOwnerRuleService.ts +51 -0
  18. package/Server/Services/ServiceLevelObjectiveService.ts +28 -0
  19. package/Server/Types/Workflow/Components/Telegram/SendMessageToChat.ts +27 -1
  20. package/Server/Utils/AI/Toolbox/SloTools.ts +1 -0
  21. package/Server/Utils/Monitor/AffectedResourceList.ts +31 -73
  22. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +1 -1
  23. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +1 -1
  24. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +181 -136
  25. package/Server/Utils/Monitor/RootCauseList.ts +139 -0
  26. package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +28 -0
  27. package/Server/Utils/Slo/SloRulePatternValidator.ts +42 -0
  28. package/Tests/App/Dashboard/FeedFilterSortIntegration.test.tsx +1632 -0
  29. package/Tests/App/Dashboard/FeedOptionsLocales.test.ts +250 -0
  30. package/Tests/App/Dashboard/FeedOrdering.test.ts +279 -9
  31. package/Tests/App/Dashboard/InvestigationFeedRefresh.test.tsx +60 -1
  32. package/Tests/App/Dashboard/InvestigationPanel.test.tsx +594 -3
  33. package/Tests/App/Dashboard/ProductOrientation.test.tsx +646 -0
  34. package/Tests/App/Dashboard/ResourceFeed.test.tsx +612 -2
  35. package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +30 -0
  36. package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +1202 -0
  37. package/Tests/App/Dashboard/ScheduledMaintenanceFeedRefresh.test.tsx +52 -1
  38. package/Tests/App/Dashboard/SloFeed.test.tsx +106 -1
  39. package/Tests/App/Dashboard/SloMonitorRuleCriteria.test.tsx +566 -0
  40. package/Tests/App/Dashboard/SloNavigationWiring.test.tsx +6 -4
  41. package/Tests/App/Dashboard/SloNoticeBanner.test.tsx +186 -0
  42. package/Tests/App/Dashboard/SloOverviewKpiStrip.test.tsx +388 -44
  43. package/Tests/App/Dashboard/SloOverviewLayout.test.tsx +613 -0
  44. package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +0 -8
  45. package/Tests/Models/DatabaseModels/SloLabelAndOwnerRuleModels.test.ts +404 -0
  46. package/Tests/Models/DatabaseModels/SloOverhaulModels.test.ts +17 -1
  47. package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
  48. package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
  49. package/Tests/Server/Infrastructure/Postgres/AddSloLabelAndOwnerRulesMigration.test.ts +407 -0
  50. package/Tests/Server/Infrastructure/Postgres/AddSloMonitorRuleMonitorTypeMigration.test.ts +171 -0
  51. package/Tests/Server/Infrastructure/Postgres/SloProductOverhaulMigration.test.ts +88 -4
  52. package/Tests/Server/Services/DatabaseServiceRuleCriteriaValidation.test.ts +104 -0
  53. package/Tests/Server/Services/RuleRun/LabelRuleEnginesGroupBRun.test.ts +48 -1
  54. package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -0
  55. package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleEngines.test.ts +1170 -0
  56. package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleServices.test.ts +172 -0
  57. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +323 -0
  58. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleService.test.ts +115 -0
  59. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleTriggers.test.ts +21 -2
  60. package/Tests/Server/Services/ServiceLevelObjectiveMonitorTypePostgres.test.ts +480 -0
  61. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +162 -0
  62. package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +45 -6
  63. package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +745 -0
  64. package/Tests/Server/Types/Markdown.test.ts +2 -1
  65. package/Tests/Server/Types/Workflow/Components/ApiComponentSsrf.test.ts +49 -0
  66. package/Tests/Server/Types/Workflow/Components/TelegramSendMessageToChat.test.ts +331 -0
  67. package/Tests/Server/Utils/AI/SloTools.test.ts +10 -0
  68. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +1 -1
  69. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +405 -0
  70. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorBreachingSamples.test.ts +1101 -0
  71. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +106 -80
  72. package/Tests/Server/Utils/Monitor/RootCauseList.test.ts +252 -0
  73. package/Tests/Server/Utils/Rules/RuleRun/RuleRunPermission.test.ts +154 -0
  74. package/Tests/Server/Utils/SSRFProtection.test.ts +111 -0
  75. package/Tests/Server/Utils/Slo/SloRulePatternValidator.test.ts +67 -0
  76. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +213 -0
  77. package/Tests/Types/API/Route.test.ts +56 -0
  78. package/Tests/Types/API/URLSchemeLikePath.test.ts +304 -0
  79. package/Tests/UI/Components/FeedOptions.test.ts +1404 -0
  80. package/Tests/UI/Components/FeedOptionsButton.test.tsx +2603 -0
  81. package/Tests/UI/Components/Icon.test.tsx +26 -0
  82. package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
  83. package/Tests/UI/Components/useFeedItemsViewKey.test.tsx +1017 -0
  84. package/Tests/UI/Components/useFeedOptions.test.tsx +1107 -0
  85. package/Tests/Utils/LabelRuleImportExport.test.ts +102 -0
  86. package/Tests/Utils/Slo/SloDuration.test.ts +115 -24
  87. package/Tests/Utils/Slo/SloHealth.test.ts +93 -10
  88. package/Tests/Utils/Slo/SloMonitorRuleCriteria.test.ts +109 -0
  89. package/Tests/Utils/Slo/SloOverviewText.test.ts +64 -0
  90. package/Types/API/Route.ts +19 -2
  91. package/Types/API/URL.ts +26 -2
  92. package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +6 -5
  93. package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +9 -0
  94. package/Types/Permission.ts +90 -0
  95. package/Types/Rules/RuleCriteriaFieldRegistry.ts +11 -0
  96. package/Types/Rules/RuleRun.ts +4 -0
  97. package/UI/Components/Feed/FeedOptions.ts +427 -0
  98. package/UI/Components/Feed/FeedOptionsButton.tsx +692 -0
  99. package/UI/Components/Feed/useFeedItems.ts +28 -9
  100. package/UI/Components/Feed/useFeedOptions.ts +225 -0
  101. package/UI/Components/Icon/Icon.tsx +1 -1
  102. package/Utils/LabelRuleImportExport.ts +27 -9
  103. package/Utils/Monitor/MetricValueFormatter.ts +2 -2
  104. package/Utils/Rules/MonitorTypeRuleCriteria.ts +31 -0
  105. package/Utils/Slo/SloDuration.ts +22 -6
  106. package/Utils/Slo/SloHealth.ts +8 -7
  107. package/Utils/Slo/SloMonitorRuleCriteria.ts +33 -1
  108. package/Utils/Slo/SloOverviewText.ts +33 -0
  109. package/Utils/Slo/SloProjection.ts +4 -3
  110. package/build/dist/Models/DatabaseModels/Index.js +4 -0
  111. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  112. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js +2 -0
  113. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js.map +1 -1
  114. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js +530 -0
  115. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js.map +1 -0
  116. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js +39 -4
  117. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js +612 -0
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js.map +1 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js +93 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js.map +1 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js +12 -0
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js.map +1 -0
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  126. package/build/dist/Server/Services/DatabaseService.js +7 -0
  127. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  128. package/build/dist/Server/Services/Index.js +4 -0
  129. package/build/dist/Server/Services/Index.js.map +1 -1
  130. package/build/dist/Server/Services/MonitorService.js +3 -2
  131. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  132. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js +295 -0
  133. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js.map +1 -0
  134. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js +56 -0
  135. package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js.map +1 -0
  136. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +59 -14
  137. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -1
  138. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js +22 -1
  139. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js.map +1 -1
  140. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js +400 -0
  141. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js.map +1 -0
  142. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js +56 -0
  143. package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js.map +1 -0
  144. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +21 -0
  145. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  146. package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js +20 -1
  147. package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js.map +1 -1
  148. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +1 -0
  149. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -1
  150. package/build/dist/Server/Utils/Monitor/AffectedResourceList.js +22 -51
  151. package/build/dist/Server/Utils/Monitor/AffectedResourceList.js.map +1 -1
  152. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +1 -1
  153. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  154. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +139 -109
  155. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  156. package/build/dist/Server/Utils/Monitor/RootCauseList.js +101 -0
  157. package/build/dist/Server/Utils/Monitor/RootCauseList.js.map +1 -0
  158. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +28 -0
  159. package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
  160. package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js +28 -0
  161. package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js.map +1 -0
  162. package/build/dist/Types/API/Route.js +18 -2
  163. package/build/dist/Types/API/Route.js.map +1 -1
  164. package/build/dist/Types/API/URL.js +23 -2
  165. package/build/dist/Types/API/URL.js.map +1 -1
  166. package/build/dist/Types/Permission.js +84 -0
  167. package/build/dist/Types/Permission.js.map +1 -1
  168. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +11 -0
  169. package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
  170. package/build/dist/Types/Rules/RuleRun.js +4 -0
  171. package/build/dist/Types/Rules/RuleRun.js.map +1 -1
  172. package/build/dist/UI/Components/Feed/FeedOptions.js +235 -0
  173. package/build/dist/UI/Components/Feed/FeedOptions.js.map +1 -0
  174. package/build/dist/UI/Components/Feed/FeedOptionsButton.js +346 -0
  175. package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -0
  176. package/build/dist/UI/Components/Feed/useFeedItems.js +17 -7
  177. package/build/dist/UI/Components/Feed/useFeedItems.js.map +1 -1
  178. package/build/dist/UI/Components/Feed/useFeedOptions.js +130 -0
  179. package/build/dist/UI/Components/Feed/useFeedOptions.js.map +1 -0
  180. package/build/dist/UI/Components/Icon/Icon.js +1 -1
  181. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  182. package/build/dist/Utils/LabelRuleImportExport.js +22 -6
  183. package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
  184. package/build/dist/Utils/Monitor/MetricValueFormatter.js +2 -2
  185. package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js +18 -0
  186. package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js.map +1 -0
  187. package/build/dist/Utils/Slo/SloDuration.js +13 -5
  188. package/build/dist/Utils/Slo/SloDuration.js.map +1 -1
  189. package/build/dist/Utils/Slo/SloHealth.js +8 -7
  190. package/build/dist/Utils/Slo/SloHealth.js.map +1 -1
  191. package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js +16 -1
  192. package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js.map +1 -1
  193. package/build/dist/Utils/Slo/SloOverviewText.js +16 -0
  194. package/build/dist/Utils/Slo/SloOverviewText.js.map +1 -1
  195. package/build/dist/Utils/Slo/SloProjection.js.map +1 -1
  196. package/package.json +1 -1
@@ -0,0 +1,2603 @@
1
+ import {
2
+ afterEach,
3
+ beforeAll,
4
+ describe,
5
+ expect,
6
+ jest,
7
+ test,
8
+ } from "@jest/globals";
9
+ import "@testing-library/jest-dom";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ RenderResult,
16
+ screen,
17
+ within,
18
+ } from "@testing-library/react";
19
+ import { createInstance, i18n } from "i18next";
20
+ import type { SpyInstance } from "jest-mock";
21
+ import * as React from "react";
22
+ import { I18nextProvider } from "react-i18next";
23
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
24
+ import IconProp from "../../../Types/Icon/IconProp";
25
+ import {
26
+ DEFAULT_FEED_OPTIONS,
27
+ FeedEventTypeOption,
28
+ FeedOptions,
29
+ } from "../../../UI/Components/Feed/FeedOptions";
30
+ import FeedOptionsButton, {
31
+ FEED_EVENT_TYPE_SEARCH_THRESHOLD,
32
+ FEED_OPTIONS_PANEL_WIDTH_PX,
33
+ FEED_OPTIONS_VIEWPORT_GUTTER_PX,
34
+ FeedOptionsPanelPlacement,
35
+ getFeedOptionsPanelPlacement,
36
+ } from "../../../UI/Components/Feed/FeedOptionsButton";
37
+ import Icon from "../../../UI/Components/Icon/Icon";
38
+
39
+ /*
40
+ * The one "Filter & Sort" control in every dashboard activity feed's header.
41
+ * The component is controlled, so most behaviour is driven through a small
42
+ * harness that holds the options in state the way useFeedOptions does, while
43
+ * exact payloads are asserted against a bare onChange mock.
44
+ */
45
+
46
+ type OnChangeMock = ReturnType<typeof jest.fn<(options: FeedOptions) => void>>;
47
+
48
+ /*
49
+ * The button is named by its visible label alone; the state behind it (order
50
+ * and filter) is its description, so a screen reader says each once.
51
+ */
52
+ const TRIGGER_NAME: string = "Filter & Sort";
53
+
54
+ const PANEL_NAME: string = "Filter and sort feed";
55
+
56
+ const UNFILTERED_SUMMARY: string =
57
+ "Showing every event type. Tick one or more to narrow the feed.";
58
+
59
+ const CUSTOMIZED_TRIGGER_CLASSES: Array<string> = [
60
+ "border-indigo-300",
61
+ "bg-indigo-50",
62
+ "text-indigo-700",
63
+ ];
64
+
65
+ const DEFAULT_TRIGGER_CLASSES: Array<string> = [
66
+ "border-gray-300",
67
+ "bg-white",
68
+ "text-gray-700",
69
+ ];
70
+
71
+ // Three entries, in checklist order; the last one has no icon.
72
+ const EVENT_TYPE_OPTIONS: Array<FeedEventTypeOption> = [
73
+ {
74
+ value: "IncidentCreated",
75
+ label: "Incident Created",
76
+ icon: IconProp.Alert,
77
+ },
78
+ {
79
+ value: "IncidentStateChanged",
80
+ label: "Incident State Changed",
81
+ icon: IconProp.Edit,
82
+ },
83
+ {
84
+ value: "OwnerUserAdded",
85
+ label: "User Added as Owner",
86
+ },
87
+ ];
88
+
89
+ // Long enough to earn the search box.
90
+ const LONG_EVENT_TYPE_OPTIONS: Array<FeedEventTypeOption> = [
91
+ { value: "AlertAcknowledged", label: "Alert Acknowledged" },
92
+ { value: "AlertCreated", label: "Alert Created" },
93
+ { value: "AlertResolved", label: "Alert Resolved" },
94
+ { value: "IncidentCreated", label: "Incident Created" },
95
+ { value: "IncidentResolved", label: "Incident Resolved" },
96
+ { value: "MonitorStatusChanged", label: "Monitor Status Changed" },
97
+ { value: "OwnerNotificationSent", label: "Owner Notification Sent" },
98
+ {
99
+ value: "SubscriberNotificationSent",
100
+ label: "Subscriber Notification Sent",
101
+ },
102
+ { value: "OwnerTeamAdded", label: "Team Added as Owner" },
103
+ { value: "OwnerUserAdded", label: "User Added as Owner" },
104
+ ];
105
+
106
+ interface HarnessProps {
107
+ eventTypeOptions: Array<FeedEventTypeOption>;
108
+ initialValue?: FeedOptions | undefined;
109
+ onChange?: ((options: FeedOptions) => void) | undefined;
110
+ }
111
+
112
+ const Harness: React.FunctionComponent<HarnessProps> = (
113
+ props: HarnessProps,
114
+ ): React.ReactElement => {
115
+ const [value, setValue] = React.useState<FeedOptions>(
116
+ props.initialValue || DEFAULT_FEED_OPTIONS,
117
+ );
118
+
119
+ return (
120
+ <FeedOptionsButton
121
+ value={value}
122
+ eventTypeOptions={props.eventTypeOptions}
123
+ onChange={(nextValue: FeedOptions): void => {
124
+ props.onChange?.(nextValue);
125
+ setValue(nextValue);
126
+ }}
127
+ />
128
+ );
129
+ };
130
+
131
+ const createOnChange: () => OnChangeMock = (): OnChangeMock => {
132
+ return jest.fn<(options: FeedOptions) => void>();
133
+ };
134
+
135
+ const getTrigger: () => HTMLElement = (): HTMLElement => {
136
+ return screen.getByRole("button", { name: TRIGGER_NAME });
137
+ };
138
+
139
+ const queryPanel: () => HTMLElement | null = (): HTMLElement | null => {
140
+ return screen.queryByRole("dialog", { name: PANEL_NAME });
141
+ };
142
+
143
+ const openPanel: () => HTMLElement = (): HTMLElement => {
144
+ fireEvent.click(getTrigger());
145
+
146
+ return screen.getByRole("dialog", { name: PANEL_NAME });
147
+ };
148
+
149
+ const getRadio: (name: string) => HTMLElement = (name: string): HTMLElement => {
150
+ return screen.getByRole("radio", { name });
151
+ };
152
+
153
+ const getCheckbox: (name: string) => HTMLElement = (
154
+ name: string,
155
+ ): HTMLElement => {
156
+ return screen.getByRole("checkbox", { name });
157
+ };
158
+
159
+ const getCheckboxLabels: () => Array<string> = (): Array<string> => {
160
+ return screen.queryAllByRole("checkbox").map((checkbox: HTMLElement) => {
161
+ return checkbox.closest("label")?.textContent || "";
162
+ });
163
+ };
164
+
165
+ const getEventTypeSummary: () => HTMLElement = (): HTMLElement => {
166
+ return screen.getByTestId("feed-options-event-type-summary");
167
+ };
168
+
169
+ const getSearchInput: () => HTMLElement = (): HTMLElement => {
170
+ return screen.getByRole("textbox", { name: "Search event types" });
171
+ };
172
+
173
+ const getSearchResults: () => HTMLElement = (): HTMLElement => {
174
+ return screen.getByTestId("feed-options-search-results");
175
+ };
176
+
177
+ const getLastCall: (onChange: OnChangeMock) => FeedOptions | undefined = (
178
+ onChange: OnChangeMock,
179
+ ): FeedOptions | undefined => {
180
+ return onChange.mock.calls[onChange.mock.calls.length - 1]?.[0];
181
+ };
182
+
183
+ const getListeners: (
184
+ spy: ReturnType<typeof jest.spyOn>,
185
+ eventType: string,
186
+ ) => Array<unknown> = (
187
+ spy: ReturnType<typeof jest.spyOn>,
188
+ eventType: string,
189
+ ): Array<unknown> => {
190
+ const calls: Array<Array<unknown>> = spy.mock.calls as unknown as Array<
191
+ Array<unknown>
192
+ >;
193
+
194
+ return calls
195
+ .filter((call: Array<unknown>): boolean => {
196
+ return call[0] === eventType;
197
+ })
198
+ .map((call: Array<unknown>): unknown => {
199
+ return call[1];
200
+ });
201
+ };
202
+
203
+ // The path data of the first glyph drawn inside an element.
204
+ const getLeadingGlyphPath: (element: HTMLElement) => string = (
205
+ element: HTMLElement,
206
+ ): string => {
207
+ return element.querySelector("svg path")?.getAttribute("d") || "";
208
+ };
209
+
210
+ /*
211
+ * The path an Icon draws, rendered on its own, so the control's glyphs are
212
+ * compared with the icon set rather than with a copied string.
213
+ */
214
+ const getGlyphPath: (icon: IconProp) => string = (icon: IconProp): string => {
215
+ const { container, unmount } = render(<Icon icon={icon} />);
216
+ const path: string = getLeadingGlyphPath(container);
217
+
218
+ unmount();
219
+
220
+ return path;
221
+ };
222
+
223
+ type MakeRect = (left: number, right: number) => DOMRect;
224
+
225
+ const makeRect: MakeRect = (left: number, right: number): DOMRect => {
226
+ return {
227
+ left,
228
+ right,
229
+ x: left,
230
+ y: 0,
231
+ top: 0,
232
+ bottom: 38,
233
+ width: right - left,
234
+ height: 38,
235
+ toJSON: (): unknown => {
236
+ return {};
237
+ },
238
+ };
239
+ };
240
+
241
+ afterEach(() => {
242
+ cleanup();
243
+ });
244
+
245
+ describe("FeedOptionsButton", () => {
246
+ describe("closed state", () => {
247
+ test("renders one collapsed dialog trigger named by its label and described by the current view", () => {
248
+ render(
249
+ <FeedOptionsButton
250
+ value={DEFAULT_FEED_OPTIONS}
251
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
252
+ onChange={createOnChange()}
253
+ />,
254
+ );
255
+
256
+ expect(screen.getAllByRole("button")).toHaveLength(1);
257
+
258
+ const trigger: HTMLElement = getTrigger();
259
+ const summary: HTMLElement = screen.getByTestId("feed-options-summary");
260
+
261
+ expect(trigger).toHaveAccessibleName(TRIGGER_NAME);
262
+ expect(trigger).toHaveAccessibleDescription(
263
+ "Newest first, all event types",
264
+ );
265
+ expect(trigger).toHaveAttribute("title", "Newest first, all event types");
266
+ expect(trigger).toHaveAttribute("type", "button");
267
+ expect(trigger).toHaveAttribute("aria-haspopup", "dialog");
268
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
269
+ expect(trigger).not.toHaveAttribute("aria-label");
270
+ expect(trigger).not.toHaveAttribute("aria-controls");
271
+ expect(trigger).toHaveTextContent("Filter & Sort");
272
+ /*
273
+ * The description lives outside the button: text inside it would join
274
+ * the name, and the reader would hear the state twice.
275
+ */
276
+ expect(summary.id).not.toBe("");
277
+ expect(trigger).toHaveAttribute("aria-describedby", summary.id);
278
+ expect(trigger).not.toContainElement(summary);
279
+ expect(queryPanel()).not.toBeInTheDocument();
280
+ expect(
281
+ screen.queryByTestId("feed-options-panel"),
282
+ ).not.toBeInTheDocument();
283
+ expect(
284
+ screen.queryByTestId("feed-options-count"),
285
+ ).not.toBeInTheDocument();
286
+ });
287
+
288
+ test("keeps its label in the accessibility tree at every width", () => {
289
+ render(
290
+ <FeedOptionsButton
291
+ value={DEFAULT_FEED_OPTIONS}
292
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
293
+ onChange={createOnChange()}
294
+ />,
295
+ );
296
+
297
+ const label: HTMLElement =
298
+ within(getTrigger()).getByTestId("feed-options-label");
299
+
300
+ expect(label).toHaveTextContent(TRIGGER_NAME);
301
+ /*
302
+ * Below xl the label is hidden visually, never with display:none -
303
+ * that would drop it from the accessibility tree and leave the button
304
+ * with no name at all on phones and tablets. jsdom applies no CSS, so
305
+ * the class is the only place this can be seen.
306
+ */
307
+ expect(label).toHaveClass("sr-only");
308
+ expect(label).not.toHaveClass("hidden");
309
+ });
310
+
311
+ test.each<[string, FeedOptions, string]>([
312
+ [
313
+ "an ascending, unfiltered view",
314
+ { sortOrder: SortOrder.Ascending, eventTypes: [] },
315
+ "Oldest first, all event types",
316
+ ],
317
+ [
318
+ "a descending, filtered view",
319
+ {
320
+ sortOrder: SortOrder.Descending,
321
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
322
+ },
323
+ "Newest first, 2 of 3 event types",
324
+ ],
325
+ [
326
+ "an ascending, filtered view",
327
+ { sortOrder: SortOrder.Ascending, eventTypes: ["IncidentCreated"] },
328
+ "Oldest first, 1 of 3 event types",
329
+ ],
330
+ ])(
331
+ "summarises %s in the accessible description and tooltip, not the name",
332
+ (_label: string, value: FeedOptions, summary: string) => {
333
+ render(
334
+ <FeedOptionsButton
335
+ value={value}
336
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
337
+ onChange={createOnChange()}
338
+ />,
339
+ );
340
+
341
+ expect(getTrigger()).toHaveAccessibleName(TRIGGER_NAME);
342
+ expect(getTrigger()).toHaveAccessibleDescription(summary);
343
+ expect(getTrigger()).toHaveAttribute("title", summary);
344
+ },
345
+ );
346
+
347
+ test("shows a count of the chosen event types, hidden from the accessible name", () => {
348
+ render(
349
+ <FeedOptionsButton
350
+ value={{
351
+ sortOrder: SortOrder.Descending,
352
+ eventTypes: ["IncidentCreated", "IncidentStateChanged"],
353
+ }}
354
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
355
+ onChange={createOnChange()}
356
+ />,
357
+ );
358
+
359
+ const count: HTMLElement =
360
+ within(getTrigger()).getByTestId("feed-options-count");
361
+
362
+ expect(count).toHaveTextContent(/^2$/);
363
+ expect(count).toHaveAttribute("aria-hidden", "true");
364
+ expect(getTrigger()).toHaveAccessibleName(TRIGGER_NAME);
365
+ });
366
+
367
+ test("does not show a count when only the sort order differs from default", () => {
368
+ render(
369
+ <FeedOptionsButton
370
+ value={{ sortOrder: SortOrder.Ascending, eventTypes: [] }}
371
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
372
+ onChange={createOnChange()}
373
+ />,
374
+ );
375
+
376
+ expect(
377
+ screen.queryByTestId("feed-options-count"),
378
+ ).not.toBeInTheDocument();
379
+ });
380
+ });
381
+
382
+ describe("default versus customized styling", () => {
383
+ test("uses the neutral style for the default view", () => {
384
+ render(
385
+ <FeedOptionsButton
386
+ value={{ sortOrder: SortOrder.Descending, eventTypes: [] }}
387
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
388
+ onChange={createOnChange()}
389
+ />,
390
+ );
391
+
392
+ const trigger: HTMLElement = getTrigger();
393
+
394
+ expect(trigger).toHaveClass(...DEFAULT_TRIGGER_CLASSES);
395
+
396
+ for (const className of CUSTOMIZED_TRIGGER_CLASSES) {
397
+ expect(trigger).not.toHaveClass(className);
398
+ }
399
+ });
400
+
401
+ test.each<[string, FeedOptions]>([
402
+ [
403
+ "the oldest-first order",
404
+ { sortOrder: SortOrder.Ascending, eventTypes: [] },
405
+ ],
406
+ [
407
+ "an event type filter",
408
+ { sortOrder: SortOrder.Descending, eventTypes: ["OwnerUserAdded"] },
409
+ ],
410
+ [
411
+ "both a changed order and a filter",
412
+ {
413
+ sortOrder: SortOrder.Ascending,
414
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
415
+ },
416
+ ],
417
+ ])(
418
+ "tints the trigger indigo for %s",
419
+ (_label: string, value: FeedOptions) => {
420
+ render(
421
+ <FeedOptionsButton
422
+ value={value}
423
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
424
+ onChange={createOnChange()}
425
+ />,
426
+ );
427
+
428
+ const trigger: HTMLElement = getTrigger();
429
+
430
+ expect(trigger).toHaveClass(...CUSTOMIZED_TRIGGER_CLASSES);
431
+
432
+ for (const className of DEFAULT_TRIGGER_CLASSES) {
433
+ expect(trigger).not.toHaveClass(className);
434
+ }
435
+ },
436
+ );
437
+
438
+ test("switches between the two styles as the options change", () => {
439
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
440
+
441
+ openPanel();
442
+ expect(getTrigger()).toHaveClass(...DEFAULT_TRIGGER_CLASSES);
443
+
444
+ fireEvent.click(getRadio("Oldest first"));
445
+ expect(getTrigger()).toHaveClass(...CUSTOMIZED_TRIGGER_CLASSES);
446
+
447
+ fireEvent.click(getRadio("Newest first"));
448
+ expect(getTrigger()).toHaveClass(...DEFAULT_TRIGGER_CLASSES);
449
+
450
+ fireEvent.click(getCheckbox("Incident Created"));
451
+ expect(getTrigger()).toHaveClass(...CUSTOMIZED_TRIGGER_CLASSES);
452
+
453
+ fireEvent.click(getCheckbox("Incident Created"));
454
+ expect(getTrigger()).toHaveClass(...DEFAULT_TRIGGER_CLASSES);
455
+ });
456
+ });
457
+
458
+ /*
459
+ * A reversed feed must not rely on the indigo tint alone to say so: Oldest
460
+ * first swaps the trigger's funnel for the up-arrow sort glyph.
461
+ */
462
+ describe("trigger icon", () => {
463
+ // Guards the comparisons below: two empty paths would compare equal.
464
+ test("has two distinct, non-empty glyphs to choose between", () => {
465
+ const funnel: string = getGlyphPath(IconProp.Filter);
466
+ const upArrow: string = getGlyphPath(IconProp.BarsArrowUp);
467
+
468
+ expect(funnel).not.toBe("");
469
+ expect(upArrow).not.toBe("");
470
+ expect(upArrow).not.toBe(funnel);
471
+ });
472
+
473
+ test.each<[string, FeedOptions, IconProp, IconProp]>([
474
+ [
475
+ "the default view",
476
+ DEFAULT_FEED_OPTIONS,
477
+ IconProp.Filter,
478
+ IconProp.BarsArrowUp,
479
+ ],
480
+ [
481
+ "a newest-first, filtered view",
482
+ { sortOrder: SortOrder.Descending, eventTypes: ["IncidentCreated"] },
483
+ IconProp.Filter,
484
+ IconProp.BarsArrowUp,
485
+ ],
486
+ [
487
+ "an oldest-first view",
488
+ { sortOrder: SortOrder.Ascending, eventTypes: [] },
489
+ IconProp.BarsArrowUp,
490
+ IconProp.Filter,
491
+ ],
492
+ [
493
+ "an oldest-first, filtered view",
494
+ { sortOrder: SortOrder.Ascending, eventTypes: ["IncidentCreated"] },
495
+ IconProp.BarsArrowUp,
496
+ IconProp.Filter,
497
+ ],
498
+ ])(
499
+ "leads with the right glyph for %s",
500
+ (
501
+ _label: string,
502
+ value: FeedOptions,
503
+ expectedIcon: IconProp,
504
+ otherIcon: IconProp,
505
+ ) => {
506
+ render(
507
+ <FeedOptionsButton
508
+ value={value}
509
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
510
+ onChange={createOnChange()}
511
+ />,
512
+ );
513
+
514
+ const leadingGlyph: string = getLeadingGlyphPath(getTrigger());
515
+
516
+ expect(leadingGlyph).toBe(getGlyphPath(expectedIcon));
517
+ expect(leadingGlyph).not.toBe(getGlyphPath(otherIcon));
518
+ },
519
+ );
520
+
521
+ test("shows the Oldest first radio's own glyph while that order is chosen, and the funnel again after", () => {
522
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
523
+
524
+ openPanel();
525
+ fireEvent.click(getRadio("Oldest first"));
526
+
527
+ expect(getLeadingGlyphPath(getTrigger())).toBe(
528
+ getLeadingGlyphPath(getRadio("Oldest first")),
529
+ );
530
+
531
+ fireEvent.click(getRadio("Newest first"));
532
+
533
+ expect(getLeadingGlyphPath(getTrigger())).toBe(
534
+ getGlyphPath(IconProp.Filter),
535
+ );
536
+ });
537
+ });
538
+
539
+ describe("opening", () => {
540
+ test("opens a named dialog that the expanded trigger controls", () => {
541
+ render(
542
+ <FeedOptionsButton
543
+ value={DEFAULT_FEED_OPTIONS}
544
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
545
+ onChange={createOnChange()}
546
+ />,
547
+ );
548
+
549
+ const panel: HTMLElement = openPanel();
550
+ const trigger: HTMLElement = getTrigger();
551
+
552
+ expect(panel).toHaveAttribute("data-testid", "feed-options-panel");
553
+ expect(panel.id).not.toBe("");
554
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
555
+ expect(trigger).toHaveAttribute("aria-controls", panel.id);
556
+ expect(document.getElementById(panel.id)).toBe(panel);
557
+ /*
558
+ * Focusable, so a press on its padding keeps focus inside the control,
559
+ * but not a tab stop of its own.
560
+ */
561
+ expect(panel).toHaveAttribute("tabindex", "-1");
562
+ });
563
+
564
+ test.each<[SortOrder, string, string]>([
565
+ [SortOrder.Descending, "Newest first", "Oldest first"],
566
+ [SortOrder.Ascending, "Oldest first", "Newest first"],
567
+ ])(
568
+ "moves focus onto the checked sort order (%s)",
569
+ (sortOrder: SortOrder, checkedName: string, otherName: string) => {
570
+ render(
571
+ <FeedOptionsButton
572
+ value={{ sortOrder, eventTypes: [] }}
573
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
574
+ onChange={createOnChange()}
575
+ />,
576
+ );
577
+
578
+ openPanel();
579
+
580
+ expect(getRadio(checkedName)).toHaveFocus();
581
+ expect(getRadio(checkedName)).toHaveAttribute("aria-checked", "true");
582
+ expect(getRadio(otherName)).not.toHaveFocus();
583
+ },
584
+ );
585
+
586
+ test("focuses the order chosen during the previous visit when re-opened", () => {
587
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
588
+
589
+ openPanel();
590
+ fireEvent.click(getRadio("Oldest first"));
591
+ fireEvent.keyDown(getRadio("Oldest first"), { key: "Escape" });
592
+
593
+ expect(queryPanel()).not.toBeInTheDocument();
594
+
595
+ openPanel();
596
+
597
+ expect(getRadio("Oldest first")).toHaveFocus();
598
+ });
599
+
600
+ test("does not pull focus back to the sort order when it changes while open", () => {
601
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
602
+
603
+ openPanel();
604
+
605
+ const checkbox: HTMLElement = getCheckbox("Incident Created");
606
+
607
+ act(() => {
608
+ checkbox.focus();
609
+ });
610
+
611
+ fireEvent.click(getRadio("Oldest first"));
612
+
613
+ expect(getRadio("Oldest first")).toHaveAttribute("aria-checked", "true");
614
+ expect(checkbox).toHaveFocus();
615
+ });
616
+ });
617
+
618
+ describe("panel placement", () => {
619
+ test("uses a 288px panel kept 16px from the viewport edges", () => {
620
+ expect(FEED_OPTIONS_PANEL_WIDTH_PX).toBe(288);
621
+ expect(FEED_OPTIONS_VIEWPORT_GUTTER_PX).toBe(16);
622
+ });
623
+
624
+ test("lines the panel's right edge up with the trigger's when there is room on the left", () => {
625
+ const placement: FeedOptionsPanelPlacement = getFeedOptionsPanelPlacement(
626
+ {
627
+ triggerLeft: 900,
628
+ triggerRight: 1000,
629
+ viewportWidth: 1280,
630
+ },
631
+ );
632
+
633
+ // left = triggerRight - width - triggerLeft = 1000 - 288 - 900.
634
+ expect(placement).toEqual({ left: -188, width: 288 });
635
+ expect(900 + placement.left + placement.width).toBe(1000);
636
+ });
637
+
638
+ test("slides a panel that would cross the left edge of a phone back to the gutter", () => {
639
+ /*
640
+ * A stacked card header puts the trigger at the left of a 390px
641
+ * screen: right-aligned, the panel would start at 150 - 288 = -138.
642
+ */
643
+ const placement: FeedOptionsPanelPlacement = getFeedOptionsPanelPlacement(
644
+ {
645
+ triggerLeft: 24,
646
+ triggerRight: 150,
647
+ viewportWidth: 390,
648
+ },
649
+ );
650
+
651
+ expect(placement).toEqual({ left: -8, width: 288 });
652
+ expect(24 + placement.left).toBe(16);
653
+ });
654
+
655
+ test("slides a panel that would cross the right edge back to the gutter", () => {
656
+ // The trigger's right edge sits inside the 16px gutter.
657
+ const placement: FeedOptionsPanelPlacement = getFeedOptionsPanelPlacement(
658
+ {
659
+ triggerLeft: 300,
660
+ triggerRight: 385,
661
+ viewportWidth: 390,
662
+ },
663
+ );
664
+
665
+ // Panel left = 390 - 16 - 288 = 86, i.e. 86 - 300 from the trigger.
666
+ expect(placement).toEqual({ left: -214, width: 288 });
667
+ expect(300 + placement.left + placement.width).toBe(390 - 16);
668
+ });
669
+
670
+ test("narrows the panel to fit a viewport under 288px plus both gutters", () => {
671
+ const placement: FeedOptionsPanelPlacement = getFeedOptionsPanelPlacement(
672
+ {
673
+ triggerLeft: 20,
674
+ triggerRight: 100,
675
+ viewportWidth: 300,
676
+ },
677
+ );
678
+
679
+ expect(placement).toEqual({ left: -4, width: 300 - 32 });
680
+ expect(20 + placement.left).toBe(16);
681
+ expect(20 + placement.left + placement.width).toBe(300 - 16);
682
+
683
+ // At exactly 288 + 32 the full width still fits.
684
+ expect(
685
+ getFeedOptionsPanelPlacement({
686
+ triggerLeft: 20,
687
+ triggerRight: 100,
688
+ viewportWidth: 320,
689
+ }).width,
690
+ ).toBe(288);
691
+ });
692
+
693
+ test.each<[number]>([[0], [20], [32]])(
694
+ "never gives a negative width in a degenerate %ipx viewport",
695
+ (viewportWidth: number) => {
696
+ const placement: FeedOptionsPanelPlacement =
697
+ getFeedOptionsPanelPlacement({
698
+ triggerLeft: 0,
699
+ triggerRight: 0,
700
+ viewportWidth,
701
+ });
702
+
703
+ expect(placement.width).toBe(0);
704
+ expect(Number.isFinite(placement.left)).toBe(true);
705
+ },
706
+ );
707
+
708
+ test("keeps the panel inside the gutters wherever the trigger sits, and right-aligned whenever that fits", () => {
709
+ const triggerWidth: number = 40;
710
+
711
+ for (const viewportWidth of [320, 390, 768, 1024, 1440]) {
712
+ for (
713
+ let triggerLeft: number = 0;
714
+ triggerLeft + triggerWidth <= viewportWidth;
715
+ triggerLeft += 37
716
+ ) {
717
+ const triggerRight: number = triggerLeft + triggerWidth;
718
+ const placement: FeedOptionsPanelPlacement =
719
+ getFeedOptionsPanelPlacement({
720
+ triggerLeft,
721
+ triggerRight,
722
+ viewportWidth,
723
+ });
724
+ const panelLeft: number = triggerLeft + placement.left;
725
+ const panelRight: number = panelLeft + placement.width;
726
+
727
+ expect(placement.width).toBe(288);
728
+ expect(panelLeft).toBeGreaterThanOrEqual(16);
729
+ expect(panelRight).toBeLessThanOrEqual(viewportWidth - 16);
730
+
731
+ if (triggerRight - 288 >= 16 && triggerRight <= viewportWidth - 16) {
732
+ expect(panelRight).toBe(triggerRight);
733
+ }
734
+ }
735
+ }
736
+ });
737
+
738
+ test("places the open panel from the trigger's box and the viewport, re-places it on resize and again on re-open", () => {
739
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
740
+
741
+ // The trigger's wrapper is what the panel is positioned against.
742
+ const container: HTMLElement = getTrigger().parentElement!;
743
+ let triggerBox: { left: number; right: number } = {
744
+ left: 24,
745
+ right: 150,
746
+ };
747
+ let viewportWidth: number = 390;
748
+
749
+ const rectSpy: SpyInstance<() => DOMRect> = jest
750
+ .spyOn(container, "getBoundingClientRect")
751
+ .mockImplementation((): DOMRect => {
752
+ return makeRect(triggerBox.left, triggerBox.right);
753
+ });
754
+ const widthSpy: SpyInstance<() => number> = jest
755
+ .spyOn(document.documentElement, "clientWidth", "get")
756
+ .mockImplementation((): number => {
757
+ return viewportWidth;
758
+ });
759
+
760
+ try {
761
+ const panel: HTMLElement = openPanel();
762
+
763
+ expect(rectSpy).toHaveBeenCalled();
764
+ // Phone: slid right so its left edge is 16px from the screen's.
765
+ expect(panel).toHaveStyle({ left: "-8px", width: "288px" });
766
+ // The fallback classes would fight the measured position.
767
+ expect(panel).not.toHaveClass("right-0");
768
+ expect(panel).not.toHaveClass("w-72");
769
+
770
+ // Rotated to a wide screen with the trigger now on the right.
771
+ viewportWidth = 1280;
772
+ triggerBox = { left: 900, right: 1000 };
773
+ fireEvent(window, new Event("resize"));
774
+
775
+ expect(queryPanel()).toBe(panel);
776
+ expect(panel).toHaveStyle({ left: "-188px", width: "288px" });
777
+
778
+ fireEvent.keyDown(getRadio("Newest first"), { key: "Escape" });
779
+ expect(queryPanel()).not.toBeInTheDocument();
780
+
781
+ // Re-opened on a screen narrower than the panel plus both gutters.
782
+ viewportWidth = 300;
783
+ triggerBox = { left: 20, right: 100 };
784
+
785
+ const reopened: HTMLElement = openPanel();
786
+
787
+ expect(reopened).toHaveStyle({ left: "-4px", width: "268px" });
788
+ } finally {
789
+ rectSpy.mockRestore();
790
+ widthSpy.mockRestore();
791
+ }
792
+
793
+ // Restored, so no later test measures against this fake layout.
794
+ expect(jest.isMockFunction(container.getBoundingClientRect)).toBe(false);
795
+ expect(
796
+ jest.isMockFunction(
797
+ Object.getOwnPropertyDescriptor(
798
+ document.documentElement,
799
+ "clientWidth",
800
+ )?.get,
801
+ ),
802
+ ).toBe(false);
803
+ });
804
+
805
+ test("falls back to window.innerWidth when the document reports no width", () => {
806
+ const originalInnerWidth: number = window.innerWidth;
807
+ const innerWidthDescriptor: PropertyDescriptor | undefined =
808
+ Object.getOwnPropertyDescriptor(window, "innerWidth");
809
+
810
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
811
+
812
+ const container: HTMLElement = getTrigger().parentElement!;
813
+ const rectSpy: SpyInstance<() => DOMRect> = jest
814
+ .spyOn(container, "getBoundingClientRect")
815
+ .mockImplementation((): DOMRect => {
816
+ return makeRect(24, 150);
817
+ });
818
+
819
+ Object.defineProperty(window, "innerWidth", {
820
+ configurable: true,
821
+ writable: true,
822
+ value: 390,
823
+ });
824
+
825
+ try {
826
+ // jsdom lays nothing out, so documentElement.clientWidth is 0.
827
+ expect(document.documentElement.clientWidth).toBe(0);
828
+
829
+ const panel: HTMLElement = openPanel();
830
+
831
+ expect(panel).toHaveStyle({ left: "-8px", width: "288px" });
832
+ } finally {
833
+ rectSpy.mockRestore();
834
+
835
+ if (innerWidthDescriptor) {
836
+ Object.defineProperty(window, "innerWidth", innerWidthDescriptor);
837
+ } else {
838
+ Reflect.deleteProperty(window, "innerWidth");
839
+ }
840
+ }
841
+
842
+ expect(window.innerWidth).toBe(originalInnerWidth);
843
+ });
844
+
845
+ test("stops listening for resize once the panel closes", () => {
846
+ const addSpy: ReturnType<typeof jest.spyOn> = jest.spyOn(
847
+ window,
848
+ "addEventListener",
849
+ );
850
+ const removeSpy: ReturnType<typeof jest.spyOn> = jest.spyOn(
851
+ window,
852
+ "removeEventListener",
853
+ );
854
+
855
+ try {
856
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
857
+
858
+ openPanel();
859
+
860
+ const resizeListeners: Array<unknown> = getListeners(addSpy, "resize");
861
+
862
+ expect(resizeListeners.length).toBeGreaterThan(0);
863
+
864
+ fireEvent.keyDown(getRadio("Newest first"), { key: "Escape" });
865
+
866
+ const removedListeners: Array<unknown> = getListeners(
867
+ removeSpy,
868
+ "resize",
869
+ );
870
+
871
+ for (const listener of resizeListeners) {
872
+ expect(removedListeners).toContain(listener);
873
+ }
874
+ } finally {
875
+ addSpy.mockRestore();
876
+ removeSpy.mockRestore();
877
+ }
878
+ });
879
+ });
880
+
881
+ describe("sort order", () => {
882
+ test("renders two radios in a group labelled by its heading, with roving tab stops", () => {
883
+ render(
884
+ <FeedOptionsButton
885
+ value={DEFAULT_FEED_OPTIONS}
886
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
887
+ onChange={createOnChange()}
888
+ />,
889
+ );
890
+
891
+ openPanel();
892
+
893
+ const group: HTMLElement = screen.getByRole("radiogroup", {
894
+ name: "Sort by time",
895
+ });
896
+ const radios: Array<HTMLElement> = within(group).getAllByRole("radio");
897
+
898
+ expect(radios).toHaveLength(2);
899
+ expect(radios[0]).toHaveAccessibleName("Newest first");
900
+ expect(radios[1]).toHaveAccessibleName("Oldest first");
901
+ expect(radios[0]).toHaveAttribute("aria-checked", "true");
902
+ expect(radios[1]).toHaveAttribute("aria-checked", "false");
903
+ expect(radios[0]).toHaveAttribute("tabindex", "0");
904
+ expect(radios[1]).toHaveAttribute("tabindex", "-1");
905
+ expect(
906
+ radios.filter((radio: HTMLElement) => {
907
+ return radio.getAttribute("aria-checked") === "true";
908
+ }),
909
+ ).toHaveLength(1);
910
+ });
911
+
912
+ /*
913
+ * BarsArrowUp used to draw the same path as BarsArrowDown, so the two
914
+ * orders looked identical.
915
+ */
916
+ test("draws a different glyph for each order", () => {
917
+ render(
918
+ <FeedOptionsButton
919
+ value={DEFAULT_FEED_OPTIONS}
920
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
921
+ onChange={createOnChange()}
922
+ />,
923
+ );
924
+
925
+ openPanel();
926
+
927
+ const newestGlyph: string = getLeadingGlyphPath(getRadio("Newest first"));
928
+ const oldestGlyph: string = getLeadingGlyphPath(getRadio("Oldest first"));
929
+
930
+ expect(newestGlyph).not.toBe("");
931
+ expect(oldestGlyph).not.toBe("");
932
+ expect(oldestGlyph).not.toBe(newestGlyph);
933
+ expect(oldestGlyph).toBe(getGlyphPath(IconProp.BarsArrowUp));
934
+ });
935
+
936
+ test("moves the checked state, the tab stop and the emphasis when the order changes", () => {
937
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
938
+
939
+ openPanel();
940
+ fireEvent.click(getRadio("Oldest first"));
941
+
942
+ expect(getRadio("Oldest first")).toHaveAttribute("aria-checked", "true");
943
+ expect(getRadio("Oldest first")).toHaveAttribute("tabindex", "0");
944
+ expect(getRadio("Newest first")).toHaveAttribute("aria-checked", "false");
945
+ expect(getRadio("Newest first")).toHaveAttribute("tabindex", "-1");
946
+ // The chosen order is marked by weight too, not by a pale fill alone.
947
+ expect(getRadio("Oldest first")).toHaveClass("font-semibold");
948
+ expect(getRadio("Newest first")).not.toHaveClass("font-semibold");
949
+ expect(queryPanel()).toBeInTheDocument();
950
+ });
951
+
952
+ test("reports the new order and keeps the chosen event types", () => {
953
+ const onChange: OnChangeMock = createOnChange();
954
+ const value: FeedOptions = {
955
+ sortOrder: SortOrder.Descending,
956
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
957
+ };
958
+
959
+ render(
960
+ <FeedOptionsButton
961
+ value={value}
962
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
963
+ onChange={onChange}
964
+ />,
965
+ );
966
+
967
+ openPanel();
968
+ fireEvent.click(getRadio("Oldest first"));
969
+
970
+ expect(onChange).toHaveBeenCalledTimes(1);
971
+ expect(onChange).toHaveBeenCalledWith({
972
+ sortOrder: SortOrder.Ascending,
973
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
974
+ });
975
+ // The value it was given is left alone.
976
+ expect(value).toEqual({
977
+ sortOrder: SortOrder.Descending,
978
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
979
+ });
980
+ });
981
+
982
+ test("reports newest-first from an ascending view", () => {
983
+ const onChange: OnChangeMock = createOnChange();
984
+
985
+ render(
986
+ <FeedOptionsButton
987
+ value={{
988
+ sortOrder: SortOrder.Ascending,
989
+ eventTypes: ["OwnerUserAdded"],
990
+ }}
991
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
992
+ onChange={onChange}
993
+ />,
994
+ );
995
+
996
+ openPanel();
997
+ fireEvent.click(getRadio("Newest first"));
998
+
999
+ expect(onChange).toHaveBeenCalledTimes(1);
1000
+ expect(onChange).toHaveBeenCalledWith({
1001
+ sortOrder: SortOrder.Descending,
1002
+ eventTypes: ["OwnerUserAdded"],
1003
+ });
1004
+ });
1005
+
1006
+ test("does not report a change when the checked order is clicked again", () => {
1007
+ const onChange: OnChangeMock = createOnChange();
1008
+
1009
+ render(
1010
+ <FeedOptionsButton
1011
+ value={DEFAULT_FEED_OPTIONS}
1012
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1013
+ onChange={onChange}
1014
+ />,
1015
+ );
1016
+
1017
+ openPanel();
1018
+ fireEvent.click(getRadio("Newest first"));
1019
+
1020
+ expect(onChange).not.toHaveBeenCalled();
1021
+ expect(queryPanel()).toBeInTheDocument();
1022
+ });
1023
+
1024
+ test("reports the next order from an arrow key with the exact payload", () => {
1025
+ const onChange: OnChangeMock = createOnChange();
1026
+
1027
+ render(
1028
+ <FeedOptionsButton
1029
+ value={{
1030
+ sortOrder: SortOrder.Descending,
1031
+ eventTypes: ["IncidentStateChanged"],
1032
+ }}
1033
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1034
+ onChange={onChange}
1035
+ />,
1036
+ );
1037
+
1038
+ openPanel();
1039
+
1040
+ // false: the arrow key's default (scrolling the page) is prevented.
1041
+ expect(
1042
+ fireEvent.keyDown(getRadio("Newest first"), { key: "ArrowDown" }),
1043
+ ).toBe(false);
1044
+ expect(onChange).toHaveBeenCalledTimes(1);
1045
+ expect(onChange).toHaveBeenCalledWith({
1046
+ sortOrder: SortOrder.Ascending,
1047
+ eventTypes: ["IncidentStateChanged"],
1048
+ });
1049
+ expect(getRadio("Oldest first")).toHaveFocus();
1050
+ });
1051
+
1052
+ test("moves selection and focus with every arrow key, wrapping at both ends", () => {
1053
+ const onChange: OnChangeMock = createOnChange();
1054
+
1055
+ render(
1056
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} onChange={onChange} />,
1057
+ );
1058
+
1059
+ openPanel();
1060
+ expect(getRadio("Newest first")).toHaveFocus();
1061
+
1062
+ const steps: Array<[string, string, string, SortOrder]> = [
1063
+ ["ArrowRight", "Newest first", "Oldest first", SortOrder.Ascending],
1064
+ ["ArrowRight", "Oldest first", "Newest first", SortOrder.Descending],
1065
+ ["ArrowLeft", "Newest first", "Oldest first", SortOrder.Ascending],
1066
+ ["ArrowUp", "Oldest first", "Newest first", SortOrder.Descending],
1067
+ ["ArrowDown", "Newest first", "Oldest first", SortOrder.Ascending],
1068
+ ["ArrowLeft", "Oldest first", "Newest first", SortOrder.Descending],
1069
+ ["ArrowUp", "Newest first", "Oldest first", SortOrder.Ascending],
1070
+ ["ArrowDown", "Oldest first", "Newest first", SortOrder.Descending],
1071
+ ];
1072
+
1073
+ for (const [key, fromName, toName, sortOrder] of steps) {
1074
+ fireEvent.keyDown(getRadio(fromName), { key });
1075
+
1076
+ expect(getRadio(toName)).toHaveFocus();
1077
+ expect(getRadio(toName)).toHaveAttribute("aria-checked", "true");
1078
+ expect(getRadio(toName)).toHaveAttribute("tabindex", "0");
1079
+ expect(getRadio(fromName)).toHaveAttribute("aria-checked", "false");
1080
+ expect(getRadio(fromName)).toHaveAttribute("tabindex", "-1");
1081
+ expect(getLastCall(onChange)).toEqual({ sortOrder, eventTypes: [] });
1082
+ }
1083
+
1084
+ expect(onChange).toHaveBeenCalledTimes(steps.length);
1085
+ expect(queryPanel()).toBeInTheDocument();
1086
+ });
1087
+
1088
+ test.each<[string]>([["Home"], ["End"], ["Tab"], ["Enter"], ["a"]])(
1089
+ "ignores the %s key on a sort radio",
1090
+ (key: string) => {
1091
+ const onChange: OnChangeMock = createOnChange();
1092
+
1093
+ render(
1094
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} onChange={onChange} />,
1095
+ );
1096
+
1097
+ openPanel();
1098
+
1099
+ // true: the key's default action is left alone.
1100
+ expect(fireEvent.keyDown(getRadio("Newest first"), { key })).toBe(true);
1101
+ expect(onChange).not.toHaveBeenCalled();
1102
+ expect(getRadio("Newest first")).toHaveFocus();
1103
+ expect(getRadio("Newest first")).toHaveAttribute(
1104
+ "aria-checked",
1105
+ "true",
1106
+ );
1107
+ expect(queryPanel()).toBeInTheDocument();
1108
+ },
1109
+ );
1110
+ });
1111
+
1112
+ describe("event types", () => {
1113
+ test("renders one labelled checkbox per option, in checklist order, inside a group named by its heading", () => {
1114
+ render(
1115
+ <FeedOptionsButton
1116
+ value={DEFAULT_FEED_OPTIONS}
1117
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1118
+ onChange={createOnChange()}
1119
+ />,
1120
+ );
1121
+
1122
+ openPanel();
1123
+
1124
+ /*
1125
+ * The group, not the list, carries the heading: a screen reader
1126
+ * announces a group's name as focus enters it from any checkbox.
1127
+ */
1128
+ const group: HTMLElement = screen.getByRole("group", {
1129
+ name: "Event types",
1130
+ });
1131
+ const list: HTMLElement = within(group).getByRole("list");
1132
+
1133
+ expect(within(list).getAllByRole("checkbox")).toHaveLength(3);
1134
+ expect(getCheckboxLabels()).toEqual([
1135
+ "Incident Created",
1136
+ "Incident State Changed",
1137
+ "User Added as Owner",
1138
+ ]);
1139
+
1140
+ for (const option of EVENT_TYPE_OPTIONS) {
1141
+ expect(getCheckbox(option.label)).not.toBeChecked();
1142
+ }
1143
+
1144
+ expect(getEventTypeSummary()).toHaveTextContent(UNFILTERED_SUMMARY);
1145
+ // Ticking a box re-words this line; it is read out when it does.
1146
+ expect(getEventTypeSummary()).toHaveAttribute("aria-live", "polite");
1147
+ expect(
1148
+ screen.queryByRole("button", { name: "Show all" }),
1149
+ ).not.toBeInTheDocument();
1150
+ });
1151
+
1152
+ test("checks exactly the event types in the value", () => {
1153
+ render(
1154
+ <FeedOptionsButton
1155
+ value={{
1156
+ sortOrder: SortOrder.Descending,
1157
+ eventTypes: ["IncidentStateChanged"],
1158
+ }}
1159
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1160
+ onChange={createOnChange()}
1161
+ />,
1162
+ );
1163
+
1164
+ openPanel();
1165
+
1166
+ expect(getCheckbox("Incident Created")).not.toBeChecked();
1167
+ expect(getCheckbox("Incident State Changed")).toBeChecked();
1168
+ expect(getCheckbox("User Added as Owner")).not.toBeChecked();
1169
+ expect(getEventTypeSummary()).toHaveTextContent(
1170
+ "Showing 1 of 3 event types.",
1171
+ );
1172
+ });
1173
+
1174
+ test("adds a ticked event type and keeps the sort order", () => {
1175
+ const onChange: OnChangeMock = createOnChange();
1176
+
1177
+ render(
1178
+ <FeedOptionsButton
1179
+ value={{ sortOrder: SortOrder.Ascending, eventTypes: [] }}
1180
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1181
+ onChange={onChange}
1182
+ />,
1183
+ );
1184
+
1185
+ openPanel();
1186
+ fireEvent.click(getCheckbox("User Added as Owner"));
1187
+
1188
+ expect(onChange).toHaveBeenCalledTimes(1);
1189
+ expect(onChange).toHaveBeenCalledWith({
1190
+ sortOrder: SortOrder.Ascending,
1191
+ eventTypes: ["OwnerUserAdded"],
1192
+ });
1193
+ });
1194
+
1195
+ test("puts a newly ticked event type in checklist order, not click order", () => {
1196
+ const onChange: OnChangeMock = createOnChange();
1197
+
1198
+ render(
1199
+ <FeedOptionsButton
1200
+ value={{
1201
+ sortOrder: SortOrder.Descending,
1202
+ eventTypes: ["OwnerUserAdded"],
1203
+ }}
1204
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1205
+ onChange={onChange}
1206
+ />,
1207
+ );
1208
+
1209
+ openPanel();
1210
+ fireEvent.click(getCheckbox("Incident Created"));
1211
+
1212
+ expect(onChange).toHaveBeenCalledWith({
1213
+ sortOrder: SortOrder.Descending,
1214
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
1215
+ });
1216
+ });
1217
+
1218
+ test("removes an unticked event type and keeps the sort order", () => {
1219
+ const onChange: OnChangeMock = createOnChange();
1220
+
1221
+ render(
1222
+ <FeedOptionsButton
1223
+ value={{
1224
+ sortOrder: SortOrder.Ascending,
1225
+ eventTypes: ["IncidentCreated", "IncidentStateChanged"],
1226
+ }}
1227
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1228
+ onChange={onChange}
1229
+ />,
1230
+ );
1231
+
1232
+ openPanel();
1233
+ fireEvent.click(getCheckbox("Incident Created"));
1234
+
1235
+ expect(onChange).toHaveBeenCalledTimes(1);
1236
+ expect(onChange).toHaveBeenCalledWith({
1237
+ sortOrder: SortOrder.Ascending,
1238
+ eventTypes: ["IncidentStateChanged"],
1239
+ });
1240
+ });
1241
+
1242
+ /*
1243
+ * The value can name an event type the checklist no longer offers (a
1244
+ * filter chosen before the feed's event types changed). A toggle reports
1245
+ * only what the checklist has, so the stale type never reaches the API.
1246
+ */
1247
+ test.each<[string, string, Array<string>]>([
1248
+ ["ticking", "User Added as Owner", ["IncidentCreated", "OwnerUserAdded"]],
1249
+ ["unticking", "Incident Created", []],
1250
+ ])(
1251
+ "drops an event type missing from the checklist when %s another",
1252
+ (
1253
+ _label: string,
1254
+ checkboxName: string,
1255
+ expectedEventTypes: Array<string>,
1256
+ ) => {
1257
+ const onChange: OnChangeMock = createOnChange();
1258
+
1259
+ render(
1260
+ <FeedOptionsButton
1261
+ value={{
1262
+ sortOrder: SortOrder.Descending,
1263
+ eventTypes: ["Gone", "IncidentCreated"],
1264
+ }}
1265
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1266
+ onChange={onChange}
1267
+ />,
1268
+ );
1269
+
1270
+ openPanel();
1271
+ fireEvent.click(getCheckbox(checkboxName));
1272
+
1273
+ expect(onChange).toHaveBeenCalledTimes(1);
1274
+ expect(onChange).toHaveBeenCalledWith({
1275
+ sortOrder: SortOrder.Descending,
1276
+ eventTypes: expectedEventTypes,
1277
+ });
1278
+ },
1279
+ );
1280
+
1281
+ test("ticks, counts and unticks through the controlled round trip", () => {
1282
+ const onChange: OnChangeMock = createOnChange();
1283
+
1284
+ render(
1285
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} onChange={onChange} />,
1286
+ );
1287
+
1288
+ openPanel();
1289
+
1290
+ // Ticked last-first; every payload still follows the checklist.
1291
+ fireEvent.click(getCheckbox("User Added as Owner"));
1292
+ expect(getLastCall(onChange)).toEqual({
1293
+ sortOrder: SortOrder.Descending,
1294
+ eventTypes: ["OwnerUserAdded"],
1295
+ });
1296
+ expect(screen.getByTestId("feed-options-count")).toHaveTextContent(/^1$/);
1297
+ expect(getEventTypeSummary()).toHaveTextContent(
1298
+ "Showing 1 of 3 event types.",
1299
+ );
1300
+
1301
+ fireEvent.click(getCheckbox("Incident Created"));
1302
+ expect(getLastCall(onChange)).toEqual({
1303
+ sortOrder: SortOrder.Descending,
1304
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
1305
+ });
1306
+ expect(screen.getByTestId("feed-options-count")).toHaveTextContent(/^2$/);
1307
+ expect(getEventTypeSummary()).toHaveTextContent(
1308
+ "Showing 2 of 3 event types.",
1309
+ );
1310
+ expect(getTrigger()).toHaveAccessibleName(TRIGGER_NAME);
1311
+ expect(getTrigger()).toHaveAccessibleDescription(
1312
+ "Newest first, 2 of 3 event types",
1313
+ );
1314
+
1315
+ fireEvent.click(getCheckbox("Incident State Changed"));
1316
+ expect(getLastCall(onChange)).toEqual({
1317
+ sortOrder: SortOrder.Descending,
1318
+ eventTypes: [
1319
+ "IncidentCreated",
1320
+ "IncidentStateChanged",
1321
+ "OwnerUserAdded",
1322
+ ],
1323
+ });
1324
+ expect(screen.getByTestId("feed-options-count")).toHaveTextContent(/^3$/);
1325
+
1326
+ fireEvent.click(getCheckbox("Incident Created"));
1327
+ expect(getLastCall(onChange)).toEqual({
1328
+ sortOrder: SortOrder.Descending,
1329
+ eventTypes: ["IncidentStateChanged", "OwnerUserAdded"],
1330
+ });
1331
+ expect(getCheckbox("Incident Created")).not.toBeChecked();
1332
+ expect(getCheckbox("Incident State Changed")).toBeChecked();
1333
+ expect(getCheckbox("User Added as Owner")).toBeChecked();
1334
+
1335
+ fireEvent.click(getCheckbox("Incident State Changed"));
1336
+ fireEvent.click(getCheckbox("User Added as Owner"));
1337
+ expect(getLastCall(onChange)).toEqual({
1338
+ sortOrder: SortOrder.Descending,
1339
+ eventTypes: [],
1340
+ });
1341
+ expect(
1342
+ screen.queryByTestId("feed-options-count"),
1343
+ ).not.toBeInTheDocument();
1344
+ expect(getEventTypeSummary()).toHaveTextContent(UNFILTERED_SUMMARY);
1345
+ expect(getTrigger()).toHaveAccessibleDescription(
1346
+ "Newest first, all event types",
1347
+ );
1348
+ expect(onChange).toHaveBeenCalledTimes(6);
1349
+ });
1350
+
1351
+ test("keeps the panel open while several boxes are ticked in one visit", () => {
1352
+ render(<Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />);
1353
+
1354
+ openPanel();
1355
+
1356
+ for (const option of EVENT_TYPE_OPTIONS) {
1357
+ fireEvent.click(getCheckbox(option.label));
1358
+
1359
+ expect(queryPanel()).toBeInTheDocument();
1360
+ expect(getCheckbox(option.label)).toBeChecked();
1361
+ }
1362
+
1363
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "true");
1364
+ expect(screen.getByTestId("feed-options-count")).toHaveTextContent(/^3$/);
1365
+ expect(getEventTypeSummary()).toHaveTextContent(
1366
+ "Showing 3 of 3 event types.",
1367
+ );
1368
+ });
1369
+
1370
+ test("offers Show all only while something is ticked, and it clears the filter but not the order", () => {
1371
+ const onChange: OnChangeMock = createOnChange();
1372
+
1373
+ const { rerender } = render(
1374
+ <FeedOptionsButton
1375
+ value={{
1376
+ sortOrder: SortOrder.Ascending,
1377
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
1378
+ }}
1379
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1380
+ onChange={onChange}
1381
+ />,
1382
+ );
1383
+
1384
+ openPanel();
1385
+ fireEvent.click(screen.getByRole("button", { name: "Show all" }));
1386
+
1387
+ expect(onChange).toHaveBeenCalledTimes(1);
1388
+ expect(onChange).toHaveBeenCalledWith({
1389
+ sortOrder: SortOrder.Ascending,
1390
+ eventTypes: [],
1391
+ });
1392
+
1393
+ // The same open panel with nothing ticked: no Show all to offer.
1394
+ rerender(
1395
+ <FeedOptionsButton
1396
+ value={{ sortOrder: SortOrder.Ascending, eventTypes: [] }}
1397
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1398
+ onChange={onChange}
1399
+ />,
1400
+ );
1401
+
1402
+ expect(queryPanel()).toBeInTheDocument();
1403
+ expect(
1404
+ screen.queryByRole("button", { name: "Show all" }),
1405
+ ).not.toBeInTheDocument();
1406
+ });
1407
+
1408
+ test("Show all empties the checklist and then disappears", () => {
1409
+ render(
1410
+ <Harness
1411
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1412
+ initialValue={{
1413
+ sortOrder: SortOrder.Ascending,
1414
+ eventTypes: ["IncidentCreated", "IncidentStateChanged"],
1415
+ }}
1416
+ />,
1417
+ );
1418
+
1419
+ openPanel();
1420
+ fireEvent.click(screen.getByRole("button", { name: "Show all" }));
1421
+
1422
+ for (const option of EVENT_TYPE_OPTIONS) {
1423
+ expect(getCheckbox(option.label)).not.toBeChecked();
1424
+ }
1425
+
1426
+ expect(
1427
+ screen.queryByRole("button", { name: "Show all" }),
1428
+ ).not.toBeInTheDocument();
1429
+ expect(getEventTypeSummary()).toHaveTextContent(UNFILTERED_SUMMARY);
1430
+ expect(getRadio("Oldest first")).toHaveAttribute("aria-checked", "true");
1431
+ expect(queryPanel()).toBeInTheDocument();
1432
+ });
1433
+
1434
+ test("shows the no-event-types message for a feed with an empty checklist", () => {
1435
+ render(
1436
+ <FeedOptionsButton
1437
+ value={DEFAULT_FEED_OPTIONS}
1438
+ eventTypeOptions={[]}
1439
+ onChange={createOnChange()}
1440
+ />,
1441
+ );
1442
+
1443
+ expect(getTrigger()).toHaveAccessibleName(TRIGGER_NAME);
1444
+ expect(getTrigger()).toHaveAccessibleDescription(
1445
+ "Newest first, all event types",
1446
+ );
1447
+
1448
+ openPanel();
1449
+
1450
+ expect(
1451
+ screen.getByText("This feed has no event types to filter by."),
1452
+ ).toBeVisible();
1453
+ expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
1454
+ expect(
1455
+ screen.queryByRole("textbox", { name: "Search event types" }),
1456
+ ).not.toBeInTheDocument();
1457
+ expect(getEventTypeSummary()).toHaveTextContent(UNFILTERED_SUMMARY);
1458
+ // The sort order still works without any event types.
1459
+ expect(screen.getAllByRole("radio")).toHaveLength(2);
1460
+ });
1461
+ });
1462
+
1463
+ describe("icons", () => {
1464
+ test("renders an option's icon and leaves an option without one plain", () => {
1465
+ render(
1466
+ <FeedOptionsButton
1467
+ value={DEFAULT_FEED_OPTIONS}
1468
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1469
+ onChange={createOnChange()}
1470
+ />,
1471
+ );
1472
+
1473
+ openPanel();
1474
+
1475
+ const withIcon: HTMLElement | null =
1476
+ getCheckbox("Incident Created").closest("label");
1477
+ const withoutIcon: HTMLElement | null = getCheckbox(
1478
+ "User Added as Owner",
1479
+ ).closest("label");
1480
+
1481
+ expect(withIcon).not.toBeNull();
1482
+ expect(withoutIcon).not.toBeNull();
1483
+ expect(withIcon!.querySelector("svg")).not.toBeNull();
1484
+ expect(withoutIcon!.querySelector("svg")).toBeNull();
1485
+ // The icon is decoration: the checkbox is named by its label alone.
1486
+ expect(getCheckbox("Incident Created")).toHaveAccessibleName(
1487
+ "Incident Created",
1488
+ );
1489
+ expect(getCheckbox("User Added as Owner")).toHaveAccessibleName(
1490
+ "User Added as Owner",
1491
+ );
1492
+ });
1493
+ });
1494
+
1495
+ describe("reset", () => {
1496
+ test("is not offered for the default view", () => {
1497
+ render(
1498
+ <FeedOptionsButton
1499
+ value={DEFAULT_FEED_OPTIONS}
1500
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1501
+ onChange={createOnChange()}
1502
+ />,
1503
+ );
1504
+
1505
+ openPanel();
1506
+
1507
+ expect(
1508
+ screen.queryByRole("button", { name: "Reset to default" }),
1509
+ ).not.toBeInTheDocument();
1510
+ });
1511
+
1512
+ test.each<[string, FeedOptions]>([
1513
+ [
1514
+ "the order is oldest first",
1515
+ { sortOrder: SortOrder.Ascending, eventTypes: [] },
1516
+ ],
1517
+ [
1518
+ "event types are ticked",
1519
+ { sortOrder: SortOrder.Descending, eventTypes: ["IncidentCreated"] },
1520
+ ],
1521
+ [
1522
+ "both differ from default",
1523
+ {
1524
+ sortOrder: SortOrder.Ascending,
1525
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
1526
+ },
1527
+ ],
1528
+ ])(
1529
+ "resets both the order and the filter when %s",
1530
+ (_label: string, value: FeedOptions) => {
1531
+ const onChange: OnChangeMock = createOnChange();
1532
+
1533
+ render(
1534
+ <FeedOptionsButton
1535
+ value={value}
1536
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1537
+ onChange={onChange}
1538
+ />,
1539
+ );
1540
+
1541
+ openPanel();
1542
+ fireEvent.click(
1543
+ screen.getByRole("button", { name: "Reset to default" }),
1544
+ );
1545
+
1546
+ expect(onChange).toHaveBeenCalledTimes(1);
1547
+ expect(onChange).toHaveBeenCalledWith({
1548
+ sortOrder: SortOrder.Descending,
1549
+ eventTypes: [],
1550
+ });
1551
+ },
1552
+ );
1553
+
1554
+ test("returns the whole control to its default look and then disappears", () => {
1555
+ render(
1556
+ <Harness
1557
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1558
+ initialValue={{
1559
+ sortOrder: SortOrder.Ascending,
1560
+ eventTypes: ["IncidentStateChanged", "OwnerUserAdded"],
1561
+ }}
1562
+ />,
1563
+ );
1564
+
1565
+ openPanel();
1566
+ fireEvent.click(screen.getByRole("button", { name: "Reset to default" }));
1567
+
1568
+ expect(getRadio("Newest first")).toHaveAttribute("aria-checked", "true");
1569
+
1570
+ for (const option of EVENT_TYPE_OPTIONS) {
1571
+ expect(getCheckbox(option.label)).not.toBeChecked();
1572
+ }
1573
+
1574
+ expect(
1575
+ screen.queryByRole("button", { name: "Reset to default" }),
1576
+ ).not.toBeInTheDocument();
1577
+ expect(
1578
+ screen.queryByTestId("feed-options-count"),
1579
+ ).not.toBeInTheDocument();
1580
+ expect(getTrigger()).toHaveClass(...DEFAULT_TRIGGER_CLASSES);
1581
+ expect(getTrigger()).toHaveAccessibleDescription(
1582
+ "Newest first, all event types",
1583
+ );
1584
+ expect(getLeadingGlyphPath(getTrigger())).toBe(
1585
+ getGlyphPath(IconProp.Filter),
1586
+ );
1587
+ expect(queryPanel()).toBeInTheDocument();
1588
+ });
1589
+ });
1590
+
1591
+ describe("search", () => {
1592
+ test("uses a threshold of eight event types", () => {
1593
+ expect(FEED_EVENT_TYPE_SEARCH_THRESHOLD).toBe(8);
1594
+ });
1595
+
1596
+ test("is absent when the checklist is at the threshold", () => {
1597
+ render(
1598
+ <FeedOptionsButton
1599
+ value={DEFAULT_FEED_OPTIONS}
1600
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS.slice(
1601
+ 0,
1602
+ FEED_EVENT_TYPE_SEARCH_THRESHOLD,
1603
+ )}
1604
+ onChange={createOnChange()}
1605
+ />,
1606
+ );
1607
+
1608
+ openPanel();
1609
+
1610
+ expect(screen.getAllByRole("checkbox")).toHaveLength(
1611
+ FEED_EVENT_TYPE_SEARCH_THRESHOLD,
1612
+ );
1613
+ expect(
1614
+ screen.queryByRole("textbox", { name: "Search event types" }),
1615
+ ).not.toBeInTheDocument();
1616
+ });
1617
+
1618
+ test("is absent for a short checklist", () => {
1619
+ render(
1620
+ <FeedOptionsButton
1621
+ value={DEFAULT_FEED_OPTIONS}
1622
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1623
+ onChange={createOnChange()}
1624
+ />,
1625
+ );
1626
+
1627
+ openPanel();
1628
+
1629
+ expect(
1630
+ screen.queryByRole("textbox", { name: "Search event types" }),
1631
+ ).not.toBeInTheDocument();
1632
+ });
1633
+
1634
+ test("appears once the checklist is longer than the threshold", () => {
1635
+ render(
1636
+ <FeedOptionsButton
1637
+ value={DEFAULT_FEED_OPTIONS}
1638
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS.slice(
1639
+ 0,
1640
+ FEED_EVENT_TYPE_SEARCH_THRESHOLD + 1,
1641
+ )}
1642
+ onChange={createOnChange()}
1643
+ />,
1644
+ );
1645
+
1646
+ openPanel();
1647
+
1648
+ const search: HTMLElement = getSearchInput();
1649
+
1650
+ expect(search).toHaveValue("");
1651
+ expect(search).toHaveAttribute("placeholder", "Search event types");
1652
+ expect(screen.getAllByRole("checkbox")).toHaveLength(
1653
+ FEED_EVENT_TYPE_SEARCH_THRESHOLD + 1,
1654
+ );
1655
+ });
1656
+
1657
+ test.each<[string, Array<string>]>([
1658
+ ["INCIDENT", ["Incident Created", "Incident Resolved"]],
1659
+ ["incident", ["Incident Created", "Incident Resolved"]],
1660
+ [
1661
+ "notification",
1662
+ ["Owner Notification Sent", "Subscriber Notification Sent"],
1663
+ ],
1664
+ ["as owner", ["Team Added as Owner", "User Added as Owner"]],
1665
+ [" resolved ", ["Alert Resolved", "Incident Resolved"]],
1666
+ ["ACKNOW", ["Alert Acknowledged"]],
1667
+ ])(
1668
+ "filters the checklist to labels containing %p, ignoring case",
1669
+ (term: string, expectedLabels: Array<string>) => {
1670
+ render(
1671
+ <FeedOptionsButton
1672
+ value={DEFAULT_FEED_OPTIONS}
1673
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
1674
+ onChange={createOnChange()}
1675
+ />,
1676
+ );
1677
+
1678
+ openPanel();
1679
+ fireEvent.change(getSearchInput(), { target: { value: term } });
1680
+
1681
+ expect(getSearchInput()).toHaveValue(term);
1682
+ expect(getCheckboxLabels()).toEqual(expectedLabels);
1683
+ },
1684
+ );
1685
+
1686
+ test("restores the whole checklist when the search is cleared", () => {
1687
+ render(
1688
+ <FeedOptionsButton
1689
+ value={DEFAULT_FEED_OPTIONS}
1690
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
1691
+ onChange={createOnChange()}
1692
+ />,
1693
+ );
1694
+
1695
+ openPanel();
1696
+ fireEvent.change(getSearchInput(), { target: { value: "alert" } });
1697
+ expect(getCheckboxLabels()).toHaveLength(3);
1698
+
1699
+ fireEvent.change(getSearchInput(), { target: { value: " " } });
1700
+ expect(getCheckboxLabels()).toHaveLength(LONG_EVENT_TYPE_OPTIONS.length);
1701
+
1702
+ fireEvent.change(getSearchInput(), { target: { value: "" } });
1703
+ expect(getCheckboxLabels()).toEqual(
1704
+ LONG_EVENT_TYPE_OPTIONS.map((option: FeedEventTypeOption) => {
1705
+ return option.label;
1706
+ }),
1707
+ );
1708
+ });
1709
+
1710
+ test("matches labels, not raw event type values", () => {
1711
+ render(
1712
+ <FeedOptionsButton
1713
+ value={DEFAULT_FEED_OPTIONS}
1714
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
1715
+ onChange={createOnChange()}
1716
+ />,
1717
+ );
1718
+
1719
+ openPanel();
1720
+ fireEvent.change(getSearchInput(), { target: { value: "OwnerUser" } });
1721
+
1722
+ expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
1723
+ });
1724
+
1725
+ test("says so when nothing matches", () => {
1726
+ render(
1727
+ <FeedOptionsButton
1728
+ value={DEFAULT_FEED_OPTIONS}
1729
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
1730
+ onChange={createOnChange()}
1731
+ />,
1732
+ );
1733
+
1734
+ openPanel();
1735
+ fireEvent.change(getSearchInput(), { target: { value: " zzz " } });
1736
+
1737
+ expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
1738
+ expect(screen.getByText('No event types match "zzz".')).toBeVisible();
1739
+ expect(
1740
+ screen.queryByText("This feed has no event types to filter by."),
1741
+ ).not.toBeInTheDocument();
1742
+ });
1743
+
1744
+ /*
1745
+ * The filtered list changes silently under the search box, so the number
1746
+ * of matches is spoken from a live region. It is mounted empty with the
1747
+ * search box: a region that appears together with its first text is
1748
+ * often not announced at all.
1749
+ */
1750
+ test("announces the number of matches, and nothing while there is no search", () => {
1751
+ render(
1752
+ <FeedOptionsButton
1753
+ value={DEFAULT_FEED_OPTIONS}
1754
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
1755
+ onChange={createOnChange()}
1756
+ />,
1757
+ );
1758
+
1759
+ openPanel();
1760
+
1761
+ const results: HTMLElement = getSearchResults();
1762
+
1763
+ expect(results).toHaveAttribute("aria-live", "polite");
1764
+ expect(results.textContent).toBe("");
1765
+
1766
+ fireEvent.change(getSearchInput(), { target: { value: "incident" } });
1767
+
1768
+ expect(getCheckboxLabels()).toHaveLength(2);
1769
+ expect(getSearchResults()).toBe(results);
1770
+ expect(results).toHaveTextContent(/^Matching event types: 2$/);
1771
+
1772
+ fireEvent.change(getSearchInput(), { target: { value: " " } });
1773
+
1774
+ expect(results.textContent).toBe("");
1775
+
1776
+ fireEvent.change(getSearchInput(), { target: { value: "zzz" } });
1777
+
1778
+ expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
1779
+ expect(screen.getByText('No event types match "zzz".')).toBeVisible();
1780
+ expect(results).toHaveTextContent(/^Matching event types: 0$/);
1781
+
1782
+ fireEvent.change(getSearchInput(), { target: { value: "" } });
1783
+
1784
+ expect(results.textContent).toBe("");
1785
+ });
1786
+
1787
+ test("ticks a search result without dropping a ticked event type the search hides", () => {
1788
+ const onChange: OnChangeMock = createOnChange();
1789
+
1790
+ render(
1791
+ <Harness
1792
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
1793
+ initialValue={{
1794
+ sortOrder: SortOrder.Ascending,
1795
+ eventTypes: ["OwnerUserAdded"],
1796
+ }}
1797
+ onChange={onChange}
1798
+ />,
1799
+ );
1800
+
1801
+ openPanel();
1802
+ fireEvent.change(getSearchInput(), { target: { value: "incident" } });
1803
+ fireEvent.click(getCheckbox("Incident Resolved"));
1804
+
1805
+ expect(onChange).toHaveBeenCalledTimes(1);
1806
+ expect(onChange).toHaveBeenCalledWith({
1807
+ sortOrder: SortOrder.Ascending,
1808
+ eventTypes: ["IncidentResolved", "OwnerUserAdded"],
1809
+ });
1810
+ // The search stays put so the reader can keep ticking results.
1811
+ expect(getSearchInput()).toHaveValue("incident");
1812
+ expect(getCheckboxLabels()).toEqual([
1813
+ "Incident Created",
1814
+ "Incident Resolved",
1815
+ ]);
1816
+ expect(getCheckbox("Incident Resolved")).toBeChecked();
1817
+ expect(getEventTypeSummary()).toHaveTextContent(
1818
+ `Showing 2 of ${LONG_EVENT_TYPE_OPTIONS.length} event types.`,
1819
+ );
1820
+ expect(queryPanel()).toBeInTheDocument();
1821
+ });
1822
+
1823
+ test.each<[string, () => void]>([
1824
+ [
1825
+ "Escape",
1826
+ (): void => {
1827
+ fireEvent.keyDown(getSearchInput(), { key: "Escape" });
1828
+ },
1829
+ ],
1830
+ [
1831
+ "a press outside",
1832
+ (): void => {
1833
+ fireEvent.mouseDown(document.body);
1834
+ },
1835
+ ],
1836
+ [
1837
+ "the trigger",
1838
+ (): void => {
1839
+ fireEvent.mouseDown(getTrigger());
1840
+ fireEvent.click(getTrigger());
1841
+ },
1842
+ ],
1843
+ [
1844
+ "focus leaving the control",
1845
+ (): void => {
1846
+ const outside: HTMLElement = screen.getByRole("button", {
1847
+ name: "Outside",
1848
+ });
1849
+
1850
+ act(() => {
1851
+ outside.focus();
1852
+ });
1853
+ },
1854
+ ],
1855
+ ])(
1856
+ "clears the search text when closed by %s",
1857
+ (_label: string, dismiss: () => void) => {
1858
+ render(
1859
+ <div>
1860
+ <FeedOptionsButton
1861
+ value={DEFAULT_FEED_OPTIONS}
1862
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
1863
+ onChange={createOnChange()}
1864
+ />
1865
+ <button type="button">Outside</button>
1866
+ </div>,
1867
+ );
1868
+
1869
+ openPanel();
1870
+
1871
+ const search: HTMLElement = getSearchInput();
1872
+
1873
+ act(() => {
1874
+ search.focus();
1875
+ });
1876
+ fireEvent.change(search, { target: { value: "incident" } });
1877
+ expect(getCheckboxLabels()).toHaveLength(2);
1878
+
1879
+ dismiss();
1880
+ expect(queryPanel()).not.toBeInTheDocument();
1881
+
1882
+ openPanel();
1883
+
1884
+ expect(getSearchInput()).toHaveValue("");
1885
+ expect(getSearchResults().textContent).toBe("");
1886
+ expect(getCheckboxLabels()).toHaveLength(
1887
+ LONG_EVENT_TYPE_OPTIONS.length,
1888
+ );
1889
+ },
1890
+ );
1891
+ });
1892
+
1893
+ describe("dismissal", () => {
1894
+ const renderWithOutside: (onChange?: OnChangeMock) => void = (
1895
+ onChange?: OnChangeMock,
1896
+ ): void => {
1897
+ render(
1898
+ <div>
1899
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} onChange={onChange} />
1900
+ <button type="button">Outside</button>
1901
+ </div>,
1902
+ );
1903
+ };
1904
+
1905
+ const getOutside: () => HTMLElement = (): HTMLElement => {
1906
+ return screen.getByRole("button", { name: "Outside" });
1907
+ };
1908
+
1909
+ test("Escape closes the panel and returns focus to the trigger", () => {
1910
+ renderWithOutside();
1911
+
1912
+ openPanel();
1913
+ expect(getRadio("Newest first")).toHaveFocus();
1914
+
1915
+ expect(
1916
+ fireEvent.keyDown(getRadio("Newest first"), { key: "Escape" }),
1917
+ ).toBe(false);
1918
+
1919
+ expect(queryPanel()).not.toBeInTheDocument();
1920
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
1921
+ expect(getTrigger()).not.toHaveAttribute("aria-controls");
1922
+ expect(getTrigger()).toHaveFocus();
1923
+ });
1924
+
1925
+ test("Escape from a checkbox also closes and returns focus", () => {
1926
+ renderWithOutside();
1927
+
1928
+ openPanel();
1929
+
1930
+ const checkbox: HTMLElement = getCheckbox("Incident Created");
1931
+
1932
+ act(() => {
1933
+ checkbox.focus();
1934
+ });
1935
+ fireEvent.keyDown(checkbox, { key: "Escape" });
1936
+
1937
+ expect(queryPanel()).not.toBeInTheDocument();
1938
+ expect(getTrigger()).toHaveFocus();
1939
+ });
1940
+
1941
+ test("Escape inside the open panel does not reach an enclosing handler, but does when closed", () => {
1942
+ const onParentKeyDown: ReturnType<
1943
+ typeof jest.fn<(event: React.KeyboardEvent) => void>
1944
+ > = jest.fn<(event: React.KeyboardEvent) => void>();
1945
+
1946
+ render(
1947
+ <div onKeyDown={onParentKeyDown}>
1948
+ <FeedOptionsButton
1949
+ value={DEFAULT_FEED_OPTIONS}
1950
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
1951
+ onChange={createOnChange()}
1952
+ />
1953
+ </div>,
1954
+ );
1955
+
1956
+ openPanel();
1957
+ fireEvent.keyDown(getRadio("Newest first"), { key: "Escape" });
1958
+
1959
+ expect(queryPanel()).not.toBeInTheDocument();
1960
+ expect(onParentKeyDown).not.toHaveBeenCalled();
1961
+
1962
+ // Closed, Escape is none of this control's business.
1963
+ expect(fireEvent.keyDown(getTrigger(), { key: "Escape" })).toBe(true);
1964
+ expect(onParentKeyDown).toHaveBeenCalledTimes(1);
1965
+ expect(queryPanel()).not.toBeInTheDocument();
1966
+ });
1967
+
1968
+ test("a press outside closes the panel", () => {
1969
+ renderWithOutside();
1970
+
1971
+ openPanel();
1972
+ fireEvent.mouseDown(getOutside());
1973
+
1974
+ expect(queryPanel()).not.toBeInTheDocument();
1975
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
1976
+ });
1977
+
1978
+ test("a press on the page body closes the panel", () => {
1979
+ renderWithOutside();
1980
+
1981
+ openPanel();
1982
+ fireEvent.mouseDown(document.body);
1983
+
1984
+ expect(queryPanel()).not.toBeInTheDocument();
1985
+ });
1986
+
1987
+ test("a press inside the panel keeps it open", () => {
1988
+ renderWithOutside();
1989
+
1990
+ const panel: HTMLElement = openPanel();
1991
+
1992
+ fireEvent.mouseDown(panel);
1993
+ expect(queryPanel()).toBeInTheDocument();
1994
+
1995
+ fireEvent.mouseDown(getCheckbox("Incident State Changed"));
1996
+ expect(queryPanel()).toBeInTheDocument();
1997
+
1998
+ fireEvent.mouseDown(getRadio("Oldest first"));
1999
+ expect(queryPanel()).toBeInTheDocument();
2000
+
2001
+ fireEvent.mouseDown(screen.getByText("Sort by time"));
2002
+ expect(queryPanel()).toBeInTheDocument();
2003
+ });
2004
+
2005
+ /*
2006
+ * A press on the panel's padding moves focus to the panel itself (the
2007
+ * nearest focusable ancestor of the press). Were the panel not focusable,
2008
+ * focus would fall to the page body, outside the control's key handler,
2009
+ * and Escape would stop closing it.
2010
+ */
2011
+ test("a press on the panel's padding keeps it open, and Escape from there still closes it", () => {
2012
+ renderWithOutside();
2013
+
2014
+ const panel: HTMLElement = openPanel();
2015
+
2016
+ expect(getRadio("Newest first")).toHaveFocus();
2017
+
2018
+ fireEvent.mouseDown(panel);
2019
+ act(() => {
2020
+ panel.focus();
2021
+ });
2022
+
2023
+ expect(queryPanel()).toBe(panel);
2024
+ expect(panel).toHaveFocus();
2025
+
2026
+ expect(fireEvent.keyDown(panel, { key: "Escape" })).toBe(false);
2027
+
2028
+ expect(queryPanel()).not.toBeInTheDocument();
2029
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
2030
+ expect(getTrigger()).toHaveFocus();
2031
+ });
2032
+
2033
+ test("a press outside while closed changes nothing", () => {
2034
+ const onChange: OnChangeMock = createOnChange();
2035
+
2036
+ renderWithOutside(onChange);
2037
+
2038
+ fireEvent.mouseDown(getOutside());
2039
+
2040
+ expect(queryPanel()).not.toBeInTheDocument();
2041
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
2042
+ expect(onChange).not.toHaveBeenCalled();
2043
+ });
2044
+
2045
+ test("pressing the trigger while open closes it without re-opening", () => {
2046
+ renderWithOutside();
2047
+
2048
+ openPanel();
2049
+
2050
+ // A real press is mousedown then click.
2051
+ fireEvent.mouseDown(getTrigger());
2052
+ fireEvent.click(getTrigger());
2053
+
2054
+ expect(queryPanel()).not.toBeInTheDocument();
2055
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
2056
+
2057
+ fireEvent.mouseDown(getTrigger());
2058
+ fireEvent.click(getTrigger());
2059
+
2060
+ expect(queryPanel()).toBeInTheDocument();
2061
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "true");
2062
+ });
2063
+
2064
+ test("focus moving to an element outside closes the panel", () => {
2065
+ renderWithOutside();
2066
+
2067
+ openPanel();
2068
+ fireEvent.focusOut(getRadio("Newest first"), {
2069
+ relatedTarget: getOutside(),
2070
+ });
2071
+
2072
+ expect(queryPanel()).not.toBeInTheDocument();
2073
+ expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
2074
+ });
2075
+
2076
+ test("tabbing focus out to another control closes the panel", () => {
2077
+ renderWithOutside();
2078
+
2079
+ openPanel();
2080
+
2081
+ act(() => {
2082
+ getOutside().focus();
2083
+ });
2084
+
2085
+ expect(queryPanel()).not.toBeInTheDocument();
2086
+ expect(getOutside()).toHaveFocus();
2087
+ });
2088
+
2089
+ test("a blur with no next focus, like switching to another window, keeps it open", () => {
2090
+ renderWithOutside();
2091
+
2092
+ openPanel();
2093
+ fireEvent.focusOut(getRadio("Newest first"), { relatedTarget: null });
2094
+ expect(queryPanel()).toBeInTheDocument();
2095
+
2096
+ fireEvent.blur(getRadio("Newest first"));
2097
+ expect(queryPanel()).toBeInTheDocument();
2098
+ });
2099
+
2100
+ test("focus moving between controls inside the panel keeps it open", () => {
2101
+ renderWithOutside();
2102
+
2103
+ openPanel();
2104
+
2105
+ act(() => {
2106
+ getCheckbox("Incident Created").focus();
2107
+ });
2108
+ expect(queryPanel()).toBeInTheDocument();
2109
+
2110
+ act(() => {
2111
+ getRadio("Newest first").focus();
2112
+ });
2113
+ expect(queryPanel()).toBeInTheDocument();
2114
+
2115
+ // Shift+Tab from the first radio lands on the trigger, still inside.
2116
+ act(() => {
2117
+ getTrigger().focus();
2118
+ });
2119
+ expect(queryPanel()).toBeInTheDocument();
2120
+ });
2121
+
2122
+ test("stops listening for outside presses once unmounted", () => {
2123
+ const addSpy: ReturnType<typeof jest.spyOn> = jest.spyOn(
2124
+ document,
2125
+ "addEventListener",
2126
+ );
2127
+ const removeSpy: ReturnType<typeof jest.spyOn> = jest.spyOn(
2128
+ document,
2129
+ "removeEventListener",
2130
+ );
2131
+
2132
+ try {
2133
+ const { unmount } = render(
2134
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />,
2135
+ );
2136
+
2137
+ openPanel();
2138
+
2139
+ const mouseDownListeners: Array<unknown> = getListeners(
2140
+ addSpy,
2141
+ "mousedown",
2142
+ );
2143
+
2144
+ expect(mouseDownListeners.length).toBeGreaterThan(0);
2145
+
2146
+ unmount();
2147
+
2148
+ /*
2149
+ * removeEventListener only detaches when the handler identity matches
2150
+ * the add, so compare the functions rather than counting calls.
2151
+ */
2152
+ const removedListeners: Array<unknown> = getListeners(
2153
+ removeSpy,
2154
+ "mousedown",
2155
+ );
2156
+
2157
+ for (const listener of mouseDownListeners) {
2158
+ expect(removedListeners).toContain(listener);
2159
+ }
2160
+ } finally {
2161
+ addSpy.mockRestore();
2162
+ removeSpy.mockRestore();
2163
+ }
2164
+ });
2165
+ });
2166
+
2167
+ /*
2168
+ * "Reset to default" and "Show all" each remove themselves once used, so
2169
+ * the button a keyboard user just pressed vanishes. Focus must land on
2170
+ * another control inside the panel rather than fall back to the page body,
2171
+ * where Escape would never reach the control's key handler and the panel
2172
+ * could no longer be closed from the keyboard.
2173
+ */
2174
+ describe("keyboard focus after an action that removes its own button", () => {
2175
+ const pressThenEscape: (
2176
+ buttonName: string,
2177
+ expectFocusOn: () => HTMLElement,
2178
+ ) => void = (
2179
+ buttonName: string,
2180
+ expectFocusOn: () => HTMLElement,
2181
+ ): void => {
2182
+ const button: HTMLElement = screen.getByRole("button", {
2183
+ name: buttonName,
2184
+ });
2185
+
2186
+ act(() => {
2187
+ button.focus();
2188
+ });
2189
+ fireEvent.click(button);
2190
+
2191
+ expect(
2192
+ screen.queryByRole("button", { name: buttonName }),
2193
+ ).not.toBeInTheDocument();
2194
+ expect(queryPanel()).toBeInTheDocument();
2195
+ expect(document.activeElement).not.toBe(document.body);
2196
+ expect(expectFocusOn()).toHaveFocus();
2197
+
2198
+ fireEvent.keyDown(document.activeElement || document.body, {
2199
+ key: "Escape",
2200
+ });
2201
+
2202
+ expect(queryPanel()).not.toBeInTheDocument();
2203
+ expect(getTrigger()).toHaveFocus();
2204
+ };
2205
+
2206
+ test("Reset to default moves focus to Newest first, where Escape still closes the panel", () => {
2207
+ render(
2208
+ <Harness
2209
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
2210
+ initialValue={{
2211
+ sortOrder: SortOrder.Ascending,
2212
+ eventTypes: ["IncidentCreated"],
2213
+ }}
2214
+ />,
2215
+ );
2216
+
2217
+ openPanel();
2218
+ pressThenEscape("Reset to default", () => {
2219
+ return screen.getByRole("radio", { name: "Newest first" });
2220
+ });
2221
+ });
2222
+
2223
+ test("Show all moves focus to the first event type, where Escape still closes the panel", () => {
2224
+ render(
2225
+ <Harness
2226
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
2227
+ initialValue={{
2228
+ sortOrder: SortOrder.Ascending,
2229
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
2230
+ }}
2231
+ />,
2232
+ );
2233
+
2234
+ openPanel();
2235
+ pressThenEscape("Show all", () => {
2236
+ return screen.getAllByRole("checkbox")[0]!;
2237
+ });
2238
+ });
2239
+
2240
+ /*
2241
+ * With a search that hides every row there is no checkbox to land on, so
2242
+ * focus goes to the checked sort order. Oldest first is checked here, so
2243
+ * this also tells "the checked radio" apart from "the first radio".
2244
+ */
2245
+ test("Show all while the search hides every row moves focus to the checked sort order, where Escape still closes the panel", () => {
2246
+ render(
2247
+ <Harness
2248
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
2249
+ initialValue={{
2250
+ sortOrder: SortOrder.Ascending,
2251
+ eventTypes: ["AlertCreated", "OwnerUserAdded"],
2252
+ }}
2253
+ />,
2254
+ );
2255
+
2256
+ openPanel();
2257
+ fireEvent.change(getSearchInput(), { target: { value: "zzz" } });
2258
+ expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
2259
+
2260
+ pressThenEscape("Show all", () => {
2261
+ return getRadio("Oldest first");
2262
+ });
2263
+ });
2264
+ });
2265
+
2266
+ describe("two instances on one page", () => {
2267
+ test("get distinct ids and do not interfere with each other", () => {
2268
+ const firstOnChange: OnChangeMock = createOnChange();
2269
+ const secondOnChange: OnChangeMock = createOnChange();
2270
+
2271
+ render(
2272
+ <div>
2273
+ <Harness
2274
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
2275
+ onChange={firstOnChange}
2276
+ />
2277
+ <Harness
2278
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS.slice(0, 2)}
2279
+ onChange={secondOnChange}
2280
+ />
2281
+ </div>,
2282
+ );
2283
+
2284
+ const triggers: Array<HTMLElement> = screen.getAllByRole("button", {
2285
+ name: TRIGGER_NAME,
2286
+ });
2287
+
2288
+ expect(triggers).toHaveLength(2);
2289
+
2290
+ const firstTrigger: HTMLElement = triggers[0]!;
2291
+ const secondTrigger: HTMLElement = triggers[1]!;
2292
+
2293
+ expect(firstTrigger.id).not.toBe("");
2294
+ expect(secondTrigger.id).not.toBe("");
2295
+ expect(firstTrigger.id).not.toBe(secondTrigger.id);
2296
+ expect(firstTrigger.getAttribute("aria-describedby")).not.toBe(
2297
+ secondTrigger.getAttribute("aria-describedby"),
2298
+ );
2299
+
2300
+ // Open the first one and filter it.
2301
+ fireEvent.click(firstTrigger);
2302
+
2303
+ const firstPanel: HTMLElement = screen.getByRole("dialog", {
2304
+ name: PANEL_NAME,
2305
+ });
2306
+ const firstPanelId: string = firstPanel.id;
2307
+
2308
+ expect(firstTrigger).toHaveAttribute("aria-controls", firstPanelId);
2309
+ expect(secondTrigger).toHaveAttribute("aria-expanded", "false");
2310
+ expect(secondTrigger).not.toHaveAttribute("aria-controls");
2311
+ expect(
2312
+ within(firstPanel).getByRole("radiogroup", { name: "Sort by time" }),
2313
+ ).toBeInTheDocument();
2314
+ expect(
2315
+ within(firstPanel).getByRole("group", { name: "Event types" }),
2316
+ ).toBeInTheDocument();
2317
+
2318
+ fireEvent.click(
2319
+ within(firstPanel).getByRole("checkbox", {
2320
+ name: "Incident Created",
2321
+ }),
2322
+ );
2323
+
2324
+ expect(firstOnChange).toHaveBeenCalledTimes(1);
2325
+ expect(secondOnChange).not.toHaveBeenCalled();
2326
+
2327
+ // Pressing the second trigger is a press outside the first control.
2328
+ fireEvent.mouseDown(secondTrigger);
2329
+ fireEvent.click(secondTrigger);
2330
+
2331
+ const panels: Array<HTMLElement> = screen.getAllByRole("dialog", {
2332
+ name: PANEL_NAME,
2333
+ });
2334
+
2335
+ expect(panels).toHaveLength(1);
2336
+
2337
+ const secondPanel: HTMLElement = panels[0]!;
2338
+
2339
+ expect(secondPanel.id).not.toBe("");
2340
+ expect(secondPanel.id).not.toBe(firstPanelId);
2341
+ expect(firstTrigger).toHaveAttribute("aria-expanded", "false");
2342
+ expect(firstTrigger).not.toHaveAttribute("aria-controls");
2343
+ expect(secondTrigger).toHaveAttribute("aria-expanded", "true");
2344
+ expect(secondTrigger).toHaveAttribute("aria-controls", secondPanel.id);
2345
+ expect(
2346
+ within(secondPanel).getByRole("radio", { name: "Newest first" }),
2347
+ ).toHaveFocus();
2348
+ expect(
2349
+ within(secondPanel).getByRole("radiogroup", { name: "Sort by time" }),
2350
+ ).toBeInTheDocument();
2351
+ expect(
2352
+ within(secondPanel)
2353
+ .getAllByRole("checkbox")
2354
+ .map((checkbox: HTMLElement) => {
2355
+ return checkbox.closest("label")?.textContent || "";
2356
+ }),
2357
+ ).toEqual(["Alert Acknowledged", "Alert Created"]);
2358
+
2359
+ fireEvent.click(
2360
+ within(secondPanel).getByRole("radio", { name: "Oldest first" }),
2361
+ );
2362
+
2363
+ expect(secondOnChange).toHaveBeenCalledTimes(1);
2364
+ expect(secondOnChange).toHaveBeenCalledWith({
2365
+ sortOrder: SortOrder.Ascending,
2366
+ eventTypes: [],
2367
+ });
2368
+ expect(firstOnChange).toHaveBeenCalledTimes(1);
2369
+
2370
+ // Each trigger keeps describing only its own feed.
2371
+ expect(firstTrigger).toHaveAccessibleDescription(
2372
+ "Newest first, 1 of 3 event types",
2373
+ );
2374
+ expect(secondTrigger).toHaveAccessibleDescription(
2375
+ "Oldest first, all event types",
2376
+ );
2377
+ expect(
2378
+ within(firstTrigger).getByTestId("feed-options-count"),
2379
+ ).toHaveTextContent(/^1$/);
2380
+ expect(
2381
+ within(secondTrigger).queryByTestId("feed-options-count"),
2382
+ ).not.toBeInTheDocument();
2383
+ });
2384
+
2385
+ test("focus moving into the other instance closes the first", () => {
2386
+ render(
2387
+ <div>
2388
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />
2389
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} />
2390
+ </div>,
2391
+ );
2392
+
2393
+ const triggers: Array<HTMLElement> = screen.getAllByRole("button", {
2394
+ name: TRIGGER_NAME,
2395
+ });
2396
+
2397
+ fireEvent.click(triggers[0]!);
2398
+ expect(screen.getAllByRole("dialog")).toHaveLength(1);
2399
+
2400
+ // A click without a mousedown: only the focus hand-off can close it.
2401
+ fireEvent.click(triggers[1]!);
2402
+
2403
+ expect(screen.getAllByRole("dialog")).toHaveLength(1);
2404
+ expect(triggers[0]).toHaveAttribute("aria-expanded", "false");
2405
+ expect(triggers[1]).toHaveAttribute("aria-expanded", "true");
2406
+ });
2407
+ });
2408
+
2409
+ /*
2410
+ * A real i18next instance rather than a stubbed t(): the control looks its
2411
+ * sentences up whole, relies on i18next leaving the {{placeholders}} it is
2412
+ * not given, and fills them itself afterwards. Only a real instance proves
2413
+ * that hand-off. The German values are the shipped ones, copied from
2414
+ * App/FeatureSet/Dashboard/src/Locales/de.json; the keys are the English
2415
+ * source text, exactly as those locale files key them.
2416
+ *
2417
+ * The instance reaches the component through I18nextProvider only. Adding
2418
+ * .use(initReactI18next) would install it as react-i18next's global
2419
+ * instance and quietly turn every other test in this file German.
2420
+ */
2421
+ describe("in German", () => {
2422
+ const german: i18n = createInstance();
2423
+
2424
+ /*
2425
+ * Two event labels whose German order differs from the English one.
2426
+ * "Incident State Changed" is left out on purpose: a label with no
2427
+ * translation yet stays in English, and is sorted among the German ones.
2428
+ */
2429
+ const GERMAN_TRANSLATIONS: Record<string, string> = {
2430
+ "Filter & Sort": "Filtern & Sortieren",
2431
+ "Filter and sort feed": "Feed filtern und sortieren",
2432
+ "Newest first": "Neueste zuerst",
2433
+ "Oldest first": "Älteste zuerst",
2434
+ "Event types": "Ereignistypen",
2435
+ "Search event types": "Ereignistypen suchen",
2436
+ "Matching event types: {{count}}": "Passende Ereignistypen: {{count}}",
2437
+ "Showing {{selected}} of {{total}} event types.":
2438
+ "{{selected}} von {{total}} Ereignistypen werden angezeigt.",
2439
+ "{{sortOrder}}, all event types": "{{sortOrder}}, alle Ereignistypen",
2440
+ "{{sortOrder}}, {{selected}} of {{total}} event types":
2441
+ "{{sortOrder}}, {{selected}} von {{total}} Ereignistypen",
2442
+ "Incident Created": "Vorfall erstellt",
2443
+ "User Added as Owner": "Benutzer als Eigentümer hinzugefügt",
2444
+ };
2445
+
2446
+ beforeAll(async () => {
2447
+ await german.init({
2448
+ lng: "de",
2449
+ resources: { de: { translation: GERMAN_TRANSLATIONS } },
2450
+ interpolation: { escapeValue: false },
2451
+ keySeparator: false,
2452
+ nsSeparator: false,
2453
+ });
2454
+ });
2455
+
2456
+ const renderInGerman: (ui: React.ReactElement) => RenderResult = (
2457
+ ui: React.ReactElement,
2458
+ ): RenderResult => {
2459
+ return render(<I18nextProvider i18n={german}>{ui}</I18nextProvider>);
2460
+ };
2461
+
2462
+ const getGermanTrigger: () => HTMLElement = (): HTMLElement => {
2463
+ return screen.getByRole("button", { name: "Filtern & Sortieren" });
2464
+ };
2465
+
2466
+ const openGermanPanel: () => HTMLElement = (): HTMLElement => {
2467
+ fireEvent.click(getGermanTrigger());
2468
+
2469
+ return screen.getByRole("dialog", { name: "Feed filtern und sortieren" });
2470
+ };
2471
+
2472
+ test("names the trigger with the German label and describes the view in German", () => {
2473
+ renderInGerman(
2474
+ <FeedOptionsButton
2475
+ value={DEFAULT_FEED_OPTIONS}
2476
+ eventTypeOptions={EVENT_TYPE_OPTIONS}
2477
+ onChange={createOnChange()}
2478
+ />,
2479
+ );
2480
+
2481
+ const trigger: HTMLElement = getGermanTrigger();
2482
+
2483
+ expect(trigger).toHaveAccessibleName("Filtern & Sortieren");
2484
+ expect(trigger).toHaveAccessibleDescription(
2485
+ "Neueste zuerst, alle Ereignistypen",
2486
+ );
2487
+ expect(trigger).toHaveAttribute(
2488
+ "title",
2489
+ "Neueste zuerst, alle Ereignistypen",
2490
+ );
2491
+
2492
+ openGermanPanel();
2493
+
2494
+ expect(
2495
+ screen.getByRole("group", { name: "Ereignistypen" }),
2496
+ ).toBeInTheDocument();
2497
+ expect(
2498
+ screen.getAllByRole("radio").map((radio: HTMLElement) => {
2499
+ return radio.textContent;
2500
+ }),
2501
+ ).toEqual(["Neueste zuerst", "Älteste zuerst"]);
2502
+ });
2503
+
2504
+ test("fills the German sentences with the numbers as boxes are ticked", () => {
2505
+ const onChange: OnChangeMock = createOnChange();
2506
+
2507
+ renderInGerman(
2508
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} onChange={onChange} />,
2509
+ );
2510
+
2511
+ openGermanPanel();
2512
+ fireEvent.click(getCheckbox("Vorfall erstellt"));
2513
+
2514
+ expect(getLastCall(onChange)).toEqual({
2515
+ sortOrder: SortOrder.Descending,
2516
+ eventTypes: ["IncidentCreated"],
2517
+ });
2518
+ expect(getEventTypeSummary()).toHaveTextContent(
2519
+ /^1 von 3 Ereignistypen werden angezeigt\.$/,
2520
+ );
2521
+ expect(getGermanTrigger()).toHaveAccessibleDescription(
2522
+ "Neueste zuerst, 1 von 3 Ereignistypen",
2523
+ );
2524
+
2525
+ fireEvent.click(getRadio("Älteste zuerst"));
2526
+
2527
+ expect(getGermanTrigger()).toHaveAccessibleDescription(
2528
+ "Älteste zuerst, 1 von 3 Ereignistypen",
2529
+ );
2530
+ });
2531
+
2532
+ test("orders the checklist by the German labels, while payloads keep the feed's own order", () => {
2533
+ const onChange: OnChangeMock = createOnChange();
2534
+
2535
+ renderInGerman(
2536
+ <Harness eventTypeOptions={EVENT_TYPE_OPTIONS} onChange={onChange} />,
2537
+ );
2538
+
2539
+ openGermanPanel();
2540
+
2541
+ /*
2542
+ * In English this list reads Incident Created, Incident State Changed,
2543
+ * User Added as Owner.
2544
+ */
2545
+ expect(getCheckboxLabels()).toEqual([
2546
+ "Benutzer als Eigentümer hinzugefügt",
2547
+ "Incident State Changed",
2548
+ "Vorfall erstellt",
2549
+ ]);
2550
+
2551
+ /*
2552
+ * Ticked top to bottom as a German reader sees them, the request still
2553
+ * lists them in the feed's order, so it - and the reload key - is the
2554
+ * same in every language.
2555
+ */
2556
+ fireEvent.click(getCheckbox("Benutzer als Eigentümer hinzugefügt"));
2557
+ fireEvent.click(getCheckbox("Vorfall erstellt"));
2558
+
2559
+ expect(getLastCall(onChange)).toEqual({
2560
+ sortOrder: SortOrder.Descending,
2561
+ eventTypes: ["IncidentCreated", "OwnerUserAdded"],
2562
+ });
2563
+ });
2564
+
2565
+ test("searches the German labels the reader sees, not the English ones behind them", () => {
2566
+ const onChange: OnChangeMock = createOnChange();
2567
+
2568
+ renderInGerman(
2569
+ <Harness
2570
+ eventTypeOptions={LONG_EVENT_TYPE_OPTIONS}
2571
+ onChange={onChange}
2572
+ />,
2573
+ );
2574
+
2575
+ openGermanPanel();
2576
+
2577
+ const search: HTMLElement = screen.getByRole("textbox", {
2578
+ name: "Ereignistypen suchen",
2579
+ });
2580
+
2581
+ fireEvent.change(search, { target: { value: "EIGENTÜMER" } });
2582
+
2583
+ expect(getCheckboxLabels()).toEqual([
2584
+ "Benutzer als Eigentümer hinzugefügt",
2585
+ ]);
2586
+ expect(getSearchResults()).toHaveTextContent(
2587
+ /^Passende Ereignistypen: 1$/,
2588
+ );
2589
+
2590
+ fireEvent.click(getCheckbox("Benutzer als Eigentümer hinzugefügt"));
2591
+
2592
+ expect(getLastCall(onChange)).toEqual({
2593
+ sortOrder: SortOrder.Descending,
2594
+ eventTypes: ["OwnerUserAdded"],
2595
+ });
2596
+
2597
+ // Its English label no longer matches; the untranslated rows still do.
2598
+ fireEvent.change(search, { target: { value: "as owner" } });
2599
+
2600
+ expect(getCheckboxLabels()).toEqual(["Team Added as Owner"]);
2601
+ });
2602
+ });
2603
+ });