@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.
- package/Models/DatabaseModels/Index.ts +4 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveFeed.ts +2 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.ts +522 -0
- package/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.ts +38 -4
- package/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.ts +603 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.ts +248 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.ts +19 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/DatabaseService.ts +10 -0
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/MonitorService.ts +3 -2
- package/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.ts +385 -0
- package/Server/Services/ServiceLevelObjectiveLabelRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +92 -16
- package/Server/Services/ServiceLevelObjectiveMonitorRuleService.ts +36 -1
- package/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.ts +521 -0
- package/Server/Services/ServiceLevelObjectiveOwnerRuleService.ts +51 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +28 -0
- package/Server/Types/Workflow/Components/Telegram/SendMessageToChat.ts +27 -1
- package/Server/Utils/AI/Toolbox/SloTools.ts +1 -0
- package/Server/Utils/Monitor/AffectedResourceList.ts +31 -73
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +1 -1
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +1 -1
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +181 -136
- package/Server/Utils/Monitor/RootCauseList.ts +139 -0
- package/Server/Utils/Rules/RuleRun/RuleRunRegistry.ts +28 -0
- package/Server/Utils/Slo/SloRulePatternValidator.ts +42 -0
- package/Tests/App/Dashboard/FeedFilterSortIntegration.test.tsx +1632 -0
- package/Tests/App/Dashboard/FeedOptionsLocales.test.ts +250 -0
- package/Tests/App/Dashboard/FeedOrdering.test.ts +279 -9
- package/Tests/App/Dashboard/InvestigationFeedRefresh.test.tsx +60 -1
- package/Tests/App/Dashboard/InvestigationPanel.test.tsx +594 -3
- package/Tests/App/Dashboard/ProductOrientation.test.tsx +646 -0
- package/Tests/App/Dashboard/ResourceFeed.test.tsx +612 -2
- package/Tests/App/Dashboard/ResourceFeedPageWiring.test.ts +30 -0
- package/Tests/App/Dashboard/ResourceFeedPagesFilterSort.test.tsx +1202 -0
- package/Tests/App/Dashboard/ScheduledMaintenanceFeedRefresh.test.tsx +52 -1
- package/Tests/App/Dashboard/SloFeed.test.tsx +106 -1
- package/Tests/App/Dashboard/SloMonitorRuleCriteria.test.tsx +566 -0
- package/Tests/App/Dashboard/SloNavigationWiring.test.tsx +6 -4
- package/Tests/App/Dashboard/SloNoticeBanner.test.tsx +186 -0
- package/Tests/App/Dashboard/SloOverviewKpiStrip.test.tsx +388 -44
- package/Tests/App/Dashboard/SloOverviewLayout.test.tsx +613 -0
- package/Tests/Models/DatabaseModels/ResourceFeedModels.test.ts +0 -8
- package/Tests/Models/DatabaseModels/SloLabelAndOwnerRuleModels.test.ts +404 -0
- package/Tests/Models/DatabaseModels/SloOverhaulModels.test.ts +17 -1
- package/Tests/Models/DatabaseModels/StaticRuleCriteriaInheritance.test.ts +4 -2
- package/Tests/Server/Infrastructure/Postgres/AddConfigurableRuleCriteriaMigration.test.ts +3 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloLabelAndOwnerRulesMigration.test.ts +407 -0
- package/Tests/Server/Infrastructure/Postgres/AddSloMonitorRuleMonitorTypeMigration.test.ts +171 -0
- package/Tests/Server/Infrastructure/Postgres/SloProductOverhaulMigration.test.ts +88 -4
- package/Tests/Server/Services/DatabaseServiceRuleCriteriaValidation.test.ts +104 -0
- package/Tests/Server/Services/RuleRun/LabelRuleEnginesGroupBRun.test.ts +48 -1
- package/Tests/Server/Services/RuleRun/OwnerRuleEnginesGroupBRun.test.ts +30 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleEngines.test.ts +1170 -0
- package/Tests/Server/Services/ServiceLevelObjectiveLabelAndOwnerRuleServices.test.ts +172 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +323 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleService.test.ts +115 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleTriggers.test.ts +21 -2
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorTypePostgres.test.ts +480 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +162 -0
- package/Tests/Server/Services/StaticRuleCriteriaRuntimeCoverage.test.ts +45 -6
- package/Tests/Server/Types/Database/FeedEventTypeFilterQuery.test.ts +745 -0
- package/Tests/Server/Types/Markdown.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/ApiComponentSsrf.test.ts +49 -0
- package/Tests/Server/Types/Workflow/Components/TelegramSendMessageToChat.test.ts +331 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +10 -0
- package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +405 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorBreachingSamples.test.ts +1101 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +106 -80
- package/Tests/Server/Utils/Monitor/RootCauseList.test.ts +252 -0
- package/Tests/Server/Utils/Rules/RuleRun/RuleRunPermission.test.ts +154 -0
- package/Tests/Server/Utils/SSRFProtection.test.ts +111 -0
- package/Tests/Server/Utils/Slo/SloRulePatternValidator.test.ts +67 -0
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +213 -0
- package/Tests/Types/API/Route.test.ts +56 -0
- package/Tests/Types/API/URLSchemeLikePath.test.ts +304 -0
- package/Tests/UI/Components/FeedOptions.test.ts +1404 -0
- package/Tests/UI/Components/FeedOptionsButton.test.tsx +2603 -0
- package/Tests/UI/Components/Icon.test.tsx +26 -0
- package/Tests/UI/Components/RuleCriteria/RuleCriteriaModelTable.test.tsx +3 -3
- package/Tests/UI/Components/useFeedItemsViewKey.test.tsx +1017 -0
- package/Tests/UI/Components/useFeedOptions.test.tsx +1107 -0
- package/Tests/Utils/LabelRuleImportExport.test.ts +102 -0
- package/Tests/Utils/Slo/SloDuration.test.ts +115 -24
- package/Tests/Utils/Slo/SloHealth.test.ts +93 -10
- package/Tests/Utils/Slo/SloMonitorRuleCriteria.test.ts +109 -0
- package/Tests/Utils/Slo/SloOverviewText.test.ts +64 -0
- package/Types/API/Route.ts +19 -2
- package/Types/API/URL.ts +26 -2
- package/Types/Monitor/MetricMonitor/MetricCriteriaContext.ts +6 -5
- package/Types/Monitor/MetricMonitor/MetricMonitorResponse.ts +9 -0
- package/Types/Permission.ts +90 -0
- package/Types/Rules/RuleCriteriaFieldRegistry.ts +11 -0
- package/Types/Rules/RuleRun.ts +4 -0
- package/UI/Components/Feed/FeedOptions.ts +427 -0
- package/UI/Components/Feed/FeedOptionsButton.tsx +692 -0
- package/UI/Components/Feed/useFeedItems.ts +28 -9
- package/UI/Components/Feed/useFeedOptions.ts +225 -0
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/Utils/LabelRuleImportExport.ts +27 -9
- package/Utils/Monitor/MetricValueFormatter.ts +2 -2
- package/Utils/Rules/MonitorTypeRuleCriteria.ts +31 -0
- package/Utils/Slo/SloDuration.ts +22 -6
- package/Utils/Slo/SloHealth.ts +8 -7
- package/Utils/Slo/SloMonitorRuleCriteria.ts +33 -1
- package/Utils/Slo/SloOverviewText.ts +33 -0
- package/Utils/Slo/SloProjection.ts +4 -3
- package/build/dist/Models/DatabaseModels/Index.js +4 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js +530 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveLabelRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js +39 -4
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveMonitorRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js +612 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjectiveOwnerRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794100000000-AddSloLabelAndOwnerRules.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794200000000-AddSloMonitorRuleMonitorType.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +7 -0
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +3 -2
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js +295 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveLabelRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +59 -14
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js +22 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js +400 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js +56 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveOwnerRuleService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +21 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js +20 -1
- package/build/dist/Server/Types/Workflow/Components/Telegram/SendMessageToChat.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js +22 -51
- package/build/dist/Server/Utils/Monitor/AffectedResourceList.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +139 -109
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/RootCauseList.js +101 -0
- package/build/dist/Server/Utils/Monitor/RootCauseList.js.map +1 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js +28 -0
- package/build/dist/Server/Utils/Rules/RuleRun/RuleRunRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js +28 -0
- package/build/dist/Server/Utils/Slo/SloRulePatternValidator.js.map +1 -0
- package/build/dist/Types/API/Route.js +18 -2
- package/build/dist/Types/API/Route.js.map +1 -1
- package/build/dist/Types/API/URL.js +23 -2
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Permission.js +84 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js +11 -0
- package/build/dist/Types/Rules/RuleCriteriaFieldRegistry.js.map +1 -1
- package/build/dist/Types/Rules/RuleRun.js +4 -0
- package/build/dist/Types/Rules/RuleRun.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedOptions.js +235 -0
- package/build/dist/UI/Components/Feed/FeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js +346 -0
- package/build/dist/UI/Components/Feed/FeedOptionsButton.js.map +1 -0
- package/build/dist/UI/Components/Feed/useFeedItems.js +17 -7
- package/build/dist/UI/Components/Feed/useFeedItems.js.map +1 -1
- package/build/dist/UI/Components/Feed/useFeedOptions.js +130 -0
- package/build/dist/UI/Components/Feed/useFeedOptions.js.map +1 -0
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/Utils/LabelRuleImportExport.js +22 -6
- package/build/dist/Utils/LabelRuleImportExport.js.map +1 -1
- package/build/dist/Utils/Monitor/MetricValueFormatter.js +2 -2
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js +18 -0
- package/build/dist/Utils/Rules/MonitorTypeRuleCriteria.js.map +1 -0
- package/build/dist/Utils/Slo/SloDuration.js +13 -5
- package/build/dist/Utils/Slo/SloDuration.js.map +1 -1
- package/build/dist/Utils/Slo/SloHealth.js +8 -7
- package/build/dist/Utils/Slo/SloHealth.js.map +1 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js +16 -1
- package/build/dist/Utils/Slo/SloMonitorRuleCriteria.js.map +1 -1
- package/build/dist/Utils/Slo/SloOverviewText.js +16 -0
- package/build/dist/Utils/Slo/SloOverviewText.js.map +1 -1
- package/build/dist/Utils/Slo/SloProjection.js.map +1 -1
- 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
|
+
});
|