@oneuptime/common 12.0.9 → 12.0.11
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/NetworkDevice.ts +44 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
- package/Models/DatabaseModels/RunnerJob.ts +17 -2
- package/Server/API/LlmProviderAPI.ts +8 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
- package/Server/Services/NetworkDeviceLinkService.ts +338 -2
- package/Server/Services/NetworkDeviceService.ts +265 -12
- package/Server/Services/RunnerJobService.ts +24 -1
- package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
- package/Server/Types/Database/JSONColumnQuery.ts +42 -4
- package/Server/Utils/LLM/LLMService.ts +108 -19
- package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
- package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
- package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
- package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
- package/Server/Utils/SSRFProtection.ts +5 -4
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
- package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
- package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
- package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
- package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
- package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
- package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
- package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
- package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
- package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
- package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
- package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
- package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
- package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
- package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
- package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
- package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
- package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
- package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
- package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
- package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
- package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
- package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
- package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
- package/Tests/Types/API/URLTelScheme.test.ts +261 -0
- package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
- package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
- package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
- package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
- package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
- package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
- package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
- package/Tests/UI/Components/Button.test.tsx +279 -0
- package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
- package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
- package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
- package/Tests/UI/Components/Icon.test.tsx +57 -0
- package/Tests/UI/Components/List.test.tsx +20 -3
- package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
- package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
- package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
- package/Tests/UI/Components/Pagination.test.tsx +721 -146
- package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
- package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
- package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
- package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
- package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
- package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
- package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
- package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
- package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
- package/Types/API/Protocol.ts +9 -0
- package/Types/API/URL.ts +286 -20
- package/Types/Billing/PayAsYouGoPricing.ts +47 -0
- package/Types/CustomField/CustomFieldType.ts +28 -0
- package/Types/Monitor/MonitorType.ts +11 -0
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
- package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
- package/Types/Runbook/RunbookStepType.ts +23 -0
- package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
- package/UI/Components/Button/Button.tsx +14 -22
- package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
- package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
- package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
- package/UI/Components/Charts/Line/LineChart.tsx +4 -2
- package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
- package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
- package/UI/Components/Dictionary/Dictionary.tsx +4 -39
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
- package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
- package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
- package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
- package/UI/Components/Pagination/Pagination.tsx +373 -328
- package/UI/Components/Pagination/PaginationUtil.ts +263 -0
- package/UI/Components/Pagination/Screenshots/README.md +15 -0
- package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
- package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
- package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
- package/UI/Components/TextArea/TextArea.tsx +3 -0
- package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
- package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
- package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
- package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
- package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
- package/Utils/API.ts +243 -36
- package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
- package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
- package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
- package/build/dist/Server/API/LlmProviderAPI.js +9 -1
- package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/RunnerJobService.js +20 -2
- package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
- package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
- package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
- package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
- package/build/dist/Server/Utils/SSRFProtection.js +5 -4
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
- package/build/dist/Types/API/Protocol.js +9 -0
- package/build/dist/Types/API/Protocol.js.map +1 -1
- package/build/dist/Types/API/URL.js +241 -18
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
- package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
- package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +10 -0
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
- package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
- package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
- package/build/dist/UI/Components/Button/Button.js +10 -16
- package/build/dist/UI/Components/Button/Button.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
- package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
- package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
- package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
- package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
- package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
- package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
- package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
- package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
- package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
- package/build/dist/Utils/API.js +175 -34
- package/build/dist/Utils/API.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
- package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { beforeEach, describe, expect, jest, test } from "@jest/globals";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* This modal only COLLECTS. It asks for a project, one of that project's teams
|
|
10
|
+
* and whether to accept the invitations, then hands the three of them to
|
|
11
|
+
* props.onSubmit - the page is what creates the TeamMembers and feeds the
|
|
12
|
+
* table's shared bulk progress modal. So every test here is about what comes
|
|
13
|
+
* out of onSubmit, and nothing here mocks a create: a create appearing in this
|
|
14
|
+
* file would mean the collecting and the creating had been fused back together,
|
|
15
|
+
* which is what keeps the picker off the progress modal in the first place.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* Common/jest.config.json sets no testTimeout, so the default 5s has to cover
|
|
20
|
+
* mounting Modal + BasicForm + the async teams fetch, twice over in the tests
|
|
21
|
+
* that walk both steps.
|
|
22
|
+
*/
|
|
23
|
+
jest.setTimeout(30000);
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* waitFor's own default ceiling is 1s, which is the tighter of the two and the
|
|
27
|
+
* one that actually bites on a loaded CI box.
|
|
28
|
+
*/
|
|
29
|
+
const WAIT_FOR_TIMEOUT: number = 20000;
|
|
30
|
+
|
|
31
|
+
const PROJECT_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162001";
|
|
32
|
+
const TEAM_ENGINEERING_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162002";
|
|
33
|
+
const TEAM_SUPPORT_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162003";
|
|
34
|
+
/*
|
|
35
|
+
* A second project with a team of its own. The two team sets are disjoint on
|
|
36
|
+
* purpose: it is the only way a test can tell "the team of the project that is
|
|
37
|
+
* selected now" apart from "the team of the project that was selected before",
|
|
38
|
+
* which is the whole of the stale-selection bug.
|
|
39
|
+
*/
|
|
40
|
+
const OTHER_PROJECT_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162004";
|
|
41
|
+
const TEAM_BILLING_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162005";
|
|
42
|
+
const USER_ONE_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162011";
|
|
43
|
+
const USER_TWO_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162012";
|
|
44
|
+
const USER_THREE_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162013";
|
|
45
|
+
|
|
46
|
+
interface TranslationCall {
|
|
47
|
+
key: string;
|
|
48
|
+
options: Record<string, unknown> | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const translationCalls: Array<TranslationCall> = [];
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* t() echoes its key so assertions can target the raw key strings rather than
|
|
55
|
+
* today's English wording. The second argument matters as much as the first:
|
|
56
|
+
* the description is interpolated, and a key-only echo would happily pass a
|
|
57
|
+
* modal that had stopped passing the user count at all.
|
|
58
|
+
*/
|
|
59
|
+
const translate: (key: string, options?: Record<string, unknown>) => string = (
|
|
60
|
+
key: string,
|
|
61
|
+
options?: Record<string, unknown>,
|
|
62
|
+
): string => {
|
|
63
|
+
translationCalls.push({ key: key, options: options });
|
|
64
|
+
|
|
65
|
+
if (options && "userCount" in options) {
|
|
66
|
+
return `${key} userCount=${String(options["userCount"])}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return key;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
jest.mock("react-i18next", () => {
|
|
73
|
+
return {
|
|
74
|
+
__esModule: true,
|
|
75
|
+
useTranslation: (): {
|
|
76
|
+
t: (key: string, options?: Record<string, unknown>) => string;
|
|
77
|
+
} => {
|
|
78
|
+
return { t: translate };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* The project field is an entity dropdown, so the real one would lazily search
|
|
85
|
+
* the Projects endpoint. All this file needs from it is a way to say "the admin
|
|
86
|
+
* picked this project", in the shape the real component reports a single
|
|
87
|
+
* selection in: the bare id string, not an option object. Two projects are on
|
|
88
|
+
* offer so a test can change its mind about which one, which is what surfaces
|
|
89
|
+
* the stale-team bug.
|
|
90
|
+
*/
|
|
91
|
+
jest.mock("Common/UI/Components/EntityDropdown/EntityDropdown", () => {
|
|
92
|
+
return {
|
|
93
|
+
__esModule: true,
|
|
94
|
+
default: (props: {
|
|
95
|
+
onChange?: ((value: string) => void) | undefined;
|
|
96
|
+
}): React.ReactElement => {
|
|
97
|
+
return (
|
|
98
|
+
<div>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => {
|
|
102
|
+
props.onChange?.(PROJECT_ID);
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
Choose a project
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => {
|
|
110
|
+
props.onChange?.(OTHER_PROJECT_ID);
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
Choose the other project
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
interface DropdownStubOption {
|
|
122
|
+
label: string;
|
|
123
|
+
value: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface DropdownStubProps {
|
|
127
|
+
isMultiSelect?: boolean | undefined;
|
|
128
|
+
options: Array<DropdownStubOption>;
|
|
129
|
+
/*
|
|
130
|
+
* Captured, not rendered: in single-select mode the real Dropdown is handed
|
|
131
|
+
* one option and in multi-select an array of them, so the shape of this is
|
|
132
|
+
* the picker's own answer to which mode it thinks it is in.
|
|
133
|
+
*/
|
|
134
|
+
value?: unknown;
|
|
135
|
+
placeholder?: string | undefined;
|
|
136
|
+
onChange?: ((value: string) => void) | undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let capturedDropdownProps: DropdownStubProps | null = null;
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
* The team picker renders this. react-select in jsdom is more machinery than
|
|
143
|
+
* signal, so it is stood in for - but DROPDOWN_MENU_Z_INDEX has to come along:
|
|
144
|
+
* it is a real value export of the module and EntityDropdown imports it, so a
|
|
145
|
+
* stub without it crashes that importer instead of this file's subject.
|
|
146
|
+
*/
|
|
147
|
+
jest.mock("Common/UI/Components/Dropdown/Dropdown", () => {
|
|
148
|
+
return {
|
|
149
|
+
__esModule: true,
|
|
150
|
+
DROPDOWN_MENU_Z_INDEX: 60,
|
|
151
|
+
default: (props: DropdownStubProps): React.ReactElement => {
|
|
152
|
+
capturedDropdownProps = props;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div>
|
|
156
|
+
{props.options.map((option: DropdownStubOption) => {
|
|
157
|
+
return (
|
|
158
|
+
<button
|
|
159
|
+
key={option.value}
|
|
160
|
+
type="button"
|
|
161
|
+
onClick={() => {
|
|
162
|
+
props.onChange?.(option.value);
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
Pick {option.label}
|
|
166
|
+
</button>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/*
|
|
176
|
+
* The teams come from the admin ModelAPI - the one that sends no tenant header,
|
|
177
|
+
* because a master admin is not inside any project.
|
|
178
|
+
*/
|
|
179
|
+
jest.mock(
|
|
180
|
+
"../../../../App/FeatureSet/AdminDashboard/src/Utils/ModelAPI",
|
|
181
|
+
() => {
|
|
182
|
+
return {
|
|
183
|
+
__esModule: true,
|
|
184
|
+
default: {
|
|
185
|
+
getList: jest.fn(),
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
import BulkAddUsersToProjectModal, {
|
|
192
|
+
BulkAddUsersToProjectSelection,
|
|
193
|
+
} from "../../../../App/FeatureSet/AdminDashboard/src/Components/User/BulkAddUsersToProjectModal";
|
|
194
|
+
/*
|
|
195
|
+
* The picker is rendered by this modal, but a few of its rules cannot be driven
|
|
196
|
+
* through the modal at all - an in-flight fetch and a failed fetch are states
|
|
197
|
+
* the step rail cannot hold still in - so it is also exercised on its own here,
|
|
198
|
+
* with the same isMultiSelect={false} wiring the modal gives it.
|
|
199
|
+
*/
|
|
200
|
+
import ProjectScopedTeamsPicker from "../../../../App/FeatureSet/AdminDashboard/src/Components/GlobalProvider/ProjectScopedTeamsPicker";
|
|
201
|
+
import AdminModelAPI from "../../../../App/FeatureSet/AdminDashboard/src/Utils/ModelAPI";
|
|
202
|
+
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
|
203
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
204
|
+
import Team from "../../../Models/DatabaseModels/Team";
|
|
205
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
206
|
+
|
|
207
|
+
const mockGetList: jest.MockedFunction<any> =
|
|
208
|
+
AdminModelAPI.getList as unknown as jest.MockedFunction<any>;
|
|
209
|
+
|
|
210
|
+
const LOCALE_FILE_PATH: string = path.join(
|
|
211
|
+
__dirname,
|
|
212
|
+
"../../../../App/FeatureSet/AdminDashboard/src/Locales/en.json",
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
type MakeTeamFunction = (id: string, name: string) => Team;
|
|
216
|
+
|
|
217
|
+
const makeTeam: MakeTeamFunction = (id: string, name: string): Team => {
|
|
218
|
+
const team: Team = new Team();
|
|
219
|
+
team._id = id;
|
|
220
|
+
team.name = name;
|
|
221
|
+
return team;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
type TeamsOfProjectFunction = (projectId: string) => Array<Team>;
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* Two projects with nothing in common. `getList` is scoped by projectId, so a
|
|
228
|
+
* mock that ignored the query would hand back the same teams whichever project
|
|
229
|
+
* was chosen - and a picker that kept showing the old project's teams would
|
|
230
|
+
* look exactly like one that had refetched.
|
|
231
|
+
*/
|
|
232
|
+
const teamsOfProject: TeamsOfProjectFunction = (
|
|
233
|
+
projectId: string,
|
|
234
|
+
): Array<Team> => {
|
|
235
|
+
if (projectId === OTHER_PROJECT_ID) {
|
|
236
|
+
return [makeTeam(TEAM_BILLING_ID, "Billing")];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
makeTeam(TEAM_ENGINEERING_ID, "Engineering"),
|
|
241
|
+
makeTeam(TEAM_SUPPORT_ID, "Support"),
|
|
242
|
+
];
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
type MakeUserFunction = (id: string) => User;
|
|
246
|
+
|
|
247
|
+
// The modal reads nothing off a user but how many of them there are.
|
|
248
|
+
const makeUser: MakeUserFunction = (id: string): User => {
|
|
249
|
+
const user: User = new User();
|
|
250
|
+
user._id = id;
|
|
251
|
+
return user;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
type RenderModalFunction = (
|
|
255
|
+
overrides?: Partial<React.ComponentProps<typeof BulkAddUsersToProjectModal>>,
|
|
256
|
+
) => ReturnType<typeof render>;
|
|
257
|
+
|
|
258
|
+
const renderModal: RenderModalFunction = (
|
|
259
|
+
overrides: Partial<
|
|
260
|
+
React.ComponentProps<typeof BulkAddUsersToProjectModal>
|
|
261
|
+
> = {},
|
|
262
|
+
): ReturnType<typeof render> => {
|
|
263
|
+
return render(
|
|
264
|
+
<BulkAddUsersToProjectModal
|
|
265
|
+
users={[makeUser(USER_ONE_ID), makeUser(USER_TWO_ID)]}
|
|
266
|
+
onClose={jest.fn()}
|
|
267
|
+
onSubmit={jest.fn()}
|
|
268
|
+
{...overrides}
|
|
269
|
+
/>,
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
type ChooseProjectFunction = (view: ReturnType<typeof render>) => void;
|
|
274
|
+
|
|
275
|
+
const chooseProject: ChooseProjectFunction = (
|
|
276
|
+
view: ReturnType<typeof render>,
|
|
277
|
+
): void => {
|
|
278
|
+
fireEvent.click(view.getByRole("button", { name: "Choose a project" }));
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const chooseOtherProject: ChooseProjectFunction = (
|
|
282
|
+
view: ReturnType<typeof render>,
|
|
283
|
+
): void => {
|
|
284
|
+
fireEvent.click(
|
|
285
|
+
view.getByRole("button", { name: "Choose the other project" }),
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
type GoBackToProjectStepFunction = (
|
|
290
|
+
view: ReturnType<typeof render>,
|
|
291
|
+
) => Promise<void>;
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
* The way back is the step rail, the same as in the browser: a completed step
|
|
295
|
+
* is clickable and returns the form to it. There is no back button - the modal
|
|
296
|
+
* footer only ever goes forward - so this is the only route an admin has to
|
|
297
|
+
* change their mind about the project after picking a team.
|
|
298
|
+
*/
|
|
299
|
+
const goBackToProjectStep: GoBackToProjectStepFunction = async (
|
|
300
|
+
view: ReturnType<typeof render>,
|
|
301
|
+
): Promise<void> => {
|
|
302
|
+
fireEvent.click(view.getByText("pages.users.bulkAddToProjectStepProject"));
|
|
303
|
+
|
|
304
|
+
await waitFor(
|
|
305
|
+
() => {
|
|
306
|
+
expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
|
|
307
|
+
"pages.users.bulkAddToProjectNext",
|
|
308
|
+
);
|
|
309
|
+
},
|
|
310
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
type SubmitFunction = (view: ReturnType<typeof render>) => void;
|
|
315
|
+
|
|
316
|
+
const submitModal: SubmitFunction = (view: ReturnType<typeof render>): void => {
|
|
317
|
+
fireEvent.click(view.getByTestId("modal-footer-submit-button"));
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
type GoToTeamStepFunction = (
|
|
321
|
+
view: ReturnType<typeof render>,
|
|
322
|
+
expectedTeamName?: string,
|
|
323
|
+
) => Promise<void>;
|
|
324
|
+
|
|
325
|
+
/*
|
|
326
|
+
* Step two is where every interesting assertion lives, and getting there means
|
|
327
|
+
* both the step change and the teams fetch have to have settled. The team named
|
|
328
|
+
* here is the one the fetch is waited on through, so it has to be a team of the
|
|
329
|
+
* project that was just chosen.
|
|
330
|
+
*/
|
|
331
|
+
const goToTeamStep: GoToTeamStepFunction = async (
|
|
332
|
+
view: ReturnType<typeof render>,
|
|
333
|
+
expectedTeamName: string = "Engineering",
|
|
334
|
+
): Promise<void> => {
|
|
335
|
+
submitModal(view);
|
|
336
|
+
|
|
337
|
+
await waitFor(
|
|
338
|
+
() => {
|
|
339
|
+
expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
|
|
340
|
+
"pages.users.bulkAddToProjectSubmit",
|
|
341
|
+
);
|
|
342
|
+
},
|
|
343
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
await waitFor(
|
|
347
|
+
() => {
|
|
348
|
+
expect(
|
|
349
|
+
view.getByRole("button", { name: `Pick ${expectedTeamName}` }),
|
|
350
|
+
).toBeVisible();
|
|
351
|
+
},
|
|
352
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
353
|
+
);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
type OnSubmitFunction = (selection: BulkAddUsersToProjectSelection) => void;
|
|
357
|
+
|
|
358
|
+
type MakeOnSubmitFunction = () => OnSubmitFunction;
|
|
359
|
+
|
|
360
|
+
const makeOnSubmit: MakeOnSubmitFunction = (): OnSubmitFunction => {
|
|
361
|
+
return jest.fn((_selection: BulkAddUsersToProjectSelection): void => {});
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
type SubmittedSelectionFunction = (
|
|
365
|
+
onSubmit: OnSubmitFunction,
|
|
366
|
+
) => BulkAddUsersToProjectSelection;
|
|
367
|
+
|
|
368
|
+
const submittedSelection: SubmittedSelectionFunction = (
|
|
369
|
+
onSubmit: OnSubmitFunction,
|
|
370
|
+
): BulkAddUsersToProjectSelection => {
|
|
371
|
+
const submitMock: jest.MockedFunction<any> =
|
|
372
|
+
onSubmit as unknown as jest.MockedFunction<any>;
|
|
373
|
+
|
|
374
|
+
return submitMock.mock.calls[0]![0];
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
type ChooseTeamFunction = (
|
|
378
|
+
view: ReturnType<typeof render>,
|
|
379
|
+
teamName: string,
|
|
380
|
+
) => void;
|
|
381
|
+
|
|
382
|
+
const chooseTeam: ChooseTeamFunction = (
|
|
383
|
+
view: ReturnType<typeof render>,
|
|
384
|
+
teamName: string,
|
|
385
|
+
): void => {
|
|
386
|
+
fireEvent.click(view.getByRole("button", { name: `Pick ${teamName}` }));
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
describe("BulkAddUsersToProjectModal", () => {
|
|
390
|
+
beforeEach(() => {
|
|
391
|
+
jest.clearAllMocks();
|
|
392
|
+
translationCalls.length = 0;
|
|
393
|
+
capturedDropdownProps = null;
|
|
394
|
+
|
|
395
|
+
mockGetList.mockImplementation(
|
|
396
|
+
async (args: {
|
|
397
|
+
query?: { projectId?: ObjectID | undefined } | undefined;
|
|
398
|
+
}): Promise<any> => {
|
|
399
|
+
const teams: Array<Team> = teamsOfProject(
|
|
400
|
+
args?.query?.projectId?.toString() || "",
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
data: teams,
|
|
405
|
+
count: teams.length,
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
type RenderPickerFunction = (overrides: {
|
|
412
|
+
projectId: ObjectID;
|
|
413
|
+
selectedTeamIds: Array<string>;
|
|
414
|
+
onChange: (teamIds: Array<string>) => void;
|
|
415
|
+
}) => ReturnType<typeof render>;
|
|
416
|
+
|
|
417
|
+
/*
|
|
418
|
+
* The picker exactly as this modal configures it: one team, not a list.
|
|
419
|
+
*/
|
|
420
|
+
const renderPicker: RenderPickerFunction = (overrides: {
|
|
421
|
+
projectId: ObjectID;
|
|
422
|
+
selectedTeamIds: Array<string>;
|
|
423
|
+
onChange: (teamIds: Array<string>) => void;
|
|
424
|
+
}): ReturnType<typeof render> => {
|
|
425
|
+
return render(
|
|
426
|
+
<ProjectScopedTeamsPicker
|
|
427
|
+
isMultiSelect={false}
|
|
428
|
+
projectId={overrides.projectId}
|
|
429
|
+
selectedTeamIds={overrides.selectedTeamIds}
|
|
430
|
+
onChange={overrides.onChange}
|
|
431
|
+
/>,
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
type SettleFunction = () => Promise<void>;
|
|
436
|
+
|
|
437
|
+
/*
|
|
438
|
+
* Drains the microtask queue so a `.then` that was going to fire has fired.
|
|
439
|
+
* "onChange was not called" is only worth anything once the promise chain the
|
|
440
|
+
* call would have come from has had its turn.
|
|
441
|
+
*/
|
|
442
|
+
const settle: SettleFunction = async (): Promise<void> => {
|
|
443
|
+
for (let index: number = 0; index < 10; index++) {
|
|
444
|
+
await Promise.resolve();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
/*
|
|
449
|
+
* The teams on offer are the chosen project's, so there is nothing to ask on
|
|
450
|
+
* step one but the project. A team picker rendered alongside it would either
|
|
451
|
+
* sit there empty or - worse - fetch before a project exists and offer the
|
|
452
|
+
* teams of no project at all.
|
|
453
|
+
*/
|
|
454
|
+
test("asks for the project on its own step, with no team picker beside it", () => {
|
|
455
|
+
const view: ReturnType<typeof render> = renderModal();
|
|
456
|
+
|
|
457
|
+
expect(
|
|
458
|
+
view.getByText("pages.users.bulkAddToProjectFieldProject"),
|
|
459
|
+
).toBeVisible();
|
|
460
|
+
expect(
|
|
461
|
+
view.queryByText("pages.users.bulkAddToProjectFieldTeam"),
|
|
462
|
+
).not.toBeInTheDocument();
|
|
463
|
+
expect(capturedDropdownProps).toBeNull();
|
|
464
|
+
expect(mockGetList).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
/*
|
|
468
|
+
* The query is the whole of the scoping. Without projectId on it the picker
|
|
469
|
+
* would offer every team on the instance, and an admin picking one would move
|
|
470
|
+
* users into a project they never chose.
|
|
471
|
+
*/
|
|
472
|
+
test("fetches the chosen project's teams, by name, on the team step", async () => {
|
|
473
|
+
const view: ReturnType<typeof render> = renderModal();
|
|
474
|
+
|
|
475
|
+
chooseProject(view);
|
|
476
|
+
await goToTeamStep(view);
|
|
477
|
+
|
|
478
|
+
expect(mockGetList).toHaveBeenCalledTimes(1);
|
|
479
|
+
|
|
480
|
+
const getListArgs: {
|
|
481
|
+
modelType: unknown;
|
|
482
|
+
query: Record<string, ObjectID>;
|
|
483
|
+
sort: Record<string, SortOrder>;
|
|
484
|
+
} = mockGetList.mock.calls[0]![0];
|
|
485
|
+
|
|
486
|
+
expect(getListArgs.modelType).toBe(Team);
|
|
487
|
+
expect(Object.keys(getListArgs.query)).toEqual(["projectId"]);
|
|
488
|
+
expect(getListArgs.query["projectId"]?.toString()).toBe(PROJECT_ID);
|
|
489
|
+
expect(getListArgs.sort).toEqual({ name: SortOrder.Ascending });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
/*
|
|
493
|
+
* The one thing this component exists to produce. hasAcceptedInvitation has
|
|
494
|
+
* to come out false when the box was never ticked rather than undefined -
|
|
495
|
+
* TeamMemberService rejects an already-accepted membership for anyone who is
|
|
496
|
+
* not a master admin, so the flag is not a field to leave unset.
|
|
497
|
+
*/
|
|
498
|
+
test("hands the caller the chosen project and team once", async () => {
|
|
499
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
500
|
+
const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
|
|
501
|
+
|
|
502
|
+
chooseProject(view);
|
|
503
|
+
await goToTeamStep(view);
|
|
504
|
+
chooseTeam(view, "Engineering");
|
|
505
|
+
submitModal(view);
|
|
506
|
+
|
|
507
|
+
await waitFor(
|
|
508
|
+
() => {
|
|
509
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
510
|
+
},
|
|
511
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const selection: BulkAddUsersToProjectSelection =
|
|
515
|
+
submittedSelection(onSubmit);
|
|
516
|
+
|
|
517
|
+
expect(selection.projectId.toString()).toBe(PROJECT_ID);
|
|
518
|
+
expect(selection.teamId.toString()).toBe(TEAM_ENGINEERING_ID);
|
|
519
|
+
expect(selection.hasAcceptedInvitation).toBe(false);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
/*
|
|
523
|
+
* The checkbox is the difference between users who are invited and users who
|
|
524
|
+
* are members. Dropping it on the way out would silently leave every bulk-added
|
|
525
|
+
* user pending, which looks identical in the progress modal.
|
|
526
|
+
*/
|
|
527
|
+
test("reports the auto-accept checkbox when it is ticked", async () => {
|
|
528
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
529
|
+
const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
|
|
530
|
+
|
|
531
|
+
chooseProject(view);
|
|
532
|
+
await goToTeamStep(view);
|
|
533
|
+
chooseTeam(view, "Engineering");
|
|
534
|
+
fireEvent.click(view.getByRole("checkbox"));
|
|
535
|
+
submitModal(view);
|
|
536
|
+
|
|
537
|
+
await waitFor(
|
|
538
|
+
() => {
|
|
539
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
540
|
+
},
|
|
541
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
expect(submittedSelection(onSubmit).hasAcceptedInvitation).toBe(true);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
/*
|
|
548
|
+
* A submit with no team must not reach the caller. It would either create
|
|
549
|
+
* memberships against an undefined team or - since the page closes this modal
|
|
550
|
+
* before it starts creating - tear the picker down over nothing.
|
|
551
|
+
*
|
|
552
|
+
* Which layer refuses: BasicForm's own required-field validation. `team` is
|
|
553
|
+
* `required: true`, so submitForm collects a validation error and returns
|
|
554
|
+
* before it ever calls props.onSubmit, which means the modal's own
|
|
555
|
+
* `!teamId` guard is not entered and its
|
|
556
|
+
* `pages.users.bulkAddToProjectSelectTeam` message is not what is being
|
|
557
|
+
* pinned here. This test pins the outcome - nothing reaches the caller and
|
|
558
|
+
* the form stays on the team step - not the guard.
|
|
559
|
+
*/
|
|
560
|
+
test("does not submit while no team has been chosen", async () => {
|
|
561
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
562
|
+
const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
|
|
563
|
+
|
|
564
|
+
chooseProject(view);
|
|
565
|
+
await goToTeamStep(view);
|
|
566
|
+
submitModal(view);
|
|
567
|
+
|
|
568
|
+
// Still on the last step, still asking - it held rather than advanced.
|
|
569
|
+
await waitFor(
|
|
570
|
+
() => {
|
|
571
|
+
expect(
|
|
572
|
+
view.getByTestId("modal-footer-submit-button"),
|
|
573
|
+
).toHaveTextContent("pages.users.bulkAddToProjectSubmit");
|
|
574
|
+
},
|
|
575
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
/*
|
|
582
|
+
* Changing your mind about the project after having picked a team.
|
|
583
|
+
*
|
|
584
|
+
* The team lives in the form value, and the form value survives the walk back
|
|
585
|
+
* to step one. Refetching the teams alone does not fix that: the dropdown
|
|
586
|
+
* filters its selection against the options it now has, so the old project's
|
|
587
|
+
* team stops being *shown* while it is still *set*. Required-validation reads
|
|
588
|
+
* the value, not the dropdown, so the form would happily submit, and nothing
|
|
589
|
+
* downstream would catch it - TeamMemberService checks that the team exists,
|
|
590
|
+
* not that it belongs to the project being written - leaving memberships
|
|
591
|
+
* whose team is in one project and whose projectId is another.
|
|
592
|
+
*
|
|
593
|
+
* So: pick a project, pick its team, go back, pick the other project. The
|
|
594
|
+
* team of the first project must not come out the other end.
|
|
595
|
+
*/
|
|
596
|
+
test("does not carry a team of the old project into the new one", async () => {
|
|
597
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
598
|
+
const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
|
|
599
|
+
|
|
600
|
+
chooseProject(view);
|
|
601
|
+
await goToTeamStep(view);
|
|
602
|
+
chooseTeam(view, "Engineering");
|
|
603
|
+
|
|
604
|
+
await goBackToProjectStep(view);
|
|
605
|
+
chooseOtherProject(view);
|
|
606
|
+
await goToTeamStep(view, "Billing");
|
|
607
|
+
|
|
608
|
+
// The refetch really was scoped to the newly chosen project.
|
|
609
|
+
expect(mockGetList).toHaveBeenCalledTimes(2);
|
|
610
|
+
expect(
|
|
611
|
+
(
|
|
612
|
+
mockGetList.mock.calls[1]![0] as {
|
|
613
|
+
query: Record<string, ObjectID>;
|
|
614
|
+
}
|
|
615
|
+
).query["projectId"]?.toString(),
|
|
616
|
+
).toBe(OTHER_PROJECT_ID);
|
|
617
|
+
|
|
618
|
+
// The old project's team is not on offer, and nothing is selected anymore.
|
|
619
|
+
expect(
|
|
620
|
+
view.queryByRole("button", { name: "Pick Engineering" }),
|
|
621
|
+
).not.toBeInTheDocument();
|
|
622
|
+
expect(capturedDropdownProps!.value).toBeUndefined();
|
|
623
|
+
|
|
624
|
+
submitModal(view);
|
|
625
|
+
|
|
626
|
+
/*
|
|
627
|
+
* Nothing may reach the caller carrying the first project's team. In
|
|
628
|
+
* practice the cleared value fails required-validation and the form stays
|
|
629
|
+
* on the team step, but the assertion is written to accept any outcome
|
|
630
|
+
* except a membership pointing at a team of the project that was
|
|
631
|
+
* abandoned.
|
|
632
|
+
*/
|
|
633
|
+
await waitFor(
|
|
634
|
+
() => {
|
|
635
|
+
expect(
|
|
636
|
+
view.getByTestId("modal-footer-submit-button"),
|
|
637
|
+
).toHaveTextContent("pages.users.bulkAddToProjectSubmit");
|
|
638
|
+
},
|
|
639
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
await settle();
|
|
643
|
+
|
|
644
|
+
const submitMock: jest.MockedFunction<any> =
|
|
645
|
+
onSubmit as unknown as jest.MockedFunction<any>;
|
|
646
|
+
|
|
647
|
+
const submittedTeamIds: Array<string> = submitMock.mock.calls.map(
|
|
648
|
+
(call: Array<BulkAddUsersToProjectSelection>) => {
|
|
649
|
+
return call[0]!.teamId.toString();
|
|
650
|
+
},
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
expect(submittedTeamIds).not.toContain(TEAM_ENGINEERING_ID);
|
|
654
|
+
expect(submittedTeamIds).not.toContain(TEAM_SUPPORT_ID);
|
|
655
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
/*
|
|
659
|
+
* The same invariant one layer down, where it is stated rather than inferred:
|
|
660
|
+
* a selection the fetched project does not offer is dropped, and only that
|
|
661
|
+
* one - ids that ARE the project's teams stay. A blanket clear would be just
|
|
662
|
+
* as wrong in the other direction, wiping a valid pick on every refetch.
|
|
663
|
+
*/
|
|
664
|
+
test("drops only the team ids the fetched project does not offer", async () => {
|
|
665
|
+
const onChange: (teamIds: Array<string>) => void = jest.fn(
|
|
666
|
+
(_teamIds: Array<string>): void => {},
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
renderPicker({
|
|
670
|
+
projectId: new ObjectID(OTHER_PROJECT_ID),
|
|
671
|
+
selectedTeamIds: [TEAM_ENGINEERING_ID, TEAM_BILLING_ID],
|
|
672
|
+
onChange: onChange,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
await waitFor(
|
|
676
|
+
() => {
|
|
677
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
678
|
+
},
|
|
679
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const onChangeMock: jest.MockedFunction<any> =
|
|
683
|
+
onChange as unknown as jest.MockedFunction<any>;
|
|
684
|
+
|
|
685
|
+
expect(onChangeMock.mock.calls[0]![0]).toEqual([TEAM_BILLING_ID]);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
/*
|
|
689
|
+
* The other half of that: when everything selected is still a team of the
|
|
690
|
+
* project, the picker must leave the value alone. Reporting a change here
|
|
691
|
+
* would make the form dirty - and, in a form that reacted to its own onChange
|
|
692
|
+
* by re-rendering the custom element, would be a loop.
|
|
693
|
+
*/
|
|
694
|
+
test("leaves a selection the fetched project does offer alone", async () => {
|
|
695
|
+
const onChange: (teamIds: Array<string>) => void = jest.fn(
|
|
696
|
+
(_teamIds: Array<string>): void => {},
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
const view: ReturnType<typeof render> = renderPicker({
|
|
700
|
+
projectId: new ObjectID(PROJECT_ID),
|
|
701
|
+
selectedTeamIds: [TEAM_ENGINEERING_ID],
|
|
702
|
+
onChange: onChange,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
await waitFor(
|
|
706
|
+
() => {
|
|
707
|
+
expect(
|
|
708
|
+
view.getByRole("button", { name: "Pick Engineering" }),
|
|
709
|
+
).toBeVisible();
|
|
710
|
+
},
|
|
711
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
await settle();
|
|
715
|
+
|
|
716
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
/*
|
|
720
|
+
* Clearing is bound to the fetch's success path, and this is why. While the
|
|
721
|
+
* request is in flight the picker knows nothing about which teams the project
|
|
722
|
+
* has, so a selection cleared here would be one thrown away on no evidence -
|
|
723
|
+
* and the admin, watching "Loading teams...", would never see it go.
|
|
724
|
+
*/
|
|
725
|
+
test("does not clear the selection while the teams are still loading", async () => {
|
|
726
|
+
mockGetList.mockImplementation((): Promise<any> => {
|
|
727
|
+
// Never settles: the picker is held in its loading state for the test.
|
|
728
|
+
return new Promise((): void => {});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const onChange: (teamIds: Array<string>) => void = jest.fn(
|
|
732
|
+
(_teamIds: Array<string>): void => {},
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const view: ReturnType<typeof render> = renderPicker({
|
|
736
|
+
projectId: new ObjectID(OTHER_PROJECT_ID),
|
|
737
|
+
selectedTeamIds: [TEAM_ENGINEERING_ID],
|
|
738
|
+
onChange: onChange,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
expect(view.getByText("Loading teams...")).toBeVisible();
|
|
742
|
+
|
|
743
|
+
await settle();
|
|
744
|
+
|
|
745
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
/*
|
|
749
|
+
* And when the request fails outright. The teams may well be fine and the
|
|
750
|
+
* network not; discarding the pick on an error would turn a retryable blip
|
|
751
|
+
* into silently lost input, on top of the error the admin is already reading.
|
|
752
|
+
*/
|
|
753
|
+
test("does not clear the selection when the teams fail to load", async () => {
|
|
754
|
+
mockGetList.mockImplementation(async (): Promise<any> => {
|
|
755
|
+
throw new Error("Teams could not be loaded.");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
const onChange: (teamIds: Array<string>) => void = jest.fn(
|
|
759
|
+
(_teamIds: Array<string>): void => {},
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
const view: ReturnType<typeof render> = renderPicker({
|
|
763
|
+
projectId: new ObjectID(OTHER_PROJECT_ID),
|
|
764
|
+
selectedTeamIds: [TEAM_ENGINEERING_ID],
|
|
765
|
+
onChange: onChange,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
await waitFor(
|
|
769
|
+
() => {
|
|
770
|
+
expect(view.getByText("Teams could not be loaded.")).toBeVisible();
|
|
771
|
+
},
|
|
772
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
await settle();
|
|
776
|
+
|
|
777
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
/*
|
|
781
|
+
* A project with no teams cannot be a destination: there is no team to create
|
|
782
|
+
* a TeamMember in. The picker has to say so rather than render an empty
|
|
783
|
+
* dropdown that looks like it is still loading, and the form must not be
|
|
784
|
+
* submittable out of that state.
|
|
785
|
+
*/
|
|
786
|
+
test("cannot be submitted when the chosen project has no teams", async () => {
|
|
787
|
+
mockGetList.mockImplementation(async (): Promise<any> => {
|
|
788
|
+
return { data: [], count: 0 };
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
792
|
+
const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
|
|
793
|
+
|
|
794
|
+
chooseProject(view);
|
|
795
|
+
submitModal(view);
|
|
796
|
+
|
|
797
|
+
await waitFor(
|
|
798
|
+
() => {
|
|
799
|
+
expect(
|
|
800
|
+
view.getByText("This project has no teams to choose from."),
|
|
801
|
+
).toBeVisible();
|
|
802
|
+
},
|
|
803
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
// No dropdown at all, so there is nothing that could be picked.
|
|
807
|
+
expect(capturedDropdownProps).toBeNull();
|
|
808
|
+
|
|
809
|
+
submitModal(view);
|
|
810
|
+
|
|
811
|
+
await waitFor(
|
|
812
|
+
() => {
|
|
813
|
+
expect(
|
|
814
|
+
view.getByTestId("modal-footer-submit-button"),
|
|
815
|
+
).toHaveTextContent("pages.users.bulkAddToProjectSubmit");
|
|
816
|
+
},
|
|
817
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
await settle();
|
|
821
|
+
|
|
822
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
/*
|
|
826
|
+
* Two presses of the footer button, one run.
|
|
827
|
+
*
|
|
828
|
+
* The button is not disabled after the first press - the page closes this
|
|
829
|
+
* modal on its own schedule, and BasicForm.submitForm is happy to be called
|
|
830
|
+
* again - so without a guard the second press starts a second pass over the
|
|
831
|
+
* same selection. The two passes share the page's bulk progress modal: the
|
|
832
|
+
* first one finishing re-enables its Close button while the second is still
|
|
833
|
+
* creating memberships, so the admin is invited to close a run that is only
|
|
834
|
+
* half done.
|
|
835
|
+
*/
|
|
836
|
+
test("submits once even when the button is pressed twice", async () => {
|
|
837
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
838
|
+
const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
|
|
839
|
+
|
|
840
|
+
chooseProject(view);
|
|
841
|
+
await goToTeamStep(view);
|
|
842
|
+
chooseTeam(view, "Engineering");
|
|
843
|
+
|
|
844
|
+
submitModal(view);
|
|
845
|
+
|
|
846
|
+
await waitFor(
|
|
847
|
+
() => {
|
|
848
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
849
|
+
},
|
|
850
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
// The modal is still mounted and still shows a live submit button.
|
|
854
|
+
expect(view.getByTestId("modal-footer-submit-button")).toBeVisible();
|
|
855
|
+
|
|
856
|
+
submitModal(view);
|
|
857
|
+
|
|
858
|
+
await settle();
|
|
859
|
+
|
|
860
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
/*
|
|
864
|
+
* One TeamMember is created per user, in one team. The picker defaults to
|
|
865
|
+
* multi-select - it was written for the SSO attachment forms, which provision
|
|
866
|
+
* into several teams - so this modal has to turn that off.
|
|
867
|
+
*
|
|
868
|
+
* What is pinned here is the two things the components actually decide:
|
|
869
|
+
* `isMultiSelect={false}` reaching the dropdown, and the single-select `value`
|
|
870
|
+
* being one option rather than an array of them (the picker's
|
|
871
|
+
* `isMultiSelect ? selectedOptions : selectedOptions[0]`). Then that a second
|
|
872
|
+
* pick replaces the first instead of piling up next to it, which is the
|
|
873
|
+
* modal's `teamIds[0]`.
|
|
874
|
+
*
|
|
875
|
+
* What is NOT pinned: that the emitted teamId is a scalar and not an array.
|
|
876
|
+
* The Dropdown here is a stub that hands its onChange a single string in
|
|
877
|
+
* either mode, so the shape of what comes back is the stub's doing, not the
|
|
878
|
+
* component's - asserting on it would be asserting on this file.
|
|
879
|
+
*/
|
|
880
|
+
test("takes one team, not a list of them", async () => {
|
|
881
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
882
|
+
const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
|
|
883
|
+
|
|
884
|
+
chooseProject(view);
|
|
885
|
+
await goToTeamStep(view);
|
|
886
|
+
|
|
887
|
+
expect(capturedDropdownProps!.isMultiSelect).toBe(false);
|
|
888
|
+
|
|
889
|
+
chooseTeam(view, "Support");
|
|
890
|
+
|
|
891
|
+
await waitFor(
|
|
892
|
+
() => {
|
|
893
|
+
expect(capturedDropdownProps!.value).toEqual({
|
|
894
|
+
label: "Support",
|
|
895
|
+
value: TEAM_SUPPORT_ID,
|
|
896
|
+
});
|
|
897
|
+
},
|
|
898
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
chooseTeam(view, "Engineering");
|
|
902
|
+
submitModal(view);
|
|
903
|
+
|
|
904
|
+
await waitFor(
|
|
905
|
+
() => {
|
|
906
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
907
|
+
},
|
|
908
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
expect(submittedSelection(onSubmit).teamId.toString()).toBe(
|
|
912
|
+
TEAM_ENGINEERING_ID,
|
|
913
|
+
);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
/*
|
|
917
|
+
* The modal owns the footer button, and BasicForm's own submit button is
|
|
918
|
+
* hidden, so the two have to be kept in step by hand. A button still reading
|
|
919
|
+
* "next" on the last step is the one that looks like it did nothing.
|
|
920
|
+
*/
|
|
921
|
+
test("labels the footer button for the step it is on", async () => {
|
|
922
|
+
const view: ReturnType<typeof render> = renderModal();
|
|
923
|
+
|
|
924
|
+
expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
|
|
925
|
+
"pages.users.bulkAddToProjectNext",
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
chooseProject(view);
|
|
929
|
+
await goToTeamStep(view);
|
|
930
|
+
|
|
931
|
+
expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
|
|
932
|
+
"pages.users.bulkAddToProjectSubmit",
|
|
933
|
+
);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
/*
|
|
937
|
+
* How many users this is about is the one thing the admin cannot see from
|
|
938
|
+
* inside the modal - the table's selection is behind it. The count is passed
|
|
939
|
+
* as `userCount` and not `count` on purpose: i18next reads a `count` option as
|
|
940
|
+
* a request for the plural forms (`..._one` / `..._other`), which these keys
|
|
941
|
+
* do not define, so the lookup would miss and the sentence would come out as
|
|
942
|
+
* the raw key.
|
|
943
|
+
*/
|
|
944
|
+
test("says how many users are being added", () => {
|
|
945
|
+
const view: ReturnType<typeof render> = renderModal({
|
|
946
|
+
users: [
|
|
947
|
+
makeUser(USER_ONE_ID),
|
|
948
|
+
makeUser(USER_TWO_ID),
|
|
949
|
+
makeUser(USER_THREE_ID),
|
|
950
|
+
],
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
expect(view.getByTestId("modal-description")).toHaveTextContent(
|
|
954
|
+
"pages.users.bulkAddToProjectDescription userCount=3",
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
const descriptionCall: TranslationCall | undefined = translationCalls.find(
|
|
958
|
+
(call: TranslationCall) => {
|
|
959
|
+
return call.key === "pages.users.bulkAddToProjectDescription";
|
|
960
|
+
},
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
expect(descriptionCall?.options).toEqual({ userCount: 3 });
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
/*
|
|
967
|
+
* Closing has to stay a pure cancel. The page treats onSubmit as the signal to
|
|
968
|
+
* close the picker and start creating memberships, so a close that also
|
|
969
|
+
* submitted would add every selected user to a team the admin just backed out
|
|
970
|
+
* of.
|
|
971
|
+
*/
|
|
972
|
+
test("closing cancels and never submits", () => {
|
|
973
|
+
const onClose: () => void = jest.fn((): void => {});
|
|
974
|
+
const onSubmit: OnSubmitFunction = makeOnSubmit();
|
|
975
|
+
const view: ReturnType<typeof render> = renderModal({
|
|
976
|
+
onClose: onClose,
|
|
977
|
+
onSubmit: onSubmit,
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
fireEvent.click(view.getByTestId("modal-footer-close-button"));
|
|
981
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
982
|
+
|
|
983
|
+
// The header's X is the other way out, and it has to mean the same thing.
|
|
984
|
+
fireEvent.click(view.getByTestId("close-button"));
|
|
985
|
+
expect(onClose).toHaveBeenCalledTimes(2);
|
|
986
|
+
|
|
987
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
/*
|
|
991
|
+
* Every string in here is an i18n key, and a key with no entry renders as the
|
|
992
|
+
* key itself - "pages.users.bulkAddToProjectFieldTeam" in the middle of the
|
|
993
|
+
* form. Nothing else catches that: the modal renders, the form works, and the
|
|
994
|
+
* only symptom is the wording. So the keys are collected from the component
|
|
995
|
+
* as it is actually driven, and each one is looked up in the locale file.
|
|
996
|
+
*
|
|
997
|
+
* Only the modal's own lookups count. Modal and BasicForm re-translate the
|
|
998
|
+
* strings handed to them through the same t(), always with a `defaultValue` -
|
|
999
|
+
* those are whole sentences, not keys, and are filtered back out here.
|
|
1000
|
+
*/
|
|
1001
|
+
test("asks only for keys that exist in the admin locale file", async () => {
|
|
1002
|
+
const view: ReturnType<typeof render> = renderModal();
|
|
1003
|
+
|
|
1004
|
+
chooseProject(view);
|
|
1005
|
+
await goToTeamStep(view);
|
|
1006
|
+
|
|
1007
|
+
const localeStrings: unknown = JSON.parse(
|
|
1008
|
+
fs.readFileSync(LOCALE_FILE_PATH, "utf8"),
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
type ResolveKeyFunction = (key: string) => unknown;
|
|
1012
|
+
|
|
1013
|
+
const resolveKey: ResolveKeyFunction = (key: string): unknown => {
|
|
1014
|
+
return key
|
|
1015
|
+
.split(".")
|
|
1016
|
+
.reduce((current: unknown, part: string): unknown => {
|
|
1017
|
+
if (current && typeof current === "object") {
|
|
1018
|
+
return (current as Record<string, unknown>)[part];
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return undefined;
|
|
1022
|
+
}, localeStrings);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
const requestedKeys: Array<string> = Array.from(
|
|
1026
|
+
new Set(
|
|
1027
|
+
translationCalls
|
|
1028
|
+
.filter((call: TranslationCall) => {
|
|
1029
|
+
return !call.options || !("defaultValue" in call.options);
|
|
1030
|
+
})
|
|
1031
|
+
.map((call: TranslationCall) => {
|
|
1032
|
+
return call.key;
|
|
1033
|
+
}),
|
|
1034
|
+
),
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
// Guards the guard: an empty list would make the loop below vacuously true.
|
|
1038
|
+
expect(requestedKeys).toContain("pages.users.bulkAddToProjectTitle");
|
|
1039
|
+
expect(requestedKeys).toContain("pages.users.bulkAddToProjectFieldTeam");
|
|
1040
|
+
|
|
1041
|
+
const unresolvedKeys: Array<string> = requestedKeys.filter(
|
|
1042
|
+
(key: string) => {
|
|
1043
|
+
const value: unknown = resolveKey(key);
|
|
1044
|
+
|
|
1045
|
+
return typeof value !== "string" || value.length === 0;
|
|
1046
|
+
},
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
expect(unresolvedKeys).toEqual([]);
|
|
1050
|
+
});
|
|
1051
|
+
});
|