@oneuptime/common 11.7.0 → 11.7.2
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/DashboardDomain.ts +1 -0
- package/Models/DatabaseModels/Incident.ts +2 -0
- package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
- package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
- package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
- package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
- package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
- package/Models/DatabaseModels/Monitor.ts +1 -0
- package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
- package/Models/DatabaseModels/NetworkSite.ts +1 -0
- package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
- package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
- package/Models/DatabaseModels/Probe.ts +13 -13
- package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
- package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
- package/Models/DatabaseModels/StatusPage.ts +1 -0
- package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
- package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
- package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
- package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
- package/Server/API/BaseAPI.ts +17 -1
- package/Server/API/CommonAPI.ts +32 -0
- package/Server/API/MicrosoftTeamsAPI.ts +99 -2
- package/Server/API/ProbeAPI.ts +2 -0
- package/Server/API/SlackAPI.ts +62 -0
- package/Server/API/StatusPageAPI.ts +157 -192
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Services/DatabaseService.ts +11 -3
- package/Server/Services/MonitorGroupService.ts +145 -0
- package/Server/Services/MonitorService.ts +231 -30
- package/Server/Services/ProbeService.ts +319 -16
- package/Server/Services/StatusPageService.ts +72 -42
- package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
- package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
- package/Server/Utils/Monitor/MonitorResource.ts +88 -35
- package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
- package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
- package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
- package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
- package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
- package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
- package/Tests/Server/API/ProbeAPI.test.ts +6 -0
- package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
- package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
- package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
- package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
- package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
- package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
- package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
- package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
- package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
- package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
- package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
- package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
- package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
- package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
- package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
- package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
- package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
- package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
- package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
- package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
- package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
- package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
- package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
- package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
- package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
- package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
- package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
- package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
- package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
- package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
- package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
- package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
- package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
- package/Tests/Types/DateUserTimezone.test.ts +205 -0
- package/Tests/Types/DateUserTimezoneOverridesBrowser.test.ts +73 -0
- package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
- package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
- package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
- package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
- package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
- package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
- package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
- package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
- package/Tests/UI/Components/TimePicker/TimePicker.test.tsx +11 -0
- package/Tests/UI/Utils/Project.test.ts +809 -0
- package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
- package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
- package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
- package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
- package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
- package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
- package/Types/Date.ts +119 -50
- package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
- package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
- package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
- package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
- package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
- package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
- package/UI/Components/Forms/Fields/FormField.tsx +13 -3
- package/UI/Components/Forms/ModelForm.tsx +41 -18
- package/UI/Components/Input/Input.tsx +6 -1
- package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
- package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +300 -30
- package/UI/Components/ModelTable/Column.ts +12 -0
- package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
- package/UI/Components/Navbar/NavBar.tsx +66 -1
- package/UI/Components/Table/Table.tsx +52 -13
- package/UI/Components/Table/Types/Column.ts +18 -0
- package/UI/Components/TimePicker/TimePicker.tsx +8 -4
- package/UI/Utils/Project.ts +214 -18
- package/UI/Utils/TableColumnsToCsv.ts +86 -6
- package/UI/Utils/User.ts +19 -0
- package/UI/esbuild-config.js +34 -0
- package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
- package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Incident.js +2 -0
- package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
- package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Probe.js +13 -13
- package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
- package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
- package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +12 -1
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/CommonAPI.js +21 -0
- package/build/dist/Server/API/CommonAPI.js.map +1 -1
- package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
- package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
- package/build/dist/Server/API/ProbeAPI.js +2 -0
- package/build/dist/Server/API/ProbeAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +44 -0
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/API/StatusPageAPI.js +98 -152
- package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +10 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/MonitorGroupService.js +118 -0
- package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +198 -24
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/ProbeService.js +238 -9
- package/build/dist/Server/Services/ProbeService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +46 -39
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
- package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
- package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
- package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
- package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
- package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/Date.js +106 -35
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
- package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
- package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
- package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
- package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
- package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
- package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
- package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
- package/build/dist/UI/Components/Forms/Fields/FormField.js +11 -3
- package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
- package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
- package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +6 -1
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
- package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
- package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +234 -21
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
- package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
- package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +31 -11
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/TimePicker/TimePicker.js +4 -4
- package/build/dist/UI/Components/TimePicker/TimePicker.js.map +1 -1
- package/build/dist/UI/Utils/Project.js +161 -15
- package/build/dist/UI/Utils/Project.js.map +1 -1
- package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
- package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
- package/build/dist/UI/Utils/User.js +19 -0
- package/build/dist/UI/Utils/User.js.map +1 -1
- package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
- package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
- package/package.json +4 -3
- package/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.ts +0 -79
- package/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.ts +0 -41
- package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +0 -17
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js +0 -34
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773414578773-MigrationName.js.map +0 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js +0 -22
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773500000000-MigrationName.js.map +0 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +0 -12
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +0 -1
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/extend-expect";
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
configure,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import React from "react";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Selecting thousands of rows renders and re-renders a real table several
|
|
14
|
+
* times over, which comfortably outruns the 1s default when the whole suite
|
|
15
|
+
* is competing for the same box. A regression still fails, just later.
|
|
16
|
+
*/
|
|
17
|
+
configure({ asyncUtilTimeout: 15000 });
|
|
18
|
+
import {
|
|
19
|
+
afterEach,
|
|
20
|
+
beforeEach,
|
|
21
|
+
describe,
|
|
22
|
+
expect,
|
|
23
|
+
jest,
|
|
24
|
+
test,
|
|
25
|
+
} from "@jest/globals";
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* BaseModelTable pulls in permissions, the current project and the i18n
|
|
29
|
+
* provider. None of those are what these tests are about, so they are stubbed
|
|
30
|
+
* to their permissive/no-op form and the tests focus on one thing: what
|
|
31
|
+
* "Select All" actually asks the API for, and what ends up selected.
|
|
32
|
+
*/
|
|
33
|
+
jest.mock("../../../../UI/Utils/Permission", () => {
|
|
34
|
+
return {
|
|
35
|
+
__esModule: true,
|
|
36
|
+
default: {
|
|
37
|
+
getAllPermissions: () => {
|
|
38
|
+
return [];
|
|
39
|
+
},
|
|
40
|
+
getProjectPermissions: () => {
|
|
41
|
+
return [];
|
|
42
|
+
},
|
|
43
|
+
getGlobalPermissions: () => {
|
|
44
|
+
return [];
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
jest.mock("../../../../UI/Utils/User", () => {
|
|
51
|
+
return {
|
|
52
|
+
__esModule: true,
|
|
53
|
+
default: {
|
|
54
|
+
isMasterAdmin: () => {
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
getUserId: () => {
|
|
58
|
+
return null;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
jest.mock("../../../../UI/Utils/Translation", () => {
|
|
65
|
+
return {
|
|
66
|
+
__esModule: true,
|
|
67
|
+
default: () => {
|
|
68
|
+
return {
|
|
69
|
+
translateString: (value: string | undefined) => {
|
|
70
|
+
return value;
|
|
71
|
+
},
|
|
72
|
+
translateValue: (value: unknown) => {
|
|
73
|
+
return value;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
import BaseModelTable, {
|
|
81
|
+
BaseTableCallbacks,
|
|
82
|
+
BULK_SELECT_ALL_PAGE_SIZE,
|
|
83
|
+
ComponentProps as BaseModelTableProps,
|
|
84
|
+
} from "../../../../UI/Components/ModelTable/BaseModelTable";
|
|
85
|
+
import TableColumnsToCsv from "../../../../UI/Utils/TableColumnsToCsv";
|
|
86
|
+
import TableFilterUrlState from "../../../../UI/Utils/TableFilterUrlState";
|
|
87
|
+
import FieldType from "../../../../UI/Components/Types/FieldType";
|
|
88
|
+
import { ButtonStyleType } from "../../../../UI/Components/Button/Button";
|
|
89
|
+
import Monitor from "../../../../Models/DatabaseModels/Monitor";
|
|
90
|
+
import { LIMIT_PER_PROJECT } from "../../../../Types/Database/LimitMax";
|
|
91
|
+
import Query from "../../../../Types/BaseDatabase/Query";
|
|
92
|
+
import SortOrder from "../../../../Types/BaseDatabase/SortOrder";
|
|
93
|
+
import ListResult from "../../../../Types/BaseDatabase/ListResult";
|
|
94
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
95
|
+
|
|
96
|
+
type GetListCall = {
|
|
97
|
+
query: Query<Monitor>;
|
|
98
|
+
limit: number;
|
|
99
|
+
skip: number;
|
|
100
|
+
sort: JSONObject;
|
|
101
|
+
select: JSONObject;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type Row = {
|
|
105
|
+
_id: string;
|
|
106
|
+
name: string;
|
|
107
|
+
description: string;
|
|
108
|
+
createdAt: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type MakeRowsFunction = (count: number) => Array<Row>;
|
|
112
|
+
|
|
113
|
+
const makeRows: MakeRowsFunction = (count: number): Array<Row> => {
|
|
114
|
+
const rows: Array<Row> = [];
|
|
115
|
+
|
|
116
|
+
for (let i: number = 0; i < count; i++) {
|
|
117
|
+
rows.push({
|
|
118
|
+
_id: `id-${String(i).padStart(6, "0")}`,
|
|
119
|
+
name: `Monitor ${i}`,
|
|
120
|
+
description: `Description ${i}`,
|
|
121
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return rows;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const COLUMNS: Array<unknown> = [
|
|
129
|
+
{ field: { name: true }, title: "Name", type: FieldType.Text },
|
|
130
|
+
{
|
|
131
|
+
field: { description: true },
|
|
132
|
+
title: "Description",
|
|
133
|
+
type: FieldType.LongText,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
type CallPredicate = (call: GetListCall) => boolean;
|
|
138
|
+
|
|
139
|
+
type RenderOptions = {
|
|
140
|
+
rows: Array<Row>;
|
|
141
|
+
/*
|
|
142
|
+
* Matched against each recorded getList call. Predicates rather than call
|
|
143
|
+
* indexes, so that a future extra fetch on mount cannot silently retarget
|
|
144
|
+
* the injected failure at the table's own page request.
|
|
145
|
+
*/
|
|
146
|
+
failWhen?: CallPredicate | undefined;
|
|
147
|
+
deferWhen?: CallPredicate | undefined;
|
|
148
|
+
/*
|
|
149
|
+
* Simulates rows being inserted while the selection is being paged: every
|
|
150
|
+
* page after the first is served from `overlapPages` rows earlier than
|
|
151
|
+
* asked, so the boundary rows come back twice.
|
|
152
|
+
*/
|
|
153
|
+
overlapPages?: number | undefined;
|
|
154
|
+
/*
|
|
155
|
+
* Serve the analytics shape, where the endpoint skips COUNT(*) and reports
|
|
156
|
+
* `hasMore` with a `count` that is only a lower bound.
|
|
157
|
+
*/
|
|
158
|
+
useHasMore?: boolean | undefined;
|
|
159
|
+
selectMoreFields?: JSONObject | undefined;
|
|
160
|
+
query?: Query<Monitor> | undefined;
|
|
161
|
+
// Renders the search box, which is otherwise hidden.
|
|
162
|
+
searchableFields?: Array<string> | undefined;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
* The bulk selection pages at BULK_SELECT_ALL_PAGE_SIZE; the table's own page
|
|
167
|
+
* fetches use its (much smaller) page size, so the limit tells them apart.
|
|
168
|
+
*/
|
|
169
|
+
type IsBulkCallFunction = (call: GetListCall) => boolean;
|
|
170
|
+
|
|
171
|
+
const isBulkCall: IsBulkCallFunction = (call: GetListCall): boolean => {
|
|
172
|
+
return call.limit === BULK_SELECT_ALL_PAGE_SIZE;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
describe("BaseModelTable bulk Select All", () => {
|
|
176
|
+
let calls: Array<GetListCall> = [];
|
|
177
|
+
let deferredResolve: (() => void) | null = null;
|
|
178
|
+
let downloadedCsvFiles: Array<{ csv: string; filename: string }> = [];
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* Split out from renderTable so a test can re-render the same table with a
|
|
182
|
+
* different query - which is how a facet/filter change reaches the table
|
|
183
|
+
* from its parent.
|
|
184
|
+
*/
|
|
185
|
+
type MakePropsFunction = (
|
|
186
|
+
options: RenderOptions,
|
|
187
|
+
) => BaseModelTableProps<Monitor>;
|
|
188
|
+
|
|
189
|
+
const makeProps: MakePropsFunction = (
|
|
190
|
+
options: RenderOptions,
|
|
191
|
+
): BaseModelTableProps<Monitor> => {
|
|
192
|
+
const callbacks: BaseTableCallbacks<Monitor> = {
|
|
193
|
+
deleteItem: async () => {
|
|
194
|
+
return undefined;
|
|
195
|
+
},
|
|
196
|
+
getModelFromJSON: (item: JSONObject) => {
|
|
197
|
+
return item as unknown as Monitor;
|
|
198
|
+
},
|
|
199
|
+
getJSONFromModel: (item: Monitor) => {
|
|
200
|
+
return item as unknown as JSONObject;
|
|
201
|
+
},
|
|
202
|
+
addSlugToSelect: (select: unknown) => {
|
|
203
|
+
return select;
|
|
204
|
+
},
|
|
205
|
+
getList: async (data: GetListCall): Promise<ListResult<Monitor>> => {
|
|
206
|
+
const call: GetListCall = {
|
|
207
|
+
query: data.query,
|
|
208
|
+
limit: data.limit,
|
|
209
|
+
skip: data.skip,
|
|
210
|
+
sort: data.sort as unknown as JSONObject,
|
|
211
|
+
select: data.select as unknown as JSONObject,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
calls.push(call);
|
|
215
|
+
|
|
216
|
+
if (options.failWhen?.(call)) {
|
|
217
|
+
throw new Error("Select all blew up");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (options.deferWhen?.(call)) {
|
|
221
|
+
await new Promise<void>((resolve: () => void) => {
|
|
222
|
+
deferredResolve = resolve;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const skip: number =
|
|
227
|
+
options.overlapPages && data.skip > 0
|
|
228
|
+
? Math.max(0, data.skip - options.overlapPages)
|
|
229
|
+
: data.skip;
|
|
230
|
+
|
|
231
|
+
const page: Array<Row> = options.rows.slice(skip, skip + data.limit);
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
* The API only returns the fields the caller selected. Honouring that
|
|
235
|
+
* here is what makes these tests able to see the bug at all: a row
|
|
236
|
+
* fetched with `select: { _id: true }` comes back as a bare id with
|
|
237
|
+
* every other field missing.
|
|
238
|
+
*/
|
|
239
|
+
const projected: Array<JSONObject> = page.map((row: Row) => {
|
|
240
|
+
const projectedRow: JSONObject = {};
|
|
241
|
+
|
|
242
|
+
for (const field of Object.keys(data.select || {})) {
|
|
243
|
+
if (field in row) {
|
|
244
|
+
projectedRow[field] = (row as unknown as JSONObject)[field];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return projectedRow;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (options.useHasMore) {
|
|
252
|
+
const hasMore: boolean = skip + page.length < options.rows.length;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
data: projected as unknown as Array<Monitor>,
|
|
256
|
+
count: skip + page.length + (hasMore ? 1 : 0),
|
|
257
|
+
hasMore: hasMore,
|
|
258
|
+
skip: data.skip,
|
|
259
|
+
limit: data.limit,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
data: projected as unknown as Array<Monitor>,
|
|
265
|
+
count: options.rows.length,
|
|
266
|
+
skip: data.skip,
|
|
267
|
+
limit: data.limit,
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
toJSONArray: () => {
|
|
271
|
+
return [];
|
|
272
|
+
},
|
|
273
|
+
updateById: async () => {
|
|
274
|
+
return undefined;
|
|
275
|
+
},
|
|
276
|
+
showCreateEditModal: () => {
|
|
277
|
+
return <></>;
|
|
278
|
+
},
|
|
279
|
+
} as unknown as BaseTableCallbacks<Monitor>;
|
|
280
|
+
|
|
281
|
+
const props: BaseModelTableProps<Monitor> = {
|
|
282
|
+
modelType: Monitor,
|
|
283
|
+
id: "monitors-table",
|
|
284
|
+
name: "Monitors",
|
|
285
|
+
singularName: "Monitor",
|
|
286
|
+
pluralName: "Monitors",
|
|
287
|
+
userPreferencesKey: "monitors-bulk-table",
|
|
288
|
+
urlStateKey: "monitors-bulk-table",
|
|
289
|
+
columns: COLUMNS,
|
|
290
|
+
filters: [],
|
|
291
|
+
isDeleteable: false,
|
|
292
|
+
isCreateable: false,
|
|
293
|
+
isViewable: false,
|
|
294
|
+
isEditable: false,
|
|
295
|
+
callbacks: callbacks,
|
|
296
|
+
bulkActions: {
|
|
297
|
+
buttons: [
|
|
298
|
+
{
|
|
299
|
+
title: "Archive",
|
|
300
|
+
buttonStyleType: ButtonStyleType.NORMAL,
|
|
301
|
+
onClick: async (): Promise<void> => {
|
|
302
|
+
return Promise.resolve();
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
...(options.selectMoreFields
|
|
308
|
+
? { selectMoreFields: options.selectMoreFields }
|
|
309
|
+
: {}),
|
|
310
|
+
...(options.query ? { query: options.query } : {}),
|
|
311
|
+
...(options.searchableFields
|
|
312
|
+
? { searchableFields: options.searchableFields }
|
|
313
|
+
: {}),
|
|
314
|
+
} as unknown as BaseModelTableProps<Monitor>;
|
|
315
|
+
|
|
316
|
+
return props;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
type RenderTableFunction = (
|
|
320
|
+
options: RenderOptions,
|
|
321
|
+
) => ReturnType<typeof render>;
|
|
322
|
+
|
|
323
|
+
const renderTable: RenderTableFunction = (
|
|
324
|
+
options: RenderOptions,
|
|
325
|
+
): ReturnType<typeof render> => {
|
|
326
|
+
return render(<BaseModelTable<Monitor> {...makeProps(options)} />);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
type RerenderTableFunction = (
|
|
330
|
+
rerender: ReturnType<typeof render>["rerender"],
|
|
331
|
+
options: RenderOptions,
|
|
332
|
+
) => void;
|
|
333
|
+
|
|
334
|
+
const rerenderTable: RerenderTableFunction = (
|
|
335
|
+
rerender: ReturnType<typeof render>["rerender"],
|
|
336
|
+
options: RenderOptions,
|
|
337
|
+
): void => {
|
|
338
|
+
rerender(<BaseModelTable<Monitor> {...makeProps(options)} />);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
type SelectCurrentPageFunction = (container: HTMLElement) => Promise<void>;
|
|
342
|
+
|
|
343
|
+
/*
|
|
344
|
+
* Ticks the header checkbox, which selects the rows on screen and is what
|
|
345
|
+
* makes the bulk bar (and its "Select All" button) appear in the first
|
|
346
|
+
* place.
|
|
347
|
+
*/
|
|
348
|
+
const selectCurrentPage: SelectCurrentPageFunction = async (
|
|
349
|
+
container: HTMLElement,
|
|
350
|
+
): Promise<void> => {
|
|
351
|
+
// The header checkbox is disabled until the first page has rendered.
|
|
352
|
+
await waitFor(() => {
|
|
353
|
+
expect(
|
|
354
|
+
container.querySelectorAll('input[type="checkbox"]:not([disabled])')
|
|
355
|
+
.length,
|
|
356
|
+
).toBeGreaterThan(0);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const checkboxes: NodeListOf<HTMLInputElement> =
|
|
360
|
+
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
|
|
361
|
+
|
|
362
|
+
fireEvent.click(checkboxes[0]!);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
type BulkCallsFunction = () => Array<GetListCall>;
|
|
366
|
+
|
|
367
|
+
const bulkCalls: BulkCallsFunction = (): Array<GetListCall> => {
|
|
368
|
+
return calls.filter(isBulkCall);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const isSelectAllCall: CallPredicate = (call: GetListCall): boolean => {
|
|
372
|
+
return isBulkCall(call);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Only the opening page, so later pages resolve on their own.
|
|
376
|
+
const isFirstSelectAllCall: CallPredicate = (call: GetListCall): boolean => {
|
|
377
|
+
return isBulkCall(call) && call.skip === 0;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
calls = [];
|
|
382
|
+
deferredResolve = null;
|
|
383
|
+
downloadedCsvFiles = [];
|
|
384
|
+
window.history.replaceState(
|
|
385
|
+
window.history.state,
|
|
386
|
+
"",
|
|
387
|
+
"/dashboard/monitors",
|
|
388
|
+
);
|
|
389
|
+
TableFilterUrlState.resetClaimedKeys();
|
|
390
|
+
window.localStorage.clear();
|
|
391
|
+
|
|
392
|
+
/*
|
|
393
|
+
* Intercepting the download rather than the Blob keeps the assertion on
|
|
394
|
+
* the exact bytes the user would have opened in a spreadsheet.
|
|
395
|
+
*/
|
|
396
|
+
jest
|
|
397
|
+
.spyOn(TableColumnsToCsv, "downloadCsv")
|
|
398
|
+
.mockImplementation((data: { csv: string; filename: string }): void => {
|
|
399
|
+
downloadedCsvFiles.push(data);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
afterEach(() => {
|
|
404
|
+
cleanup();
|
|
405
|
+
jest.restoreAllMocks();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("it fetches the same columns as the page fetch, not just _id", async () => {
|
|
409
|
+
/*
|
|
410
|
+
* Regression for the reported bug: "Select All" then "Export CSV"
|
|
411
|
+
* produced a file with a header row and nothing else. Select All asked
|
|
412
|
+
* for `select: { _id: true }`, so every selected row was an id with no
|
|
413
|
+
* fields on it and every exported cell came out empty.
|
|
414
|
+
*/
|
|
415
|
+
const { container, getByText } = renderTable({ rows: makeRows(25) });
|
|
416
|
+
|
|
417
|
+
await waitFor(() => {
|
|
418
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const pageSelect: JSONObject = calls[0]!.select;
|
|
422
|
+
|
|
423
|
+
await selectCurrentPage(container);
|
|
424
|
+
|
|
425
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
426
|
+
|
|
427
|
+
await waitFor(() => {
|
|
428
|
+
expect(bulkCalls().length).toBe(1);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const bulkSelect: JSONObject = bulkCalls()[0]!.select;
|
|
432
|
+
|
|
433
|
+
// Every column the table renders is fetched for the selection too.
|
|
434
|
+
for (const field of Object.keys(pageSelect)) {
|
|
435
|
+
expect(bulkSelect[field]).toEqual(pageSelect[field]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
expect(bulkSelect["name"]).toBe(true);
|
|
439
|
+
expect(bulkSelect["description"]).toBe(true);
|
|
440
|
+
expect(bulkSelect["_id"]).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("it selects the column it orders by, which the database requires", async () => {
|
|
444
|
+
const { container, getByText } = renderTable({ rows: makeRows(25) });
|
|
445
|
+
|
|
446
|
+
await waitFor(() => {
|
|
447
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await selectCurrentPage(container);
|
|
451
|
+
|
|
452
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
453
|
+
|
|
454
|
+
await waitFor(() => {
|
|
455
|
+
expect(bulkCalls().length).toBe(1);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
for (const sortColumn of Object.keys(bulkCalls()[0]!.sort)) {
|
|
459
|
+
expect(bulkCalls()[0]!.select[sortColumn]).toBe(true);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("the exported CSV of a select-all carries real values on every row", async () => {
|
|
464
|
+
// More rows than fit on a page, so this cannot pass on the page selection.
|
|
465
|
+
const { container, getByText } = renderTable({ rows: makeRows(25) });
|
|
466
|
+
|
|
467
|
+
await waitFor(() => {
|
|
468
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await selectCurrentPage(container);
|
|
472
|
+
|
|
473
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
474
|
+
|
|
475
|
+
await waitFor(() => {
|
|
476
|
+
expect(getByText("25 Monitors Selected")).toBeTruthy();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
fireEvent.click(getByText("Bulk Actions"));
|
|
480
|
+
fireEvent.click(getByText("Export CSV"));
|
|
481
|
+
|
|
482
|
+
await waitFor(() => {
|
|
483
|
+
expect(downloadedCsvFiles.length).toBe(1);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const lines: Array<string> = downloadedCsvFiles[0]!.csv.split("\r\n");
|
|
487
|
+
|
|
488
|
+
expect(lines.length).toBe(26);
|
|
489
|
+
expect(lines[0]).toBe("Name,Description");
|
|
490
|
+
expect(lines[1]).toBe("Monitor 0,Description 0");
|
|
491
|
+
expect(lines[25]).toBe("Monitor 24,Description 24");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("it selects every matching row, not just the current page", async () => {
|
|
495
|
+
const { container, getByText, queryByText } = renderTable({
|
|
496
|
+
rows: makeRows(1262),
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await waitFor(() => {
|
|
500
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
await selectCurrentPage(container);
|
|
504
|
+
|
|
505
|
+
// The page holds 10 rows at the default page size.
|
|
506
|
+
expect(getByText("10 Monitors Selected")).toBeTruthy();
|
|
507
|
+
|
|
508
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
509
|
+
|
|
510
|
+
await waitFor(() => {
|
|
511
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Everything is selected, so there is nothing left for the button to do.
|
|
515
|
+
expect(queryByText("Select All Monitors")).toBeNull();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("it pages the fetch instead of asking for everything at once", async () => {
|
|
519
|
+
const { container, getByText } = renderTable({ rows: makeRows(1262) });
|
|
520
|
+
|
|
521
|
+
await waitFor(() => {
|
|
522
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await selectCurrentPage(container);
|
|
526
|
+
|
|
527
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
528
|
+
|
|
529
|
+
await waitFor(() => {
|
|
530
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(
|
|
534
|
+
bulkCalls().map((call: GetListCall) => {
|
|
535
|
+
return call.skip;
|
|
536
|
+
}),
|
|
537
|
+
).toEqual([0, BULK_SELECT_ALL_PAGE_SIZE]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("it stops at the first short page", async () => {
|
|
541
|
+
const { container, getByText } = renderTable({ rows: makeRows(300) });
|
|
542
|
+
|
|
543
|
+
await waitFor(() => {
|
|
544
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
await selectCurrentPage(container);
|
|
548
|
+
|
|
549
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
550
|
+
|
|
551
|
+
await waitFor(() => {
|
|
552
|
+
expect(getByText("300 Monitors Selected")).toBeTruthy();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(bulkCalls().length).toBe(1);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("it does not spend a round trip proving an exact multiple is finished", async () => {
|
|
559
|
+
/*
|
|
560
|
+
* Every list request also runs an unbounded count over the filtered set,
|
|
561
|
+
* so the empty page at the end is not free.
|
|
562
|
+
*/
|
|
563
|
+
const { container, getByText } = renderTable({
|
|
564
|
+
rows: makeRows(BULK_SELECT_ALL_PAGE_SIZE * 2),
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
await waitFor(() => {
|
|
568
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
await selectCurrentPage(container);
|
|
572
|
+
|
|
573
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
574
|
+
|
|
575
|
+
await waitFor(() => {
|
|
576
|
+
expect(
|
|
577
|
+
getByText(`${BULK_SELECT_ALL_PAGE_SIZE * 2} Monitors Selected`),
|
|
578
|
+
).toBeTruthy();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
expect(bulkCalls().length).toBe(2);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("it stops on hasMore for endpoints that report no exact count", async () => {
|
|
585
|
+
const { container, getByText, queryByText } = renderTable({
|
|
586
|
+
rows: makeRows(BULK_SELECT_ALL_PAGE_SIZE),
|
|
587
|
+
useHasMore: true,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await waitFor(() => {
|
|
591
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
await selectCurrentPage(container);
|
|
595
|
+
|
|
596
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
597
|
+
|
|
598
|
+
await waitFor(() => {
|
|
599
|
+
expect(
|
|
600
|
+
getByText(`${BULK_SELECT_ALL_PAGE_SIZE} Monitors Selected`),
|
|
601
|
+
).toBeTruthy();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(bulkCalls().length).toBe(1);
|
|
605
|
+
// Nothing was left behind, so there is nothing to warn about.
|
|
606
|
+
expect(queryByText(/matching Monitors/)).toBeNull();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("a row served twice across pages is only selected once", async () => {
|
|
610
|
+
/*
|
|
611
|
+
* Offset paging over a table that is still taking writes can hand back a
|
|
612
|
+
* row an earlier page already returned. Selecting it twice would
|
|
613
|
+
* duplicate it in the export and inflate the count.
|
|
614
|
+
*/
|
|
615
|
+
const { container, getByText } = renderTable({
|
|
616
|
+
rows: makeRows(1262),
|
|
617
|
+
overlapPages: 5,
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
await waitFor(() => {
|
|
621
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
await selectCurrentPage(container);
|
|
625
|
+
|
|
626
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
627
|
+
|
|
628
|
+
await waitFor(() => {
|
|
629
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
fireEvent.click(getByText("Bulk Actions"));
|
|
633
|
+
fireEvent.click(getByText("Export CSV"));
|
|
634
|
+
|
|
635
|
+
await waitFor(() => {
|
|
636
|
+
expect(downloadedCsvFiles.length).toBe(1);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const lines: Array<string> = downloadedCsvFiles[0]!.csv.split("\r\n");
|
|
640
|
+
|
|
641
|
+
expect(lines.length).toBe(1263);
|
|
642
|
+
expect(new Set(lines.slice(1)).size).toBe(1262);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("an unsorted table keeps the order it is displayed in", async () => {
|
|
646
|
+
/*
|
|
647
|
+
* The API only applies its default ordering when no sort is sent, so the
|
|
648
|
+
* selection has to name that ordering itself - otherwise a select-all
|
|
649
|
+
* exports in an order unrelated to the table, and a selection capped at
|
|
650
|
+
* the ceiling keeps an arbitrary slice instead of the newest rows.
|
|
651
|
+
*/
|
|
652
|
+
const { container, getByText } = renderTable({ rows: makeRows(25) });
|
|
653
|
+
|
|
654
|
+
await waitFor(() => {
|
|
655
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
await selectCurrentPage(container);
|
|
659
|
+
|
|
660
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
661
|
+
|
|
662
|
+
await waitFor(() => {
|
|
663
|
+
expect(bulkCalls().length).toBe(1);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
expect(bulkCalls()[0]!.sort).toEqual({
|
|
667
|
+
createdAt: SortOrder.Descending,
|
|
668
|
+
_id: SortOrder.Ascending,
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("an active column sort is kept, with the id appended as a tiebreaker", async () => {
|
|
673
|
+
const { container, getByText } = renderTable({ rows: makeRows(25) });
|
|
674
|
+
|
|
675
|
+
await waitFor(() => {
|
|
676
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Sorting by the "Name" header puts a sort on the table.
|
|
680
|
+
fireEvent.click(getByText("Name"));
|
|
681
|
+
|
|
682
|
+
await waitFor(() => {
|
|
683
|
+
expect(calls[calls.length - 1]!.sort).toEqual({
|
|
684
|
+
name: SortOrder.Descending,
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
await selectCurrentPage(container);
|
|
689
|
+
|
|
690
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
691
|
+
|
|
692
|
+
await waitFor(() => {
|
|
693
|
+
expect(bulkCalls().length).toBe(1);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
expect(bulkCalls()[0]!.sort).toEqual({
|
|
697
|
+
name: SortOrder.Descending,
|
|
698
|
+
_id: SortOrder.Ascending,
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("it carries the table's query so it never selects outside the filters", async () => {
|
|
703
|
+
const query: Query<Monitor> = {
|
|
704
|
+
projectId: "project-1",
|
|
705
|
+
} as unknown as Query<Monitor>;
|
|
706
|
+
|
|
707
|
+
const { container, getByText } = renderTable({
|
|
708
|
+
rows: makeRows(25),
|
|
709
|
+
query: query,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
await waitFor(() => {
|
|
713
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
await selectCurrentPage(container);
|
|
717
|
+
|
|
718
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
719
|
+
|
|
720
|
+
await waitFor(() => {
|
|
721
|
+
expect(bulkCalls().length).toBe(1);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
expect(bulkCalls()[0]!.query).toEqual(calls[0]!.query);
|
|
725
|
+
expect(bulkCalls()[0]!.query["projectId"]).toBe("project-1");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("it honours selectMoreFields, which a hand-rolled column list would drop", async () => {
|
|
729
|
+
const { container, getByText } = renderTable({
|
|
730
|
+
rows: makeRows(25),
|
|
731
|
+
selectMoreFields: { monitorType: true },
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
await waitFor(() => {
|
|
735
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
await selectCurrentPage(container);
|
|
739
|
+
|
|
740
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
741
|
+
|
|
742
|
+
await waitFor(() => {
|
|
743
|
+
expect(bulkCalls().length).toBe(1);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
expect(bulkCalls()[0]!.select["monitorType"]).toBe(true);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("it stops at the selection ceiling and says so", async () => {
|
|
750
|
+
const { container, getByText } = renderTable({
|
|
751
|
+
rows: makeRows(LIMIT_PER_PROJECT + 250),
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
await waitFor(() => {
|
|
755
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
await selectCurrentPage(container);
|
|
759
|
+
|
|
760
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
761
|
+
|
|
762
|
+
await waitFor(() => {
|
|
763
|
+
expect(getByText(`${LIMIT_PER_PROJECT} Monitors Selected`)).toBeTruthy();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
expect(bulkCalls().length).toBe(
|
|
767
|
+
LIMIT_PER_PROJECT / BULK_SELECT_ALL_PAGE_SIZE,
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
// The warning names the real numbers rather than inferring truncation.
|
|
771
|
+
expect(
|
|
772
|
+
getByText(
|
|
773
|
+
new RegExp(
|
|
774
|
+
`Selected ${LIMIT_PER_PROJECT.toLocaleString()} of ${(
|
|
775
|
+
LIMIT_PER_PROJECT + 250
|
|
776
|
+
).toLocaleString()} matching Monitors`,
|
|
777
|
+
),
|
|
778
|
+
),
|
|
779
|
+
).toBeTruthy();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("it does not warn about truncation when everything was selected", async () => {
|
|
783
|
+
const { container, getByText, queryByText } = renderTable({
|
|
784
|
+
rows: makeRows(25),
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
await waitFor(() => {
|
|
788
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
await selectCurrentPage(container);
|
|
792
|
+
|
|
793
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
794
|
+
|
|
795
|
+
await waitFor(() => {
|
|
796
|
+
expect(getByText("25 Monitors Selected")).toBeTruthy();
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
expect(queryByText(/matching Monitors/)).toBeNull();
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("a failed select-all keeps the previous selection and stays retryable", async () => {
|
|
803
|
+
/*
|
|
804
|
+
* The failure mode behind "even though I selected Select All, only the
|
|
805
|
+
* records on the current page are selected": the request failed, the
|
|
806
|
+
* error was swallowed, and the bulk bar hid the Select All button while
|
|
807
|
+
* still holding just the page rows.
|
|
808
|
+
*/
|
|
809
|
+
const { container, getByText, queryByText } = renderTable({
|
|
810
|
+
rows: makeRows(1262),
|
|
811
|
+
failWhen: isSelectAllCall,
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
await waitFor(() => {
|
|
815
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
await selectCurrentPage(container);
|
|
819
|
+
|
|
820
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
821
|
+
|
|
822
|
+
await waitFor(() => {
|
|
823
|
+
expect(getByText("Select all blew up")).toBeTruthy();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// The page selection survived untouched...
|
|
827
|
+
expect(getByText("10 Monitors Selected")).toBeTruthy();
|
|
828
|
+
// ...and the button is still there to try again.
|
|
829
|
+
expect(queryByText("Select All Monitors")).not.toBeNull();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("a failed select-all can be retried, and the error goes away", async () => {
|
|
833
|
+
let shouldFail: boolean = true;
|
|
834
|
+
|
|
835
|
+
const { container, getByText, queryByText } = renderTable({
|
|
836
|
+
rows: makeRows(1262),
|
|
837
|
+
failWhen: (call: GetListCall): boolean => {
|
|
838
|
+
return isSelectAllCall(call) && shouldFail;
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
await waitFor(() => {
|
|
843
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
await selectCurrentPage(container);
|
|
847
|
+
|
|
848
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
849
|
+
|
|
850
|
+
await waitFor(() => {
|
|
851
|
+
expect(getByText("Select all blew up")).toBeTruthy();
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
shouldFail = false;
|
|
855
|
+
|
|
856
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
857
|
+
|
|
858
|
+
await waitFor(() => {
|
|
859
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// A stale failure banner under a complete selection would be a lie.
|
|
863
|
+
expect(queryByText("Select all blew up")).toBeNull();
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("it reports progress and blocks a second run while it is loading", async () => {
|
|
867
|
+
const { container, getByText, queryByText } = renderTable({
|
|
868
|
+
rows: makeRows(1262),
|
|
869
|
+
deferWhen: isFirstSelectAllCall,
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
await waitFor(() => {
|
|
873
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
await selectCurrentPage(container);
|
|
877
|
+
|
|
878
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
879
|
+
|
|
880
|
+
await waitFor(() => {
|
|
881
|
+
expect(getByText("Selecting All Monitors...")).toBeTruthy();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// A second click while it runs must not start an overlapping fetch.
|
|
885
|
+
fireEvent.click(getByText("Selecting All Monitors..."));
|
|
886
|
+
|
|
887
|
+
await act(async () => {
|
|
888
|
+
deferredResolve!();
|
|
889
|
+
await new Promise<void>((resolve: () => void) => {
|
|
890
|
+
setTimeout(resolve, 50);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
await waitFor(() => {
|
|
895
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
expect(queryByText("Selecting All Monitors...")).toBeNull();
|
|
899
|
+
expect(bulkCalls().length).toBe(2);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("clearing the selection wins over a select-all still in flight", async () => {
|
|
903
|
+
const { container, getByText, queryByText } = renderTable({
|
|
904
|
+
rows: makeRows(1262),
|
|
905
|
+
deferWhen: isFirstSelectAllCall,
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
await waitFor(() => {
|
|
909
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
await selectCurrentPage(container);
|
|
913
|
+
|
|
914
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
915
|
+
|
|
916
|
+
await waitFor(() => {
|
|
917
|
+
expect(deferredResolve).not.toBeNull();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
fireEvent.click(getByText("Clear Selection"));
|
|
921
|
+
|
|
922
|
+
// The bulk bar is gone once nothing is selected.
|
|
923
|
+
await waitFor(() => {
|
|
924
|
+
expect(queryByText("Clear Selection")).toBeNull();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
await act(async () => {
|
|
928
|
+
deferredResolve!();
|
|
929
|
+
await new Promise<void>((resolve: () => void) => {
|
|
930
|
+
setTimeout(resolve, 50);
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// The superseded run must not resurrect a selection the user cleared...
|
|
935
|
+
expect(queryByText(/Monitors Selected/)).toBeNull();
|
|
936
|
+
// ...nor keep paging for it.
|
|
937
|
+
expect(bulkCalls().length).toBe(1);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
/*
|
|
941
|
+
* A selection names rows the *previous* query returned. The dangerous shape
|
|
942
|
+
* is "select all 6,000, then filter down to 12": the bulk bar kept claiming
|
|
943
|
+
* 6,000 selected, kept hiding "Select All" as though the selection matched
|
|
944
|
+
* what was on screen, and a bulk Delete would have deleted 6,000 rows the
|
|
945
|
+
* user could no longer see.
|
|
946
|
+
*/
|
|
947
|
+
describe("a selection does not outlive the query that produced it", () => {
|
|
948
|
+
const PROJECT_1: Query<Monitor> = {
|
|
949
|
+
projectId: "project-1",
|
|
950
|
+
} as unknown as Query<Monitor>;
|
|
951
|
+
|
|
952
|
+
const PROJECT_2: Query<Monitor> = {
|
|
953
|
+
projectId: "project-2",
|
|
954
|
+
} as unknown as Query<Monitor>;
|
|
955
|
+
|
|
956
|
+
type GetSearchInputFunction = (container: HTMLElement) => HTMLInputElement;
|
|
957
|
+
|
|
958
|
+
const getSearchInput: GetSearchInputFunction = (
|
|
959
|
+
container: HTMLElement,
|
|
960
|
+
): HTMLInputElement => {
|
|
961
|
+
return container.querySelector<HTMLInputElement>('input[type="text"]')!;
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
type GoToNextPageFunction = (container: HTMLElement) => void;
|
|
965
|
+
|
|
966
|
+
/*
|
|
967
|
+
* Pagination renders a desktop and a mobile control, so take whichever one
|
|
968
|
+
* is currently enabled rather than assuming a layout.
|
|
969
|
+
*/
|
|
970
|
+
const goToNextPage: GoToNextPageFunction = (
|
|
971
|
+
container: HTMLElement,
|
|
972
|
+
): void => {
|
|
973
|
+
const nextButtons: NodeListOf<HTMLElement> =
|
|
974
|
+
container.querySelectorAll<HTMLElement>(
|
|
975
|
+
'[aria-label="Go to next page"]',
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
for (const button of Array.from(nextButtons)) {
|
|
979
|
+
if (!button.hasAttribute("disabled")) {
|
|
980
|
+
fireEvent.click(button);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
throw new Error("No enabled next page button was found");
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
test("a narrowing query change drops a selection made under the old one", async () => {
|
|
989
|
+
const { container, getByText, queryByText, rerender } = renderTable({
|
|
990
|
+
rows: makeRows(1262),
|
|
991
|
+
query: PROJECT_1,
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
await waitFor(() => {
|
|
995
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
await selectCurrentPage(container);
|
|
999
|
+
|
|
1000
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
1001
|
+
|
|
1002
|
+
await waitFor(() => {
|
|
1003
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// The parent narrows the table - a facet click, say - down to 12 rows.
|
|
1007
|
+
rerenderTable(rerender, { rows: makeRows(12), query: PROJECT_2 });
|
|
1008
|
+
|
|
1009
|
+
await waitFor(() => {
|
|
1010
|
+
expect(queryByText(/Monitors Selected/)).toBeNull();
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
/*
|
|
1014
|
+
* The whole bar goes with it. Leaving "Bulk Actions" on screen over a
|
|
1015
|
+
* stale selection is the part that deletes rows the user cannot see.
|
|
1016
|
+
*/
|
|
1017
|
+
expect(queryByText("Bulk Actions")).toBeNull();
|
|
1018
|
+
expect(queryByText("Clear Selection")).toBeNull();
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
test("the Select All button comes back after the query changes", async () => {
|
|
1022
|
+
/*
|
|
1023
|
+
* "All items selected" lives in the table component, and used to survive
|
|
1024
|
+
* every clear except an explicit one - so the next row the user ticked
|
|
1025
|
+
* re-opened the bar with no way to select all of the *new* result set.
|
|
1026
|
+
*/
|
|
1027
|
+
const { container, getByText, queryByText, rerender } = renderTable({
|
|
1028
|
+
rows: makeRows(1262),
|
|
1029
|
+
query: PROJECT_1,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
await waitFor(() => {
|
|
1033
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
await selectCurrentPage(container);
|
|
1037
|
+
|
|
1038
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
1039
|
+
|
|
1040
|
+
await waitFor(() => {
|
|
1041
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// Everything is selected, so the button is gone.
|
|
1045
|
+
expect(queryByText("Select All Monitors")).toBeNull();
|
|
1046
|
+
|
|
1047
|
+
// Fewer rows than fit on a page, so the page selection is all of them.
|
|
1048
|
+
rerenderTable(rerender, { rows: makeRows(6), query: PROJECT_2 });
|
|
1049
|
+
|
|
1050
|
+
await waitFor(() => {
|
|
1051
|
+
expect(queryByText(/Monitors Selected/)).toBeNull();
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
/*
|
|
1055
|
+
* The old page stays on screen (then a loader) until the refetch lands,
|
|
1056
|
+
* and ticking before the new rows arrive would select the rows on their
|
|
1057
|
+
* way out. There is one checkbox per row, plus the header's.
|
|
1058
|
+
*/
|
|
1059
|
+
await waitFor(() => {
|
|
1060
|
+
expect(
|
|
1061
|
+
container.querySelectorAll('input[type="checkbox"]').length,
|
|
1062
|
+
).toBe(7);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
await selectCurrentPage(container);
|
|
1066
|
+
|
|
1067
|
+
await waitFor(() => {
|
|
1068
|
+
expect(getByText("6 Monitors Selected")).toBeTruthy();
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
expect(queryByText("Select All Monitors")).not.toBeNull();
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("a query change supersedes a select-all still in flight", async () => {
|
|
1075
|
+
const { container, getByText, queryByText, rerender } = renderTable({
|
|
1076
|
+
rows: makeRows(1262),
|
|
1077
|
+
deferWhen: isFirstSelectAllCall,
|
|
1078
|
+
query: PROJECT_1,
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
await waitFor(() => {
|
|
1082
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
await selectCurrentPage(container);
|
|
1086
|
+
|
|
1087
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
1088
|
+
|
|
1089
|
+
await waitFor(() => {
|
|
1090
|
+
expect(deferredResolve).not.toBeNull();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// No deferWhen this time, so a run that kept going would be visible.
|
|
1094
|
+
rerenderTable(rerender, { rows: makeRows(12), query: PROJECT_2 });
|
|
1095
|
+
|
|
1096
|
+
await act(async () => {
|
|
1097
|
+
deferredResolve!();
|
|
1098
|
+
await new Promise<void>((resolve: () => void) => {
|
|
1099
|
+
setTimeout(resolve, 50);
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// The superseded run must not select rows matching the old query...
|
|
1104
|
+
expect(queryByText(/Monitors Selected/)).toBeNull();
|
|
1105
|
+
// ...nor spend more requests paging for them.
|
|
1106
|
+
expect(bulkCalls().length).toBe(1);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test("typing a search term drops the selection", async () => {
|
|
1110
|
+
const { container, getByText, queryByText } = renderTable({
|
|
1111
|
+
rows: makeRows(1262),
|
|
1112
|
+
searchableFields: ["name"],
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
await waitFor(() => {
|
|
1116
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
await selectCurrentPage(container);
|
|
1120
|
+
|
|
1121
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
1122
|
+
|
|
1123
|
+
await waitFor(() => {
|
|
1124
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
fireEvent.change(getSearchInput(container), {
|
|
1128
|
+
target: { value: "gateway" },
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// The search is debounced, so this lands a little after the keystroke.
|
|
1132
|
+
await waitFor(() => {
|
|
1133
|
+
expect(queryByText(/Monitors Selected/)).toBeNull();
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test("clearing a search term drops the selection too", async () => {
|
|
1138
|
+
/*
|
|
1139
|
+
* Widening is as wrong as narrowing: the selection would then be a
|
|
1140
|
+
* strict subset presented as "all of them".
|
|
1141
|
+
*/
|
|
1142
|
+
const { container, getByText, queryByText } = renderTable({
|
|
1143
|
+
rows: makeRows(300),
|
|
1144
|
+
searchableFields: ["name"],
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
await waitFor(() => {
|
|
1148
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
fireEvent.change(getSearchInput(container), {
|
|
1152
|
+
target: { value: "gateway" },
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
await waitFor(() => {
|
|
1156
|
+
const lastQuery: JSONObject = calls[calls.length - 1]!
|
|
1157
|
+
.query as unknown as JSONObject;
|
|
1158
|
+
expect(lastQuery["_multiFieldSearch"]).toBeTruthy();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
await selectCurrentPage(container);
|
|
1162
|
+
|
|
1163
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
1164
|
+
|
|
1165
|
+
await waitFor(() => {
|
|
1166
|
+
expect(getByText("300 Monitors Selected")).toBeTruthy();
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
fireEvent.change(getSearchInput(container), { target: { value: "" } });
|
|
1170
|
+
|
|
1171
|
+
await waitFor(() => {
|
|
1172
|
+
expect(queryByText(/Monitors Selected/)).toBeNull();
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test("paging keeps the selection, because the matching rows are the same", async () => {
|
|
1177
|
+
/*
|
|
1178
|
+
* Deliberate: a page turn re-windows the same result set, and ticking
|
|
1179
|
+
* rows across pages before acting on them is a real workflow.
|
|
1180
|
+
*/
|
|
1181
|
+
const { container, getByText } = renderTable({ rows: makeRows(1262) });
|
|
1182
|
+
|
|
1183
|
+
await waitFor(() => {
|
|
1184
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
await selectCurrentPage(container);
|
|
1188
|
+
|
|
1189
|
+
expect(getByText("10 Monitors Selected")).toBeTruthy();
|
|
1190
|
+
|
|
1191
|
+
goToNextPage(container);
|
|
1192
|
+
|
|
1193
|
+
await waitFor(() => {
|
|
1194
|
+
expect(calls[calls.length - 1]!.skip).toBe(10);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
expect(getByText("10 Monitors Selected")).toBeTruthy();
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
test("sorting keeps the selection, because the matching rows are the same", async () => {
|
|
1201
|
+
/*
|
|
1202
|
+
* Also deliberate. A sort re-orders the matching set without changing
|
|
1203
|
+
* it, and a truncated select-all keeps reporting "selected N of M
|
|
1204
|
+
* matching", which stays true afterwards.
|
|
1205
|
+
*/
|
|
1206
|
+
const { container, getByText, queryByText } = renderTable({
|
|
1207
|
+
rows: makeRows(1262),
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
await waitFor(() => {
|
|
1211
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
await selectCurrentPage(container);
|
|
1215
|
+
|
|
1216
|
+
fireEvent.click(getByText("Select All Monitors"));
|
|
1217
|
+
|
|
1218
|
+
await waitFor(() => {
|
|
1219
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
fireEvent.click(getByText("Name"));
|
|
1223
|
+
|
|
1224
|
+
await waitFor(() => {
|
|
1225
|
+
expect(calls[calls.length - 1]!.sort).toEqual({
|
|
1226
|
+
name: SortOrder.Descending,
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
expect(getByText("1262 Monitors Selected")).toBeTruthy();
|
|
1231
|
+
// Still everything, so the button stays away.
|
|
1232
|
+
expect(queryByText("Select All Monitors")).toBeNull();
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("selecting only the current page still exports just those rows", async () => {
|
|
1237
|
+
const { container, getByText } = renderTable({ rows: makeRows(25) });
|
|
1238
|
+
|
|
1239
|
+
await waitFor(() => {
|
|
1240
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
await selectCurrentPage(container);
|
|
1244
|
+
|
|
1245
|
+
fireEvent.click(getByText("Bulk Actions"));
|
|
1246
|
+
fireEvent.click(getByText("Export CSV"));
|
|
1247
|
+
|
|
1248
|
+
await waitFor(() => {
|
|
1249
|
+
expect(downloadedCsvFiles.length).toBe(1);
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
const lines: Array<string> = downloadedCsvFiles[0]!.csv.split("\r\n");
|
|
1253
|
+
|
|
1254
|
+
// Header plus the ten rows on screen.
|
|
1255
|
+
expect(lines.length).toBe(11);
|
|
1256
|
+
expect(lines[1]).toBe("Monitor 0,Description 0");
|
|
1257
|
+
});
|
|
1258
|
+
});
|