@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,1632 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
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
|
+
waitFor,
|
|
18
|
+
within,
|
|
19
|
+
} from "@testing-library/react";
|
|
20
|
+
import * as React from "react";
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Every dashboard activity feed carries one "Filter & Sort" button, and the
|
|
24
|
+
* unit tests pin its pieces on their own: the rules in FeedOptions, the
|
|
25
|
+
* panel in FeedOptionsButton, the hook that remembers the sort order and the
|
|
26
|
+
* viewKey contract of useFeedItems. None of them can see a product feed
|
|
27
|
+
* wiring the pieces together wrongly - a typo in the event type column, a
|
|
28
|
+
* storage key shared with another feed, a query that forgets the resource,
|
|
29
|
+
* a checklist whose icons disagree with the feed's own items, a resetKey
|
|
30
|
+
* that does not follow the resource the feed reads.
|
|
31
|
+
*
|
|
32
|
+
* So these tests render each REAL product feed with the REAL button and
|
|
33
|
+
* assert the exact requests it sends. Only the network (ModelAPI), the
|
|
34
|
+
* markdown-rendering FeedItem, Icon (so an icon can be read back by name)
|
|
35
|
+
* and the note / runbook modals are stubbed. Every stub the hoisted
|
|
36
|
+
* jest.mock factories close over carries the "mock" prefix jest requires.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
interface FeedListRequest {
|
|
40
|
+
modelType: unknown;
|
|
41
|
+
query: Record<string, unknown>;
|
|
42
|
+
select: Record<string, unknown>;
|
|
43
|
+
skip: number;
|
|
44
|
+
limit: number;
|
|
45
|
+
sort: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface FeedPageResponse {
|
|
49
|
+
data: Array<unknown>;
|
|
50
|
+
count: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type FeedResponder = (request: FeedListRequest) => Promise<FeedPageResponse>;
|
|
54
|
+
|
|
55
|
+
const mockGetListCalls: Array<FeedListRequest> = [];
|
|
56
|
+
|
|
57
|
+
// Every text a feed item was ever rendered with, so "never painted" is checkable.
|
|
58
|
+
const mockPaintedItemTexts: Array<string> = [];
|
|
59
|
+
|
|
60
|
+
const respondWithEmptyFeed: FeedResponder = (): Promise<FeedPageResponse> => {
|
|
61
|
+
return Promise.resolve({ data: [], count: 0 });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let mockGetListResponse: FeedResponder = respondWithEmptyFeed;
|
|
65
|
+
|
|
66
|
+
jest.mock("react-i18next", () => {
|
|
67
|
+
return {
|
|
68
|
+
useTranslation: () => {
|
|
69
|
+
return {
|
|
70
|
+
t: (key: string, options?: { defaultValue?: string }): string => {
|
|
71
|
+
return options?.defaultValue ?? key;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
79
|
+
return {
|
|
80
|
+
__esModule: true,
|
|
81
|
+
default: {
|
|
82
|
+
getList: (request: FeedListRequest): Promise<FeedPageResponse> => {
|
|
83
|
+
mockGetListCalls.push(request);
|
|
84
|
+
return mockGetListResponse(request);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
* The real Feed stays - its More button and empty-feed message are part of
|
|
92
|
+
* what is under test. Only the item, which renders markdown, is replaced by
|
|
93
|
+
* one that shows its text and names its icon.
|
|
94
|
+
*/
|
|
95
|
+
jest.mock("../../../UI/Components/Feed/FeedItem", () => {
|
|
96
|
+
return {
|
|
97
|
+
__esModule: true,
|
|
98
|
+
default: (props: {
|
|
99
|
+
textInMarkdown: string;
|
|
100
|
+
icon: string;
|
|
101
|
+
}): React.ReactElement => {
|
|
102
|
+
mockPaintedItemTexts.push(props.textInMarkdown);
|
|
103
|
+
return React.createElement(
|
|
104
|
+
"li",
|
|
105
|
+
{ "data-testid": "feed-item", "data-icon": props.icon },
|
|
106
|
+
props.textInMarkdown,
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
* An SVG cannot be read back as an IconProp, so Icon renders the name it was
|
|
114
|
+
* given. The module's enums (SizeProp, ThickProp, IconType) stay real.
|
|
115
|
+
*/
|
|
116
|
+
jest.mock("../../../UI/Components/Icon/Icon", () => {
|
|
117
|
+
const actualIconModule: Record<string, unknown> = jest.requireActual(
|
|
118
|
+
"../../../UI/Components/Icon/Icon",
|
|
119
|
+
) as Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...actualIconModule,
|
|
123
|
+
__esModule: true,
|
|
124
|
+
default: (props: { icon: string }): React.ReactElement => {
|
|
125
|
+
return React.createElement("span", {
|
|
126
|
+
"data-testid": "mock-icon",
|
|
127
|
+
"data-icon": props.icon,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
jest.mock("../../../UI/Components/ModelFormModal/ModelFormModal", () => {
|
|
134
|
+
return {
|
|
135
|
+
__esModule: true,
|
|
136
|
+
default: (): null => {
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
jest.mock(
|
|
143
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Runbook/RunbookPicker",
|
|
144
|
+
() => {
|
|
145
|
+
return {
|
|
146
|
+
__esModule: true,
|
|
147
|
+
default: (): null => {
|
|
148
|
+
return null;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
import MonitorFeedElement, {
|
|
155
|
+
getMonitorFeedEventIcon,
|
|
156
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/Monitor/MonitorFeed";
|
|
157
|
+
import IncidentFeedElement, {
|
|
158
|
+
getIncidentFeedEventIcon,
|
|
159
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/Incident/IncidentFeed";
|
|
160
|
+
import AlertFeedElement, {
|
|
161
|
+
getAlertFeedEventIcon,
|
|
162
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/Alert/AlertFeed";
|
|
163
|
+
import IncidentEpisodeFeedElement, {
|
|
164
|
+
getIncidentEpisodeFeedEventIcon,
|
|
165
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/IncidentEpisode/IncidentEpisodeFeed";
|
|
166
|
+
import AlertEpisodeFeedElement, {
|
|
167
|
+
getAlertEpisodeFeedEventIcon,
|
|
168
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/AlertEpisode/AlertEpisodeFeed";
|
|
169
|
+
import ScheduledMaintenanceFeedElement, {
|
|
170
|
+
getScheduledMaintenanceFeedEventIcon,
|
|
171
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/ScheduledMaintenance/ScheduledMaintenanceFeed";
|
|
172
|
+
import OnCallDutyPolicyFeedElement, {
|
|
173
|
+
getOnCallDutyPolicyFeedEventIcon,
|
|
174
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/OnCallDutyPolicyFeed";
|
|
175
|
+
import DatabaseBaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
176
|
+
import MonitorFeed, {
|
|
177
|
+
MonitorFeedEventType,
|
|
178
|
+
} from "../../../Models/DatabaseModels/MonitorFeed";
|
|
179
|
+
import IncidentFeed, {
|
|
180
|
+
IncidentFeedEventType,
|
|
181
|
+
} from "../../../Models/DatabaseModels/IncidentFeed";
|
|
182
|
+
import AlertFeed, {
|
|
183
|
+
AlertFeedEventType,
|
|
184
|
+
} from "../../../Models/DatabaseModels/AlertFeed";
|
|
185
|
+
import IncidentEpisodeFeed, {
|
|
186
|
+
IncidentEpisodeFeedEventType,
|
|
187
|
+
} from "../../../Models/DatabaseModels/IncidentEpisodeFeed";
|
|
188
|
+
import AlertEpisodeFeed, {
|
|
189
|
+
AlertEpisodeFeedEventType,
|
|
190
|
+
} from "../../../Models/DatabaseModels/AlertEpisodeFeed";
|
|
191
|
+
import ScheduledMaintenanceFeed, {
|
|
192
|
+
ScheduledMaintenanceFeedEventType,
|
|
193
|
+
} from "../../../Models/DatabaseModels/ScheduledMaintenanceFeed";
|
|
194
|
+
import OnCallDutyPolicyFeed, {
|
|
195
|
+
OnCallDutyPolicyFeedEventType,
|
|
196
|
+
} from "../../../Models/DatabaseModels/OnCallDutyPolicyFeed";
|
|
197
|
+
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
198
|
+
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
199
|
+
import { DEFAULT_LIMIT } from "../../../Types/Database/LimitMax";
|
|
200
|
+
import IconProp from "../../../Types/Icon/IconProp";
|
|
201
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
202
|
+
import {
|
|
203
|
+
FILTERED_FEED_NO_ITEMS_MESSAGE,
|
|
204
|
+
getFeedEventTypeLabel,
|
|
205
|
+
} from "../../../UI/Components/Feed/FeedOptions";
|
|
206
|
+
import { getSortOrderStorageKey } from "../../../UI/Components/Feed/useFeedOptions";
|
|
207
|
+
import LocalStorage from "../../../UI/Utils/LocalStorage";
|
|
208
|
+
|
|
209
|
+
type FeedModelClass = new () => DatabaseBaseModel;
|
|
210
|
+
|
|
211
|
+
interface FeedCase {
|
|
212
|
+
name: string;
|
|
213
|
+
render: (resourceId: ObjectID) => React.ReactElement;
|
|
214
|
+
modelType: FeedModelClass;
|
|
215
|
+
resourceId: ObjectID;
|
|
216
|
+
// The query key naming the resource the feed belongs to.
|
|
217
|
+
resourceKey: string;
|
|
218
|
+
// The feed model's event type column, which the filter is keyed by.
|
|
219
|
+
eventTypeColumn: string;
|
|
220
|
+
enumValues: Array<string>;
|
|
221
|
+
// Names the feed kind for the remembered sort order.
|
|
222
|
+
storageKey: string;
|
|
223
|
+
iconFn: (eventType: string) => IconProp;
|
|
224
|
+
// What the feed says when the resource has no activity at all.
|
|
225
|
+
noItemsMessage: string;
|
|
226
|
+
hasActionsMenu: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/*
|
|
230
|
+
* The seven feeds with their own component. ResourceFeed, which backs the
|
|
231
|
+
* infrastructure and service feed pages, has its own suite.
|
|
232
|
+
*/
|
|
233
|
+
const FEED_CASES: Array<FeedCase> = [
|
|
234
|
+
{
|
|
235
|
+
name: "MonitorFeed",
|
|
236
|
+
render: (resourceId: ObjectID): React.ReactElement => {
|
|
237
|
+
return <MonitorFeedElement monitorId={resourceId} />;
|
|
238
|
+
},
|
|
239
|
+
modelType: MonitorFeed,
|
|
240
|
+
resourceId: new ObjectID("11111111-1111-4111-8111-111111111111"),
|
|
241
|
+
resourceKey: "monitorId",
|
|
242
|
+
eventTypeColumn: "monitorFeedEventType",
|
|
243
|
+
enumValues: Object.values(MonitorFeedEventType),
|
|
244
|
+
storageKey: "monitor",
|
|
245
|
+
iconFn: getMonitorFeedEventIcon,
|
|
246
|
+
noItemsMessage:
|
|
247
|
+
"Looks like there are no items in this feed for this monitor.",
|
|
248
|
+
hasActionsMenu: false,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "IncidentFeed",
|
|
252
|
+
render: (resourceId: ObjectID): React.ReactElement => {
|
|
253
|
+
return <IncidentFeedElement incidentId={resourceId} />;
|
|
254
|
+
},
|
|
255
|
+
modelType: IncidentFeed,
|
|
256
|
+
resourceId: new ObjectID("22222222-2222-4222-8222-222222222222"),
|
|
257
|
+
resourceKey: "incidentId",
|
|
258
|
+
eventTypeColumn: "incidentFeedEventType",
|
|
259
|
+
enumValues: Object.values(IncidentFeedEventType),
|
|
260
|
+
storageKey: "incident",
|
|
261
|
+
iconFn: getIncidentFeedEventIcon,
|
|
262
|
+
noItemsMessage:
|
|
263
|
+
"Looks like there are no items in this feed for this incident.",
|
|
264
|
+
hasActionsMenu: true,
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "AlertFeed",
|
|
268
|
+
render: (resourceId: ObjectID): React.ReactElement => {
|
|
269
|
+
return <AlertFeedElement alertId={resourceId} />;
|
|
270
|
+
},
|
|
271
|
+
modelType: AlertFeed,
|
|
272
|
+
resourceId: new ObjectID("33333333-3333-4333-8333-333333333333"),
|
|
273
|
+
resourceKey: "alertId",
|
|
274
|
+
eventTypeColumn: "alertFeedEventType",
|
|
275
|
+
enumValues: Object.values(AlertFeedEventType),
|
|
276
|
+
storageKey: "alert",
|
|
277
|
+
iconFn: getAlertFeedEventIcon,
|
|
278
|
+
noItemsMessage:
|
|
279
|
+
"Looks like there are no items in this feed for this alert.",
|
|
280
|
+
hasActionsMenu: true,
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: "IncidentEpisodeFeed",
|
|
284
|
+
render: (resourceId: ObjectID): React.ReactElement => {
|
|
285
|
+
return <IncidentEpisodeFeedElement incidentEpisodeId={resourceId} />;
|
|
286
|
+
},
|
|
287
|
+
modelType: IncidentEpisodeFeed,
|
|
288
|
+
resourceId: new ObjectID("44444444-4444-4444-8444-444444444444"),
|
|
289
|
+
resourceKey: "incidentEpisodeId",
|
|
290
|
+
eventTypeColumn: "incidentEpisodeFeedEventType",
|
|
291
|
+
enumValues: Object.values(IncidentEpisodeFeedEventType),
|
|
292
|
+
storageKey: "incident-episode",
|
|
293
|
+
iconFn: getIncidentEpisodeFeedEventIcon,
|
|
294
|
+
noItemsMessage:
|
|
295
|
+
"Looks like there are no items in this feed for this episode.",
|
|
296
|
+
hasActionsMenu: true,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "AlertEpisodeFeed",
|
|
300
|
+
render: (resourceId: ObjectID): React.ReactElement => {
|
|
301
|
+
return <AlertEpisodeFeedElement alertEpisodeId={resourceId} />;
|
|
302
|
+
},
|
|
303
|
+
modelType: AlertEpisodeFeed,
|
|
304
|
+
resourceId: new ObjectID("55555555-5555-4555-8555-555555555555"),
|
|
305
|
+
resourceKey: "alertEpisodeId",
|
|
306
|
+
eventTypeColumn: "alertEpisodeFeedEventType",
|
|
307
|
+
enumValues: Object.values(AlertEpisodeFeedEventType),
|
|
308
|
+
storageKey: "alert-episode",
|
|
309
|
+
iconFn: getAlertEpisodeFeedEventIcon,
|
|
310
|
+
noItemsMessage:
|
|
311
|
+
"Looks like there are no items in this feed for this episode.",
|
|
312
|
+
hasActionsMenu: true,
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: "ScheduledMaintenanceFeed",
|
|
316
|
+
render: (resourceId: ObjectID): React.ReactElement => {
|
|
317
|
+
return (
|
|
318
|
+
<ScheduledMaintenanceFeedElement scheduledMaintenanceId={resourceId} />
|
|
319
|
+
);
|
|
320
|
+
},
|
|
321
|
+
modelType: ScheduledMaintenanceFeed,
|
|
322
|
+
resourceId: new ObjectID("66666666-6666-4666-8666-666666666666"),
|
|
323
|
+
resourceKey: "scheduledMaintenanceId",
|
|
324
|
+
eventTypeColumn: "scheduledMaintenanceFeedEventType",
|
|
325
|
+
enumValues: Object.values(ScheduledMaintenanceFeedEventType),
|
|
326
|
+
storageKey: "scheduled-maintenance",
|
|
327
|
+
iconFn: getScheduledMaintenanceFeedEventIcon,
|
|
328
|
+
noItemsMessage:
|
|
329
|
+
"Looks like there are no items in this feed for this scheduled maintenance.",
|
|
330
|
+
hasActionsMenu: true,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "OnCallDutyPolicyFeed",
|
|
334
|
+
render: (resourceId: ObjectID): React.ReactElement => {
|
|
335
|
+
return <OnCallDutyPolicyFeedElement onCallDutyPolicyId={resourceId} />;
|
|
336
|
+
},
|
|
337
|
+
modelType: OnCallDutyPolicyFeed,
|
|
338
|
+
resourceId: new ObjectID("77777777-7777-4777-8777-777777777777"),
|
|
339
|
+
resourceKey: "onCallDutyPolicyId",
|
|
340
|
+
eventTypeColumn: "onCallDutyPolicyFeedEventType",
|
|
341
|
+
enumValues: Object.values(OnCallDutyPolicyFeedEventType),
|
|
342
|
+
storageKey: "on-call-policy",
|
|
343
|
+
iconFn: getOnCallDutyPolicyFeedEventIcon,
|
|
344
|
+
noItemsMessage:
|
|
345
|
+
"Looks like there are no items in this feed for this onCallDutyPolicy.",
|
|
346
|
+
hasActionsMenu: false,
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
// Enough rows on the server that More stays available after one click.
|
|
351
|
+
const SERVER_ROW_COUNT: number = 25;
|
|
352
|
+
|
|
353
|
+
/*
|
|
354
|
+
* The trigger's accessible name is its label alone; what the reader chose
|
|
355
|
+
* (order and filter) is its description. So the name never changes.
|
|
356
|
+
*/
|
|
357
|
+
const TRIGGER_NAME: string = "Filter & Sort";
|
|
358
|
+
|
|
359
|
+
interface PendingRequest {
|
|
360
|
+
request: FeedListRequest;
|
|
361
|
+
resolve: (page: FeedPageResponse) => void;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
type Flush = () => Promise<void>;
|
|
365
|
+
|
|
366
|
+
const flush: Flush = async (): Promise<void> => {
|
|
367
|
+
await act(async (): Promise<void> => {
|
|
368
|
+
for (let i: number = 0; i < 10; i++) {
|
|
369
|
+
await Promise.resolve();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
type RenderFeedCase = (feedCase: FeedCase) => RenderResult;
|
|
375
|
+
|
|
376
|
+
const renderFeedCase: RenderFeedCase = (feedCase: FeedCase): RenderResult => {
|
|
377
|
+
return render(feedCase.render(feedCase.resourceId));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/*
|
|
381
|
+
* Waits for the request count to reach exactly `count`, then lets every
|
|
382
|
+
* settled response land and checks nothing else was asked for: one reader
|
|
383
|
+
* action must cost exactly one request. Responses are let land inside act
|
|
384
|
+
* first, so none of them settles in the gap after waitFor returns.
|
|
385
|
+
*/
|
|
386
|
+
type WaitForRequestCount = (count: number) => Promise<void>;
|
|
387
|
+
|
|
388
|
+
const waitForRequestCount: WaitForRequestCount = async (
|
|
389
|
+
count: number,
|
|
390
|
+
): Promise<void> => {
|
|
391
|
+
await flush();
|
|
392
|
+
await waitFor((): void => {
|
|
393
|
+
expect(mockGetListCalls).toHaveLength(count);
|
|
394
|
+
});
|
|
395
|
+
await flush();
|
|
396
|
+
expect(mockGetListCalls).toHaveLength(count);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
type GetRequest = (index: number) => FeedListRequest;
|
|
400
|
+
|
|
401
|
+
const getRequest: GetRequest = (index: number): FeedListRequest => {
|
|
402
|
+
const request: FeedListRequest | undefined = mockGetListCalls[index];
|
|
403
|
+
|
|
404
|
+
if (!request) {
|
|
405
|
+
throw new Error(`No request #${index} was made.`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return request;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
type CreateFeedRow = (
|
|
412
|
+
feedCase: FeedCase,
|
|
413
|
+
eventType: string,
|
|
414
|
+
text: string,
|
|
415
|
+
postedAt: Date,
|
|
416
|
+
) => DatabaseBaseModel;
|
|
417
|
+
|
|
418
|
+
const createFeedRow: CreateFeedRow = (
|
|
419
|
+
feedCase: FeedCase,
|
|
420
|
+
eventType: string,
|
|
421
|
+
text: string,
|
|
422
|
+
postedAt: Date,
|
|
423
|
+
): DatabaseBaseModel => {
|
|
424
|
+
const row: DatabaseBaseModel = new feedCase.modelType();
|
|
425
|
+
row.id = ObjectID.generate();
|
|
426
|
+
row.setColumnValue(feedCase.eventTypeColumn, eventType);
|
|
427
|
+
row.setColumnValue("feedInfoInMarkdown", text);
|
|
428
|
+
row.setColumnValue("postedAt", postedAt);
|
|
429
|
+
row.setColumnValue("createdAt", postedAt);
|
|
430
|
+
|
|
431
|
+
// setColumnValue silently ignores a name that is not a column.
|
|
432
|
+
if (row.getColumnValue(feedCase.eventTypeColumn) !== eventType) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`${feedCase.eventTypeColumn} is not a column of ${feedCase.name}.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return row;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
type GetRequestedEventTypes = (
|
|
442
|
+
feedCase: FeedCase,
|
|
443
|
+
request: FeedListRequest,
|
|
444
|
+
) => Array<string> | undefined;
|
|
445
|
+
|
|
446
|
+
const getRequestedEventTypes: GetRequestedEventTypes = (
|
|
447
|
+
feedCase: FeedCase,
|
|
448
|
+
request: FeedListRequest,
|
|
449
|
+
): Array<string> | undefined => {
|
|
450
|
+
const filter: unknown = request.query[feedCase.eventTypeColumn];
|
|
451
|
+
|
|
452
|
+
return filter instanceof Includes
|
|
453
|
+
? (filter.values as Array<string>)
|
|
454
|
+
: undefined;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/*
|
|
458
|
+
* Names the view a request asked for - "DESC:all", "ASC:PublicNote" - so
|
|
459
|
+
* every painted row says which request it answered.
|
|
460
|
+
*/
|
|
461
|
+
type GetViewName = (feedCase: FeedCase, request: FeedListRequest) => string;
|
|
462
|
+
|
|
463
|
+
const getViewName: GetViewName = (
|
|
464
|
+
feedCase: FeedCase,
|
|
465
|
+
request: FeedListRequest,
|
|
466
|
+
): string => {
|
|
467
|
+
const eventTypes: Array<string> | undefined = getRequestedEventTypes(
|
|
468
|
+
feedCase,
|
|
469
|
+
request,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
return `${String(request.sort["postedAt"])}:${
|
|
473
|
+
eventTypes ? eventTypes.join("+") : "all"
|
|
474
|
+
}`;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
type GetViewTexts = (viewName: string, count: number) => Array<string>;
|
|
478
|
+
|
|
479
|
+
const getViewTexts: GetViewTexts = (
|
|
480
|
+
viewName: string,
|
|
481
|
+
count: number,
|
|
482
|
+
): Array<string> => {
|
|
483
|
+
return Array.from({ length: count }, (_value: unknown, index: number) => {
|
|
484
|
+
return `${viewName}#${index + 1}`;
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// The page a server holding `total` rows per view returns for a request.
|
|
489
|
+
type BuildPage = (
|
|
490
|
+
feedCase: FeedCase,
|
|
491
|
+
request: FeedListRequest,
|
|
492
|
+
total: number,
|
|
493
|
+
) => FeedPageResponse;
|
|
494
|
+
|
|
495
|
+
const buildPage: BuildPage = (
|
|
496
|
+
feedCase: FeedCase,
|
|
497
|
+
request: FeedListRequest,
|
|
498
|
+
total: number,
|
|
499
|
+
): FeedPageResponse => {
|
|
500
|
+
const viewName: string = getViewName(feedCase, request);
|
|
501
|
+
const eventTypes: Array<string> =
|
|
502
|
+
getRequestedEventTypes(feedCase, request) || feedCase.enumValues;
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
data: getViewTexts(viewName, Math.min(request.limit, total)).map(
|
|
506
|
+
(text: string, index: number): DatabaseBaseModel => {
|
|
507
|
+
return createFeedRow(
|
|
508
|
+
feedCase,
|
|
509
|
+
eventTypes[index % eventTypes.length]!,
|
|
510
|
+
text,
|
|
511
|
+
new Date(Date.UTC(2026, 8, 1, 0, index)),
|
|
512
|
+
);
|
|
513
|
+
},
|
|
514
|
+
),
|
|
515
|
+
count: total,
|
|
516
|
+
};
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
type ServeFeed = (feedCase: FeedCase, total: number) => FeedResponder;
|
|
520
|
+
|
|
521
|
+
const serveFeed: ServeFeed = (
|
|
522
|
+
feedCase: FeedCase,
|
|
523
|
+
total: number,
|
|
524
|
+
): FeedResponder => {
|
|
525
|
+
return (request: FeedListRequest): Promise<FeedPageResponse> => {
|
|
526
|
+
return Promise.resolve(buildPage(feedCase, request, total));
|
|
527
|
+
};
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// Holds every request open until the test answers it.
|
|
531
|
+
type HoldRequests = (pending: Array<PendingRequest>) => FeedResponder;
|
|
532
|
+
|
|
533
|
+
const holdRequests: HoldRequests = (
|
|
534
|
+
pending: Array<PendingRequest>,
|
|
535
|
+
): FeedResponder => {
|
|
536
|
+
return (request: FeedListRequest): Promise<FeedPageResponse> => {
|
|
537
|
+
return new Promise<FeedPageResponse>(
|
|
538
|
+
(resolve: (page: FeedPageResponse) => void): void => {
|
|
539
|
+
pending.push({ request, resolve });
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
type AnswerRequest = (
|
|
546
|
+
pending: PendingRequest | undefined,
|
|
547
|
+
page: FeedPageResponse,
|
|
548
|
+
) => Promise<void>;
|
|
549
|
+
|
|
550
|
+
const answerRequest: AnswerRequest = async (
|
|
551
|
+
pending: PendingRequest | undefined,
|
|
552
|
+
page: FeedPageResponse,
|
|
553
|
+
): Promise<void> => {
|
|
554
|
+
if (!pending) {
|
|
555
|
+
throw new Error("That request was never made.");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
await act(async (): Promise<void> => {
|
|
559
|
+
pending.resolve(page);
|
|
560
|
+
for (let i: number = 0; i < 10; i++) {
|
|
561
|
+
await Promise.resolve();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
type GetRenderedItemTexts = () => Array<string>;
|
|
567
|
+
|
|
568
|
+
const getRenderedItemTexts: GetRenderedItemTexts = (): Array<string> => {
|
|
569
|
+
return screen.queryAllByTestId("feed-item").map((item: HTMLElement) => {
|
|
570
|
+
return item.textContent || "";
|
|
571
|
+
});
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
type GetTrigger = () => HTMLElement;
|
|
575
|
+
|
|
576
|
+
const getTrigger: GetTrigger = (): HTMLElement => {
|
|
577
|
+
return screen.getByTestId("feed-options-button");
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
/*
|
|
581
|
+
* Checks the trigger says what the feed shows the way a screen reader hears
|
|
582
|
+
* it: named by its label - found by that name, the badge's count left out -
|
|
583
|
+
* and described by the summary, which is also its tooltip. A name that
|
|
584
|
+
* carried the summary would be read twice, once as name and once as
|
|
585
|
+
* description.
|
|
586
|
+
*/
|
|
587
|
+
type ExpectTriggerSummary = (summary: string) => void;
|
|
588
|
+
|
|
589
|
+
const expectTriggerSummary: ExpectTriggerSummary = (summary: string): void => {
|
|
590
|
+
const trigger: HTMLElement = getTrigger();
|
|
591
|
+
|
|
592
|
+
expect(screen.getByRole("button", { name: TRIGGER_NAME })).toBe(trigger);
|
|
593
|
+
expect(trigger).toHaveAccessibleName(TRIGGER_NAME);
|
|
594
|
+
expect(trigger).toHaveAccessibleDescription(summary);
|
|
595
|
+
expect(trigger).toHaveAttribute("title", summary);
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
/*
|
|
599
|
+
* The trigger's leading glyph, read back through the Icon mock. The trailing
|
|
600
|
+
* chevron comes after it.
|
|
601
|
+
*/
|
|
602
|
+
type GetTriggerIcon = () => string | null;
|
|
603
|
+
|
|
604
|
+
const getTriggerIcon: GetTriggerIcon = (): string | null => {
|
|
605
|
+
const icons: Array<HTMLElement> =
|
|
606
|
+
within(getTrigger()).getAllByTestId("mock-icon");
|
|
607
|
+
|
|
608
|
+
return icons[0]!.getAttribute("data-icon");
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
type OpenPanel = () => HTMLElement;
|
|
612
|
+
|
|
613
|
+
const openPanel: OpenPanel = (): HTMLElement => {
|
|
614
|
+
if (!screen.queryByTestId("feed-options-panel")) {
|
|
615
|
+
fireEvent.click(getTrigger());
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return screen.getByRole("dialog", { name: "Filter and sort feed" });
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
type GetEventTypeCheckbox = (eventType: string) => HTMLElement;
|
|
622
|
+
|
|
623
|
+
const getEventTypeCheckbox: GetEventTypeCheckbox = (
|
|
624
|
+
eventType: string,
|
|
625
|
+
): HTMLElement => {
|
|
626
|
+
return within(openPanel()).getByTestId(
|
|
627
|
+
`feed-options-event-type-${eventType}`,
|
|
628
|
+
);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
type ToggleEventType = (eventType: string) => void;
|
|
632
|
+
|
|
633
|
+
const toggleEventType: ToggleEventType = (eventType: string): void => {
|
|
634
|
+
fireEvent.click(getEventTypeCheckbox(eventType));
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
type ChooseSortOrder = (label: "Newest first" | "Oldest first") => void;
|
|
638
|
+
|
|
639
|
+
const chooseSortOrder: ChooseSortOrder = (
|
|
640
|
+
label: "Newest first" | "Oldest first",
|
|
641
|
+
): void => {
|
|
642
|
+
fireEvent.click(within(openPanel()).getByRole("radio", { name: label }));
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
type ClickShowAll = () => void;
|
|
646
|
+
|
|
647
|
+
const clickShowAll: ClickShowAll = (): void => {
|
|
648
|
+
fireEvent.click(
|
|
649
|
+
within(openPanel()).getByRole("button", { name: "Show all" }),
|
|
650
|
+
);
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
type ClickMore = () => void;
|
|
654
|
+
|
|
655
|
+
const clickMore: ClickMore = (): void => {
|
|
656
|
+
fireEvent.click(screen.getByRole("button", { name: "More" }));
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
type IsBefore = (first: Element, second: Element) => boolean;
|
|
660
|
+
|
|
661
|
+
const isBefore: IsBefore = (first: Element, second: Element): boolean => {
|
|
662
|
+
return Boolean(
|
|
663
|
+
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
664
|
+
);
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
type ExpectWindow = (
|
|
668
|
+
feedCase: FeedCase,
|
|
669
|
+
request: FeedListRequest,
|
|
670
|
+
sortOrder: SortOrder,
|
|
671
|
+
limit: number,
|
|
672
|
+
) => void;
|
|
673
|
+
|
|
674
|
+
// The paging and ordering half of a request.
|
|
675
|
+
const expectWindow: ExpectWindow = (
|
|
676
|
+
feedCase: FeedCase,
|
|
677
|
+
request: FeedListRequest,
|
|
678
|
+
sortOrder: SortOrder,
|
|
679
|
+
limit: number,
|
|
680
|
+
): void => {
|
|
681
|
+
expect(request.modelType).toBe(feedCase.modelType);
|
|
682
|
+
expect(request.sort).toStrictEqual({ postedAt: sortOrder });
|
|
683
|
+
expect(request.skip).toBe(0);
|
|
684
|
+
expect(request.limit).toBe(limit);
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
type ExpectUnfilteredQuery = (
|
|
688
|
+
feedCase: FeedCase,
|
|
689
|
+
request: FeedListRequest,
|
|
690
|
+
resourceId?: ObjectID,
|
|
691
|
+
) => void;
|
|
692
|
+
|
|
693
|
+
/*
|
|
694
|
+
* Exactly the query the feed sent before it had a filter: the resource
|
|
695
|
+
* alone - the case's own resource unless the feed was moved to another.
|
|
696
|
+
*/
|
|
697
|
+
const expectUnfilteredQuery: ExpectUnfilteredQuery = (
|
|
698
|
+
feedCase: FeedCase,
|
|
699
|
+
request: FeedListRequest,
|
|
700
|
+
resourceId: ObjectID = feedCase.resourceId,
|
|
701
|
+
): void => {
|
|
702
|
+
expect(Object.keys(request.query)).toEqual([feedCase.resourceKey]);
|
|
703
|
+
expect(request.query).toStrictEqual({
|
|
704
|
+
[feedCase.resourceKey]: resourceId,
|
|
705
|
+
});
|
|
706
|
+
expect(request.query[feedCase.resourceKey]).toBe(resourceId);
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
type ExpectFilteredQuery = (
|
|
710
|
+
feedCase: FeedCase,
|
|
711
|
+
request: FeedListRequest,
|
|
712
|
+
eventTypes: Array<string>,
|
|
713
|
+
) => void;
|
|
714
|
+
|
|
715
|
+
const expectFilteredQuery: ExpectFilteredQuery = (
|
|
716
|
+
feedCase: FeedCase,
|
|
717
|
+
request: FeedListRequest,
|
|
718
|
+
eventTypes: Array<string>,
|
|
719
|
+
): void => {
|
|
720
|
+
expect(Object.keys(request.query).sort()).toEqual(
|
|
721
|
+
[feedCase.resourceKey, feedCase.eventTypeColumn].sort(),
|
|
722
|
+
);
|
|
723
|
+
expect(request.query[feedCase.resourceKey]).toBe(feedCase.resourceId);
|
|
724
|
+
|
|
725
|
+
const filter: unknown = request.query[feedCase.eventTypeColumn];
|
|
726
|
+
|
|
727
|
+
expect(filter).toBeInstanceOf(Includes);
|
|
728
|
+
expect((filter as Includes).values).toEqual(eventTypes);
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// Any event type will do where the test is about one being ticked at all.
|
|
732
|
+
type GetSampleEventType = (feedCase: FeedCase) => string;
|
|
733
|
+
|
|
734
|
+
const getSampleEventType: GetSampleEventType = (feedCase: FeedCase): string => {
|
|
735
|
+
return feedCase.enumValues[1]!;
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/*
|
|
739
|
+
* Two event types whose enum order is the reverse of their checklist order
|
|
740
|
+
* (the checklist is alphabetical by label). Ticking the enum-later one first
|
|
741
|
+
* makes click order, checklist order and enum order all disagree, so the
|
|
742
|
+
* request can only come out in enum order if the feed puts it there.
|
|
743
|
+
*/
|
|
744
|
+
type GetOutOfOrderPair = (
|
|
745
|
+
enumValues: Array<string>,
|
|
746
|
+
) => [string, string] | undefined;
|
|
747
|
+
|
|
748
|
+
const getOutOfOrderPair: GetOutOfOrderPair = (
|
|
749
|
+
enumValues: Array<string>,
|
|
750
|
+
): [string, string] | undefined => {
|
|
751
|
+
for (let i: number = 0; i < enumValues.length; i++) {
|
|
752
|
+
for (let j: number = i + 1; j < enumValues.length; j++) {
|
|
753
|
+
const earlier: string = enumValues[i]!;
|
|
754
|
+
const later: string = enumValues[j]!;
|
|
755
|
+
|
|
756
|
+
if (
|
|
757
|
+
getFeedEventTypeLabel(earlier).localeCompare(
|
|
758
|
+
getFeedEventTypeLabel(later),
|
|
759
|
+
) > 0
|
|
760
|
+
) {
|
|
761
|
+
return [earlier, later];
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return undefined;
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
beforeEach(() => {
|
|
770
|
+
mockGetListCalls.length = 0;
|
|
771
|
+
mockPaintedItemTexts.length = 0;
|
|
772
|
+
mockGetListResponse = respondWithEmptyFeed;
|
|
773
|
+
// The chosen sort order is remembered, so one test's choice must not leak.
|
|
774
|
+
window.localStorage.clear();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
afterEach(() => {
|
|
778
|
+
cleanup();
|
|
779
|
+
window.localStorage.clear();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
describe("the feed table", () => {
|
|
783
|
+
test("covers seven feeds, each with its own storage key, resource column and event type column", () => {
|
|
784
|
+
expect(FEED_CASES).toHaveLength(7);
|
|
785
|
+
expect(
|
|
786
|
+
new Set(
|
|
787
|
+
FEED_CASES.map((feedCase: FeedCase): string => {
|
|
788
|
+
return feedCase.storageKey;
|
|
789
|
+
}),
|
|
790
|
+
).size,
|
|
791
|
+
).toBe(FEED_CASES.length);
|
|
792
|
+
|
|
793
|
+
for (const feedCase of FEED_CASES) {
|
|
794
|
+
const row: DatabaseBaseModel = new feedCase.modelType();
|
|
795
|
+
|
|
796
|
+
expect(row.isTableColumn(feedCase.resourceKey)).toBe(true);
|
|
797
|
+
expect(row.isTableColumn(feedCase.eventTypeColumn)).toBe(true);
|
|
798
|
+
expect(feedCase.enumValues.length).toBeGreaterThan(1);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
describe("the first request", () => {
|
|
804
|
+
test.each(FEED_CASES)(
|
|
805
|
+
"$name reads the newest window for its resource, with no event type in the query",
|
|
806
|
+
async (feedCase: FeedCase) => {
|
|
807
|
+
renderFeedCase(feedCase);
|
|
808
|
+
await waitForRequestCount(1);
|
|
809
|
+
|
|
810
|
+
const request: FeedListRequest = getRequest(0);
|
|
811
|
+
|
|
812
|
+
expectWindow(feedCase, request, SortOrder.Descending, DEFAULT_LIMIT);
|
|
813
|
+
expectUnfilteredQuery(feedCase, request);
|
|
814
|
+
expect(request.query[feedCase.eventTypeColumn]).toBeUndefined();
|
|
815
|
+
// The event type drives each item's icon, so it is always read.
|
|
816
|
+
expect(request.select[feedCase.eventTypeColumn]).toBe(true);
|
|
817
|
+
expect(request.select["postedAt"]).toBe(true);
|
|
818
|
+
|
|
819
|
+
expect(await screen.findByText(feedCase.noItemsMessage)).toBeVisible();
|
|
820
|
+
|
|
821
|
+
// Painting the response must not ask again.
|
|
822
|
+
await flush();
|
|
823
|
+
expect(mockGetListCalls).toHaveLength(1);
|
|
824
|
+
},
|
|
825
|
+
);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
describe("the header", () => {
|
|
829
|
+
test.each(FEED_CASES)(
|
|
830
|
+
"$name shows one Filter & Sort button, first among the header controls",
|
|
831
|
+
async (feedCase: FeedCase) => {
|
|
832
|
+
renderFeedCase(feedCase);
|
|
833
|
+
await waitForRequestCount(1);
|
|
834
|
+
|
|
835
|
+
const emptyMessage: HTMLElement = await screen.findByText(
|
|
836
|
+
feedCase.noItemsMessage,
|
|
837
|
+
);
|
|
838
|
+
const card: HTMLElement = screen.getByTestId("card");
|
|
839
|
+
const heading: HTMLElement = within(card).getByTestId(
|
|
840
|
+
"card-details-heading",
|
|
841
|
+
);
|
|
842
|
+
const triggers: Array<HTMLElement> = within(card).getAllByTestId(
|
|
843
|
+
"feed-options-button",
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
expect(triggers).toHaveLength(1);
|
|
847
|
+
|
|
848
|
+
const trigger: HTMLElement = triggers[0]!;
|
|
849
|
+
|
|
850
|
+
expect(trigger).toHaveTextContent(TRIGGER_NAME);
|
|
851
|
+
expectTriggerSummary("Newest first, all event types");
|
|
852
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
853
|
+
expect(screen.queryByTestId("feed-options-count")).toBeNull();
|
|
854
|
+
|
|
855
|
+
const refresh: HTMLElement = within(card).getByRole("button", {
|
|
856
|
+
name: "Refresh",
|
|
857
|
+
});
|
|
858
|
+
const otherControls: Array<HTMLElement> = [];
|
|
859
|
+
|
|
860
|
+
if (feedCase.hasActionsMenu) {
|
|
861
|
+
const actions: HTMLElement | null = within(card)
|
|
862
|
+
.getByText("Actions")
|
|
863
|
+
.closest('[aria-haspopup="menu"]');
|
|
864
|
+
|
|
865
|
+
expect(actions).not.toBeNull();
|
|
866
|
+
otherControls.push(actions!);
|
|
867
|
+
} else {
|
|
868
|
+
expect(within(card).queryByText("Actions")).toBeNull();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
otherControls.push(refresh);
|
|
872
|
+
|
|
873
|
+
/*
|
|
874
|
+
* The header row holds one slot per control. Walk up from the trigger
|
|
875
|
+
* to its slot - the ancestor whose parent also holds Refresh - and
|
|
876
|
+
* the row's slots must be exactly: Filter & Sort, then the rest in
|
|
877
|
+
* order.
|
|
878
|
+
*/
|
|
879
|
+
let triggerSlot: HTMLElement = trigger;
|
|
880
|
+
|
|
881
|
+
while (
|
|
882
|
+
triggerSlot.parentElement &&
|
|
883
|
+
!triggerSlot.parentElement.contains(refresh)
|
|
884
|
+
) {
|
|
885
|
+
triggerSlot = triggerSlot.parentElement;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const headerRow: HTMLElement = triggerSlot.parentElement!;
|
|
889
|
+
const slots: Array<Element> = Array.from(headerRow.children);
|
|
890
|
+
|
|
891
|
+
expect(slots).toHaveLength(otherControls.length + 1);
|
|
892
|
+
expect(slots[0]).toBe(triggerSlot);
|
|
893
|
+
otherControls.forEach((control: HTMLElement, index: number) => {
|
|
894
|
+
expect(slots[index + 1]!.contains(control)).toBe(true);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// In the header: after the title, before the feed itself.
|
|
898
|
+
expect(isBefore(heading, trigger)).toBe(true);
|
|
899
|
+
expect(isBefore(refresh, emptyMessage)).toBe(true);
|
|
900
|
+
expect(headerRow.contains(emptyMessage)).toBe(false);
|
|
901
|
+
},
|
|
902
|
+
);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
describe("the event type checklist", () => {
|
|
906
|
+
test.each(FEED_CASES)(
|
|
907
|
+
"$name lists one checkbox per event type, labelled by getFeedEventTypeLabel and iconed like its feed items",
|
|
908
|
+
async (feedCase: FeedCase) => {
|
|
909
|
+
const rows: Array<DatabaseBaseModel> = feedCase.enumValues.map(
|
|
910
|
+
(eventType: string, index: number): DatabaseBaseModel => {
|
|
911
|
+
return createFeedRow(
|
|
912
|
+
feedCase,
|
|
913
|
+
eventType,
|
|
914
|
+
`fixture-${eventType}`,
|
|
915
|
+
new Date(Date.UTC(2026, 8, 1, 0, index)),
|
|
916
|
+
);
|
|
917
|
+
},
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
mockGetListResponse = (): Promise<FeedPageResponse> => {
|
|
921
|
+
return Promise.resolve({ data: rows, count: rows.length });
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
renderFeedCase(feedCase);
|
|
925
|
+
await waitForRequestCount(1);
|
|
926
|
+
await waitFor((): void => {
|
|
927
|
+
expect(getRenderedItemTexts()).toHaveLength(rows.length);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const itemIcons: Map<string, string | null> = new Map<
|
|
931
|
+
string,
|
|
932
|
+
string | null
|
|
933
|
+
>();
|
|
934
|
+
|
|
935
|
+
for (const item of screen.getAllByTestId("feed-item")) {
|
|
936
|
+
itemIcons.set(item.textContent || "", item.getAttribute("data-icon"));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const panel: HTMLElement = openPanel();
|
|
940
|
+
const checkboxes: Array<HTMLElement> =
|
|
941
|
+
within(panel).getAllByRole("checkbox");
|
|
942
|
+
|
|
943
|
+
expect(checkboxes).toHaveLength(feedCase.enumValues.length);
|
|
944
|
+
expect(
|
|
945
|
+
checkboxes
|
|
946
|
+
.map((checkbox: HTMLElement): string => {
|
|
947
|
+
return checkbox.getAttribute("data-testid") || "";
|
|
948
|
+
})
|
|
949
|
+
.sort(),
|
|
950
|
+
).toEqual(
|
|
951
|
+
feedCase.enumValues
|
|
952
|
+
.map((eventType: string): string => {
|
|
953
|
+
return `feed-options-event-type-${eventType}`;
|
|
954
|
+
})
|
|
955
|
+
.sort(),
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
const knownIcons: Array<string> = Object.values(IconProp);
|
|
959
|
+
|
|
960
|
+
for (const eventType of feedCase.enumValues) {
|
|
961
|
+
const checkbox: HTMLElement = within(panel).getByTestId(
|
|
962
|
+
`feed-options-event-type-${eventType}`,
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
expect(checkbox).toHaveAccessibleName(getFeedEventTypeLabel(eventType));
|
|
966
|
+
expect(checkbox).not.toBeChecked();
|
|
967
|
+
|
|
968
|
+
// The icon function is total over the enum: a real icon every time.
|
|
969
|
+
const expectedIcon: IconProp = feedCase.iconFn(eventType);
|
|
970
|
+
|
|
971
|
+
expect(knownIcons).toContain(expectedIcon);
|
|
972
|
+
|
|
973
|
+
const optionIcons: NodeListOf<Element> = checkbox
|
|
974
|
+
.closest("label")!
|
|
975
|
+
.querySelectorAll('[data-testid="mock-icon"]');
|
|
976
|
+
|
|
977
|
+
expect(optionIcons).toHaveLength(1);
|
|
978
|
+
expect(optionIcons[0]!.getAttribute("data-icon")).toBe(expectedIcon);
|
|
979
|
+
|
|
980
|
+
// ...and the feed's own item for that event type wears the same one.
|
|
981
|
+
expect(itemIcons.get(`fixture-${eventType}`)).toBe(expectedIcon);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Opening the panel is not a change of view.
|
|
985
|
+
expect(mockGetListCalls).toHaveLength(1);
|
|
986
|
+
},
|
|
987
|
+
);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
describe("Oldest first", () => {
|
|
991
|
+
test.each(FEED_CASES)(
|
|
992
|
+
"$name asks the API for ascending order from the first window and remembers it for its own feed kind",
|
|
993
|
+
async (feedCase: FeedCase) => {
|
|
994
|
+
const storageKey: string = `feed-sort-order:${feedCase.storageKey}`;
|
|
995
|
+
|
|
996
|
+
expect(getSortOrderStorageKey(feedCase.storageKey)).toBe(storageKey);
|
|
997
|
+
|
|
998
|
+
const view: RenderResult = renderFeedCase(feedCase);
|
|
999
|
+
await waitForRequestCount(1);
|
|
1000
|
+
|
|
1001
|
+
chooseSortOrder("Oldest first");
|
|
1002
|
+
await waitForRequestCount(2);
|
|
1003
|
+
|
|
1004
|
+
const request: FeedListRequest = getRequest(1);
|
|
1005
|
+
|
|
1006
|
+
expectWindow(feedCase, request, SortOrder.Ascending, DEFAULT_LIMIT);
|
|
1007
|
+
expectUnfilteredQuery(feedCase, request);
|
|
1008
|
+
expect(
|
|
1009
|
+
within(openPanel()).getByRole("radio", { name: "Oldest first" }),
|
|
1010
|
+
).toHaveAttribute("aria-checked", "true");
|
|
1011
|
+
expectTriggerSummary("Oldest first, all event types");
|
|
1012
|
+
|
|
1013
|
+
/*
|
|
1014
|
+
* One key, named for this feed kind. LocalStorage writes a string as
|
|
1015
|
+
* it is (only objects are JSON-encoded), and reads it back through
|
|
1016
|
+
* the same util the hook uses.
|
|
1017
|
+
*/
|
|
1018
|
+
expect(window.localStorage.length).toBe(1);
|
|
1019
|
+
expect(window.localStorage.key(0)).toBe(storageKey);
|
|
1020
|
+
expect(window.localStorage.getItem(storageKey)).toBe(SortOrder.Ascending);
|
|
1021
|
+
expect(LocalStorage.getItem(storageKey)).toBe(SortOrder.Ascending);
|
|
1022
|
+
|
|
1023
|
+
// The hook's read path: a fresh mount starts oldest first.
|
|
1024
|
+
view.unmount();
|
|
1025
|
+
const remounted: RenderResult = renderFeedCase(feedCase);
|
|
1026
|
+
await waitForRequestCount(3);
|
|
1027
|
+
|
|
1028
|
+
expectWindow(feedCase, getRequest(2), SortOrder.Ascending, DEFAULT_LIMIT);
|
|
1029
|
+
expectUnfilteredQuery(feedCase, getRequest(2));
|
|
1030
|
+
|
|
1031
|
+
// Going back to the default forgets the choice rather than storing it.
|
|
1032
|
+
chooseSortOrder("Newest first");
|
|
1033
|
+
await waitForRequestCount(4);
|
|
1034
|
+
|
|
1035
|
+
expectWindow(
|
|
1036
|
+
feedCase,
|
|
1037
|
+
getRequest(3),
|
|
1038
|
+
SortOrder.Descending,
|
|
1039
|
+
DEFAULT_LIMIT,
|
|
1040
|
+
);
|
|
1041
|
+
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
|
1042
|
+
|
|
1043
|
+
remounted.unmount();
|
|
1044
|
+
renderFeedCase(feedCase);
|
|
1045
|
+
await waitForRequestCount(5);
|
|
1046
|
+
|
|
1047
|
+
expectWindow(
|
|
1048
|
+
feedCase,
|
|
1049
|
+
getRequest(4),
|
|
1050
|
+
SortOrder.Descending,
|
|
1051
|
+
DEFAULT_LIMIT,
|
|
1052
|
+
);
|
|
1053
|
+
},
|
|
1054
|
+
);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
describe("ticking event types", () => {
|
|
1058
|
+
test.each(FEED_CASES)(
|
|
1059
|
+
"$name filters on its event type column with Includes, in enum order whatever the click order",
|
|
1060
|
+
async (feedCase: FeedCase) => {
|
|
1061
|
+
const pair: [string, string] | undefined = getOutOfOrderPair(
|
|
1062
|
+
feedCase.enumValues,
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
expect(pair).toBeDefined();
|
|
1066
|
+
|
|
1067
|
+
const enumEarlier: string = pair![0];
|
|
1068
|
+
const enumLater: string = pair![1];
|
|
1069
|
+
|
|
1070
|
+
renderFeedCase(feedCase);
|
|
1071
|
+
await waitForRequestCount(1);
|
|
1072
|
+
|
|
1073
|
+
toggleEventType(enumLater);
|
|
1074
|
+
await waitForRequestCount(2);
|
|
1075
|
+
|
|
1076
|
+
expectWindow(
|
|
1077
|
+
feedCase,
|
|
1078
|
+
getRequest(1),
|
|
1079
|
+
SortOrder.Descending,
|
|
1080
|
+
DEFAULT_LIMIT,
|
|
1081
|
+
);
|
|
1082
|
+
expectFilteredQuery(feedCase, getRequest(1), [enumLater]);
|
|
1083
|
+
expect(getEventTypeCheckbox(enumLater)).toBeChecked();
|
|
1084
|
+
expect(screen.getByTestId("feed-options-count")).toHaveTextContent(/^1$/);
|
|
1085
|
+
|
|
1086
|
+
toggleEventType(enumEarlier);
|
|
1087
|
+
await waitForRequestCount(3);
|
|
1088
|
+
|
|
1089
|
+
expectWindow(
|
|
1090
|
+
feedCase,
|
|
1091
|
+
getRequest(2),
|
|
1092
|
+
SortOrder.Descending,
|
|
1093
|
+
DEFAULT_LIMIT,
|
|
1094
|
+
);
|
|
1095
|
+
expectFilteredQuery(feedCase, getRequest(2), [enumEarlier, enumLater]);
|
|
1096
|
+
expect(getEventTypeCheckbox(enumEarlier)).toBeChecked();
|
|
1097
|
+
expect(getEventTypeCheckbox(enumLater)).toBeChecked();
|
|
1098
|
+
expect(screen.getByTestId("feed-options-count")).toHaveTextContent(/^2$/);
|
|
1099
|
+
expectTriggerSummary(
|
|
1100
|
+
`Newest first, 2 of ${feedCase.enumValues.length} event types`,
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
// Unticking one leaves exactly the other.
|
|
1104
|
+
toggleEventType(enumLater);
|
|
1105
|
+
await waitForRequestCount(4);
|
|
1106
|
+
|
|
1107
|
+
expectFilteredQuery(feedCase, getRequest(3), [enumEarlier]);
|
|
1108
|
+
|
|
1109
|
+
// The filter is never remembered.
|
|
1110
|
+
expect(window.localStorage.length).toBe(0);
|
|
1111
|
+
},
|
|
1112
|
+
);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
describe("empty feeds", () => {
|
|
1116
|
+
test.each(FEED_CASES)(
|
|
1117
|
+
"$name says no event matches the filter when filtered, and uses its own message otherwise",
|
|
1118
|
+
async (feedCase: FeedCase) => {
|
|
1119
|
+
renderFeedCase(feedCase);
|
|
1120
|
+
await waitForRequestCount(1);
|
|
1121
|
+
|
|
1122
|
+
expect(await screen.findByText(feedCase.noItemsMessage)).toBeVisible();
|
|
1123
|
+
expect(screen.queryByText(FILTERED_FEED_NO_ITEMS_MESSAGE)).toBeNull();
|
|
1124
|
+
|
|
1125
|
+
toggleEventType(getSampleEventType(feedCase));
|
|
1126
|
+
await waitForRequestCount(2);
|
|
1127
|
+
|
|
1128
|
+
expect(
|
|
1129
|
+
await screen.findByText(FILTERED_FEED_NO_ITEMS_MESSAGE),
|
|
1130
|
+
).toBeVisible();
|
|
1131
|
+
expect(screen.queryByText(feedCase.noItemsMessage)).toBeNull();
|
|
1132
|
+
|
|
1133
|
+
clickShowAll();
|
|
1134
|
+
await waitForRequestCount(3);
|
|
1135
|
+
|
|
1136
|
+
expect(await screen.findByText(feedCase.noItemsMessage)).toBeVisible();
|
|
1137
|
+
expect(screen.queryByText(FILTERED_FEED_NO_ITEMS_MESSAGE)).toBeNull();
|
|
1138
|
+
|
|
1139
|
+
// Reordering is not filtering: an empty feed is still just empty.
|
|
1140
|
+
chooseSortOrder("Oldest first");
|
|
1141
|
+
await waitForRequestCount(4);
|
|
1142
|
+
|
|
1143
|
+
expect(await screen.findByText(feedCase.noItemsMessage)).toBeVisible();
|
|
1144
|
+
expect(screen.queryByText(FILTERED_FEED_NO_ITEMS_MESSAGE)).toBeNull();
|
|
1145
|
+
},
|
|
1146
|
+
);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
describe("Show all", () => {
|
|
1150
|
+
test.each(FEED_CASES)(
|
|
1151
|
+
"$name returns the query to exactly the resource alone",
|
|
1152
|
+
async (feedCase: FeedCase) => {
|
|
1153
|
+
const pair: [string, string] = getOutOfOrderPair(feedCase.enumValues)!;
|
|
1154
|
+
const enumEarlier: string = pair[0];
|
|
1155
|
+
const enumLater: string = pair[1];
|
|
1156
|
+
|
|
1157
|
+
renderFeedCase(feedCase);
|
|
1158
|
+
await waitForRequestCount(1);
|
|
1159
|
+
|
|
1160
|
+
toggleEventType(enumEarlier);
|
|
1161
|
+
await waitForRequestCount(2);
|
|
1162
|
+
toggleEventType(enumLater);
|
|
1163
|
+
await waitForRequestCount(3);
|
|
1164
|
+
|
|
1165
|
+
expectFilteredQuery(feedCase, getRequest(2), [enumEarlier, enumLater]);
|
|
1166
|
+
|
|
1167
|
+
clickShowAll();
|
|
1168
|
+
await waitForRequestCount(4);
|
|
1169
|
+
|
|
1170
|
+
const request: FeedListRequest = getRequest(3);
|
|
1171
|
+
|
|
1172
|
+
expectWindow(feedCase, request, SortOrder.Descending, DEFAULT_LIMIT);
|
|
1173
|
+
expectUnfilteredQuery(feedCase, request);
|
|
1174
|
+
|
|
1175
|
+
const panel: HTMLElement = openPanel();
|
|
1176
|
+
|
|
1177
|
+
for (const checkbox of within(panel).getAllByRole("checkbox")) {
|
|
1178
|
+
expect(checkbox).not.toBeChecked();
|
|
1179
|
+
}
|
|
1180
|
+
expect(
|
|
1181
|
+
within(panel).queryByRole("button", { name: "Show all" }),
|
|
1182
|
+
).toBeNull();
|
|
1183
|
+
expect(screen.queryByTestId("feed-options-count")).toBeNull();
|
|
1184
|
+
},
|
|
1185
|
+
);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
describe("the window after More", () => {
|
|
1189
|
+
test.each(FEED_CASES)(
|
|
1190
|
+
"$name re-reads the first window on a filter or sort change, and More then keeps both",
|
|
1191
|
+
async (feedCase: FeedCase) => {
|
|
1192
|
+
const eventType: string = getSampleEventType(feedCase);
|
|
1193
|
+
|
|
1194
|
+
mockGetListResponse = serveFeed(feedCase, SERVER_ROW_COUNT);
|
|
1195
|
+
|
|
1196
|
+
renderFeedCase(feedCase);
|
|
1197
|
+
await waitForRequestCount(1);
|
|
1198
|
+
await waitFor((): void => {
|
|
1199
|
+
expect(getRenderedItemTexts()).toEqual(
|
|
1200
|
+
getViewTexts("DESC:all", DEFAULT_LIMIT),
|
|
1201
|
+
);
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
clickMore();
|
|
1205
|
+
await waitForRequestCount(2);
|
|
1206
|
+
|
|
1207
|
+
expectWindow(
|
|
1208
|
+
feedCase,
|
|
1209
|
+
getRequest(1),
|
|
1210
|
+
SortOrder.Descending,
|
|
1211
|
+
DEFAULT_LIMIT * 2,
|
|
1212
|
+
);
|
|
1213
|
+
expectUnfilteredQuery(feedCase, getRequest(1));
|
|
1214
|
+
await waitFor((): void => {
|
|
1215
|
+
expect(getRenderedItemTexts()).toEqual(
|
|
1216
|
+
getViewTexts("DESC:all", DEFAULT_LIMIT * 2),
|
|
1217
|
+
);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
// A filter change starts over at one window, not the widened two.
|
|
1221
|
+
toggleEventType(eventType);
|
|
1222
|
+
await waitForRequestCount(3);
|
|
1223
|
+
|
|
1224
|
+
expectWindow(
|
|
1225
|
+
feedCase,
|
|
1226
|
+
getRequest(2),
|
|
1227
|
+
SortOrder.Descending,
|
|
1228
|
+
DEFAULT_LIMIT,
|
|
1229
|
+
);
|
|
1230
|
+
expectFilteredQuery(feedCase, getRequest(2), [eventType]);
|
|
1231
|
+
await waitFor((): void => {
|
|
1232
|
+
expect(getRenderedItemTexts()).toEqual(
|
|
1233
|
+
getViewTexts(`DESC:${eventType}`, DEFAULT_LIMIT),
|
|
1234
|
+
);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// More on the filtered view widens it and keeps the filter.
|
|
1238
|
+
clickMore();
|
|
1239
|
+
await waitForRequestCount(4);
|
|
1240
|
+
|
|
1241
|
+
expectWindow(
|
|
1242
|
+
feedCase,
|
|
1243
|
+
getRequest(3),
|
|
1244
|
+
SortOrder.Descending,
|
|
1245
|
+
DEFAULT_LIMIT * 2,
|
|
1246
|
+
);
|
|
1247
|
+
expectFilteredQuery(feedCase, getRequest(3), [eventType]);
|
|
1248
|
+
await waitFor((): void => {
|
|
1249
|
+
expect(getRenderedItemTexts()).toEqual(
|
|
1250
|
+
getViewTexts(`DESC:${eventType}`, DEFAULT_LIMIT * 2),
|
|
1251
|
+
);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// A sort change starts over too, and keeps the filter.
|
|
1255
|
+
chooseSortOrder("Oldest first");
|
|
1256
|
+
await waitForRequestCount(5);
|
|
1257
|
+
|
|
1258
|
+
expectWindow(feedCase, getRequest(4), SortOrder.Ascending, DEFAULT_LIMIT);
|
|
1259
|
+
expectFilteredQuery(feedCase, getRequest(4), [eventType]);
|
|
1260
|
+
await waitFor((): void => {
|
|
1261
|
+
expect(getRenderedItemTexts()).toEqual(
|
|
1262
|
+
getViewTexts(`ASC:${eventType}`, DEFAULT_LIMIT),
|
|
1263
|
+
);
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// More keeps both the sort and the filter.
|
|
1267
|
+
clickMore();
|
|
1268
|
+
await waitForRequestCount(6);
|
|
1269
|
+
|
|
1270
|
+
expectWindow(
|
|
1271
|
+
feedCase,
|
|
1272
|
+
getRequest(5),
|
|
1273
|
+
SortOrder.Ascending,
|
|
1274
|
+
DEFAULT_LIMIT * 2,
|
|
1275
|
+
);
|
|
1276
|
+
expectFilteredQuery(feedCase, getRequest(5), [eventType]);
|
|
1277
|
+
await waitFor((): void => {
|
|
1278
|
+
expect(getRenderedItemTexts()).toEqual(
|
|
1279
|
+
getViewTexts(`ASC:${eventType}`, DEFAULT_LIMIT * 2),
|
|
1280
|
+
);
|
|
1281
|
+
});
|
|
1282
|
+
},
|
|
1283
|
+
);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
describe("slow responses for a previous view", () => {
|
|
1287
|
+
test.each(FEED_CASES)(
|
|
1288
|
+
"$name never paints a response for a view the reader has already left",
|
|
1289
|
+
async (feedCase: FeedCase) => {
|
|
1290
|
+
const eventType: string = getSampleEventType(feedCase);
|
|
1291
|
+
const pending: Array<PendingRequest> = [];
|
|
1292
|
+
|
|
1293
|
+
mockGetListResponse = holdRequests(pending);
|
|
1294
|
+
|
|
1295
|
+
renderFeedCase(feedCase);
|
|
1296
|
+
await waitForRequestCount(1);
|
|
1297
|
+
|
|
1298
|
+
// The reader filters while the first window is still on its way.
|
|
1299
|
+
toggleEventType(eventType);
|
|
1300
|
+
await waitForRequestCount(2);
|
|
1301
|
+
|
|
1302
|
+
expectFilteredQuery(feedCase, pending[1]!.request, [eventType]);
|
|
1303
|
+
|
|
1304
|
+
// The unfiltered window lands first: it answers a view that is gone.
|
|
1305
|
+
await answerRequest(
|
|
1306
|
+
pending[0],
|
|
1307
|
+
buildPage(feedCase, pending[0]!.request, SERVER_ROW_COUNT),
|
|
1308
|
+
);
|
|
1309
|
+
|
|
1310
|
+
expect(getRenderedItemTexts()).toEqual([]);
|
|
1311
|
+
|
|
1312
|
+
// Then they reorder, while the filtered window is still on its way.
|
|
1313
|
+
chooseSortOrder("Oldest first");
|
|
1314
|
+
await waitForRequestCount(3);
|
|
1315
|
+
|
|
1316
|
+
expectWindow(
|
|
1317
|
+
feedCase,
|
|
1318
|
+
pending[2]!.request,
|
|
1319
|
+
SortOrder.Ascending,
|
|
1320
|
+
DEFAULT_LIMIT,
|
|
1321
|
+
);
|
|
1322
|
+
expectFilteredQuery(feedCase, pending[2]!.request, [eventType]);
|
|
1323
|
+
|
|
1324
|
+
await answerRequest(
|
|
1325
|
+
pending[2],
|
|
1326
|
+
buildPage(feedCase, pending[2]!.request, SERVER_ROW_COUNT),
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
const currentTexts: Array<string> = getViewTexts(
|
|
1330
|
+
`ASC:${eventType}`,
|
|
1331
|
+
DEFAULT_LIMIT,
|
|
1332
|
+
);
|
|
1333
|
+
|
|
1334
|
+
expect(getRenderedItemTexts()).toEqual(currentTexts);
|
|
1335
|
+
|
|
1336
|
+
// The newest-first filtered window lands last, and is dropped too.
|
|
1337
|
+
await answerRequest(
|
|
1338
|
+
pending[1],
|
|
1339
|
+
buildPage(feedCase, pending[1]!.request, SERVER_ROW_COUNT),
|
|
1340
|
+
);
|
|
1341
|
+
|
|
1342
|
+
expect(getRenderedItemTexts()).toEqual(currentTexts);
|
|
1343
|
+
|
|
1344
|
+
for (const text of mockPaintedItemTexts) {
|
|
1345
|
+
expect(text.startsWith(`ASC:${eventType}#`)).toBe(true);
|
|
1346
|
+
}
|
|
1347
|
+
expect(mockGetListCalls).toHaveLength(3);
|
|
1348
|
+
},
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
test.each(FEED_CASES)(
|
|
1352
|
+
"$name drops a More still in flight when the reader changes the filter",
|
|
1353
|
+
async (feedCase: FeedCase) => {
|
|
1354
|
+
const eventType: string = getSampleEventType(feedCase);
|
|
1355
|
+
const pendingMore: Array<PendingRequest> = [];
|
|
1356
|
+
const serve: FeedResponder = serveFeed(feedCase, SERVER_ROW_COUNT);
|
|
1357
|
+
|
|
1358
|
+
mockGetListResponse = (
|
|
1359
|
+
request: FeedListRequest,
|
|
1360
|
+
): Promise<FeedPageResponse> => {
|
|
1361
|
+
if (
|
|
1362
|
+
request.limit > DEFAULT_LIMIT &&
|
|
1363
|
+
!getRequestedEventTypes(feedCase, request)
|
|
1364
|
+
) {
|
|
1365
|
+
return holdRequests(pendingMore)(request);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
return serve(request);
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
renderFeedCase(feedCase);
|
|
1372
|
+
await waitForRequestCount(1);
|
|
1373
|
+
await waitFor((): void => {
|
|
1374
|
+
expect(getRenderedItemTexts()).toEqual(
|
|
1375
|
+
getViewTexts("DESC:all", DEFAULT_LIMIT),
|
|
1376
|
+
);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
clickMore();
|
|
1380
|
+
await waitForRequestCount(2);
|
|
1381
|
+
|
|
1382
|
+
expect(pendingMore).toHaveLength(1);
|
|
1383
|
+
|
|
1384
|
+
toggleEventType(eventType);
|
|
1385
|
+
await waitForRequestCount(3);
|
|
1386
|
+
|
|
1387
|
+
const filteredTexts: Array<string> = getViewTexts(
|
|
1388
|
+
`DESC:${eventType}`,
|
|
1389
|
+
DEFAULT_LIMIT,
|
|
1390
|
+
);
|
|
1391
|
+
|
|
1392
|
+
await waitFor((): void => {
|
|
1393
|
+
expect(getRenderedItemTexts()).toEqual(filteredTexts);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
await answerRequest(
|
|
1397
|
+
pendingMore[0],
|
|
1398
|
+
buildPage(feedCase, pendingMore[0]!.request, SERVER_ROW_COUNT),
|
|
1399
|
+
);
|
|
1400
|
+
|
|
1401
|
+
expect(getRenderedItemTexts()).toEqual(filteredTexts);
|
|
1402
|
+
expect(mockPaintedItemTexts).not.toContain(
|
|
1403
|
+
`DESC:all#${DEFAULT_LIMIT + 1}`,
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
// The filtered view can still be widened.
|
|
1407
|
+
clickMore();
|
|
1408
|
+
await waitForRequestCount(4);
|
|
1409
|
+
|
|
1410
|
+
expectWindow(
|
|
1411
|
+
feedCase,
|
|
1412
|
+
getRequest(3),
|
|
1413
|
+
SortOrder.Descending,
|
|
1414
|
+
DEFAULT_LIMIT * 2,
|
|
1415
|
+
);
|
|
1416
|
+
expectFilteredQuery(feedCase, getRequest(3), [eventType]);
|
|
1417
|
+
},
|
|
1418
|
+
);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
describe("a later visit", () => {
|
|
1422
|
+
test.each(FEED_CASES)(
|
|
1423
|
+
"$name restores the sort order but not the event type filter",
|
|
1424
|
+
async (feedCase: FeedCase) => {
|
|
1425
|
+
const eventType: string = getSampleEventType(feedCase);
|
|
1426
|
+
|
|
1427
|
+
const view: RenderResult = renderFeedCase(feedCase);
|
|
1428
|
+
await waitForRequestCount(1);
|
|
1429
|
+
|
|
1430
|
+
chooseSortOrder("Oldest first");
|
|
1431
|
+
await waitForRequestCount(2);
|
|
1432
|
+
toggleEventType(eventType);
|
|
1433
|
+
await waitForRequestCount(3);
|
|
1434
|
+
|
|
1435
|
+
expectWindow(feedCase, getRequest(2), SortOrder.Ascending, DEFAULT_LIMIT);
|
|
1436
|
+
expectFilteredQuery(feedCase, getRequest(2), [eventType]);
|
|
1437
|
+
|
|
1438
|
+
view.unmount();
|
|
1439
|
+
renderFeedCase(feedCase);
|
|
1440
|
+
await waitForRequestCount(4);
|
|
1441
|
+
|
|
1442
|
+
const request: FeedListRequest = getRequest(3);
|
|
1443
|
+
|
|
1444
|
+
expectWindow(feedCase, request, SortOrder.Ascending, DEFAULT_LIMIT);
|
|
1445
|
+
expectUnfilteredQuery(feedCase, request);
|
|
1446
|
+
|
|
1447
|
+
expect(screen.queryByTestId("feed-options-count")).toBeNull();
|
|
1448
|
+
expectTriggerSummary("Oldest first, all event types");
|
|
1449
|
+
|
|
1450
|
+
const panel: HTMLElement = openPanel();
|
|
1451
|
+
|
|
1452
|
+
expect(
|
|
1453
|
+
within(panel).getByRole("radio", { name: "Oldest first" }),
|
|
1454
|
+
).toHaveAttribute("aria-checked", "true");
|
|
1455
|
+
for (const checkbox of within(panel).getAllByRole("checkbox")) {
|
|
1456
|
+
expect(checkbox).not.toBeChecked();
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
);
|
|
1460
|
+
|
|
1461
|
+
test.each(FEED_CASES)(
|
|
1462
|
+
"$name reads only its own feed kind's stored sort order",
|
|
1463
|
+
async (feedCase: FeedCase) => {
|
|
1464
|
+
// Every other feed kind chose oldest first; this one never did.
|
|
1465
|
+
for (const otherCase of FEED_CASES) {
|
|
1466
|
+
if (otherCase.storageKey !== feedCase.storageKey) {
|
|
1467
|
+
LocalStorage.setItem(
|
|
1468
|
+
getSortOrderStorageKey(otherCase.storageKey),
|
|
1469
|
+
SortOrder.Ascending,
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const view: RenderResult = renderFeedCase(feedCase);
|
|
1475
|
+
await waitForRequestCount(1);
|
|
1476
|
+
|
|
1477
|
+
expectWindow(
|
|
1478
|
+
feedCase,
|
|
1479
|
+
getRequest(0),
|
|
1480
|
+
SortOrder.Descending,
|
|
1481
|
+
DEFAULT_LIMIT,
|
|
1482
|
+
);
|
|
1483
|
+
|
|
1484
|
+
view.unmount();
|
|
1485
|
+
LocalStorage.setItem(
|
|
1486
|
+
getSortOrderStorageKey(feedCase.storageKey),
|
|
1487
|
+
SortOrder.Ascending,
|
|
1488
|
+
);
|
|
1489
|
+
|
|
1490
|
+
renderFeedCase(feedCase);
|
|
1491
|
+
await waitForRequestCount(2);
|
|
1492
|
+
|
|
1493
|
+
expectWindow(feedCase, getRequest(1), SortOrder.Ascending, DEFAULT_LIMIT);
|
|
1494
|
+
expectUnfilteredQuery(feedCase, getRequest(1));
|
|
1495
|
+
},
|
|
1496
|
+
);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
describe("moving to another resource", () => {
|
|
1500
|
+
test.each(FEED_CASES)(
|
|
1501
|
+
"$name reads the new resource unfiltered from its first request, keeps the sort order, and does not bring the filter back",
|
|
1502
|
+
async (feedCase: FeedCase) => {
|
|
1503
|
+
const eventType: string = getSampleEventType(feedCase);
|
|
1504
|
+
const storageKey: string = getSortOrderStorageKey(feedCase.storageKey);
|
|
1505
|
+
const otherResourceId: ObjectID = ObjectID.generate();
|
|
1506
|
+
|
|
1507
|
+
expect(otherResourceId.toString()).not.toBe(
|
|
1508
|
+
feedCase.resourceId.toString(),
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
const view: RenderResult = renderFeedCase(feedCase);
|
|
1512
|
+
await waitForRequestCount(1);
|
|
1513
|
+
|
|
1514
|
+
toggleEventType(eventType);
|
|
1515
|
+
await waitForRequestCount(2);
|
|
1516
|
+
chooseSortOrder("Oldest first");
|
|
1517
|
+
await waitForRequestCount(3);
|
|
1518
|
+
|
|
1519
|
+
expectWindow(feedCase, getRequest(2), SortOrder.Ascending, DEFAULT_LIMIT);
|
|
1520
|
+
expectFilteredQuery(feedCase, getRequest(2), [eventType]);
|
|
1521
|
+
expect(screen.getByTestId("feed-options-count")).toHaveTextContent(/^1$/);
|
|
1522
|
+
|
|
1523
|
+
/*
|
|
1524
|
+
* The dashboard moves between resources by handing the same mounted
|
|
1525
|
+
* feed a new id. The filter was chosen on the old resource, so it must
|
|
1526
|
+
* be gone before the new resource is asked for: exactly one request
|
|
1527
|
+
* follows, and it carries the new id alone. A filter dropped a render
|
|
1528
|
+
* later (in an effect) would first ask for the new resource with the
|
|
1529
|
+
* old filter, then ask again.
|
|
1530
|
+
*/
|
|
1531
|
+
view.rerender(feedCase.render(otherResourceId));
|
|
1532
|
+
await waitForRequestCount(4);
|
|
1533
|
+
|
|
1534
|
+
const firstRequestForOther: FeedListRequest = getRequest(3);
|
|
1535
|
+
|
|
1536
|
+
// The order is a preference, not part of the resource: it stays.
|
|
1537
|
+
expectWindow(
|
|
1538
|
+
feedCase,
|
|
1539
|
+
firstRequestForOther,
|
|
1540
|
+
SortOrder.Ascending,
|
|
1541
|
+
DEFAULT_LIMIT,
|
|
1542
|
+
);
|
|
1543
|
+
expectUnfilteredQuery(feedCase, firstRequestForOther, otherResourceId);
|
|
1544
|
+
|
|
1545
|
+
expect(screen.queryByTestId("feed-options-count")).toBeNull();
|
|
1546
|
+
expectTriggerSummary("Oldest first, all event types");
|
|
1547
|
+
// An empty unfiltered feed says so in its own words, not the filter's.
|
|
1548
|
+
expect(await screen.findByText(feedCase.noItemsMessage)).toBeVisible();
|
|
1549
|
+
expect(screen.queryByText(FILTERED_FEED_NO_ITEMS_MESSAGE)).toBeNull();
|
|
1550
|
+
|
|
1551
|
+
const panel: HTMLElement = openPanel();
|
|
1552
|
+
|
|
1553
|
+
expect(
|
|
1554
|
+
within(panel).getByRole("radio", { name: "Oldest first" }),
|
|
1555
|
+
).toHaveAttribute("aria-checked", "true");
|
|
1556
|
+
for (const checkbox of within(panel).getAllByRole("checkbox")) {
|
|
1557
|
+
expect(checkbox).not.toBeChecked();
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// The stored order is untouched and the filter was never stored.
|
|
1561
|
+
expect(window.localStorage.length).toBe(1);
|
|
1562
|
+
expect(window.localStorage.getItem(storageKey)).toBe(SortOrder.Ascending);
|
|
1563
|
+
|
|
1564
|
+
/*
|
|
1565
|
+
* Going back is another move, not an undo: the first resource's old
|
|
1566
|
+
* filter was dropped, not set aside.
|
|
1567
|
+
*/
|
|
1568
|
+
view.rerender(feedCase.render(feedCase.resourceId));
|
|
1569
|
+
await waitForRequestCount(5);
|
|
1570
|
+
|
|
1571
|
+
const requestOnReturn: FeedListRequest = getRequest(4);
|
|
1572
|
+
|
|
1573
|
+
expectWindow(
|
|
1574
|
+
feedCase,
|
|
1575
|
+
requestOnReturn,
|
|
1576
|
+
SortOrder.Ascending,
|
|
1577
|
+
DEFAULT_LIMIT,
|
|
1578
|
+
);
|
|
1579
|
+
expectUnfilteredQuery(feedCase, requestOnReturn);
|
|
1580
|
+
expect(screen.queryByTestId("feed-options-count")).toBeNull();
|
|
1581
|
+
expectTriggerSummary("Oldest first, all event types");
|
|
1582
|
+
|
|
1583
|
+
for (const checkbox of within(openPanel()).getAllByRole("checkbox")) {
|
|
1584
|
+
expect(checkbox).not.toBeChecked();
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
);
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
describe("the trigger's glyph", () => {
|
|
1591
|
+
test.each(FEED_CASES)(
|
|
1592
|
+
"$name shows the funnel while newest first and the up arrow while oldest first, whatever the filter",
|
|
1593
|
+
async (feedCase: FeedCase) => {
|
|
1594
|
+
const eventType: string = getSampleEventType(feedCase);
|
|
1595
|
+
|
|
1596
|
+
const view: RenderResult = renderFeedCase(feedCase);
|
|
1597
|
+
await waitForRequestCount(1);
|
|
1598
|
+
|
|
1599
|
+
expect(getTriggerIcon()).toBe(IconProp.Filter);
|
|
1600
|
+
|
|
1601
|
+
// The glyph says the order, so a filter alone leaves the funnel.
|
|
1602
|
+
toggleEventType(eventType);
|
|
1603
|
+
await waitForRequestCount(2);
|
|
1604
|
+
|
|
1605
|
+
expect(getTriggerIcon()).toBe(IconProp.Filter);
|
|
1606
|
+
|
|
1607
|
+
chooseSortOrder("Oldest first");
|
|
1608
|
+
await waitForRequestCount(3);
|
|
1609
|
+
|
|
1610
|
+
expect(getTriggerIcon()).toBe(IconProp.BarsArrowUp);
|
|
1611
|
+
|
|
1612
|
+
// ...and clearing the filter leaves the order, so the arrow stays.
|
|
1613
|
+
clickShowAll();
|
|
1614
|
+
await waitForRequestCount(4);
|
|
1615
|
+
|
|
1616
|
+
expect(getTriggerIcon()).toBe(IconProp.BarsArrowUp);
|
|
1617
|
+
|
|
1618
|
+
// A remembered order draws the arrow from the first paint.
|
|
1619
|
+
view.unmount();
|
|
1620
|
+
renderFeedCase(feedCase);
|
|
1621
|
+
|
|
1622
|
+
expect(getTriggerIcon()).toBe(IconProp.BarsArrowUp);
|
|
1623
|
+
|
|
1624
|
+
await waitForRequestCount(5);
|
|
1625
|
+
|
|
1626
|
+
chooseSortOrder("Newest first");
|
|
1627
|
+
await waitForRequestCount(6);
|
|
1628
|
+
|
|
1629
|
+
expect(getTriggerIcon()).toBe(IconProp.Filter);
|
|
1630
|
+
},
|
|
1631
|
+
);
|
|
1632
|
+
});
|