From d1b539bac9e5cf8d1884176bf25e0afccb4ff106 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2020 15:29:56 +0100 Subject: [PATCH] frontend/packages: removed shared --- .../shared/actions/actionConstants.js | 11 - .../__mocks__/backstage-backend.json | 26 - .../apis/backstage/__mocks__/graphqlClient.js | 17 - .../shared/apis/backstage/graphqlClient.js | 68 -- .../apis/backstage/graphqlClient.test.js | 11 - .../shared/apis/baseUrls/baseUrls.test.js | 10 - .../packages/shared/apis/baseUrls/baseUrls.ts | 59 -- .../packages/shared/apis/baseUrls/index.ts | 1 - .../shared/apis/codehealth/renderers.js | 37 - .../packages/shared/apis/codehealth/utils.js | 73 -- .../shared/apis/codehealth/utils.test.js | 114 --- .../packages/shared/apis/env/index.test.ts | 59 -- frontend/packages/shared/apis/env/index.ts | 91 -- .../packages/shared/apis/errorLog/ErrorLog.js | 99 -- .../shared/apis/errorLog/ErrorLog.test.js | 85 -- .../shared/apis/errorLog/ErrorLogEntry.js | 110 -- .../apis/errorLog/ErrorLogEntry.test.js | 58 -- .../packages/shared/apis/errorLog/actions.js | 51 - .../shared/apis/errorLog/constants.js | 4 - .../packages/shared/apis/errorLog/reducer.js | 39 - .../apis/events/GoogleAnalyticsEvent.js | 42 - .../shared/apis/events/getGAContext.js | 13 - frontend/packages/shared/apis/events/index.js | 4 - .../shared/apis/events/reactGAMiddleware.js | 51 - .../packages/shared/apis/events/sendEvent.js | 49 - .../apis/featureFlags/featureFlags.test.js | 25 - .../shared/apis/featureFlags/featureFlags.ts | 144 --- .../featureFlags/featureFlagsActions.d.ts | 1 - .../apis/featureFlags/featureFlagsActions.js | 13 - .../featureFlags/featureFlagsConstants.js | 1 - .../apis/featureFlags/featureFlagsReducer.js | 12 - .../firestore/FirestoreDocFactory.test.js | 99 -- .../apis/firestore/FirestoreDocFactory.ts | 73 -- .../apis/firestore/FirestoreProvider.test.tsx | 41 - .../apis/firestore/FirestoreProvider.tsx | 22 - .../apis/firestore/FirestoreStorage.test.ts | 570 ----------- .../shared/apis/firestore/FirestoreStorage.ts | 298 ------ .../firestore/MockFirestoreStorage.test.ts | 253 ----- .../apis/firestore/MockFirestoreStorage.ts | 303 ------ .../apis/firestore/getFirestoreApi.test.ts | 36 - .../shared/apis/firestore/getFirestoreApi.ts | 20 - .../packages/shared/apis/firestore/index.ts | 4 - .../packages/shared/apis/firestore/types.ts | 91 -- .../shared/apis/ghe/GheClient.test.js | 297 ------ .../packages/shared/apis/ghe/GheClient.ts | 259 ----- frontend/packages/shared/apis/ghe/index.ts | 2 - frontend/packages/shared/apis/ghe/types.ts | 263 ----- .../apis/gheAuth/OAuthGheSignInRequestPage.js | 16 - .../gheAuth/OAuthGheSignInRequestPage.test.js | 21 - .../gheAuth/OAuthGheSignInResponsePage.js | 32 - .../OAuthGheSignInResponsePage.test.js | 100 -- .../packages/shared/apis/gheAuth/actions.js | 26 - .../packages/shared/apis/gheAuth/gheAuth.js | 212 ---- .../shared/apis/gheAuth/gheAuth.test.js | 94 -- .../packages/shared/apis/gheAuth/reducer.js | 29 - .../packages/shared/apis/gheAuth/selectors.js | 1 - .../apis/googleAuthV2/GoogleAuth.test.ts | 150 --- .../shared/apis/googleAuthV2/GoogleAuth.ts | 144 --- .../googleAuthV2/GoogleAuthBarrier.test.tsx | 185 ---- .../apis/googleAuthV2/GoogleAuthBarrier.tsx | 99 -- .../googleAuthV2/GoogleAuthDialog.test.tsx | 102 -- .../apis/googleAuthV2/GoogleAuthDialog.tsx | 67 -- .../googleAuthV2/GoogleAuthHelper.test.ts | 212 ---- .../apis/googleAuthV2/GoogleAuthHelper.ts | 206 ---- .../googleAuthV2/GoogleAuthPendingRequests.ts | 4 - .../apis/googleAuthV2/GoogleScopes.test.ts | 62 -- .../shared/apis/googleAuthV2/GoogleScopes.ts | 42 - .../apis/googleAuthV2/MockAuthHelper.test.ts | 20 - .../apis/googleAuthV2/MockAuthHelper.ts | 36 - .../shared/apis/googleAuthV2/clientIds.ts | 35 - .../shared/apis/googleAuthV2/index.ts | 6 - .../shared/apis/googleAuthV2/types.ts | 78 -- .../apis/googleClient/googleClientApi.js | 59 -- .../shared/apis/highlighting/highlight.js | 139 --- .../packages/shared/apis/jira/jiraUtils.js | 59 -- .../shared/apis/jira/jiraUtils.test.js | 30 - .../shared/apis/menu/BackstageMenuItem.js | 30 - .../packages/shared/apis/menu/DefaultMenu.js | 953 ------------------ .../packages/shared/apis/menu/MenuItem.js | 82 -- .../shared/apis/notifications/index.js | 2 - .../apis/notifications/pagerDutyIncidents.ts | 192 ---- .../notifications/stackoverflowQuestions.ts | 36 - .../shared/apis/oauth/BasicOAuthScopes.ts | 41 - .../apis/oauth/OAuthPendingRequests.test.ts | 81 -- .../shared/apis/oauth/OAuthPendingRequests.ts | 88 -- frontend/packages/shared/apis/oauth/types.ts | 11 - .../apis/search/ComponentDefinitions.tsx | 234 ----- .../shared/apis/search/ESQueryBuilder.js | 203 ---- .../shared/apis/search/ESQueryBuilder.test.js | 52 - .../packages/shared/apis/search/searchApi.js | 88 -- .../settings/FirestoreSettingsStore.test.ts | 72 -- .../apis/settings/FirestoreSettingsStore.ts | 64 -- .../apis/settings/MemorySettingsStore.ts | 36 - .../shared/apis/settings/Setting.test.ts | 31 - .../packages/shared/apis/settings/Setting.ts | 30 - .../apis/settings/SettingCollection.test.ts | 40 - .../apis/settings/SettingCollection.tsx | 30 - .../packages/shared/apis/settings/types.ts | 19 - .../shared/apis/settings/useSetting.test.tsx | 116 --- .../shared/apis/settings/useSetting.ts | 58 -- .../shared/apis/snackbar/GlobalSnackbar.js | 134 --- .../apis/snackbar/GlobalSnackbar.test.js | 104 -- .../packages/shared/apis/snackbar/actions.js | 80 -- .../shared/apis/snackbar/constants.js | 4 - .../packages/shared/apis/snackbar/reducer.js | 22 - .../apis/storage/__mocks__/persistentStore.js | 3 - .../packages/shared/apis/storage/favorites.ts | 61 -- .../shared/apis/storage/persistentStore.js | 105 -- .../shared/apis/storage/recentlyVisited.ts | 35 - .../shared/apis/sysmodel/SysmodelClient.js | 170 ---- .../apis/sysmodel/SysmodelClient.test.js | 137 --- .../components/ComponentContextMenu.js | 124 --- .../components/ComponentContextMenu.test.js | 57 -- .../apis/sysmodel/components/ComponentList.js | 77 -- .../components/ComponentResourceChecker.js | 78 -- .../ComponentResourceChecker.test.js | 171 ---- .../apis/sysmodel/hooks/useResourceChecker.ts | 96 -- .../apis/sysmodel/sysModelUtils.test.js | 31 - .../shared/apis/sysmodel/sysmodelActions.js | 12 - .../shared/apis/sysmodel/sysmodelUtils.js | 46 - .../shared/apis/user/AppUserApi.test.ts | 80 -- .../packages/shared/apis/user/AppUserApi.ts | 80 -- .../shared/apis/user/MockUserApi.test.ts | 12 - .../packages/shared/apis/user/MockUserApi.ts | 38 - .../shared/apis/user/OverrideUserApi.test.ts | 73 -- .../shared/apis/user/OverrideUserApi.ts | 78 -- .../shared/apis/user/UserInfoBarrier.test.tsx | 118 --- .../shared/apis/user/UserInfoBarrier.tsx | 59 -- .../packages/shared/apis/user/getUserApi.ts | 25 - frontend/packages/shared/apis/user/hooks.ts | 9 - frontend/packages/shared/apis/user/index.ts | 8 - .../shared/apis/user/overrideUtils.test.ts | 48 - .../shared/apis/user/overrideUtils.ts | 32 - frontend/packages/shared/apis/user/types.ts | 30 - .../packages/shared/auth/GheComposable.js | 94 -- .../packages/shared/auth/GheSignInDialog.js | 34 - .../shared/auth/__mocks__/GheComposable.js | 11 - .../packages/shared/components/BreakHints.js | 24 - .../shared/components/BreakHints.test.js | 30 - .../shared/components/Button/Button.js | 56 - .../shared/components/Button/index.js | 1 - .../shared/components/ColumnStack.test.js | 35 - .../shared/components/ColumnStack.tsx | 195 ---- .../components/ConfirmPrompt/ConfirmPrompt.js | 113 --- .../shared/components/ConfirmPrompt/index.js | 1 - .../shared/components/CopyTextButton.js | 93 -- .../shared/components/CopyTextButton.test.js | 27 - .../DataGrid/Cells/ClickableCell.js | 76 -- .../components/DataGrid/Cells/DateCell.js | 32 - .../DataGrid/Cells/DateCell.test.js | 25 - .../components/DataGrid/Cells/DropdownCell.js | 140 --- .../DataGrid/Cells/DropdownCell.test.js | 154 --- .../DataGrid/Cells/DropdownFilterCell.js | 34 - .../DataGrid/Cells/DropdownFilterCell.test.js | 73 -- .../components/DataGrid/Cells/ListCell.js | 73 -- .../DataGrid/Cells/ListCell.test.js | 43 - .../components/DataGrid/Cells/NumberCell.js | 31 - .../DataGrid/Cells/NumberCell.test.js | 24 - .../components/DataGrid/Cells/StatusCell.js | 47 - .../DataGrid/Cells/StatusCell.test.js | 38 - .../DataGrid/Cells/TruncatedCell.js | 28 - .../DataGrid/Cells/TruncatedCell.test.js | 38 - .../shared/components/DataGrid/DataGrid.js | 482 --------- .../components/DataGrid/DataGrid.test.js | 106 -- .../DataGrid/Plugins/DataGridContext.js | 31 - .../DataGrid/Plugins/DataGridPopover.js | 45 - .../DataGrid/Plugins/RowDetailState.js | 69 -- .../DataGrid/Plugins/TableRowDetail.js | 178 ---- .../DataGrid/Plugins/TableRowDetail.test.js | 141 --- .../components/DataGrid/Plugins/Toolbar.js | 155 --- .../DataGrid/RenderingComponents.js | 67 -- .../shared/components/DataGrid/index.js | 2 - .../shared/components/DataNotFound.js | 35 - .../components/DebugPrint/DebugPrint.js | 69 -- .../shared/components/DebugPrint/index.js | 1 - .../components/DismissableBanner.test.js | 29 - .../shared/components/DismissableBanner.tsx | 78 -- .../components/EmptyState/EmptyState.js | 54 - .../shared/components/EmptyState/index.js | 1 - .../packages/shared/components/Error/Error.js | 346 ------- .../shared/components/Error/Error.test.js | 41 - .../packages/shared/components/Error/index.js | 1 - .../shared/components/Error/util/JSON.js | 31 - .../shared/components/Error/util/clipboard.js | 66 -- .../shared/components/Error/util/console.js | 36 - .../shared/components/Error/util/errorUtil.js | 233 ----- .../components/Error/util/errorUtil.test.js | 304 ------ .../shared/components/Error/util/react.js | 155 --- .../shared/components/ErrorBoundary.js | 35 - .../components/ErrorPanel/ErrorPanel.js | 90 -- .../shared/components/ErrorPanel/index.js | 1 - .../shared/components/ExpansionAlertPanel.js | 71 -- .../components/ExpansionAlertPanel.test.js | 17 - .../FilterSettings/FilterSettings.tsx | 62 -- .../shared/components/FilterSettings/index.js | 1 - .../shared/components/Filters/README.md | 139 --- .../shared/components/Filters/actions.js | 21 - .../Filters/components/FilterField.js | 126 --- .../Filters/components/FilterSet.js | 106 -- .../components/Filters/components/Filters.js | 66 -- .../Filters/components/SearchableSet.js | 51 - .../shared/components/Filters/constants.js | 7 - .../shared/components/Filters/index.js | 6 - .../shared/components/Filters/reducer.js | 48 - .../shared/components/HorizontalProgress.tsx | 27 - .../components/IconButton/IconButton.js | 50 - .../shared/components/IconButton/index.js | 1 - .../shared/components/IconLink/IconLink.tsx | 74 -- .../shared/components/IconLink/index.js | 1 - .../shared/components/InfoPanel/InfoPanel.js | 54 - .../components/InfoPanel/InfoPanel.test.js | 12 - .../shared/components/InfoPanel/index.js | 1 - .../packages/shared/components/InlineIcon.js | 25 - .../components/JsonSyntaxHighlighter.js | 8 - .../shared/components/Lifecycle/Lifecycle.js | 44 - .../shared/components/Lifecycle/index.js | 1 - .../packages/shared/components/Link/Link.js | 240 ----- .../shared/components/Link/Link.test.js | 84 -- .../packages/shared/components/Link/index.js | 1 - .../packages/shared/components/Markdown.js | 52 - .../shared/components/Markdown.test.js | 29 - .../shared/components/Metadata/Metadata.js | 187 ---- .../components/Metadata/MetadataCardAction.js | 53 - .../Metadata/MetadataCardAction.test.js | 56 - .../shared/components/Metadata/index.js | 1 - .../components/Metadata/metadataUtils.test.js | 64 -- .../components/Metadata/metadataUtils.tsx | 54 - .../shared/components/MetadataTable.js | 66 -- .../shared/components/ModalTruncate.js | 67 -- .../packages/shared/components/ModalView.js | 28 - .../packages/shared/components/OwnerLink.js | 45 - .../packages/shared/components/Page/Page.js | 29 - .../shared/components/Page/Page.test.js | 12 - .../packages/shared/components/Page/index.js | 1 - .../components/PagerDutyServiceStatus.js | 38 - .../components/PagerDutyServiceStatus.test.js | 18 - .../PartitionPicker/PartitionPicker.js | 75 -- .../components/PartitionPicker/index.js | 10 - .../shared/components/PrimaryTableLink.js | 13 - .../ProgressOverlay/ProgressOverlay.js | 55 - .../components/ProgressOverlay/index.js | 1 - .../SearchableList/SearchableList.js | 310 ------ .../SearchableList/SearchableList.test.js | 112 -- .../shared/components/SearchableList/index.js | 1 - .../components/Sequence/Sequence.test.js | 83 -- .../shared/components/Sequence/Sequence.tsx | 111 -- .../components/Sequence/SequenceFooter.tsx | 120 --- .../shared/components/Sequence/index.js | 1 - .../shared/components/Status/Status.js | 79 -- .../shared/components/Status/index.js | 1 - .../StructuredMetadataTable/README.md | 67 -- .../StructuredMetadataTable.js | 88 -- .../StructuredMetadataTable.test.js | 66 -- .../StructuredMetadataTable/index.js | 1 - .../shared/components/SvgImageIcon.js | 19 - .../components/TabbedContent/TabbedContent.js | 113 --- .../TabbedContent/TabbedContent.test.js | 74 -- .../shared/components/TabbedContent/index.js | 1 - frontend/packages/shared/components/Tabs.js | 45 - .../shared/components/TextIconLink.js | 59 -- frontend/packages/shared/components/User.js | 104 -- .../components/WarningPanel/WarningPanel.js | 62 -- .../shared/components/WarningPanel/index.js | 1 - .../shared/components/form/EventDispatcher.js | 66 -- .../components/form/FormHelperErrorText.js | 10 - .../components/form/ValidatedCheckbox.js | 61 -- .../shared/components/form/ValidatedForm.js | 79 -- .../form/ValidatedFormClearButton.js | 33 - .../components/form/ValidatedFormModel.js | 434 -------- .../form/ValidatedFormModel.test.js | 311 ------ .../form/ValidatedFormSubmitButton.js | 67 -- .../components/form/ValidatedRadioGroup.js | 92 -- .../shared/components/form/ValidatedSelect.js | 83 -- .../shared/components/form/ValidatedSwitch.js | 66 -- .../components/form/ValidatedTextField.js | 91 -- .../shared/components/form/utils/idUtil.js | 5 - .../form/validators/ValidatorBase.js | 151 --- .../form/validators/ValidatorBase.test.js | 289 ------ .../form/validators/ValidatorNumberRange.js | 39 - .../form/validators/ValidatorRegex.js | 18 - .../form/validators/ValidatorRegex.test.js | 31 - .../form/validators/ValidatorRequired.js | 11 - .../form/validators/ValidatorRequired.test.js | 19 - .../form/validators/ValidatorStringRange.js | 35 - .../validators/ValidatorStringRange.test.js | 64 -- .../components/form/validators/index.js | 5 - .../components/form/validators/regex.js | 1 - .../shared/components/hocs/errorOrLoading.js | 46 - .../components/hocs/errorOrLoading.test.js | 35 - .../packages/shared/components/hocs/index.js | 2 - .../shared/components/hocs/isEmpty.js | 33 - .../shared/components/hocs/isEmpty.test.js | 44 - frontend/packages/shared/components/index.js | 48 - .../components/jsonForm/JsonCheckboxField.js | 31 - .../components/jsonForm/JsonFieldSelector.js | 66 -- .../shared/components/jsonForm/JsonForm.js | 43 - .../components/jsonForm/JsonForm.test.js | 110 -- .../jsonForm/JsonMultiSelectField.js | 41 - .../jsonForm/JsonRadioGroupField.js | 51 - .../components/jsonForm/JsonSelectField.js | 41 - .../components/jsonForm/JsonTextField.js | 35 - .../shared/components/jsonForm/index.js | 6 - .../shared/components/layout/Burst.js | 16 - .../components/layout/CardLayoutStyles.js | 21 - .../shared/components/layout/Content.tsx | 51 - .../shared/components/layout/ContentHeader.js | 69 -- .../shared/components/layout/Documentation.js | 27 - .../components/layout/Documentation.test.js | 46 - .../components/layout/FavoriteButton.js | 60 -- .../components/layout/FavoriteButton.test.js | 151 --- .../shared/components/layout/Header.js | 194 ---- .../shared/components/layout/Header.test.js | 37 - .../components/layout/HeaderActionMenu.js | 56 - .../layout/HeaderActionMenu.test.js | 70 -- .../shared/components/layout/HeaderLabel.js | 48 - .../components/layout/HeaderLabel.test.js | 28 - .../packages/shared/components/layout/Jira.js | 35 - .../shared/components/layout/Jira.test.js | 38 - .../shared/components/layout/NavItem.js | 168 --- .../shared/components/layout/NavItem.test.js | 94 -- .../shared/components/layout/Navigation.js | 91 -- .../components/layout/Navigation.test.js | 57 -- .../components/layout/OwnerHeaderLabel.js | 65 -- .../layout/OwnerHeaderLabel.test.js | 37 - .../packages/shared/components/layout/Page.js | 37 - .../components/layout/QueryLayout.test.js | 92 -- .../shared/components/layout/QueryLayout.tsx | 95 -- .../shared/components/layout/StackOverflow.js | 28 - .../components/layout/StackOverflow.test.js | 39 - .../components/layout/StackOverflowTag.js | 22 - .../shared/components/layout/SupportButton.js | 114 --- .../components/layout/SupportButton.test.js | 36 - .../shared/components/layout/app/AppHeader.js | 38 - .../components/layout/app/AppHeader.test.js | 23 - .../shared/components/layout/app/AppLayout.js | 48 - .../components/layout/app/AppLayout.test.js | 66 -- .../components/layout/app/AppNavigation.js | 25 - .../layout/app/AppNavigation.test.js | 11 - .../layout/appFeature/AppFeatureHeader.js | 54 - .../appFeature/AppFeatureHeader.test.js | 54 - .../appFeature/AppFeatureLayout.test.js | 68 -- .../layout/appFeature/AppFeatureLayout.tsx | 54 - .../layout/appFeature/AppFeatureNavigation.js | 24 - .../appFeature/AppFeatureNavigation.test.js | 11 - .../components/layout/docs/DocsLayout.js | 65 -- .../components/layout/docs/DocsLayout.test.js | 66 -- .../components/layout/home/HomeLayout.test.js | 13 - .../components/layout/home/HomeLayout.tsx | 81 -- .../components/layout/home/HomeNavigation.js | 42 - .../layout/home/HomeNavigation.test.js | 20 - .../shared/components/layout/home/hooks.ts | 23 - .../shared/components/layout/index.js | 16 - .../layout/orgEntity/OrgEntityLayout.js | 114 --- .../layout/orgEntity/OrgEntityLayout.test.js | 162 --- .../layout/orgEntity/OrgEntityNavigation.js | 139 --- .../layout/other/OtherLayout.test.js | 153 --- .../components/layout/other/OtherLayout.tsx | 108 -- .../layout/other/OtherNavigation.js | 37 - .../layout/service/ServiceLayout.test.js | 98 -- .../layout/service/ServiceLayout.tsx | 95 -- .../layout/service/ServiceNavigation.js | 51 - .../layout/service/ServiceNavigation.test.js | 48 - .../components/layout/system/SystemLayout.js | 51 - .../layout/system/SystemLayout.test.js | 74 -- .../layout/system/SystemNavigation.js | 43 - .../layout/website/WebsiteLayout.test.js | 64 -- .../layout/website/WebsiteLayout.tsx | 83 -- .../layout/website/WebsiteNavigation.js | 35 - .../layout/website/WebsiteNavigation.test.js | 25 - .../packages/shared/components/markdown.css | 129 --- frontend/packages/shared/icons/Add.tsx | 11 - frontend/packages/shared/icons/AreaChart.tsx | 11 - frontend/packages/shared/icons/Attendance.tsx | 11 - frontend/packages/shared/icons/Automation.tsx | 11 - .../shared/icons/AvailableUpdates.tsx | 11 - frontend/packages/shared/icons/Bigquery.tsx | 17 - .../shared/icons/BlockchainTechnology.tsx | 11 - frontend/packages/shared/icons/Bug.tsx | 11 - frontend/packages/shared/icons/Cancel.tsx | 11 - .../packages/shared/icons/CardboardBox.tsx | 11 - frontend/packages/shared/icons/CargoShip.tsx | 11 - frontend/packages/shared/icons/Circled2.tsx | 11 - frontend/packages/shared/icons/Circled3.tsx | 11 - frontend/packages/shared/icons/Circled4.tsx | 11 - .../shared/icons/CircledDownRight.tsx | 11 - .../packages/shared/icons/CircledUpRight.tsx | 11 - .../packages/shared/icons/CollapseArrow.tsx | 7 - frontend/packages/shared/icons/CommitGit.tsx | 11 - frontend/packages/shared/icons/Compass.tsx | 11 - .../packages/shared/icons/ConnectedNoData.tsx | 11 - frontend/packages/shared/icons/Copy.tsx | 11 - frontend/packages/shared/icons/Counter.tsx | 11 - frontend/packages/shared/icons/Database.tsx | 11 - .../packages/shared/icons/DatabaseRestore.tsx | 11 - .../packages/shared/icons/DeliveryTime.tsx | 11 - .../packages/shared/icons/DeveloperMode.tsx | 11 - .../packages/shared/icons/Disconnected.tsx | 11 - frontend/packages/shared/icons/Document.tsx | 11 - frontend/packages/shared/icons/Download.tsx | 11 - frontend/packages/shared/icons/Edit.tsx | 11 - frontend/packages/shared/icons/Error.tsx | 11 - frontend/packages/shared/icons/Exit.tsx | 11 - .../packages/shared/icons/ExpandArrow.tsx | 7 - .../packages/shared/icons/ExternalLink.tsx | 11 - .../packages/shared/icons/FilledCircle.tsx | 11 - frontend/packages/shared/icons/FlowChart.tsx | 11 - frontend/packages/shared/icons/Folder.tsx | 11 - frontend/packages/shared/icons/Gas.tsx | 11 - .../shared/icons/GcpProjectByTools.tsx | 14 - frontend/packages/shared/icons/Geography.tsx | 11 - frontend/packages/shared/icons/Github.tsx | 11 - frontend/packages/shared/icons/GridView.tsx | 11 - frontend/packages/shared/icons/Handshake.tsx | 11 - .../packages/shared/icons/HeartWithPulse.tsx | 11 - frontend/packages/shared/icons/Help.tsx | 11 - frontend/packages/shared/icons/Hexagon.tsx | 11 - frontend/packages/shared/icons/Home.tsx | 11 - frontend/packages/shared/icons/Icons8.tsx | 11 - .../packages/shared/icons/ImportantMail.tsx | 11 - frontend/packages/shared/icons/Info.tsx | 11 - .../packages/shared/icons/InfoSquared.tsx | 11 - frontend/packages/shared/icons/Iphone.tsx | 11 - frontend/packages/shared/icons/Itinerary.tsx | 16 - frontend/packages/shared/icons/Kubeflow.tsx | 14 - frontend/packages/shared/icons/Learning.tsx | 11 - frontend/packages/shared/icons/Level1.tsx | 11 - frontend/packages/shared/icons/LineChart.tsx | 11 - frontend/packages/shared/icons/List.tsx | 11 - frontend/packages/shared/icons/Lock.tsx | 11 - frontend/packages/shared/icons/Manage.tsx | 16 - frontend/packages/shared/icons/Management.tsx | 11 - .../packages/shared/icons/MaterialFilled.tsx | 11 - .../packages/shared/icons/MaterialFilled2.tsx | 11 - .../packages/shared/icons/MaterialSharp.tsx | 11 - frontend/packages/shared/icons/Meeting.tsx | 11 - frontend/packages/shared/icons/Megaphone.tsx | 11 - frontend/packages/shared/icons/Memory.tsx | 13 - .../packages/shared/icons/MenuVertical.tsx | 11 - frontend/packages/shared/icons/MergeGit.tsx | 11 - .../packages/shared/icons/Metamorphose.tsx | 11 - frontend/packages/shared/icons/Multiply.tsx | 11 - .../packages/shared/icons/NetworkCable.tsx | 11 - frontend/packages/shared/icons/News.tsx | 11 - .../packages/shared/icons/Notification.tsx | 11 - frontend/packages/shared/icons/Ok.tsx | 11 - frontend/packages/shared/icons/Padlock.tsx | 11 - frontend/packages/shared/icons/Password.tsx | 11 - frontend/packages/shared/icons/Person.tsx | 11 - frontend/packages/shared/icons/Pin.tsx | 11 - frontend/packages/shared/icons/Planner.tsx | 11 - frontend/packages/shared/icons/Platforms.tsx | 11 - frontend/packages/shared/icons/Process.tsx | 11 - .../packages/shared/icons/PullRequest.tsx | 11 - frontend/packages/shared/icons/Reading.tsx | 11 - .../packages/shared/icons/RemoteControl.tsx | 11 - frontend/packages/shared/icons/Right.tsx | 11 - frontend/packages/shared/icons/Rss.tsx | 11 - frontend/packages/shared/icons/Schedule.tsx | 11 - frontend/packages/shared/icons/Search.tsx | 11 - frontend/packages/shared/icons/Server.tsx | 11 - frontend/packages/shared/icons/Settings.tsx | 11 - .../shared/icons/ShippingContainer.tsx | 11 - frontend/packages/shared/icons/Shuffle.tsx | 11 - frontend/packages/shared/icons/Shutdown.tsx | 11 - frontend/packages/shared/icons/Slack.tsx | 11 - frontend/packages/shared/icons/Spotify.tsx | 11 - frontend/packages/shared/icons/Star.tsx | 11 - frontend/packages/shared/icons/StarFilled.tsx | 11 - frontend/packages/shared/icons/TestTube.tsx | 11 - frontend/packages/shared/icons/Toolbox.tsx | 11 - frontend/packages/shared/icons/Tools.tsx | 11 - frontend/packages/shared/icons/Topic.tsx | 11 - .../packages/shared/icons/TopicBubble.tsx | 11 - frontend/packages/shared/icons/Triangle.tsx | 15 - .../shared/icons/UncheckedCheckbox.tsx | 7 - .../packages/shared/icons/WorkplaceLogo.tsx | 14 - frontend/packages/shared/icons/index.ts | 105 -- frontend/packages/shared/pluginApi/Api.ts | 38 - .../packages/shared/pluginApi/PluginBase.d.ts | 25 - .../packages/shared/pluginApi/PluginBase.js | 114 --- .../shared/pluginApi/PluginBase.test.js | 137 --- .../shared/pluginApi/PluginContext.js | 5 - .../packages/shared/pluginApi/PluginError.js | 1 - .../shared/pluginApi/PluginManager.js | 211 ---- .../shared/pluginApi/PluginManager.test.js | 229 ----- .../shared/pluginApi/PluginRedirect.d.ts | 6 - .../shared/pluginApi/PluginRedirect.js | 53 - .../shared/pluginApi/PluginRoute.d.ts | 8 - .../packages/shared/pluginApi/PluginRoute.js | 127 --- frontend/packages/shared/pluginApi/README.md | 30 - .../shared/pluginApi/__mock__/PluginRoute.js | 1 - .../shared/pluginApi/createPlugin.test.ts | 127 --- .../packages/shared/pluginApi/createPlugin.ts | 88 -- frontend/packages/shared/pluginApi/index.js | 8 - frontend/packages/shared/reducers.js | 9 - 495 files changed, 28802 deletions(-) delete mode 100644 frontend/packages/shared/actions/actionConstants.js delete mode 100644 frontend/packages/shared/apis/backstage/__mocks__/backstage-backend.json delete mode 100644 frontend/packages/shared/apis/backstage/__mocks__/graphqlClient.js delete mode 100644 frontend/packages/shared/apis/backstage/graphqlClient.js delete mode 100644 frontend/packages/shared/apis/backstage/graphqlClient.test.js delete mode 100644 frontend/packages/shared/apis/baseUrls/baseUrls.test.js delete mode 100644 frontend/packages/shared/apis/baseUrls/baseUrls.ts delete mode 100644 frontend/packages/shared/apis/baseUrls/index.ts delete mode 100644 frontend/packages/shared/apis/codehealth/renderers.js delete mode 100644 frontend/packages/shared/apis/codehealth/utils.js delete mode 100644 frontend/packages/shared/apis/codehealth/utils.test.js delete mode 100644 frontend/packages/shared/apis/env/index.test.ts delete mode 100644 frontend/packages/shared/apis/env/index.ts delete mode 100644 frontend/packages/shared/apis/errorLog/ErrorLog.js delete mode 100644 frontend/packages/shared/apis/errorLog/ErrorLog.test.js delete mode 100644 frontend/packages/shared/apis/errorLog/ErrorLogEntry.js delete mode 100644 frontend/packages/shared/apis/errorLog/ErrorLogEntry.test.js delete mode 100644 frontend/packages/shared/apis/errorLog/actions.js delete mode 100644 frontend/packages/shared/apis/errorLog/constants.js delete mode 100644 frontend/packages/shared/apis/errorLog/reducer.js delete mode 100644 frontend/packages/shared/apis/events/GoogleAnalyticsEvent.js delete mode 100644 frontend/packages/shared/apis/events/getGAContext.js delete mode 100644 frontend/packages/shared/apis/events/index.js delete mode 100644 frontend/packages/shared/apis/events/reactGAMiddleware.js delete mode 100644 frontend/packages/shared/apis/events/sendEvent.js delete mode 100644 frontend/packages/shared/apis/featureFlags/featureFlags.test.js delete mode 100644 frontend/packages/shared/apis/featureFlags/featureFlags.ts delete mode 100644 frontend/packages/shared/apis/featureFlags/featureFlagsActions.d.ts delete mode 100644 frontend/packages/shared/apis/featureFlags/featureFlagsActions.js delete mode 100644 frontend/packages/shared/apis/featureFlags/featureFlagsConstants.js delete mode 100644 frontend/packages/shared/apis/featureFlags/featureFlagsReducer.js delete mode 100644 frontend/packages/shared/apis/firestore/FirestoreDocFactory.test.js delete mode 100644 frontend/packages/shared/apis/firestore/FirestoreDocFactory.ts delete mode 100644 frontend/packages/shared/apis/firestore/FirestoreProvider.test.tsx delete mode 100644 frontend/packages/shared/apis/firestore/FirestoreProvider.tsx delete mode 100644 frontend/packages/shared/apis/firestore/FirestoreStorage.test.ts delete mode 100644 frontend/packages/shared/apis/firestore/FirestoreStorage.ts delete mode 100644 frontend/packages/shared/apis/firestore/MockFirestoreStorage.test.ts delete mode 100644 frontend/packages/shared/apis/firestore/MockFirestoreStorage.ts delete mode 100644 frontend/packages/shared/apis/firestore/getFirestoreApi.test.ts delete mode 100644 frontend/packages/shared/apis/firestore/getFirestoreApi.ts delete mode 100644 frontend/packages/shared/apis/firestore/index.ts delete mode 100644 frontend/packages/shared/apis/firestore/types.ts delete mode 100644 frontend/packages/shared/apis/ghe/GheClient.test.js delete mode 100644 frontend/packages/shared/apis/ghe/GheClient.ts delete mode 100644 frontend/packages/shared/apis/ghe/index.ts delete mode 100644 frontend/packages/shared/apis/ghe/types.ts delete mode 100644 frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.js delete mode 100644 frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.test.js delete mode 100644 frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.js delete mode 100644 frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.test.js delete mode 100644 frontend/packages/shared/apis/gheAuth/actions.js delete mode 100644 frontend/packages/shared/apis/gheAuth/gheAuth.js delete mode 100644 frontend/packages/shared/apis/gheAuth/gheAuth.test.js delete mode 100644 frontend/packages/shared/apis/gheAuth/reducer.js delete mode 100644 frontend/packages/shared/apis/gheAuth/selectors.js delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuth.test.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuth.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.test.tsx delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.tsx delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.test.tsx delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.tsx delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.test.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleAuthPendingRequests.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleScopes.test.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/GoogleScopes.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.test.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/clientIds.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/index.ts delete mode 100644 frontend/packages/shared/apis/googleAuthV2/types.ts delete mode 100644 frontend/packages/shared/apis/googleClient/googleClientApi.js delete mode 100644 frontend/packages/shared/apis/highlighting/highlight.js delete mode 100644 frontend/packages/shared/apis/jira/jiraUtils.js delete mode 100644 frontend/packages/shared/apis/jira/jiraUtils.test.js delete mode 100644 frontend/packages/shared/apis/menu/BackstageMenuItem.js delete mode 100644 frontend/packages/shared/apis/menu/DefaultMenu.js delete mode 100644 frontend/packages/shared/apis/menu/MenuItem.js delete mode 100644 frontend/packages/shared/apis/notifications/index.js delete mode 100644 frontend/packages/shared/apis/notifications/pagerDutyIncidents.ts delete mode 100644 frontend/packages/shared/apis/notifications/stackoverflowQuestions.ts delete mode 100644 frontend/packages/shared/apis/oauth/BasicOAuthScopes.ts delete mode 100644 frontend/packages/shared/apis/oauth/OAuthPendingRequests.test.ts delete mode 100644 frontend/packages/shared/apis/oauth/OAuthPendingRequests.ts delete mode 100644 frontend/packages/shared/apis/oauth/types.ts delete mode 100644 frontend/packages/shared/apis/search/ComponentDefinitions.tsx delete mode 100644 frontend/packages/shared/apis/search/ESQueryBuilder.js delete mode 100644 frontend/packages/shared/apis/search/ESQueryBuilder.test.js delete mode 100644 frontend/packages/shared/apis/search/searchApi.js delete mode 100644 frontend/packages/shared/apis/settings/FirestoreSettingsStore.test.ts delete mode 100644 frontend/packages/shared/apis/settings/FirestoreSettingsStore.ts delete mode 100644 frontend/packages/shared/apis/settings/MemorySettingsStore.ts delete mode 100644 frontend/packages/shared/apis/settings/Setting.test.ts delete mode 100644 frontend/packages/shared/apis/settings/Setting.ts delete mode 100644 frontend/packages/shared/apis/settings/SettingCollection.test.ts delete mode 100644 frontend/packages/shared/apis/settings/SettingCollection.tsx delete mode 100644 frontend/packages/shared/apis/settings/types.ts delete mode 100644 frontend/packages/shared/apis/settings/useSetting.test.tsx delete mode 100644 frontend/packages/shared/apis/settings/useSetting.ts delete mode 100644 frontend/packages/shared/apis/snackbar/GlobalSnackbar.js delete mode 100644 frontend/packages/shared/apis/snackbar/GlobalSnackbar.test.js delete mode 100644 frontend/packages/shared/apis/snackbar/actions.js delete mode 100644 frontend/packages/shared/apis/snackbar/constants.js delete mode 100644 frontend/packages/shared/apis/snackbar/reducer.js delete mode 100644 frontend/packages/shared/apis/storage/__mocks__/persistentStore.js delete mode 100644 frontend/packages/shared/apis/storage/favorites.ts delete mode 100644 frontend/packages/shared/apis/storage/persistentStore.js delete mode 100644 frontend/packages/shared/apis/storage/recentlyVisited.ts delete mode 100644 frontend/packages/shared/apis/sysmodel/SysmodelClient.js delete mode 100644 frontend/packages/shared/apis/sysmodel/SysmodelClient.test.js delete mode 100644 frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.js delete mode 100644 frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.test.js delete mode 100644 frontend/packages/shared/apis/sysmodel/components/ComponentList.js delete mode 100644 frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.js delete mode 100644 frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.test.js delete mode 100644 frontend/packages/shared/apis/sysmodel/hooks/useResourceChecker.ts delete mode 100644 frontend/packages/shared/apis/sysmodel/sysModelUtils.test.js delete mode 100644 frontend/packages/shared/apis/sysmodel/sysmodelActions.js delete mode 100644 frontend/packages/shared/apis/sysmodel/sysmodelUtils.js delete mode 100644 frontend/packages/shared/apis/user/AppUserApi.test.ts delete mode 100644 frontend/packages/shared/apis/user/AppUserApi.ts delete mode 100644 frontend/packages/shared/apis/user/MockUserApi.test.ts delete mode 100644 frontend/packages/shared/apis/user/MockUserApi.ts delete mode 100644 frontend/packages/shared/apis/user/OverrideUserApi.test.ts delete mode 100644 frontend/packages/shared/apis/user/OverrideUserApi.ts delete mode 100644 frontend/packages/shared/apis/user/UserInfoBarrier.test.tsx delete mode 100644 frontend/packages/shared/apis/user/UserInfoBarrier.tsx delete mode 100644 frontend/packages/shared/apis/user/getUserApi.ts delete mode 100644 frontend/packages/shared/apis/user/hooks.ts delete mode 100644 frontend/packages/shared/apis/user/index.ts delete mode 100644 frontend/packages/shared/apis/user/overrideUtils.test.ts delete mode 100644 frontend/packages/shared/apis/user/overrideUtils.ts delete mode 100644 frontend/packages/shared/apis/user/types.ts delete mode 100644 frontend/packages/shared/auth/GheComposable.js delete mode 100644 frontend/packages/shared/auth/GheSignInDialog.js delete mode 100644 frontend/packages/shared/auth/__mocks__/GheComposable.js delete mode 100644 frontend/packages/shared/components/BreakHints.js delete mode 100644 frontend/packages/shared/components/BreakHints.test.js delete mode 100644 frontend/packages/shared/components/Button/Button.js delete mode 100644 frontend/packages/shared/components/Button/index.js delete mode 100644 frontend/packages/shared/components/ColumnStack.test.js delete mode 100644 frontend/packages/shared/components/ColumnStack.tsx delete mode 100644 frontend/packages/shared/components/ConfirmPrompt/ConfirmPrompt.js delete mode 100644 frontend/packages/shared/components/ConfirmPrompt/index.js delete mode 100644 frontend/packages/shared/components/CopyTextButton.js delete mode 100644 frontend/packages/shared/components/CopyTextButton.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/ClickableCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/DateCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/DateCell.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/DropdownCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/DropdownCell.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/ListCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/ListCell.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/NumberCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/NumberCell.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/StatusCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/StatusCell.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.js delete mode 100644 frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/DataGrid.js delete mode 100644 frontend/packages/shared/components/DataGrid/DataGrid.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Plugins/DataGridContext.js delete mode 100644 frontend/packages/shared/components/DataGrid/Plugins/DataGridPopover.js delete mode 100644 frontend/packages/shared/components/DataGrid/Plugins/RowDetailState.js delete mode 100644 frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.js delete mode 100644 frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.test.js delete mode 100644 frontend/packages/shared/components/DataGrid/Plugins/Toolbar.js delete mode 100644 frontend/packages/shared/components/DataGrid/RenderingComponents.js delete mode 100644 frontend/packages/shared/components/DataGrid/index.js delete mode 100644 frontend/packages/shared/components/DataNotFound.js delete mode 100644 frontend/packages/shared/components/DebugPrint/DebugPrint.js delete mode 100644 frontend/packages/shared/components/DebugPrint/index.js delete mode 100644 frontend/packages/shared/components/DismissableBanner.test.js delete mode 100644 frontend/packages/shared/components/DismissableBanner.tsx delete mode 100644 frontend/packages/shared/components/EmptyState/EmptyState.js delete mode 100644 frontend/packages/shared/components/EmptyState/index.js delete mode 100644 frontend/packages/shared/components/Error/Error.js delete mode 100644 frontend/packages/shared/components/Error/Error.test.js delete mode 100644 frontend/packages/shared/components/Error/index.js delete mode 100644 frontend/packages/shared/components/Error/util/JSON.js delete mode 100644 frontend/packages/shared/components/Error/util/clipboard.js delete mode 100644 frontend/packages/shared/components/Error/util/console.js delete mode 100644 frontend/packages/shared/components/Error/util/errorUtil.js delete mode 100644 frontend/packages/shared/components/Error/util/errorUtil.test.js delete mode 100644 frontend/packages/shared/components/Error/util/react.js delete mode 100644 frontend/packages/shared/components/ErrorBoundary.js delete mode 100644 frontend/packages/shared/components/ErrorPanel/ErrorPanel.js delete mode 100644 frontend/packages/shared/components/ErrorPanel/index.js delete mode 100644 frontend/packages/shared/components/ExpansionAlertPanel.js delete mode 100644 frontend/packages/shared/components/ExpansionAlertPanel.test.js delete mode 100644 frontend/packages/shared/components/FilterSettings/FilterSettings.tsx delete mode 100644 frontend/packages/shared/components/FilterSettings/index.js delete mode 100644 frontend/packages/shared/components/Filters/README.md delete mode 100644 frontend/packages/shared/components/Filters/actions.js delete mode 100644 frontend/packages/shared/components/Filters/components/FilterField.js delete mode 100644 frontend/packages/shared/components/Filters/components/FilterSet.js delete mode 100644 frontend/packages/shared/components/Filters/components/Filters.js delete mode 100644 frontend/packages/shared/components/Filters/components/SearchableSet.js delete mode 100644 frontend/packages/shared/components/Filters/constants.js delete mode 100644 frontend/packages/shared/components/Filters/index.js delete mode 100644 frontend/packages/shared/components/Filters/reducer.js delete mode 100644 frontend/packages/shared/components/HorizontalProgress.tsx delete mode 100644 frontend/packages/shared/components/IconButton/IconButton.js delete mode 100644 frontend/packages/shared/components/IconButton/index.js delete mode 100644 frontend/packages/shared/components/IconLink/IconLink.tsx delete mode 100644 frontend/packages/shared/components/IconLink/index.js delete mode 100644 frontend/packages/shared/components/InfoPanel/InfoPanel.js delete mode 100644 frontend/packages/shared/components/InfoPanel/InfoPanel.test.js delete mode 100644 frontend/packages/shared/components/InfoPanel/index.js delete mode 100644 frontend/packages/shared/components/InlineIcon.js delete mode 100644 frontend/packages/shared/components/JsonSyntaxHighlighter.js delete mode 100644 frontend/packages/shared/components/Lifecycle/Lifecycle.js delete mode 100644 frontend/packages/shared/components/Lifecycle/index.js delete mode 100644 frontend/packages/shared/components/Link/Link.js delete mode 100644 frontend/packages/shared/components/Link/Link.test.js delete mode 100644 frontend/packages/shared/components/Link/index.js delete mode 100644 frontend/packages/shared/components/Markdown.js delete mode 100644 frontend/packages/shared/components/Markdown.test.js delete mode 100644 frontend/packages/shared/components/Metadata/Metadata.js delete mode 100644 frontend/packages/shared/components/Metadata/MetadataCardAction.js delete mode 100644 frontend/packages/shared/components/Metadata/MetadataCardAction.test.js delete mode 100644 frontend/packages/shared/components/Metadata/index.js delete mode 100644 frontend/packages/shared/components/Metadata/metadataUtils.test.js delete mode 100644 frontend/packages/shared/components/Metadata/metadataUtils.tsx delete mode 100644 frontend/packages/shared/components/MetadataTable.js delete mode 100644 frontend/packages/shared/components/ModalTruncate.js delete mode 100644 frontend/packages/shared/components/ModalView.js delete mode 100644 frontend/packages/shared/components/OwnerLink.js delete mode 100644 frontend/packages/shared/components/Page/Page.js delete mode 100644 frontend/packages/shared/components/Page/Page.test.js delete mode 100644 frontend/packages/shared/components/Page/index.js delete mode 100644 frontend/packages/shared/components/PagerDutyServiceStatus.js delete mode 100644 frontend/packages/shared/components/PagerDutyServiceStatus.test.js delete mode 100644 frontend/packages/shared/components/PartitionPicker/PartitionPicker.js delete mode 100644 frontend/packages/shared/components/PartitionPicker/index.js delete mode 100644 frontend/packages/shared/components/PrimaryTableLink.js delete mode 100644 frontend/packages/shared/components/ProgressOverlay/ProgressOverlay.js delete mode 100644 frontend/packages/shared/components/ProgressOverlay/index.js delete mode 100644 frontend/packages/shared/components/SearchableList/SearchableList.js delete mode 100644 frontend/packages/shared/components/SearchableList/SearchableList.test.js delete mode 100644 frontend/packages/shared/components/SearchableList/index.js delete mode 100644 frontend/packages/shared/components/Sequence/Sequence.test.js delete mode 100644 frontend/packages/shared/components/Sequence/Sequence.tsx delete mode 100644 frontend/packages/shared/components/Sequence/SequenceFooter.tsx delete mode 100644 frontend/packages/shared/components/Sequence/index.js delete mode 100644 frontend/packages/shared/components/Status/Status.js delete mode 100644 frontend/packages/shared/components/Status/index.js delete mode 100644 frontend/packages/shared/components/StructuredMetadataTable/README.md delete mode 100644 frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.js delete mode 100644 frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.test.js delete mode 100644 frontend/packages/shared/components/StructuredMetadataTable/index.js delete mode 100644 frontend/packages/shared/components/SvgImageIcon.js delete mode 100644 frontend/packages/shared/components/TabbedContent/TabbedContent.js delete mode 100644 frontend/packages/shared/components/TabbedContent/TabbedContent.test.js delete mode 100644 frontend/packages/shared/components/TabbedContent/index.js delete mode 100644 frontend/packages/shared/components/Tabs.js delete mode 100644 frontend/packages/shared/components/TextIconLink.js delete mode 100644 frontend/packages/shared/components/User.js delete mode 100644 frontend/packages/shared/components/WarningPanel/WarningPanel.js delete mode 100644 frontend/packages/shared/components/WarningPanel/index.js delete mode 100644 frontend/packages/shared/components/form/EventDispatcher.js delete mode 100644 frontend/packages/shared/components/form/FormHelperErrorText.js delete mode 100644 frontend/packages/shared/components/form/ValidatedCheckbox.js delete mode 100644 frontend/packages/shared/components/form/ValidatedForm.js delete mode 100644 frontend/packages/shared/components/form/ValidatedFormClearButton.js delete mode 100644 frontend/packages/shared/components/form/ValidatedFormModel.js delete mode 100644 frontend/packages/shared/components/form/ValidatedFormModel.test.js delete mode 100644 frontend/packages/shared/components/form/ValidatedFormSubmitButton.js delete mode 100644 frontend/packages/shared/components/form/ValidatedRadioGroup.js delete mode 100644 frontend/packages/shared/components/form/ValidatedSelect.js delete mode 100644 frontend/packages/shared/components/form/ValidatedSwitch.js delete mode 100644 frontend/packages/shared/components/form/ValidatedTextField.js delete mode 100644 frontend/packages/shared/components/form/utils/idUtil.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorBase.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorBase.test.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorNumberRange.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorRegex.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorRegex.test.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorRequired.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorRequired.test.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorStringRange.js delete mode 100644 frontend/packages/shared/components/form/validators/ValidatorStringRange.test.js delete mode 100644 frontend/packages/shared/components/form/validators/index.js delete mode 100644 frontend/packages/shared/components/form/validators/regex.js delete mode 100644 frontend/packages/shared/components/hocs/errorOrLoading.js delete mode 100644 frontend/packages/shared/components/hocs/errorOrLoading.test.js delete mode 100644 frontend/packages/shared/components/hocs/index.js delete mode 100644 frontend/packages/shared/components/hocs/isEmpty.js delete mode 100644 frontend/packages/shared/components/hocs/isEmpty.test.js delete mode 100644 frontend/packages/shared/components/index.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonCheckboxField.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonFieldSelector.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonForm.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonForm.test.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonMultiSelectField.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonRadioGroupField.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonSelectField.js delete mode 100644 frontend/packages/shared/components/jsonForm/JsonTextField.js delete mode 100644 frontend/packages/shared/components/jsonForm/index.js delete mode 100644 frontend/packages/shared/components/layout/Burst.js delete mode 100644 frontend/packages/shared/components/layout/CardLayoutStyles.js delete mode 100644 frontend/packages/shared/components/layout/Content.tsx delete mode 100644 frontend/packages/shared/components/layout/ContentHeader.js delete mode 100644 frontend/packages/shared/components/layout/Documentation.js delete mode 100644 frontend/packages/shared/components/layout/Documentation.test.js delete mode 100644 frontend/packages/shared/components/layout/FavoriteButton.js delete mode 100644 frontend/packages/shared/components/layout/FavoriteButton.test.js delete mode 100644 frontend/packages/shared/components/layout/Header.js delete mode 100644 frontend/packages/shared/components/layout/Header.test.js delete mode 100644 frontend/packages/shared/components/layout/HeaderActionMenu.js delete mode 100644 frontend/packages/shared/components/layout/HeaderActionMenu.test.js delete mode 100644 frontend/packages/shared/components/layout/HeaderLabel.js delete mode 100644 frontend/packages/shared/components/layout/HeaderLabel.test.js delete mode 100644 frontend/packages/shared/components/layout/Jira.js delete mode 100644 frontend/packages/shared/components/layout/Jira.test.js delete mode 100644 frontend/packages/shared/components/layout/NavItem.js delete mode 100644 frontend/packages/shared/components/layout/NavItem.test.js delete mode 100644 frontend/packages/shared/components/layout/Navigation.js delete mode 100644 frontend/packages/shared/components/layout/Navigation.test.js delete mode 100644 frontend/packages/shared/components/layout/OwnerHeaderLabel.js delete mode 100644 frontend/packages/shared/components/layout/OwnerHeaderLabel.test.js delete mode 100644 frontend/packages/shared/components/layout/Page.js delete mode 100644 frontend/packages/shared/components/layout/QueryLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/QueryLayout.tsx delete mode 100644 frontend/packages/shared/components/layout/StackOverflow.js delete mode 100644 frontend/packages/shared/components/layout/StackOverflow.test.js delete mode 100644 frontend/packages/shared/components/layout/StackOverflowTag.js delete mode 100644 frontend/packages/shared/components/layout/SupportButton.js delete mode 100644 frontend/packages/shared/components/layout/SupportButton.test.js delete mode 100644 frontend/packages/shared/components/layout/app/AppHeader.js delete mode 100644 frontend/packages/shared/components/layout/app/AppHeader.test.js delete mode 100644 frontend/packages/shared/components/layout/app/AppLayout.js delete mode 100644 frontend/packages/shared/components/layout/app/AppLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/app/AppNavigation.js delete mode 100644 frontend/packages/shared/components/layout/app/AppNavigation.test.js delete mode 100644 frontend/packages/shared/components/layout/appFeature/AppFeatureHeader.js delete mode 100644 frontend/packages/shared/components/layout/appFeature/AppFeatureHeader.test.js delete mode 100644 frontend/packages/shared/components/layout/appFeature/AppFeatureLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/appFeature/AppFeatureLayout.tsx delete mode 100644 frontend/packages/shared/components/layout/appFeature/AppFeatureNavigation.js delete mode 100644 frontend/packages/shared/components/layout/appFeature/AppFeatureNavigation.test.js delete mode 100644 frontend/packages/shared/components/layout/docs/DocsLayout.js delete mode 100644 frontend/packages/shared/components/layout/docs/DocsLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/home/HomeLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/home/HomeLayout.tsx delete mode 100644 frontend/packages/shared/components/layout/home/HomeNavigation.js delete mode 100644 frontend/packages/shared/components/layout/home/HomeNavigation.test.js delete mode 100644 frontend/packages/shared/components/layout/home/hooks.ts delete mode 100644 frontend/packages/shared/components/layout/index.js delete mode 100644 frontend/packages/shared/components/layout/orgEntity/OrgEntityLayout.js delete mode 100644 frontend/packages/shared/components/layout/orgEntity/OrgEntityLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/orgEntity/OrgEntityNavigation.js delete mode 100644 frontend/packages/shared/components/layout/other/OtherLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/other/OtherLayout.tsx delete mode 100644 frontend/packages/shared/components/layout/other/OtherNavigation.js delete mode 100644 frontend/packages/shared/components/layout/service/ServiceLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/service/ServiceLayout.tsx delete mode 100644 frontend/packages/shared/components/layout/service/ServiceNavigation.js delete mode 100644 frontend/packages/shared/components/layout/service/ServiceNavigation.test.js delete mode 100644 frontend/packages/shared/components/layout/system/SystemLayout.js delete mode 100644 frontend/packages/shared/components/layout/system/SystemLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/system/SystemNavigation.js delete mode 100644 frontend/packages/shared/components/layout/website/WebsiteLayout.test.js delete mode 100644 frontend/packages/shared/components/layout/website/WebsiteLayout.tsx delete mode 100644 frontend/packages/shared/components/layout/website/WebsiteNavigation.js delete mode 100644 frontend/packages/shared/components/layout/website/WebsiteNavigation.test.js delete mode 100644 frontend/packages/shared/components/markdown.css delete mode 100644 frontend/packages/shared/icons/Add.tsx delete mode 100644 frontend/packages/shared/icons/AreaChart.tsx delete mode 100644 frontend/packages/shared/icons/Attendance.tsx delete mode 100644 frontend/packages/shared/icons/Automation.tsx delete mode 100644 frontend/packages/shared/icons/AvailableUpdates.tsx delete mode 100644 frontend/packages/shared/icons/Bigquery.tsx delete mode 100644 frontend/packages/shared/icons/BlockchainTechnology.tsx delete mode 100644 frontend/packages/shared/icons/Bug.tsx delete mode 100644 frontend/packages/shared/icons/Cancel.tsx delete mode 100644 frontend/packages/shared/icons/CardboardBox.tsx delete mode 100644 frontend/packages/shared/icons/CargoShip.tsx delete mode 100644 frontend/packages/shared/icons/Circled2.tsx delete mode 100644 frontend/packages/shared/icons/Circled3.tsx delete mode 100644 frontend/packages/shared/icons/Circled4.tsx delete mode 100644 frontend/packages/shared/icons/CircledDownRight.tsx delete mode 100644 frontend/packages/shared/icons/CircledUpRight.tsx delete mode 100644 frontend/packages/shared/icons/CollapseArrow.tsx delete mode 100644 frontend/packages/shared/icons/CommitGit.tsx delete mode 100644 frontend/packages/shared/icons/Compass.tsx delete mode 100644 frontend/packages/shared/icons/ConnectedNoData.tsx delete mode 100644 frontend/packages/shared/icons/Copy.tsx delete mode 100644 frontend/packages/shared/icons/Counter.tsx delete mode 100644 frontend/packages/shared/icons/Database.tsx delete mode 100644 frontend/packages/shared/icons/DatabaseRestore.tsx delete mode 100644 frontend/packages/shared/icons/DeliveryTime.tsx delete mode 100644 frontend/packages/shared/icons/DeveloperMode.tsx delete mode 100644 frontend/packages/shared/icons/Disconnected.tsx delete mode 100644 frontend/packages/shared/icons/Document.tsx delete mode 100644 frontend/packages/shared/icons/Download.tsx delete mode 100644 frontend/packages/shared/icons/Edit.tsx delete mode 100644 frontend/packages/shared/icons/Error.tsx delete mode 100644 frontend/packages/shared/icons/Exit.tsx delete mode 100644 frontend/packages/shared/icons/ExpandArrow.tsx delete mode 100644 frontend/packages/shared/icons/ExternalLink.tsx delete mode 100644 frontend/packages/shared/icons/FilledCircle.tsx delete mode 100644 frontend/packages/shared/icons/FlowChart.tsx delete mode 100644 frontend/packages/shared/icons/Folder.tsx delete mode 100644 frontend/packages/shared/icons/Gas.tsx delete mode 100644 frontend/packages/shared/icons/GcpProjectByTools.tsx delete mode 100644 frontend/packages/shared/icons/Geography.tsx delete mode 100644 frontend/packages/shared/icons/Github.tsx delete mode 100644 frontend/packages/shared/icons/GridView.tsx delete mode 100644 frontend/packages/shared/icons/Handshake.tsx delete mode 100644 frontend/packages/shared/icons/HeartWithPulse.tsx delete mode 100644 frontend/packages/shared/icons/Help.tsx delete mode 100644 frontend/packages/shared/icons/Hexagon.tsx delete mode 100644 frontend/packages/shared/icons/Home.tsx delete mode 100644 frontend/packages/shared/icons/Icons8.tsx delete mode 100644 frontend/packages/shared/icons/ImportantMail.tsx delete mode 100644 frontend/packages/shared/icons/Info.tsx delete mode 100644 frontend/packages/shared/icons/InfoSquared.tsx delete mode 100644 frontend/packages/shared/icons/Iphone.tsx delete mode 100644 frontend/packages/shared/icons/Itinerary.tsx delete mode 100644 frontend/packages/shared/icons/Kubeflow.tsx delete mode 100644 frontend/packages/shared/icons/Learning.tsx delete mode 100644 frontend/packages/shared/icons/Level1.tsx delete mode 100644 frontend/packages/shared/icons/LineChart.tsx delete mode 100644 frontend/packages/shared/icons/List.tsx delete mode 100644 frontend/packages/shared/icons/Lock.tsx delete mode 100644 frontend/packages/shared/icons/Manage.tsx delete mode 100644 frontend/packages/shared/icons/Management.tsx delete mode 100644 frontend/packages/shared/icons/MaterialFilled.tsx delete mode 100644 frontend/packages/shared/icons/MaterialFilled2.tsx delete mode 100644 frontend/packages/shared/icons/MaterialSharp.tsx delete mode 100644 frontend/packages/shared/icons/Meeting.tsx delete mode 100644 frontend/packages/shared/icons/Megaphone.tsx delete mode 100644 frontend/packages/shared/icons/Memory.tsx delete mode 100644 frontend/packages/shared/icons/MenuVertical.tsx delete mode 100644 frontend/packages/shared/icons/MergeGit.tsx delete mode 100644 frontend/packages/shared/icons/Metamorphose.tsx delete mode 100644 frontend/packages/shared/icons/Multiply.tsx delete mode 100644 frontend/packages/shared/icons/NetworkCable.tsx delete mode 100644 frontend/packages/shared/icons/News.tsx delete mode 100644 frontend/packages/shared/icons/Notification.tsx delete mode 100644 frontend/packages/shared/icons/Ok.tsx delete mode 100644 frontend/packages/shared/icons/Padlock.tsx delete mode 100644 frontend/packages/shared/icons/Password.tsx delete mode 100644 frontend/packages/shared/icons/Person.tsx delete mode 100644 frontend/packages/shared/icons/Pin.tsx delete mode 100644 frontend/packages/shared/icons/Planner.tsx delete mode 100644 frontend/packages/shared/icons/Platforms.tsx delete mode 100644 frontend/packages/shared/icons/Process.tsx delete mode 100644 frontend/packages/shared/icons/PullRequest.tsx delete mode 100644 frontend/packages/shared/icons/Reading.tsx delete mode 100644 frontend/packages/shared/icons/RemoteControl.tsx delete mode 100644 frontend/packages/shared/icons/Right.tsx delete mode 100644 frontend/packages/shared/icons/Rss.tsx delete mode 100644 frontend/packages/shared/icons/Schedule.tsx delete mode 100644 frontend/packages/shared/icons/Search.tsx delete mode 100644 frontend/packages/shared/icons/Server.tsx delete mode 100644 frontend/packages/shared/icons/Settings.tsx delete mode 100644 frontend/packages/shared/icons/ShippingContainer.tsx delete mode 100644 frontend/packages/shared/icons/Shuffle.tsx delete mode 100644 frontend/packages/shared/icons/Shutdown.tsx delete mode 100644 frontend/packages/shared/icons/Slack.tsx delete mode 100644 frontend/packages/shared/icons/Spotify.tsx delete mode 100644 frontend/packages/shared/icons/Star.tsx delete mode 100644 frontend/packages/shared/icons/StarFilled.tsx delete mode 100644 frontend/packages/shared/icons/TestTube.tsx delete mode 100644 frontend/packages/shared/icons/Toolbox.tsx delete mode 100644 frontend/packages/shared/icons/Tools.tsx delete mode 100644 frontend/packages/shared/icons/Topic.tsx delete mode 100644 frontend/packages/shared/icons/TopicBubble.tsx delete mode 100644 frontend/packages/shared/icons/Triangle.tsx delete mode 100644 frontend/packages/shared/icons/UncheckedCheckbox.tsx delete mode 100644 frontend/packages/shared/icons/WorkplaceLogo.tsx delete mode 100644 frontend/packages/shared/icons/index.ts delete mode 100644 frontend/packages/shared/pluginApi/Api.ts delete mode 100644 frontend/packages/shared/pluginApi/PluginBase.d.ts delete mode 100644 frontend/packages/shared/pluginApi/PluginBase.js delete mode 100644 frontend/packages/shared/pluginApi/PluginBase.test.js delete mode 100644 frontend/packages/shared/pluginApi/PluginContext.js delete mode 100644 frontend/packages/shared/pluginApi/PluginError.js delete mode 100644 frontend/packages/shared/pluginApi/PluginManager.js delete mode 100644 frontend/packages/shared/pluginApi/PluginManager.test.js delete mode 100644 frontend/packages/shared/pluginApi/PluginRedirect.d.ts delete mode 100644 frontend/packages/shared/pluginApi/PluginRedirect.js delete mode 100644 frontend/packages/shared/pluginApi/PluginRoute.d.ts delete mode 100644 frontend/packages/shared/pluginApi/PluginRoute.js delete mode 100644 frontend/packages/shared/pluginApi/README.md delete mode 100644 frontend/packages/shared/pluginApi/__mock__/PluginRoute.js delete mode 100644 frontend/packages/shared/pluginApi/createPlugin.test.ts delete mode 100644 frontend/packages/shared/pluginApi/createPlugin.ts delete mode 100644 frontend/packages/shared/pluginApi/index.js delete mode 100644 frontend/packages/shared/reducers.js diff --git a/frontend/packages/shared/actions/actionConstants.js b/frontend/packages/shared/actions/actionConstants.js deleted file mode 100644 index c42d2d9a0c..0000000000 --- a/frontend/packages/shared/actions/actionConstants.js +++ /dev/null @@ -1,11 +0,0 @@ -// TODO: move these into their respective cases: core or plugins depending on usage - -export const GOOGLE_LOGGED_IN = 'GOOGLE_LOGGED_IN'; -export const GOOGLE_LOGGED_OUT = 'GOOGLE_LOGGED_OUT'; - -export const GHE_LOGGED_IN = 'GHE_LOGGED_IN'; -export const GHE_LOGGED_OUT = 'GHE_LOGGED_OUT'; - -// gcp-project-creator actions -export const GCP_PROJECT_FETCHED_DATA = 'GCP_PROJECT_FETCHED_DATA'; -export const GCP_PROJECT_FAILED = 'GCP_PROJECT_FAILED'; diff --git a/frontend/packages/shared/apis/backstage/__mocks__/backstage-backend.json b/frontend/packages/shared/apis/backstage/__mocks__/backstage-backend.json deleted file mode 100644 index a7f7639d0e..0000000000 --- a/frontend/packages/shared/apis/backstage/__mocks__/backstage-backend.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data": { - "elasticSearch": { - "data": [ - { - "__typename": "Component", - "id": "backstage-backend", - "componentType": "service" - } - ], - "aggregations": [ - { - "name": "metadata", - "buckets": [{ "key": "data-endpoints", "docCount": 10 }] - }, - { - "name": "lifecycle.keyword", - "buckets": [ - { "key": "production", "docCount": 5 }, - { "key": "experimental", "docCount": 5 } - ] - } - ] - } - } -} diff --git a/frontend/packages/shared/apis/backstage/__mocks__/graphqlClient.js b/frontend/packages/shared/apis/backstage/__mocks__/graphqlClient.js deleted file mode 100644 index 8dadb7dbb9..0000000000 --- a/frontend/packages/shared/apis/backstage/__mocks__/graphqlClient.js +++ /dev/null @@ -1,17 +0,0 @@ -import backstage_backend_json from './backstage-backend.json'; - -export function getOpenProxyHost() { - return 'mocked-proxy'; -} - -export default { - client: { - query: jest.fn().mockImplementation(() => { - return new Promise(resolve => { - resolve(backstage_backend_json); - }); - }), - }, -}; - -throw new Error('This file is not used'); diff --git a/frontend/packages/shared/apis/backstage/graphqlClient.js b/frontend/packages/shared/apis/backstage/graphqlClient.js deleted file mode 100644 index 4ac5fea663..0000000000 --- a/frontend/packages/shared/apis/backstage/graphqlClient.js +++ /dev/null @@ -1,68 +0,0 @@ -import { ApolloClient } from 'apollo-client'; -import { execute, makePromise } from 'apollo-link'; -import { createHttpLink } from 'apollo-link-http'; -import { InMemoryCache } from 'apollo-cache-inmemory'; -import axios from 'axios'; -// Needed for PhantomJS, which does not have fetch (should be a null operation on browsers) -import 'whatwg-fetch'; -import { urls } from 'shared/apis/baseUrls'; -import FeatureFlags from 'shared/apis/featureFlags/featureFlags'; -import { getGraphqlBackendRegion } from 'core/app/AppBar/LoggedIn/OverrideRegion'; -import { GoogleAnalyticsEvent, sendGAEvent } from 'shared/apis/events'; - -export function getGraphQlApiHost() { - if (FeatureFlags.getItem('graphql-backend-region-switcher')) { - const region = getGraphqlBackendRegion(); - return `http://backstage-backend.services.${region}.spotify.net/graphql`; - } - const backstageOpenProxyHost = urls.openProxy; - return process.env.REACT_APP_GRAPHQL_API || `${backstageOpenProxyHost}/api/backend/graphql`; -} - -function getClientOptions() { - if (FeatureFlags.getItem('graphql-backend-no-cache')) { - return { - watchQuery: { - fetchPolicy: 'no-cache', - errorPolicy: 'ignore', - }, - query: { - fetchPolicy: 'no-cache', - errorPolicy: 'all', - }, - }; - } else { - return {}; - } -} - -export const graphqlLink = createHttpLink({ - uri: getGraphQlApiHost(), -}); - -export const graphqlClient = new ApolloClient({ - link: graphqlLink, - cache: new InMemoryCache(), - defaultOptions: getClientOptions(), -}); - -export function graphqlRequest(query, variables) { - return makePromise( - execute(graphqlLink, { - query, - variables, - }), - ); -} - -export function evictGraphqlCacheEntity(entity) { - sendGAEvent( - 'GraphqlEviction', - GoogleAnalyticsEvent.IMPRESSION, - 'Evict graphql cache for type', - entity, - 'tools', - null, - ); - return axios.post(`${getGraphQlApiHost()}/evict/${entity}`); -} diff --git a/frontend/packages/shared/apis/backstage/graphqlClient.test.js b/frontend/packages/shared/apis/backstage/graphqlClient.test.js deleted file mode 100644 index 4168fb5d9f..0000000000 --- a/frontend/packages/shared/apis/backstage/graphqlClient.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { getGraphQlApiHost, graphqlRequest } from './graphqlClient'; - -describe('graphqlClient', () => { - it('should have correct uri for tests', () => { - expect(getGraphQlApiHost()).toBe('https://backstage-proxy.spotify.net/api/backend/graphql'); - }); - - it('should fail to execute a malformed query directly', async () => { - expect(() => graphqlRequest('durr')).toThrow(/^Invalid AST Node/); - }); -}); diff --git a/frontend/packages/shared/apis/baseUrls/baseUrls.test.js b/frontend/packages/shared/apis/baseUrls/baseUrls.test.js deleted file mode 100644 index 89d582e418..0000000000 --- a/frontend/packages/shared/apis/baseUrls/baseUrls.test.js +++ /dev/null @@ -1,10 +0,0 @@ -import { urls } from './baseUrls'; - -describe('baseUrls', () => { - it('urls should be base urls', () => { - Object.values(urls).forEach(url => { - expect(typeof url).toBe('string'); - expect(url).toMatch(/^https?:\/\/(?:[a-z.-]+)$/); - }); - }); -}); diff --git a/frontend/packages/shared/apis/baseUrls/baseUrls.ts b/frontend/packages/shared/apis/baseUrls/baseUrls.ts deleted file mode 100644 index 29df1c6925..0000000000 --- a/frontend/packages/shared/apis/baseUrls/baseUrls.ts +++ /dev/null @@ -1,59 +0,0 @@ -import Api from 'shared/pluginApi/Api'; - -const BACKSTAGE_PROXY_URL = 'https://backstage-proxy.spotify.net'; -const BACKSTAGE_PUBLIC_URL = 'https://backstage.spotify.net'; - -export const urls: Urls = { - get public() { - return BACKSTAGE_PUBLIC_URL; - }, - get proxy() { - return BACKSTAGE_PROXY_URL; - }, - get openProxy() { - if (window.location.hostname === 'backstage.spotify.net') { - return BACKSTAGE_PUBLIC_URL; - } - // If we're not on the public URL we'll assume local dev/e2e and use the internal proxy, since GLB/IAP authentication won't work. - return BACKSTAGE_PROXY_URL; - }, - get sysmodel() { - return 'https://sysmodel.spotify.net'; - }, - get ghe() { - return 'https://ghe.spotify.net'; - }, -}; - -/** - * A collection of common urls used in the backstage-frontend. - * - * Import and use directly. - * - * ```typescript - * import { urls } from 'shared/apis/baseUrls'; - * - * fetch(`${urls.proxy}/api/my-backend/my-api`) - * ``` - */ -export type Urls = { - /** Public URL that Backstage is served from. */ - public: string; - /** Internal Backstage proxy URL. */ - proxy: string; - /** - * URL to the Backstage open proxy, which does not require access to the - * Spotify network if possible. - */ - openProxy: string; - /** Internal sysmodel URL. */ - sysmodel: string; - /** Internal GHE URL. */ - ghe: string; -}; - -export const urlsApi = new Api({ - id: 'urls', - title: 'Common URLs', - description: 'A collection of common urls used in the backstage-frontend', -}); diff --git a/frontend/packages/shared/apis/baseUrls/index.ts b/frontend/packages/shared/apis/baseUrls/index.ts deleted file mode 100644 index c1c83d99a0..0000000000 --- a/frontend/packages/shared/apis/baseUrls/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { urls } from './baseUrls'; diff --git a/frontend/packages/shared/apis/codehealth/renderers.js b/frontend/packages/shared/apis/codehealth/renderers.js deleted file mode 100644 index 45519d716f..0000000000 --- a/frontend/packages/shared/apis/codehealth/renderers.js +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import { pluralize, UNKNOWN_DISPLAY_VALUE } from 'shared/apis/codehealth/utils'; -import Link from 'shared/components/Link'; -import TestStatus from 'plugins/health/components/TestStatus'; -import { Tooltip } from '@material-ui/core'; - -const TOOLTIP_DETAILS = 'Click to view latest invocations'; - -export const flakyRateRenderer = ({ value }) => `${(100.0 * value).toFixed(2)}%`; - -export const passRateRenderer = ({ row, value }) => { - if (value === Number.MAX_SAFE_INTEGER) { - return 'No runs'; - } - const { - aggregateCount: { failed, successful }, - } = row; - const cellContent = `${value.toFixed(2)}%`; - const successfulString = 'successful'; - const totalRuns = successful + failed; - const runString = pluralize(totalRuns, 'run'); - const tooltipContent = `${successful} ${successfulString} / ${successful + failed} ${runString} - ${TOOLTIP_DETAILS}`; - return ( - - {cellContent} - - ); -}; - -export const ownerCellRenderer = ({ row }) => - row.owner && row.owner !== 'UNKNOWN' ? ( - {row.owner} - ) : ( - UNKNOWN_DISPLAY_VALUE - ); - -export const statusRenderer = ({ row }) => ; diff --git a/frontend/packages/shared/apis/codehealth/utils.js b/frontend/packages/shared/apis/codehealth/utils.js deleted file mode 100644 index 6f93835e3d..0000000000 --- a/frontend/packages/shared/apis/codehealth/utils.js +++ /dev/null @@ -1,73 +0,0 @@ -export const UNKNOWN_DISPLAY_VALUE = '(unknown)'; - -export const pluralize = (length, targetString) => { - if (length !== 1) { - targetString += 's'; - } - return targetString; -}; - -/** - * Get percentage of passed test runs - * @param skipped - * @param failed - * @param successful - * @param flaked - * @returns {number} - Percentage - */ -export const passRate = ({ /* skipped, */ failed, successful /* , flaked */ }) => { - const totalRuns = successful + failed; - if (totalRuns === 0) { - return Number.MAX_SAFE_INTEGER; - } - return (successful / totalRuns) * 100; -}; - -/** - * Get average percentage of passed test runs for all tests - * @param tests - * @returns {number} - Percentage - */ -export const averagePassRate = tests => { - // no tests found for this component - if (!tests.length) { - return null; - } - - const validTests = tests.map(test => passRate(test.aggregateCount)).filter(rate => rate !== Number.MAX_SAFE_INTEGER); - - // no valid tests found for this component - if (!validTests.length) { - return null; - } - - return validTests.reduce((sum, rate) => sum + rate, 0) / validTests.length; -}; - -/** - * Get flakiness rate - * @param skipped - * @param failed - * @param successful - * @param flaked - * @param clusterFlaked - * @returns {number} - */ -export const flakinessRate = ({ /* skipped, */ failed, successful, flaked, clusterFlaked }) => { - const totalRuns = successful + failed; - if (totalRuns > 0) { - return (1.0 * Math.max(flaked || 0, clusterFlaked || 0)) / totalRuns; - } - return 0; -}; - -export const durationLabel = durationMs => { - const ms = parseInt(durationMs, 10); - if (ms < 1000) { - return `${ms} ms`; - } else if (ms < 60000) { - return `${(ms / 1000.0).toFixed(2)} sec`; - } else { - return `${(ms / 60000.0).toFixed(2)} min`; - } -}; diff --git a/frontend/packages/shared/apis/codehealth/utils.test.js b/frontend/packages/shared/apis/codehealth/utils.test.js deleted file mode 100644 index 41780bfddd..0000000000 --- a/frontend/packages/shared/apis/codehealth/utils.test.js +++ /dev/null @@ -1,114 +0,0 @@ -import { render } from '@testing-library/react'; -import { averagePassRate, durationLabel, pluralize, flakinessRate } from './utils'; -import { passRateRenderer, flakyRateRenderer, ownerCellRenderer, statusRenderer } from './renderers'; -import { wrapInTestApp, wrapInThemedTestApp } from 'testUtils'; - -describe('codehealth utils', () => { - describe('averagePassRate', () => { - describe('when there are no tests', () => { - it('returns null', () => { - expect(averagePassRate([])).toBe(null); - }); - }); - - describe('when there are no valid tests', () => { - it('returns null', () => { - const testRuns = [ - // This test-run of zeros will cause the passRate() function in utils - // to return MAX_SAFE_INTEGER which should cause averagePassRate to - // have tests, but no valid tests. - createTestRun({ successful: 0, failed: 0 }), - ]; - expect(averagePassRate(testRuns)).toBe(null); - }); - }); - - describe('when there are tests', () => { - it('returns 100% on only succesful', () => { - const testRuns = [ - createTestRun({ successful: 10 }), - createTestRun({ successful: 20 }), - createTestRun({ successful: 30 }), - ]; - expect(averagePassRate(testRuns)).toBe(100); - }); - it('correctly calculates average percentage', () => { - const testRuns = [ - createTestRun({ successful: 150 }), // 100% passed - createTestRun({ successful: 10, failed: 10 }), // 50% passed - createTestRun({ failed: 30 }), // 0 passed - ]; - expect(averagePassRate(testRuns)).toBe(50); - }); - it('ignores entirely skipped tests', () => { - const testRuns = [ - createTestRun({ successful: 50 }), - createTestRun({ skipped: 200 }), - createTestRun({ failed: 50, skipped: 200 }), - ]; - expect(averagePassRate(testRuns)).toBe(50); - }); - }); - }); - it('calcualtes duration', () => { - const ms = durationLabel(200); - const sec = durationLabel(2000); - const min = durationLabel(120000); - expect(ms).toBe('200 ms'); - expect(sec).toBe('2.00 sec'); - expect(min).toBe('2.00 min'); - }); - it('pluralizes', () => { - const singular = pluralize(1, 'run'); - const plural = pluralize(2, 'run'); - expect(singular).toBe('run'); - expect(plural).toBe('runs'); - }); - it('calculates flakiness', () => { - const noRuns = flakinessRate({ failed: 0, successful: 0, flaked: 0 }); - const notFlaky = flakinessRate({ failed: 1, successful: 1, flaked: 0 }); - const flaky = flakinessRate({ failed: 1, successful: 1, flaked: 1 }); - expect(noRuns).toBe(0); - expect(notFlaky).toBe(0); - expect(flaky).toBe(0.5); - }); -}); - -describe('renderers', () => { - it('passRateRenderer', () => { - const { getByTitle, getByText } = render( - passRateRenderer({ row: createTestRun({ successful: 1, failed: 1 }), value: 50 }), - ); - getByTitle('1 successful / 2 runs - Click to view latest invocations'); - getByText('50.00%'); - }); - it('flakyRateRenderer', () => { - const { getByText } = render(flakyRateRenderer({ value: 0.11111 })); - getByText('11.11%'); - }); - it('ownerCellRenderer', () => { - const { getByText } = render(wrapInTestApp(ownerCellRenderer({ row: { owner: 'mockOwner' } }))); - getByText('mockOwner'); - }); - it('ownerCellRenderer', () => { - const { getByText } = render(wrapInTestApp(ownerCellRenderer({ row: {} }))); - getByText('(unknown)'); - }); - it('statusRenderer', () => { - const { getByText } = render(wrapInThemedTestApp(statusRenderer({ row: { status: 'ok' } }))); - getByText('OK'); - }); -}); - -function createTestRun(aggregateCount) { - return { - aggregateCount: { - successful: 0, - failed: 0, - skipped: 0, - flaked: 0, - clusterFlaked: 0, - ...aggregateCount, - }, - }; -} diff --git a/frontend/packages/shared/apis/env/index.test.ts b/frontend/packages/shared/apis/env/index.test.ts deleted file mode 100644 index 100f59df6f..0000000000 --- a/frontend/packages/shared/apis/env/index.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { env } from './index'; - -function runTest( - fn: () => boolean, - hostname: string | undefined, - env: { [key: string]: string } | undefined, - expectedSuccess: boolean, -) { - let oldLocation: PropertyDescriptor | undefined; - if (hostname !== undefined) { - oldLocation = Object.getOwnPropertyDescriptor(window, 'location'); - delete window.location; - Object.defineProperty(window, 'location', { configurable: true, value: { hostname } }); - } - - let oldEnv: NodeJS.ProcessEnv | undefined; - if (env !== undefined) { - oldEnv = { ...process.env }; - process.env = env; - } - - const outcome = fn(); - if (expectedSuccess) { - expect(outcome).toBeTruthy(); - } else { - expect(outcome).toBeFalsy(); - } - - if (oldLocation !== undefined) { - Object.defineProperty(window, 'location', oldLocation!); - } - - if (oldEnv !== undefined) { - process.env = oldEnv; - } -} - -describe('env api', () => { - it('matches localhost addresses to the development type', () => { - runTest(() => env.hostingEnv === 'development', 'localhost', { NODE_ENV: 'blblbl' }, true); - runTest(() => env.isDevelopment, 'localhost', { NODE_ENV: 'blblbl' }, true); - runTest(() => env.isDevelopment, 'localhosts', { NODE_ENV: 'development' }, true); - runTest(() => env.isDevelopment, '[::1]', { NODE_ENV: 'blblbl' }, true); - runTest(() => env.isDevelopment, '127.0.0.1', { NODE_ENV: 'blblbl' }, true); - runTest(() => env.isDevelopment, '127.0.6.1', { NODE_ENV: 'blblbl' }, true); - runTest(() => env.isDevelopment, '0.0.0.0', { NODE_ENV: 'blblbl' }, true); - }); - - it('matches the docker host address to the staging type', () => { - runTest(() => env.hostingEnv === 'staging', '10.99.0.1', { NODE_ENV: 'blblbl' }, true); - runTest(() => env.isStaging, '10.99.0.1', { NODE_ENV: 'blblbl' }, true); - runTest(() => env.isStaging, 'localhost', { NODE_ENV: 'blblbl' }, false); - }); - - it('matches the SLINGSHOT_BUILD env var to the staging type', () => { - runTest(() => env.hostingEnv === 'staging', undefined, { SLINGSHOT_BUILD: 'dfd' }, true); - runTest(() => env.isStaging, undefined, { SLINGSHOT_BUILD: 'dfd' }, true); - }); -}); diff --git a/frontend/packages/shared/apis/env/index.ts b/frontend/packages/shared/apis/env/index.ts deleted file mode 100644 index ffd929f87c..0000000000 --- a/frontend/packages/shared/apis/env/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import Api from 'shared/pluginApi/Api'; - -/** The different types of hosting environment. */ -export type HostingEnv = 'development' | 'test' | 'staging' | 'production'; - -/** Decides whether the given input is a valid HostingEnv. */ -export function isHostingEnv(s: any): s is HostingEnv { - return ['development', 'test', 'staging', 'production'].includes(s); -} - -/** - * Properties of the current execution environment. - * - * Example usage: - * - * ```typescript - * import { env } from 'shared/apis/env'; - * if (env.hostingEnv === 'production') { - * // ... - * } - * ``` - */ -export type Env = { - /** Inspects the type of hosting environment that we are currently in. */ - hostingEnv: HostingEnv; - /** Checks whether we are currently in a development environment. */ - isDevelopment: boolean; - /** Checks whether we are currently in a test environment. */ - isTest: boolean; - /** Checks whether we are currently in a staging environment. */ - isStaging: boolean; - /** Checks whether we are currently in a production environment. */ - isProduction: boolean; -}; - -function getHostingEnv() { - const { NODE_ENV, REACT_APP_NODE_ENV = false } = process.env; - const nodeEnv = REACT_APP_NODE_ENV || NODE_ENV; - const hostname = (window && window.location && window.location.hostname) || ''; - - // End-to-end tests run on the docker host address - if (hostname === '10.99.0.1') { - return 'staging'; - } - - // Slingshot builds are treated as staging as well - if (Boolean(process.env.SLINGSHOT_BUILD)) { - return 'staging'; - } - - // If running a prod build locally (rare, but happens) - if ( - nodeEnv === 'production' && - (hostname === 'localhost' || - hostname === '[::1]' || - hostname === '0.0.0.0' || - Boolean(hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/))) - ) { - return 'development'; - } - - if (isHostingEnv(nodeEnv)) { - return nodeEnv; - } - - return 'development'; -} - -export const env: Env = { - get hostingEnv() { - return getHostingEnv(); - }, - get isDevelopment() { - return getHostingEnv() === 'development'; - }, - get isTest() { - return getHostingEnv() === 'test'; - }, - get isStaging() { - return getHostingEnv() === 'staging'; - }, - get isProduction() { - return getHostingEnv() === 'production'; - }, -}; - -export const envApi = new Api({ - id: 'env', - title: 'Properties of the current execution environment', - description: 'A collection of properties that are useful to inspect the current execution environment.', -}); diff --git a/frontend/packages/shared/apis/errorLog/ErrorLog.js b/frontend/packages/shared/apis/errorLog/ErrorLog.js deleted file mode 100644 index 08a7da0869..0000000000 --- a/frontend/packages/shared/apis/errorLog/ErrorLog.js +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { Drawer, IconButton, List, ListItemSecondaryAction, ListSubheader, withStyles } from '@material-ui/core'; -import Check from '@material-ui/icons/Check'; -import CloseIcon from '@material-ui/icons/Close'; -import FeaturedPlayListIcon from '@material-ui/icons/FeaturedPlayList'; - -import { errorLogClose } from './actions'; -import ErrorLogEntry from './ErrorLogEntry'; - -const closeButtonStyles = { - root: { - color: 'black', - }, -}; - -const drawerStyles = { - paperAnchorBottom: { - maxHeight: 'auto', - height: '45vh', - }, -}; - -const listSubheaderStyles = { - sticky: { - backgroundColor: '#fafafa', - }, -}; - -const listStyles = { - root: { - display: 'flex', - flexDirection: 'column', - }, -}; - -const CloseButton = withStyles(closeButtonStyles)(({ classes }) => ( - - - -)); - -const noErrorsStyle = theme => ({ - errorLogNoErrors: { - flex: '1', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - color: theme.palette.textVerySubtle, - fontSize: '24px', - }, -}); - -const errorLogHeaderIconStyles = { - root: { - marginBottom: '-7px', - marginRight: '9px', - }, -}; - -const NoErrors = withStyles(noErrorsStyle)(({ classes }) => ( -
  • - -  All good -
  • -)); - -const ListSubheaderStyled = withStyles(listSubheaderStyles)(ListSubheader); -const DrawerStyled = withStyles(drawerStyles)(Drawer); -const ListStyled = withStyles(listStyles)(List); -const FeaturedPlayListIconStyled = withStyles(errorLogHeaderIconStyles)(FeaturedPlayListIcon); - -const ErrorLogDrawer = ({ errorLog, onClose }) => { - const { open, selectedErrorId, errors } = errorLog; - - const errorList = errors.map(error => ( - - )); - - return ( - - - - ERROR LOG - - - - - } - > - {errorList && errorList.length ? errorList : } - - - ); -}; - -export default connect(({ errorLog }) => ({ errorLog }), { onClose: errorLogClose })(ErrorLogDrawer); diff --git a/frontend/packages/shared/apis/errorLog/ErrorLog.test.js b/frontend/packages/shared/apis/errorLog/ErrorLog.test.js deleted file mode 100644 index 0b1621c0fa..0000000000 --- a/frontend/packages/shared/apis/errorLog/ErrorLog.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; -import { render, fireEvent } from '@testing-library/react'; - -import ErrorLog from './ErrorLog'; -import { wrapInThemedTestApp } from 'testUtils'; -import { createStore } from 'core/store'; -import { errorLogAdd, errorLogOpen, errorLogClose, unselectError } from './actions'; - -describe('', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders without exploding', () => { - const rendered = render(wrapInThemedTestApp()); - expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument(); - }); - - it('should open and close', () => { - const store = createStore(); - const rendered = render(wrapInThemedTestApp(} />)); - - expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument(); - store.dispatch(errorLogOpen()); - rendered.getByText('ERROR LOG'); - rendered.getByText('All good'); - - expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe(null); - store.dispatch(errorLogClose()); - expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe('true'); - }); - - it('should close on click', () => { - const store = createStore(); - const rendered = render(wrapInThemedTestApp(} />)); - store.dispatch(errorLogOpen()); - - expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe(null); - fireEvent.click(rendered.getByLabelText('Close')); - expect(rendered.getByTestId('error-log-modal').getAttribute('aria-hidden')).toBe('true'); - }); - - it('should add an error to the log without opening', () => { - const store = createStore(); - const rendered = render(wrapInThemedTestApp(} />)); - - expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument(); - store.dispatch(errorLogAdd('msg-a', 'err-b', 'err-id', 'long-msg')); - expect(rendered.queryByText('ERROR LOG')).not.toBeInTheDocument(); - expect(rendered.queryByText('msg-a')).not.toBeInTheDocument(); - - store.dispatch(errorLogOpen()); - rendered.getByText('ERROR LOG'); - rendered.getByText('msg-a'); - // only when selected - expect(rendered.queryByText('long-msg')).not.toBeInTheDocument(); - }); - - it('should open the details of an error when selecting it on open', () => { - const store = createStore(); - const rendered = render(wrapInThemedTestApp(} />)); - - store.dispatch(errorLogAdd('msg-a', 'err-b', 'err-id', 'long-msg')); - store.dispatch(errorLogOpen('err-id')); - - rendered.getByText('ERROR LOG'); - rendered.getByText('msg-a'); - expect(hasCollapsedAncestor(rendered.getByText('long-msg'))).toBe(false); - - store.dispatch(unselectError()); - - rendered.getByText('ERROR LOG'); - rendered.getByText('msg-a'); - expect(hasCollapsedAncestor(rendered.getByText('long-msg'))).toBe(true); - }); -}); - -function hasCollapsedAncestor(element) { - if (!element || element === document.body) { - return false; - } - const isCollapsed = window.getComputedStyle(element).height === '0px'; - return isCollapsed || hasCollapsedAncestor(element.parentElement); -} diff --git a/frontend/packages/shared/apis/errorLog/ErrorLogEntry.js b/frontend/packages/shared/apis/errorLog/ErrorLogEntry.js deleted file mode 100644 index 448467a345..0000000000 --- a/frontend/packages/shared/apis/errorLog/ErrorLogEntry.js +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { - Collapse, - ListItem, - ListItemIcon, - ListItemSecondaryAction, - ListItemText, - Tooltip, - withStyles, - Typography, -} from '@material-ui/core'; -import IconButton from '@material-ui/core/IconButton'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; -import { CopyIcon } from 'shared/icons'; - -const errorEntryStyles = theme => ({ - nested: { - flexFlow: 'column nowrap', - alignItems: 'flex-start', - paddingLeft: theme.spacing(9), - paddingTop: 0, - paddingBottom: theme.spacing(2), - }, - errorDetail: { - margin: 0, - marginTop: theme.spacing(1), - }, - message: { - '&:hover': { - backgroundColor: theme.palette.highlight, - cursor: 'pointer', - }, - }, - timestamp: { - fontSize: '12px', - }, -}); - -const ErrorLogEntry = ({ message, error, timestamp, longMessage, highlight, classes }) => { - const [open, setOpen] = React.useState(highlight); - const clipboardInputRef = React.useRef(null); - - React.useLayoutEffect(() => { - setOpen(highlight); - }, [highlight]); - - const handleClick = () => { - setOpen(open => !open); - }; - - const handleCopyClick = e => { - e.stopPropagation(); - clipboardInputRef.current.select(); - document.execCommand('copy'); - }; - - const errorForClipboard = JSON.stringify({ - message, - timestamp, - error, - longMessage, - }); - - return ( -
    - - {open ? : } - - - - - - - - - {/* Adding the styles below seems to be the only way to copy text from the input to - clipboard and keep the input hidden from the view. */} - {}} - value={errorForClipboard} - type="text" - style={{ position: 'absolute', top: '-9999px', left: '-9999px' }} - /> - - - - {longMessage ? {longMessage} : null} -
    {error}
    -
    -
    -
    - ); -}; - -ErrorLogEntry.propTypes = { - error: PropTypes.string.isRequired, - message: PropTypes.string, - timestamp: PropTypes.string, - longMessage: PropTypes.string, - highlight: PropTypes.bool, -}; - -export default withStyles(errorEntryStyles)(ErrorLogEntry); diff --git a/frontend/packages/shared/apis/errorLog/ErrorLogEntry.test.js b/frontend/packages/shared/apis/errorLog/ErrorLogEntry.test.js deleted file mode 100644 index b79433f3e3..0000000000 --- a/frontend/packages/shared/apis/errorLog/ErrorLogEntry.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; - -import ErrorLogEntry from './ErrorLogEntry'; - -describe('', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders without exploding', () => { - const rendered = render(); - rendered.getByText('Unknown error'); - }); - - it('renders with all props', () => { - const rendered = render( - , - ); - - expect(rendered.queryByText('a')).toBeInTheDocument(); - expect(rendered.queryByText('Error: b')).not.toBeInTheDocument(); - expect(rendered.queryByText('c')).toBeInTheDocument(); - expect(rendered.queryByText('d')).not.toBeInTheDocument(); - }); - - it('renders and expands', () => { - const rendered = render(); - - expect(rendered.queryByText('d')).not.toBeInTheDocument(); - fireEvent.click(rendered.getByText('a')); - expect(rendered.queryByText('d')).toBeInTheDocument(); - }); - - it('copies error to clipboard', () => { - let selectedText = null; - let copiedText = null; - - document.onselect = event => { - const { target } = event; - selectedText = target.value.slice(target.selectionStart, target.selectionEnd); - }; - - document.execCommand = command => { - if (command === 'copy') { - copiedText = selectedText; - } - }; - - const rendered = render(); - expect(copiedText).toBeNull(); - fireEvent.click(rendered.getByLabelText('Copy to clipboard')); - expect(copiedText).toBe(JSON.stringify({ message: 'a', error: 'b' })); - - document.onselect = null; - document.execCommand = null; - }); -}); diff --git a/frontend/packages/shared/apis/errorLog/actions.js b/frontend/packages/shared/apis/errorLog/actions.js deleted file mode 100644 index fe7acb307e..0000000000 --- a/frontend/packages/shared/apis/errorLog/actions.js +++ /dev/null @@ -1,51 +0,0 @@ -import * as constants from './constants'; - -/** - * Add new entry to error log - * @param {String} message - Human readable error message - * @param {String|Object} error - Error message or object - * @param {String} errorId - Id of error - * @param {String} longFailureMessage - Optional: An extra error message that can be long and contain html - */ -export const errorLogAdd = (message, error, errorId, longMessage) => dispatch => { - dispatch({ - type: constants.ERROR_LOG_ADD, - payload: { - message, - error: `${error}`, - errorId, - longMessage, - }, - }); -}; - -/** - * Close error log - */ -export const errorLogClose = () => dispatch => { - dispatch({ - type: constants.ERROR_LOG_CLOSE, - }); -}; - -/** - * Open snackbar - * @param {String} errorId - Optional: Error to highlight - */ -export const errorLogOpen = errorId => dispatch => { - dispatch({ - type: constants.ERROR_LOG_OPEN, - payload: { - selectedErrorId: errorId, - }, - }); -}; - -/** - * Stop highlighting error - */ -export const unselectError = () => dispatch => { - dispatch({ - type: constants.ERROR_LOG_UNSELECT, - }); -}; diff --git a/frontend/packages/shared/apis/errorLog/constants.js b/frontend/packages/shared/apis/errorLog/constants.js deleted file mode 100644 index 87db25ffb2..0000000000 --- a/frontend/packages/shared/apis/errorLog/constants.js +++ /dev/null @@ -1,4 +0,0 @@ -export const ERROR_LOG_ADD = 'ERROR_LOG_ADD'; -export const ERROR_LOG_OPEN = 'ERROR_LOG_OPEN'; -export const ERROR_LOG_CLOSE = 'ERROR_LOG_CLOSE'; -export const ERROR_LOG_UNSELECT = 'ERROR_LOG_UNSELECT'; diff --git a/frontend/packages/shared/apis/errorLog/reducer.js b/frontend/packages/shared/apis/errorLog/reducer.js deleted file mode 100644 index 3c58509ec3..0000000000 --- a/frontend/packages/shared/apis/errorLog/reducer.js +++ /dev/null @@ -1,39 +0,0 @@ -import * as constants from './constants'; -import moment from 'moment'; - -/** - * The error log is a runtime log for error details that shouldn't be shown in the UI. - * The user can open the error log directly from an error message, or from a button in the app bar. - */ - -const defaultState = { - open: false, - errors: [], - selectedErrorId: null, -}; - -const errorLogReducer = (state = defaultState, action) => { - switch (action.type) { - case constants.ERROR_LOG_ADD: { - const newError = { - id: action.payload.errorId, - timestamp: moment().format('YYYY-MM-DD HH:mm:ss:SSS Z'), // reusable? - message: action.payload.message, - longMessage: action.payload.longMessage, - error: action.payload.error, - }; - - return { ...state, errors: [newError, ...state.errors] }; - } - case constants.ERROR_LOG_OPEN: - return { ...state, open: true, selectedErrorId: action.payload.selectedErrorId }; - case constants.ERROR_LOG_CLOSE: - return { ...state, open: false }; - case constants.ERROR_LOG_UNSELECT: - return { ...state, selectedErrorId: null }; - default: - return state; - } -}; - -export default errorLogReducer; diff --git a/frontend/packages/shared/apis/events/GoogleAnalyticsEvent.js b/frontend/packages/shared/apis/events/GoogleAnalyticsEvent.js deleted file mode 100644 index b385c5ec52..0000000000 --- a/frontend/packages/shared/apis/events/GoogleAnalyticsEvent.js +++ /dev/null @@ -1,42 +0,0 @@ -class GoogleAnalyticsEvent { - static LINK_CLICK = 'Link_Click'; - static BTN_CLICK = 'Button_Click'; - static TAB_CLICK = 'Tab_Click'; - static ROW_CLICK = 'Row_Click'; - static IMPRESSION = 'Impression'; - static HOVER = 'HOVER'; - - static EVENT_TYPES = [ - GoogleAnalyticsEvent.LINK_CLICK, - GoogleAnalyticsEvent.BTN_CLICK, - GoogleAnalyticsEvent.TAB_CLICK, - GoogleAnalyticsEvent.ROW_CLICK, - GoogleAnalyticsEvent.IMPRESSION, - GoogleAnalyticsEvent.HOVER, - ]; - - constructor(category, action, label, value, owner, context) { - if (GoogleAnalyticsEvent.EVENT_TYPES.indexOf(action) === -1) { - throw new Error( - `ERROR: Unsupported event action! Valid actions are one of: ${GoogleAnalyticsEvent.EVENT_TYPES.join(', ')}`, - ); - } - this.category = category; - this.action = action; - this.label = label; - this.value = value; - this.owner = owner; - this.context = context; - } -} -export default GoogleAnalyticsEvent; - -export const GA_BACKSTAGE_TRACKER = ['backstageTracker']; -export const GA_CROSS_DOMAIN_TRACKER = ['crossDomainTracker']; -export const GA_ALL_TRACKERS = ['backstageTracker', 'crossDomainTracker']; -export const GA_DEFAULT_PLUGIN_ID = 'backstage'; -export const GA_DEFAULT_PLUGIN_OWNER = 'tools'; -export const GA_LABEL_UNKNOWN = 'Unknown'; -export const GA_DEFAULT_USERNAME_PROPS = { label: 'Username' }; -export const GA_DEFAULT_OWNER_PROPS = { label: 'Owner' }; -export const GA_HEADER_LOGO_PROPS = { label: 'BackstageHome' }; diff --git a/frontend/packages/shared/apis/events/getGAContext.js b/frontend/packages/shared/apis/events/getGAContext.js deleted file mode 100644 index 085d3f210f..0000000000 --- a/frontend/packages/shared/apis/events/getGAContext.js +++ /dev/null @@ -1,13 +0,0 @@ -import { getAllReactComponentAncestors } from 'shared/components/Error/util/react'; - -const getGAContext = el => { - const componentStack = getAllReactComponentAncestors(el); - - const componentWithContext = componentStack.find(component => { - return component.props && component.props.gacontext; - }); - - return componentWithContext ? componentWithContext.props.gacontext : 'NA'; -}; - -export default getGAContext; diff --git a/frontend/packages/shared/apis/events/index.js b/frontend/packages/shared/apis/events/index.js deleted file mode 100644 index f6eebd5b17..0000000000 --- a/frontend/packages/shared/apis/events/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default as GoogleAnalyticsEvent } from 'shared/apis/events/GoogleAnalyticsEvent'; -export { default as getGAContext } from 'shared/apis/events/getGAContext'; -export { sendGAEvent } from 'shared/apis/events/sendEvent'; -export { sendGAOutboundLinkEvent } from 'shared/apis/events/sendEvent'; diff --git a/frontend/packages/shared/apis/events/reactGAMiddleware.js b/frontend/packages/shared/apis/events/reactGAMiddleware.js deleted file mode 100644 index 64f4159650..0000000000 --- a/frontend/packages/shared/apis/events/reactGAMiddleware.js +++ /dev/null @@ -1,51 +0,0 @@ -import ReactGA from 'react-ga'; -import { GA_ALL_TRACKERS } from 'shared/apis/events/GoogleAnalyticsEvent'; -import { matchPath } from 'react-router'; -import { env } from 'shared/apis/env'; - -// The following is in line with the recommended way of adding GA tracking in a react App -// https://github.com/react-ga/react-ga/wiki/React-Router-v4-Redux-Middleware - -export const trackPage = (page, info = {}) => { - // Report Google Analytics pageview - ReactGA.set({ page, ...info }, GA_ALL_TRACKERS); - ReactGA.pageview(page, GA_ALL_TRACKERS); -}; - -let currentPage = ''; - -export const googleAnalytics = store => next => action => { - if (env.isProduction) { - if (action.type === '@@router/LOCATION_CHANGE') { - const reduxStore = store.getState(); - const route = reduxStore.routes.find(r => matchPath(action.payload.location.pathname, r)); - - let pluginInfo = { - dimension1: null, - dimension2: null, - dimension3: null, - dimension4: null, - }; - - if (route && route.pluginOwner && route.pluginOwner.manifest) { - const { name, manifest } = route.pluginOwner; - const { id, owner, facts } = manifest; - - pluginInfo = { - dimension1: owner || null, - dimension2: name || null, - dimension4: id || null, - dimension3: facts ? facts.support_channel : null, - }; - } - - const nextPage = `${action.payload.location.pathname}${action.payload.location.search}`; - - if (currentPage !== nextPage) { - currentPage = nextPage; - trackPage(nextPage, pluginInfo); - } - } - } - return next(action); -}; diff --git a/frontend/packages/shared/apis/events/sendEvent.js b/frontend/packages/shared/apis/events/sendEvent.js deleted file mode 100644 index d73b202cc9..0000000000 --- a/frontend/packages/shared/apis/events/sendEvent.js +++ /dev/null @@ -1,49 +0,0 @@ -import GoogleAnalyticsEvent, { GA_BACKSTAGE_TRACKER } from 'shared/apis/events/GoogleAnalyticsEvent'; -import * as ReactGA from 'react-ga'; -import { env } from 'shared/apis/env'; - -const sendEvent = event => { - if (event instanceof GoogleAnalyticsEvent) { - const { category, action, label, value, owner, context } = event; - - ReactGA.event( - { - category: category, - action: action, - label: label, - value: value, - dimension1: owner, - dimension2: context, - }, - GA_BACKSTAGE_TRACKER, - ); - } -}; - -export const sendGAEvent = (eventCategory, eventAction, eventLabel, eventValue, eventOwner, eventContext) => { - if (env.isProduction) { - let gaEvent = new GoogleAnalyticsEvent( - eventCategory, - eventAction, - eventLabel, - eventValue, - eventOwner, - eventContext, - ); - - // Send click event - sendEvent(gaEvent); - } -}; - -function isAbsoluteUrl(url) { - return /^([a-z]+:\/\/|\/\/)/i.test(url); -} - -export const sendGAOutboundLinkEvent = to => { - // Send a page view if outbound link - if (to && isAbsoluteUrl(to)) { - ReactGA.set({ page: to }, GA_BACKSTAGE_TRACKER); - ReactGA.pageview(to, GA_BACKSTAGE_TRACKER); - } -}; diff --git a/frontend/packages/shared/apis/featureFlags/featureFlags.test.js b/frontend/packages/shared/apis/featureFlags/featureFlags.test.js deleted file mode 100644 index 5b60c6c59a..0000000000 --- a/frontend/packages/shared/apis/featureFlags/featureFlags.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { registerFeatureFlag } from 'shared/apis/featureFlags/featureFlagsActions'; -import FeatureFlags from 'shared/apis/featureFlags/featureFlags'; - -let testFeatureFlag = 'test'; - -describe('featureFlags', () => { - it('when no feature flag is registered', () => { - expect(FeatureFlags.getItem(testFeatureFlag)).toBe(false); - }); - - it('when feature flag is registered', () => { - registerFeatureFlag(testFeatureFlag); - expect(FeatureFlags.getItem(testFeatureFlag)).toBe(false); - }); - - it('when feature flag is enabled', () => { - FeatureFlags.enable(testFeatureFlag); - expect(FeatureFlags.getItem(testFeatureFlag)).toBe(true); - }); - - it('when feature flag is disabled', () => { - FeatureFlags.disable(testFeatureFlag); - expect(FeatureFlags.getItem(testFeatureFlag)).toBe(false); - }); -}); diff --git a/frontend/packages/shared/apis/featureFlags/featureFlags.ts b/frontend/packages/shared/apis/featureFlags/featureFlags.ts deleted file mode 100644 index 5eba74dc65..0000000000 --- a/frontend/packages/shared/apis/featureFlags/featureFlags.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Class which aims to be compliant with the feature flags functionality in - * the angular codebase of system-z. See src/legacy/_modules/systemZCore/modules/featureFlags/featureFlagsProvider.js - */ - -// Polyfill needed for protractor on jenkins to work -import 'url-search-params-polyfill'; - -type FlagObject = { [key: string]: boolean }; -type ChangeHandler = (flagValue: boolean) => void; - -class FeatureFlags { - private static instance: FeatureFlags; - private changeHandlers: Map = new Map(); - private featureFlags: FlagObject = {}; - - constructor() { - if (FeatureFlags.instance) { - return FeatureFlags.instance; - } - this.readFlagsFromStorage(); - this.processFeatureFlagsFromLocationSearch(); - FeatureFlags.instance = this; - } - - /** - * Get a feature flag - */ - public getItem(flag: string): boolean { - return !!this.featureFlags[flag]; - } - - /** - * Get all feature flags - */ - public getFlags(): FlagObject { - return Object.assign({}, this.featureFlags); - } - - /** - * Register a changeHandler that will be invoked when a flag changes - */ - public onChange(flagName: string, changeHandler: ChangeHandler) { - this.changeHandlers.set(flagName, changeHandler); - } - - /** - * Sets a feature flag to true - */ - public enable(flag: string) { - this.featureFlags[flag] = true; - this.callChangeHandler(flag); - this.writeFlagsToStorage(); - } - - /** - * Stores the entire opbject - */ - public storeAll(flags: FlagObject) { - const changedFlags = Object.keys(flags).filter(flagName => !!this.featureFlags[flagName] !== !!flags[flagName]); - this.featureFlags = flags; - changedFlags.forEach(flag => this.callChangeHandler(flag)); - this.writeFlagsToStorage(); - } - - /** - * Sets a feature flag to false - */ - public disable(flag: string) { - this.featureFlags[flag] = false; - this.callChangeHandler(flag); - this.writeFlagsToStorage(); - } - - private callChangeHandler(flag: string) { - const handler = this.changeHandlers.get(flag); - if (handler) { - handler(!!this.featureFlags[flag]); - } - } - - private readFlagsFromStorage() { - const flagsString = localStorage.getItem('featureFlags'); - if (!flagsString) { - this.featureFlags = {}; - } else { - try { - this.featureFlags = JSON.parse(flagsString); - let tmpFlags: FlagObject = {}; - // Backstage angular stores an object as a key which messes up the flags - Object.entries(this.featureFlags) - .filter(([key]) => key !== '[object Object]') - .forEach(([key, value]) => { - tmpFlags[key] = value; - }); - this.featureFlags = tmpFlags; - } catch (e) { - console.warn('Feature flags parse error. Initializing new empty storage.'); - localStorage.removeItem('featureFlags'); - this.featureFlags = {}; - } - } - } - - private writeFlagsToStorage() { - // Filter out false flags as in angular system-z - const trimmedFlags: FlagObject = Object.entries(this.featureFlags) - .filter(([, value]) => value) - .reduce((agg, [key, value]) => { - agg[key] = value; - return agg; - }, {} as FlagObject); - - const flagsString = JSON.stringify(trimmedFlags); - window.localStorage.setItem('featureFlags', flagsString); - this.readFlagsFromStorage(); - } - - /** - * Will modify the feature flags according to the location.search - * functionality as in the angular system-z. - * valid parameters are *flag*, *flagsOn* and *flagsOff: - */ - private processFeatureFlagsFromLocationSearch() { - const tmpFeatureFlags = Object.assign({}, this.featureFlags); - - const params = new URLSearchParams(document.location.search.substring(1)); - const flagsOff = params.getAll('flagsOff'); - flagsOff.forEach(flag => { - tmpFeatureFlags[flag] = false; - }); - - const flagsOn = params.getAll('flagsOn').concat(params.getAll('flags')); - flagsOn.forEach(flag => { - tmpFeatureFlags[flag] = true; - }); - this.featureFlags = tmpFeatureFlags; - this.writeFlagsToStorage(); - } -} - -let _instance = new FeatureFlags(); - -export default _instance; diff --git a/frontend/packages/shared/apis/featureFlags/featureFlagsActions.d.ts b/frontend/packages/shared/apis/featureFlags/featureFlagsActions.d.ts deleted file mode 100644 index 530477128b..0000000000 --- a/frontend/packages/shared/apis/featureFlags/featureFlagsActions.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function registerFeatureFlag(flag: string): void; diff --git a/frontend/packages/shared/apis/featureFlags/featureFlagsActions.js b/frontend/packages/shared/apis/featureFlags/featureFlagsActions.js deleted file mode 100644 index 32654369b4..0000000000 --- a/frontend/packages/shared/apis/featureFlags/featureFlagsActions.js +++ /dev/null @@ -1,13 +0,0 @@ -import store from 'core/store'; -import { REGISTER_FEATURE_FLAG } from 'shared/apis/featureFlags/featureFlagsConstants'; - -/** - * Register a feature flag that should be available for the app and visible in settings - * @param flag the feature flag - */ -export function registerFeatureFlag(flag) { - store.dispatch({ - type: REGISTER_FEATURE_FLAG, - payload: flag, - }); -} diff --git a/frontend/packages/shared/apis/featureFlags/featureFlagsConstants.js b/frontend/packages/shared/apis/featureFlags/featureFlagsConstants.js deleted file mode 100644 index d99ac6edbe..0000000000 --- a/frontend/packages/shared/apis/featureFlags/featureFlagsConstants.js +++ /dev/null @@ -1 +0,0 @@ -export const REGISTER_FEATURE_FLAG = 'REGISTER_FEATURE_FLAG'; diff --git a/frontend/packages/shared/apis/featureFlags/featureFlagsReducer.js b/frontend/packages/shared/apis/featureFlags/featureFlagsReducer.js deleted file mode 100644 index f31deb09cc..0000000000 --- a/frontend/packages/shared/apis/featureFlags/featureFlagsReducer.js +++ /dev/null @@ -1,12 +0,0 @@ -import { REGISTER_FEATURE_FLAG } from 'shared/apis/featureFlags/featureFlagsConstants'; - -const featureFlagsReducer = (state = [], action) => { - switch (action.type) { - case REGISTER_FEATURE_FLAG: - return [...state, action.payload]; - default: - return state; - } -}; - -export default featureFlagsReducer; diff --git a/frontend/packages/shared/apis/firestore/FirestoreDocFactory.test.js b/frontend/packages/shared/apis/firestore/FirestoreDocFactory.test.js deleted file mode 100644 index 6a2d125b8b..0000000000 --- a/frontend/packages/shared/apis/firestore/FirestoreDocFactory.test.js +++ /dev/null @@ -1,99 +0,0 @@ -import FirestoreDocFactory from './FirestoreDocFactory'; -import { withLogCollector } from 'testUtils'; -import appAuth from 'core/app/auth'; - -jest.mock('core/app/auth', () => ({ - auth: jest.fn(), -})); - -const successAuthMock = async () => ({ - firebaseAuth: { - customToken: 'my-token', - }, -}); - -const missingUserAuthMock = async () => ({ - firebaseAuth: { - customToken: 'missing-user', - }, -}); - -const errorAuthMock = async () => { - throw new Error('NOPE'); -}; - -jest.mock('firebase/app', () => ({ - initializeApp: () => ({ - auth: () => ({ - signOut: () => {}, - signInWithCredential: async ({ id, access }) => ({ user: { uid: `uid:${id}:${access}` } }), - signInWithCustomToken: async token => ({ - user: token === 'missing-user' ? undefined : { uid: `token:${token}` }, - }), - }), - firestore: () => ({ - enablePersistence() {}, - collection: collectionId => ({ - doc: docId => `doc:${collectionId}/${docId}`, - }), - }), - }), - auth: { - GoogleAuthProvider: { - credential: (id, access) => ({ id, access }), - }, - }, -})); - -const config = { - apiKey: '123', - projectId: 'abc', -}; - -describe('FirestoreDocFactory', () => { - it('should use either google token or custom token for auth', async () => { - appAuth.auth.mockResolvedValueOnce({ firebaseAuth: { googleAccessToken: 'google-token' } }); - await FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test1-1').getDoc(); - - appAuth.auth.mockResolvedValueOnce({ firebaseAuth: { customToken: 'my-token' } }); - await FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test1-2').getDoc(); - - appAuth.auth.mockResolvedValueOnce({ firebaseAuth: { noToken: 'nope' } }); - const logs = await withLogCollector(async () => { - await expect(FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test1-3').getDoc()).rejects.toThrow( - 'no token available', - ); - }); - - expect(logs.error).toEqual(['Firebase auth failed, Error: no token available']); - }); - - it('should get doc', async () => { - appAuth.auth.mockImplementation(successAuthMock); - - const factory = FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test2'); - expect(factory.getDoc()).resolves.toBe('doc:users/token:my-token'); - }); - - it('should not provide a doc if auth fails', async () => { - appAuth.auth.mockImplementation(errorAuthMock); - - const factory = FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test4'); - - const logs = await withLogCollector(['error'], async () => { - await expect(factory.getDoc()).rejects.toThrow('NOPE'); - }); - expect(logs.error).toEqual(['Firebase auth failed, Error: NOPE']); - }); - - it('should not provide a doc if user is missing from session', async () => { - appAuth.auth.mockImplementation(missingUserAuthMock); - - const factory = FirestoreDocFactory.createWithAppAuth(appAuth, config, 'test5'); - - const logs = await withLogCollector(['error'], async () => { - await expect(factory.getDoc()).rejects.toThrow('received null user from signIn'); - }); - expect(logs.error).toEqual(['Firebase auth failed, Error: received null user from signIn']); - }); -}); diff --git a/frontend/packages/shared/apis/firestore/FirestoreDocFactory.ts b/frontend/packages/shared/apis/firestore/FirestoreDocFactory.ts deleted file mode 100644 index 2a6728330b..0000000000 --- a/frontend/packages/shared/apis/firestore/FirestoreDocFactory.ts +++ /dev/null @@ -1,73 +0,0 @@ -import firebase from 'firebase/app'; -import 'firebase/auth'; -import 'firebase/firestore'; -import { AppAuth } from 'core/app/auth'; - -export type DocumentReference = firebase.firestore.DocumentReference; - -function defer() { - let resolve: (t: T) => void; - let reject: (e: Error) => void; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - return { promise, resolve: resolve!, reject: reject! }; -} - -// The purpose of this class is to provide a Firestore instance that is -// authenticated with Backstage's existing google auth. It also recreates -// and removes the Firestore instance if the google session changes (login/logout). -export default class FirestoreDocFactory { - private deferredDoc = defer(); - - static createWithAppAuth(appAuth: AppAuth, options: Object, appName: string): FirestoreDocFactory { - const app = firebase.initializeApp(options, appName); - const docFactory = new FirestoreDocFactory(app); - - (async () => { - try { - const authInit = await appAuth.auth(); - - const { googleAccessToken, customToken } = authInit.firebaseAuth; - // Firebase auth with google credentials is much faster, so use if available. - if (googleAccessToken) { - const credential = firebase.auth.GoogleAuthProvider.credential(null, googleAccessToken); - const session = await app.auth().signInWithCredential(credential); - await docFactory.init(session); - } else if (customToken) { - const session = await app.auth().signInWithCustomToken(customToken); - await docFactory.init(session); - } else { - throw new Error('no token available'); - } - } catch (error) { - console.error(`Firebase auth failed, ${error}`); - docFactory.setError(error); - } - })(); - - return docFactory; - } - - constructor(private readonly app: firebase.app.App) {} - - getDoc(): Promise { - return this.deferredDoc.promise; - } - - private async init(session: firebase.auth.UserCredential) { - if (!session.user) { - throw new Error('received null user from signIn'); - } - const firestore = this.app.firestore(); - await firestore.enablePersistence({ synchronizeTabs: true }); - const doc = firestore.collection('users').doc(session.user.uid); - - this.deferredDoc.resolve(doc); - } - - private setError(error: Error) { - this.deferredDoc.reject(error); - } -} diff --git a/frontend/packages/shared/apis/firestore/FirestoreProvider.test.tsx b/frontend/packages/shared/apis/firestore/FirestoreProvider.test.tsx deleted file mode 100644 index a5fa030732..0000000000 --- a/frontend/packages/shared/apis/firestore/FirestoreProvider.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { useFirestore, FirestoreProvider } from './FirestoreProvider'; -import { render } from '@testing-library/react'; -import getFirestoreApi from './getFirestoreApi'; - -jest.mock('./getFirestoreApi'); - -const mockedGetFirestoreApi = getFirestoreApi as jest.MockedFunction; - -describe('FirestoreProvider', () => { - it('should provide a firestore api', () => { - const mockApi = { text: 'firestore' }; - - const MyComponent = () => { - const api = useFirestore() as any; - return
    this is the api: {api.text}
    ; - }; - - const rendered = render( - - - , - ); - - rendered.getByText('this is the api: firestore'); - }); - - it('should fall back to fetching default firestore api instance', () => { - const mockApi = { text: 'default-firestore' }; - mockedGetFirestoreApi.mockReturnValue(mockApi as any); - - const MyComponent = () => { - const api = useFirestore() as any; - return
    this is the api: {api.text}
    ; - }; - - const rendered = render(); - - rendered.getByText('this is the api: default-firestore'); - }); -}); diff --git a/frontend/packages/shared/apis/firestore/FirestoreProvider.tsx b/frontend/packages/shared/apis/firestore/FirestoreProvider.tsx deleted file mode 100644 index 25aa11ffa4..0000000000 --- a/frontend/packages/shared/apis/firestore/FirestoreProvider.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { FC, createContext, useContext } from 'react'; -import FirestoreStorage from './FirestoreStorage'; -import { FirestoreApi } from './types'; -import getFirestoreApi from './getFirestoreApi'; - -const Context = createContext(undefined); - -type ProviderProps = { - api: FirestoreStorage; -}; - -export const FirestoreProvider: FC = ({ api, children }) => { - return ; -}; - -export function useFirestore(): FirestoreApi { - let firestoreApi = useContext(Context); - if (!firestoreApi) { - firestoreApi = getFirestoreApi(); - } - return firestoreApi; -} diff --git a/frontend/packages/shared/apis/firestore/FirestoreStorage.test.ts b/frontend/packages/shared/apis/firestore/FirestoreStorage.test.ts deleted file mode 100644 index b4e77977af..0000000000 --- a/frontend/packages/shared/apis/firestore/FirestoreStorage.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import firebase from 'firebase/app'; -import FirestoreDocFactory from './FirestoreDocFactory'; -import FirestoreStorage from './FirestoreStorage'; - -type DocumentData = firebase.firestore.DocumentData; -type DocumentSnapshot = firebase.firestore.DocumentSnapshot; -type DocumentReference = firebase.firestore.DocumentReference; -type CollectionReference = firebase.firestore.CollectionReference; - -function createQueryMock(result: DocumentSnapshot[]) { - const calls: any[] = []; - - const pushFn = (name: string) => (...args: any[]) => { - calls.push([name, ...args]); - return mock; - }; - - const mock = { - calls, - limit: pushFn('limit'), - limitToLast: pushFn('limitToLast'), - orderBy: pushFn('orderBy'), - startAfter: pushFn('startAfter'), - startAt: pushFn('startAt'), - endAt: pushFn('endAt'), - endBefore: pushFn('endBefore'), - where: pushFn('where'), - get: jest.fn().mockReturnValue({ - docs: result, - }), - }; - return mock; -} - -describe('FirestoreStorage', () => { - it('should be created using doc factory', async () => { - const mockDoc = {} as any; - - jest.spyOn(FirestoreDocFactory, 'createWithAppAuth').mockReturnValue({ getDoc: async () => mockDoc } as any); - const storage = FirestoreStorage.create({} as any); - - await expect((storage as any).rootDoc).resolves.toBe(mockDoc); - }); - - it('should get the root doc', async () => { - const mockDoc = { - get: async () => mockSnapshot as DocumentSnapshot, - path: 'users/root', - } as DocumentReference; - const mockSnapshot = { - id: 'root', - ref: mockDoc, - exists: true, - data: () => ({ hello: 'world' } as DocumentData), - }; - - const storage = new FirestoreStorage(Promise.resolve(mockDoc)); - await expect(storage.get('/')).resolves.toEqual({ - data: { hello: 'world' }, - exists: true, - id: 'root', - path: '/', - }); - }); - - it('should get a doc in a collection', async () => { - const mockDoc = { - get: async () => mockSnapshot as DocumentSnapshot, - path: 'users/root/col/doc', - } as DocumentReference; - const mockCollection = { - path: 'users/root/col', - doc: (path: string) => path === 'doc' && mockDoc, - } as CollectionReference; - const rootDoc = { - path: 'users/root', - collection: (path: string) => path === 'col' && mockCollection, - } as DocumentReference; - const mockSnapshot = { - id: 'doc', - ref: mockDoc, - exists: true, - data: () => ({ hello: 'world' } as DocumentData), - }; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.get('/col/doc')).resolves.toEqual({ - data: { hello: 'world' }, - exists: true, - id: 'doc', - path: '/col/doc', - }); - }); - - it('should get missing doc', async () => { - const mockDoc = { - get: async () => (mockSnapshot as unknown) as DocumentSnapshot, - path: 'users/root/col/doc', - } as DocumentReference; - const mockCollection = { - path: 'users/root/col', - doc: (path: string) => path === 'doc' && mockDoc, - } as CollectionReference; - const rootDoc = { - path: 'users/root', - collection: (path: string) => path === 'col' && mockCollection, - } as DocumentReference; - const mockSnapshot = { - id: 'doc', - ref: mockDoc, - exists: false, - }; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.get('/col/doc')).resolves.toEqual({ - data: undefined, - exists: false, - id: 'doc', - path: '/col/doc', - }); - }); - - it('should fail to get docs and cols with invalid paths', async () => { - const storage = new FirestoreStorage(undefined as any); - - await expect(storage.get('')).rejects.toThrow("Storage document path must start with '/', got ''"); - await expect(storage.get('col')).rejects.toThrow("Storage document path must start with '/', got 'col'"); - await expect(storage.get('/col/doc/')).rejects.toThrow( - "Storage document path must not end with '/', got '/col/doc/'", - ); - await expect(storage.get('/col')).rejects.toThrow( - "Storage document path must have an even number of path components, got '/col'", - ); - await expect(storage.get('/col/doc/col2')).rejects.toThrow( - "Storage document path must have an even number of path components, got '/col/doc/col2'", - ); - - await expect(storage.query({ path: '' })).rejects.toThrow("Storage collection path must start with '/', got ''"); - await expect(storage.query({ path: 'col/' })).rejects.toThrow( - "Storage collection path must start with '/', got 'col/'", - ); - await expect(storage.query({ path: '/col' })).rejects.toThrow( - "Storage collection path must end with '/', got '/col'", - ); - await expect(storage.query({ path: '/' })).rejects.toThrow( - "Storage collection path must have an odd number of path components, got '/'", - ); - await expect(storage.query({ path: '/col/doc/' })).rejects.toThrow( - "Storage collection path must have an odd number of path components, got '/col/doc/'", - ); - await expect(storage.query({ path: '/col/doc/col2/doc2/' })).rejects.toThrow( - "Storage collection path must have an odd number of path components, got '/col/doc/col2/doc2/'", - ); - }); - - it('should add a doc to a collection', async () => { - const mockDoc2 = { - path: 'users/root/col/doc/col2/new-doc', - } as DocumentReference; - const mockAdd = jest.fn().mockImplementation(() => mockDoc2); - const mockCollection2 = { - path: 'users/root/col/doc/col2', - add: mockAdd as (data: any) => Promise, - } as CollectionReference; - const mockDoc = { - path: 'users/root/col/new-doc', - collection: (path: string) => path === 'col2' && mockCollection2, - } as DocumentReference; - const mockCollection = { - path: 'users/root/col', - doc: (path: string) => path === 'doc' && mockDoc, - } as CollectionReference; - const rootDoc = { - path: 'users/root', - collection: (path: string) => path === 'col' && mockCollection, - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.add('/col/doc/col2/', { hello: 'world' })).resolves.toBe('/col/doc/col2/new-doc'); - expect(mockAdd).toHaveBeenCalledWith({ hello: 'world' }); - }); - - it('should set data in a doc', async () => { - const mockSet = jest.fn(); - const rootDoc = { - path: 'users/root', - set: mockSet as any, - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.set('/', { foo: 'bar' })).resolves.toBeUndefined(); - expect(mockSet).toHaveBeenCalledWith({ foo: 'bar' }); - }); - - it('should update data in a doc', async () => { - const mockUpdate = jest.fn(); - const rootDoc = { - path: 'users/root', - update: mockUpdate as any, - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.update('/', { foo: 'bar' })).resolves.toBeUndefined(); - expect(mockUpdate).toHaveBeenCalledWith({ foo: 'bar' }); - }); - - it('should merge data into a doc', async () => { - const mockSet = jest.fn(); - const rootDoc = { - path: 'users/root', - set: mockSet as any, - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.merge('/', { foo: 'bar' })).resolves.toBeUndefined(); - expect(mockSet).toHaveBeenCalledWith({ foo: 'bar' }, { merge: true }); - }); - - it('should delete a doc', async () => { - const mockDelete = jest.fn(); - const rootDoc = { - path: 'users/root', - delete: mockDelete as any, - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.delete('/')).resolves.toBeUndefined(); - expect(mockDelete).toHaveBeenCalledWith(); - }); - - it('should query a collection with no params', async () => { - const queryMock = createQueryMock([ - { - id: 'doc1', - ref: { path: 'users/root/col/doc1' } as DocumentReference, - exists: true, - data: () => ({ foo: 'bar' } as DocumentData), - } as DocumentSnapshot, - ]); - const rootDoc = { - path: 'users/root', - collection: (path: string) => path && (queryMock as any), - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.query({ path: '/col/' })).resolves.toEqual({ - docs: [ - { - id: 'doc1', - path: '/col/doc1', - exists: true, - data: { foo: 'bar' }, - }, - ], - }); - expect(queryMock.calls).toEqual([]); - }); - - it('should query a collection with limit', async () => { - const queryMock = createQueryMock([]); - const rootDoc = { - path: 'users/root', - collection: (path: string) => path && (queryMock as any), - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect(storage.query({ path: '/col/', limit: 10 })).resolves.toEqual({ - docs: [], - }); - expect(queryMock.calls).toEqual([['limit', 10]]); - }); - - it('should query a collection with many params', async () => { - const queryMock = { - ...createQueryMock([]), - doc: (path: string) => ({ fakeDoc: path }), - }; - const rootDoc = { - path: 'users/root', - collection: (path: string) => path && (queryMock as any), - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect( - storage.query({ - path: '/col/', - limit: 10, - startAt: '/col/bar', - startAfter: '/col/bar2', - endAt: '/col/foo', - endBefore: '/col/foo2', - orderBy: { field: 'my.counter', direction: 'desc', startAfter: '2', endBefore: '10' }, - where: { field: 'type', op: '==', value: 'correct' }, - }), - ).resolves.toEqual({ - docs: [], - }); - expect(queryMock.calls).toEqual([ - ['startAt', { fakeDoc: 'bar' }], - ['startAfter', { fakeDoc: 'bar2' }], - ['endAt', { fakeDoc: 'foo' }], - ['endBefore', { fakeDoc: 'foo2' }], - ['where', 'type', '==', 'correct'], - ['orderBy', 'my.counter', 'desc'], - ['startAfter', '2'], - ['endBefore', '10'], - ['limit', 10], - ]); - }); - - it('should query with multiple orderBy clauses', async () => { - const queryMock = createQueryMock([]); - const rootDoc = { - path: 'users/root', - collection: (path: string) => path && (queryMock as any), - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect( - storage.query({ - path: '/col/', - orderBy: [ - { field: ['foo', 'bar'], direction: 'asc', startAt: 'a', endAt: 'z' }, - { field: 'b', startAt: 1, endAt: 10 }, - ], - }), - ).resolves.toEqual({ docs: [] }); - expect(queryMock.calls).toEqual([ - ['orderBy', new firebase.firestore.FieldPath('foo', 'bar'), 'asc'], - ['orderBy', 'b', undefined], - ['startAt', 'a', 1], - ['endAt', 'z', 10], - ]); - }); - - it('should fail query with gap in order delimiters', async () => { - const rootDoc = { - path: 'users/root', - collection: (path: string) => path && (createQueryMock([]) as any), - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - await expect( - storage.query({ - path: '/col/', - orderBy: [{ field: 'a' }, { field: 'b', startAt: 1 }], - }), - ).rejects.toThrow('Invalid storage query, startAt option must appear in all previous orderBy clauses'); - - await expect( - storage.query({ - path: '/col/', - orderBy: [ - { field: 'a', endAt: 1 }, - { field: 'b', startAfter: 1 }, - ], - }), - ).rejects.toThrow('Invalid storage query, startAfter option must appear in all previous orderBy clauses'); - - await expect( - storage.query({ - path: '/col/', - orderBy: [ - { field: 'a', startAfter: 1 }, - { field: 'b', endAt: 1 }, - { field: 'b', endBefore: 1 }, - ], - }), - ).rejects.toThrow('Invalid storage query, endAt option must appear in all previous orderBy clauses'); - - await expect( - storage.query({ - path: '/col/', - orderBy: [ - { field: 'a', startAt: 1 }, - { field: 'b', endBefore: 0 }, - ], - }), - ).rejects.toThrow('Invalid storage query, endBefore option must appear in all previous orderBy clauses'); - - await expect( - storage.query({ - path: '/col/', - orderBy: [ - { field: 'a', startAt: 1, startAfter: 2, endBefore: 0 }, - { field: 'b', startAt: null }, - ], - }), - ).rejects.toThrow('Invalid storage query, only one of startAt and startAfter can be used in all orderBy clauses'); - - await expect( - storage.query({ - path: '/col/', - orderBy: [ - { field: 'a', endAt: null, endBefore: null }, - { field: 'b', endBefore: 0 }, - ], - }), - ).rejects.toThrow('Invalid storage query, only one of endAt and endBefore can be used in all orderBy clauses'); - }); - - it('should observe a document', async () => { - let resolveListener: any; - let listenerPromise = new Promise(resolve => { - resolveListener = resolve; - }); - - const mockDoc = { - path: 'users/root', - onSnapshot: ({ next }: any) => { - resolveListener(next); - }, - } as DocumentReference; - const mockSnapshot = { - id: 'root', - ref: mockDoc, - exists: true, - data: () => ({ foo: 'bar' } as DocumentData), - }; - - const storage = new FirestoreStorage(Promise.resolve(mockDoc)); - const subscriber = { - next: jest.fn(), - error: jest.fn(), - complete: jest.fn(), - }; - const observable = storage.observe('/'); - observable.subscribe(subscriber); - - const listener = await listenerPromise; - - expect(subscriber.next).not.toHaveBeenCalled(); - listener(mockSnapshot); - expect(subscriber.next).toHaveBeenCalledWith({ id: 'root', path: '/', exists: true, data: { foo: 'bar' } }); - }); - - it('should observe and listen separately for each subscriber', async () => { - const onSnapshot = jest.fn().mockImplementation(({ next }) => { - next?.(mockSnapshot); - return () => {}; - }); - const mockDoc = { - path: 'users/root', - onSnapshot: onSnapshot as any, - } as DocumentReference; - const mockSnapshot = { - id: 'root', - ref: mockDoc, - exists: true, - data: () => ({ foo: 'bar' } as DocumentData), - }; - - const storage = new FirestoreStorage(Promise.resolve(mockDoc)); - - const out = await new Promise(resolve => { - const subscription = storage.observe('/').subscribe({}); - storage - .observe('/') - .subscribe({}) - .unsubscribe(); - storage.observe('/').subscribe({ - next: val => { - subscription.unsubscribe(); - resolve(val); - }, - }); - }); - - expect(out).toEqual({ id: 'root', path: '/', exists: true, data: { foo: 'bar' } }); - expect(onSnapshot).toHaveBeenCalledTimes(2); - }); - - it('should forward root doc errors in observe', async () => { - const storage = new FirestoreStorage(Promise.reject(new Error('NOPE'))); - - const out = await new Promise(resolve => { - storage.observe('/').subscribe({ - error: resolve, - }); - }); - - expect(out).toEqual(new Error('NOPE')); - }); - - it('should forward errors in observe', async () => { - const onSnapshot = jest.fn().mockImplementation(({ error }) => error(new Error('NOPE'))); - const mockDoc = { - path: 'users/root', - onSnapshot: onSnapshot as any, - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(mockDoc)); - - const error = await new Promise(resolve => { - storage.observe('/').subscribe({ - error: resolve, - }); - }); - - expect(error).toEqual(new Error('NOPE')); - }); - - it('should observe a query', async () => { - const onSnapshot = jest.fn().mockImplementation(({ next }) => { - next?.({ docs: [mockSnapshot] }); - return () => {}; - }); - const queryMock = { ...createQueryMock([]), onSnapshot }; - const rootDoc = { - path: 'users/root', - collection: (path: string) => path && (queryMock as any), - } as DocumentReference; - const mockSnapshot = { - id: 'root', - ref: rootDoc, - exists: true, - data: () => ({ foo: 'bar' } as DocumentData), - }; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - - const result = await new Promise(resolve => { - const subscriber = storage.observeQuery({ path: '/col/' }).subscribe({}); - storage - .observeQuery({ path: '/col/' }) - .subscribe({}) - .unsubscribe(); - storage.observeQuery({ path: '/col/' }).subscribe({ - next: value => { - resolve(value); - subscriber.unsubscribe(); - }, - }); - }); - - expect(result.docs).toEqual([{ id: 'root', path: '/', exists: true, data: { foo: 'bar' } }]); - expect(onSnapshot).toHaveBeenCalledTimes(2); - }); - - it('should forward root doc errors in observe query', async () => { - const storage = new FirestoreStorage(Promise.reject(new Error('NOPE'))); - - const out = await new Promise(resolve => { - storage.observeQuery({ path: '/col/' }).subscribe({ - error: resolve, - }); - }); - - expect(out).toEqual(new Error('NOPE')); - }); - - it('should forward errors in observe query', async () => { - const onSnapshot = jest.fn().mockImplementation(({ error }) => error(new Error('NOPE'))); - const queryMock = { ...createQueryMock([]), onSnapshot }; - const rootDoc = { - path: 'users/root', - collection: (path: string) => path && (queryMock as any), - } as DocumentReference; - - const storage = new FirestoreStorage(Promise.resolve(rootDoc)); - - const error = await new Promise(resolve => { - storage.observeQuery({ path: '/col/' }).subscribe({ - error: resolve, - }); - }); - - expect(error).toEqual(new Error('NOPE')); - }); -}); diff --git a/frontend/packages/shared/apis/firestore/FirestoreStorage.ts b/frontend/packages/shared/apis/firestore/FirestoreStorage.ts deleted file mode 100644 index 49864bfef5..0000000000 --- a/frontend/packages/shared/apis/firestore/FirestoreStorage.ts +++ /dev/null @@ -1,298 +0,0 @@ -import Observable from 'zen-observable'; -import firebase from 'firebase/app'; -import chunk from 'lodash/chunk'; -import castArray from 'lodash/castArray'; -import FirestoreDocFactory from './FirestoreDocFactory'; -import { - JsonPrimitive, - DocData, - DocumentPath, - CollectionPath, - Doc, - FieldPath, - FirestoreApi, - Query, - QueryResult, -} from './types'; -import { AppAuth } from 'core/app/auth'; - -const firebaseConfig = { - apiKey: 'AIzaSyD9lgzKmd9jliPcCH1petc7HiB67jlh1GQ', - authDomain: 'spotify.com', - projectId: 'spotify-backstage', -}; - -type DocumentSnapshot = firebase.firestore.DocumentSnapshot; -type DocumentReference = firebase.firestore.DocumentReference; -type CollectionReference = firebase.firestore.CollectionReference; -type FirestoreQuery = firebase.firestore.Query; - -function mkFieldPath(path: FieldPath): string | firebase.firestore.FieldPath { - if (Array.isArray(path)) { - return new firebase.firestore.FieldPath(...path); - } - return path; -} - -export default class FirestoreStorage implements FirestoreApi { - static create(appAuth: AppAuth): FirestoreStorage { - const factory = FirestoreDocFactory.createWithAppAuth(appAuth, firebaseConfig, 'default'); - return new FirestoreStorage(factory.getDoc()); - } - - constructor(private readonly rootDoc: Promise) {} - - async get(path: DocumentPath): Promise> { - const doc = await this.getDocRef(await this.rootDoc, path); - const snapshot = await doc.get(); - - return this.transformSnapshot(snapshot, await this.rootDoc); - } - - observe(path: DocumentPath): Observable> { - const docPromise = this.rootDoc.then(rootDoc => { - return Promise.all([this.getDocRef(rootDoc, path), this.rootDoc]); - }); - - return new Observable(subscriber => { - let unsubscribe: () => void; - let didUnsubscribe = false; - - docPromise.then( - ([doc, rootDoc]) => { - if (didUnsubscribe) { - return; - } - unsubscribe = doc.onSnapshot({ - next: snapshot => { - subscriber.next(this.transformSnapshot(snapshot, rootDoc)); - }, - error(error) { - subscriber.error(error); - }, - }); - }, - error => subscriber.error(error), - ); - - return () => { - didUnsubscribe = true; - if (unsubscribe) { - unsubscribe(); - } - }; - }); - } - - async query(query: Query): Promise> { - const collectionRef = await this.getColRef(query.path); - const rootDoc = await this.rootDoc; - const queryRef = this.prepareQuery(rootDoc, collectionRef, query); - - const result = await queryRef.get(); - const docs = result.docs.map(snapshot => this.transformSnapshot(snapshot, rootDoc)); - - return { docs }; - } - - observeQuery(query: Query): Observable> { - const collectionRefPromise = Promise.all([this.getColRef(query.path), this.rootDoc]); - - return new Observable(subscriber => { - let unsubscribe: () => void; - let didUnsubscribe = false; - - collectionRefPromise.then( - ([collectionRef, rootDoc]) => { - if (didUnsubscribe) { - return; - } - unsubscribe = this.prepareQuery(rootDoc, collectionRef, query).onSnapshot({ - next: result => { - const docs = result.docs.map(snapshot => this.transformSnapshot(snapshot, rootDoc)); - subscriber.next({ docs }); - }, - error(error) { - subscriber.error(error); - }, - }); - }, - error => subscriber.error(error), - ); - - return () => { - didUnsubscribe = true; - if (unsubscribe) { - unsubscribe(); - } - }; - }); - } - - async add(path: CollectionPath, data: DocData): Promise { - const colRef = await this.getColRef(path); - const newDoc = await colRef.add(data); - const rootDoc = await this.rootDoc; - return newDoc.path.replace(rootDoc.path, ''); - } - - async set(path: DocumentPath, data: DocData): Promise { - const doc = await this.getDocRef(await this.rootDoc, path); - return doc.set(data); - } - - async update(path: DocumentPath, data: DocData): Promise { - const doc = await this.getDocRef(await this.rootDoc, path); - return doc.update(data); - } - - async merge(path: DocumentPath, data: DocData): Promise { - const doc = await this.getDocRef(await this.rootDoc, path); - return doc.set(data, { merge: true }); - } - - async delete(path: DocumentPath): Promise { - const doc = await this.getDocRef(await this.rootDoc, path); - return doc.delete(); - } - - private prepareQuery(rootDoc: DocumentReference, ref: FirestoreQuery, query: Query): FirestoreQuery { - if (query.startAt) { - ref = ref.startAt(this.getDocRef(rootDoc, query.startAt)); - } - if (query.startAfter) { - ref = ref.startAfter(this.getDocRef(rootDoc, query.startAfter)); - } - if (query.endAt) { - ref = ref.endAt(this.getDocRef(rootDoc, query.endAt)); - } - if (query.endBefore) { - ref = ref.endBefore(this.getDocRef(rootDoc, query.endBefore)); - } - - if (query.where) { - for (const where of castArray(query.where)) { - ref = ref.where(mkFieldPath(where.field), where.op, where.value); - } - } - - if (query.orderBy) { - const startAt: JsonPrimitive[] = []; - const startAfter: JsonPrimitive[] = []; - const endAt: JsonPrimitive[] = []; - const endBefore: JsonPrimitive[] = []; - - castArray(query.orderBy).forEach((orderBy, index) => { - ref = ref.orderBy(mkFieldPath(orderBy.field), orderBy.direction); - - if (orderBy.startAt !== undefined) { - if (startAt.length < index) { - throw new TypeError('Invalid storage query, startAt option must appear in all previous orderBy clauses'); - } - startAt.push(orderBy.startAt); - } - if (orderBy.startAfter !== undefined) { - if (startAfter.length < index) { - throw new TypeError('Invalid storage query, startAfter option must appear in all previous orderBy clauses'); - } - startAfter.push(orderBy.startAfter); - } - if (orderBy.endAt !== undefined) { - if (endAt.length < index) { - throw new TypeError('Invalid storage query, endAt option must appear in all previous orderBy clauses'); - } - endAt.push(orderBy.endAt); - } - if (orderBy.endBefore !== undefined) { - if (endBefore.length < index) { - throw new TypeError('Invalid storage query, endBefore option must appear in all previous orderBy clauses'); - } - endBefore.push(orderBy.endBefore); - } - }); - - if (startAt.length && startAfter.length) { - throw new TypeError( - 'Invalid storage query, only one of startAt and startAfter can be used in all orderBy clauses', - ); - } - if (endAt.length && endBefore.length) { - throw new TypeError( - 'Invalid storage query, only one of endAt and endBefore can be used in all orderBy clauses', - ); - } - if (startAt.length) { - ref = ref.startAt(...startAt); - } - if (startAfter.length) { - ref = ref.startAfter(...startAfter); - } - if (endAt.length) { - ref = ref.endAt(...endAt); - } - if (endBefore.length) { - ref = ref.endBefore(...endBefore); - } - } - - if (query.limit && query.limit > 0) { - ref = ref.limit(query.limit); - } - - return ref; - } - - private transformSnapshot(snapshot: DocumentSnapshot, rootDoc: DocumentReference): Doc { - const { id, exists } = snapshot; - const path = snapshot.ref.path.replace(rootDoc.path, '') || '/'; - - if (exists) { - const data = snapshot.data() as T; - return { id, path, exists, data }; - } - - return { id, path, exists, data: undefined }; - } - - private getDocRef(rootDoc: DocumentReference, path: DocumentPath): DocumentReference { - if (!path.startsWith('/')) { - throw new TypeError(`Storage document path must start with '/', got '${path}'`); - } - if (path !== '/' && path.endsWith('/')) { - throw new TypeError(`Storage document path must not end with '/', got '${path}'`); - } - const parts = path - .slice(1) - .split('/') - .filter(Boolean); - if (parts.length % 2 !== 0) { - throw new TypeError(`Storage document path must have an even number of path components, got '${path}'`); - } - - const doc = chunk(parts, 2).reduce((doc, [colPath, docPath]) => { - return doc.collection(colPath).doc(docPath); - }, rootDoc); - - return doc; - } - - private async getColRef(path: CollectionPath): Promise { - if (!path.startsWith('/')) { - throw new TypeError(`Storage collection path must start with '/', got '${path}'`); - } - if (!path.endsWith('/')) { - throw new TypeError(`Storage collection path must end with '/', got '${path}'`); - } - const [rootColPath, ...parts] = path.slice(1, -1).split('/'); - if (parts.length % 2 !== 0 || !rootColPath) { - throw new TypeError(`Storage collection path must have an odd number of path components, got '${path}'`); - } - const rootCol = (await this.rootDoc).collection(rootColPath); - - const col = chunk(parts, 2).reduce((col, [docPath, colPath]) => { - return col.doc(docPath).collection(colPath); - }, rootCol); - - return col; - } -} diff --git a/frontend/packages/shared/apis/firestore/MockFirestoreStorage.test.ts b/frontend/packages/shared/apis/firestore/MockFirestoreStorage.test.ts deleted file mode 100644 index 52d1183e1e..0000000000 --- a/frontend/packages/shared/apis/firestore/MockFirestoreStorage.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import MockFirestoreStorage from './MockFirestoreStorage'; - -describe('MockFirestoreStorage', () => { - it('should be constructed', async () => { - const storage = new MockFirestoreStorage(); - expect(storage).toBeDefined(); - - storage.set('/', { hello: 'world' }); - await expect(storage.get('/')).resolves.toMatchObject({ data: { hello: 'world' } }); - }); - - it('should observe a doc', async () => { - const storage = new MockFirestoreStorage(); - const next = jest.fn(); - const next2 = jest.fn(); - - const subscription = storage.observe('/foo/bar').subscribe(next); - storage.observe('/foo/baz').subscribe(next2); - - await storage.set('/foo/bar', { foo: 'bar' }); - expect(next).toHaveBeenNthCalledWith(1, { id: 'bar', path: '/foo/bar', exists: false, data: undefined }); - expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: true, data: { foo: 'bar' } }); - await storage.update('/foo/bar', { foo: 1 }); - expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: true, data: { foo: 1 } }); - await storage.merge('/foo/bar', { bar: 2 }); - expect(next).toHaveBeenLastCalledWith({ - id: 'bar', - path: '/foo/bar', - exists: true, - data: { foo: 1, bar: 2 }, - }); - - await expect(storage.get('/foo/bar')).resolves.toEqual({ - id: 'bar', - path: '/foo/bar', - exists: true, - data: { foo: 1, bar: 2 }, - }); - await storage.delete('/foo/bar'); - expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: false, data: undefined }); - await expect(storage.get('/foo/bar')).resolves.toEqual({ - id: 'bar', - path: '/foo/bar', - exists: false, - data: undefined, - }); - - await expect(storage.update('/foo/bar', { bar: 2 })).rejects.toThrow( - new Error('Update failed, no document found at /foo/bar'), - ); - await storage.merge('/foo/bar', { bar: 2 }); - expect(next).toHaveBeenLastCalledWith({ id: 'bar', path: '/foo/bar', exists: true, data: { bar: 2 } }); - - next.mockClear(); - subscription.unsubscribe(); - await storage.set('/foo/bar', { bar: 3 }); - expect(next).not.toHaveBeenCalled(); - - expect(next2).toHaveBeenCalledTimes(1); - expect(next2).toHaveBeenCalledWith({ id: 'baz', path: '/foo/baz', exists: false, data: undefined }); - }); - - it('should observe a query', async () => { - const storage = new MockFirestoreStorage(); - const next1 = jest.fn(); - const next2 = jest.fn(); - const next3 = jest.fn(); - - const subscription1 = storage.observeQuery({ path: '/foo/', limit: 2 }).subscribe(next1); - storage.observeQuery({ path: '/foo/', orderBy: { field: 'x', direction: 'asc', startAfter: 1 } }).subscribe(next2); - - const path = await storage.add('/foo/', { x: 1 }); - const id = path.replace(/.*\//, ''); - expect(next1).toHaveBeenNthCalledWith(1, { docs: [] }); - expect(next2).toHaveBeenNthCalledWith(1, { docs: [] }); - expect(next1).toHaveBeenNthCalledWith(2, { - docs: [{ id, path, exists: true, data: { x: 1 } }], - }); - expect(next2).toHaveBeenNthCalledWith(2, { docs: [] }); - await storage.set('/foo/bar', { x: 2 }); - expect(next1).toHaveBeenLastCalledWith({ - docs: [ - { id, path, exists: true, data: { x: 1 } }, - { id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }, - ], - }); - expect(next2).toHaveBeenLastCalledWith({ - docs: [{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }], - }); - await storage.delete(path); - expect(next1).toHaveBeenLastCalledWith({ - docs: [{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }], - }); - expect(next2).toHaveBeenLastCalledWith({ - docs: [{ id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }], - }); - await storage.set('/foo/foo', { x: 1 }); - const subscription3 = storage - .observeQuery({ path: '/foo/', where: { field: 'x', op: '==', value: 1 } }) - .subscribe(next3); - await storage.set('/foo/baz', { x: 3 }); - expect(next3).toHaveBeenCalledTimes(2); - expect(next3).toHaveBeenNthCalledWith(1, { docs: [{ id: 'foo', path: '/foo/foo', exists: true, data: { x: 1 } }] }); - subscription3.unsubscribe(); - await storage.set('/foo/wut', { x: 4 }); - expect(next3).toHaveBeenCalledTimes(2); - expect(next1).toHaveBeenLastCalledWith({ - docs: [ - { id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }, - { id: 'foo', path: '/foo/foo', exists: true, data: { x: 1 } }, - ], - }); - expect(next2).toHaveBeenLastCalledWith({ - docs: [ - { id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }, - { id: 'baz', path: '/foo/baz', exists: true, data: { x: 3 } }, - { id: 'wut', path: '/foo/wut', exists: true, data: { x: 4 } }, - ], - }); - - next1.mockClear(); - next2.mockClear(); - - subscription1.unsubscribe(); - await storage.delete('/foo/wut'); - - expect(next1).not.toHaveBeenCalled(); - expect(next2).toHaveBeenCalledWith({ - docs: [ - { id: 'bar', path: '/foo/bar', exists: true, data: { x: 2 } }, - { id: 'baz', path: '/foo/baz', exists: true, data: { x: 3 } }, - ], - }); - }); - - it('should make some queries', async () => { - const storage = new MockFirestoreStorage(); - - const item1 = { data: { x: 1, y: 'foo', z: 1 }, exists: true, path: '/foo/1', id: '1' }; - const item2 = { data: { x: 2, y: 'bar', z: 1 }, exists: true, path: '/foo/2', id: '2' }; - const item3 = { data: { x: 3, y: 'baz', z: 2 }, exists: true, path: '/foo/3', id: '3' }; - const item4 = { data: { x: 4, y: 'lol', z: 2 }, exists: true, path: '/foo/4', id: '4' }; - const item5 = { data: { x: 5, y: 'wut', z: 2 }, exists: true, path: '/foo/5', id: '5' }; - - await Promise.all([item1, item2, item3, item4, item5].map(({ path, data }) => storage.set(path, data))); - - const path = '/foo/'; - - await expect(storage.query({ path })).resolves.toEqual({ docs: [item1, item2, item3, item4, item5] }); - - await expect(storage.query({ path, orderBy: { field: 'y' } })).resolves.toEqual({ - docs: [item2, item3, item1, item4, item5], - }); - - await expect(storage.query({ path, orderBy: { field: 'x', startAt: 3 } })).resolves.toEqual({ - docs: [item3, item4, item5], - }); - - await expect(storage.query({ path, orderBy: { field: 'x', endBefore: 2 } })).resolves.toEqual({ - docs: [item1], - }); - - await expect(storage.query({ path, orderBy: { field: 'x', endBefore: 2, direction: 'desc' } })).resolves.toEqual({ - docs: [item5, item4, item3], - }); - - await expect(storage.query({ path, orderBy: { field: 'x', startAfter: 2, endAt: 4 } })).resolves.toEqual({ - docs: [item3, item4], - }); - - await expect( - storage.query({ path, orderBy: { field: 'x', startAfter: 2, endAt: 4, direction: 'desc' } }), - ).resolves.toEqual({ docs: [] }); - - await expect( - storage.query({ - path, - orderBy: [ - { field: 'z', direction: 'desc', startAt: 2 }, - { field: 'x', direction: 'asc' }, - ], - }), - ).resolves.toEqual({ docs: [item3, item4, item5, item1, item2] }); - - await expect(storage.query({ path, where: { field: 'x', op: '<', value: 2 } })).resolves.toEqual({ - docs: [item1], - }); - - await expect( - storage.query({ - path, - where: [ - { field: 'x', op: '>=', value: 2 }, - { field: 'x', op: '<=', value: 4 }, - ], - }), - ).resolves.toEqual({ docs: [item2, item3, item4] }); - - await expect(storage.query({ path, where: [{ field: 'z', op: '==', value: 1 }] })).resolves.toEqual({ - docs: [item1, item2], - }); - - await expect(storage.query({ path, where: [{ field: 'z', op: '>', value: 6 }] })).resolves.toEqual({ - docs: [], - }); - - await expect(storage.query({ path, startAt: '/foo/4' })).resolves.toEqual({ docs: [item4, item5] }); - await expect(storage.query({ path, startAfter: '/foo/4' })).resolves.toEqual({ docs: [item5] }); - await expect(storage.query({ path, endAt: '/foo/4' })).resolves.toEqual({ docs: [item1, item2, item3, item4] }); - await expect(storage.query({ path, endBefore: '/foo/4' })).resolves.toEqual({ docs: [item1, item2, item3] }); - - await expect(storage.query({ path, endBefore: '/foo/4', limit: 2 })).resolves.toEqual({ docs: [item1, item2] }); - - await expect(storage.query({ path, where: [{ field: 'z', op: 'derp' as '>', value: 6 }] })).rejects.toThrow( - "Invalid where filter op 'derp'", - ); - }); - - it('should handle some more things', async () => { - const storage = new MockFirestoreStorage(); - - await expect(storage.add('/foo/bar/baz/', { x: 1 })).resolves.toMatch(/^\/foo\/bar\/baz\/[a-z0-9]+$/); - - await expect(storage.get('')).rejects.toThrow("Storage document path must start with '/', got ''"); - await expect(storage.get('col')).rejects.toThrow("Storage document path must start with '/', got 'col'"); - await expect(storage.get('/col/doc/')).rejects.toThrow( - "Storage document path must not end with '/', got '/col/doc/'", - ); - await expect(storage.get('/col')).rejects.toThrow( - "Storage document path must have an even number of path components, got '/col'", - ); - await expect(storage.get('/col/doc/col2')).rejects.toThrow( - "Storage document path must have an even number of path components, got '/col/doc/col2'", - ); - - await expect(storage.query({ path: '' })).rejects.toThrow("Storage collection path must start with '/', got ''"); - await expect(storage.query({ path: 'col/' })).rejects.toThrow( - "Storage collection path must start with '/', got 'col/'", - ); - await expect(storage.query({ path: '/col' })).rejects.toThrow( - "Storage collection path must end with '/', got '/col'", - ); - await expect(storage.query({ path: '/' })).rejects.toThrow( - "Storage collection path must have an odd number of path components, got '/'", - ); - await expect(storage.query({ path: '/col/doc/' })).rejects.toThrow( - "Storage collection path must have an odd number of path components, got '/col/doc/'", - ); - await expect(storage.query({ path: '/col/doc/col2/doc2/' })).rejects.toThrow( - "Storage collection path must have an odd number of path components, got '/col/doc/col2/doc2/'", - ); - }); -}); diff --git a/frontend/packages/shared/apis/firestore/MockFirestoreStorage.ts b/frontend/packages/shared/apis/firestore/MockFirestoreStorage.ts deleted file mode 100644 index 98375e6186..0000000000 --- a/frontend/packages/shared/apis/firestore/MockFirestoreStorage.ts +++ /dev/null @@ -1,303 +0,0 @@ -import Observable from 'zen-observable'; -import get from 'lodash/get'; -import chunk from 'lodash/chunk'; -import merge from 'lodash/merge'; -import orderBy from 'lodash/orderBy'; -import castArray from 'lodash/castArray'; -import { DocData, DocumentPath, CollectionPath, Doc, FirestoreApi, Query, QueryResult } from './types'; - -class MockDocRef { - exists = false; - - private docData?: DocData; - private readonly collections = new Map(); - - constructor(readonly path: string) {} - - get id() { - return this.path.replace(/.*\//, ''); - } - - collection(path: string) { - let col = this.collections.get(path); - if (!col) { - col = new MockCollectionRef(`${this.path}/${path}`); - this.collections.set(path, col); - } - return col; - } - - data(): DocData | undefined { - return this.docData && JSON.parse(JSON.stringify(this.docData)); - } - - set(data: DocData) { - this.exists = true; - this.docData = data; - } - - delete() { - this.exists = false; - this.docData = undefined; - } -} - -class MockCollectionRef { - private readonly docs = new Map(); - - constructor(private readonly path: string) {} - - doc(path: string) { - let doc = this.docs.get(path); - if (!doc) { - doc = new MockDocRef(`${this.path}/${path}`); - this.docs.set(path, doc); - } - return doc; - } - - query(query: Query, rootPath: string): MockDocRef[] { - let entries = Array.from(this.docs.entries()).filter(([, doc]) => doc.exists); - - for (const filter of castArray(query.where || [])) { - entries = entries.filter(([, doc]) => { - const value = get(doc.data(), filter.field); - const notNull = value !== null && filter.value !== null; - switch (filter.op) { - case '<': - return notNull && value < filter.value!; - case '<=': - return notNull && value <= filter.value!; - case '==': - return value === filter.value; - case '>': - return notNull && value > filter.value!; - case '>=': - return notNull && value >= filter.value!; - default: - throw new Error(`Invalid where filter op '${filter.op}'`); - } - }); - } - - const sorters = castArray(query.orderBy || []).slice(); - for (const sorter of sorters.reverse()) { - entries = orderBy(entries, ([, doc]) => get(doc.data(), sorter.field), [sorter.direction || 'asc']); - - const asc = sorter.direction !== 'desc'; - if (sorter.startAt) { - entries = entries.filter(([, doc]) => { - const data = get(doc.data(), sorter.field); - return asc ? data >= sorter.startAt! : data <= sorter.startAt!; - }); - } - if (sorter.startAfter) { - entries = entries.filter(([, doc]) => { - const data = get(doc.data(), sorter.field); - return asc ? data > sorter.startAfter! : data < sorter.startAfter!; - }); - } - if (sorter.endAt) { - entries = entries.filter(([, doc]) => { - const data = get(doc.data(), sorter.field); - return asc ? data <= sorter.endAt! : data >= sorter.endAt!; - }); - } - if (sorter.endBefore) { - entries = entries.filter(([, doc]) => { - const data = get(doc.data(), sorter.field); - return asc ? data < sorter.endBefore! : data > sorter.endBefore!; - }); - } - } - - if (query.startAt) { - entries = entries.filter(([, { path }]) => path >= rootPath + query.startAt!); - } - if (query.startAfter) { - entries = entries.filter(([, { path }]) => path > rootPath + query.startAfter!); - } - if (query.endAt) { - entries = entries.filter(([, { path }]) => path <= rootPath + query.endAt!); - } - if (query.endBefore) { - entries = entries.filter(([, { path }]) => path < rootPath + query.endBefore!); - } - - if (query.limit !== undefined) { - entries = entries.slice(0, query.limit); - } - - return entries.map(([, doc]) => doc); - } -} - -type Listener = { - path: string; - onUpdate: () => void; -}; - -export default class MockFirestoreStorage implements FirestoreApi { - private readonly rootDoc = new MockDocRef('users/me'); - private readonly listeners = new Set(); - - async get(path: DocumentPath): Promise> { - return this.transformSnapshot(this.getDocRef(path)); - } - - observe(path: DocumentPath): Observable> { - return new Observable(subscriber => { - const doc = this.getDocRef(path); - - subscriber.next(this.transformSnapshot(doc)); - - const listener = { - path, - onUpdate: () => subscriber.next(this.transformSnapshot(doc)), - }; - - this.listeners.add(listener); - - return () => { - this.listeners.delete(listener); - }; - }); - } - - async query(query: Query): Promise> { - const colRef = this.getColRef(query.path); - const docs = colRef.query(query, this.rootDoc.path).map(snapshot => this.transformSnapshot(snapshot)); - return { docs }; - } - - observeQuery(query: Query): Observable> { - return new Observable(subscriber => { - const colRef = this.getColRef(query.path); - - subscriber.next({ - docs: colRef.query(query, this.rootDoc.path).map(snapshot => this.transformSnapshot(snapshot)), - }); - - const listener = { - path: query.path, - onUpdate: () => { - subscriber.next({ - docs: colRef.query(query, this.rootDoc.path).map(snapshot => this.transformSnapshot(snapshot)), - }); - }, - }; - - this.listeners.add(listener); - - return () => { - this.listeners.delete(listener); - }; - }); - } - - async add(path: CollectionPath, data: DocData): Promise { - const id = Math.random() - .toString(36) - .slice(2, 10); - - const newDoc = this.getColRef(path).doc(id); - newDoc.set(data); - await this.notify(newDoc); - return newDoc.path.replace(this.rootDoc.path, ''); - } - - async set(path: DocumentPath, data: DocData): Promise { - const doc = this.getDocRef(path); - doc.set(data); - await this.notify(doc); - } - - async update(path: DocumentPath, data: DocData): Promise { - const doc = this.getDocRef(path); - if (!doc.exists) { - const error = new Error(`Update failed, no document found at ${path}`); - error.name = 'NotFoundError'; - throw error; - } - doc.set(data); - await this.notify(doc); - } - - async merge(path: DocumentPath, data: DocData): Promise { - const doc = this.getDocRef(path); - const existingData = doc.data(); - if (!existingData) { - doc.set(data); - } else { - doc.set(merge({}, existingData, data)); - } - await this.notify(doc); - } - - async delete(path: DocumentPath): Promise { - const doc = this.getDocRef(path); - doc.delete(); - await this.notify(doc); - } - - private async notify(doc: MockDocRef) { - const notifyPath = doc.path.replace(this.rootDoc.path, '') || '/'; - await Promise.resolve(); - - const parentPath = notifyPath.replace(/[^\/]+$/, ''); - - this.listeners.forEach(({ path, onUpdate }) => { - if (path === notifyPath || path === parentPath) { - onUpdate(); - } - }); - } - - private transformSnapshot(snapshot: MockDocRef): Doc { - const { id, path, exists } = snapshot; - return { - id, - path: path.replace(this.rootDoc.path, '') || '/', - exists, - data: exists ? (snapshot.data() as T) : undefined, - }; - } - - private getDocRef(path: DocumentPath): MockDocRef { - if (!path.startsWith('/')) { - throw new TypeError(`Storage document path must start with '/', got '${path}'`); - } - if (path !== '/' && path.endsWith('/')) { - throw new TypeError(`Storage document path must not end with '/', got '${path}'`); - } - const parts = path - .slice(1) - .split('/') - .filter(Boolean); - if (parts.length % 2 !== 0) { - throw new TypeError(`Storage document path must have an even number of path components, got '${path}'`); - } - - return chunk(parts, 2).reduce((doc, [colPath, docPath]) => { - return doc.collection(colPath).doc(docPath); - }, this.rootDoc); - } - - private getColRef(path: CollectionPath): MockCollectionRef { - if (!path.startsWith('/')) { - throw new TypeError(`Storage collection path must start with '/', got '${path}'`); - } - if (!path.endsWith('/')) { - throw new TypeError(`Storage collection path must end with '/', got '${path}'`); - } - const [rootColPath, ...parts] = path.slice(1, -1).split('/'); - if (parts.length % 2 !== 0 || !rootColPath) { - throw new TypeError(`Storage collection path must have an odd number of path components, got '${path}'`); - } - const rootCol = this.rootDoc.collection(rootColPath); - - return chunk(parts, 2).reduce((col, [docPath, colPath]) => { - return col.doc(docPath).collection(colPath); - }, rootCol); - } -} diff --git a/frontend/packages/shared/apis/firestore/getFirestoreApi.test.ts b/frontend/packages/shared/apis/firestore/getFirestoreApi.test.ts deleted file mode 100644 index 53fda8dff9..0000000000 --- a/frontend/packages/shared/apis/firestore/getFirestoreApi.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import MockFirestoreStorage from './MockFirestoreStorage'; -import FirestoreStorage from './FirestoreStorage'; - -describe('getFirestoreApi', () => { - it('should get a mock implementation in tests', () => { - jest.isolateModules(() => { - const getFirestoreApi = require('./getFirestoreApi').default; - const api = getFirestoreApi(); - expect(api).toBeInstanceOf(MockFirestoreStorage); - }); - }); - - it('should return the same instance', () => { - jest.isolateModules(() => { - const getFirestoreApi = require('./getFirestoreApi').default; - const api1 = getFirestoreApi(); - const api2 = getFirestoreApi(); - expect(api1).toBe(api2); - }); - }); - - it('should create a real FirestoreStorage in production', () => { - jest.isolateModules(() => { - const mockInstance = {}; - jest.spyOn(FirestoreStorage, 'create').mockReturnValue(mockInstance as any); - - process.env.NODE_ENV = 'production'; - const getFirestoreApi = require('./getFirestoreApi').default; - const api1 = getFirestoreApi(); - const api2 = getFirestoreApi(); - expect(api1).toBe(mockInstance); - expect(api2).toBe(mockInstance); - expect(FirestoreStorage.create).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/frontend/packages/shared/apis/firestore/getFirestoreApi.ts b/frontend/packages/shared/apis/firestore/getFirestoreApi.ts deleted file mode 100644 index d8354041ca..0000000000 --- a/frontend/packages/shared/apis/firestore/getFirestoreApi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import FirestoreStorage from './FirestoreStorage'; -import appAuth from 'core/app/auth'; - -// Lazy initialization to avoid side effects on module load in tests -const getFirestoreApi = (() => { - let storage: FirestoreStorage | undefined; - - return () => { - if (!storage) { - if (process.env.NODE_ENV === 'test') { - storage = new (require('./MockFirestoreStorage').default)(); - } else { - storage = FirestoreStorage.create(appAuth); - } - } - return storage!; - }; -})(); - -export default getFirestoreApi; diff --git a/frontend/packages/shared/apis/firestore/index.ts b/frontend/packages/shared/apis/firestore/index.ts deleted file mode 100644 index 6be1464cd0..0000000000 --- a/frontend/packages/shared/apis/firestore/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './types'; -export * from './FirestoreProvider'; -export { default as FirestoreStorage } from './FirestoreStorage'; -export { default as getFirestoreApi } from './getFirestoreApi'; diff --git a/frontend/packages/shared/apis/firestore/types.ts b/frontend/packages/shared/apis/firestore/types.ts deleted file mode 100644 index 13a4756706..0000000000 --- a/frontend/packages/shared/apis/firestore/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -import Observable from 'zen-observable'; -import Api from 'shared/pluginApi/Api'; - -export type JsonPrimitive = string | number | boolean | null; - -export interface JsonObject { - [x: string]: JsonValue; -} -export interface JsonArray extends Array {} - -export type JsonValue = JsonPrimitive | JsonObject | JsonArray; - -export type DocData = { [key in string]: JsonValue }; - -export type CollectionPath = string; -export type DocumentPath = string; - -export type FieldPath = string | string[]; - -export type OrderBy = { - field: FieldPath; - // Sort order, defaults to ascending. - direction?: 'asc' | 'desc'; - startAt?: JsonPrimitive; - startAfter?: JsonPrimitive; - endAt?: JsonPrimitive; - endBefore?: JsonPrimitive; -}; - -export type WhereFilter = { - field: FieldPath; - op: '<' | '<=' | '==' | '>' | '>='; - value: JsonPrimitive; -}; - -export type Query = { - path: CollectionPath; - startAt?: DocumentPath; - startAfter?: DocumentPath; - endAt?: DocumentPath; - endBefore?: DocumentPath; - limit?: number; - orderBy?: OrderBy | OrderBy[]; - where?: WhereFilter | WhereFilter[]; -}; - -// export type Mutation = -// | { -// op: 'add'; -// path: CollectionPath; -// data: DocData; -// } -// | { -// op: 'set' | 'update' | 'merge'; -// path: DocumentPath; -// data: DocData; -// } -// | { -// op: 'delete'; -// path: DocumentPath; -// }; - -export type Doc = { - id: string; - path: DocumentPath; - exists: boolean; - data: T | undefined; -}; - -export type QueryResult = { - docs: Doc[]; -}; - -export type FirestoreApi = { - get(path: DocumentPath): Promise>; - observe(path: DocumentPath): Observable>; - query(query: Query): Promise>; - observeQuery(query: Query): Observable>; - add(path: CollectionPath, data: DocData): Promise; - set(path: DocumentPath, data: DocData): Promise; - update(path: DocumentPath, data: DocData): Promise; - merge(path: DocumentPath, data: DocData): Promise; - delete(path: DocumentPath): Promise; - // write(mutation: Mutation | Mutation[]): Promise; -}; - -export const firestoreApiRef = new Api({ - id: 'firestore', - title: 'Firestore', - description: 'Api for talking to user-scoped Firebase database', -}); diff --git a/frontend/packages/shared/apis/ghe/GheClient.test.js b/frontend/packages/shared/apis/ghe/GheClient.test.js deleted file mode 100644 index bca4b40166..0000000000 --- a/frontend/packages/shared/apis/ghe/GheClient.test.js +++ /dev/null @@ -1,297 +0,0 @@ -import GheClient from './GheClient'; - -describe('gheClient', () => { - afterEach(() => { - fetch.resetMocks(); - }); - - it('should fetch file content', async () => { - fetch.mockResponseOnce(JSON.stringify({ encoding: 'base64', content: btoa('abc') }), { status: 200 }); - await expect(GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c' })).resolves.toEqual({ - content: 'abc', - }); - expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc'); - }); - - it('should fetch raw file content', async () => { - fetch.mockResponseOnce(JSON.stringify({ encoding: 'base64', content: btoa('abc') }), { status: 200 }); - await expect(GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c' })).resolves.toMatchObject({ - content: 'abc', - }); - expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc'); - }); - - it('should fetch raw file content with no encoding', async () => { - fetch.mockResponseOnce(JSON.stringify({ encoding: 'utf-8', content: 'abc' }), { status: 200 }); - await expect(GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c' })).resolves.toMatchObject({ - content: 'abc', - }); - expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/repos/a/b/contents/c'); - expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc'); - }); - - it('should fetch raw file content from ref', async () => { - fetch.mockResponseOnce(JSON.stringify({ encoding: 'base64', content: btoa('abc') }), { status: 200 }); - await expect( - GheClient.fromAccessToken('abc').getFile({ org: 'a', repo: 'b', path: 'c', sha: 'abc123' }), - ).resolves.toMatchObject({ - content: 'abc', - }); - expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/repos/a/b/contents/c?ref=abc123'); - expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc'); - }); - - it('should fetch from any api', async () => { - fetch.mockResponseOnce(JSON.stringify({ some: 'data' }), { status: 200 }); - await expect(GheClient.fromAccessToken('abc').request('/my-path')).resolves.toEqual({ some: 'data' }); - expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/my-path'); - expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc'); - }); - - it('should handle failture', async () => { - fetch.mockResponseOnce('{}', { status: 401, statusText: 'NOPE' }); - await expect(GheClient.fromAccessToken('abc').request('/my-path')).rejects.toMatchObject({ - message: 'Failed to do authenticated GHE request: 401 NOPE', - status: 401, - statusText: 'NOPE', - body: {}, - }); - }); - - it('should fetch a commit', async () => { - fetch.mockResponseOnce(JSON.stringify({ sha: 'abc123' }), { status: 200 }); - await expect(GheClient.fromAccessToken('abc').getCommit({ org: 'a', repo: 'b', sha: 'abc123' })).resolves.toEqual({ - sha: 'abc123', - }); - expect(fetch.mock.calls[0][0]).toBe('https://ghe.spotify.net/api/v3/repos/a/b/commits/abc123'); - expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token abc'); - }); - - it('should create a commit', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - - jest - .spyOn(gheClient, 'request') - .mockResolvedValueOnce({ tree: { sha: 'parent-tree-sha' } }) - .mockResolvedValueOnce({ sha: 'new-tree-sha' }) - .mockResolvedValueOnce({ sha: 'new-commit-sha' }); - - const commit = await gheClient.createCommit({ - org: 'org', - repo: 'repo', - message: 'my-message', - parentCommitSha: 'parent-commit-sha', - changes: [{ path: 'file.txt', content: 'my-content' }], - }); - expect(commit.sha).toBe('new-commit-sha'); - - expect(gheClient.request).toHaveBeenCalledTimes(3); - expect(gheClient.request).toHaveBeenNthCalledWith(1, '/repos/org/repo/git/commits/parent-commit-sha'); - expect(gheClient.request).toHaveBeenNthCalledWith(2, '/repos/org/repo/git/trees', 'POST', { - base_tree: 'parent-tree-sha', - tree: [{ path: 'file.txt', content: 'my-content', mode: '100644', type: 'blob' }], - }); - expect(gheClient.request).toHaveBeenNthCalledWith(3, '/repos/org/repo/git/commits', 'POST', { - parents: ['parent-commit-sha'], - message: 'my-message', - tree: 'new-tree-sha', - }); - }); - - it('should create a commit with binary and text data', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - - jest - .spyOn(gheClient, 'request') - .mockResolvedValueOnce({ tree: { sha: 'parent-tree-sha' } }) - .mockResolvedValueOnce({ sha: 'new-blob-sha' }) - .mockResolvedValueOnce({ sha: 'new-tree-sha' }) - .mockResolvedValueOnce({ sha: 'new-commit-sha' }); - - const commit = await gheClient.createCommit({ - org: 'org', - repo: 'repo', - message: 'my-message', - parentCommitSha: 'parent-commit-sha', - changes: [ - { - path: 'file.txt', - content: 'my-content', - }, - { - path: 'binary.txt', - content: new Uint8Array([0x41, 0x42, 0x43]), - }, - ], - }); - expect(commit.sha).toBe('new-commit-sha'); - - expect(gheClient.request).toHaveBeenCalledTimes(4); - expect(gheClient.request).toHaveBeenNthCalledWith(1, '/repos/org/repo/git/commits/parent-commit-sha'); - expect(gheClient.request).toHaveBeenNthCalledWith(2, '/repos/org/repo/git/blobs', 'POST', { - content: btoa(String.fromCharCode(0x41, 0x42, 0x43)), - encoding: 'base64', - }); - expect(gheClient.request).toHaveBeenNthCalledWith(3, '/repos/org/repo/git/trees', 'POST', { - base_tree: 'parent-tree-sha', - tree: [ - { - path: 'file.txt', - content: 'my-content', - mode: '100644', - type: 'blob', - }, - { - path: 'binary.txt', - sha: 'new-blob-sha', - mode: '100644', - type: 'blob', - }, - ], - }); - expect(gheClient.request).toHaveBeenNthCalledWith(4, '/repos/org/repo/git/commits', 'POST', { - parents: ['parent-commit-sha'], - message: 'my-message', - tree: 'new-tree-sha', - }); - }); - - it('should create a PR for a branch', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - - jest.spyOn(gheClient, 'request').mockResolvedValueOnce({ number: 3 }); - - const { number } = await gheClient.createPullRequestWithBranch({ - org: 'org', - repo: 'repo', - title: 'My PR', - body: 'my pr body', - branch: 'my-branch', - }); - expect(number).toBe(3); - - expect(gheClient.request).toHaveBeenCalledTimes(1); - expect(gheClient.request).toHaveBeenCalledWith('/repos/org/repo/pulls', 'POST', { - title: 'My PR', - body: 'my pr body', - base: 'master', - head: 'my-branch', - }); - }); - - it('should create a PR with contents', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - const opts = { org: 'org', repo: 'repo' }; - - jest.spyOn(gheClient, 'getSha').mockResolvedValueOnce('base-sha'); - jest.spyOn(gheClient, 'createCommit').mockResolvedValueOnce({ sha: 'new-commit-sha' }); - jest.spyOn(gheClient, 'getUserInfo').mockResolvedValueOnce({ login: 'my-user' }); - jest.spyOn(gheClient, 'createBranch').mockResolvedValueOnce(); - jest.spyOn(gheClient, 'createPullRequestWithBranch').mockResolvedValueOnce({ number: 4 }); - - const { number } = await gheClient.createPullRequest({ - ...opts, - title: 'My PR', - body: 'my pr body', - base: 'my-master', - changes: [ - { - path: 'file.txt', - content: 'my-contents', - }, - ], - }); - expect(number).toBe(4); - - expect(gheClient.getSha).toHaveBeenCalledTimes(1); - expect(gheClient.getSha).toHaveBeenCalledWith({ ...opts, ref: 'heads/my-master' }); - expect(gheClient.createCommit).toHaveBeenCalledTimes(1); - expect(gheClient.createCommit).toHaveBeenCalledWith({ - ...opts, - parentCommitSha: 'base-sha', - message: 'My PR', - changes: [ - { - path: 'file.txt', - content: 'my-contents', - }, - ], - }); - expect(gheClient.getUserInfo).toHaveBeenCalledTimes(1); - expect(gheClient.createBranch).toHaveBeenCalledTimes(1); - expect(gheClient.createBranch).toHaveBeenCalledWith({ - ...opts, - branch: expect.stringMatching(/^my-user\/patch-[a-z0-9]{4}$/), - sha: 'new-commit-sha', - }); - expect(gheClient.createPullRequestWithBranch).toHaveBeenCalledTimes(1); - expect(gheClient.createPullRequestWithBranch).toHaveBeenCalledWith({ - ...opts, - base: 'my-master', - branch: gheClient.createBranch.mock.calls[0][0].branch, - title: 'My PR', - body: 'my pr body', - }); - }); - - it('should get user info', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - - fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), { status: 200 }); - await expect(gheClient.getUserInfo('abc')).resolves.toEqual({ id: 'my-user' }); - expect(fetch.mock.calls[0][1].headers.Authorization).toBe('token access-token'); - }); - - it('should handle errors when getting user info', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - - fetch.mockResponseOnce(JSON.stringify({ message: 'user not found' }), { status: 404, statusText: 'NOPE' }); - await expect(gheClient.getUserInfo('abc')).rejects.toMatchObject({ - name: 'GheError', - message: 'Failed to do authenticated GHE request: 404 NOPE', - status: 404, - statusText: 'NOPE', - body: { - message: 'user not found', - }, - }); - }); -}); - -describe('checkScopes', () => { - afterEach(() => { - fetch.resetMocks(); - }); - - it('should be true if all scopes are present in headers', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), { - headers: { 'X-OAuth-Scopes': 'abc, def, ghe' }, - status: 200, - }); - await expect(gheClient.verifyUserHasScopes(['abc', 'def'])).resolves.toEqual(true); - }); - it('should be false if some scopes are missing in headers', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), { - headers: { 'X-OAuth-Scopes': 'abc, def' }, - status: 200, - }); - await expect(gheClient.verifyUserHasScopes(['abc', 'def', 'ghe'])).resolves.toEqual(false); - }); - it('should be false if some scopes are missing in headers but are substrings of others', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), { - headers: { 'X-OAuth-Scopes': 'abc, defg' }, - status: 200, - }); - await expect(gheClient.verifyUserHasScopes(['abc', 'ef'])).resolves.toEqual(false); - }); - it('should be false there are no scopes', async () => { - const gheClient = GheClient.fromAccessToken('access-token'); - fetch.mockResponseOnce(JSON.stringify({ id: 'my-user' }), { - status: 200, - }); - await expect(gheClient.verifyUserHasScopes(['abc', 'def', 'ghe'])).resolves.toEqual(false); - }); -}); diff --git a/frontend/packages/shared/apis/ghe/GheClient.ts b/frontend/packages/shared/apis/ghe/GheClient.ts deleted file mode 100644 index 811ae2667d..0000000000 --- a/frontend/packages/shared/apis/ghe/GheClient.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { urls } from 'shared/apis/baseUrls'; -import { - GheApi, - UserInfoResponse, - CommitResponse, - FileResponse, - PullRequestResponse, - GetCommitOptions, - GetShaOptions, - GetFileOptions, - CreateBranchOptions, - CreateCommitOptions, - CreatePullRequestWithBranchOptions, - GetPullRequestOptions, - CreatePullRequestOptions, - isGheError, -} from './types'; - -async function blobToBase64(blob: Blob | File): Promise { - const reader = new FileReader(); - const readerPromise = new Promise((resolve, reject) => { - reader.onload = () => resolve(); - reader.onerror = error => reject(error); - reader.onabort = () => reject(new Error('file reader was aborted')); - }); - - reader.readAsDataURL(blob); - await readerPromise; - - const dataUri = reader.result as string; - const base64Data = dataUri.replace(/data:[^;]+;base64,/, ''); - return base64Data; -} - -export default class GheClient implements GheApi { - private readonly baseUrl: string; - private readonly fetchOptions: object; - - static fromAccessToken(accessToken: string) { - return new GheClient(urls.ghe, accessToken); - } - - constructor(baseUrl: string, accessToken: string) { - this.baseUrl = `${baseUrl}/api/v3`; - this.fetchOptions = { - mode: 'cors', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: `token ${accessToken}`, - }, - }; - } - - getUserInfo = async (): Promise => { - return this.request('/user'); - }; - - verifyUserHasScopes = async (requiredScopes: string[]): Promise => { - const res = await fetch(`${this.baseUrl}/user`, { - ...this.fetchOptions, - method: 'GET', - }); - if (!res.ok) { - await this.handleError(res); - } else { - const scopes = res.headers.get('X-OAuth-Scopes'); - if (!scopes) { - return false; - } - const scopesList = scopes.split(',').map(scope => scope.trim()); - if (requiredScopes.every(scope => scopesList.includes(scope))) { - return true; - } - } - return false; - }; - - getCommit = async ({ org, repo, sha }: GetCommitOptions): Promise => { - return this.request(`/repos/${org}/${repo}/commits/${sha}`); - }; - - getSha = async ({ org, repo, ref = 'heads/master' }: GetShaOptions): Promise => { - const data = await this.request(`/repos/${org}/${repo}/git/refs/${ref}`); - return data.object.sha; - }; - - getFile = async ({ repo, org, path, sha = undefined }: GetFileOptions): Promise => { - const body = await this.request(`/repos/${org}/${repo}/contents/${path}${sha ? `?ref=${sha}` : ''}`); - - const { content, encoding, ...rest } = body; - - let plainContent = content; - if (encoding === 'base64') { - // escape + decodeURIComponent avoids character encoding issues since the contents are utf-8 - plainContent = decodeURIComponent(escape(atob(content))); - } - - return { content: plainContent, ...rest }; - }; - - /** Creates a new branch pointing to a commit sha. Will fail with a ConflictError if the branch already exists. */ - private createBranch = async ({ org, repo, branch, sha }: CreateBranchOptions): Promise => { - try { - await this.request(`/repos/${org}/${repo}/git/refs`, 'POST', { - ref: `refs/heads/${branch}`, - sha, - }); - } catch (error) { - if (isGheError(error) && error.body.message === 'Reference already exists') { - const error = new Error(`Branch '${branch}' already exists`); - error.name = 'ConflictError'; - throw error; - } - - throw error; - } - }; - - /** - * Creates a new commit given a set of changes to files in the repo and a parent commit. - * - * This will throw an error with the name `"EmptyCommitError"` if the commit doesn't have any changes. - */ - private createCommit = async ({ - org, - repo, - parentCommitSha, - message, - changes, - }: CreateCommitOptions): Promise => { - const { tree: parentTree } = await this.request(`/repos/${org}/${repo}/git/commits/${parentCommitSha}`); - - // Iterate through changes and upload all binary data as blobs - const blobUpdates = await Promise.all( - changes.map(async ({ path, content }) => { - if (typeof content === 'string') { - // Strings just work, character encoding doesn't seem to be an issue - return { path, content, mode: '100644', type: 'blob' }; - } - - if (content instanceof ArrayBuffer || ArrayBuffer.isView(content)) { - content = new Blob([content], { type: 'application/octet-stream' }); - } - - if (!(content instanceof Blob)) { - throw new TypeError(`change content must be a string, typed array, or blob, got ${content}`); - } - - const { sha } = await this.request(`/repos/${org}/${repo}/git/blobs`, 'POST', { - content: await blobToBase64(content), - encoding: 'base64', - }); - - return { path, sha, mode: '100644', type: 'blob' }; - }), - ); - - const tree = await this.request(`/repos/${org}/${repo}/git/trees`, 'POST', { - base_tree: parentTree.sha, - tree: blobUpdates, - }); - - if (parentTree.sha === tree.sha) { - const error = new Error('Commit is empty'); - error.name = 'EmptyCommitError'; - throw error; - } - - const commit = await this.request(`/repos/${org}/${repo}/git/commits`, 'POST', { - parents: [parentCommitSha], - message, - tree: tree.sha, - }); - - return commit; - }; - - /** - * Creates a pull request given a feature branch. - * This is a lower level than createPullRequest where you need to supply the branch with changes yourself. - */ - private createPullRequestWithBranch = async ({ - org, - repo, - base = 'master', - title, - body = undefined, - branch, - }: CreatePullRequestWithBranchOptions): Promise => { - return this.request(`/repos/${org}/${repo}/pulls`, 'POST', { base, head: branch, title, body }); - }; - - getPullRequest = async ({ org, repo, number }: GetPullRequestOptions): Promise => { - return this.request(`/repos/${org}/${repo}/pulls/${number}`); - }; - - createPullRequest = async ({ - org, - repo, - base = 'master', - title, - body = undefined, - parentCommitSha = undefined, - changes, - }: CreatePullRequestOptions): Promise => { - if (!parentCommitSha) { - parentCommitSha = await this.getSha({ org, repo, ref: `heads/${base}` }); - } - - const commit = await this.createCommit({ org, repo, parentCommitSha, message: title, changes }); - - const { login } = await this.getUserInfo(); - - // Keep trying to create a branch until we find one that doesn't exist - for (;;) { - const suffix = Math.random() - .toString(36) - .slice(2, 6); - let branch = `${login}/patch-${suffix}`; - - try { - await this.createBranch({ org, repo, branch, sha: commit.sha }); - } catch (error) { - if (error.name === 'ConflictError') { - continue; - } - throw error; - } - - return await this.createPullRequestWithBranch({ org, repo, base, branch, title, body }); - } - }; - - request = async ( - path: string, - method: 'GET' | 'PATCH' | 'POST' | 'PUT' | 'DELETE' = 'GET', - body?: object, - ): Promise => { - const res = await fetch(`${this.baseUrl}${path}`, { - ...this.fetchOptions, - method, - body: body && JSON.stringify(body), - }); - if (!res.ok) { - await this.handleError(res); - } - return await res.json(); - }; - - private async handleError(res: Response) { - const error: any = new Error(`Failed to do authenticated GHE request: ${res.status} ${res.statusText}`); - error.name = 'GheError'; - error.status = res.status; - error.statusText = res.statusText; - error.body = await res.json(); - throw error; - } -} diff --git a/frontend/packages/shared/apis/ghe/index.ts b/frontend/packages/shared/apis/ghe/index.ts deleted file mode 100644 index 0f34f6448f..0000000000 --- a/frontend/packages/shared/apis/ghe/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as GheClient } from './GheClient'; -export * from './types'; diff --git a/frontend/packages/shared/apis/ghe/types.ts b/frontend/packages/shared/apis/ghe/types.ts deleted file mode 100644 index fdfe633299..0000000000 --- a/frontend/packages/shared/apis/ghe/types.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Api } from 'shared/pluginApi'; - -/** - * This API provides a way to talk to the GitHub Enterprise API, for both reading and writing to repos. - * - * A GHE Client instance is acquired using the `withGheAuth` HOC: - * - * ```typescript - * const MyComponent = ({gheApi}) => { - * console.log('gheApi', gheApi); - * - * return ...; - * } - * - * export default withGheAuth()(MyComponent); - * ``` - * - * See https://backstage.spotify.net/docs/backstage-frontend/apis/#ghe-api for more examples. - */ -export type GheApi = { - /** Get information about the currently logged in user. */ - getUserInfo(): Promise; - - /** Verify that the user has authenticated with scopes. */ - verifyUserHasScopes(scope: string[]): Promise; - - /** Get the commit information for a commit sha. */ - getCommit(options: GetCommitOptions): Promise; - - /** Get the commit sha of a ref. */ - getSha(options: GetShaOptions): Promise; - - /** Fetches a single file (blob) from a commit, including it's contents. Defaults to the tip of the default branch. */ - getFile(options: GetFileOptions): Promise; - - /** - * Get a pull request by it's number in a repo. - */ - getPullRequest(options: GetPullRequestOptions): Promise; - - /** - * Create a pull request given a set of changes to files in the repo. - * This method takes care of creating a branch and commit for you. - * - * The generated branch will be named using the template /patch- - * - * This will throw an error with the name `"EmptyCommitError"` if the PR would've been created without any changes. - */ - createPullRequest(options: CreatePullRequestOptions): Promise; - - /** Sends a request to GHE v3 rest API, e.g. /user, defaults to GET with empty body. */ - request(path: string, method?: 'GET' | 'PATCH' | 'POST' | 'PUT' | 'DELETE', body?: object): Promise; -}; - -export const gheApiToken = new Api({ - id: 'ghe', - title: 'GHE', - description: 'Enables you to talk to GHE, both reading repo contents and creating PRs', -}); - -/** These are the most interesting fields of the response, for the full response, see https://developer.github.com/v3/users/#get-a-single-user */ -export type UserInfoResponse = { - // The username of the user. - login: string; - - // The email of the user. - email: string; -}; - -/** These are the most interesting fields of the response, for the full response, see https://developer.github.com/v3/git/commits/#get-a-commit */ -export type CommitResponse = { - // The sha of the commit. - sha: string; - - // The commit message. - message: string; - - // The tree (working dir contents) of the commit. - tree: { sha: string }; - - // The parents of the commits. - parents: Array<{ sha: string }>; - - // The commit author. - author: { date: string; name: string; email: string }; - - // The committer. - committer: { date: string; name: string; email: string }; -}; - -/** - * These are the most interesting fields of the response. - * - * For the full response, see https://developer.github.com/v3/repos/contents/#get-contents - * - * This type differs from the API response in that content is always plain text (base64 decoded), and the encoding field is removed. - */ -export type FileResponse = { - // Size of the file in bytes. - size: number; - - // Name of the file without the path. - name: string; - - // Path to the file. - path: string; - - // Contents of the file. - content: string; - - // Sha of the file (blob) contents. - sha: string; - - // Link to the file in GHE. - html_url: string; - - // Download URL for the file. - download_url: string; -}; - -/** These are the most interesting fields of the response, for the full response, see https://developer.github.com/v3/pulls/#get-a-single-pull-request */ -export type PullRequestResponse = { - // The PR # - number: number; - - // Link to the PR in GHE - html_url: string; - - // Title of the PR - title: string; - // Body of the PR - body: string; - - // The state of the PR. - state: 'open' | 'closed'; - - // Time the PR was created at, e.g. '2011-01-26T19:01:12Z' - created_at: string; - - // Number of commits in the PR. - commits: number; - - // Lines added by the PR. - additions: number; - // Lines removed by the PR. - deletions: number; - // Number of files changed by the PR. - changed_files: number; -}; - -export type BaseOptions = { - // The org, e.g. 'backstage' - org: string; - - // The repo, e.g. 'backstage-frontend' - repo: string; -}; - -export type GetCommitOptions = BaseOptions & { - // The sha of the commit. - sha: string; -}; - -export type GetShaOptions = BaseOptions & { - // The ref the fetch the commit sha for, defaults to 'heads/master' - ref?: string; -}; - -export type GetFileOptions = BaseOptions & { - // The path to the file. e.g. 'docs/README.md' - path: string; - - // The commit sha to get the file from, defaults to master. - sha?: string; -}; - -export type CreateBranchOptions = BaseOptions & { - // The name of the branch. - branch: string; - - // The commit sha that the branch should point to. - sha: string; -}; - -export type CreateCommitOptions = BaseOptions & { - // The sha of the parent commit that this commit should be created on top of. - parentCommitSha: string; - - // The commit message. - message: string; - - // A list of changes to include in this commit. - changes: Change[]; -}; - -export type CreatePullRequestWithBranchOptions = BaseOptions & { - // The base branch to send the PR towards, defaults to 'master'. - base?: string; - - // The title of the PR. - title: string; - - // The body of the PR. - body?: string; - - // The name of the branch to create a PR for. - branch: string; -}; - -export type GetPullRequestOptions = BaseOptions & { - // The PR # - number: number; -}; - -export type CreatePullRequestOptions = BaseOptions & { - // The base branch to send the PR towards, defaults to 'master'. - base?: string; - - // The title of the PR. - title: string; - - // The body of the PR. - body?: string; - - // If this is specified it will be used as the parent commit sha, instead of - // using the tip of the base branch. - // - // It is best to specify this if it is available, since you might override - // recent changes to master otherwise. And it should always be used when - // writing to files that are modified frequently. - parentCommitSha?: string; - - // A list of changes that will be included in the PR. - changes: Change[]; -}; - -/** - * Change describes a modification to a file in a repo. - * - * If the file doesn't exist it will be created, and if it exists it will be overwritten. - * - * Change is used by createCommit and createPullRequest to describe changes it should include. - */ -export type Change = { - // The path to the file to change. - path: string; - - // The new contents of the file. - content: string | ArrayBuffer | ArrayBufferView | Blob | File; -}; - -export type GheError = Error & { - name: 'GheError'; - status: number; - statusText: string; - body: { - message: string; - }; -}; - -export function isGheError(error: Error): error is GheError { - return error.name === 'GheError'; -} diff --git a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.js b/frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.js deleted file mode 100644 index 12bfb61184..0000000000 --- a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Button } from '@material-ui/core'; -import { initiateLogin } from 'shared/apis/gheAuth/gheAuth'; - -const OAuthGheSignInRequestPage = ({ targetUrl }) => ( - -); - -OAuthGheSignInRequestPage.propTypes = { - targetUrl: PropTypes.string.isRequired, -}; - -export default OAuthGheSignInRequestPage; diff --git a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.test.js b/frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.test.js deleted file mode 100644 index 94682665f9..0000000000 --- a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInRequestPage.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import OAuthGheSignInRequestPage from './OAuthGheSignInRequestPage'; -import { buildComponentInApp } from 'testUtils'; -import { validateLoginState } from './gheAuth'; - -describe('', () => { - it('should render and initiate login', () => { - Object.defineProperty(window, 'location', { - value: { protocol: 'http', host: 'localhost' }, - writable: true, - }); - - const rendered = buildComponentInApp(OAuthGheSignInRequestPage) - .withTheme() - .render({ targetUrl: '/here' }); - - fireEvent.click(rendered.getByText('Sign in')); - const [, state] = window.location.match(/&state=(.*?)&/); - expect(validateLoginState(state)).toBe('/here'); - }); -}); diff --git a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.js b/frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.js deleted file mode 100644 index 58e0c81e8e..0000000000 --- a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import qs from 'qs'; -import { connect } from 'react-redux'; -import { GheClient } from 'shared/apis/ghe'; -import { getNewAccessToken, validateLoginState } from 'shared/apis/gheAuth/gheAuth'; -import Progress from 'shared/components/Progress'; -import * as gheAuthActions from 'shared/apis/gheAuth/actions'; - -const OAuthGheSignInResponsePage = ({ dispatch }) => { - const [error, setError] = React.useState(null); - - React.useEffect(() => { - const { code, state } = qs.parse(window.location.search.slice(1)); - const originalUrl = validateLoginState(state); - - if (!code || !state || !originalUrl) { - setError(new Error('Illegal request')); - } else { - getNewAccessToken(code, state) - .then(accessToken => { - return GheClient.fromAccessToken(accessToken) - .getUserInfo() - .then(userInfo => dispatch(gheAuthActions.gheLoggedIn(accessToken, userInfo, originalUrl))); - }) - .catch(setError); - } - }, [dispatch]); - - return error ? error.toString() : ; -}; - -export default connect()(OAuthGheSignInResponsePage); diff --git a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.test.js b/frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.test.js deleted file mode 100644 index de7291c1e9..0000000000 --- a/frontend/packages/shared/apis/gheAuth/OAuthGheSignInResponsePage.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; -import OAuthGheSignInResponsePage from './OAuthGheSignInResponsePage'; -import { buildComponentInApp } from 'testUtils'; -import { initiateLogin } from './gheAuth'; - -import { Provider } from 'react-redux'; -import { createStore } from 'core/store'; -import { createMemoryHistory } from 'history'; -import { Route, Switch } from 'react-router-dom'; -import { ConnectedRouter } from 'connected-react-router'; -import * as gheAuthActions from 'shared/apis/gheAuth/actions'; -import * as gheAuthSelectors from 'shared/apis/gheAuth/selectors'; - -describe('', () => { - let store = null; - let WrappedComponent = null; - - beforeEach(() => { - const history = createMemoryHistory({ initialEntries: ['/ghe/login'] }); - store = createStore(history); - - WrappedComponent = () => ( - - - - - after login - logged in - - - - ); - }); - - afterEach(() => { - fetch.resetMocks(); - }); - - it('should render and initiate login', async () => { - Object.defineProperty(window, 'location', { - value: { protocol: 'http', host: 'localhost' }, - writable: true, - }); - - initiateLogin('/here'); - - const search = window.location.slice(window.location.indexOf('?')); - window.location = { search: `${search}&code=123` }; - - fetch.mockResponses( - [JSON.stringify({ access_token: 'abc' }), { status: 200 }], - [JSON.stringify({ id: 'mock-user' }), { status: 200 }], - ); - - expect(gheAuthSelectors.getUser(store.getState())).toBeNull(); - - const rendered = buildComponentInApp(WrappedComponent) - .withTheme() - .render({ targetUrl: '/here' }); - - rendered.getByTestId('progress'); - await rendered.findByText('after login'); - - expect(fetch.mock.calls[0][1].body).toMatch(/"code":"123"/); - expect(gheAuthSelectors.getUser(store.getState())).toEqual({ accessToken: 'abc', userInfo: { id: 'mock-user' } }); - - store.dispatch(gheAuthActions.gheLogOut()); - - expect(gheAuthSelectors.getUser(store.getState())).toBeNull(); - }); - - it('should render error if login fails', async () => { - Object.defineProperty(window, 'location', { - value: { protocol: 'http', host: 'localhost' }, - writable: true, - }); - - initiateLogin('/here'); - - const search = window.location.slice(window.location.indexOf('?')); - window.location = { search: `${search}&code=123` }; - - fetch.mockResponse(JSON.stringify({ error: 'NOPE' }), { status: 401 }); - - const rendered = buildComponentInApp(WrappedComponent) - .withTheme() - .render({ targetUrl: '/here' }); - - rendered.getByTestId('progress'); - await rendered.findByText('Error: Failed to authorize: 401 Unauthorized'); - }); - - it('should render error if precondition check fails', () => { - const rendered = buildComponentInApp(WrappedComponent) - .withTheme() - .render({ targetUrl: '/here' }); - - rendered.getByText('Error: Illegal request'); - }); -}); diff --git a/frontend/packages/shared/apis/gheAuth/actions.js b/frontend/packages/shared/apis/gheAuth/actions.js deleted file mode 100644 index d6fcd762d1..0000000000 --- a/frontend/packages/shared/apis/gheAuth/actions.js +++ /dev/null @@ -1,26 +0,0 @@ -import { push } from 'connected-react-router'; -import * as actionConstants from 'shared/actions/actionConstants'; -import { removeCachedGheAccessToken, setCachedGheAccessToken } from 'shared/apis/gheAuth/gheAuth'; - -export function gheLoggedIn(accessToken, userInfo, originalUrl) { - return dispatch => { - dispatch({ - type: actionConstants.GHE_LOGGED_IN, - payload: { accessToken, userInfo }, - }); - setCachedGheAccessToken(accessToken); - if (originalUrl) { - dispatch(push(originalUrl)); - } - }; -} - -export function gheLogOut() { - return dispatch => { - removeCachedGheAccessToken(); - dispatch({ - type: actionConstants.GHE_LOGGED_OUT, - }); - // TODO: Show success notification - }; -} diff --git a/frontend/packages/shared/apis/gheAuth/gheAuth.js b/frontend/packages/shared/apis/gheAuth/gheAuth.js deleted file mode 100644 index 0e3fe7ed70..0000000000 --- a/frontend/packages/shared/apis/gheAuth/gheAuth.js +++ /dev/null @@ -1,212 +0,0 @@ -import { urls } from 'shared/apis/baseUrls'; -import { env } from 'shared/apis/env'; - -/** - * Checks whether a hostname represents a Slingshot host, and in that case returns - * information about the slingshot build. - * - * @param {string} host - The hostname to check. - * @returns {{}} - An object with the slingshot build `id` and `region`, both as strings. - */ -function getSlingshotInfo(host) { - if (!host) { - return null; - } - - const match = host.match(/^backstage-backstage-frontend-([0-9]+)\.services\.([a-z0-9]+)\.spotify\.net$/); - if (!match) { - return null; - } - - const [, id, region] = match; - return { id, region }; -} - -/** - * Checks whether a hostname/IP represents the local host. - * - * @param host The host name or IP to inspect, with or without port number - * @returns {boolean} - */ -export function isLocalHost(host) { - if (!host) { - return false; - } - - return !!host.match(/^(0\.0\.0\.0|127\.0\.0\.1|localhost)(:\d+)?$/); -} - -const PRODUCTION_CLIENT_ID = '354bc7c49b64bf7d7bc8'; -const DEVELOPMENT_CLIENT_ID = 'b444743194e5f57b7f17'; -const SLINGSHOT_CLIENT_IDS = { - '0': 'ae3c6b52a22ff5509fb7', - '1': '5787beb3ee9c0f9dfc7d', - '2': 'fafdcf9f860dc63ff86d', - '3': '73521c219b3c62af996b', - '4': 'd92e272e2c2c8b89f9c5', - '5': '344acf7e426ad4b32eb5', - '6': '724521bc9238bcf8e63f', - '7': 'fd0eb21789e76e74f48a', - '8': '4134c26941c896dae0c6', - '9': '0409e26dfe87f78e96f5', - '10': '2ba99717f2513cba2208', - '11': '88a70d171a2f53e18966', - '12': '8647a90097c26fb68698', - '13': '0a69388c960f6987e6cd', - '14': 'b2fe4cd9e61c3e71ddb5', - '15': 'b50841e2e6dd50b4a3dc', - '16': '1a37e7489299c5c8c6fd', - '17': '626a5554b095de95561b', - '18': 'c98129a1cdf02a4af285', - '19': '97062a432db9972b6f80', -}; - -// TODO: non-prefixed keys are deprecated, remove once this has been live long enough that ppl have refreshed their tokens -const TOKEN_KEY = 'gheAccessToken'; -const STATE_KEY = 'gheOauthState'; -const REDIRECT_URL_KEY = 'gheOauthRedirectUrl'; - -function storeValue(key, value) { - window.localStorage.setItem(key, JSON.stringify(value)); -} - -function getValue(key) { - const value = window.localStorage.getItem(key); - return value ? JSON.parse(value) : value; -} - -function removeValue(key) { - return window.localStorage.removeItem(key); -} - -function getClientId(host) { - if (isLocalHost(host)) { - return DEVELOPMENT_CLIENT_ID; - } - const slingshotInfo = getSlingshotInfo(host); - if (slingshotInfo) { - return SLINGSHOT_CLIENT_IDS[slingshotInfo.id]; - } - - return PRODUCTION_CLIENT_ID; -} - -function gheOAuthConfig() { - const { protocol, host } = window.location; - - return { - // Access tokens could in theory be fetched directly from GHE, but the call requires a - // client_secret which we don't want to distribute to browsers. We do that specific call - // through the proxy instead which has the ability to decorate it with the correct client - // secret. - accessTokenEndpoint: `${urls.proxy}/api/backend/ghe-proxy/login/oauth/access_token`, - // This is where the user is redirected initially, to authorize our application to talk to GHE. - authorizationEndpoint: 'https://ghe.spotify.net/login/oauth/authorize', - // This is where the user shall be redirected back, after completing the above authorization. - redirectUri: `${protocol}//${host}/oauth/ghe`, - // The root of the GHE API. - apiEndpoint: `${urls.ghe}/api/v3`, - // The ID of the identity that our application wants to use when performing operations in GHE. - clientId: getClientId(host), - }; -} - -// Store a GHE access token. This lets us act on the user's behalf across sessions, without them -// re-authorizing us over and over. -export function setCachedGheAccessToken(accessToken) { - storeValue('accessToken', accessToken); - storeValue(TOKEN_KEY, accessToken); -} - -// Attempt to fetch a stored GHE access token. -export function getCachedGheAccessToken() { - return getValue(TOKEN_KEY) || getValue('accessToken'); -} - -// Forget a stored GHE access token. -export function removeCachedGheAccessToken() { - const removed = removeValue('accessToken'); - return removeValue(TOKEN_KEY) || removed; -} - -function randomBase64String(length) { - if (env.isTest) { - // window.crypto is not available in jsdom, get rid of this when it is https://github.com/jsdom/jsdom/issues/1612 - return Math.random().toString(36); - } - const randomValues = crypto.getRandomValues(new Uint8Array((length * 1.2 + 10) | 0)); - const string = btoa(String.fromCharCode(...randomValues)).replace(/[+\/=]/g, ''); - return string.slice(0, length); -} - -/* - * Initiates a new GHE login cycle, by redirecting the user to the GHE authorization page. When the - * user has consented, they will be redirected back to the redirect_uri together with a temporary - * code that they generate, and the state that we generate in here. - */ -export function initiateLogin(targetUrl) { - const oauthState = randomBase64String(20); - const config = gheOAuthConfig(); - storeValue('oauthState', oauthState); - storeValue('oauthRedirectUrl', targetUrl); - storeValue(STATE_KEY, oauthState); // Store for validation later (see below) - storeValue(REDIRECT_URL_KEY, targetUrl); - window.location = [ - config.authorizationEndpoint, - `?client_id=${config.clientId}`, - `&redirect_uri=${config.redirectUri}`, - `&state=${oauthState}`, - '&scope=user,admin:org,repo,gist', - ].join(''); -} - -/* - * When the user has authorized us and the redirect has happened back to our site, we need to check - * that the state parameter we got back was the one that we generated earlier. This protects against - * some forms of attack. Returns null on failure, or the desired target URL if successful. - */ -export function validateLoginState(state) { - const originalState = getValue(STATE_KEY) || getValue('oauthState'); - removeValue('oauthState'); - removeValue(STATE_KEY); - if (state !== originalState) { - return null; - } else { - return getValue(REDIRECT_URL_KEY) || getValue('oauthRedirectUrl'); - } -} - -/* - * When a valid login redirect has happened, we take the code and state and send those again to GHE, - * in order to get a long lived access token. This token is tied to the current user, and can be - * supplied in future API calls. - */ -export async function getNewAccessToken(code, state) { - const config = gheOAuthConfig(); - - const response = await fetch(config.accessTokenEndpoint, { - method: 'POST', - mode: 'cors', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - code: code, - state: state, - client_id: config.clientId, - redirect_uri: config.redirectUri, - }), - }); - - if (!response.ok) { - throw new Error(`Failed to authorize: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - if (!data.access_token || data.error) { - throw new Error(`Failed to authorize: ${JSON.stringify(data)}`); - } - - return data.access_token; -} diff --git a/frontend/packages/shared/apis/gheAuth/gheAuth.test.js b/frontend/packages/shared/apis/gheAuth/gheAuth.test.js deleted file mode 100644 index d2d37a3f31..0000000000 --- a/frontend/packages/shared/apis/gheAuth/gheAuth.test.js +++ /dev/null @@ -1,94 +0,0 @@ -import { - initiateLogin, - validateLoginState, - getNewAccessToken, - setCachedGheAccessToken, - getCachedGheAccessToken, - removeCachedGheAccessToken, - isLocalHost, -} from './gheAuth'; - -describe('isLocalHost', () => { - it('handles anomalous input', () => { - expect(isLocalHost('')).toBe(false); - expect(isLocalHost(null)).toBe(false); - }); - - it('deals with it', () => { - expect(isLocalHost('localhost')).toBe(true); - expect(isLocalHost('localhost:3000')).toBe(true); - expect(isLocalHost('localhosts')).toBe(false); - expect(isLocalHost('127.0.0.1')).toBe(true); - expect(isLocalHost('0.0.0.0')).toBe(true); - expect(isLocalHost('google.com')).toBe(false); - }); -}); - -describe('gheClient', () => { - beforeEach(() => { - Object.defineProperty(window, 'location', { - value: { protocol: 'http', host: 'localhost' }, - writable: true, - }); - }); - - afterEach(() => { - fetch.resetMocks(); - }); - - it('should initiate login', () => { - initiateLogin('/here'); - - // Remove once removed - const oldState = JSON.parse(window.localStorage.getItem('oauthState')); - expect(oldState.length).toBeGreaterThan(8); - expect(JSON.parse(window.localStorage.getItem('oauthRedirectUrl'))).toBe('/here'); - expect(window.location).toMatch(`state=${oldState}`); - - const state = JSON.parse(window.localStorage.getItem('gheOauthState')); - expect(state.length).toBeGreaterThan(8); - expect(JSON.parse(window.localStorage.getItem('gheOauthRedirectUrl'))).toBe('/here'); - expect(window.location).toMatch(`state=${state}`); - }); - - it('should validate login state', () => { - initiateLogin('/here'); - - expect(validateLoginState(JSON.parse(window.localStorage.getItem('oauthState')))).toBe('/here'); - expect(validateLoginState(JSON.parse(window.localStorage.getItem('gheOauthState')))).toBe('/here'); - }); - - it('should detect invalid login state', () => { - initiateLogin('/here'); - - expect(validateLoginState('not-the-state')).toBeNull(); - }); - - it('should be able to cache access token', () => { - expect(getCachedGheAccessToken()).toBeNull(); - setCachedGheAccessToken('abc'); - expect(getCachedGheAccessToken()).toBe('abc'); - removeCachedGheAccessToken(); - expect(getCachedGheAccessToken()).toBeNull(); - }); - - it('should get access token', async () => { - fetch.mockResponseOnce(JSON.stringify({ access_token: 'abc' }), { status: 200 }); - await expect(getNewAccessToken('my-code', 'my-state')).resolves.toBe('abc'); - }); - - it('should fail to get access token', async () => { - fetch.mockRejectOnce(new Error('it failed')); - await expect(getNewAccessToken('my-code', 'my-state')).rejects.toMatchObject({ message: 'it failed' }); - - fetch.mockResponseOnce(JSON.stringify({ access_token: 'abc' }), { status: 401, statusText: 'NOPE' }); - await expect(getNewAccessToken('my-code', 'my-state')).rejects.toMatchObject({ - message: 'Failed to authorize: 401 NOPE', - }); - - fetch.mockResponseOnce(JSON.stringify({ error: 'fail' }), { status: 200 }); - await expect(getNewAccessToken('my-code', 'my-state')).rejects.toMatchObject({ - message: 'Failed to authorize: {"error":"fail"}', - }); - }); -}); diff --git a/frontend/packages/shared/apis/gheAuth/reducer.js b/frontend/packages/shared/apis/gheAuth/reducer.js deleted file mode 100644 index c2c75dfebd..0000000000 --- a/frontend/packages/shared/apis/gheAuth/reducer.js +++ /dev/null @@ -1,29 +0,0 @@ -import * as actionConstants from 'shared/actions/actionConstants'; - -const initialState = { - user: null, -}; - -const gheAuthReducer = (state = initialState, action) => { - switch (action.type) { - case actionConstants.GHE_LOGGED_IN: - return _gheLoggedIn(state, action); - case actionConstants.GHE_LOGGED_OUT: - return _gheLoggedOut(state, action); - default: - break; - } - return state; -}; - -export default gheAuthReducer; - -// Private methods below - -const _gheLoggedIn = (state, action) => { - return { ...state, user: action.payload }; -}; - -const _gheLoggedOut = state => { - return { ...state, user: null }; -}; diff --git a/frontend/packages/shared/apis/gheAuth/selectors.js b/frontend/packages/shared/apis/gheAuth/selectors.js deleted file mode 100644 index d0cfa26bc6..0000000000 --- a/frontend/packages/shared/apis/gheAuth/selectors.js +++ /dev/null @@ -1 +0,0 @@ -export const getUser = state => state.gheAuth.user; diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuth.test.ts b/frontend/packages/shared/apis/googleAuthV2/GoogleAuth.test.ts deleted file mode 100644 index dea7ae9587..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuth.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import GoogleAuth from './GoogleAuth'; -import GoogleScopes from './GoogleScopes'; - -const theFuture = new Date(Date.now() + 3600000); -const thePast = new Date(Date.now() - 10); - -describe('GoogleAuth', () => { - it('should save result form createSession', async () => { - const createSession = jest.fn().mockResolvedValue({ expiresAt: theFuture }); - const refreshSession = jest.fn().mockResolvedValue(undefined); - const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); - - await googleAuth.getSession({}); - expect(createSession).toBeCalledTimes(1); - - await googleAuth.getSession({}); - expect(createSession).toBeCalledTimes(1); - - expect(refreshSession).toBeCalledTimes(1); - }); - - it('should ask consent only if scopes have changed', async () => { - const createSession = jest.fn(); - const refreshSession = jest.fn().mockResolvedValue(undefined); - const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); - - createSession.mockResolvedValue({ scopes: GoogleScopes.from('a'), expiresAt: theFuture }); - await googleAuth.getSession({ scope: 'a' }); - expect(createSession).toBeCalledTimes(1); - - await googleAuth.getSession({ scope: 'a' }); - expect(createSession).toBeCalledTimes(1); - - await googleAuth.getSession({ scope: 'b' }); - expect(createSession).toBeCalledTimes(2); - }); - - it('should check for session expiry', async () => { - const createSession = jest.fn(); - const refreshSession = jest - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValue({}); - const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); - - createSession.mockResolvedValue({ scopes: GoogleScopes.from('a'), expiresAt: thePast }); - - await googleAuth.getSession({ scope: 'a' }); - expect(createSession).toBeCalledTimes(1); - - await googleAuth.getSession({ scope: 'a' }); - expect(createSession).toBeCalledTimes(1); - }); - - it('should handle user closed popup', async () => { - const createSession = jest.fn(); - const refreshSession = jest.fn().mockResolvedValue(undefined); - const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); - - createSession.mockRejectedValueOnce(new Error('some error')); - await expect(googleAuth.getSession({ scope: 'a' })).rejects.toThrow('some error'); - }); - - it('should logout and reload', async () => { - jest.spyOn(window.location, 'reload').mockImplementation(); - const removeSession = jest.fn(); - const googleAuth = new GoogleAuth({ removeSession } as any); - - await googleAuth.logout(); - expect(window.location.reload).toHaveBeenCalled(); - expect(removeSession).toHaveBeenCalled(); - }); - - it('should get refreshed access token', async () => { - const refreshSession = jest.fn().mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture }); - const googleAuth = new GoogleAuth({ refreshSession } as any); - - expect(await googleAuth.getAccessToken()).toBe('access-token'); - expect(refreshSession).toBeCalledTimes(1); - }); - - it('should get refreshed id token', async () => { - const refreshSession = jest.fn().mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); - const googleAuth = new GoogleAuth({ refreshSession } as any); - - expect(await googleAuth.getIdToken()).toBe('id-token'); - expect(refreshSession).toBeCalledTimes(1); - }); - - it('should get optional id token', async () => { - const refreshSession = jest.fn().mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture }); - const googleAuth = new GoogleAuth({ refreshSession } as any); - - expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token'); - expect(refreshSession).toBeCalledTimes(1); - }); - - it('should not get optional id token', async () => { - const refreshSession = jest.fn().mockResolvedValue(undefined); - const googleAuth = new GoogleAuth({ refreshSession } as any); - - expect(await googleAuth.getIdToken({ optional: true })).toBe(''); - expect(refreshSession).toBeCalledTimes(1); - }); - - it('should share popup closed errors', async () => { - const error = new Error('NOPE'); - error.name = 'RejectedError'; - const createSession = jest.fn().mockRejectedValue(error); - const refreshSession = jest.fn().mockResolvedValue({ - accessToken: 'access-token', - expiresAt: theFuture, - scopes: GoogleScopes.from('not-enough'), - }); - const googleAuth = new GoogleAuth({ createSession, refreshSession } as any); - - // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check - await expect(googleAuth.getAccessToken()).resolves.toBe('access-token'); - - const promise1 = googleAuth.getAccessToken('more'); - const promise2 = googleAuth.getAccessToken('more'); - await expect(promise1).rejects.toBe(error); - await expect(promise2).rejects.toBe(error); - expect(refreshSession).toBeCalledTimes(1); - expect(createSession).toBeCalledTimes(1); - }); - - it('should wait for all session refreshes', async () => { - const initialSession = { idToken: 'token1', expiresAt: theFuture }; - const refreshSession = jest - .fn() - .mockResolvedValueOnce(initialSession) - .mockResolvedValue({ idToken: 'token2', expiresAt: theFuture }); - const googleAuth = new GoogleAuth({ refreshSession } as any); - - // Grab the expired session first - await expect(googleAuth.getIdToken()).resolves.toBe('token1'); - expect(refreshSession).toBeCalledTimes(1); - - initialSession.expiresAt = thePast; - - const promise1 = googleAuth.getIdToken(); - const promise2 = googleAuth.getIdToken(); - const promise3 = googleAuth.getIdToken(); - await expect(promise1).resolves.toBe('token2'); - await expect(promise2).resolves.toBe('token2'); - await expect(promise3).resolves.toBe('token2'); - expect(refreshSession).toBeCalledTimes(2); - }); -}); diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuth.ts b/frontend/packages/shared/apis/googleAuthV2/GoogleAuth.ts deleted file mode 100644 index a379461687..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuth.ts +++ /dev/null @@ -1,144 +0,0 @@ -import Api from 'shared/pluginApi/Api'; -import { OAuthScopes } from '../oauth/types'; -import { AuthHelper, googleAuthHelper } from './GoogleAuthHelper'; -import GoogleScopes from './GoogleScopes'; -import { GoogleAuthApi, GoogleSession, IdTokenOptions } from './types'; - -class GoogleAuth implements GoogleAuthApi { - private currentSession: GoogleSession | undefined; - private newSessionPromise: Promise | undefined; - - constructor(private readonly helper: AuthHelper) {} - - async getAccessToken(scope?: string | string[]) { - const session = await this.getSession({ optional: false, scope }); - return session.accessToken; - } - - async getIdToken({ optional }: IdTokenOptions = {}) { - const session = await this.getSession({ optional: optional || false }); - if (session) { - return session.idToken; - } else { - return ''; - } - } - - async getSession(options: { optional: false; scope?: string | string[] }): Promise; - async getSession(options: { optional?: boolean; scope?: string | string[] }): Promise; - async getSession(options: { optional?: boolean; scope?: string | string[] }): Promise { - if (this.sessionExistsAndHasScope(this.currentSession, options.scope)) { - if (!this.sessionWillExpire(this.currentSession!)) { - return this.currentSession!; - } - - // Is a session refresh already in progress? If so, at this point we know that - // that refresh was for a set of scopes that is at least as large as what we need. - // So just return that ongoing refresh promise, it'll suffice for the user's needs. - while (this.newSessionPromise) { - const newSession = await this.newSessionPromise; - if (newSession) { - return newSession; - } - } - - this.newSessionPromise = this.helper.refreshSession(); - try { - const newSession = await this.newSessionPromise; - this.currentSession = newSession; - return newSession; - } finally { - this.newSessionPromise = undefined; - } - } - - if (this.newSessionPromise) { - try { - await this.newSessionPromise; - } catch (error) { - if (error.name === 'RejectedError') { - throw error; - } - } - return this.getSession(options); - } - - // The user may still have a valid refresh token in their cookies. Attempt to - // initiate a fresh session through the backend using that refresh token. - if (!this.currentSession) { - try { - // This is an "optional" session request, meaning it will return undefined if we don't have a session. - this.newSessionPromise = this.helper.refreshSession(true); - try { - const newSession = await this.newSessionPromise; - this.currentSession = newSession; - } finally { - this.newSessionPromise = undefined; - } - } catch (error) { - throw new Error(`Initial session request failed, ${error}`); - } - - // We may not have received a session in the above request, and missing session won't throw - if (this.currentSession) { - // The session might not have the scopes requested so go back and check again - return this.getSession(options); - } - - // If we continue here we will show a popup, so exit if this is an optional session request. - if (options.optional) { - return; - } - } - - this.newSessionPromise = this.helper.createSession(this.getExtendedScope(options.scope)); - try { - const newSession = await this.newSessionPromise; - this.currentSession = newSession; - return newSession; - } finally { - this.newSessionPromise = undefined; - } - } - - async logout() { - await this.helper.removeSession(); - window.location.reload(); - } - - private sessionExistsAndHasScope(session: GoogleSession | undefined, scope?: string | string[]): boolean { - if (!session) { - return false; - } - if (!scope) { - return true; - } - return session.scopes.hasScopes(scope); - } - - private sessionWillExpire(session: GoogleSession) { - const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; - } - - private getExtendedScope(scope?: string | string[]) { - let newScope: OAuthScopes = GoogleScopes.default(); - if (this.currentSession) { - newScope = this.currentSession.scopes; - } - if (scope) { - newScope = newScope.extend(scope); - } - return newScope.toString(); - } -} - -export const googleAuthApiToken = new Api({ - id: 'googleAuth', - title: 'Google Auth', - description: 'Provides Google tokens and manages a Google session', -}); - -export const googleAuth = new GoogleAuth(googleAuthHelper); - -export default GoogleAuth; diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.test.tsx b/frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.test.tsx deleted file mode 100644 index 5a63613152..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import GoogleAuthBarrier, { useGetGoogleAccessToken, useGetGoogleIdToken } from './GoogleAuthBarrier'; -import { render, fireEvent } from '@testing-library/react'; -import React from 'react'; -import { withLogCollector } from 'testUtils'; -import { googleAuth } from './GoogleAuth'; -import GoogleScopes from './GoogleScopes'; -import { useInterval, useUpdate } from 'react-use'; - -describe('GoogleAuthBarrier', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it('does not explode', () => { - jest.spyOn(googleAuth, 'getSession').mockResolvedValueOnce({ - accessToken: 'mockAccessToken', - idToken: 'mockIdToken', - scopes: GoogleScopes.from('mockscope'), - expiresAt: new Date(), - }); - - const rendered = render( - -
    hej
    -
    , - ); - expect(rendered.queryByText('hej')).not.toBeInTheDocument(); - rendered.getByTestId('progress'); - }); - - it('should fail if no context is available', () => { - const MyAccessComponent = () => { - const getToken = useGetGoogleAccessToken(); - return access token is {getToken()}; - }; - const MyIdComponent = () => { - const getToken = useGetGoogleIdToken(); - return id token is {getToken()}; - }; - - const accessLogs = withLogCollector(['error'], () => { - expect(() => render()).toThrowError(/You can only use this hook inside a GoogleAuthBarrier/); - }); - expect(accessLogs.error.length).toBe(2); - expect(accessLogs.error[0]).toMatch( - /^Error: Uncaught \[Error: You can only use this hook inside a GoogleAuthBarrier/, - ); - expect(accessLogs.error[1]).toMatch(/^The above error occurred in the component/); - - const idLogs = withLogCollector(['error'], () => { - expect(() => render()).toThrowError(/You can only use this hook inside a GoogleAuthBarrier/); - }); - expect(idLogs.error.length).toBe(2); - expect(idLogs.error[0]).toMatch(/^Error: Uncaught \[Error: You can only use this hook inside a GoogleAuthBarrier/); - expect(idLogs.error[1]).toMatch(/^The above error occurred in the component/); - }); - - it('should make tokens available', async () => { - const MyAccessComponent = () => { - const getToken = useGetGoogleAccessToken(); - return access token is {getToken()}; - }; - const MyIdComponent = () => { - const getToken = useGetGoogleIdToken(); - return id token is {getToken()}; - }; - - jest.spyOn(googleAuth, 'getSession').mockResolvedValueOnce({ - accessToken: 'mockAccessToken', - idToken: 'mockIdToken', - scopes: GoogleScopes.from('mockscope'), - expiresAt: new Date(), - }); - - const rendered = render( - - - - , - ); - - await rendered.findByText('access token is mockAccessToken'); - await rendered.findByText('id token is mockIdToken'); - }); - - it('handles errors and retries', async () => { - const getSession = jest - .spyOn(googleAuth, 'getSession') - .mockRejectedValueOnce(new Error('network error')) - .mockResolvedValueOnce({ - accessToken: 'mockAccessToken', - idToken: 'mockIdToken', - scopes: GoogleScopes.from('mockscope'), - expiresAt: new Date(), - }); - - const rendered = render( - -
    hej
    -
    , - ); - - const button = await rendered.findByText('Retry'); - expect(getSession).toHaveBeenCalledTimes(1); - fireEvent.click(button); - await rendered.findByText('hej'); - expect(getSession).toHaveBeenCalledTimes(2); - }); - - it('issues refreshes', async () => { - jest - .spyOn(googleAuth, 'getSession') - .mockResolvedValueOnce({ - accessToken: 'mockAccessToken1', - idToken: 'mockIdToken1', - scopes: GoogleScopes.from('mockscope'), - expiresAt: new Date(), - }) - .mockResolvedValue({ - accessToken: 'mockAccessToken2', - idToken: 'mockIdToken2', - scopes: GoogleScopes.from('mockscope'), - expiresAt: new Date(), - }); - - const Component = () => { - const getToken = useGetGoogleAccessToken(); - const update = useUpdate(); - useInterval(update, 5); - return {getToken()}; - }; - - const rendered = render( - - - , - ); - - await rendered.findByText('mockAccessToken1'); - - await rendered.findByText('mockAccessToken2'); - }); - - it('should show error if refresh is rejected', async () => { - const rejectError = new Error('NOPE'); - rejectError.name = 'PopupClosedError'; - - jest - .spyOn(googleAuth, 'getSession') - .mockResolvedValueOnce({ - accessToken: 'mockAccessToken1', - idToken: 'mockIdToken1', - scopes: GoogleScopes.from('mockscope'), - expiresAt: new Date(), - }) - .mockRejectedValueOnce(rejectError) - .mockResolvedValue({ - accessToken: 'mockAccessToken2', - idToken: 'mockIdToken2', - scopes: GoogleScopes.from('mockscope'), - expiresAt: new Date(), - }); - - const Component = () => { - const getToken = useGetGoogleAccessToken(); - const update = useUpdate(); - useInterval(update, 10); - return {getToken()}; - }; - - const rendered = render( - - - , - ); - - await rendered.findByText('mockAccessToken1'); - - await rendered.findByText('Google auth failed, PopupClosedError: NOPE'); - - fireEvent.click(rendered.getByText('Retry')); - - await rendered.findByText('mockAccessToken2'); - }); -}); diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.tsx b/frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.tsx deleted file mode 100644 index 355f0b1fa0..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthBarrier.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { createContext, FC, useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { GoogleSession } from './types'; -import { googleAuth } from './GoogleAuth'; -import Progress from 'shared/components/Progress'; - -const DEFAULT_INTERVAL = 60 * 1000; - -type Props = { - scope?: string | string[]; - refreshInterval?: number; -}; - -const Context = createContext<(() => GoogleSession) | undefined>(undefined); - -export const useGetGoogleAccessToken = (): (() => String) => { - const getSession = useContext(Context); - if (!getSession) { - throw new Error('You can only use this hook inside a GoogleAuthBarrier'); - } - - return useCallback(() => getSession().accessToken, [getSession]); -}; - -export const useGetGoogleIdToken = (): (() => String) => { - const getSession = useContext(Context); - if (!getSession) { - throw new Error('You can only use this hook inside a GoogleAuthBarrier'); - } - - return useCallback(() => getSession().idToken, [getSession]); -}; - -const GoogleAuthBarrier: FC = ({ scope, refreshInterval = DEFAULT_INTERVAL, children }) => { - const ref = useRef(); - const [state, setState] = useState({ loading: true, error: null }); - const [retryAttempts, setRetryAttempts] = useState(0); - const getSession = useCallback(() => ref.current!, []); - const api = googleAuth; - const scopeString = Array.isArray(scope) ? scope.join(' ') : scope; - - const handleRetry = () => { - setState({ loading: true, error: null }); - setRetryAttempts(x => x + 1); - }; - - useEffect(() => { - let timeoutId: NodeJS.Timer | undefined; - - const initialGet = async () => { - try { - const googleSession = await api.getSession({ optional: false, scope: scopeString }); - ref.current = googleSession; - setState({ loading: false, error: null }); - - timeoutId = setTimeout(refresh, refreshInterval); - } catch (error) { - setState({ loading: false, error }); - } - }; - - const refresh = async () => { - try { - const googleSession = await api.getSession({ optional: false, scope: scopeString }); - ref.current = googleSession; - timeoutId = setTimeout(refresh, refreshInterval); - } catch (error) { - if (error.name === 'PopupClosedError') { - ref.current = undefined; - setState({ loading: false, error }); - } else { - timeoutId = setTimeout(refresh, 5 * 1000); - } - } - }; - - initialGet(); - - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [retryAttempts, refreshInterval, api, scopeString]); - - if (state.loading) { - return ; - } - if (state.error) { - return ( -
    - Google auth failed, {String(state.error)} -
    - ); - } - - return ; -}; - -export default GoogleAuthBarrier; diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.test.tsx b/frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.test.tsx deleted file mode 100644 index ddc0c2c595..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { fireEvent, render, waitForElementToBeRemoved } from '@testing-library/react'; -import React from 'react'; -import Observable from 'zen-observable'; -import GoogleAuthDialog from './GoogleAuthDialog'; -import GoogleScopes from './GoogleScopes'; -import { GoogleSession } from './types'; -import { PendingRequest } from '../oauth/OAuthPendingRequests'; -import { BasicOAuthScopes } from '../oauth/BasicOAuthScopes'; -import { OAuthScopes } from '../oauth/types'; - -const mockPending = { - scopes: BasicOAuthScopes.from('a b'), - resolve: jest.fn(), - reject: jest.fn(), -}; - -const mockSession: GoogleSession = { - scopes: GoogleScopes.from('profile'), - idToken: 'i', - accessToken: 'a', - expiresAt: new Date(), -}; - -describe('GoogleAuthDialog', () => { - it('should render without exploding', () => { - const rendered = render( - {})} onRequestConsent={jest.fn()} />, - ); - expect(rendered).toBeDefined(); - }); - - it('should trigger the popup flow', async () => { - let subscriber: ZenObservable.SubscriptionObserver | undefined>; - const onRequestConsent: (scopes: OAuthScopes) => Promise = () => Promise.resolve(mockSession); - const onRequestConsentSpy = jest.fn(onRequestConsent); - const rendered = render( - { - subscriber = s; - }) - } - onRequestConsent={onRequestConsentSpy} - />, - ); - - subscriber!.next(mockPending); - await rendered.findByText('Google Auth Required'); - fireEvent.click(rendered.getByText('Continue')); - subscriber!.next(undefined); - await waitForElementToBeRemoved(() => rendered.getByText('Google Auth Required')); - - expect(onRequestConsentSpy).toHaveBeenCalledTimes(1); - expect(onRequestConsentSpy.mock.calls[0]![0].toString()).toBe('a b'); - expect(mockPending.resolve).toHaveBeenCalledTimes(1); - }); - - it('should handle rejection', async () => { - let subscriber: ZenObservable.SubscriptionObserver | undefined>; - const onRequestConsent = jest.fn(); - const rendered = render( - { - subscriber = s; - }) - } - onRequestConsent={onRequestConsent} - />, - ); - - subscriber!.next(mockPending); - await rendered.findByText('Google Auth Required'); - fireEvent.click(rendered.getByText('Reject')); - - expect(onRequestConsent).not.toHaveBeenCalled(); - expect(mockPending.reject).toHaveBeenCalledTimes(1); - expect(mockPending.reject.mock.calls[0][0].name).toBe('PopupClosedError'); - }); - - it('should show consent errors', async () => { - let subscriber: ZenObservable.SubscriptionObserver | undefined>; - const onRequestConsent = jest.fn(() => Promise.reject(new Error('BOOM'))); - const rendered = render( - { - subscriber = s; - }) - } - onRequestConsent={onRequestConsent} - />, - ); - - subscriber!.next(mockPending); - await rendered.findByText('Google Auth Required'); - fireEvent.click(rendered.getByText('Continue')); - await rendered.findByText('BOOM'); - - expect(onRequestConsent).toHaveBeenCalledTimes(1); - }); -}); diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.tsx b/frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.tsx deleted file mode 100644 index a8efdcc62b..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthDialog.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Typography } from '@material-ui/core'; -import React, { FC, useState } from 'react'; -import { useObservable } from 'react-use'; -import { PendingRequest } from 'shared/apis/oauth/OAuthPendingRequests'; -import { OAuthScopes } from 'shared/apis/oauth/types'; -import Button from 'shared/components/Button'; -import Observable from 'zen-observable'; -import { googleAuthHelper } from './GoogleAuthHelper'; -import { googleAuthPendingRequests } from './GoogleAuthPendingRequests'; -import { GoogleSession } from './types'; - -type Props = { - scopesRequest$: Observable>; - onRequestConsent: (scopes: OAuthScopes) => Promise; -}; - -const defaultProps: Props = { - scopesRequest$: googleAuthPendingRequests.pending(), - onRequestConsent: scopes => googleAuthHelper.showPopup(scopes.toString()), -}; - -const GoogleAuthDialog: FC = props => { - const { scopesRequest$, onRequestConsent } = { ...defaultProps, ...props }; - const [error, setError] = useState(); - const [busy, setBusy] = useState(false); - const scopesRequest = useObservable(scopesRequest$); - - const handleContinue = async () => { - const currentRequest = scopesRequest; - - if (currentRequest && currentRequest.scopes) { - setBusy(true); - try { - const session = await onRequestConsent(currentRequest.scopes); - currentRequest.resolve(session); - } catch (e) { - setError(e); - } finally { - setBusy(false); - } - } - }; - - const handleReject = () => { - const error = new Error('Google auth failed, the user rejected'); - error.name = 'PopupClosedError'; - scopesRequest!.reject(error); - }; - - return ( - - Google Auth Required - - Some content on this page requires you to authenticate with Google. - {error && {error.message || 'An unspecified error occurred'}} - - - - - - - ); -}; - -export default GoogleAuthDialog; diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.test.ts b/frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.test.ts deleted file mode 100644 index 70e28fafe4..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import GoogleAuthHelper from './GoogleAuthHelper'; -import GoogleScopes from './GoogleScopes'; - -const anyFetch = fetch as any; - -const pendingRequests = { - request: jest.fn(), - resolve: jest.fn(), - reject: jest.fn(), - pending: jest.fn(), -}; - -describe('GoogleAuthHelper', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should refresh a session', async () => { - anyFetch.mockResponseOnce( - JSON.stringify({ - idToken: 'mock-id-token', - accessToken: 'mock-access-token', - scopes: 'a b c', - expiresInSeconds: '60', - }), - ); - - const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests }); - const session = await helper.refreshSession(); - expect(session.idToken).toBe('mock-id-token'); - expect(session.accessToken).toBe('mock-access-token'); - expect(session.scopes.hasScopes('a b c')).toBe(true); - expect(session.expiresAt.getTime()).toBeLessThan(Date.now() + 70000); - expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now() + 50000); - }); - - it('should handle failure to refresh session', async () => { - anyFetch.mockRejectOnce(new Error('Network NOPE')); - - const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: true, pendingRequests }); - await expect(helper.refreshSession()).rejects.toThrow('Auth refresh request failed, Error: Network NOPE'); - }); - - it('should handle failure response when refreshing session', async () => { - anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' }); - - const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests }); - await expect(helper.refreshSession()).rejects.toThrow('Auth refresh request failed with status NOPE'); - }); - - it('should fail if popup could not be shown', async () => { - pendingRequests.request.mockRejectedValueOnce(new Error('BAH')); - const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests }); - await expect(helper.createSession('a b')).rejects.toThrow('BAH'); - }); - - it('should show an auth popup', async () => { - const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests }); - - const openSpy = jest.spyOn(window, 'open'); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const popupMock = { closed: false }; - - openSpy.mockReturnValue(popupMock as Window); - pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString())); - - const sessionPromise = helper.createSession('a b'); - - expect(openSpy).toBeCalledTimes(1); - expect(openSpy.mock.calls[0][0]).toBe( - 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', - ); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - - await expect(Promise.race([sessionPromise, 'waiting'])).resolves.toBe('waiting'); - - listener({} as MessageEvent); - - await expect(Promise.race([sessionPromise, 'waiting'])).resolves.toBe('waiting'); - - // None of these should be accepted - listener({ source: popupMock } as MessageEvent); - listener({ origin: 'my-origin' } as MessageEvent); - listener({ data: { type: 'oauth-result' } } as MessageEvent); - listener({ source: popupMock, origin: 'my-origin', data: {} } as MessageEvent); - listener({ - source: popupMock, - origin: 'my-origin', - data: { type: 'not-oauth-result', payload: {} }, - } as MessageEvent); - - await expect(Promise.race([sessionPromise, 'waiting'])).resolves.toBe('waiting'); - - // This should be accepted as a valid sessions response - listener({ - source: popupMock, - origin: 'my-origin', - data: { - type: 'oauth-result', - payload: { accessToken: 'my-access-token', idToken: 'my-id-token', expiresInSeconds: 5, scopes: 'a b' }, - }, - } as MessageEvent); - - await expect(sessionPromise).resolves.toEqual({ - idToken: 'my-id-token', - accessToken: 'my-access-token', - scopes: expect.any(GoogleScopes), - expiresAt: expect.any(Date), - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); - - it('should forward slingshot info', async () => { - const helper = new GoogleAuthHelper({ - clientId: 'mock-id', - apiOrigin: 'my-origin', - dev: false, - slingshotInfo: { - id: 101, - site: 'narnia', - }, - pendingRequests, - }); - - const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); - pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString())); - - await expect(helper.createSession('a b')).rejects.toThrow('Failed to open google login popup.'); - - expect(openSpy).toBeCalledTimes(1); - expect(openSpy.mock.calls[0][0]).toBe( - 'my-origin/api/backend/auth/start?slingshot=101%3Anarnia&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', - ); - }); - - it('should fail if popup returns error', async () => { - const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests }); - - const openSpy = jest.spyOn(window, 'open'); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const popupMock = { closed: false }; - - openSpy.mockReturnValue(popupMock as Window); - pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString())); - - const sessionPromise = helper.createSession('a b'); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; - - listener({ - source: popupMock, - origin: 'my-origin', - data: { - type: 'oauth-result', - payload: { - error: { - message: 'NOPE', - name: 'NopeError', - }, - }, - }, - } as MessageEvent); - - await expect(sessionPromise).rejects.toThrow({ - name: 'NopeError', - message: 'NOPE', - }); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); - - it('should fail if popup is closed', async () => { - const helper = new GoogleAuthHelper({ clientId: 'mock-id', apiOrigin: 'my-origin', dev: false, pendingRequests }); - - const openSpy = jest.spyOn(window, 'open'); - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const popupMock = { closed: false }; - - openSpy.mockReturnValue(popupMock as Window); - pendingRequests.request.mockImplementationOnce(scopes => helper.showPopup(scopes.toString())); - - const sessionPromise = helper.createSession('a b'); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(0); - - setTimeout(() => { - popupMock.closed = true; - }, 150); - await expect(sessionPromise).rejects.toThrow('Google login failed, popup was closed'); - - expect(openSpy).toBeCalledTimes(1); - expect(addEventListenerSpy).toBeCalledTimes(1); - expect(removeEventListenerSpy).toBeCalledTimes(1); - }); -}); diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.ts b/frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.ts deleted file mode 100644 index 4dcc1a6a3c..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthHelper.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { urls } from 'shared/apis/baseUrls'; -import { env } from 'shared/apis/env'; -import { OAuthPendingRequestsApi } from 'shared/apis/oauth/OAuthPendingRequests'; -import { CLIENT_ID_DEV, getClientId, getSlingshotInfo, SlingshotInfo } from './clientIds'; -import { googleAuthPendingRequests } from './GoogleAuthPendingRequests'; -import GoogleScopes from './GoogleScopes'; -import MockAuthHelper from './MockAuthHelper'; -import { GoogleSession } from './types'; - -const API_PATH = '/api/backend/auth'; - -type Options = { - clientId: string; - slingshotInfo?: SlingshotInfo; - apiOrigin: string; - dev: boolean; - pendingRequests: OAuthPendingRequestsApi; -}; - -export type GoogleAuthResponse = { - accessToken: string; - idToken: string; - scopes: string; - expiresInSeconds: number; -}; - -export type AuthHelper = { - refreshSession(optional?: false): Promise; - refreshSession(optional: true): Promise; - removeSession(): Promise; - createSession(scope: string): Promise; - showPopup(scope: string): Promise; -}; - -class GoogleAuthHelper implements AuthHelper { - static create() { - const clientId = getClientId(); - const slingshotInfo = getSlingshotInfo(); - - return new GoogleAuthHelper({ - clientId, - slingshotInfo, - apiOrigin: urls.openProxy, - dev: clientId === CLIENT_ID_DEV, - pendingRequests: googleAuthPendingRequests, - }); - } - - constructor(private readonly options: Options) {} - - async refreshSession(optional?: false): Promise; - async refreshSession(optional: true): Promise; - async refreshSession(optional?: boolean): Promise { - const res = await fetch(this.buildUrl('/token', { optional }), { - headers: { - 'x-requested-with': 'XMLHttpRequest', - }, - credentials: 'include', - }).catch(error => { - throw new Error(`Auth refresh request failed, ${error}`); - }); - - if (!res.ok) { - const error: any = new Error(`Auth refresh request failed with status ${res.statusText}`); - error.status = res.status; - throw error; - } - - const authInfo = await res.json(); - - if (optional && authInfo.error) { - return undefined; - } - return GoogleAuthHelper.convertAuthInfo(authInfo); - } - - async removeSession(): Promise { - const res = await fetch(this.buildUrl('/logout'), { - method: 'POST', - headers: { - 'x-requested-with': 'XMLHttpRequest', - }, - credentials: 'include', - }); - - if (!res.ok) { - throw new Error(`Logout request failed with status ${res.status}`); - } - } - - async createSession(scope: string): Promise { - return this.options.pendingRequests.request(GoogleScopes.from(scope)); - } - - async showPopup(scope: string): Promise { - const { slingshotInfo } = this.options; - const slingshot = slingshotInfo && `${slingshotInfo.id}:${slingshotInfo.site}`; - - const popupUrl = this.buildUrl('/start', { slingshot, scope }); - - return new Promise((resolve, reject) => { - const width = 450; - const height = 730; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; - - const popup = window.open( - popupUrl, - 'google-login', - `menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`, - ); - - if (!popup || typeof popup.closed === 'undefined' || popup.closed) { - reject(new Error('Failed to open google login popup.')); - return; - } - - if (!env.isTest) { - window.focus(); - } - - const messageListener = (event: MessageEvent) => { - if (event.source !== popup) { - return; - } - if (event.origin !== this.options.apiOrigin) { - return; - } - const { data } = event; - if (data.type !== 'oauth-result') { - return; - } - - if (data.payload.error) { - const error = new Error(data.payload.error.message); - error.name = data.payload.error.name; - // TODO: proper error type - // error.extra = data.payload.error.extra; - reject(error); - } else { - resolve(GoogleAuthHelper.convertAuthInfo(data.payload)); - } - done(); - }; - - const done = () => { - window.removeEventListener('message', messageListener); - clearInterval(intervalId); - }; - - const intervalId = setInterval(() => { - if (popup.closed) { - const error = new Error('Google login failed, popup was closed'); - error.name = 'PopupClosedError'; - reject(error); - done(); - } - }, 100); - - window.addEventListener('message', messageListener); - }); - } - - private buildUrl(path: string, query?: { [key: string]: string | boolean | undefined }): string { - const queryString = this.buildQueryString({ ...query, dev: this.options.dev }); - - return `${this.options.apiOrigin}${API_PATH}${path}${queryString}`; - } - - private buildQueryString(query?: { [key: string]: string | boolean | undefined }): string { - if (!query) { - return ''; - } - - const queryString = Object.entries(query) - .map(([key, value]) => { - if (typeof value === 'string') { - return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - } else if (value) { - return encodeURIComponent(key); - } else { - return undefined; - } - }) - .filter(Boolean) - .join('&'); - - if (!queryString) { - return ''; - } - return `?${queryString}`; - } - - private static convertAuthInfo(authInfo: GoogleAuthResponse): GoogleSession { - return { - idToken: authInfo.idToken, - accessToken: authInfo.accessToken, - scopes: GoogleScopes.from(authInfo.scopes), - expiresAt: new Date(Date.now() + authInfo.expiresInSeconds * 1000), - }; - } -} - -export const googleAuthHelper = env.isTest ? new MockAuthHelper() : GoogleAuthHelper.create(); - -export default GoogleAuthHelper; diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthPendingRequests.ts b/frontend/packages/shared/apis/googleAuthV2/GoogleAuthPendingRequests.ts deleted file mode 100644 index 9d38126e40..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleAuthPendingRequests.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { OAuthPendingRequests } from 'shared/apis/oauth/OAuthPendingRequests'; -import { GoogleSession } from './types'; - -export const googleAuthPendingRequests = new OAuthPendingRequests(); diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleScopes.test.ts b/frontend/packages/shared/apis/googleAuthV2/GoogleScopes.test.ts deleted file mode 100644 index 3baf9d85ed..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleScopes.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import GoogleScopes from './GoogleScopes'; - -const PREFIX = 'https://www.googleapis.com/auth/'; - -describe('GoogleScopes', () => { - it('should be created from scopes', () => { - const scopes = GoogleScopes.from('a openid b profile'); - expect(scopes.toString()).toBe(`${PREFIX}a openid ${PREFIX}b ${PREFIX}userinfo.profile`); - }); - - it('should be created with default scopes', () => { - expect(GoogleScopes.default().toString()).toBe(`openid ${PREFIX}userinfo.email ${PREFIX}userinfo.profile`); - }); - - it('should have or not have scopes', () => { - const scopes = GoogleScopes.from(`a b ${PREFIX}c`); - expect(scopes.hasScopes('a')).toBe(true); - expect(scopes.hasScopes('a b')).toBe(true); - expect(scopes.hasScopes('b')).toBe(true); - expect(scopes.hasScopes('b c')).toBe(true); - expect(scopes.hasScopes('a b c')).toBe(true); - expect(scopes.hasScopes(`a b ${PREFIX}c`)).toBe(true); - expect(scopes.hasScopes(`a ${PREFIX}b c`)).toBe(true); - expect(scopes.hasScopes('a b c d')).toBe(false); - expect(scopes.hasScopes('d')).toBe(false); - expect(scopes.hasScopes('')).toBe(true); - expect(scopes.hasScopes('abc')).toBe(false); - expect(scopes.hasScopes(`${PREFIX}a`)).toBe(true); - }); - - it('should handle scope shorthands correctly', () => { - const scopes = GoogleScopes.default(); - expect(scopes.hasScopes('email')).toBe(true); - expect(scopes.hasScopes('profile')).toBe(true); - expect(scopes.hasScopes('openid')).toBe(true); - expect(scopes.hasScopes('userinfo.email')).toBe(true); - expect(scopes.hasScopes('userinfo.profile')).toBe(true); - expect(scopes.hasScopes('userinfo.openid')).toBe(false); - expect(scopes.hasScopes(`${PREFIX}userinfo.email`)).toBe(true); - expect(scopes.hasScopes(`${PREFIX}userinfo.profile`)).toBe(true); - expect(scopes.hasScopes(`${PREFIX}userinfo.openid`)).toBe(false); - expect(scopes.hasScopes(`${PREFIX}email`)).toBe(false); - expect(scopes.hasScopes(`${PREFIX}profile`)).toBe(false); - expect(scopes.hasScopes(`${PREFIX}openid`)).toBe(false); - }); - - it('should be extended', () => { - const scopes = GoogleScopes.from('a b'); - expect(scopes.extend('')).not.toBe(scopes); - expect(scopes.extend('d').toString()).toBe(`${PREFIX}a ${PREFIX}b ${PREFIX}d`); - expect(scopes.extend('profile').toString()).toBe(`${PREFIX}a ${PREFIX}b ${PREFIX}userinfo.profile`); - expect(scopes.extend('d profile').toString()).toBe(`${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`); - expect(scopes.extend(`${PREFIX}d profile`).toString()).toBe( - `${PREFIX}a ${PREFIX}b ${PREFIX}d ${PREFIX}userinfo.profile`, - ); - expect(scopes.extend('a').toString()).toBe(scopes.toString()); - expect(scopes.extend('').toString()).toBe(scopes.toString()); - expect(scopes.extend('b').toString()).toBe(scopes.toString()); - expect(scopes.extend('b a').toString()).toBe(scopes.toString()); - expect(scopes.extend('b a b a a').toString()).toBe(scopes.toString()); - }); -}); diff --git a/frontend/packages/shared/apis/googleAuthV2/GoogleScopes.ts b/frontend/packages/shared/apis/googleAuthV2/GoogleScopes.ts deleted file mode 100644 index 06feef122c..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/GoogleScopes.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { BasicOAuthScopes } from 'shared/apis/oauth/BasicOAuthScopes'; -import { OAuthScopeLike } from '../oauth/types'; - -// https://www.googleapis.com/auth/userinfo.profile -// https://www.googleapis.com/auth/userinfo.email -// openid - -const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; - -export default class GoogleScopes extends BasicOAuthScopes { - static from(scope: OAuthScopeLike): GoogleScopes { - return new GoogleScopes(new Set(BasicOAuthScopes.asStrings(scope, GoogleScopes.canonicalScope))); - } - - static default(): GoogleScopes { - return new GoogleScopes(new Set(['openid', `${SCOPE_PREFIX}userinfo.email`, `${SCOPE_PREFIX}userinfo.profile`])); - } - - static empty(): GoogleScopes { - return new GoogleScopes(new Set()); - } - - constructor(scopes: Set) { - super(scopes, GoogleScopes.canonicalScope); - } - - private static canonicalScope(scope: string): string { - if (scope === 'openid') { - return scope; - } - - if (scope === 'profile' || scope === 'email') { - return `${SCOPE_PREFIX}userinfo.${scope}`; - } - - if (scope.startsWith(SCOPE_PREFIX)) { - return scope; - } - - return `${SCOPE_PREFIX}${scope}`; - } -} diff --git a/frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.test.ts b/frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.test.ts deleted file mode 100644 index ca6d040431..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import GoogleScopes from './GoogleScopes'; -import MockAuthHelper, { mockIdToken, mockAccessToken } from './MockAuthHelper'; - -describe('MockAuthHelper', () => { - it('should return mock tokens', async () => { - const helper = new MockAuthHelper(); - await expect(helper.createSession()).resolves.toEqual({ - idToken: mockIdToken, - accessToken: mockAccessToken, - expiresAt: expect.any(Date), - scopes: expect.any(GoogleScopes), - }); - await expect(helper.refreshSession()).resolves.toEqual({ - idToken: mockIdToken, - accessToken: mockAccessToken, - expiresAt: expect.any(Date), - scopes: expect.any(GoogleScopes), - }); - }); -}); diff --git a/frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.ts b/frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.ts deleted file mode 100644 index f6cf268036..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/MockAuthHelper.ts +++ /dev/null @@ -1,36 +0,0 @@ -import GoogleScopes from './GoogleScopes'; -import { GoogleSession } from './types'; -import { AuthHelper } from './GoogleAuthHelper'; - -export const mockIdToken = 'mock-id-token'; -export const mockAccessToken = 'mock-access-token'; - -const defaultMockSession: GoogleSession = { - idToken: mockIdToken, - accessToken: mockAccessToken, - expiresAt: new Date(), - scopes: GoogleScopes.default(), -}; - -export default class MockAuthHelper implements AuthHelper { - constructor(private readonly mockSession: GoogleSession = defaultMockSession) {} - - async refreshSession() { - return this.mockSession; - } - - async removeSession() {} - - async createSession() { - return this.mockSession; - } - - async showPopup(scope: string) { - return { - scopes: GoogleScopes.from(scope), - idToken: 'i', - accessToken: 'a', - expiresAt: new Date(), - }; - } -} diff --git a/frontend/packages/shared/apis/googleAuthV2/clientIds.ts b/frontend/packages/shared/apis/googleAuthV2/clientIds.ts deleted file mode 100644 index 77765680cb..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/clientIds.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { env } from 'shared/apis/env'; - -/** - * OAuth 2.0 Client IDs - * https://console.cloud.google.com/apis/credentials?folder=&organizationId=642708779950&orgonly=true&project=xpn-system-z-1&supportedpurview=organizationId - */ -export const CLIENT_ID_DEV = '820235932049-nc367ocqr8rfknrjrd71cfog3figgt1s.apps.googleusercontent.com'; // oauth client allowing JavaScript origin http://localhost:5678 and slingshot domains -export const CLIENT_ID_PROD = '820235932049-g88s8ltjd53af38go61db8debamdlh7d.apps.googleusercontent.com'; // oauth client allowing JavaScript origin from trusted domains. - -export function getClientId() { - if (env.isDevelopment || env.isStaging) { - return CLIENT_ID_DEV; - } - return CLIENT_ID_PROD; -} - -export type SlingshotInfo = { - id: number; - site: string; -}; - -export function getSlingshotInfo(): undefined | SlingshotInfo { - const { hostname } = location; - if (!hostname) { - return; - } - - const match = hostname.match(/^backstage-backstage-frontend-([0-9]+)\.services\.([a-z0-9]+)\.spotify\.net$/); - if (!match) { - return; - } - - const [, id, site] = match; - return { id: Number(id), site }; -} diff --git a/frontend/packages/shared/apis/googleAuthV2/index.ts b/frontend/packages/shared/apis/googleAuthV2/index.ts deleted file mode 100644 index 20d7fbb4f1..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './types'; -export { default as GoogleAuthBarrier, useGetGoogleAccessToken, useGetGoogleIdToken } from './GoogleAuthBarrier'; -export { default as GoogleAuth } from './GoogleAuth'; -import { googleAuth as googleAuthInstance } from './GoogleAuth'; -import { GoogleAuthApi } from './types'; -export const googleAuth = googleAuthInstance as GoogleAuthApi; diff --git a/frontend/packages/shared/apis/googleAuthV2/types.ts b/frontend/packages/shared/apis/googleAuthV2/types.ts deleted file mode 100644 index a344fab972..0000000000 --- a/frontend/packages/shared/apis/googleAuthV2/types.ts +++ /dev/null @@ -1,78 +0,0 @@ -import GoogleScopes from './GoogleScopes'; - -/** - * This api provides access to Google OAuth credentials. It lets you request access tokens, - * which can be used to act on behalf of the user when talking to Google APIs. It also supplies - * ID Tokens, which can be passed to backend services to prove the user's identity. - * - * The API can be called directly to get access and ID tokens, which will cause a modal dialog - * to show up if the user is not yet signed in. - * - * For more fine grained control of where the sign in prompt is shown, it is possible to use - * the GoogleAuthBarrier components, which ensures that all components rendered inside it - * have synchronous access to both access and ID tokens. - * - * For full examples, see https://backstage.spotify.net/docs/backstage-frontend/apis/#google-auth-api - */ -export type GoogleAuthApi = { - /** - * Requests a Google OAuth ID Token, optionally with a set of scopes. The scopes allow you to access - * google APIs on behalf of the user. A full list of scopes can be found at https://developers.google.com/identity/protocols/googlescopes. - * - * Be sure to include all required scopes when requesting an access token. When testing your implementation - * it is best to log out the Backstage Google session and then visit your plugin page directly, as - * you might already have some required scopes in your existing session. Not requesting the correct - * scopes can lead to 403 or other authorization errors, which can be tricky to debug. - * - * This method is cheap and should be called each time an access token is used. Do not for example - * store the access token in React component state, as that could cause the token to expire. Instead - * fetch a new access token for each request. - * - * If the user has not yet logged in to Google inside Backstage, a dialog window will be shown - * that prompts the user to log in, and the returned promise will not resolve until the user has - * successfully logged in. - * - * The returned promise can be rejected, but only if the user rejects the login request. If the - * login fails because the user fails to log in to their google account, the dialog will simply - * remain and ask them to try again, and the promise will still be pending. - */ - getAccessToken(scope?: string | string[]): Promise; - - /** - * Requests a Google OAuth ID Token. - * - * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, - * email and expiration information. Do not rely on any other fields, as they might not be present. - * - * This method is cheap and should be called each time an ID token is used. Do not for example - * store the id token in React component state, as that could cause the token to expire. Instead - * fetch a new id token for each request. - * - * If the user has not yet logged in to Google inside Backstage, a dialog window will be shown - * that prompts the user to log in, and the returned promise will not resolve until the user has - * successfully logged in. - * - * The returned promise can be rejected, but only if the user rejects the login request. If the - * login fails because the user fails to log in to their google account, the dialog will simply - * remain and ask them to try again, and the promise will still be pending. - */ - getIdToken(options?: IdTokenOptions): Promise; - - /** - * Logs out the user's Google session. This will reload the page. - */ - logout(): Promise; -}; - -export type GoogleSession = { - idToken: string; - accessToken: string; - scopes: GoogleScopes; - expiresAt: Date; -}; - -export type IdTokenOptions = { - // If this is set to true, the user will not be prompted to log in, - // and an empty id token will be returned if there is no existing session. - optional?: boolean; -}; diff --git a/frontend/packages/shared/apis/googleClient/googleClientApi.js b/frontend/packages/shared/apis/googleClient/googleClientApi.js deleted file mode 100644 index 837a5146d1..0000000000 --- a/frontend/packages/shared/apis/googleClient/googleClientApi.js +++ /dev/null @@ -1,59 +0,0 @@ -/* global gapi */ - -import { googleAuth } from 'shared/apis/googleAuthV2'; - -// Using the Google API Client Library for JavaScript -// https://github.com/google/google-api-javascript-client - -export const googleClientApi = { - async load({ api, version, scope }) { - await gapi.client.load(api, version); - const accessToken = await googleAuth.getAccessToken(scope); - gapi.client.setToken({ access_token: accessToken }); - }, - analytics: { - getGaData(query) { - return gapi.client.analytics.data.ga.get(query); - }, - }, - compute: { - getUrlMaps(project) { - return gapi.client.compute.urlMaps.list({ - project, - }); - }, - getFwdRules(project, filter = null) { - return gapi.client.compute.globalForwardingRules.list({ - project, - filter, - }); - }, - getHttpProxies(project, filter = null) { - return gapi.client.compute.targetHttpProxies.list({ - project, - filter, - }); - }, - getHttpsProxies(project, filter = null) { - return gapi.client.compute.targetHttpsProxies.list({ - project, - filter, - }); - }, - getBackends(project, filter = null) { - return gapi.client.compute.backendServices.list({ - project, - filter, - }); - }, - getBackendHealth(project, backendService, group) { - return gapi.client.compute.backendServices.getHealth({ - project, - backendService, - resource: { - group, - }, - }); - }, - }, -}; diff --git a/frontend/packages/shared/apis/highlighting/highlight.js b/frontend/packages/shared/apis/highlighting/highlight.js deleted file mode 100644 index a7714a4b69..0000000000 --- a/frontend/packages/shared/apis/highlighting/highlight.js +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; - -import 'highlight.js/styles/atom-one-dark.css'; - -let library; -function loadLibrary() { - if (library) { - return Promise.resolve(library); - } - const name = str => m => [str, m.default || m]; - - // Full path specs are needed for supported languages so that webpack knows how to properly bundle this library. - return Promise.all([ - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/highlight.js'), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/bash.js').then(name('bash')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/cpp.js').then(name('cpp')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/dockerfile.js').then(name('docker')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/go.js').then(name('go')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/groovy.js').then(name('groovy')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/java.js').then(name('java')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/javascript.js').then(name('javascript')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/json.js').then(name('json')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/kotlin.js').then(name('kotlin')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/markdown.js').then(name('markdown')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/objectivec.js').then(name('objectivec')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/python.js').then(name('python')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/ruby.js').then(name('ruby')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/scala.js').then(name('scala')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/swift.js').then(name('swift')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/typescript.js').then(name('typescript')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/xml.js').then(name('xml')), - import(/* webpackChunkName: "highlight-js" */ 'highlight.js/lib/languages/yaml.js').then(name('yaml')), - ]).then(([highlight, ...langs]) => { - langs.forEach(([moduleName, module]) => { - highlight.registerLanguage(moduleName, module); - }); - library = highlight; - return highlight; - }); -} - -/* - * Given a file extension, repo name, and array of code lines, return a Promise resolving - * to an array of formatted lines with html/css formatting. - * - * @param {String} fileExtension The extension of the source file - * @param {String} repo The name of the code repository - * @param {Array} lines The source code lines - * - * @returns {Promise>} Promise of formatted lines - * - * @see http://highlightjs.readthedocs.io/en/latest/api.html#highlight-name-value-ignore-illegals-continuation - */ -export function highlightLines(fileExtension, repo, lines) { - return loadLibrary().then(highlight => { - const formattedLines = []; - let state = null; - - let fileformat = fileExtension; - // edge case for .h fileExtension - if (fileExtension === 'h') { - if (repo === 'client-core') { - fileformat = 'cpp'; - } else { - fileformat = 'objectivec'; - } - } - - // edge case for .m fileExtension - if (fileExtension === 'm') { - fileformat = 'objectivec'; - } - - // make sure tsx and jsx are interpreted correctly - if (fileExtension === 'tsx') { - fileformat = 'typescript'; - } - if (fileExtension === 'jsx') { - fileformat = 'javascript'; - } - if (fileExtension === 'kt') { - fileformat = 'kotlin'; - } - - lines.forEach(line => { - const result = highlight.highlight(fileformat, line, true, state); - state = result.top; - formattedLines.push(highlight.fixMarkup(result.value)); - }); - return formattedLines; - }); -} - -/** - * Given a block of code, return a formatted string with the code highlighted, using language detection - * - * @param {String} block The block of code. - * - * @returns {Promise} Promise resolving when highlighting is complete. - */ -export const highlightBlock = block => loadLibrary().then(highlight => highlight.highlightBlock(block)); - -/** - * This React component should maintain a compatible API with the "react-highlight" module. Using our own - * module ensures that we can - * 1) Only load the languages we really need - * 2) Configure the webpack async chunk in one place (the loadLibrary() function). - */ -export class Highlight extends React.Component { - constructor(props) { - super(props); - this.state = { - highlighted: [], - }; - } - - componentDidMount() { - const { language, children: body } = this.props; - highlightLines(language, null, body.split('\n')).then(lines => { - this.setState({ - highlighted: lines, - }); - }); - } - - render() { - return ( -
    -        
    -      
    - ); - } - - buildMarkup() { - return { - __html: this.state.highlighted.join('
    '), - }; - } -} diff --git a/frontend/packages/shared/apis/jira/jiraUtils.js b/frontend/packages/shared/apis/jira/jiraUtils.js deleted file mode 100644 index 7dcfd8e342..0000000000 --- a/frontend/packages/shared/apis/jira/jiraUtils.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Jira utilities for merging results - */ - -class JiraUtils { - static instance; - - constructor() { - if (JiraUtils.instance) { - return JiraUtils.instance; - } - this.instance = this; - } - - makeJiraIssueURL = (projectID, issueType) => { - if (!projectID || !issueType) { - throw Error('Input projectID and issueType needs to be set'); - } - if (issueType !== 1 && issueType !== 2) { - throw Error('issueType must be either 1 (bug) or 2 (feature request)'); - } - return `https://jira.spotify.net/secure/CreateIssueDetails!init.jspa?priority=3&issuetype=${issueType}&pid=${projectID}`; - }; - - genIssuesWithStatusFromFilterNameMap(issueMap, filtername, statusText) { - let issues = {}; - if (issueMap.hasOwnProperty(filtername)) { - issueMap[filtername].forEach(issue => { - let newIssue = Object.assign({ status: statusText }, issue); - issues[newIssue.id] = newIssue; - }); - } - return issues; - } - - mergeFilterNameIssues(filterResults) { - // Extract the results to keys from the results - let filterMap = {}; - filterResults.forEach(filter => { - filterMap[filter.filter] = filter.issues; - }); - - // Extract and enhance the warning issues - const warningIssues = this.genIssuesWithStatusFromFilterNameMap(filterMap, 'APP_RELEASE_RADAR', 'yellow'); - // Extract and enhance the error issues - const errorIssues = this.genIssuesWithStatusFromFilterNameMap(filterMap, 'APP_IMMEDIATE_ACTION', 'red'); - // Merge the two with overwriting the left objec keys with the right - const combined = Object.assign({}, warningIssues, errorIssues); - - // flatten to an array - const issues = Object.values(combined); - - return issues; - } -} - -let _instance = new JiraUtils(); - -export default _instance; diff --git a/frontend/packages/shared/apis/jira/jiraUtils.test.js b/frontend/packages/shared/apis/jira/jiraUtils.test.js deleted file mode 100644 index a96bea117c..0000000000 --- a/frontend/packages/shared/apis/jira/jiraUtils.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import JiraUtils from 'shared/apis/jira/jiraUtils'; - -describe('JiraUtils', () => { - it('throws proper error when required input is missing', () => { - expect(() => { - JiraUtils.makeJiraIssueURL(); - }).toThrowError(); - }); - - it('throws proper error when one required input is missing', () => { - expect(() => { - JiraUtils.makeJiraIssueURL(1222); - }).toThrowError(); - }); - - it('throws proper error when one input is wrong', () => { - expect(() => { - JiraUtils.makeJiraIssueURL(1222, 0); - }).toThrowError(); - }); - - it('does not throw error when input is valid', () => { - expect(() => { - JiraUtils.makeJiraIssueURL(1222, 1); - }).not.toThrowError(); - expect(() => { - JiraUtils.makeJiraIssueURL(1222, 2); - }).not.toThrowError(); - }); -}); diff --git a/frontend/packages/shared/apis/menu/BackstageMenuItem.js b/frontend/packages/shared/apis/menu/BackstageMenuItem.js deleted file mode 100644 index ecdcb094be..0000000000 --- a/frontend/packages/shared/apis/menu/BackstageMenuItem.js +++ /dev/null @@ -1,30 +0,0 @@ -import MenuItem from 'shared/apis/menu/MenuItem'; - -import { PluginBase } from 'shared/pluginApi'; - -import FeatureFlags from 'shared/apis/featureFlags/featureFlags'; - -export default class BackstageMenuItem extends MenuItem { - type = 'Link'; - - constructor(ownerPlugin, id, title, options = {}, parent = undefined) { - super(id, title, options, parent); - - if (!(ownerPlugin instanceof PluginBase)) { - console.error(ownerPlugin); - throw new Error('BackstageMenuItem: ownerPlugin must extend PluginBase'); - } - - this.ownerPlugin = ownerPlugin; - - this.type = this.options.type || this.type; - } - - get visible() { - if (this.ownerPlugin.manifest.featureFlag) { - return super.visible && FeatureFlags.getItem(this.ownerPlugin.manifest.featureFlag); - } - - return super.visible; - } -} diff --git a/frontend/packages/shared/apis/menu/DefaultMenu.js b/frontend/packages/shared/apis/menu/DefaultMenu.js deleted file mode 100644 index e43a0de0c7..0000000000 --- a/frontend/packages/shared/apis/menu/DefaultMenu.js +++ /dev/null @@ -1,953 +0,0 @@ -import React from 'react'; - -import BackstageMenuItem from 'shared/apis/menu/BackstageMenuItem'; - -import { AppIcon, GroupIcon, ServiceIcon, StorageIcon } from 'shared/icons'; -import DataIcon from '@material-ui/icons/DataUsage'; -import ReliabilityIcon from '@material-ui/icons/Stars'; -import ComplianceIcon from '@material-ui/icons/Business'; -import CodeIcon from '@material-ui/icons/Code'; -import FeatureFlags from 'shared/apis/featureFlags/featureFlags'; - -// Explore icons. -import ProgrammingPlatformIcon from 'shared/assets/icons/explore/pp.png'; -import TechLearningIcon from 'shared/assets/icons/explore/techlearning.png'; -import SearchIcon from 'shared/assets/icons/explore/search.png'; -import MessagingIcon from 'shared/assets/icons/explore/journey.png'; -import EncoreIcon from 'shared/assets/icons/explore/encore.png'; -import SecurityIcon from 'shared/assets/icons/explore/SecurityTribe.png'; -import Tc4bIcon from 'shared/assets/icons/explore/tc4b.jpg'; -import MLIcon from 'shared/assets/icons/explore/ml.png'; - -// Client SDK icons. -import SDKGenericIcon from 'shared/assets/icons/explore/sdk.png'; -import BetamaxIcon from 'shared/assets/icons/explore/betamax.png'; -import ConnectivitySDKIcon from 'shared/assets/icons/explore/connectivity_logo_black.png'; - -// Infrastructure and Tooling. -import PlatformDefaultIcon from 'shared/assets/icons/explore/tool-logo.svg'; -import DataInfrastructureIcon from 'shared/assets/icons/explore/di.png'; -import DataMonitoringIcon from 'shared/assets/icons/explore/datamonitoring.png'; -import TingleIcon from 'shared/assets/icons/explore/tingle.png'; -import TechDocsIcon from 'shared/assets/icons/explore/techdocs.png'; -import SlingshotIcon from 'shared/assets/icons/explore/slingshot.png'; -import ScioIcon from 'shared/assets/icons/explore/scio.png'; -import StackOverflowEnterpriseIcon from 'shared/assets/icons/explore/soe.png'; -import JukeboxIcon from 'shared/assets/icons/explore/jukebox.png'; -import KubeflowIcon from 'shared/assets/icons/explore/kubeflow.png'; -import KlioIcon from 'shared/assets/icons/explore/klio.png'; -import GrpcIcon from 'shared/assets/icons/explore/spoticakes.png'; -import BigQueryIcon from 'shared/assets/icons/explore/bigquery.png'; -import ScienceBoxIcon from 'shared/assets/icons/explore/sciencebox.png'; -import TableauIcon from 'shared/assets/icons/explore/tableau.png'; -import QlikSenseIcon from 'shared/assets/icons/explore/qliksense.png'; -import MobiusIcon from 'shared/assets/icons/explore/mobius-logo.png'; -import DistributedTracingIcon from 'shared/assets/icons/explore/lightstep.png'; -import GrafanaIcon from 'shared/assets/icons/explore/grafana.png'; -import LuigiIcon from 'shared/assets/icons/explore/luigi.png'; -import CassetteIcon from 'shared/assets/icons/explore/cassette-logo.png'; -import SecurityTribeIcon from 'shared/assets/icons/explore/SecurityTribe.png'; -import ArtifactoryIcon from 'shared/assets/icons/explore/artifactory.png'; -import BackstageIcon from 'shared/assets/icons/explore/backstage-platform.png'; -import ITGCtrackerIcon from 'shared/assets/icons/explore/itgc_tracker.svg'; -import BigtableIcon from 'shared/assets/icons/explore/bigtable.png'; - -export function setupMenu(pluginManager) { - const rp = pluginManager.rootPlugin; - - const menuRoot = (pluginManager.menu = new BackstageMenuItem(rp, 'root', '')); - - menuRoot.add(new BackstageMenuItem(rp, 'tools', 'Tools')); - - menuRoot.add( - new BackstageMenuItem(rp, 'explore', 'Explore', { - searchable: false, - }), - ); - - /** - * This is a non-searchable menu item that is used by plugins to add links to the search. - */ - menuRoot.add(new BackstageMenuItem(rp, 'general', 'General')); - - //------------------------------------- - // Explore - //------------------------------------- - // Platforms - menuRoot.getByIdPath('explore').add(new BackstageMenuItem(rp, 'platform', 'Platforms', { searchable: false })); - - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'metadata', 'Content Metadata', { - img: 'https://cdn2.techadvisor.co.uk/cmsdata/reviews/3620240/Spotify-Menu.png', - url: '/docs/metadata', - desc: - "The source for all of Spotify's ingested, curated and refined content metadata to create a catalog that both listeners and creators expect.", - newsTag: 'contentmetadata', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'pzn', 'Personalization', { - img: - 'https://domain.me/wp-content/uploads/2016/10/ME-blog-cover-Personalization-The-Future-of-Marketing-okt-2016.jpg', - url: 'https://confluence.spotify.net/display/PZNPL/Product+Pages', - desc: 'Understand the ways content on Spotify can be similar to other content', - newsTag: 'personalization', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'messaging', 'Quicksilver Messaging', { - img: MessagingIcon, - fit: 'contain', - url: 'https://confluence.spotify.net/display/quicksilver/Quicksilver+Messaging+Platform', - desc: - 'Quicksilver is the internal messaging tool used to send in-app, email and push messages to Spotify listeners.', - newsTag: 'quicksilver', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'pp', 'Programming', { - img: ProgrammingPlatformIcon, - url: 'https://docs.google.com/document/d/1JlTWLPF39uDKMOWKrjzQ4_BaVa6X28B9_vTVaNbOxEM/edit#', - desc: - 'Content aggregation, curation and personalization of views. Tools for curating playlists and programming which playlists go where.', - newsTag: 'programming', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'security', 'Security', { - img: SecurityIcon, // or external URL. - fit: 'contain', - url: '/docs/security', - desc: 'Security guidelines you should take into account when building and reviewing your service.', - newsTag: 'security', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'encore', 'Encore', { - img: EncoreIcon, - url: 'https://encore.spotify.net/', - desc: - 'Our new approach to design systems. It’s everything you need to build beautiful, scalable apps that look and feel like Spotify.', - newsTag: 'encore', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'storage', 'Storage', { - img: - 'https://storage.googleapis.com/gweb-cloudblog-publish/images/google-cloud-storage-pub-sub3a60.max-700x700.PNG', - url: '/docs/storage', - desc: - 'Enabling squads to focus on their vision by providing reliable, easy to use storage solutions that grow with Spotify.', - newsTag: 'storage', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'search', 'Search', { - img: SearchIcon, - url: 'https://confluence.spotify.net/display/JAM/Search+Product+Area', - desc: 'Enables a frictionless path from intent expressed through text, to great sessions and the right entities.', - newsTag: 'search', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'connect', 'Spotify Connect', { - img: 'https://assets.sbnation.com/assets/3147739/spotify-connect-devices1_560.jpg', - url: 'https://www.spotify.com/connect/', - desc: 'Listen on your speakers or TV, using the Spotify app as a remote.', - newsTag: 'connect', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'tl', 'Tech Learning', { - img: TechLearningIcon, - url: '/docs/tech-learning', - desc: 'Educational programs to grow the breadth and depth of Spotify employees’ technical skill sets.', - newsTag: 'techlearning', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'web-api', 'Spotify Web API', { - img: 'https://developer.spotify.com/assets/WebAPI_intro.png', - url: 'https://developer.spotify.com/documentation/web-api/', - desc: - 'Spotify Web API endpoints return JSON metadata about music artists, albums, and tracks, directly from the Spotify Data Catalogue.', - newsTag: 'webapi', - }), - ); - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'research', 'Research', { - img: 'https://s3.envato.com/files/2709ab7e-dd8f-4243-983d-82a5c763e46a/inline_image_preview.jpg', - url: 'https://research.spotify.com', - desc: "Extending the State of the Art in Technologies related to Spotify's Products", - newsTag: 'research', - }), - ); - if (FeatureFlags.getItem('machine-learning')) { - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'ml', 'Machine Learning', { - img: MLIcon, - url: '/machine-learning', - lifecycle: 'Alpha', - desc: 'Spotify’s ML Platform to discover, share, and manage your ML work.', - newsTag: 'ml', - }), - ); - } - if (FeatureFlags.getItem('sciencebox-cloud-notebook-creator')) { - menuRoot.getByIdPath('explore.platform').add( - new BackstageMenuItem(rp, 'sciencebox', 'ScienceBox Cloud', { - img: ScienceBoxIcon, - url: '/sciencebox-cloud', - lifecycle: 'Alpha', - desc: "Spotify's Platform to simplify your data analysis workflow", - newsTag: 'scienceboxcloud', - }), - ); - } - - // Client SDKs - menuRoot.getByIdPath('explore').add(new BackstageMenuItem(rp, 'sdk', 'Client SDKs', { searchable: false })); - menuRoot.getByIdPath('explore.sdk').add( - new BackstageMenuItem(rp, 'betamax', 'Betamax', { - img: BetamaxIcon, - url: '/docs/betamax-sdk', - desc: 'The Betamax SDK enables Spotify to create reliable and creative video experiences our users love.', - newsTag: 'betamax', - }), - ); - menuRoot.getByIdPath('explore.sdk').add( - new BackstageMenuItem(rp, 'gabito', 'Event Delivery', { - img: 'https://spotifylabscom.files.wordpress.com/2016/03/gabo-system-design-2x.png', - url: '/docs/gabito-docs/', - desc: - 'Event Delivery (Gabito) is a reliable, high throughput, highly available, cost efficient event delivery system infrastructure.', - newsTag: 'eventdelivery', - }), - ); - menuRoot.getByIdPath('explore.sdk').add( - new BackstageMenuItem(rp, 'rcs', 'Remote Configuration', { - img: SDKGenericIcon, - url: '/docs/remote-configuration/', - desc: - 'Remote configuration is a configuration platform for dynamic configuration assignment to a given service or client.', - newsTag: 'remoteconfig', - }), - ); - menuRoot.getByIdPath('explore.sdk').add( - new BackstageMenuItem(rp, 'connectivity-sdk', 'Connectivity', { - img: ConnectivitySDKIcon, - url: '/docs/connectivity-sdk-docs/', - desc: - 'The Connectivity SDK is a self-contained and complete solution for working with mobile and desktop apps, enabling authenticated networking to the Spotify backend as well as general networking for other internet services, such as CDNs.', - newsTag: 'connectivity', - }), - ); - - // Infrastructure and tooling - menuRoot - .getByIdPath('explore') - .add(new BackstageMenuItem(rp, 'infra', 'Infrastructure & Tooling', { searchable: false })); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'tingle', 'Tingle', { - img: TingleIcon, - url: '/docs/tingle', - lifecycle: 'GA', - domains: ['backend', 'web', 'data', 'mobile'], - desc: "Tingle is Spotify's centralized CI/CD system for backend, data and web-services.", - newsTag: 'tingle', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'gke', 'GKE', { - img: 'https://miro.medium.com/max/1200/1*_saMmI_5Kse6rqZPkiekfg.png', - url: '/docs/gke', - lifecycle: 'GA', - domains: ['backend'], - desc: 'Managed tool for deploying containerized applications in a developer and cost efficient way.', - newsTag: 'gke', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'grpc', 'gRPC', { - img: GrpcIcon, - url: '/docs/grpc', - lifecycle: 'GA', - domains: ['backend'], - desc: 'An open source RPC framework.', - newsTag: 'grpc', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'techdocs', 'TechDocs', { - img: TechDocsIcon, - url: '/docs/docs', - lifecycle: 'GA', - domains: ['backend', 'web', 'data', 'data-science', 'mobile', 'ml'], - desc: 'TechDocs is the way to write technical documentation at Spotify.', - newsTag: 'techdocs', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'scio', 'Scio', { - img: ScioIcon, - url: 'https://spotify.github.io/scio/', - lifecycle: 'GA', - domains: ['data', 'data-science'], - desc: 'An Open Source Scala API for Apache Beam and Google Cloud Dataflow.', - newsTag: 'scio', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'slingshot', 'Slingshot', { - img: SlingshotIcon, - url: '/docs/slingshot/', - lifecycle: 'GA', - domains: ['web'], - desc: 'Provides temporary review instances as a result of a Tingle Review Build.', - newsTag: 'slingshot', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'teamcity', 'TeamCity', { - img: 'https://www.nclouds.com/blog/wp-content/uploads/2017/04/teamcity-post-banner.jpg', - url: 'https://teamcity.spotify.net', - lifecycle: 'GA', - domains: ['mobile'], - desc: 'CI platform for mobile and desktop clients.', - newsTag: 'teamcity', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'data-monitoring', 'Data Monitoring', { - img: DataMonitoringIcon, - url: '/data-monitoring', - lifecycle: 'Beta', - domains: ['data'], - desc: 'Dashboarding tool to monitor the health of your data', - newsTag: 'datamonitoring', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'data-discovery', 'Data Discovery', { - img: DataInfrastructureIcon, - url: '/data', - lifecycle: 'Beta', - domains: ['data', 'data-science'], - desc: 'Experience for finding high quality data in Spotify ecosystem.', - newsTag: 'datadiscovery', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'lexikon', 'Lexikon', { - img: PlatformDefaultIcon, - url: 'https://lexikon.spotify.net/', - lifecycle: 'GA', - domains: ['data-science'], - desc: "Spotify's data and knowledge management solution.", - newsTag: 'lexikon', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'ti', 'Tech Insights', { - img: PlatformDefaultIcon, - url: '/tech-insights', - lifecycle: 'Beta', - domains: ['backend', 'web', 'data', 'data-science', 'mobile'], - desc: 'Get a overview of the Spotify tech landscape and its health.', - newsTag: 'techinsights', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'soe', 'Stack Overflow Enterprise', { - img: StackOverflowEnterpriseIcon, - url: 'https://spotify.stackenterprise.co/', - lifecycle: 'GA', - domains: ['backend', 'web', 'data', 'data-science', 'mobile'], - desc: "Spotify's main technical support tool", - newsTag: 'soe', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'jukebox', 'Jukebox', { - img: JukeboxIcon, - url: '/docs/jukebox/', - lifecycle: 'Alpha', - domains: ['data', 'data-science', 'ml'], - desc: 'Components enabling ML Feature Stores', - newsTag: 'jukebox', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'kubeflow', 'Kubeflow', { - img: KubeflowIcon, - url: '/docs/spotify-kubeflow/', - lifecycle: 'Alpha', - domains: ['data', 'data-science', 'ml'], - desc: 'Components enabling rapid ML model iteration', - newsTag: 'kubeflow', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'klio', 'Klio', { - img: KlioIcon, - url: '/docs/klio-docs/', - lifecycle: 'Alpha', - domains: ['data', 'data-science', 'ml'], - desc: 'Python API for Apache Beam and Google Cloud Dataflow', - newsTag: 'klio', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'tc4x', 'Test Certified', { - img: Tc4bIcon, - url: '/docs/tc4x-docs', - lifecycle: 'GA', - domains: ['backend', 'web', 'data', 'mobile'], - desc: - 'Test Certified Programs for all disciplines, which help guide you to high levels of quality and confidence with your products.', - newsTag: 'testcertified', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'xclogparser', 'XCLogParser', { - img: PlatformDefaultIcon, - url: 'https://github.com/spotify/XCLogParser', - lifecycle: 'Beta', - domains: ['mobile'], - desc: 'Xcode Log Parsing and Reporting tools that gives insight on iOS Build performance.', - newsTag: 'xclogparser', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'androidstudiotuner', 'Android Studio Tuner', { - img: PlatformDefaultIcon, - url: - 'https://backstage.spotify.net/docs/client-golden-path/part-1-configuring-your-local-development-environment/10-configuring-android-locally/', - lifecycle: 'GA', - domains: ['mobile'], - desc: 'Tool that optimizes Android Studio performance and development environment settings', - newsTag: 'androidstudiotuner', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'remotebuild', 'Remote Build', { - img: PlatformDefaultIcon, - url: '/docs/client-android/remote-build/', - lifecycle: 'GA', - domains: ['mobile'], - desc: - 'A tool that does quick remote builds of the Android Music Client that is then synced to your local development machine', - newsTag: 'remotebuild', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'qliksense', 'Qlik Sense', { - img: QlikSenseIcon, - url: - 'https://backstage.spotify.net/docs/data-science-golden-path/part-3-creating-your-first-qlik-sense-app/1-introduction/', - lifecycle: 'GA', - domains: ['data', 'data-science'], - desc: "Spotify's visualisation toolkit.", - newsTag: 'qliksense', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'tableau', 'Tableau', { - img: TableauIcon, - url: - 'https://backstage.spotify.net/docs/data-science-golden-path/part-2-creating-your-first-tableau-dashboard/1-introduction/', - lifecycle: 'GA', - domains: ['data', 'data-science'], - desc: "Spotify's visualisation toolkit.", - newsTag: 'tableau', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'scienceboxclassic', 'Science Box Classic', { - img: ScienceBoxIcon, - url: 'https://confluence.spotify.net/display/SB/Science+Box', - lifecycle: 'GA', - domains: ['data', 'data-science'], - desc: "Spotify's data science toolkit.", - newsTag: 'scienceboxclassic', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'bigquery', 'BigQuery', { - img: BigQueryIcon, - url: 'https://console.cloud.google.com/bigquery', - lifecycle: 'GA', - domains: ['data', 'data-science'], - desc: "Google's cloud-based web service for interactive SQL queries against big data sets.", - newsTag: 'bigquery', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'bigqueryrunner', 'BigQuery Runner', { - img: PlatformDefaultIcon, - url: 'https://confluence.spotify.net/display/BBQ/BQ+Runner+Documentation', - lifecycle: 'GA', - domains: ['data', 'data-science'], - desc: 'A tool that allows you to run queries on a schedule.', - newsTag: 'bqrunner', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'bigqueryload', 'BigQuery Load', { - img: PlatformDefaultIcon, - url: 'https://confluence.spotify.net/display/BBQ/BQ+Load+User+Manual', - lifecycle: 'GA', - domains: ['data', 'data-science'], - desc: 'A tool that allows you to load data from Google Cloud Storage to BigQuery on a schedule.', - newsTag: 'bqload', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'elitzur', 'Elitzur', { - img: PlatformDefaultIcon, - url: 'https://backstage.spotify.net/docs/elitzur/', - lifecycle: 'Beta', - domains: ['data'], - desc: 'Library to validate dataset column values using custom-types at run-time in Scio Pipelines.', - newsTag: 'elitzur', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'contours', 'Contours', { - img: PlatformDefaultIcon, - url: 'https://backstage.spotify.net/docs/data-profiling-docs/', - lifecycle: 'Alpha', - domains: ['data'], - desc: 'Library to generate descriptive statistics of each field in your dataset.', - newsTag: 'contours', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'iceluigi', 'Ice-Luigi', { - img: PlatformDefaultIcon, - url: 'https://ghe.spotify.net/datainfra/ice-luigi', - lifecycle: 'Alpha', - domains: ['data'], - desc: 'Luigi tasks and targets to make it easier to test Data Pipelines.', - newsTag: 'iceluigi', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'ratatool', 'Ratatool', { - img: PlatformDefaultIcon, - url: 'https://github.com/spotify/ratatool/', - lifecycle: 'Beta', - domains: ['data'], - desc: 'Tool for random data sampling and generation.', - newsTag: 'ratatool', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'mobius', 'Mobius', { - img: MobiusIcon, - url: '/docs/mobius-docs/', - lifecycle: 'GA', - domains: ['mobile'], - desc: 'Mobius is a functional reactive framework for managing state evolution and side-effects.', - newsTag: 'mobius', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'hubsrenderer', ' HubsRenderer', { - img: PlatformDefaultIcon, - url: '/docs/hubs-renderer-docs/', - lifecycle: 'GA', - domains: ['mobile'], - desc: - 'HubsRenderer is Spotify’s component-driven UI framework. We use it to build, tweak, and ship user interface features in new or existing apps. It also makes it easy to build backend-driven UIs.', - newsTag: 'hubsrenderer', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'crashview', 'Crash View', { - img: PlatformDefaultIcon, - url: '/docs/crash-stack-documentation', - lifecycle: 'Alpha', - domains: ['mobile'], - desc: "Crash View is Spotify's tool to find your crashes in the main music app.", - newsTag: 'crashview', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'experimentation', 'Experimentation Platform', { - img: PlatformDefaultIcon, - lifecycle: 'Alpha', - domains: ['backend', 'web', 'data', 'data-science', 'mobile'], - url: '/docs/experimentation-platform-docs/', - desc: - 'The experimentation platform enables users to quickly iterate through new ideas or improvements, learn from them to adopt better solutions', - newsTag: 'experimentation', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'grafana', 'Grafana', { - img: GrafanaIcon, - url: '/docs/monitoring/', - lifecycle: 'GA', - domains: ['backend', 'web'], - desc: 'Observability platform frontend for graphing and time series analytics, in real time.', - newsTag: 'grafana', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'distributedtracing', 'Distributed Tracing', { - img: DistributedTracingIcon, - url: '/docs/tracing/', - lifecycle: 'GA', - domains: ['backend', 'web', 'mobile'], - desc: 'Observability platform tool to instrument and trace requests at scale, in real time. ', - newsTag: 'tracing', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'spotifystatus', 'Spotity Status', { - img: PlatformDefaultIcon, - url: 'https://status.spotify.net', - lifecycle: 'GA', - domains: ['backend', 'web', 'data', 'mobile'], - desc: - 'The starting place for incidents. Know at a glance whether there are known problems affecting Spotify and how those incidents are progressing towards resolution.', - newsTag: 'spotifystatus', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'styx', 'Styx', { - img: PlatformDefaultIcon, - url: 'https://ghe.spotify.net/datainfra/styx', - lifecycle: 'GA', - domains: ['data'], - desc: - 'A service for scheduling the execution of docker containers used to periodically trigger batch data workflows.', - newsTag: 'styx', - }), - ); - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'luigi', 'Luigi', { - img: LuigiIcon, - url: 'https://github.com/spotify/luigi', - lifecycle: 'GA', - domains: ['data'], - desc: 'An open source Python library for defining tasks and their dependencies within a data workflow.', - newsTag: 'luigi', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'artifactory', 'Artifactory', { - img: ArtifactoryIcon, - url: '/docs/artifactory/', - lifecycle: 'GA', - domains: ['backend', 'web', 'data', 'mobile'], - desc: 'Store and manage your binaries.', - newsTag: 'artifactory', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'cloudefficiency', 'Cost Efficiency', { - img: PlatformDefaultIcon, - url: '/docs/gcp-efficiency-score-service', - lifecycle: 'Beta', - domains: ['backend'], - desc: - 'Tool that calculates cost efficiency based on utilization of cloud resources and provides recommendations for better efficiency.', - newsTag: 'cost', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'cassette-ios', 'Cassette (iOS)', { - img: CassetteIcon, - url: 'https://backstage.spotify.net/docs/client-ios-docs/Writing-Cassette-Tests/', - lifecycle: 'GA', - domains: ['mobile'], - desc: 'Cassette is a framework for writing fast and stable integration tests', - newsTag: 'casetteios', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'cassette-android', 'Cassette (Android)', { - img: CassetteIcon, - url: 'https://backstage.spotify.net/docs/client-android/03-testing/03-cassette-tests/overview/', - lifecycle: 'GA', - domains: ['mobile'], - desc: 'Cassette is a framework for writing fast and stable integration tests', - newsTag: 'casetteandroid', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'gabito', 'Gabito', { - img: DataInfrastructureIcon, - url: '/docs/gabito-docs', - lifecycle: 'Beta', - domains: ['data', 'data-science', 'backend', 'web', 'mobile'], - desc: - 'Instrumentation SDKs and Event Delivery Infrastructure. Reliable, high throughput, highly available and cost efficient', - newsTag: 'gabito', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'safetynet', 'Safetynet', { - img: SecurityTribeIcon, - url: '/docs/security/safetynet/', - lifecycle: 'Beta', - domains: ['backend', 'web', 'data'], - desc: "Safetynet is Security's product for detecting and alerting on malicious activity in our environment.", - newsTag: 'safetynet', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'client-performance-dashboards', 'Client Performance Dashboards', { - img: PlatformDefaultIcon, - url: 'https://arewefastyet.spotify.net/', - lifecycle: 'GA', - domains: ['mobile'], - desc: - 'A series of interactive dashboards for digging into Client Performance data to find out how fast and efficient our products are.', - newsTag: 'perfdashboards', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'remote-admin', 'Remote Admin', { - img: PlatformDefaultIcon, - url: 'https://remoteadmin.spotifyinternal.com/', - lifecycle: 'GA', - domains: ['backend', 'mobile'], - desc: - 'A backend tool which enables live traffic visualization for selected users. Has additional debugging functionality for Spotify Connect.', - newsTag: 'remoteadmin', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'padlock', 'Padlock', { - img: PlatformDefaultIcon, - url: '/docs/padlock', - lifecycle: 'GA', - domains: ['backend', 'data'], - desc: 'Padlock is a key management service that helps service-owners respect users privacy.', - newsTag: 'padlock', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'scio-anonym', 'Scio Anonym', { - img: PlatformDefaultIcon, - url: '/docs/padlock/adoption/scio/scio_anonym', - lifecycle: 'Alpha', - domains: ['data'], - desc: 'Scio Anonym is a library for producing and consuming personal data in Scio pipelines.', - newsTag: 'scioanonym', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'backstage', 'Backstage Platform', { - img: BackstageIcon, - url: '/docs/backstage', - desc: 'The Spotify platform for internal tooling. Information targeted to contributors.', - domains: ['web', 'backend'], - newsTag: 'backstagedevs', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'itgctracker', 'ITGC Tracker', { - img: ITGCtrackerIcon, - url: '/itgc/tracker', - lifecycle: 'Alpha', - domains: ['backend', 'itgc'], - desc: - 'The ITGC tracker allows you to visualize the state of all components in scope for ITGC or awating to be reviewed.', - newsTag: 'itgctrcker', - }), - ); - - menuRoot.getByIdPath('explore.infra').add( - new BackstageMenuItem(rp, 'bigtable', 'Bigtable', { - img: BigtableIcon, - url: '/docs/storage/bigtable', - lifecycle: 'GA', - domains: ['backend', 'data'], - desc: - 'Bigtable is a high performance cross-region replicated NoSQL database service for large analytical and operational workloads.', - newsTag: 'bigtable', - }), - ); - - //------------------------------------- - // Data - //------------------------------------- - menuRoot.getByIdPath('tools').add(new BackstageMenuItem(rp, 'data', 'Data', { img: })); - menuRoot.getByIdPath('tools.data').add( - new BackstageMenuItem(rp, 'dataMonitoring', 'Data Monitoring', { - url: '/data-monitoring', - }), - ); - menuRoot - .getByIdPath('tools.data') - .add(new BackstageMenuItem(rp, 'realtimeEvents', 'Realtime Events', { url: '/realtime-events' })); - menuRoot - .getByIdPath('tools.data') - .add(new BackstageMenuItem(rp, 'dataAccessStatus', 'Data Access Status', { url: '/data-requests/access/status' })); - menuRoot - .getByIdPath('tools.data') - .add(new BackstageMenuItem(rp, 'alchemy', 'Data Alchemy', { url: 'https://alchemy.spotify.net' })); - - //------------------------------------- - // Data Science - //------------------------------------- - menuRoot.getByIdPath('tools').add(new BackstageMenuItem(rp, 'data-science', 'Data Science', { img: })); - - menuRoot.getByIdPath('tools.data-science').add( - new BackstageMenuItem(rp, 'qliksense', 'Qlik Sense', { - url: 'https://confluence.spotify.net/display/IT/Qlik+sense+access', - }), - ); - menuRoot.getByIdPath('tools.data-science').add( - new BackstageMenuItem(rp, 'tableau', 'Tableau Desktop', { - url: 'https://confluence.spotify.net/display/IT/Access+to+Tableau+Desktop', - }), - ); - - //------------------------------------- - // Code - //------------------------------------- - menuRoot.getByIdPath('tools').add(new BackstageMenuItem(rp, 'code', 'Code', { img: })); - menuRoot.getByIdPath('tools.code').add(new BackstageMenuItem(rp, 'ghe', 'GHE', { url: 'https://ghe.spotify.net' })); - menuRoot.getByIdPath('tools.code').add( - new BackstageMenuItem(rp, 'foss', 'Open Source @ Spotify', { - url: 'https://foss.spotify.net', - }), - ); - menuRoot.getByIdPath('tools.code').add( - new BackstageMenuItem(rp, 'tingleConsole', 'Tingle Console', { - url: 'https://tingle-console.spotify.net/', - }), - ); - menuRoot.getByIdPath('tools.code').add( - new BackstageMenuItem(rp, 'tingle-validator', 'Tingle Build Info Validator', { - url: 'https://build-info-validator.spotify.net/', - }), - ); - - //------------------------------------- - // Reliability - //------------------------------------- - menuRoot - .getByIdPath('tools') - .add(new BackstageMenuItem(rp, 'reliability', 'Reliability', { img: })); - menuRoot.getByIdPath('tools.reliability').add( - new BackstageMenuItem(rp, 'grafanaMonitoring', 'Grafana Monitoring', { - url: 'https://grafana.spotify.net', - }), - ); - menuRoot.getByIdPath('tools.reliability').add( - new BackstageMenuItem(rp, 'distributedTracing', 'Distributed Tracing', { - url: 'https://app.lightstep.com/spotify', - }), - ); - - //------------------------------------- - // Services - //------------------------------------- - menuRoot.getByIdPath('tools').add( - new BackstageMenuItem(rp, 'services', 'Services', { - img: , - }), - ); - menuRoot.getByIdPath('tools.services').add( - new BackstageMenuItem(rp, 'apolloLibrary', 'Apollo library', { - url: 'https://developer.spotify.net/products/apollo.html', - }), - ); - menuRoot.getByIdPath('tools.services').add( - new BackstageMenuItem(rp, 'serviceDiscovery', 'Service Discovery', { - url: 'https://developer.spotify.net/products/nameless.html', - }), - ); - - //------------------------------------- - // Apps - //------------------------------------- - menuRoot.getByIdPath('tools').add( - new BackstageMenuItem(rp, 'apps', 'Apps', { - img: , - }), - ); - menuRoot.getByIdPath('tools.apps').add( - new BackstageMenuItem(rp, 'junit', 'Visualization of JUnit test data', { - url: 'https://odeneye.spotify.net/', - }), - ); - menuRoot.getByIdPath('tools.apps').add( - new BackstageMenuItem(rp, 'integrationTesting', 'Integration testing', { - url: 'https://developer.spotify.net/products/cassette.html', - }), - ); - menuRoot.getByIdPath('tools.apps').add( - new BackstageMenuItem(rp, 'productQuality', 'Product Quality', { - url: 'https://product-quality.spotify.net/', - }), - ); - - //------------------------------------- - // Compliance - //------------------------------------- - menuRoot.getByIdPath('tools').add( - new BackstageMenuItem(rp, 'compliance', 'Compliance', { - img: , - }), - ); - menuRoot.getByIdPath('tools.compliance').add( - new BackstageMenuItem(rp, 'itgc', 'ITGC Components', { - url: '/itgc/components', - }), - ); - menuRoot.getByIdPath('tools.compliance').add( - new BackstageMenuItem(rp, 'activity', 'ITGC Activity', { - url: '/itgc/activity', - }), - ); - menuRoot.getByIdPath('tools.compliance').add( - new BackstageMenuItem(rp, 'yopass', 'Yopass - send passwords', { - url: 'https://yopass.spotify.net', - }), - ); - - //------------------------------------- - // Groups & Access - //------------------------------------- - menuRoot.getByIdPath('tools').add( - new BackstageMenuItem(rp, 'groups', 'Groups & Access', { - img: , - }), - ); - - //------------------------------------- - // Storage - //------------------------------------- - menuRoot.getByIdPath('tools').add( - new BackstageMenuItem(rp, 'storage', 'Storage', { - img: , - searchable: false, - }), - ); - menuRoot.getByIdPath('tools.storage').add( - new BackstageMenuItem(rp, 'storage', 'Selection Guide (Beta)', { - url: '/storage-selection-guide', - }), - ); - - return menuRoot; -} diff --git a/frontend/packages/shared/apis/menu/MenuItem.js b/frontend/packages/shared/apis/menu/MenuItem.js deleted file mode 100644 index 80a9de84f0..0000000000 --- a/frontend/packages/shared/apis/menu/MenuItem.js +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react'; - -export default class MenuItem { - constructor(id, title, options, parent) { - this.id = id; - this.title = title; - this.parent = parent; - this.children = []; - this.childrenById = {}; - - this.options = options; - - this._visible = true; - } - - get visibleChildren() { - return this.children.filter(child => child.visible); - } - - set visible(value) { - this._visible = value; - } - - get visible() { - return this._visible; - } - - /** - * Recursively construct a breadcrumb title path to this node. - * - * @param delimiter Defaults to ' > ' - * @returns {string} - */ - getTitlePath(delimiter = ' > ') { - if (this.options.ignoreParentsInTitle) { - return this.title; - } - - return `${this.parent && this.parent.title ? `${this.parent.title}${delimiter}` : ''}${this.title}`; - } - - /** - * Path can be an array of MenuItem ids: ['tools', 'compliance'] - * - * OR - * - * Path can be a dot-delimited string: 'tools.compliance' - * - * @param path - * @returns {MenuItem} - */ - getByIdPath(path) { - path = path instanceof Array ? path : path.split('.'); - - return path.reduce((menuItem, id) => { - return menuItem.childrenById[id]; - }, this); - } - - /** - * Add a MenuItem instance as a child to this MenuItem. - * - * @param menuItem - * @returns {*} - */ - add(menuItem) { - menuItem.parent = this; - - this.childrenById[menuItem.id] = menuItem; - this.children.push(menuItem); - - return menuItem; - } - - render() { - return
    {this.title}
    ; - } - - toString() { - return this.parent ? `${this.parent.toString()}.${this.id}` : this.id; - } -} diff --git a/frontend/packages/shared/apis/notifications/index.js b/frontend/packages/shared/apis/notifications/index.js deleted file mode 100644 index d561be41c0..0000000000 --- a/frontend/packages/shared/apis/notifications/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { usePagerDutyIncidents } from './pagerDutyIncidents'; -export { useStackoverflowUnansweredQuestions } from './stackoverflowQuestions'; diff --git a/frontend/packages/shared/apis/notifications/pagerDutyIncidents.ts b/frontend/packages/shared/apis/notifications/pagerDutyIncidents.ts deleted file mode 100644 index 7a7ad77166..0000000000 --- a/frontend/packages/shared/apis/notifications/pagerDutyIncidents.ts +++ /dev/null @@ -1,192 +0,0 @@ -import React from 'react'; -import gql from 'graphql-tag'; -import _ from 'lodash'; -import { ApolloClient } from 'apollo-client'; -import { useApolloClient } from '@apollo/react-hooks'; -import { useUser } from 'shared/apis/user'; - -const POLL_INTERVAL_MS = 30000; - -const query = gql` - query($username: String!) { - user(username: $username) { - components { - id - pagerDutyService { - id - ...PD - } - } - dataEndpoints { - id - warningPagerDutyService { - ...PD - } - errorPagerDutyService { - ...PD - } - } - workflows { - id - component { - id - pagerDutyService { - ...PD - } - } - } - } - } - fragment PD on PagerDutyService { - id - name - homepageUrl - activeIncidents { - id - homepageUrl - status - title - createdAt - } - } -`; - -function getIncidentsFor({ components, dataEndpoints, workflows }: any) { - const incidents = [ - ...components.map((c: any) => getServiceIncidents(c.pagerDutyService)), - ...dataEndpoints.map((d: any) => getServiceIncidents(d.warningPagerDutyService)), - ...dataEndpoints.map((d: any) => getServiceIncidents(d.errorPagerDutyService)), - ...workflows.map((w: any) => getServiceIncidents(w.component.pagerDutyService)), - ]; - - return _.chain(incidents) - .flatten() - .uniqBy('id') - .orderBy('createdAt') - .value(); -} - -function getServiceIncidents(service: any) { - if (!service) { - return []; - } - - return service.activeIncidents.map((incident: any) => ({ - ...incident, - service, - })); -} - -async function getPagerDutyIncidents(client: ApolloClient, username: string): Promise { - try { - let { data, errors } = await client.query({ query, variables: { username } }); - - if (errors) { - console.warn('Failed to fetch PagerDuty incidents', errors); - return []; - } - - if (!data || !data.user) { - return []; - } - - return getIncidentsFor(data.user); - } catch (error) { - console.error('Failed to fetch PagerDuty incidents', error); - return []; - } -} - -/* - * A hook that repeatedly polls the backend for all incident-capable entities, and returns - * an array with all active incidents. - */ -export function usePagerDutyIncidents() { - const { id: username } = useUser(); - const client = useApolloClient(); - const [incidents, setIncidents] = React.useState([]); - const timeoutIdRef = React.useRef(); - - const [broadcastChannel] = React.useState(() => { - // Broadcast messages to other channels open on the same origin in any window, not available in Safari. - if ((window as any).BroadcastChannel) { - return new (window as any).BroadcastChannel('pager-duty-refresh'); - } - return; - }); - - React.useEffect( - () => () => { - if (broadcastChannel) { - broadcastChannel.close(); // Cleanup on unmount - } - }, - [broadcastChannel], - ); - - React.useEffect(() => { - let didCancel = false; - - // Updates incidents, but doesn't replace empty arrays to avoid rerendering - const handleNewIncidents = (newIncidents: any[]) => { - setIncidents(incidents => { - if (newIncidents.length !== 0 || incidents.length !== 0) { - return newIncidents; - } else { - return incidents; - } - }); - }; - - // Handle incidents received from another window, restart the timout with some extra time added - // to give time for another update, and a random component for conflict resolution if the window is closed. - const handleIncidentsFromOtherTab = ({ data }: any) => { - scheduleFetch(1000 + Math.random() * 2000); - handleNewIncidents(data.incidents); - }; - - const fetchIncidents = async () => { - const newIncidents = await getPagerDutyIncidents(client, username); - - if (didCancel) { - return; - } - - // Broadcast the incidents we fetched to other tabs, so they won't need to fetch themselves. - if (broadcastChannel) { - broadcastChannel.postMessage({ incidents: newIncidents }); - } - handleNewIncidents(newIncidents); - }; - - const scheduleFetch = (extraWaitMs = 0) => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - } - timeoutIdRef.current = setTimeout(() => { - fetchIncidents(); - scheduleFetch(); - }, POLL_INTERVAL_MS + extraWaitMs); - }; - - // Fetch initial incidents and start fetch loop - fetchIncidents(); - scheduleFetch(); - - if (broadcastChannel) { - broadcastChannel.addEventListener('message', handleIncidentsFromOtherTab); - } - - return () => { - didCancel = true; - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - } - if (broadcastChannel) { - broadcastChannel.removeEventListener('message', handleIncidentsFromOtherTab); - } - }; - }, [client, username, broadcastChannel]); - - return incidents; -} diff --git a/frontend/packages/shared/apis/notifications/stackoverflowQuestions.ts b/frontend/packages/shared/apis/notifications/stackoverflowQuestions.ts deleted file mode 100644 index 083bf62178..0000000000 --- a/frontend/packages/shared/apis/notifications/stackoverflowQuestions.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useAsync } from 'react-use'; -import pluginManagerBootstrap from 'plugins/pluginManagerBootstrap'; -import StackOverflowClient from 'shared/apis/stackOverflow/StackOverflowClient'; -import { useUser } from 'shared/apis/user'; - -const LAST_READ_KEY = 'notifications.stackoverflowUnanswered.lastReadTimestamp'; - -export const useStackoverflowUnansweredQuestions = () => { - const [lastRead, setLastRead] = useState(() => Number(localStorage.getItem(LAST_READ_KEY)) || 0); - const user = useUser(); - - const markRead = useCallback(() => { - const now = new Date().getTime(); - localStorage.setItem(LAST_READ_KEY, String(now)); - setLastRead(now); - }, []); - - const status = useAsync(async () => { - const squadIds = user.groups.filter(group => group.type === 'squad').map(s => s.id); - const tags = pluginManagerBootstrap.plugins.reduce((tags, plugin) => { - if (squadIds.includes(plugin.owner)) tags = tags.concat(plugin.stackoverflowTags); - return tags; - }, []); - if (tags.length) { - return await StackOverflowClient.unansweredFor(tags, lastRead); - } else { - return []; - } - }, [lastRead]); - - return { - ...status, - markRead, - }; -}; diff --git a/frontend/packages/shared/apis/oauth/BasicOAuthScopes.ts b/frontend/packages/shared/apis/oauth/BasicOAuthScopes.ts deleted file mode 100644 index a0e13a2220..0000000000 --- a/frontend/packages/shared/apis/oauth/BasicOAuthScopes.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { OAuthScopes, OAuthScopeLike } from './types'; - -export class BasicOAuthScopes implements OAuthScopes { - static from(scopes: OAuthScopeLike, normalizer: (scope: string) => string = x => x) { - const normalized = BasicOAuthScopes.asStrings(scopes, normalizer); - return new BasicOAuthScopes(new Set(normalized), normalizer); - } - - constructor(private readonly scopes: Set, private readonly normalizer: (scope: string) => string) {} - - extend(requestedScopes: OAuthScopeLike): OAuthScopes { - const newScopes = new Set(this.scopes); - BasicOAuthScopes.asStrings(requestedScopes, this.normalizer).forEach(s => newScopes.add(s)); - return new BasicOAuthScopes(newScopes, this.normalizer); - } - - hasScopes(scopes: OAuthScopeLike): boolean { - return BasicOAuthScopes.asStrings(scopes, this.normalizer).every(s => this.scopes.has(s)); - } - - toSet(): Set { - return this.scopes; - } - - toString(): string { - return Array.from(this.scopes).join(' '); - } - - static asStrings(input: OAuthScopeLike, normalizer: (scope: string) => string): string[] { - if (typeof input === 'string') { - return input - .split(' ') - .filter(Boolean) - .map(s => normalizer(s)); - } else if (Array.isArray(input)) { - return input.map(s => normalizer(s)); - } else { - return Array.from(input.toSet()).map(s => normalizer(s)); - } - } -} diff --git a/frontend/packages/shared/apis/oauth/OAuthPendingRequests.test.ts b/frontend/packages/shared/apis/oauth/OAuthPendingRequests.test.ts deleted file mode 100644 index 3bd2e2bf7c..0000000000 --- a/frontend/packages/shared/apis/oauth/OAuthPendingRequests.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { wait } from '@testing-library/react'; -import { OAuthPendingRequests } from './OAuthPendingRequests'; -import { BasicOAuthScopes } from './BasicOAuthScopes'; - -describe('OAuthPendingRequests', () => { - it('notifies new observers about current state', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(); - const error = jest.fn(); - - const input = BasicOAuthScopes.from('a b'); - target.pending().subscribe({ next, error }); - target.request(input); - - await wait(() => expect(next).toBeCalledTimes(2)); - expect(next.mock.calls[0][0].scopes).toBeUndefined(); - expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString()); - expect(error.mock.calls.length).toBe(0); - }); - - it('resolves requests and notifies observers', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(); - const error = jest.fn(); - - const request1 = target.request(BasicOAuthScopes.from('a')); - const request2 = target.request(BasicOAuthScopes.from('a')); - target.pending().subscribe({ next, error }); - target.resolve(BasicOAuthScopes.from('a'), 'session1'); - target.resolve(BasicOAuthScopes.from('a'), 'session2'); - - await expect(request1).resolves.toBe('session1'); - await expect(request2).resolves.toBe('session1'); - expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve - expect(error).toBeCalledTimes(0); - }); - - it('can resolve through the observable', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(pendingRequest => pendingRequest.resolve('done')); - const error = jest.fn(); - - const request1 = target.request(BasicOAuthScopes.from('a')); - target.pending().subscribe({ next, error }); - - await expect(request1).resolves.toBe('done'); - expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution - expect(error).toBeCalledTimes(0); - }); - - it('rejects requests and notifies observers only once', async () => { - const target = new OAuthPendingRequests(); - const next = jest.fn(); - const error = jest.fn(); - const rejection = new Error('eek'); - - const request1 = target.request(BasicOAuthScopes.from('a')); - const request2 = target.request(BasicOAuthScopes.from('a')); - target.pending().subscribe({ next, error }); - target.reject(rejection); - target.resolve(BasicOAuthScopes.from('a'), 'session'); - - await expect(request1).rejects.toBe(rejection); - await expect(request2).rejects.toBe(rejection); - expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve - expect(error).toBeCalledTimes(0); - }); - - it('can reject through the observable', async () => { - const target = new OAuthPendingRequests(); - const rejection = new Error('nope'); - const next = jest.fn(pendingRequest => pendingRequest.reject(rejection)); - const error = jest.fn(); - - const request1 = target.request(BasicOAuthScopes.from('a')); - target.pending().subscribe({ next, error }); - - await expect(request1).rejects.toBe(rejection); - expect(next).toBeCalledTimes(2); - }); -}); diff --git a/frontend/packages/shared/apis/oauth/OAuthPendingRequests.ts b/frontend/packages/shared/apis/oauth/OAuthPendingRequests.ts deleted file mode 100644 index 493819a2eb..0000000000 --- a/frontend/packages/shared/apis/oauth/OAuthPendingRequests.ts +++ /dev/null @@ -1,88 +0,0 @@ -import Observable from 'zen-observable'; -import { OAuthScopes } from './types'; - -type RequestQueueEntry = { - scopes: OAuthScopes; - resolve: (value?: ResultType | PromiseLike | undefined) => void; - reject: (reason: Error) => void; -}; - -export type PendingRequest = { - scopes: OAuthScopes | undefined; - resolve: (value: ResultType) => void; - reject: (reason: Error) => void; -}; - -export type OAuthPendingRequestsApi = { - request(scopes: OAuthScopes): Promise; - resolve(scopes: OAuthScopes, result: ResultType): void; - reject(error: Error): void; - pending(): Observable>; -}; - -export class OAuthPendingRequests implements OAuthPendingRequestsApi { - private requests: RequestQueueEntry[] = []; - private listeners: ZenObservable.SubscriptionObserver>[] = []; - - request(scopes: OAuthScopes): Promise { - return new Promise((resolve, reject) => { - this.requests.push({ scopes, resolve, reject }); - - const pending = this.getCurrentPending(); - this.listeners.forEach(listener => listener.next(pending)); - }); - } - - resolve(scopes: OAuthScopes, result: ResultType): void { - this.requests = this.requests.filter(request => { - if (scopes.hasScopes(request.scopes)) { - request.resolve(result); - return false; - } else { - return true; - } - }); - - const pending = this.getCurrentPending(); - this.listeners.forEach(listener => listener.next(pending)); - } - - reject(error: Error) { - this.requests.forEach(request => request.reject(error)); - this.requests = []; - - const pending = this.getCurrentPending(); - this.listeners.forEach(listener => listener.next(pending)); - } - - pending(): Observable> { - return new Observable(subscriber => { - this.listeners.push(subscriber); - subscriber.next(this.getCurrentPending()); - return () => { - this.listeners = this.listeners.filter(l => l !== subscriber); - }; - }); - } - - private getCurrentPending(): PendingRequest { - const currentScopes = - this.requests.length === 0 - ? undefined - : this.requests.slice(1).reduce((acc, current) => acc.extend(current.scopes), this.requests[0].scopes); - - return { - scopes: currentScopes, - resolve: (value: ResultType) => { - if (currentScopes) { - this.resolve(currentScopes, value); - } - }, - reject: (reason: Error) => { - if (currentScopes) { - this.reject(reason); - } - }, - }; - } -} diff --git a/frontend/packages/shared/apis/oauth/types.ts b/frontend/packages/shared/apis/oauth/types.ts deleted file mode 100644 index 6cd855e1f6..0000000000 --- a/frontend/packages/shared/apis/oauth/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type OAuthScopes = { - extend(scopes: OAuthScopeLike): OAuthScopes; - hasScopes(scopes: OAuthScopeLike): boolean; - toSet(): Set; - toString(): string; -}; - -export type OAuthScopeLike = - | string /** Space separated scope strings */ - | string[] /** Array of individual scope strings */ - | OAuthScopes; diff --git a/frontend/packages/shared/apis/search/ComponentDefinitions.tsx b/frontend/packages/shared/apis/search/ComponentDefinitions.tsx deleted file mode 100644 index 2eeb4e0238..0000000000 --- a/frontend/packages/shared/apis/search/ComponentDefinitions.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import React from 'react'; -import { - AppIcon, - AppFeatureIcon, - CdnIcon, - DataEndpointsIcon, - GcpProjectIcon, - GroupIcon, - LibraryIcon, - OtherIcon, - ServiceIcon, - SystemIcon, - TechDocsIcon, - UserIcon, - WebsiteIcon, - WorkflowIcon, - SdkIcon, - MachineLearningIcon, -} from 'shared/icons'; -import Link from 'shared/components/Link'; -import { theme } from 'core/app/PageThemeProvider'; -import FeatureFlags from '../featureFlags/featureFlags'; - -// The type that needs to be declared for every component type below. -// Indexable will be one of the indexable types below. -type SearchTypeDefinition = { - title: string; - icon: JSX.Element; - theme?: keyof typeof theme; - shorthands: string[]; - buildUrl(indexable: I): string; - buildTitle(indexable: I): string; - buildSubtitle(indexable: I): string; -}; - -// This is the search result item type for generic component-like search results. -type ComponentResult = { - id: string; - description: string; - componentType: string; -}; - -// These are custom types for other indexed resources, keyed by componentType -type ResultTypes = { - user: { - id: string; - fullName: string; - componentType: 'user'; - }; - 'tech-doc': { - title: string; - location: string; - componentId: string; - componentType: 'tech-doc'; - }; - 'ml-project': { - id: string; - name: string; - description: string; - componentType: 'ml-project'; - }; -}; - -// Helps infer the type of all definitions below. -// The result is an object type where the key is a union of component type strings -// and the value is the type definition for that component type. -type Definitions = { - [key in T]: SearchTypeDefinition; -}; - -// Typescript needs a function to be able to infer the type of each definition -const inferDefinitions = (definitions: Definitions) => definitions; - -export const componentTypes = inferDefinitions({ - // TechDocs have a custom search results page, so this is only for SearchBox - 'tech-doc': { - title: 'Documentation', - icon: , - theme: 'documentation', - shorthands: ['doc', 'docs'], - buildUrl: ({ location }) => `/docs/${location}`, - buildTitle: ({ title, componentId }) => `${componentId} - ${title}`, - buildSubtitle: ({ location }) => `docs/${location}`, - }, - service: { - title: 'Services', - icon: , - shorthands: ['srv'], - buildUrl: ({ id }) => `/services/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - dataset: { - title: 'Data Endpoints', - icon: , - theme: 'endpoint', - shorthands: ['data'], - buildUrl: ({ id }) => `/data-endpoints/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - workflow: { - title: 'Workflows', - icon: , - shorthands: ['work'], - buildUrl: ({ id }) => `/workflows/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - app: { - title: 'Apps', - icon: , - theme: 'app', - shorthands: ['app'], - buildUrl: ({ id }) => `/apps/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - 'app-feature': { - title: 'App Features', - icon: , - theme: 'appFeature', - shorthands: ['feat'], - buildUrl: ({ id }) => `/app-features/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - website: { - title: 'Websites', - icon: , - shorthands: ['web'], - buildUrl: ({ id }) => `/websites/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - library: { - title: 'Libraries', - icon: , - shorthands: ['lib'], - buildUrl: ({ id }) => `/libraries/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - 'client-sdk': { - title: 'Client SDK', - icon: , - shorthands: ['sdk'], - buildUrl: ({ id }) => `/client-sdks/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - 'gcp-project': { - title: 'GCP Projects', - icon: , - theme: 'project', - shorthands: ['proj'], - buildUrl: ({ id }) => `/projects/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - system: { - title: 'Systems', - icon: , - shorthands: ['sys'], - buildUrl: ({ id }) => `/system/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - squad: { - title: 'Groups & Squads', - icon: , - theme: 'org', - shorthands: ['group', 'squad'], - buildUrl: ({ id }) => `/org/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - user: { - title: 'Users', - icon: , - theme: 'tool', - shorthands: ['user'], - buildUrl: ({ id }) => `/groups/users/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ fullName }) => fullName, - }, - other: { - title: 'Other', - icon: , - shorthands: ['other'], - buildUrl: ({ id }) => `/components/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - cdn: { - title: 'CDN', - icon: , - shorthands: ['cdn'], - buildUrl: ({ id }) => `/components/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, - buildSubtitle: ({ description }) => description, - }, - 'ml-project': { - title: 'ML Projects', - icon: , - shorthands: ['ml'], - buildUrl: ({ id }) => `/machine-learning/projects/${encodeURIComponent(id)}`, - buildTitle: ({ name }) => name, - buildSubtitle: ({ description }) => description, - }, - 'sciencebox-project': { - title: 'ScienceBox Projects', - icon: , // We don't have a ScienceBox icon yet. - shorthands: ['sb'], - buildUrl: ({ id }) => `/sciencebox-projects/${encodeURIComponent(id)}`, - buildTitle: ({ id }) => id, // ScienceBox ids are human-readable. - buildSubtitle: ({ description }) => description, - }, -}); - -if (!FeatureFlags.getItem('machine-learning')) { - delete componentTypes['ml-project']; -} - -export function ComponentTypeLink({ - componentType, - id, -}: { - componentType: Exclude; - id: string; -}) { - const comp = componentTypes[componentType] !== null ? componentTypes[componentType] : componentTypes['other']; - return {id}; -} diff --git a/frontend/packages/shared/apis/search/ESQueryBuilder.js b/frontend/packages/shared/apis/search/ESQueryBuilder.js deleted file mode 100644 index 4b2552d6c9..0000000000 --- a/frontend/packages/shared/apis/search/ESQueryBuilder.js +++ /dev/null @@ -1,203 +0,0 @@ -import bodybuilder from 'bodybuilder'; -import { pruneFilterObject } from 'plugins/searchPage/components/FacetsFilter'; - -export const DEFAULT_PAGE_SIZE = 30; -const COMPONENT_TYPE_AGG_SIZE = 100; -const AGG_SIZE = 300; - -const getFieldWithDataType = name => - ['tc4dLevel', 'isGolden', 'isTestRun'].indexOf(name) > -1 ? name : `${name}.keyword`; - -const applyFilters = (query, filters = {}) => { - Object.keys(filters).forEach(filterSetId => { - const filterValues = Object.keys(filters[filterSetId]); - if (filterValues.length) { - query.filter('terms', getFieldWithDataType(filterSetId), filterValues); - if (filterValues.options) { - applyFilters(query, filterValues.options); - } - } - }); - return query; -}; - -export const createFilteredQuery = (filters = {}) => { - const search = bodybuilder(); - - if (Object.keys(filters).length) { - search.aggregation('filter', 'componentType.keyword', a => { - applyFilters(a, filters); - a.aggregation('terms', 'componentType.keyword', { size: COMPONENT_TYPE_AGG_SIZE }, 'metadata'); - return a; - }); - } else { - search.aggregation('terms', 'componentType.keyword', { size: COMPONENT_TYPE_AGG_SIZE }, 'metadata'); - } - - return search; -}; - -export const constructDefaultQuery = (term, filters = {}) => { - const search = createFilteredQuery(filters); - - if (!term) { - return search.query('match_all'); - } - - return ( - search - .query('multi_match', { - query: term, - fields: ['_all', 'id^2', 'id.raw^100', 'id.ngram_raw^2', 'tags.raw^5', 'tags.ngram_raw^2'], - }) - .orQuery('match', 'lifecycle', { query: 'production', boost: 3 }) - .orQuery('match', 'tc4dLevel', { query: '3', boost: 4 }) - .orQuery('match', 'tc4dLevel', { query: '2', boost: 3 }) - .orQuery('match', 'tc4dLevel', { query: '1', boost: 2 }) - .orQuery('match', 'isGolden', { query: 'true', boost: 1 }) - .orQuery('match', 'resourceId', { query: term, boost: 1 }) - .notFilter('term', 'componentType.keyword', 'data-endpoint') - // The index boost for search-partnership must be wildcarded, if an exact name is provided Elasticsearch throws - // an error when the index doesn't exist (in tests, but this also seems like good safety for production). - .rawOption('indices_boost', [{ 'search-partnershi*': 10 }]) - ); -}; - -export const constructDocsQuery = (term, filters = {}) => { - const search = createFilteredQuery(filters); - - if (!term) { - return search.query('match_all'); - } - - /* If a search term includes double quotes we would like to give exact matches for that. - For example "Find horse ever bus" should return a empty search result list. - 1. Words should be included in any docs that shows up in search result. - 2. Words should have the exact same sequence in the document as in the search term to be a valid search result. - (as in Googles exact match search) */ - // TODO: Boosting for exact match - - if (term.match(/"[^"]*"/)) { - return search.rawOption('query', { - query_string: { - query: term, - }, - }); - } - - return search.rawOption('query', { - function_score: { - query: { - multi_match: { - query: term, - fields: [ - '_all', - 'componentId^6', - 'text^7', - 'title^2', - 'title.ngram^1', - 'location^10', - 'id^2', - 'id.raw^100', - 'id.ngram_raw^2', - 'tags.raw^5', - 'tags.ngram_raw^2', - ], - }, - }, - functions: [ - { - field_value_factor: { - field: 'hitsLast30Days', - factor: 1.0, - modifier: 'sqrt', - missing: 1, - }, - }, - ], - boost: '5', - boost_mode: 'sum', - score_mode: 'multiply', - }, - }); -}; - -// Construct elastic search query from term and filters -export const constructQuery = ({ - aggFields = [], - componentType, - filters = {}, - pagination = { pageIndex: 0, size: DEFAULT_PAGE_SIZE }, - sort = {}, - query: term = '', -}) => { - const prunedFilters = pruneFilterObject(filters); - const hasFilters = !!Object.keys(prunedFilters).length; - let query; - - // TODO: get rid of this piece of tech dept, platformize search - if (componentType === 'tech-doc') { - query = constructDocsQuery(term, prunedFilters); - } else { - query = constructDefaultQuery(term, prunedFilters); - } - - // Apply pagination - query.from(pagination.pageIndex * Number(pagination.size)).size(Number(pagination.size)); - - if (sort.column && sort.column !== 'false') { - query.sort([ - { - // tc4d value needs to default to 0 if it is missing for sorting to work - [getFieldWithDataType(sort.column)]: { - order: sort.direction, - ...(sort.column === 'tc4dLevel' ? { missing: 0 } : {}), - }, - }, - ]); - } - - // Apply selected filters and component type filter as a post_filter so that aggregation counts - // are not affected (for example, filtering on lifecycle 'production' should NOT then show that there are 0 components - // with lifecycle 'experimental', as it would if this was not a post_filter). - let postFilters = bodybuilder(); - if (hasFilters) { - postFilters = applyFilters(postFilters, prunedFilters); - } - if (componentType) { - postFilters.filter('term', 'componentType.keyword', componentType); - } - query.rawOption('post_filter', postFilters.getFilter()); - - // Add aggregations for each filterable field, to get counts for the terms in that field; the aggregation should have - // active filters applied EXCEPT its own, so that other filters affect the counts but selecting the filter doesn't - // drop the counts for other values of the same filter to zero. - if (aggFields.length) { - aggFields.forEach(agg => { - const otherFilters = Object.keys(prunedFilters).reduce((acc, key) => { - if (key !== agg && Object.keys(prunedFilters[key]).length) { - acc[key] = prunedFilters[key]; - } - return acc; - }, {}); - - if (Object.keys(otherFilters).length || componentType) { - query.aggregation('filter', getFieldWithDataType(agg), a => { - applyFilters(a, otherFilters); - a.filter('term', 'componentType.keyword', componentType); - - // Component type filter is standalone and NOT included in the filter object - if (componentType) { - a.aggregation('terms', getFieldWithDataType(agg), { size: AGG_SIZE }); - } - - return a; - }); - } else { - query.aggregation('terms', getFieldWithDataType(agg), { size: AGG_SIZE }); - } - }); - } - - return JSON.stringify(query.build()); -}; diff --git a/frontend/packages/shared/apis/search/ESQueryBuilder.test.js b/frontend/packages/shared/apis/search/ESQueryBuilder.test.js deleted file mode 100644 index c14fa77429..0000000000 --- a/frontend/packages/shared/apis/search/ESQueryBuilder.test.js +++ /dev/null @@ -1,52 +0,0 @@ -import { constructDefaultQuery } from './ESQueryBuilder'; -import axios from 'axios'; -import { urls } from 'shared/apis/baseUrls'; - -async function doEsQuery(term) { - const query = constructDefaultQuery(term); - const esQuery = ` - query ElasticSearch($query: String!) { - elasticSearch(esQuery: $query) { - data { - ... on Indexable { - id - componentType - } - } - } - } - `; - - let error; - for (let attempts = 0; attempts < 3; attempts++) { - try { - const res = await axios.post(`${urls.proxy}/api/backend/graphql`, { - operationName: 'ElasticSearch', - query: esQuery, - variables: { - query: JSON.stringify(query.build()), - }, - }); - - return res.data.data.elasticSearch.data; - } catch (e) { - error = e; - } - } - throw error; -} - -describe('ESQueryBuilder', () => { - // TODO: Too flaky, move to script / external service - it.skip.each([ - 'sysmodel', - 'backstage-lb', - 'backstage-backend', - 'backstage-frontend', - 'backstage-e2e-test-data', - 'backstage-e2e-test-data.TestData', - ])('find exact match for term - %s', async queryTerm => { - const result = await doEsQuery(queryTerm); - expect(result[0].id).toBe(queryTerm); - }); -}); diff --git a/frontend/packages/shared/apis/search/searchApi.js b/frontend/packages/shared/apis/search/searchApi.js deleted file mode 100644 index b4ed192e6b..0000000000 --- a/frontend/packages/shared/apis/search/searchApi.js +++ /dev/null @@ -1,88 +0,0 @@ -import { graphqlRequest } from 'shared/apis/backstage/graphqlClient'; -import gql from 'graphql-tag'; - -const defaultExtraFields = ` - ... on Component { - description - lifecycle - owner { - id - name - type - } - } - ... on TechDoc { - componentId - location - title - text - hitsLast30Days - } - ... on Dataset { - lifecycle - sysmodelComponentId - storageType - isGolden - dataFormat - publishFrequency - accessPolicy - tc4dLevel - description - isTestRun - owner { - id - name - type - } - } - ... on Workflow { - lifecycle - owner { - id - name - type - } - } - ... on GoogleCloudPlatformProject { - owner { - id - name - type - } - } - ... on MLProject { - name - description - }`; - -export function esSearch(filter, options = {}) { - const { extraFields = defaultExtraFields } = options; - - const query = gql` - query ElasticSearch($query: String!) { - elasticSearch(esQuery: $query) { - data { - __typename - ... on Indexable { - id - componentType - } - ${extraFields} - } - aggregations { - name - buckets { - key - docCount - } - } - } - } - `; - - return graphqlRequest(query, { - query: filter.query, - }); -} - -export default { esSearch }; diff --git a/frontend/packages/shared/apis/settings/FirestoreSettingsStore.test.ts b/frontend/packages/shared/apis/settings/FirestoreSettingsStore.test.ts deleted file mode 100644 index 0e0f5ef2ab..0000000000 --- a/frontend/packages/shared/apis/settings/FirestoreSettingsStore.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import FirestoreSettingsStore from './FirestoreSettingsStore'; -import MockFirestoreStorage from 'shared/apis/firestore/MockFirestoreStorage'; - -describe('FirestoreSettingsStore', () => { - it('should be created', async () => { - const storage = new MockFirestoreStorage(); - const store = new FirestoreSettingsStore(storage); - expect(store).toBeDefined(); - }); - - it('should forward value to storage', async () => { - const storage = new MockFirestoreStorage(); - const store = new FirestoreSettingsStore(storage); - await expect(storage.get('/')).resolves.toMatchObject({ exists: false }); - await store.set('my.setting', 3); - await expect(storage.get('/')).resolves.toEqual({ - exists: true, - data: { - settings: { 'my.setting': 3 }, - }, - path: '/', - id: 'me', - }); - }); - - it('should not forward unchanged values', async () => { - const storage = new MockFirestoreStorage(); - storage.set('/', { settings: { 'my.setting': 3 } }); - jest.spyOn(storage, 'set'); - - const store = new FirestoreSettingsStore(storage); - expect(storage.set).not.toHaveBeenCalled(); - await store.set('my.setting', 3); - expect(storage.set).not.toHaveBeenCalled(); - }); - - it('should subscribe to changes', async () => { - const storage = new MockFirestoreStorage(); - const store = new FirestoreSettingsStore(storage); - - const subscribeFn1 = jest.fn(); - const unsubscribe1 = store.subscribe('my.setting', subscribeFn1); - const subscribeFn2 = jest.fn(); - const unsubscribe2 = store.subscribe('my.missingSetting', subscribeFn2); - - expect(subscribeFn1).not.toHaveBeenCalled(); - expect(subscribeFn2).not.toHaveBeenCalled(); - - await storage.merge('/', { settings: { 'my.setting': 4 } }); - expect(subscribeFn1).toHaveBeenCalledTimes(2); - expect(subscribeFn1).toHaveBeenLastCalledWith(4); - expect(subscribeFn2).toHaveBeenCalledTimes(1); - expect(subscribeFn2).toHaveBeenLastCalledWith(undefined); - - // unchanged values should be ignored - await storage.merge('/', { settings: { 'my.setting': 4 } }); - expect(subscribeFn1).toHaveBeenCalledTimes(2); - expect(subscribeFn2).toHaveBeenCalledTimes(1); - - // unchanged values should be ignored - await storage.merge('/', { settings: { 'my.setting': 5 } }); - expect(subscribeFn1).toHaveBeenCalledTimes(3); - expect(subscribeFn2).toHaveBeenCalledTimes(1); - - unsubscribe1(); - unsubscribe2(); - - await storage.merge('/', { settings: { 'my.setting': 6 } }); - expect(subscribeFn1).toHaveBeenCalledTimes(3); - expect(subscribeFn2).toHaveBeenCalledTimes(1); - }); -}); diff --git a/frontend/packages/shared/apis/settings/FirestoreSettingsStore.ts b/frontend/packages/shared/apis/settings/FirestoreSettingsStore.ts deleted file mode 100644 index 43f0b13748..0000000000 --- a/frontend/packages/shared/apis/settings/FirestoreSettingsStore.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { SettingsStore, SettingValue, SettingListener, Unsubscribe, SettingsData } from './types'; -import { FirestoreApi, JsonObject } from 'shared/apis/firestore'; - -export default class FirestoreSettingsStore implements SettingsStore { - private api: FirestoreApi; - private data: SettingsData | undefined; - private listeners: Map> = new Map(); - - constructor(api: FirestoreApi) { - this.api = api; - this.start(); - } - - private start() { - this.api.observe<{ settings: SettingsData }>('/').forEach(snapshot => { - const data = snapshot.data?.settings; - this.data = data; - - this.listeners.forEach((listenerSet, id) => { - listenerSet.forEach(listener => { - listener(data && data[id]); - }); - }); - }); - } - - async set(id: string, value: T): Promise { - const snapshot = await this.api.get('/'); - const data = snapshot.data?.settings || {}; - const settings = data as JsonObject; - if (settings[id] === value) { - return; - } - - settings[id] = value; - await this.api.merge('/', { settings }); - } - - subscribe(id: string, listener: SettingListener): Unsubscribe { - let currentValue = this.data && (this.data[id] as T); - let calledListenerOnce = false; - - if (this.data) { - listener(currentValue); - calledListenerOnce = true; - } - - const internalListener = (newValue: T) => { - if (newValue !== currentValue || !calledListenerOnce) { - currentValue = newValue; - listener(newValue); - calledListenerOnce = true; - } - }; - - const listeners = this.listeners.get(id) || new Set(); - this.listeners.set(id, listeners); - listeners.add(internalListener as SettingListener); - - return () => { - listeners.delete(internalListener as SettingListener); - }; - } -} diff --git a/frontend/packages/shared/apis/settings/MemorySettingsStore.ts b/frontend/packages/shared/apis/settings/MemorySettingsStore.ts deleted file mode 100644 index 0c38dfe966..0000000000 --- a/frontend/packages/shared/apis/settings/MemorySettingsStore.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SettingListener, Unsubscribe, SettingValue, SettingsData, SettingsStore } from './types'; - -export default class MemorySettingsStore implements SettingsStore { - private data: SettingsData; - private listeners: Map> = new Map(); - - constructor(data: SettingsData = {}) { - this.data = data; - } - - async set(id: string, value: T): Promise { - this.data[id] = value; - - const listeners = this.listeners.get(id); - - Promise.resolve().then(() => { - (listeners as Set).forEach(listener => { - listener(value); - }); - }); - } - - subscribe(id: string, listener: SettingListener): Unsubscribe { - let listeners = this.listeners.get(id) || new Set(); - this.listeners.set(id, listeners); - listeners.add(listener as SettingListener); - - Promise.resolve().then(() => { - listener(this.data[id] as T); - }); - - return () => { - listeners.delete(listener as SettingListener); - }; - } -} diff --git a/frontend/packages/shared/apis/settings/Setting.test.ts b/frontend/packages/shared/apis/settings/Setting.test.ts deleted file mode 100644 index cb1fbf1a3a..0000000000 --- a/frontend/packages/shared/apis/settings/Setting.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import Setting from './Setting'; -import { SettingsStore } from './types'; - -describe('Setting', () => { - it('should be created', async () => { - const setting = new Setting('my.id', 3 as number, () => undefined); - expect(setting.id).toBe('my.id'); - expect(setting.defaultValue).toBe(3); - - expect(() => { - setting.subscribe(() => {}); - }).toThrowError('no settings store available'); - - await expect(setting.set(4)).rejects.toThrowError('no settings store available'); - }); - - it('should be accessible directly', async () => { - const mockStore: SettingsStore = { - set: jest.fn(), - subscribe: jest.fn(), - }; - const setting = new Setting('my.id', 3 as number, () => mockStore); - - const listener = () => {}; - setting.subscribe(listener); - setting.set(4); - - expect(mockStore.set).toHaveBeenCalledWith('my.id', 4); - expect(mockStore.subscribe).toHaveBeenCalledWith('my.id', listener); - }); -}); diff --git a/frontend/packages/shared/apis/settings/Setting.ts b/frontend/packages/shared/apis/settings/Setting.ts deleted file mode 100644 index 2ea881804f..0000000000 --- a/frontend/packages/shared/apis/settings/Setting.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { SettingValue, SettingListener, SettingsStore } from './types'; - -export default class Setting { - readonly id: string; - readonly defaultValue: T; - - private readonly storeAccessor: () => SettingsStore | undefined; - - constructor(id: string, defaultValue: T, storeAccessor: () => SettingsStore | undefined) { - this.id = id; - this.defaultValue = defaultValue; - this.storeAccessor = storeAccessor; - } - - async set(value: T): Promise { - const store = this.storeAccessor(); - if (!store) { - throw new Error('no settings store available'); - } - return store.set(this.id, value); - } - - subscribe(listener: SettingListener) { - const store = this.storeAccessor(); - if (!store) { - throw new Error('no settings store available'); - } - return store.subscribe(this.id, listener); - } -} diff --git a/frontend/packages/shared/apis/settings/SettingCollection.test.ts b/frontend/packages/shared/apis/settings/SettingCollection.test.ts deleted file mode 100644 index e6836bd342..0000000000 --- a/frontend/packages/shared/apis/settings/SettingCollection.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import SettingCollection from './SettingCollection'; -import { SettingsStore } from './types'; - -describe('SettingCollection', () => { - it('should be created', async () => { - const collection = new SettingCollection(); - expect(collection.bind()).toBeDefined(); - }); - - it('should register settings', async () => { - const collection = new SettingCollection(); - - const mySetting = collection.register({ id: 'my.setting', defaultValue: 4 }); - mySetting.set(5).catch(() => {}); // make sure any number is allowed - - expect(() => { - collection.register({ id: 'my.setting', defaultValue: 2 }); - }).toThrowError('already exists'); - - const myOtherSetting = collection.register({ id: 'my.other.setting', defaultValue: false }); - myOtherSetting.set(true).catch(() => {}); // make sure type allows all boolean values - }); - - it('should bind a store', async () => { - const mockStore: SettingsStore = { - set: jest.fn(), - subscribe: jest.fn(), - }; - - const collection = new SettingCollection(); - - const mySetting = collection.register({ id: 'my.setting', defaultValue: 'initial-value' }); - - collection.bind()(mockStore); - - await mySetting.set('other-value'); - - expect(mockStore.set).toHaveBeenCalledWith('my.setting', 'other-value'); - }); -}); diff --git a/frontend/packages/shared/apis/settings/SettingCollection.tsx b/frontend/packages/shared/apis/settings/SettingCollection.tsx deleted file mode 100644 index bad0a35194..0000000000 --- a/frontend/packages/shared/apis/settings/SettingCollection.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { FC } from 'react'; -import { SettingConfig, SettingValue, SettingsStore, SettingsStoreContext } from './types'; -import Setting from './Setting'; - -export default class SettingCollection { - private readonly settings: Map> = new Map(); - private store: SettingsStore | undefined; - - register(config: SettingConfig): Setting; - register(config: SettingConfig): Setting; - register(config: SettingConfig): Setting; - register(config: SettingConfig): Setting { - if (this.settings.has(config.id)) { - throw new Error(`Setting '${config.id}' already exists`); - } - const setting = new Setting(config.id, config.defaultValue, () => this.store); - this.settings.set(config.id, setting); - return setting; - } - - // Creates a function that is used to bind this settings collection to a settings store, - // and returns a context provider for that store. - bind(): (store: SettingsStore) => FC<{}> { - return store => { - this.store = store; - - return ({ children }) => ; - }; - } -} diff --git a/frontend/packages/shared/apis/settings/types.ts b/frontend/packages/shared/apis/settings/types.ts deleted file mode 100644 index 701edd07b6..0000000000 --- a/frontend/packages/shared/apis/settings/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext } from 'react'; - -export type SettingValue = boolean | number | string; -export type SettingsData = { [id: string]: SettingValue }; -export type SettingSetFunc = (value: T) => Promise; -export type SettingListener = (value: T | undefined) => void; -export type Unsubscribe = () => void; - -export type SettingsStore = { - set(id: string, value: T): Promise; - subscribe(id: string, listener: SettingListener): Unsubscribe; -}; - -export type SettingConfig = { - id: string; - defaultValue: T; -}; - -export const SettingsStoreContext = createContext(undefined); diff --git a/frontend/packages/shared/apis/settings/useSetting.test.tsx b/frontend/packages/shared/apis/settings/useSetting.test.tsx deleted file mode 100644 index 450a7f9e79..0000000000 --- a/frontend/packages/shared/apis/settings/useSetting.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { FC } from 'react'; -import useSetting from './useSetting'; -import SettingCollection from './SettingCollection'; -import { SettingsStore, SettingValue } from './types'; -import Setting from './Setting'; -import { render } from '@testing-library/react'; -import MemorySettingsStore from './MemorySettingsStore'; -import { renderWithEffects } from 'testUtils'; - -type TestProps = { - setting: Setting; - mock: (ret: ReturnType) => void; -}; - -const TestComponent: FC = ({ setting, mock }) => { - mock(useSetting(setting)); - return null; -}; - -describe('useSetting', () => { - it('should return default value', async () => { - const collection = new SettingCollection(); - - const mySetting = collection.register({ id: 'my.setting', defaultValue: 4 }); - - const mock = jest.fn(); - render(); - - expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith([4, undefined, true]); - }); - - it('should interact with a store', async () => { - const mockStore: SettingsStore = { - set: jest.fn(), - subscribe: jest.fn(), - }; - - const collection = new SettingCollection(); - const mySetting = collection.register({ id: 'my.setting', defaultValue: 6 }); - const Provider = collection.bind()(mockStore); - - const retFn = jest.fn(); - render( - - - , - ); - - expect(retFn).toHaveBeenCalledTimes(1); - expect(mockStore.subscribe).toHaveBeenCalledTimes(1); - expect(retFn).toHaveBeenNthCalledWith(1, [6, expect.any(Function), true]); - - const listener = (mockStore.subscribe as jest.Mock).mock.calls[0][1]; - - listener(7); - expect(retFn).toHaveBeenCalledTimes(2); - expect(retFn).toHaveBeenLastCalledWith([7, expect.any(Function), false]); - - listener(8); - expect(retFn).toHaveBeenCalledTimes(3); - expect(retFn).toHaveBeenLastCalledWith([8, expect.any(Function), false]); - - listener(undefined); - expect(retFn).toHaveBeenCalledTimes(4); - expect(retFn).toHaveBeenLastCalledWith([6, expect.any(Function), false]); - - const setValue = retFn.mock.calls[0][0][1]; - expect(mockStore.set).not.toHaveBeenCalled(); - setValue(9); - expect(mockStore.set).toHaveBeenCalledWith('my.setting', 9); - }); - - it('should interact with memory store', async () => { - const store = new MemorySettingsStore(); - const collection = new SettingCollection(); - const mySetting = collection.register({ id: 'my.setting', defaultValue: 6 }); - const Provider = collection.bind()(store); - - const retFn = jest.fn(); - await renderWithEffects( - - - , - ); - - expect(retFn).toHaveBeenCalledTimes(2); - expect(retFn).toHaveBeenNthCalledWith(1, [6, expect.any(Function), true]); - expect(retFn).toHaveBeenNthCalledWith(2, [6, expect.any(Function), false]); - - await store.set('my.setting', 5); - - expect(retFn).toHaveBeenCalledTimes(3); - expect(retFn).toHaveBeenLastCalledWith([5, expect.any(Function), false]); - - const listener = jest.fn(); - const unsubscribe = store.subscribe('my.setting', listener); - await Promise.resolve(); - expect(listener).toHaveBeenCalledWith(5); - - const setValue = retFn.mock.calls[0][0][1]; - await setValue(4); - - expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenCalledWith(4); - expect(retFn).toHaveBeenCalledTimes(4); - expect(retFn).toHaveBeenLastCalledWith([4, expect.any(Function), false]); - - unsubscribe(); - - await setValue(3); - expect(listener).toHaveBeenCalledTimes(2); - expect(retFn).toHaveBeenCalledTimes(5); - expect(retFn).toHaveBeenLastCalledWith([3, expect.any(Function), false]); - }); -}); diff --git a/frontend/packages/shared/apis/settings/useSetting.ts b/frontend/packages/shared/apis/settings/useSetting.ts deleted file mode 100644 index d39350d137..0000000000 --- a/frontend/packages/shared/apis/settings/useSetting.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useContext, useEffect, useReducer } from 'react'; -import { SettingValue, SettingSetFunc, SettingsStoreContext } from './types'; -import Setting from './Setting'; - -type State = { - loading: boolean; - value: T | undefined; -}; - -type Action = { type: 'CLEAR' } | { type: 'SET'; value: T | undefined }; - -const initialState = { loading: true, value: undefined }; - -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'CLEAR': - if (state.value === undefined) { - return state; - } - return { ...state, value: undefined }; - case 'SET': - return { loading: false, value: action.value }; - default: - return state; - } -} - -const useSetting = ( - setting: Setting, -): [T, SettingSetFunc, boolean] | [T, undefined, boolean] => { - const settingStore = useContext(SettingsStoreContext); - const [state, dispatch] = useReducer<(state: State, action: Action) => State>(reducer, initialState); - - useEffect(() => { - if (!settingStore) { - dispatch({ type: 'CLEAR' }); - return; - } - - return settingStore.subscribe(setting.id, (value: T | undefined) => { - dispatch({ type: 'SET', value }); - }); - }, [setting, settingStore]); - - const returnedValue = state.value === undefined ? setting.defaultValue : state.value; - - if (settingStore) { - const setValue: SettingSetFunc = async newValue => { - await settingStore.set(setting.id, newValue); - }; - - return [returnedValue, setValue, state.loading]; - } - - return [returnedValue, undefined, state.loading]; -}; - -export default useSetting; diff --git a/frontend/packages/shared/apis/snackbar/GlobalSnackbar.js b/frontend/packages/shared/apis/snackbar/GlobalSnackbar.js deleted file mode 100644 index eac8449d1f..0000000000 --- a/frontend/packages/shared/apis/snackbar/GlobalSnackbar.js +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; -import CloseIcon from '@material-ui/icons/Close'; -import { Button, IconButton, Snackbar } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import { connect } from 'react-redux'; - -import { errorLogOpen } from 'shared/apis/errorLog/actions'; -import { snackbarClose } from './actions'; - -import { - StatusError as ErrorIcon, - StatusOK as SuccessIcon, - StatusWarning as WarningIcon, -} from 'shared/components/Status'; - -const SNACKBAR_TYPES = { - default: 'default', - info: 'info', - success: 'success', - error: 'error', - warning: 'warning', -}; - -const SNACKBAR_ICONS = { - default: '', - info: '', - success: , - error: , - warning: , -}; - -const useMessageContainerStyles = makeStyles(theme => ({ - message: { - '& a': { - color: theme.palette.link, - }, - }, - icon: { - display: 'inline-block', - verticalAlign: 'middle', - marginRight: '5px', - }, - error: { - color: theme.palette.status.error, - }, - success: { - color: theme.palette.status.ok, - }, - warning: { - color: theme.palette.status.warning, - }, -})); - -const MessageContainer = ({ message, type }) => { - const classes = useMessageContainerStyles(); - const messageIcon = SNACKBAR_ICONS[type] || ''; - const messageIconStyle = classes[type] || ''; - - // TODO: use react nodes in messages with instead - const styledMessage = - typeof message !== 'string' - ? message - : message - .split(/(.*?)<\/strong>/) - .map((part, index) => (index % 2 ? {part} : part)); - - return ( - <> - {messageIcon && {messageIcon}} - {styledMessage} - - ); -}; - -const useStyles = makeStyles({ - anchorOriginTopCenter: { - top: '73px', - }, -}); - -/** - * Global snack bar. There can only be one instance of this shown at any time. - */ -const GlobalSnackbar = ({ snackbar, onClose, onErrorLogOpen }) => { - const classes = useStyles(); - const { open, message, type, errorId, duration } = snackbar; - - const handleClose = (_ignored, reason) => { - if (reason !== 'clickaway' && onClose) { - onClose(); - } - }; - - const handleViewLogClick = () => { - if (onClose) { - onClose(); - } - if (onErrorLogOpen) { - onErrorLogOpen(errorId); - } - }; - - return ( -
    - } - action={ - <> - {errorId && ( - - )} - - - - - } - /> -
    - ); -}; - -export default connect(({ snackbar }) => ({ snackbar }), { onClose: snackbarClose, onErrorLogOpen: errorLogOpen })( - GlobalSnackbar, -); diff --git a/frontend/packages/shared/apis/snackbar/GlobalSnackbar.test.js b/frontend/packages/shared/apis/snackbar/GlobalSnackbar.test.js deleted file mode 100644 index 43269c0710..0000000000 --- a/frontend/packages/shared/apis/snackbar/GlobalSnackbar.test.js +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; -import { render, fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; -import { createStore } from 'core/store'; -import GlobalSnackbar from './GlobalSnackbar'; -import { wrapInThemedTestApp } from 'testUtils'; -import { snackbarOpenDefault, snackbarOpenSuccess, snackbarOpenError } from './actions'; - -describe('', () => { - let store = null; - - function renderSnackbar() { - return render(wrapInThemedTestApp(} />)); - } - - beforeEach(() => { - store = createStore(); - }); - - it('renders an empty snackbar', async () => { - const rendered = renderSnackbar(); - - expect(rendered.container.childElementCount).toBe(1); - expect(rendered.container.children[0]).toBeEmpty(); - }); - - it('opens the snackbar with a message', () => { - const rendered = renderSnackbar(); - - expect(rendered.queryByText('Hello!')).not.toBeInTheDocument(); - store.dispatch(snackbarOpenDefault('Hello!')); - rendered.getByText('Hello!'); - }); - - it('opens the snackbar with a success message', () => { - const rendered = renderSnackbar(); - - expect(rendered.queryByText('Success!')).not.toBeInTheDocument(); - store.dispatch(snackbarOpenSuccess('Success!')); - rendered.getByText('Success!'); - }); - - it('opens the snackbar with an error message', () => { - const rendered = renderSnackbar(); - - expect(rendered.queryByText('fail :(')).not.toBeInTheDocument(); - store.dispatch(snackbarOpenError('fail :(')); - rendered.getByText('fail :('); - }); - - it('should close when clicking the close button', async () => { - const rendered = renderSnackbar(); - - expect(rendered.queryByText('Hello!')).not.toBeInTheDocument(); - store.dispatch(snackbarOpenDefault('Hello!')); - rendered.getByText('Hello!'); - fireEvent.click(rendered.getByLabelText('Close')); - await waitForElementToBeRemoved(() => rendered.getByText('Hello!')); - }); - - it('open error log when clicking error message', async () => { - const rendered = renderSnackbar(); - const error = new Error('fail :('); - - expect(store.getState().errorLog.open).toBe(false); - - expect(rendered.queryByText('fail :(')).not.toBeInTheDocument(); - store.dispatch(snackbarOpenError(error.message, error, 'what terrible failure')); - rendered.getByText('fail :('); - - // Only when opening error log, but we don't have a log here - expect(rendered.queryByText('what terrible failure')).not.toBeInTheDocument(); - - fireEvent.click(rendered.getByText('VIEW LOG')); - - await waitForElementToBeRemoved(() => rendered.getByText('fail :(')); - - expect(store.getState().errorLog.open).toBe(true); - }); - - it('should not hide on clickaway', () => { - const rendered = renderSnackbar(); - - expect(rendered.queryByText('Success!')).not.toBeInTheDocument(); - store.dispatch(snackbarOpenSuccess('Success!')); - - expect(store.getState().snackbar.open).toBe(true); - fireEvent.click(rendered.container); - expect(store.getState().snackbar.open).toBe(true); - }); - - it('should close after timeout if not error', async () => { - const rendered = renderSnackbar(); - - expect(rendered.queryByText('Success!')).not.toBeInTheDocument(); - store.dispatch(snackbarOpenSuccess('Success!', { duration: 50 })); - - expect(store.getState().snackbar.open).toBe(true); - rendered.getByText('Success!'); - - await waitForElementToBeRemoved(() => rendered.getByText('Success!')); - expect(store.getState().snackbar.open).toBe(false); - }); -}); diff --git a/frontend/packages/shared/apis/snackbar/actions.js b/frontend/packages/shared/apis/snackbar/actions.js deleted file mode 100644 index f8a1787ad8..0000000000 --- a/frontend/packages/shared/apis/snackbar/actions.js +++ /dev/null @@ -1,80 +0,0 @@ -import * as constants from './constants'; -import { errorLogAdd } from 'shared/apis/errorLog/actions'; - -const DEFAULT_TIMEOUT = 5000; - -/** - * Show default / neutral snackbar - * @param {String} message - A human readable short message - * @param {Object} [options] - Optional options object - * @param {Number} [options.duration] - Duration in ms until the message will be automatically hidden - */ -export const snackbarOpenDefault = (message, options = {}) => dispatch => { - const { duration = DEFAULT_TIMEOUT } = options; - dispatch({ - type: constants.SNACKBAR_OPEN_DEFAULT, - payload: { - open: true, - message, - type: 'default', - duration, - }, - }); -}; - -/** - * Show success snackbar - * @param {String} message - A human readable short message - * @param {Object} [options] - Optional options object - * @param {Number} [options.duration] - Duration in ms until the message will be automatically hidden - */ -export const snackbarOpenSuccess = (message, options = {}) => dispatch => { - const { duration = DEFAULT_TIMEOUT } = options; - dispatch({ - type: constants.SNACKBAR_OPEN_SUCCESS, - payload: { - open: true, - message, - type: 'success', - duration, - }, - }); -}; - -/** - * Show error snackbar - * @param {String} message - A human readable short error message - * @param {String|Object} error - Details of the error, e.g response code + message from a backend, or a stacktrace / error object - * @param longMessage - Optional: An extra error message that can be long and contain html - */ -export const snackbarOpenError = (message, error, longMessage) => dispatch => { - let errorId; - if (error) { - errorId = Math.random() - .toString(36) - .substring(7); - // batching actions? - // https://github.com/reactjs/redux/issues/911#issuecomment-149361073 - // -> https://github.com/tshelburne/redux-batched-actions - errorLogAdd(message, error, errorId, longMessage)(dispatch); - } - - dispatch({ - type: constants.SNACKBAR_OPEN_ERROR, - payload: { - open: true, - message, - type: 'error', - errorId, - }, - }); -}; - -/** - * Close the snackbar - */ -export const snackbarClose = () => dispatch => { - dispatch({ - type: constants.SNACKBAR_CLOSE, - }); -}; diff --git a/frontend/packages/shared/apis/snackbar/constants.js b/frontend/packages/shared/apis/snackbar/constants.js deleted file mode 100644 index cab2149050..0000000000 --- a/frontend/packages/shared/apis/snackbar/constants.js +++ /dev/null @@ -1,4 +0,0 @@ -export const SNACKBAR_OPEN_DEFAULT = 'SNACKBAR_OPEN_DEFAULT'; -export const SNACKBAR_OPEN_SUCCESS = 'SNACKBAR_OPEN_SUCCESS'; -export const SNACKBAR_OPEN_ERROR = 'SNACKBAR_OPEN_ERROR'; -export const SNACKBAR_CLOSE = 'SNACKBAR_CLOSE'; diff --git a/frontend/packages/shared/apis/snackbar/reducer.js b/frontend/packages/shared/apis/snackbar/reducer.js deleted file mode 100644 index 520add9162..0000000000 --- a/frontend/packages/shared/apis/snackbar/reducer.js +++ /dev/null @@ -1,22 +0,0 @@ -import * as constants from './constants'; - -const defaultState = { - open: false, - message: '', - type: 'default', -}; - -const snackbarReducer = (state = defaultState, action) => { - switch (action.type) { - case constants.SNACKBAR_OPEN_DEFAULT: - case constants.SNACKBAR_OPEN_SUCCESS: - case constants.SNACKBAR_OPEN_ERROR: - return { ...state, ...action.payload }; - case constants.SNACKBAR_CLOSE: - return { ...state, open: false }; - default: - return state; - } -}; - -export default snackbarReducer; diff --git a/frontend/packages/shared/apis/storage/__mocks__/persistentStore.js b/frontend/packages/shared/apis/storage/__mocks__/persistentStore.js deleted file mode 100644 index 72f3ddb28d..0000000000 --- a/frontend/packages/shared/apis/storage/__mocks__/persistentStore.js +++ /dev/null @@ -1,3 +0,0 @@ -export const saveToStore = jest.fn(); -export const removeFromStore = jest.fn(); -export const getFromStore = jest.fn((id, key, fallback) => fallback); diff --git a/frontend/packages/shared/apis/storage/favorites.ts b/frontend/packages/shared/apis/storage/favorites.ts deleted file mode 100644 index 95939552c0..0000000000 --- a/frontend/packages/shared/apis/storage/favorites.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useState, useEffect, useMemo } from 'react'; -import { useFirestore } from 'shared/apis/firestore'; -import { useObservable } from 'react-use'; - -const COLLECTION = '/favorites/'; - -export type Favorite = { path: string; title: string; timestamp: string }; - -export function useFavorite(path: string) { - const favoritePath = `${COLLECTION}${encodeURIComponent(path)}`; - const firestoreApi = useFirestore(); - const favoriteSnapshot = useObservable( - useMemo(() => firestoreApi.observe(favoritePath), [firestoreApi, favoritePath]), - ); - - const save = (title: string) => { - let timestamp = new Date().toUTCString(); - return firestoreApi.set(favoritePath, { timestamp, path, title }); - }; - - const remove = () => { - return firestoreApi.delete(favoritePath); - }; - - return [favoriteSnapshot?.data, save, remove, !!favoriteSnapshot, favoriteSnapshot] as const; -} - -function appendNewFavs(currentFavs: Favorite[], newFavs: Favorite[]) { - const allFavs = currentFavs.slice(); - - newFavs.forEach(newFav => { - if (!allFavs.find(fav => fav.path === newFav.path)) { - allFavs.push(newFav); - } - }); - - return allFavs; -} - -export function useFavoriteList() { - const firestoreApi = useFirestore(); - const updatedFavsCol = useObservable( - useMemo( - () => firestoreApi.observeQuery({ path: COLLECTION }), - [firestoreApi], - ), - ); - const [shownFavorites, setShownFavorites] = useState([]); - - useEffect(() => { - setShownFavorites(currentFavs => { - if (updatedFavsCol) { - const newFavs = updatedFavsCol.docs.map(doc => doc.data!).filter(Boolean); - return appendNewFavs(currentFavs, newFavs); - } - return currentFavs; - }); - }, [updatedFavsCol]); - - return [shownFavorites, !updatedFavsCol] as const; -} diff --git a/frontend/packages/shared/apis/storage/persistentStore.js b/frontend/packages/shared/apis/storage/persistentStore.js deleted file mode 100644 index c5533de98b..0000000000 --- a/frontend/packages/shared/apis/storage/persistentStore.js +++ /dev/null @@ -1,105 +0,0 @@ -import { useState, useEffect } from 'react'; - -/** - * Save persistent data. - * - * TODO: Replace with e.g. Google Cloud Firestore in the future. - * - * @param {String} storeName Name of the store. - * @param {String} key Unique key associated with the data. - * @param {Object} data The data that should be stored. - */ -export function saveToStore(storeName, key, data) { - const store = JSON.parse(localStorage.getItem(storeName)) || {}; - store[key] = data; - localStorage.setItem(storeName, JSON.stringify(store)); -} - -/** - * Remove persistent data. - * - * TODO: Replace with e.g. Google Cloud Firestore in the future. - * - * @param {String} storeName Name of the store. - * @param {String} key Unique key associated with the data. - */ -export function removeFromStore(storeName, key) { - const store = JSON.parse(localStorage.getItem(storeName)) || {}; - delete store[key]; - localStorage.setItem(storeName, JSON.stringify(store)); -} - -/** - * Get persistent data. - * - * TODO: Replace with e.g. Google Cloud Firestore in the future. - * - * @param {String} storeName Name of the store. - * @param {String?} key (Optional) Unique key associated with the data. - * If not key is specified,the whole store is returned. - * @param {String} key (Optional) Empty value to return if there is not data. - * @return {Object} data The data that should is stored. - */ -export function getFromStore(storeName, key, emptyValue = {}) { - let store; - try { - store = JSON.parse(localStorage.getItem(storeName)) || {}; - } catch (e) { - console.error(`Error when parsing JSON config from storage for: ${storeName}`); - return emptyValue; - } - - if (key) { - return store[key] || emptyValue; - } - return store; -} - -/** - * React hook that observes a single store value and provides functions for updating the value. - * - * @param {String} storeName Name of the store. - * @param {String} key Unique key associated with the data. - * @return {[value, setValue(value), removeValue()]} An array to be deconstructed, - * The first element is the value, the second is a function to call to update the value, - * and the third is a function to call to clear the value. - */ -export function useStoreValue(storeName, key) { - // We hardcode the emptyValue to null, since allowing objects or arrays would make - // the useEffect params a lot more complex and it would easy to get stuck in a loop. - let [value, setValue] = useState(() => getFromStore(storeName, key, null)); - - // Listen to storage change events from other tabs - useEffect(() => { - const onChange = event => { - if (event.key === storeName) { - // Avoid sending unnecessary updates when only the reference changes because of JSON.parse - setValue(currentValue => { - let newValue = getFromStore(storeName, key, null); - - // Fastest way to do a deep equals, since we know references will differ, - // the values are serializable, and it's ok to signal a change if key order changes. - if (JSON.stringify(currentValue) === JSON.stringify(newValue)) { - return currentValue; - } - return newValue; - }); - } - }; - - window.addEventListener('storage', onChange); - return () => window.removeEventListener('storage', onChange); - }, [storeName, key]); - - const saveValue = value => { - saveToStore(storeName, key, value); - setValue(getFromStore(storeName, key, null)); - }; - - const removeValue = () => { - removeFromStore(storeName, key); - setValue(null); - }; - - return [value, saveValue, removeValue]; -} diff --git a/frontend/packages/shared/apis/storage/recentlyVisited.ts b/frontend/packages/shared/apis/storage/recentlyVisited.ts deleted file mode 100644 index 6d7283ebb4..0000000000 --- a/frontend/packages/shared/apis/storage/recentlyVisited.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMemo } from 'react'; -import { getFirestoreApi, useFirestore } from 'shared/apis/firestore'; -import { useObservable } from 'react-use'; - -const COLLECTION = '/recentlyVisited/'; - -export type RecentlyVisitedItem = { - path: string; - name: string; - timestamp: number; -}; - -export async function addRecentlyVisited(item: RecentlyVisitedItem): Promise { - await getFirestoreApi().set(`${COLLECTION}${encodeURIComponent(item.path)}`, item); -} - -export function useRecentlyVisited(limit: number = 10): RecentlyVisitedItem[] { - const firestoreApi = useFirestore(); - const queryResult = useObservable( - useMemo( - () => - firestoreApi.observeQuery({ - limit, - path: COLLECTION, - orderBy: { - field: 'timestamp', - direction: 'desc', - }, - }), - [firestoreApi, limit], - ), - ); - - return (queryResult?.docs || []).filter(s => s.exists).map(s => s.data!); -} diff --git a/frontend/packages/shared/apis/sysmodel/SysmodelClient.js b/frontend/packages/shared/apis/sysmodel/SysmodelClient.js deleted file mode 100644 index dfedadea66..0000000000 --- a/frontend/packages/shared/apis/sysmodel/SysmodelClient.js +++ /dev/null @@ -1,170 +0,0 @@ -import axios from 'axios'; -import { googleAuth } from 'shared/apis/googleAuthV2'; -import { evictGraphqlCacheEntity } from 'shared/apis/backstage/graphqlClient'; -import { urls } from 'shared/apis/baseUrls'; - -const SYSMODEL_URL = urls.sysmodel; - -async function authHeaderForScopes(scopes) { - const token = await googleAuth.getAccessToken(scopes); - return { - Authorization: `Bearer ${token}`, - }; -} - -class SysmodelClient { - static SYSMODEL_ENDPOINTS = { - components: `${SYSMODEL_URL}/v1/components`, - locations: `${SYSMODEL_URL}/v1/locations`, - projects: `${SYSMODEL_URL}/v2-alpha/projects`, - rolesV2: `${SYSMODEL_URL}/v2-alpha/roles`, - }; - - /** - * Get all V2 roles - */ - static async getRolesV2() { - return await axios.get(`${this.SYSMODEL_ENDPOINTS.rolesV2}`).then(res => res.data); - } - - /** - * Get all V2 projects - */ - static async getProjectsV2() { - return await axios.get(`${this.SYSMODEL_ENDPOINTS.projects}`).then(res => res.data); - } - - /** - * Get a component. - * @param {String} componentId - The ID of the component - */ - static async getComponent(componentId) { - return await axios.get(`${this.SYSMODEL_ENDPOINTS.components}/${componentId}`).then(res => res.data); - } - - /** - * Register a component in Sysmodel. - * @param {String} location - The location key, e.g. ghe:/orgname/reponame/service-info.yaml - */ - static async registerComponentFromGheUrl(gheUrl, componentFile) { - const location = SysmodelClient.generateSysmodelUri(gheUrl, componentFile); - return axios - .post(this.SYSMODEL_ENDPOINTS.locations, JSON.stringify({ location: location }), { - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - }) - .catch(error => { - let message = ''; - if (error.response && error.response.data && error.response.data.reason && error.response.data.error) { - message = `${error.response.data.reason}: ${error.response.data.error}`; - } else if (error.response) { - message = `Request failed with status code ${error.response.status}: ${error.response.statusText}`; - } else { - message = error.message; - } - - throw Error(message); - }); - } - - static generateSysmodelUri(gheUrl, componentFile) { - const yamlFileSuffix = componentFile || 'service-info.yaml'; - const ghePattern = /^(https?:\/\/)?ghe\.spotify\.net\//; - const yamFullPathPattern = /([^/]+\/[^/]+\/blob\/master\/.+\.yaml)$/; - - if (!gheUrl.match(ghePattern)) { - throw new Error('Malformed GHE URL passed to SysmodelClient.registerComponentFromGheUrl', gheUrl); - } - - gheUrl = gheUrl.replace(ghePattern, ''); - - if (gheUrl.match(yamFullPathPattern)) { - gheUrl = gheUrl.replace('blob/master/', ''); - } else { - gheUrl = `${gheUrl}/${yamlFileSuffix}`; - } - - // there might be double slashes due to user input - gheUrl = gheUrl.replace(/\/\//g, '/'); - - return `ghe:/${gheUrl}`; - } - - /** - * Unregister a component from Sysmodel. - * @param {String} componentId - The ID of the component - * @param {String} location - The location key, e.g. ghe:/orgname/reponame/service-info.yaml - */ - static async unregisterComponent(componentId, location) { - await axios.delete(`${this.SYSMODEL_ENDPOINTS.components}/${componentId}`); - await axios.delete(`${this.SYSMODEL_ENDPOINTS.locations}/${location}`); - } - - /** - * Move a components location. - * @param {String} oldLocation - The location of the component to move - * @param {String} newLocation - The new location of the component - */ - static async moveComponentLocation(oldLocation, newLocation) { - try { - return await axios.put( - `${this.SYSMODEL_ENDPOINTS.locations}/${oldLocation}`, - { location: newLocation }, - { - errorHandle: false, - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - }, - ); - } catch (e) { - throw new Error(`Request failed with status code ${e.response.status}: ${e.response.statusText}`); - } - } - - /** - * Link a role to a GCP project. - * @param {String} roleId - The ID of the role, e.g. "tantrum" - * @param {String} gcpProjectId - The ID of the GCP project, e.g. "golden-path-tutorial-6522" - */ - static async linkRoleToGcpProject(roleId, gcpProjectId) { - const sysmodelProjectId = await SysmodelClient.gcpProjectIdToSysmodelProjectId(gcpProjectId); - const headers = await authHeaderForScopes('cloudplatformprojects.readonly'); - await axios.put(`${this.SYSMODEL_ENDPOINTS.projects}/${sysmodelProjectId}/roles/${roleId}`, null, { headers }); - await evictGraphqlCacheEntity('roles'); - } - - /** - * Unlinks a role from a GCP project. - * @param {String} roleId - The ID of the role, e.g. "tantrum" - * @param {String} gcpProjectId - The ID of the GCP project, e.g. "golden-path-tutorial-6522" - */ - static async unlinkRoleFromGcpProject(roleId, gcpProjectId) { - const sysmodelProjectId = await SysmodelClient.gcpProjectIdToSysmodelProjectId(gcpProjectId); - const headers = await authHeaderForScopes('cloudplatformprojects.readonly'); - await axios.delete(`${this.SYSMODEL_ENDPOINTS.projects}/${sysmodelProjectId}/roles/${roleId}`, { headers }); - await evictGraphqlCacheEntity('roles'); - } - - /** - * Given an actual GCP project ID, returns the ID of the corresponding Sysmodel project, or - * creates one if it did not exist. - * @param gcpProjectId The GCP project ID - * @returns {Promise<*>} The sysmodel project ID - */ - static async gcpProjectIdToSysmodelProjectId(gcpProjectId) { - // First, list all projects that are registered in sysmodel, and find the right one - const sysmodelProjects = await axios.get(this.SYSMODEL_ENDPOINTS.projects); - const matchingSysmodelProjects = sysmodelProjects.data.filter(p => p.gcpProjectId === gcpProjectId); - - // If the project has not yet been registered, do so automatically first - if (!matchingSysmodelProjects.length) { - const requestBody = { id: gcpProjectId, gcpProjectId: gcpProjectId }; - await axios.post(this.SYSMODEL_ENDPOINTS.projects, requestBody); - return gcpProjectId; - } else { - return matchingSysmodelProjects[0].id; - } - } -} - -export default SysmodelClient; diff --git a/frontend/packages/shared/apis/sysmodel/SysmodelClient.test.js b/frontend/packages/shared/apis/sysmodel/SysmodelClient.test.js deleted file mode 100644 index 0905df5374..0000000000 --- a/frontend/packages/shared/apis/sysmodel/SysmodelClient.test.js +++ /dev/null @@ -1,137 +0,0 @@ -import SysmodelClient from './SysmodelClient'; -import axios from 'axios'; -import { mockAccessToken } from 'shared/apis/googleAuthV2/MockAuthHelper'; - -describe('SysmodelClient', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('generateSysmodelUri should return a sysmodel URI', function() { - const input = [ - ['https://ghe.spotify.net/tools/testing', 'service-info.yaml'], - ['https://ghe.spotify.net/tools/testing', 'my-service-info.yaml'], - ['https://ghe.spotify.net/tools/testing'], - ['ghe.spotify.net/tools/testing', 'service-info.yaml'], - ['https://ghe.spotify.net/user/repo/blob/master/service-info.yaml'], - ['http://ghe.spotify.net/user/repo/blob/master/folder/service-info.yaml'], - ['https://ghe.spotify.net/user/repo/blob/master/one/two/three/service-info.yaml'], - ['https://ghe.spotify.net/user/repo/blob/master/service-info.yaml', 'my-service-info.yaml'], - ]; - const output = [ - 'ghe:/tools/testing/service-info.yaml', - 'ghe:/tools/testing/my-service-info.yaml', - 'ghe:/tools/testing/service-info.yaml', - 'ghe:/tools/testing/service-info.yaml', - 'ghe:/user/repo/service-info.yaml', - 'ghe:/user/repo/folder/service-info.yaml', - 'ghe:/user/repo/one/two/three/service-info.yaml', - 'ghe:/user/repo/service-info.yaml', - ]; - - [0, 1, 2, 3, 4, 5, 6, 7].forEach(idx => { - expect(SysmodelClient.generateSysmodelUri(...input[idx])).toEqual(output[idx]); - }); - }); - - it('generateSysmodelUri should throw error', function() { - const input = [ - ['asd://ghe.spotify.net/tools/testing', 'service-info.yaml'], - ['ghe://ghe.spotify.net/tools/testing', 'service-info.yaml'], - ['incorrectFormat', 'service-info.yaml'], - ]; - - [0, 1, 2].forEach(idx => { - expect(() => { - SysmodelClient.generateSysmodelUri(...input[idx]); - }).toThrow(); - }); - }); - - it('registerComponentFromGheUrl should handle errors', async () => { - const url = 'http://ghe.spotify.net/a/b/blob/master/.yaml'; - const spy = jest.spyOn(axios, 'post').mockReturnValue(); - - spy.mockRejectedValueOnce({ response: { data: { reason: 'it', error: 'broke' } } }); - expect(SysmodelClient.registerComponentFromGheUrl(url)).rejects.toMatchObject({ message: 'it: broke' }); - - spy.mockRejectedValueOnce({ response: 3 }); - expect(SysmodelClient.registerComponentFromGheUrl(url)).rejects.toMatchObject({ - message: 'Request failed with status code undefined: undefined', - }); - - spy.mockRejectedValueOnce(new Error('nope')); - expect(SysmodelClient.registerComponentFromGheUrl(url)).rejects.toMatchObject({ message: 'nope' }); - }); - - it('should unregister a component', async () => { - jest.spyOn(axios, 'delete').mockReturnValue(); - - await SysmodelClient.unregisterComponent('a', 'loc'); - - expect(axios.delete).toHaveBeenNthCalledWith(1, 'https://sysmodel.spotify.net/v1/components/a'); - expect(axios.delete).toHaveBeenNthCalledWith(2, 'https://sysmodel.spotify.net/v1/locations/loc'); - }); - - it('link existing gcp project', async () => { - jest.spyOn(axios, 'get').mockResolvedValue({ - data: [ - { gcpProjectId: 'gcp-1', id: 'id-1' }, - { gcpProjectId: 'gcp-2', id: 'id-2' }, - ], - }); - jest.spyOn(axios, 'post').mockReturnValue(); - jest.spyOn(axios, 'put').mockReturnValue(); - - await SysmodelClient.linkRoleToGcpProject('role1', 'gcp-2'); - - expect(axios.post).toHaveBeenCalledWith('https://backstage-proxy.spotify.net/api/backend/graphql/evict/roles'); - expect(axios.put).toHaveBeenCalledWith('https://sysmodel.spotify.net/v2-alpha/projects/id-2/roles/role1', null, { - headers: { - Authorization: `Bearer ${mockAccessToken}`, - }, - }); - }); - - it('link nonexistent gcp project', async () => { - jest.spyOn(axios, 'get').mockResolvedValue({ - data: [ - { gcpProjectId: 'gcp-1', id: 'id-1' }, - { gcpProjectId: 'gcp-2', id: 'id-2' }, - ], - }); - jest.spyOn(axios, 'post').mockReturnValue(); - jest.spyOn(axios, 'put').mockReturnValue(); - - await SysmodelClient.linkRoleToGcpProject('role1', 'gcp-3'); - - expect(axios.post).toHaveBeenNthCalledWith(1, 'https://sysmodel.spotify.net/v2-alpha/projects', { - gcpProjectId: 'gcp-3', - id: 'gcp-3', - }); - expect(axios.post).toHaveBeenNthCalledWith( - 2, - 'https://backstage-proxy.spotify.net/api/backend/graphql/evict/roles', - ); - expect(axios.put).toHaveBeenCalledWith('https://sysmodel.spotify.net/v2-alpha/projects/gcp-3/roles/role1', null, { - headers: { - Authorization: `Bearer ${mockAccessToken}`, - }, - }); - }); - - it('unlink gcp project', async () => { - jest.spyOn(axios, 'get').mockResolvedValue({ data: [{ gcpProjectId: 'gcp-2', id: 'id-2' }] }); - jest.spyOn(axios, 'post').mockReturnValue(); - jest.spyOn(axios, 'delete').mockReturnValue(); - - await SysmodelClient.unlinkRoleFromGcpProject('role1', 'gcp-2'); - - expect(axios.post).toHaveBeenCalledWith('https://backstage-proxy.spotify.net/api/backend/graphql/evict/roles'); - expect(axios.delete).toHaveBeenCalledWith('https://sysmodel.spotify.net/v2-alpha/projects/id-2/roles/role1', { - headers: { - Authorization: `Bearer ${mockAccessToken}`, - }, - }); - }); -}); diff --git a/frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.js b/frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.js deleted file mode 100644 index 4ff08ab755..0000000000 --- a/frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.js +++ /dev/null @@ -1,124 +0,0 @@ -import React, { Fragment } from 'react'; -import { useHistory } from 'react-router-dom'; -import PropTypes from 'prop-types'; -import { TextField } from '@material-ui/core'; -import MoveIcon from '@material-ui/icons/SwapHoriz'; -import { DeleteIcon } from 'shared/icons'; -import FormHelperErrorText from 'shared/components/form/FormHelperErrorText'; -import SysmodelClient from 'shared/apis/sysmodel/SysmodelClient'; -import { evictGraphqlCacheEntity } from 'shared/apis/backstage/graphqlClient'; -import ConfirmPrompt from 'shared/components/ConfirmPrompt'; -import ComponentResourceChecker from './ComponentResourceChecker'; -import HeaderActionMenu from 'shared/components/layout/HeaderActionMenu'; - -const UnregisterPrompt = ({ componentId, componentType, componentLocation, roles, children }) => { - const history = useHistory(); - - const handleConfirm = async () => { - await SysmodelClient.unregisterComponent(componentId, componentLocation); - history.push('/'); - }; - return ( - - This action will unregister {componentId}. To undo, just re-register the component in - Backstage. - - - } - successMessage={`Unregistered ${componentId}`} - failureMessage={`Failed to unregister ${componentId}`} - onConfirm={handleConfirm} - > - {children} - - ); -}; - -const MovePrompt = ({ componentId, componentLocation, children }) => { - const [newLocation, setNewLocation] = React.useState(''); - const [newLocationError, setNewLocationError] = React.useState(''); - - const handleNewLocationInput = event => { - const { value } = event.target; - const regex = new RegExp('^(https?:\\/\\/)?ghe\\.spotify\\.net\\/.*\\.yaml$'); - - setNewLocation(value); - setNewLocationError(regex.test(value) ? '' : 'Must be a valid ghe.spotify.net url.'); - }; - - const handleConfirm = async () => { - await SysmodelClient.moveComponentLocation(componentLocation, SysmodelClient.generateSysmodelUri(newLocation)); - await evictGraphqlCacheEntity('components'); - }; - - return ( - - Move {componentId} from {componentLocation} to:{' '} - - {newLocationError && {newLocationError}} - - } - successMessage={`Moved ${componentId} to ${newLocation}`} - failureMessage={`Failed to move ${componentId}`} - onConfirm={handleConfirm} - > - {children} - - ); -}; - -const ComponentContextMenu = ({ componentId, componentType, componentLocation, roles }) => { - return ( - , - WrapperComponent: ({ children }) => ( - - ), - }, - { - label: 'Move repository', - icon: , - WrapperComponent: ({ children }) => ( - - ), - }, - ]} - /> - ); -}; - -ComponentContextMenu.propTypes = { - componentType: PropTypes.string.isRequired, - componentId: PropTypes.string.isRequired, - componentLocation: PropTypes.string.isRequired, - roles: PropTypes.arrayOf(PropTypes.object), -}; - -export default ComponentContextMenu; diff --git a/frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.test.js b/frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.test.js deleted file mode 100644 index f63d28e5a0..0000000000 --- a/frontend/packages/shared/apis/sysmodel/components/ComponentContextMenu.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInThemedTestApp, Keyboard } from 'testUtils'; -import SysmodelClient from 'shared/apis/sysmodel/SysmodelClient'; -import ComponentContextMenu from './ComponentContextMenu'; - -jest.mock('shared/auth/GheComposable'); -jest.mock('shared/apis/backstage/graphqlClient', () => ({ - evictGraphqlCacheEntity: jest.fn(), -})); - -const minProps = { - componentId: 'test-component', - componentType: 'other', - componentLocation: 'example/service-info.yaml', - history: '', -}; - -describe('', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders without exploding', () => { - render(wrapInThemedTestApp()); - }); - - it('should move a component', async () => { - const rendered = render(wrapInThemedTestApp()); - - jest.spyOn(SysmodelClient, 'moveComponentLocation').mockResolvedValue(); - jest.spyOn(SysmodelClient, 'generateSysmodelUri').mockImplementation(uri => uri); - - fireEvent.click(rendered.getByTitle('menu')); - fireEvent.click(rendered.getByText('Move repository')); - - const newLocation = 'http://ghe.spotify.net/new-location.yaml'; - await Keyboard.type(rendered, ` ${newLocation} `); - - expect(SysmodelClient.moveComponentLocation).toHaveBeenCalledWith('example/service-info.yaml', newLocation); - }); - - it('should unregister a component', async () => { - const rendered = render(wrapInThemedTestApp()); - - jest.spyOn(SysmodelClient, 'unregisterComponent').mockResolvedValue(); - - fireEvent.click(rendered.getByTitle('menu')); - fireEvent.click(rendered.getByText('Unregister component')); - - rendered.queryByText('me'); - - await Keyboard.type(rendered, ' '); - - expect(SysmodelClient.unregisterComponent).toHaveBeenCalledWith('test-component', 'example/service-info.yaml'); - }); -}); diff --git a/frontend/packages/shared/apis/sysmodel/components/ComponentList.js b/frontend/packages/shared/apis/sysmodel/components/ComponentList.js deleted file mode 100644 index e6b244bca7..0000000000 --- a/frontend/packages/shared/apis/sysmodel/components/ComponentList.js +++ /dev/null @@ -1,77 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import gql from 'graphql-tag'; -import PagerDutyServiceStatus from 'shared/components/PagerDutyServiceStatus'; -import Link from 'shared/components/Link'; -import BreakHints from 'shared/components/BreakHints'; -import PrimaryTableLink from 'shared/components/PrimaryTableLink'; -import DataGrid from 'shared/components/DataGrid'; - -class ComponentList extends Component { - static propTypes = { - components: PropTypes.arrayOf(PropTypes.object).isRequired, - }; - - static fragments = { - list: gql` - fragment ComponentList on Component { - id - system - pagerDutyService { - id - name - homepageUrl - activeIncidents { - id - title - status - homepageUrl - } - } - stateInfo { - state - info - } - } - `, - }; - - static fragment = gql` - fragment UntypedComponents on System { - untypedComponents { - ...ComponentList - } - } - ${ComponentList.fragments.list} - `; - - getColumns = () => { - return [ - { - name: 'id', - width: 200, - renderer: ({ row }) => , - alwaysVisible: true, - }, - { - name: 'system', - renderer: ({ row }) => ( - - - - ), - }, - { - name: 'pagerduty', - width: 150, - renderer: ({ row }) => , - }, - ]; - }; - - render() { - return ; - } -} - -export default ComponentList; diff --git a/frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.js b/frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.js deleted file mode 100644 index fdcf8126c8..0000000000 --- a/frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.js +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import { withGheAuth } from 'shared/auth/GheComposable'; -import WarningIcon from '@material-ui/icons/Warning'; -import ErrorIcon from '@material-ui/icons/Error'; -import Progress from 'shared/components/Progress'; -import TugApi from 'plugins/tugboat/lib/TugApi'; -import useResourceChecker from 'shared/apis/sysmodel/hooks/useResourceChecker'; - -const useStyles = makeStyles(theme => ({ - container: { - display: 'flex', - marginTop: theme.spacing(2), - marginLeft: 0, - marginRight: 0, - }, - icon: { - height: theme.spacing(5), - width: theme.spacing(5), - marginRight: theme.spacing(1), - }, - warningIcon: { - color: theme.palette.status.warning, - }, - errorIcon: { - color: theme.palette.status.error, - }, -})); - -const ComponentResourceChecker = ({ componentType, componentId, roles, gheApi }) => { - const classes = useStyles(); - const tugboatApi = React.useMemo(() => new TugApi(gheApi), [gheApi]); - const [loading, error, { instanceCount, machineCount, hasUniqueRoles }] = useResourceChecker({ - componentId, - componentType, - roles, - tugboatApi, - }); - - if (loading) { - return ; - } - - if (error) { - return ( -
    - - {error.message} -
    - ); - } - - if (!instanceCount && !machineCount) { - return null; - } - - const message = [ - `Note that this service has ${instanceCount} running container instances`, - hasUniqueRoles ? ` and ${machineCount} machines provisioned in CPM` : '', - '.', - ].join(''); - - return ( -
    - - {message} -
    - ); -}; - -ComponentResourceChecker.propTypes = { - componentType: PropTypes.string.isRequired, - componentId: PropTypes.string.isRequired, - roles: PropTypes.arrayOf(PropTypes.object), -}; - -export default withGheAuth()(ComponentResourceChecker); diff --git a/frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.test.js b/frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.test.js deleted file mode 100644 index 2d565481ea..0000000000 --- a/frontend/packages/shared/apis/sysmodel/components/ComponentResourceChecker.test.js +++ /dev/null @@ -1,171 +0,0 @@ -import React from 'react'; -import { render, waitForElementToBeRemoved } from '@testing-library/react'; -import { wrapInThemedTestApp } from 'testUtils'; -import Checker from './ComponentResourceChecker'; - -jest.mock('shared/auth/GheComposable'); - -jest.mock('plugins/tugboat/lib/TugApi', () => { - return class MockTugApi { - installation = { - findByComponent: jest.fn().mockImplementation(async id => { - if (id === 'empty-component') { - return []; - } else if (id === 'running-component') { - return [{ id: 'inst-running' }]; - } else if (id === 'shutdown-component') { - return [{ id: 'inst-shutdown' }]; - } else if (id === 'mix-component') { - return [{ id: 'inst-running' }, { id: 'inst-shutdown' }]; - } else if (id === 'error-component') { - throw new Error('tugboat failure'); - } else { - return []; - } - }), - status: jest.fn().mockImplementation(async id => { - if (id === 'inst-running') { - return { - statuses: [ - { status: { running: true }, instance: {} }, - { status: { running: true }, instance: {} }, - ], - }; - } else if (id === 'inst-shutdown') { - return { statuses: [{ status: { running: false } }] }; - } - }), - }; - }; -}); - -jest.mock('plugins/capacity/lib/CPMClient', () => { - const pools = { - 'role-0': { desired_capacity: 0 }, - 'role-1': { desired_capacity: 1 }, - 'role-2': { desired_capacity: 2 }, - }; - return class MockCPMClient { - static getPools = jest.fn().mockImplementation(async roles => { - if (roles.includes('role-error')) { - throw new Error('cortana failure'); - } - return roles.map(role => pools[role.id]).filter(pool => pool); - }); - }; -}); - -describe('', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders without exploding', () => { - render(wrapInThemedTestApp()); - }); - - it('shows a warning when only instances are running', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - rendered.getByText('Note that this service has 2 running container instances and 0 machines provisioned in CPM.'); - }); - - it('shows a warning when only machines are provisioned', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - rendered.getByText('Note that this service has 0 running container instances and 3 machines provisioned in CPM.'); - }); - - it('does not warn about machines when roles are shared with other components', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - rendered.getByText('Note that this service has 2 running container instances.'); - }); - - it('shows a warning with instances running and provisioned machines', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - rendered.getByText('Note that this service has 2 running container instances and 1 machines provisioned in CPM.'); - }); - - it('does not show a warning if the instances are not running', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - expect(rendered.container).toBeEmpty(); - }); - - it('ignores roles that are shared with other components', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - rendered.getByText('Note that this service has 0 running container instances and 2 machines provisioned in CPM.'); - }); - - it('shows an error when failing to connect to tugboat', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - rendered.getByText('Failed to fetch installation information from tugboat'); - }); - - it('shows an error when failing to connect to cortana', async () => { - const rendered = render( - wrapInThemedTestApp( - , - ), - ); - await waitForElementToBeRemoved(() => rendered.queryByTestId('progress')); - rendered.getByText('Failed to fetch capacity information from cortana'); - }); -}); diff --git a/frontend/packages/shared/apis/sysmodel/hooks/useResourceChecker.ts b/frontend/packages/shared/apis/sysmodel/hooks/useResourceChecker.ts deleted file mode 100644 index aa25a842aa..0000000000 --- a/frontend/packages/shared/apis/sysmodel/hooks/useResourceChecker.ts +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import CPMClient from 'plugins/capacity/lib/CPMClient'; -import TugApi from 'plugins/tugboat/lib/TugApi'; - -const CHECKED_COMPONENT_TYPES = ['service', 'website']; - -async function fetchPoolInformation(roles: any): Promise<{ machineCount: number; hasUniqueRoles: boolean }> { - try { - // Only count roles that are unique to this service - const uniqueRoles = roles.filter((role: any) => role.components.length < 2); - - const cortanaPools = (await CPMClient.getPools(uniqueRoles)) as { desired_capacity: number }[]; - - const machineCount = cortanaPools.map(pool => pool.desired_capacity).reduce((sum, capacity) => sum + capacity, 0); - - return { machineCount, hasUniqueRoles: !!uniqueRoles.length }; - } catch (error) { - throw new Error('Failed to fetch capacity information from cortana'); - } -} - -async function fetchInstanceCount(tugboatApi: any, componentId: string): Promise { - try { - const tugboatInstallations = await tugboatApi.installation.findByComponent(componentId); - - // Fetch instance statuses for each installation - const instancesStatuses = (await Promise.all( - tugboatInstallations.map((installation: any) => tugboatApi.installation.status(installation.id)), - )) as { statuses: any[] }[]; - - // Count running instances in all installations - const instanceCount = instancesStatuses.reduce( - (count, instance) => count + instance.statuses.filter(stat => stat.status.running).length, - 0, - ); - - return instanceCount; - } catch (error) { - throw new Error('Failed to fetch installation information from tugboat'); - } -} - -type Params = { - componentId: string; - componentType: string; - roles: any; - tugboatApi: TugApi; -}; - -export default function useResourceChecker({ componentId, componentType, roles, tugboatApi }: Params) { - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - const [result, setResult] = React.useState({ instanceCount: 0, machineCount: 0, hasUniqueRoles: false }); - - React.useEffect(() => { - if (!CHECKED_COMPONENT_TYPES.includes(componentType)) { - setLoading(false); - return; - } - - if (roles.length === 0) { - setLoading(false); - return; - } - - setLoading(true); - let didCancel = false; - - async function fetchCounts() { - try { - const [{ hasUniqueRoles, machineCount }, instanceCount] = await Promise.all([ - fetchPoolInformation(roles), - fetchInstanceCount(tugboatApi, componentId), - ]); - - if (!didCancel) { - setResult({ instanceCount, machineCount, hasUniqueRoles }); - setLoading(false); - } - } catch (error) { - if (!didCancel) { - setError(error); - setLoading(false); - } - } - } - - fetchCounts(); - - return () => { - didCancel = true; - }; - }, [componentType, componentId, roles, tugboatApi]); - - return [loading, error, result]; -} diff --git a/frontend/packages/shared/apis/sysmodel/sysModelUtils.test.js b/frontend/packages/shared/apis/sysmodel/sysModelUtils.test.js deleted file mode 100644 index 1cea6ccb30..0000000000 --- a/frontend/packages/shared/apis/sysmodel/sysModelUtils.test.js +++ /dev/null @@ -1,31 +0,0 @@ -import { normalizeComponent } from './sysmodelUtils'; - -describe('normalizeComponent', () => { - it('normalizes urls for ghe', () => { - const component = { - componentInfoLocationUrl: 'https://ghe.spotify.net/raw/build/build-artifact-archiver/master/service-info.yaml', - }; - const normalized = normalizeComponent(component); - expect(normalized.serviceInfoLocation).toBe( - 'https://ghe.spotify.net/build/build-artifact-archiver/tree/master/service-info.yaml', - ); - expect(normalized.gheRepoUrl).toBe('https://ghe.spotify.net/build/build-artifact-archiver/'); - }); - it('normalizes urls for github', () => { - const component = { - componentInfoLocationUrl: 'https://raw.githubusercontent.com/spotify/missinglink/master/lib-info.yaml', - }; - const normalized = normalizeComponent(component); - expect(normalized.serviceInfoLocation).toBe('https://github.com/spotify/missinglink/tree/master/lib-info.yaml'); - expect(normalized.gheRepoUrl).toBe('https://github.com/spotify/missinglink/'); - }); - - it('handles abornal URLs', () => { - const component = { - componentInfoLocationUrl: '', - }; - const normalized = normalizeComponent(component); - expect(normalized.serviceInfoLocation).toBe(''); - expect(normalized.gheRepoUrl).toBe(''); - }); -}); diff --git a/frontend/packages/shared/apis/sysmodel/sysmodelActions.js b/frontend/packages/shared/apis/sysmodel/sysmodelActions.js deleted file mode 100644 index 0d126dc4bb..0000000000 --- a/frontend/packages/shared/apis/sysmodel/sysmodelActions.js +++ /dev/null @@ -1,12 +0,0 @@ -import axios from 'axios'; -import { urls } from 'shared/apis/baseUrls'; - -const baseUrl = urls.sysmodel; -const refreshURL = componentId => `${baseUrl}/api/v1/components/${encodeURIComponent(componentId)}/refresh`; - -// TODO: please move this to the SysmodelClient -const refreshAction = componentId => { - return axios.post(refreshURL(componentId)).then(data => data.data); -}; - -export { refreshAction }; diff --git a/frontend/packages/shared/apis/sysmodel/sysmodelUtils.js b/frontend/packages/shared/apis/sysmodel/sysmodelUtils.js deleted file mode 100644 index 5de1cb81ac..0000000000 --- a/frontend/packages/shared/apis/sysmodel/sysmodelUtils.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Returns the ghe/github repo url. - * @param url example https://ghe.spotify.net/build/build-artifact-archiver/edit/master/service-info.yaml - * @return example https://ghe.spotify.net/build/build-artifact-archiver/ - */ -const _getRepoUrl = url => { - if (!url) { - return url; - } - // matches ://// - // - let match = url.match(/(.+:\/\/[^/]+\/[^/]+\/[^/]+\/).*/); - return match ? match[1] : ''; -}; - -/** - * Takes a locationInfo URL and returns a normalized URL for the service-info.yaml - * @param url example https://ghe.spotify.net/raw/build/build-artifact-archiver/master/service-info.yaml - * @return example https://ghe.spotify.net/build/build-artifact-archiver/tree/master/service-info.yaml - */ -export function getServiceInfoYamlUrl(url) { - if (!url) { - return url; - } - - url = - url.indexOf('githubusercontent') > -1 - ? url.replace('raw.githubusercontent', 'github').replace('/master/', '/tree/master/') - : url.replace('/master/', '/tree/master/').replace('/raw/', '/'); - - return url; -} - -/** - * Normalize a sysmodel Component - * It adds serviceInfoLocation and gheRepoUrl as properties - */ -export function normalizeComponent(component) { - const serviceInfoLocation = getServiceInfoYamlUrl(component.componentInfoLocationUrl); - const gheRepoUrl = _getRepoUrl(serviceInfoLocation); - return { - ...component, - serviceInfoLocation, - gheRepoUrl, - }; -} diff --git a/frontend/packages/shared/apis/user/AppUserApi.test.ts b/frontend/packages/shared/apis/user/AppUserApi.test.ts deleted file mode 100644 index 895b95efab..0000000000 --- a/frontend/packages/shared/apis/user/AppUserApi.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import AppUserApi from './AppUserApi'; -import AppAuth, { AuthInfo } from 'core/app/auth'; - -describe('AppUserApi', () => { - it('should fail if auth init fails', async () => { - const userApi = new AppUserApi({ - auth: async (): Promise => { - throw new Error('NOPE'); - }, - } as typeof AppAuth); - await expect(userApi.getUser()).rejects.toThrow('NOPE'); - }); - - it('should forward user info', async () => { - const userApi = new AppUserApi({ - auth: async () => ({ - userInfo: { - id: 'my-id', - email: 'my@mail.com', - groups: [{ type: 'squad', id: 'my-group' }], - }, - }), - profile: async () => ({ - fullName: 'Full Name', - givenName: 'Given', - avatarData: 'a-b_c', - }), - } as typeof AppAuth); - await expect(userApi.getUser()).resolves.toEqual({ - id: 'my-id', - email: 'my@mail.com', - groups: [{ type: 'squad', id: 'my-group' }], - isMocked: false, - }); - await expect(userApi.getProfile()).resolves.toEqual({ - fullName: 'Full Name', - givenName: 'Given', - avatarUrl: 'data:image/jpeg;base64,a+b/c', - }); - }); - - it('should return the same object for multiple calls', async () => { - const userApi = new AppUserApi({ - auth: async () => ({ userInfo: {} }), - } as typeof AppAuth); - - const user = await userApi.getUser(); - await expect(userApi.getUser()).resolves.toBe(user); - }); - - it('should derive given name from full name', async () => { - const userApi = new AppUserApi({ - auth: async () => ({ userInfo: { id: 'unused' } }), - profile: async () => ({ fullName: 'Mocky McMockface' }), - } as typeof AppAuth); - await expect(userApi.getProfile()).resolves.toEqual({ - fullName: 'Mocky McMockface', - givenName: 'Mocky', - }); - }); - - it('should ignore complicated full names', async () => { - const userApi = new AppUserApi({ - auth: async () => ({ userInfo: { id: 'mocky-id' } }), - profile: async () => ({ fullName: 'Dr. Mocky McMockface' }), - } as typeof AppAuth); - await expect(userApi.getProfile()).resolves.toEqual({ - fullName: 'Dr. Mocky McMockface', - givenName: 'mocky-id', - }); - }); - - it('should derive given name from id', async () => { - const userApi = new AppUserApi({ - auth: async () => ({ userInfo: { id: 'mocky' } }), - profile: async () => ({}), - } as typeof AppAuth); - await expect(userApi.getProfile()).resolves.toEqual({ givenName: 'mocky' }); - }); -}); diff --git a/frontend/packages/shared/apis/user/AppUserApi.ts b/frontend/packages/shared/apis/user/AppUserApi.ts deleted file mode 100644 index f5d5c7badd..0000000000 --- a/frontend/packages/shared/apis/user/AppUserApi.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { AppAuth } from 'core/app/auth'; -import { UserApi, User, UserProfile } from './types'; - -export default class AppUserApi implements UserApi { - private userPromise: Promise | undefined; - private profilePromise: Promise | undefined; - - constructor(private readonly appAuth: AppAuth) {} - - async getUser(): Promise { - if (!this.userPromise) { - this.userPromise = this.loadUser(); - } - return this.userPromise; - } - - async getProfile(): Promise { - if (!this.profilePromise) { - this.profilePromise = this.loadProfile(); - } - return this.profilePromise; - } - - private async loadUser(): Promise { - const authInfo = await this.appAuth.auth(); - const { userInfo } = authInfo; - - return { - id: userInfo.id, - email: userInfo.email, - groups: userInfo.groups, - isMocked: false, - }; - } - - private async loadProfile(): Promise { - const profilePromise = this.appAuth.profile(); - const user = await this.getUser(); - try { - const profile = await profilePromise; - - let { fullName, givenName } = profile; - - if (!fullName) { - fullName = user.email; - } - - if (!givenName) { - if (fullName) { - const nameParts = fullName.split(/\s+/); - if (nameParts.length === 2) { - givenName = nameParts[0]; - } else { - givenName = user.id; - } - } else { - givenName = user.id; - } - } - - let avatarUrl: string | undefined; - if (profile.avatarData) { - avatarUrl = `data:image/jpeg;base64,${profile.avatarData.replace(/_/g, '/').replace(/-/g, '+')}`; - } - - return { - fullName, - givenName, - avatarUrl, - }; - } catch (error) { - console.error(`Failed to load user profile, ${error}`); - - return { - fullName: user.email, - givenName: user.id, - }; - } - } -} diff --git a/frontend/packages/shared/apis/user/MockUserApi.test.ts b/frontend/packages/shared/apis/user/MockUserApi.test.ts deleted file mode 100644 index 61ba8228de..0000000000 --- a/frontend/packages/shared/apis/user/MockUserApi.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import MockUserApi from './MockUserApi'; -import { User, UserProfile } from './types'; - -describe('MockUserApi', () => { - it('should return the user it was provided', async () => { - const userInfo = ({} as unknown) as User; - const userProfile = ({} as unknown) as UserProfile; - const userApi = new MockUserApi(userInfo, userProfile); - await expect(userApi.getUser()).resolves.toBe(userInfo); - await expect(userApi.getProfile()).resolves.toBe(userProfile); - }); -}); diff --git a/frontend/packages/shared/apis/user/MockUserApi.ts b/frontend/packages/shared/apis/user/MockUserApi.ts deleted file mode 100644 index 3c93a251a4..0000000000 --- a/frontend/packages/shared/apis/user/MockUserApi.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { UserApi, User, UserProfile } from './types'; - -export const defaultMockUser: User = { - id: 'mockuser', - email: 'mockuser@spotify.com', - groups: [ - { - id: 'tech', - type: '', - }, - { - id: 'mocksquad', - type: 'squad', - }, - ], - isMocked: true, -}; - -export const defaultMockUserProfile: UserProfile = { - fullName: 'Mocky McMockFace', - givenName: 'Mocky', - avatarUrl: 'my-avatar.jpeg', -}; - -export default class MockUserApi implements UserApi { - constructor( - private readonly user: User = defaultMockUser, - private readonly profile: UserProfile = defaultMockUserProfile, - ) {} - - async getUser(): Promise { - return this.user; - } - - async getProfile(): Promise { - return this.profile; - } -} diff --git a/frontend/packages/shared/apis/user/OverrideUserApi.test.ts b/frontend/packages/shared/apis/user/OverrideUserApi.test.ts deleted file mode 100644 index cd59ce7ffc..0000000000 --- a/frontend/packages/shared/apis/user/OverrideUserApi.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import OverrideUserApi from './OverrideUserApi'; -import { withLogCollector } from 'testUtils'; - -jest.mock('shared/apis/backstage/graphqlClient', () => { - return { - graphqlClient: { - async query({ variables: { username } }: any): Promise { - if (username === 'bad') { - return { - errors: [new Error('NOPE')], - }; - } else if (username === 'reallybad') { - return { - errors: [new Error('NOPE'), new Error('NEIN')], - }; - } else { - return { - data: { - user: { - fullName: 'Mocky Override', - email: 'mover@spotify.com', - squads: [{ id: 'tools' }], - ldapGroups: ['tech'], - }, - }, - }; - } - }, - }, - }; -}); - -describe('OverrideUserApi', () => { - it('should fetch information about mocked user from graphql', async () => { - const userApi = new OverrideUserApi('mocky'); - await expect(userApi.getUser()).resolves.toEqual({ - id: 'mocky', - email: 'mover@spotify.com', - groups: [ - { - id: 'tools', - type: 'squad', - }, - { - id: 'tech', - type: '', - }, - ], - isMocked: true, - }); - - await expect(userApi.getProfile()).resolves.toEqual({ - fullName: 'Mocky Override', - givenName: 'mocky', - }); - }); - - it('should fail on error to fetch information', async () => { - const { error } = await withLogCollector(['error'], async () => { - const userApi = new OverrideUserApi('bad'); - await expect(userApi.getUser()).rejects.toThrowError('NOPE'); - }); - expect(error.length).toBe(0); - }); - - it('should log additional errors', async () => { - const { error } = await withLogCollector(['error'], async () => { - const userApi = new OverrideUserApi('reallybad'); - await expect(userApi.getUser()).rejects.toThrowError('NOPE'); - }); - expect(error).toEqual(['Additional error when loading override user data, NEIN']); - }); -}); diff --git a/frontend/packages/shared/apis/user/OverrideUserApi.ts b/frontend/packages/shared/apis/user/OverrideUserApi.ts deleted file mode 100644 index a331f73aa0..0000000000 --- a/frontend/packages/shared/apis/user/OverrideUserApi.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { UserApi, User, UserProfile } from './types'; -import { graphqlClient } from 'shared/apis/backstage/graphqlClient'; -import gql from 'graphql-tag'; - -const query = gql` - query($username: String!) { - user(username: $username) { - fullName - email - squads { - id - } - ldapGroups - } - } -`; - -type QueryResult = { - user: { - fullName: string; - email: string; - squads: { id: string }[]; - ldapGroups: string[]; - }; -}; - -export default class OverrideUserApi implements UserApi { - private graphqlPromise?: Promise; - - constructor(private readonly userId: string) {} - - async getUser(): Promise { - if (!this.graphqlPromise) { - this.graphqlPromise = this.loadGraphqlData(); - } - - const { user } = await this.graphqlPromise; - - return { - id: this.userId, - email: user.email, - groups: [ - ...user.squads.map(({ id }: { id: string }) => ({ id, type: 'squad' })), - ...user.ldapGroups.map((id: string) => ({ id, type: '' })), - ], - isMocked: true, - }; - } - - async getProfile(): Promise { - if (!this.graphqlPromise) { - this.graphqlPromise = this.loadGraphqlData(); - } - - const { user } = await this.graphqlPromise; - - return { - fullName: user.fullName, - givenName: this.userId, - }; - } - - private async loadGraphqlData(): Promise { - const result = await graphqlClient.query({ query, variables: { username: this.userId } }); - - if (result.errors) { - const [error, ...additionalErrors] = result.errors; - if (additionalErrors.length) { - for (const additionalError of additionalErrors) { - console.error(`Additional error when loading override user data, ${additionalError.message}`); - } - } - throw new Error(error.message); - } - - return result.data; - } -} diff --git a/frontend/packages/shared/apis/user/UserInfoBarrier.test.tsx b/frontend/packages/shared/apis/user/UserInfoBarrier.test.tsx deleted file mode 100644 index 7d9fb168cf..0000000000 --- a/frontend/packages/shared/apis/user/UserInfoBarrier.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { FC } from 'react'; -import { render } from '@testing-library/react'; -import { withLogCollector } from 'testUtils'; -import { User } from './types'; -import MockUserApi from './MockUserApi'; -import UserInfoBarrier, { UserInfoProvider, useUser, withUser } from './UserInfoBarrier'; - -describe('useUser', () => { - it('should fail if no context is available', () => { - const MyComponent = () => { - const user = useUser(); - return I am {user.id}; - }; - - const logs = withLogCollector(['error'], () => { - expect(() => render()).toThrowError(/No user info context available/); - }); - expect(logs.error.length).toBe(2); - expect(logs.error[0]).toMatch(/^Error: Uncaught \[Error: No user info context available/); - expect(logs.error[1]).toMatch(/^The above error occurred in the component/); - }); -}); - -describe('withUser', () => { - it('should fail if no context is available', () => { - const MyComponent: FC<{ user: User }> = ({ user }) => { - return I am {user.id}; - }; - const MyHOC = withUser(MyComponent); - - const logs = withLogCollector(['error'], () => { - expect(() => render()).toThrowError(/No user info context available/); - }); - expect(logs.error.length).toBe(2); - expect(logs.error[0]).toMatch(/^Error: Uncaught \[Error: No user info context available/); - expect(logs.error[1]).toMatch(/^The above error occurred in the component/); - }); -}); - -describe('', () => { - it('should make user available', async () => { - const MyComponent = () => { - const user = useUser(); - return I am {user.id}; - }; - - const rendered = render( - - - , - ); - - const text = 'I am mockuser'; - expect(rendered.queryByText(text)).toBeNull(); - await rendered.findByText(text); - }); - - it('should show errors to fetch user', async () => { - const MyComponent = () => { - const user = useUser(); - return I am {user.id}; - }; - - const rendered = render( - { - throw new Error('NOPE'); - }, - getProfile: async () => ({ givenName: 'unused', fullName: 'unused' }), - }} - > - - , - ); - - await rendered.findByText('An error occurred, please copy to the clipboard and report to tools.'); - }); -}); - -describe('', () => { - it('should make user available', async () => { - const userInfo = { - id: 'derpy', - } as User; - - const MyComponent = () => { - const user = useUser(); - return I am {user.id}; - }; - - const rendered = render( - - - , - ); - - rendered.getByText('I am derpy'); - }); - - it('should make user available to HOC', async () => { - const userInfo = { - id: 'derpy', - } as User; - - const MyComponent = withUser(({ user }) => { - return I am {user.id}; - }); - - const rendered = render( - - - , - ); - - rendered.getByText('I am derpy'); - }); -}); diff --git a/frontend/packages/shared/apis/user/UserInfoBarrier.tsx b/frontend/packages/shared/apis/user/UserInfoBarrier.tsx deleted file mode 100644 index 7d16cc95cc..0000000000 --- a/frontend/packages/shared/apis/user/UserInfoBarrier.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { FC, createContext, useContext } from 'react'; -import hoistStatics from 'hoist-non-react-statics'; -import { useAsync } from 'react-use'; -import Progress from 'shared/components/Progress'; -import ErrorComponent from 'shared/components/Error'; -import { UserApi, User } from './types'; - -const Context = createContext(undefined); - -type Props = { - userApi: UserApi; -}; - -const UserInfoBarrier: FC = ({ children, userApi }) => { - const user = useAsync(() => userApi.getUser()); - - if (user.loading) { - return ; - } - if (user.error) { - return ; - } - - return ; -}; - -export const UserInfoProvider: FC<{ user: User }> = ({ children, user }) => ( - -); - -export function useUser(): User { - const user = useContext(Context); - if (!user) { - throw new Error( - 'No user info context available, make sure to use this hook inside UserInfoBarrier or UserInfoProvider', - ); - } - return user; -} - -type UserProps = { - user: User; -}; - -export function withUser

    (WrappedComponent: React.ComponentType

    ) { - const Hoc: FC> = props => { - const user = useUser(); - return ; - }; - const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; - - Hoc.displayName = `withUser(${displayName})`; - - hoistStatics(Hoc, WrappedComponent); - - return Hoc; -} - -export default UserInfoBarrier; diff --git a/frontend/packages/shared/apis/user/getUserApi.ts b/frontend/packages/shared/apis/user/getUserApi.ts deleted file mode 100644 index 17b4983236..0000000000 --- a/frontend/packages/shared/apis/user/getUserApi.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { UserApi } from './types'; -import { getOverrideUser } from './overrideUtils'; -import OverrideUserApi from './OverrideUserApi'; -import AppUserApi from './AppUserApi'; -import MockUserApi from './MockUserApi'; -import appAuth from 'core/app/auth'; - -// TODO(freben): Exposed like this temporarily; should be supplied via some API provider instead -let instance: UserApi; -export default function getUserApi() { - if (!instance) { - if (process.env.NODE_ENV === 'test') { - instance = new MockUserApi(); - } else { - const overrideUser = getOverrideUser(); - if (overrideUser) { - instance = new OverrideUserApi(overrideUser); - } else { - instance = new AppUserApi(appAuth); - } - } - } - - return instance; -} diff --git a/frontend/packages/shared/apis/user/hooks.ts b/frontend/packages/shared/apis/user/hooks.ts deleted file mode 100644 index a4d1abc6e6..0000000000 --- a/frontend/packages/shared/apis/user/hooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useAsync } from 'react-use'; -import { getUserApi } from '.'; -import { UserProfile } from './types'; - -export const useProfile = (): UserProfile | undefined => { - const state = useAsync(() => getUserApi().getProfile()); - - return state.value; -}; diff --git a/frontend/packages/shared/apis/user/index.ts b/frontend/packages/shared/apis/user/index.ts deleted file mode 100644 index ce527f5d41..0000000000 --- a/frontend/packages/shared/apis/user/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { default as AppUserApi } from './AppUserApi'; -export { default as UserInfoBarrier, UserInfoProvider, useUser, withUser } from './UserInfoBarrier'; -export { default as MockUserApi, defaultMockUser } from './MockUserApi'; -export { default as OverrideUserApi } from './OverrideUserApi'; -export { default as getUserApi } from './getUserApi'; -export * from './overrideUtils'; -export * from './hooks'; -export * from './types'; diff --git a/frontend/packages/shared/apis/user/overrideUtils.test.ts b/frontend/packages/shared/apis/user/overrideUtils.test.ts deleted file mode 100644 index 354c3e5b51..0000000000 --- a/frontend/packages/shared/apis/user/overrideUtils.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getOverrideUser, setOverrideUser } from './overrideUtils'; - -describe('overrideUtils', () => { - beforeEach(() => { - delete window.location; - window.location = { search: '' } as Location; - }); - - afterEach(() => { - localStorage.clear(); - }); - - it('should read from local storage', () => { - expect(getOverrideUser()).toBeUndefined(); - localStorage.setItem('usernameOverride', '"mocky"'); - expect(getOverrideUser()).toBe('mocky'); - localStorage.removeItem('usernameOverride'); - expect(getOverrideUser()).toBeUndefined(); - setOverrideUser('mocky'); - expect(getOverrideUser()).toBe('mocky'); - }); - - it('should read from query params', () => { - expect(getOverrideUser()).toBeUndefined(); - - window.location.search = '?bypassLogin'; - expect(localStorage.getItem('usernameOverride')).toBeNull(); - expect(getOverrideUser()).toBe('anonymous'); - expect(localStorage.getItem('usernameOverride')).toBe('"anonymous"'); - - window.location.search = '?bypassLogin&overrideUser=mocky'; - expect(localStorage.getItem('usernameOverride')).toBe('"anonymous"'); - expect(getOverrideUser()).toBe('mocky'); - expect(localStorage.getItem('usernameOverride')).toBe('"mocky"'); - setOverrideUser(); - expect(localStorage.getItem('usernameOverride')).toBeNull(); - expect(getOverrideUser()).toBe('mocky'); - expect(localStorage.getItem('usernameOverride')).toBe('"mocky"'); - - window.location.search = ''; - expect(localStorage.getItem('usernameOverride')).toBe('"mocky"'); - expect(getOverrideUser()).toBe('mocky'); - expect(localStorage.getItem('usernameOverride')).toBe('"mocky"'); - setOverrideUser(); - expect(localStorage.getItem('usernameOverride')).toBeNull(); - expect(getOverrideUser()).toBeUndefined(); - }); -}); diff --git a/frontend/packages/shared/apis/user/overrideUtils.ts b/frontend/packages/shared/apis/user/overrideUtils.ts deleted file mode 100644 index e0f6fba9d7..0000000000 --- a/frontend/packages/shared/apis/user/overrideUtils.ts +++ /dev/null @@ -1,32 +0,0 @@ -const KEY = 'usernameOverride'; -const BYPASS_PARAM = 'bypassLogin'; -const OVERRIDE_PARAM = 'overrideUser'; - -export const getOverrideUser = (): string | undefined => { - const params = new URLSearchParams(window.location.search); - - if (params.has(BYPASS_PARAM)) { - const id = params.get(OVERRIDE_PARAM) || 'anonymous'; - setOverrideUser(id); - return id; - } - - const stored = localStorage.getItem(KEY); - if (stored) { - try { - return JSON.parse(stored); - } catch { - /* ignored */ - } - } - - return undefined; -}; - -export const setOverrideUser = (user?: string) => { - if (!user) { - localStorage.removeItem(KEY); - return; - } - localStorage.setItem(KEY, JSON.stringify(user)); -}; diff --git a/frontend/packages/shared/apis/user/types.ts b/frontend/packages/shared/apis/user/types.ts deleted file mode 100644 index 34339c7de3..0000000000 --- a/frontend/packages/shared/apis/user/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Api from 'shared/pluginApi/Api'; - -export type Group = { - id: string; - type: string; -}; - -export type User = { - id: string; - email: string; - groups: Group[]; - isMocked: boolean; -}; - -export type UserProfile = { - fullName: string; - givenName: string; - avatarUrl?: string; -}; - -export type UserApi = { - getUser(): Promise; - getProfile(): Promise; -}; - -export const userApi = new Api({ - id: 'user', - title: 'User', - description: 'API for getting information about the logged in user', -}); diff --git a/frontend/packages/shared/auth/GheComposable.js b/frontend/packages/shared/auth/GheComposable.js deleted file mode 100644 index 7d9e4360eb..0000000000 --- a/frontend/packages/shared/auth/GheComposable.js +++ /dev/null @@ -1,94 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { GheClient } from 'shared/apis/ghe'; -import { getCachedGheAccessToken } from 'shared/apis/gheAuth/gheAuth'; -import * as gheAuthActions from 'shared/apis/gheAuth/actions'; -import Progress from 'shared/components/Progress'; -import GheSignInDialog from 'shared/auth/GheSignInDialog'; - -const mapStateToProps = state => ({ - isLoggedIn: !!state.gheAuth.user, - gheUser: state.gheAuth.user, -}); - -/** - * withGheAuth is a higher-order-component (HOC) which takes any React component - * and returns a component that will display an auth dialogue if the user - * is not authenticated with GHE. If the user is authenticated, the - * `gheUser` and `gheApi` props will be provided to the wrapped component. - * Options: - * - showDialog: Automatically show the login dialog if user is not authenticated with GHE. (Default: true) - * - message: The message displayed in the dialog content area - */ -export const withGheAuth = (opts = {}) => BaseComponent => { - class GheAuthorizedView extends Component { - state = { - loading: false, - }; - - options = { - showDialog: true, - ...opts, - }; - - checkRequiredScopes(gheApi) { - if (this.options.requiredScopes) { - gheApi.verifyUserHasScopes(this.options.requiredScopes).then(response => { - if (!response) { - if (this.props.isLoggedIn) { - this.props.dispatch(gheAuthActions.gheLogOut()); - } - this.setState({ loading: false }); - } - }); - } - } - - componentDidMount() { - if (this.props.isLoggedIn) { - this.checkRequiredScopes(GheClient.fromAccessToken(this.props.gheUser.accessToken)); - } else { - // Did we have a previous token lying around? If so, try to make use of it. - const accessToken = getCachedGheAccessToken(); - - if (accessToken) { - this.setState({ loading: true }); - - const gheApi = GheClient.fromAccessToken(accessToken); - - // Attempts to fetch user info and perform a login action with a pre-existing token. If it fails, - // we just set loading to false and let the route insert a sign-in page as a result (see below). - gheApi - .getUserInfo() - .then(userInfo => this.props.dispatch(gheAuthActions.gheLoggedIn(accessToken, userInfo))) - .catch(error => { - console.error('GHE Authorization Error when retrieving user info', error); - - this.setState({ loading: false }); - }); - - this.checkRequiredScopes(gheApi); - } - } - } - - render() { - const { isLoggedIn } = this.props; - const { loading } = this.state; - const { showDialog, message } = this.options; - - const props = Object.assign({}, this.props); - if (isLoggedIn) { - props.gheApi = GheClient.fromAccessToken(this.props.gheUser.accessToken); - } else if (loading) { - return ; - } else if (showDialog) { - return ; - } - - return ; - } - } - - return connect(mapStateToProps)(GheAuthorizedView); -}; diff --git a/frontend/packages/shared/auth/GheSignInDialog.js b/frontend/packages/shared/auth/GheSignInDialog.js deleted file mode 100644 index 3b37b95674..0000000000 --- a/frontend/packages/shared/auth/GheSignInDialog.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; -import { compose } from 'recompose'; -import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@material-ui/core'; -import { GheIcon } from 'shared/icons'; -import OAuthGheSignInRequestPage from 'shared/apis/gheAuth/OAuthGheSignInRequestPage'; - -const DEFAULT_GHE_MSG = `Some functionality requires you to be logged in with GHE. -In order to log in press the button.`; -const GheSignInDialog = ({ message = DEFAULT_GHE_MSG, originalUrl, open = true }) => ( -

    - - - Authenticate to Github Enterprise - - - {message} - - - - - -); -export default compose( - withRouter, - connect((state, props) => { - const location = props.location; - const { pathname, search, hash } = location; - return { - originalUrl: pathname + search + hash, - }; - }), -)(GheSignInDialog); diff --git a/frontend/packages/shared/auth/__mocks__/GheComposable.js b/frontend/packages/shared/auth/__mocks__/GheComposable.js deleted file mode 100644 index 7d840852c0..0000000000 --- a/frontend/packages/shared/auth/__mocks__/GheComposable.js +++ /dev/null @@ -1,11 +0,0 @@ -import React, { Component } from 'react'; - -export const withGheAuth = () => BaseComponent => { - class GheAuthorizedView extends Component { - render() { - return ; - } - } - - return GheAuthorizedView; -}; diff --git a/frontend/packages/shared/components/BreakHints.js b/frontend/packages/shared/components/BreakHints.js deleted file mode 100644 index 0040c68e8e..0000000000 --- a/frontend/packages/shared/components/BreakHints.js +++ /dev/null @@ -1,24 +0,0 @@ -import React, { Fragment } from 'react'; - -const TOKEN = ''; - -/** - * Return a component with React tags in strategic places in the given string body, - * which gives the browser hints about where to break lines. This is useful for long-running - * things like IDs that are to be displayed. - */ -const BreakHints = ({ body }) => { - const parts = body - .replace(/([-_./])/g, (...match) => `${TOKEN}${match[1]}`) - .replace(/([a-z])([A-Z])/g, (...match) => `${match[1]}${TOKEN}${match[2]}`) - .split(TOKEN); - let n = 0; - const children = parts.reduce((comps, next) => { - return [...comps, next, ]; - }, []); - // Remove unnecessary trailing tag - children.pop(); - return {children}; -}; - -export default BreakHints; diff --git a/frontend/packages/shared/components/BreakHints.test.js b/frontend/packages/shared/components/BreakHints.test.js deleted file mode 100644 index bf79114abc..0000000000 --- a/frontend/packages/shared/components/BreakHints.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import BreakHints from './BreakHints'; - -describe('BreakHints', () => { - it('Inserts wbr tags in camelCase expressions', () => { - const { container } = render(); - expect(container.innerHTML).toBe('aCamelCaseWord'); - }); - - it('Inserts wbr tags with periods', () => { - const { container } = render(); - expect(container.innerHTML).toBe('a.b'); - }); - - it('Inserts wbr tags with underscores', () => { - const { container } = render(); - expect(container.innerHTML).toBe('a_b'); - }); - - it('Inserts wbr tags with hyphens', () => { - const { container } = render(); - expect(container.innerHTML).toBe('a-b'); - }); - - it('Inserts wbr tags with forward slashes', () => { - const { container } = render(); - expect(container.innerHTML).toBe('a/b/c'); - }); -}); diff --git a/frontend/packages/shared/components/Button/Button.js b/frontend/packages/shared/components/Button/Button.js deleted file mode 100644 index dacb4ae600..0000000000 --- a/frontend/packages/shared/components/Button/Button.js +++ /dev/null @@ -1,56 +0,0 @@ -import React, { Component } from 'react'; -import { Button as MUIButton } from '@material-ui/core'; -import PropTypes from 'prop-types'; -import { - GA_DEFAULT_PLUGIN_ID, - GA_DEFAULT_PLUGIN_OWNER, - GA_LABEL_UNKNOWN, -} from 'shared/apis/events/GoogleAnalyticsEvent'; -import textContent from 'react-addons-text-content'; -import { withPluginContext } from 'core/app/withPluginContext'; -import { getGAContext, GoogleAnalyticsEvent, sendGAEvent } from 'shared/apis/events'; - -class Button extends Component { - static propTypes = { - onClick: PropTypes.func, // a click handler - gaprops: PropTypes.shape({ - label: PropTypes.string, // string e.g. link text - value: PropTypes.number, // integer - }), - }; - - render() { - const { children, ...otherProps } = this.props; - return ( - - {children} - - ); - } - - button_onClickHandler = e => { - this.trackEvent(); - - if (this.props.onClick && typeof this.props.onClick === 'function') { - this.props.onClick(e); - } - }; - - trackEvent = () => { - const { children, plugin, gaprops = {} } = this.props; - - const pluginId = plugin && plugin.id ? plugin.id : GA_DEFAULT_PLUGIN_ID; - const btnLabel = textContent(children) || GA_LABEL_UNKNOWN; - - const eventCategory = pluginId; - const eventAction = GoogleAnalyticsEvent.BTN_CLICK; - const eventLabel = gaprops.label || btnLabel; - const eventValue = gaprops.value; - const eventOwner = plugin && plugin.owner ? plugin.owner : GA_DEFAULT_PLUGIN_OWNER; - const eventContext = getGAContext(this); - - sendGAEvent(eventCategory, eventAction, eventLabel, eventValue, eventOwner, eventContext); - }; -} - -export default withPluginContext(Button); diff --git a/frontend/packages/shared/components/Button/index.js b/frontend/packages/shared/components/Button/index.js deleted file mode 100644 index efe8c800c0..0000000000 --- a/frontend/packages/shared/components/Button/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Button'; diff --git a/frontend/packages/shared/components/ColumnStack.test.js b/frontend/packages/shared/components/ColumnStack.test.js deleted file mode 100644 index 431ee1c39e..0000000000 --- a/frontend/packages/shared/components/ColumnStack.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { render, fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; -import { ColumnStack, useColumnStackControls } from './ColumnStack'; - -const Page1 = () => { - const columnStack = useColumnStackControls(); - return ( -
    - -
    Page 1
    -
    - ); -}; - -const Page2 = () => { - const columnStack = useColumnStackControls(); - return ( -
    - -
    Page 2
    -
    - ); -}; - -describe('', () => { - it('can add and remove columns', async () => { - const rendered = render(} />); - expect(rendered.queryByText('Page 1')).toBeInTheDocument(); - expect(rendered.queryByText('Page 2')).not.toBeInTheDocument(); - fireEvent.click(rendered.getByText('Push')); - await rendered.findByText('Page 2'); - fireEvent.click(rendered.getByText('Pop')); - await waitForElementToBeRemoved(() => rendered.queryByText('Page 2')); - }); -}); diff --git a/frontend/packages/shared/components/ColumnStack.tsx b/frontend/packages/shared/components/ColumnStack.tsx deleted file mode 100644 index ab6646f28b..0000000000 --- a/frontend/packages/shared/components/ColumnStack.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React, { FC, RefObject, createRef, createContext, useCallback, useState, useContext, useEffect } from 'react'; -import { Theme, makeStyles } from '@material-ui/core'; - -// The number of milliseconds spent transitioning between columns -const transitionMillis = 250; - -// Global unique ID for columns -const getUid = (() => { - let uid = 0; - return () => { - return uid++; - }; -})(); - -const useStyles = makeStyles({ - outer: { - width: 0, - maxWidth: '80vw', - overflow: 'hidden', - transition: `width ${transitionMillis}ms ease-in-out`, - height: '100%', - }, - inner: { - display: 'flex', - flexWrap: 'nowrap', - transition: `margin-left ${transitionMillis}ms ease-in-out`, - width: 0, - marginLeft: 0, - height: '100%', - }, - column: { - flexGrow: 0, - flexShrink: 0, - height: '100%', - overflowX: 'hidden', - overflowY: 'auto', - }, -}); - -type Column = { - id: number; - element: JSX.Element; - columnRef: RefObject; -}; - -type InternalState = { - columns: Column[]; - columnIndex: number; -}; - -type State = { - columns: Column[]; - columnIndex: number; - push: (element: JSX.Element) => void; - pop: () => void; -}; - -const columnStackContext = createContext({} as State); - -const ProvideColumnStackContext: FC<{}> = ({ children }) => { - const state = useProvideState(); - return {children}; -}; - -const useProvideState: () => State = () => { - const [state, setState] = useState({ columns: [], columnIndex: -1 }); - - const push = useCallback( - (element: JSX.Element) => { - setState(oldState => { - const nextUid = getUid(); - const nextColumnIndex = oldState.columnIndex + 1; - const nextColumn: Column = { id: nextUid, element, columnRef: createRef() }; - const nextColumns = [...oldState.columns.slice(0, nextColumnIndex), nextColumn]; - return { - columns: nextColumns, - columnIndex: nextColumnIndex, - }; - }); - }, - [setState], - ); - - const pop = useCallback(() => { - setState(oldState => { - if (oldState.columnIndex < 0) { - return oldState; - } - - // We postpone the deletion of the columns to the right, since they will still be partially - // visible to the right during the transition period - const idsToDelete = oldState.columns.slice(oldState.columnIndex, oldState.columns.length).map(c => c.id); - setTimeout(() => { - setState(x => ({ - columns: x.columns.filter(c => !idsToDelete.includes(c.id)), - columnIndex: x.columnIndex, - })); - }, transitionMillis); - - return { - columns: oldState.columns, - columnIndex: oldState.columnIndex - 1, - }; - }); - }, [setState]); - - return { - columns: state.columns, - columnIndex: state.columnIndex, - push, - pop, - }; -}; - -const useColumnSizes = (state: State) => { - const [outerWidth, setOuterWidth] = useState(0); - const [innerWidth, setInnerWidth] = useState(0); - const [innerMargin, setInnerMargin] = useState(0); - const [epoch, setEpoch] = useState(0); - - const columnWidth = (column: Column) => (column.columnRef.current ? column.columnRef.current.offsetWidth : 0); - - useEffect(() => { - // Refs may not be fulfilled immediately; we can't update these measurements until they are. - // So give up and retry again soon. - if (state.columnIndex < 0 || state.columns.some(c => !c.columnRef.current)) { - setEpoch(epoch + 1); - return; - } - - setOuterWidth(columnWidth(state.columns[state.columnIndex])); - setInnerWidth(state.columns.reduce((x, column) => x + columnWidth(column), 0)); - setInnerMargin(-state.columns.slice(0, state.columnIndex).reduce((x, column) => x + columnWidth(column), 0)); - }, [state, epoch]); - - return { outerWidth, innerWidth, innerMargin }; -}; - -const ColumnStackContents: FC = ({ initialElement }) => { - const state = useContext(columnStackContext); - const { outerWidth, innerWidth, innerMargin } = useColumnSizes(state); - const classes = useStyles(); - - useEffect(() => { - if (!state.columns.length) { - state.push(initialElement); - } - }, [state, initialElement]); - - const { columns, columnIndex } = state; - if (columnIndex < 0 || columnIndex >= columns.length) { - return null; - } - - return ( -
    -
    - {columns.map(column => ( -
    - {column.element} -
    - ))} -
    -
    - ); -}; - -/* - * Public API - */ - -export type ColumnStackProps = { - initialElement: JSX.Element; -}; - -export type ColumnStackControls = { - push: (element: JSX.Element) => void; - pop: () => void; -}; - -export const ColumnStack: FC = props => { - return ( - - - - ); -}; - -export const useColumnStackControls: () => ColumnStackControls = () => { - const state = useContext(columnStackContext); - return { - push: state.push, - pop: state.pop, - }; -}; diff --git a/frontend/packages/shared/components/ConfirmPrompt/ConfirmPrompt.js b/frontend/packages/shared/components/ConfirmPrompt/ConfirmPrompt.js deleted file mode 100644 index b5de167692..0000000000 --- a/frontend/packages/shared/components/ConfirmPrompt/ConfirmPrompt.js +++ /dev/null @@ -1,113 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core'; - -import { snackbarOpenSuccess } from 'shared/apis/snackbar/actions'; -import ProgressOverlay from 'shared/components/ProgressOverlay'; -import ErrorPanel from 'shared/components/ErrorPanel'; -import Button from 'shared/components/Button'; - -/** - * Confirmation prompt. - * - * Wrap e.g. a button in this component to trigger a confirmation modal. - * Errors in the onConfirm handler is handled by this component and informs the user. - */ -class ConfirmPrompt extends React.Component { - state = { - loading: false, - error: null, - open: false, - errorId: null, - }; - - open = () => { - this.setState({ open: true }); - }; - - close = () => { - this.setState({ open: false }); - }; - - reset = () => { - // we're never unmounted so we reset state - this.setState({ error: null, loading: false, errorId: null }); - }; - - handleConfirm = async () => { - const { successMessage = 'Success!', onConfirm, snackbarOpenSuccess } = this.props; - - this.setState({ loading: true, error: null }); - try { - await onConfirm(); - if (successMessage) { - snackbarOpenSuccess(successMessage); - } - this.close(); - } catch (e) { - const errorId = Math.random() - .toString(36) - .substring(7); - this.setState({ error: e, loading: false, errorId }); - } - }; - - render() { - const { - children, - content = 'Are you sure?', - title = 'Confirm', - onConfirm, - confirmButtonText = 'Ok', - confirmButtonDisabled = false, - failureMessage = 'Operation failed.', - longFailureMessage, - ContainerElement = 'span', - gacontext = 'Confirm Prompt', - ...other - } = this.props; - delete other.successMessage; - delete other.snackbarOpenSuccess; - const { open, error, loading, errorId } = this.state; - - return ( - - - - {title} - -
    {content}
    -
    - - {onConfirm ? ( - - ) : null} - - - {error && ( - - )} -
    -
    - {children} -
    - ); - } -} - -export default connect(null, { snackbarOpenSuccess })(ConfirmPrompt); diff --git a/frontend/packages/shared/components/ConfirmPrompt/index.js b/frontend/packages/shared/components/ConfirmPrompt/index.js deleted file mode 100644 index 0dde22caf9..0000000000 --- a/frontend/packages/shared/components/ConfirmPrompt/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ConfirmPrompt'; diff --git a/frontend/packages/shared/components/CopyTextButton.js b/frontend/packages/shared/components/CopyTextButton.js deleted file mode 100644 index 9d507125e7..0000000000 --- a/frontend/packages/shared/components/CopyTextButton.js +++ /dev/null @@ -1,93 +0,0 @@ -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { IconButton, Tooltip, withStyles } from '@material-ui/core'; -import { CopyIcon } from 'shared/icons'; - -const buttonStyles = theme => ({ - button: { - '&:hover': { - backgroundColor: theme.palette.highlight, - cursor: 'pointer', - }, - }, -}); - -/** - * Copy text button with visual feedback in the form of - * - a hover color - * - click ripple - * - Tooltip shown when user has clicked - * - * Properties: - * - text: the text to be copied - * - tooltipDelay: Number os ms to show the tooltip, default: 1000ms - * - tooltipText: Text to show in the tooltip when user has clicked the button, default: "Text - * copied to clipboard" - * - * Example: - * - */ -class CopyTextButton extends React.Component { - static propTypes = { - text: PropTypes.string.isRequired, - tooltipDelay: PropTypes.number, - tooltipText: PropTypes.string, - }; - - static defaultProps = { - tooltipDelay: 1000, - tooltipText: 'Text copied to clipboard', - }; - - state = { - open: false, - }; - - handleTooltipClose = () => { - this.setState({ open: false }); - }; - - handleTooltipOpen = () => { - this.setState({ open: true }); - }; - - handleCopyClick = e => { - e.stopPropagation(); - this.handleTooltipOpen(); - try { - this.clipboardInput.select(); - document.execCommand('copy'); - } catch (e) { - console.error(e); - } - }; - - render() { - const { classes, text, tooltipDelay, tooltipText } = this.props; - - return ( - - (this.clipboardInput = el)} - type="text" - style={{ position: 'absolute', top: '-9999px', left: '-9999px' }} - defaultValue={text} - /> - - - - - - - ); - } -} - -export default withStyles(buttonStyles)(CopyTextButton); diff --git a/frontend/packages/shared/components/CopyTextButton.test.js b/frontend/packages/shared/components/CopyTextButton.test.js deleted file mode 100644 index 8740d399c3..0000000000 --- a/frontend/packages/shared/components/CopyTextButton.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { wrapInThemedTestApp } from 'testUtils'; -import CopyTextButton from 'shared/components/CopyTextButton'; - -const props = { - text: 'mockText', - tooltipDelay: 2, - tooltipText: 'mockTooltip', -}; - -describe('', () => { - it('renders without exploding', () => { - const { getByDisplayValue } = render(wrapInThemedTestApp()); - getByDisplayValue('mockText'); - }); - - it('displays tooltip on click', () => { - const spy = jest.fn(); - Object.defineProperty(document, 'execCommand', { value: spy }); - const rendered = render(wrapInThemedTestApp()); - const button = rendered.getByTitle('mockTooltip'); - button.click(); - expect(spy).toHaveBeenCalled(); - rendered.getByText('mockTooltip'); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Cells/ClickableCell.js b/frontend/packages/shared/components/DataGrid/Cells/ClickableCell.js deleted file mode 100644 index 41961a32c2..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/ClickableCell.js +++ /dev/null @@ -1,76 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { CardContent, CardHeader, Popover } from '@material-ui/core'; -import { InfoIcon } from 'shared/icons'; - -import Link from 'shared/components/Link'; - -const ClickableCellDetails = ({ children, title }) => ( - - {title && } - {children} - -); - -/** - * Creates a DataGrid Cell with popover when the text or the info icon is clicked - */ -class ClickableCell extends Component { - static propTypes = { - linkText: PropTypes.string.isRequired, - title: PropTypes.string, - showIcon: PropTypes.bool, - horizontalAlign: PropTypes.string, - }; - - static defaultProps = { - title: null, - showIcon: true, - horizontalAlign: 'left', - }; - - state = { - dropdownAnchorElement: null, - }; - - onOpenDropdown = event => { - this.setState({ - dropdownAnchorElement: event.currentTarget, - }); - event.preventDefault(); - }; - - onCloseDropdown = () => { - this.setState({ - dropdownAnchorElement: null, - }); - }; - - render() { - const { linkText, horizontalAlign, showIcon, title, children } = this.props; - const { dropdownAnchorElement } = this.state; - return ( - - {dropdownAnchorElement && ( - - - {children} - - - )} - - {linkText} {showIcon && } - - - ); - } -} - -export default ClickableCell; diff --git a/frontend/packages/shared/components/DataGrid/Cells/DateCell.js b/frontend/packages/shared/components/DataGrid/Cells/DateCell.js deleted file mode 100644 index 6418a92427..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/DateCell.js +++ /dev/null @@ -1,32 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import moment from 'moment'; - -export default class DateCell extends Component { - static propTypes = { - value: PropTypes.string, - column: PropTypes.shape({ - format: PropTypes.string, - }), - }; - - render() { - const { value, column } = this.props; - const { format, utc } = column; - - if (!value) { - return null; - } - - const date = utc ? moment.utc(value) : moment(value); - - if (format) { - return date.format(format); - } - return date.toString(); - } - - static sort(a, b) { - return moment(a).isBefore(moment(b)) ? -1 : 1; - } -} diff --git a/frontend/packages/shared/components/DataGrid/Cells/DateCell.test.js b/frontend/packages/shared/components/DataGrid/Cells/DateCell.test.js deleted file mode 100644 index 513e821dc2..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/DateCell.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import DateCell from 'shared/components/DataGrid/Cells/DateCell'; - -describe('', () => { - it('renders no date', () => { - render(); - }); - - it('renders a date with format', () => { - const rendered = render(); - rendered.getByText('2020-01-01'); - }); - - it('renders a UTC date', () => { - const rendered = render(); - rendered.getByText('Wed Jan 01 2020 00:00:00 GMT+0000'); - }); - - it('sorts dates', () => { - expect(DateCell.sort(new Date(0), new Date(1))).toBe(-1); - expect(DateCell.sort(new Date(2), new Date(1))).toBe(1); - expect(DateCell.sort(new Date(1), new Date(1))).toBe(1); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Cells/DropdownCell.js b/frontend/packages/shared/components/DataGrid/Cells/DropdownCell.js deleted file mode 100644 index 1178c2a5b4..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/DropdownCell.js +++ /dev/null @@ -1,140 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Icon, Menu, MenuItem } from '@material-ui/core'; -import Link from 'shared/components/Link'; -import Button from 'shared/components/Button'; -import ConfirmPrompt from 'shared/components/ConfirmPrompt'; - -/* - * Example of implementation: - * { - * name: 'actions', - * getCellValue: row => [ - * , - * , - * , - * - * favorite - * - * ], - * renderer: props => , - * } - */ - -export default class DropdownCell extends Component { - static propTypes = { - value: PropTypes.arrayOf(PropTypes.element), - title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), - }; - - static defaultProps = { - value: [], - title: 'Choose...', - }; - - state = { - anchorEl: null, - }; - - openMenu = event => { - this.setState({ anchorEl: event.currentTarget }); - }; - - closeMenu = () => { - this.setState({ anchorEl: null }); - }; - - render() { - const { anchorEl } = this.state; - const { value, title } = this.props; - - return ( - - - - {value.map(item => React.cloneElement(item, { close: this.closeMenu }))} - - - ); - } - - static closeAfterAction = ({ action, close, keepOpen }) => { - if (keepOpen) { - return action; - } else { - return async () => { - try { - await action(); - if (close) close(); - } catch (e) { - throw Error(e); - } - }; - } - }; - - static ConfirmAction(props) { - const { - promptText, - promptTitle, - confirmText, - confirmButtonDisabled, - successMessage, - failureMessage, - content, - children, - gacontext, - } = props; - const onConfirm = DropdownCell.closeAfterAction(props); - - return ( - - {content || children} - - ); - } - - static Action(props) { - const { content, children, ...otherProps } = props; - delete otherProps.action; - delete otherProps.keepOpen; - delete otherProps.close; - const onClick = DropdownCell.closeAfterAction(props); - - return ( - - {content || children} - - ); - } - - static Link(props) { - const { to, content, children, ...otherProps } = props; - delete otherProps.close; - return ( - - {content || children} - - ); - } -} diff --git a/frontend/packages/shared/components/DataGrid/Cells/DropdownCell.test.js b/frontend/packages/shared/components/DataGrid/Cells/DropdownCell.test.js deleted file mode 100644 index 2926b290d9..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/DropdownCell.test.js +++ /dev/null @@ -1,154 +0,0 @@ -import React from 'react'; -import { render, fireEvent, wait } from '@testing-library/react'; -import DropdownCell from './DropdownCell'; -import { wrapInTestApp } from 'testUtils'; -import { snackbarOpenSuccess } from 'shared/apis/snackbar/actions'; - -jest.mock('shared/apis/snackbar/actions'); - -describe('', () => { - it('renders the title', () => { - const { getByText } = render(); - expect(getByText('title')).toBeInTheDocument(); - }); - - it('is disabled if no value is passed down', () => { - const { getByTestId } = render(); - expect(getByTestId('datagrid-dropdown-btn')).toHaveAttribute('disabled'); - }); - - it('renders the content from value when open', () => { - const { queryByText, getByText } = render( - a, - b, - ]} - />, - ); - expect(queryByText('a')).not.toBeInTheDocument(); - expect(queryByText('b')).not.toBeInTheDocument(); - fireEvent.click(getByText('open')); - expect(getByText('a')).toBeInTheDocument(); - expect(getByText('b')).toBeInTheDocument(); - }); -}); - -describe('', () => { - const action = jest.fn(); - const close = jest.fn(); - - afterEach(() => { - action.mockClear(); - close.mockClear(); - snackbarOpenSuccess.mockClear(); - }); - - it('renders the content', () => { - const { getByText } = render(wrapInTestApp(Hello)); - expect(getByText('Hello')).toBeInTheDocument(); - }); - - it('opens a ConfirmPrompt with passed down values if clicked', () => { - const { getByText } = render( - wrapInTestApp( - - Open - , - ), - ); - fireEvent.click(getByText('Open')); - expect(getByText('Prompt')).toBeInTheDocument(); - expect(getByText('Content')).toBeInTheDocument(); - expect(getByText('Confirm')).toBeInTheDocument(); - }); - - it('calls action and close on confirmation', async () => { - const { getByText, getByTestId } = render( - wrapInTestApp( - - Action - , - ), - ); - fireEvent.click(getByText('Action')); - fireEvent.click(getByTestId('prompt-confirm')); - await wait(() => expect(action).toHaveBeenCalled()); - expect(close).toHaveBeenCalled(); - expect(snackbarOpenSuccess).toHaveBeenCalledWith('Success'); - }); - - it('propagates errors to the ConfirmPrompt', async () => { - action.mockImplementationOnce(() => Promise.reject()); - const { getByText, getByTestId } = render( - wrapInTestApp( - - Action - , - ), - ); - fireEvent.click(getByText('Action')); - fireEvent.click(getByTestId('prompt-confirm')); - await wait(() => expect(action).toHaveBeenCalled()); - expect(getByText('Failed')).toBeInTheDocument(); - }); - - it('does not call close if keepOpen is true', async () => { - const { getByText, getByTestId } = render( - wrapInTestApp( - - Action - , - ), - ); - fireEvent.click(getByText('Action')); - fireEvent.click(getByTestId('prompt-confirm')); - await wait(() => expect(action).toHaveBeenCalled()); - expect(close).not.toHaveBeenCalled(); - }); -}); - -describe('', () => { - const action = jest.fn(); - const close = jest.fn(); - - afterEach(() => { - action.mockClear(); - close.mockClear(); - }); - - it('renders the content', () => { - const { getByText } = render(Hello); - expect(getByText('Hello')).toBeInTheDocument(); - }); - - it('performs action on click and closes the menu', async () => { - const { getByText } = render( - - Action - , - ); - fireEvent.click(getByText('Action')); - await wait(() => expect(action).toHaveBeenCalled()); - expect(close).toHaveBeenCalled(); - }); - - it('does not call close if keepOpen is true', async () => { - const { getByText } = render( - - Action - , - ); - fireEvent.click(getByText('Action')); - await wait(() => expect(action).toHaveBeenCalled()); - expect(close).not.toHaveBeenCalled(); - }); -}); - -describe('', () => { - it('renders the content', () => { - const { getByText } = render(wrapInTestApp(Link)); - expect(getByText('Link')).toBeInTheDocument(); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.js b/frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.js deleted file mode 100644 index 980d3326ad..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.js +++ /dev/null @@ -1,34 +0,0 @@ -import { MenuItem, TableCell, Select } from '@material-ui/core'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -export default class DropdownFilterCell extends Component { - static propTypes = { - filter: PropTypes.object, - onFilter: PropTypes.func, - options: PropTypes.array, - }; - - render() { - const { filter, onFilter, options } = this.props; - return ( - - - - ); - } -} diff --git a/frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.test.js b/frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.test.js deleted file mode 100644 index 5538d993d4..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/DropdownFilterCell.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { DataGrid } from 'shared/components/DataGrid'; -import DropdownFilterCell from 'shared/components/DataGrid/Cells/DropdownFilterCell'; - -const data = [ - { - text: 'data: a b', - }, - { - text: 'data: a', - }, -]; - -const columns = [ - { - name: 'text', - width: 100, - filteringEnabled: true, - filterCell: data => , - }, -]; - -describe('', () => { - it('works in isolation', async () => { - const handleFilter = jest.fn(); - const rendered = await render( - - - - - - -
    , - ); - - expect(handleFilter).not.toHaveBeenCalled(); - fireEvent.mouseDown(rendered.getByText('a')); - fireEvent.click(rendered.getByText('b')); - - expect(handleFilter).toHaveBeenCalledWith({ value: 'b' }); - handleFilter.mockReset(); - fireEvent.click(rendered.container.querySelector('[aria-haspopup="listbox"]')); - fireEvent.click(rendered.getByText('None')); - - expect(handleFilter).toHaveBeenCalledWith(null); - }); - - it('works in data grid', async () => { - const rendered = await render(); - - expect(rendered.queryByText('data: a')).not.toBeNull(); - expect(rendered.queryByText('data: a b')).not.toBeNull(); - - fireEvent.mouseDown(rendered.container.querySelector('[aria-haspopup="listbox"]')); - fireEvent.click(rendered.getByText('a')); - - expect(rendered.queryByText('data: a')).not.toBeNull(); - expect(rendered.queryByText('data: a b')).not.toBeNull(); - - fireEvent.click(rendered.container.querySelector('[aria-haspopup="listbox"]')); - fireEvent.click(rendered.getByText('b')); - - expect(rendered.queryByText('data: a')).toBeNull(); - expect(rendered.queryByText('data: a b')).not.toBeNull(); - - fireEvent.click(rendered.container.querySelector('[aria-haspopup="listbox"]')); - fireEvent.click(rendered.getByText('c')); - - expect(rendered.queryByText('data: a')).toBeNull(); - expect(rendered.queryByText('data: a b')).toBeNull(); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Cells/ListCell.js b/frontend/packages/shared/components/DataGrid/Cells/ListCell.js deleted file mode 100644 index c0a03bbf6f..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/ListCell.js +++ /dev/null @@ -1,73 +0,0 @@ -import React, { Component } from 'react'; -import { Tooltip } from '@material-ui/core'; - -export default class ListCell extends Component { - state = { - expanded: false, - }; - - static listItemStyle = { - marginTop: '4px', - marginBottom: '4px', - }; - - static listItemMoreStyle = Object.assign( - { - cursor: 'pointer', - color: 'grey', - }, - ListCell.listItemStyle, - ); - - render() { - const { value, column } = this.props; - if (!value || !column) return null; - let rows = value; - const maxRows = column.maxRows ? (column.maxRows === -1 ? 10000 : column.maxRows) : 10; - const rowsCapped = !this.state.expanded && rows.length > maxRows; - - let slicedRows = rowsCapped ? rows.slice(0, maxRows) : rows; - - let listItemRenderer = column.listItemRenderer || ListCell.defaultListItemRenderer; - let rendered = slicedRows.map(listItemRenderer.bind(this, { rowsCapped, maxRows, column })); - - if (rowsCapped) { - rendered.push( -
    this.expandClickHandler()} - onKeyPress={() => this.expandClickHandler()} - style={column.listItemMoreStyle || ListCell.listItemMoreStyle} - > - {column.moreText || `And ${rows.length - slicedRows.length} More`} -
    , - ); - } - - return rendered; - } - - expandClickHandler() { - this.setState({ - expanded: true, - }); - } - - static sort(a1, a2) { - if (a1.length === a2.length) { - return 0; - } - - return a1.length > a2.length ? -1 : 1; - } - - static defaultListItemRenderer({ column }, text, index) { - return ( - -
    {text}
    -
    - ); - } -} diff --git a/frontend/packages/shared/components/DataGrid/Cells/ListCell.test.js b/frontend/packages/shared/components/DataGrid/Cells/ListCell.test.js deleted file mode 100644 index df7033a0db..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/ListCell.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import ListCell from 'shared/components/DataGrid/Cells/ListCell'; - -describe('', () => { - it('renders without exploding', () => { - render(); - }); - - it('renders a list of items', () => { - const rendered = render(); - rendered.getByText('a'); - rendered.getByText('b'); - rendered.getByText('c'); - }); - - it('hides long lists', () => { - const rendered = render(); - expect(rendered.queryByText('a')).not.toBeNull(); - expect(rendered.queryByText('b')).toBeNull(); - expect(rendered.queryByText('c')).toBeNull(); - fireEvent.click(rendered.getByText('And 2 More')); - expect(rendered.queryByText('a')).not.toBeNull(); - expect(rendered.queryByText('b')).not.toBeNull(); - expect(rendered.queryByText('c')).not.toBeNull(); - }); - - it('uses a custom item renderer', () => { - function listItemRenderer(options, text, index) { - return
    {`item[${index}]: ${text}`}
    ; - } - const rendered = render(); - rendered.getByText('item[0]: a'); - rendered.getByText('item[1]: b'); - rendered.getByText('item[2]: c'); - }); - - it('sorts based on length', () => { - expect(ListCell.sort([], [0])).toBe(1); - expect(ListCell.sort([0, 0], [0])).toBe(-1); - expect(ListCell.sort([0, 0], [0, 0])).toBe(0); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Cells/NumberCell.js b/frontend/packages/shared/components/DataGrid/Cells/NumberCell.js deleted file mode 100644 index 29d7ebb063..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/NumberCell.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; - -export default class NumberCell extends Component { - static propTypes = { - value: PropTypes.number, - column: PropTypes.shape({ - roundTo: PropTypes.number, - percentOf: PropTypes.number, - }), - }; - - render() { - const { value, column } = this.props; - const { roundTo = null, percentOf } = column; - - if (percentOf) { - let percentage = (value / percentOf) * 100; - if (roundTo !== null || percentage % 1 !== 0) { - percentage = percentage.toFixed(roundTo === null ? 2 : roundTo); - } - return `${percentage}%`; - } - - if (roundTo !== null || value % 1 !== 0) { - return value.toFixed(roundTo === null ? 2 : roundTo); - } - - return value; - } -} diff --git a/frontend/packages/shared/components/DataGrid/Cells/NumberCell.test.js b/frontend/packages/shared/components/DataGrid/Cells/NumberCell.test.js deleted file mode 100644 index ee4df48fd8..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/NumberCell.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import NumberCell from 'shared/components/DataGrid/Cells/NumberCell'; - -describe('', () => { - it('renders a number', () => { - render().getByText('1'); - }); - - it('displays percent of total', () => { - render().getByText('33.33%'); - render().getByText('100%'); - render().getByText('25%'); - render().getByText('33.3%'); - }); - - it('rounds numbers if needed', () => { - render().getByText('0.67'); - render().getByText('1'); - render().getByText('0.7'); - render().getByText('100000'); - render().getByText('100000.000000'); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Cells/StatusCell.js b/frontend/packages/shared/components/DataGrid/Cells/StatusCell.js deleted file mode 100644 index 02c0eae70e..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/StatusCell.js +++ /dev/null @@ -1,47 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Tooltip } from '@material-ui/core'; -import { StatusOK, StatusError, StatusWarning, StatusPending, StatusRunning } from 'shared/components/Status'; - -export default class StatusCell extends Component { - static statusIcons = { - ok: , - running: , - pending: , - warning: , - error: , - }; - - static statuses = ['ok', 'running', 'pending', 'warning', 'error']; - - static propTypes = { - value: PropTypes.shape({ - icon: PropTypes.oneOf(StatusCell.statuses).isRequired, - message: PropTypes.string, - }), - }; - - render() { - if (!this.props.value) { - return N/A; - } - - const { icon, message } = this.props.value; - - return ( - - {StatusCell.statusIcons[icon.toLowerCase()]} - - ); - } - - static sort = (a, b) => { - if (!a && !b) return 0; - if (!a) return 1; - if (!b) return -1; - const aIndex = StatusCell.statuses.indexOf(a.icon); - const bIndex = StatusCell.statuses.indexOf(b.icon); - if (aIndex === bIndex) return 0; - return aIndex > bIndex ? -1 : 1; - }; -} diff --git a/frontend/packages/shared/components/DataGrid/Cells/StatusCell.test.js b/frontend/packages/shared/components/DataGrid/Cells/StatusCell.test.js deleted file mode 100644 index 812660233c..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/StatusCell.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import StatusCell from 'shared/components/DataGrid/Cells/StatusCell'; -import { wrapInTheme } from 'testUtils'; - -describe('', () => { - it('renders status', () => { - render(wrapInTheme()).getByText('N/A'); - render(wrapInTheme()).getByLabelText('Status OK'); - render(wrapInTheme()).getByLabelText('Status pending'); - render(wrapInTheme()).getByLabelText('Status running'); - render(wrapInTheme()).getByLabelText('Status error'); - render(wrapInTheme()).getByLabelText('Status warning'); - }); - - it('renders with custom message', () => { - const renderWithMessage = render(wrapInTheme()); - renderWithMessage.getByLabelText('Status OK'); - renderWithMessage.getByTitle('hello there'); - - const renderWithout = render(wrapInTheme()); - renderWithout.getByLabelText('Status error'); - renderWithout.getByTitle('ERROR'); - }); - - it('sorts by status', () => { - expect(StatusCell.sort(null, null)).toBe(0); - expect(StatusCell.sort(null, { icon: 'ok' })).toBe(1); - expect(StatusCell.sort({ icon: 'ok' }, null)).toBe(-1); - expect(StatusCell.sort({}, {})).toBe(0); - expect(StatusCell.sort({}, { icon: 'ok' })).toBe(1); - expect(StatusCell.sort({ icon: 'ok' }, {})).toBe(-1); - expect(StatusCell.sort({ icon: 'ok' }, { icon: 'error' })).toBe(1); - expect(StatusCell.sort({ icon: 'ok' }, { icon: 'ok' })).toBe(0); - expect(StatusCell.sort({ icon: 'warning' }, { icon: 'error' })).toBe(1); - expect(StatusCell.sort({ icon: 'pending' }, { icon: 'running' })).toBe(-1); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.js b/frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.js deleted file mode 100644 index 936a98c722..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.js +++ /dev/null @@ -1,28 +0,0 @@ -import React, { useContext } from 'react'; -import DataGridContext from 'shared/components/DataGrid/Plugins/DataGridContext'; - -/** - * Truncate contents of cell if overflowing. Expands on click to show full - * contents - */ -export default function TruncatedCell({ value }) { - const { setPopoverAnchorEl, setPopoverValue } = useContext(DataGridContext); - - const handleClick = (elem, value) => { - setPopoverAnchorEl(elem); - setPopoverValue(value); - }; - - return ( - handleClick(event.target, value)} - onKeyPress={event => handleClick(event.target, value)} - > - {value} - - ); -} diff --git a/frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.test.js b/frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.test.js deleted file mode 100644 index 8acc474a26..0000000000 --- a/frontend/packages/shared/components/DataGrid/Cells/TruncatedCell.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { DataGrid } from 'shared/components/DataGrid'; -import { act } from 'react-dom/test-utils'; -import TruncatedCell from 'shared/components/DataGrid/Cells/TruncatedCell'; - -describe('', () => { - it('shows the complete text in a DataGridPopover on click', () => { - // const TruncatedCell = getTruncatedCellWithContext(); - const data = [ - { - text: 'Going to space is very fun, but verrrrry cold!', - }, - ]; - const columns = [ - { - name: 'text', - renderer: ({ row }) => , - }, - ]; - - let wrapped; - // flush context state - act(() => { - wrapped = render(); - }); - - const { getByTestId } = wrapped; - - // popover is not showing - expect(() => getByTestId('data-grid-popover')).toThrow(); - fireEvent.click(getByTestId('truncated-cell')); - // popover is now showing - expect(getByTestId('data-grid-popover')).toBeInTheDocument(); - // and has the correct content - expect(getByTestId('data-grid-popover').textContent).toBe('Going to space is very fun, but verrrrry cold!'); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/DataGrid.js b/frontend/packages/shared/components/DataGrid/DataGrid.js deleted file mode 100644 index 557bca560f..0000000000 --- a/frontend/packages/shared/components/DataGrid/DataGrid.js +++ /dev/null @@ -1,482 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { - FilteringState, - GroupingState, - IntegratedFiltering, - IntegratedGrouping, - IntegratedPaging, - IntegratedSelection, - IntegratedSorting, - PagingState, - SearchState, - SelectionState, - SortingState, -} from '@devexpress/dx-react-grid'; -import { - Grid, - PagingPanel, - SearchPanel, - Table, - TableColumnResizing, - TableColumnVisibility, - TableFilterRow, - TableGroupRow, - TableHeaderRow, - TableSelection, - VirtualTable, -} from '@devexpress/dx-react-grid-material-ui'; -import { AutoSizer } from 'react-virtualized'; -import { withRouter } from 'react-router'; -import debounce from 'lodash/debounce'; - -import { getFromStore, saveToStore } from 'shared/apis/storage/persistentStore'; -import { Cell, Container, FilterCell, HeaderCell, HeaderCellSortLabel } from './RenderingComponents'; -// Custom Plugins for dx-react-grid -import Toolbar from './Plugins/Toolbar'; -import TableRowDetail from './Plugins/TableRowDetail'; -import RowDetailState from './Plugins/RowDetailState'; -import DataGridPopover from 'shared/components/DataGrid/Plugins/DataGridPopover'; -import { DataGridContextProvider } from 'shared/components/DataGrid/Plugins/DataGridContext'; -import { env } from 'shared/apis/env'; - -export const DEFAULT_ROW_HEIGHT = 32; -export const DEFAULT_HEADER_HEIGHT = 40; - -/** - * This is an experimental auto height version of DataGrid which is how devexpress recommends this to be solved. - * @see https://github.com/DevExpress/devextreme-reactive/issues/697 - * @see https://codesandbox.io/s/n6pzmvq94 - */ -export const AutoHeightDataGrid = ({ ...restProps }) => { - delete restProps.height; - delete restProps.fullHeight; - delete restProps.style; - - return ( - - {autoSizerData => { - return ; - }} - - ); -}; - -/** - * This Table is making use of the dx-react-grid - * from https://devexpress.github.io/devextreme-reactive/react/grid/demos/ - * - * Properties: - * columns: an array of objects determining which columns to render - * for reference on what properties these objects can have, see: - * - https://devexpress.github.io/devextreme-reactive/react/grid/docs/reference/table/#tablecolumnextension - * - https://devexpress.github.io/devextreme-reactive/react/grid/docs/reference/sorting-state/#sortingstatecolumnextension - * - https://devexpress.github.io/devextreme-reactive/react/grid/docs/reference/integrated-sorting/#integratedsortingcolumnextension - * in addition, you can supply the following: - * - renderer: a function to render the content of that column - * - expandRow: whether the column should be able to toggle an expandable detail state for the row - * columnResizing: enable resizable columns in data grid. Check defaultColumnWidths option to configure initial column width - * data: data array of objects - * defaultColumnWidths: An array of objects specifying column names and their minimum width. - * Example: [{ columnName: 'id', width: 150 }] - * If specified the array should have width set for all columns in the table. 'auto' as column width doesnt work. - * If unspecified then datagrid sets a min width of 100 for all columns. - * defaultCurrentPage: the default page to render if paginated is true - * defaultExpandedRows: which rows to expand by default, example: `{: }` - * defaultHiddenColumns: which columns to hide by default (using the columnSelector) - * defaultPageSize: the default amount of rows to render per page if paginated is true - * defaultSorting: provide the default sorting by specifying column and direction - * detailContentComponent: the component to render when expanding a column, gets expandedColumn as a prop for conditional rendering - * filtering: whether to provide integrated filtering, default: false - * filterLocationId: The search query URL parameter to replace in browser history when filter changes. - * If set, this will update history and use that parameter in initial configuration. - * If not set, no updates to browser history will occur when sorting state is changed, - * and it will not be possible to deep-link filter state. - * fixedHeight: whether to use a fixed height table even if pagination is not used - * groupColumns: an array of objects determining which column(s) to group by - * localStorageId: an ID for localStorage in which to save DataGrid settings (only hiddenColumns for now) - * manualVisibility: let the users determine which columns should be visible, default: true - * onCurrentPageChange: a function to be called when the user goes to another page - * onExpandedRowsChange: a function to be called when the user toggles an expandable row - * onPageSizeChange: a function to be called when the user changes the amount of rows per page - * onSearchValueChange: a function to be called when the user changes the search filter - * onSelectionChange: a function to be called when the user changes their row selections - * onRowClick: a function to be called when a user clicks on a row - * pageLocationId: The search query URL parameter to replace when pagination is updated. - * paginate: whether the table data should be paginated, default: false - * selection: pass an array to enable table row selection UI. pass in default selection which is an array of indices in the data. - * The corresponding table rows will be enabled by default. - * showColumnsWhenGrouped: a boolean value that specifies whether the grid’s table displays a column by which data is grouped - * showSelectAll: adds a select all option for table row selection - * sorting: enable sorting on columns, default: true - * sortLocationId: The search query URL parameter to replace in browser history when sorting changes. - * If set, this will update history and use that parameter in initial configuration. - * If not set, no updates to browser history will occur when sorting state is changed, - * and it will not be possible to deep-link sorting state. - * title: display a title above the table in the toolbar - * tableHeader: show or hide the table header row (which also contains the sorting controls), default: true - - * toolbar: show or hide the toolbar with columnSelector, default: true - * toolbarItems: a single node or an array of nodes to render in the toolbar - * - * How to enable sorting of your DataGrid: - * - if your column has a 'name' property that directly relates to a property - * on the row, then Grid will use that as the value of the cell when sorting. - * - if the data shown in the cell is nested or does not directly relate to a - * property on the row, you may need to declare a `getCellValue` property in - * the row so that the Grid can know what value to use when sorting - * - to implement a custom sorting algorithm, declare a `compare(a, b)` - * function in the row - */ -export class DataGrid extends Component { - static propTypes = { - columns: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string.isRequired, - title: PropTypes.string, - getCellValue: PropTypes.func, - renderer: PropTypes.func, - width: PropTypes.number, - align: PropTypes.oneOf(['left', 'right', 'center']), - compare: PropTypes.func, - sortingEnabled: PropTypes.bool, - expandRow: PropTypes.bool, - }), - ), - columnResizing: PropTypes.bool, - data: PropTypes.arrayOf(PropTypes.object).isRequired, - defaultColumnWidths: PropTypes.arrayOf( - PropTypes.shape({ - columnName: PropTypes.string.isRequired, - width: PropTypes.number.isRequired, - }), - ), - defaultCurrentPage: PropTypes.number, - defaultExpandedRows: PropTypes.object, - defaultHiddenColumns: PropTypes.arrayOf(PropTypes.string), - defaultPageSize: PropTypes.number, - defaultSorting: PropTypes.arrayOf( - PropTypes.shape({ - columnName: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired, - }), - ), - detailContentComponent: PropTypes.elementType, - expandedGroups: PropTypes.arrayOf(PropTypes.string), - filtering: PropTypes.bool, - filterLocationId: PropTypes.string, - fixedHeight: PropTypes.bool, - getRowId: PropTypes.func, - groupColumns: PropTypes.arrayOf( - PropTypes.shape({ - columnName: PropTypes.string.isRequired, - }), - ), - localStorageId: PropTypes.string, - manualVisibility: PropTypes.bool, - onCurrentPageChange: PropTypes.func, - onExpandedRowsChange: PropTypes.func, - onPageSizeChange: PropTypes.func, - onSearchValueChange: PropTypes.func, - onSelectionChange: PropTypes.func, - onRowClick: PropTypes.func, - paginate: PropTypes.bool, - pageLocationId: PropTypes.string, - selection: PropTypes.array, - showColumnsWhenGrouped: PropTypes.bool, - showSelectAll: PropTypes.bool, - sortLocationId: PropTypes.string, - title: PropTypes.string, - toolbar: PropTypes.bool, - toolbarItems: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), - }; - - static defaultProps = { - columns: [], - data: [], - defaultCurrentPage: 0, - defaultHiddenColumns: [], - defaultPageSize: 25, - manualVisibility: true, - pageSizes: [25, 50, 100], - sorting: true, - toolbar: true, - tableHeader: true, - }; - - static getDerivedStateFromProps(props, state) { - const newState = {}; - if (props.columns !== state.previousColumns) { - const columns = props.columns ? props.columns.map(column => ({ ...column, columnName: column.name })) : []; - const hasExpandableContent = Boolean(columns.find(column => column.expandRow)); - Object.assign(newState, { previousColumns: props.columns, columns, hasExpandableContent }); - } - if (props.rowComponent && props.rowComponent !== state.rowComponent) { - Object.assign(newState, { rowComponent: props.rowComponent }); - } - if (Object.keys(newState).length) { - return newState; - } - return null; - } - - state = { - hiddenColumns: [], - gridHeight: undefined, - rowComponent: ({ row, ...restProps }) => ( - { - if (this.props.onRowClick) { - this.props.onRowClick(row, e); - } - }} - /> - ), - }; - - componentDidMount() { - const { - defaultCurrentPage, - defaultHiddenColumns, - localStorageId, - sortLocationId, - filterLocationId, - pageLocationId, - location, - } = this.props; - - const hiddenColumns = localStorageId && getFromStore(localStorageId, 'hiddenColumns', defaultHiddenColumns); - - this.setState({ - hiddenColumns: hiddenColumns || defaultHiddenColumns, - locationSorting: this.getLocationParams(sortLocationId, location), - locationFiltering: this.getLocationParams(filterLocationId, location), - paginationPage: this.getLocationParams(pageLocationId, location) || defaultCurrentPage, - }); - } - - getLocationParams(id, location) { - if (!id) { - return null; - } - try { - return (location.search || '?') - .slice(1) - .split('&') - .filter(params => params.split('=')[0] === id) - .map(params => JSON.parse(decodeURIComponent(params.split('=')[1])))[0]; - } catch (e) { - console.error('Error decoding sorting param', e); - return null; - } - } - - updateHistory = (newState, locationId, history, location) => { - if (!locationId) { - return; - } - const newStateObject = encodeURIComponent(JSON.stringify(newState)); - const newSearch = (location.search || '?') - .slice(1) - .split('&') - .filter(param => param.split('=')[0] !== locationId) - .concat([`${locationId}=${newStateObject}`]) - .join('&'); - history.replace({ - ...location, - search: `?${newSearch}`, - }); - }; - - toggleHiddenColumn = name => { - const hiddenColumns = [...this.state.hiddenColumns]; - if (hiddenColumns.includes(name)) { - hiddenColumns.splice(hiddenColumns.indexOf(name), 1); - } else { - hiddenColumns.push(name); - } - this.setState({ hiddenColumns }); - if (this.props.localStorageId) { - saveToStore(this.props.localStorageId, 'hiddenColumns', hiddenColumns); - } - }; - - render() { - const { data } = this.props; - const { columns, hasExpandableContent, hiddenColumns, rowComponent, paginationPage } = this.state; - - const { - autoHeight, - classes, - defaultExpandedRows, - defaultHiddenColumns, - defaultPageSize, - defaultSearchValue, - defaultSorting, - detailContentComponent, - expandedGroups, - filtering, - filterLocationId, - fixedHeight, - fullHeight, // deprecated - groupColumns, - history, - height, // deprecated - location, - manualVisibility, - onCurrentPageChange, - onExpandedRowsChange, - onPageSizeChange, - onSearchValueChange, - pageSizes, - pageLocationId, - searching, - showColumnsWhenGrouped, - sorting, - tableHeader, - title, - toolbar, - toolbarItems, - selection, - onSelectionChange, - showSelectAll, - sortLocationId, - getRowId, - style, - columnResizing, - } = this.props; - let { defaultColumnWidths = [] } = this.props; - const sortingProps = this.state.locationSorting - ? { - sorting: this.state.locationSorting, - } - : {}; - const filteringProps = this.state.locationFiltering - ? { - filters: this.state.locationFiltering, - } - : {}; - let { paginate } = this.props; - // When paginate is false DataGrid uses VirtualTable and renders only 2 columns. - // Setting paginate to true for tests here as a workaround. - paginate = paginate || env.isTest; - - const rowSelection = typeof selection !== 'undefined'; - - const heightStyle = fullHeight ? '100%' : ''; - if (height || fullHeight || style) { - console.warn( - 'DataGrid: The component is using one or more of deprecated properties height, fullHeight, style)', - this.props, - ); - } - - const historyListener = (locationId, onUpdate) => { - return value => { - if (locationId) { - this.updateHistory(value, locationId, history, location); - } - if (onUpdate) { - onUpdate(value); - } - }; - }; - - if (columnResizing && !defaultColumnWidths.length) { - defaultColumnWidths = columns.map(col => { - return { - columnName: col.name, - width: col.width || 100, - }; - }); - } - - return ( - - - {searching && } - {sorting && ( - - )} - {paginate && ( - - )} - {rowSelection && } - {rowSelection && showSelectAll && } - {sorting && } - {filtering && ( - - )} - {(filtering || searching) && } - {groupColumns && } - {groupColumns && } - - {paginate && } - {(paginate || fixedHeight) && ( - - )} - {!(paginate || fixedHeight) && ( - - )} - {columnResizing && } - {hasExpandableContent && } - {tableHeader && ( - - )} - {rowSelection && (showSelectAll ? : )} - {groupColumns && } - {filtering && } - - {toolbar && ( - - )} - {toolbar && searching && } - {paginate && } - - - - ); - } -} - -const DataGridWithRouter = withRouter(DataGrid); -export default DataGridWithRouter; diff --git a/frontend/packages/shared/components/DataGrid/DataGrid.test.js b/frontend/packages/shared/components/DataGrid/DataGrid.test.js deleted file mode 100644 index 5fb5ebe968..0000000000 --- a/frontend/packages/shared/components/DataGrid/DataGrid.test.js +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react'; -import { render, fireEvent, waitForElement } from '@testing-library/react'; -import { getFromStore, saveToStore } from 'shared/apis/storage/persistentStore'; -import { DataGrid } from './DataGrid'; - -jest.mock('shared/apis/storage/persistentStore'); // Swap all calls to persistentStore to mock ones - -describe('', () => { - beforeEach(() => { - saveToStore.mockClear(); - getFromStore.mockClear(); - }); - - it('renders with default props', () => { - expect(render()).toBeTruthy(); - }); - - it('renders the supplied columns', () => { - const { getByText } = render(); - expect(getByText('id')).toBeInTheDocument(); - }); - - it('renders the supplied data according to the supplied columns', () => { - const { queryByText } = render(); - expect(queryByText('id')).toBeInTheDocument(); - expect(queryByText('abc')).toBeInTheDocument(); - expect(queryByText('other')).not.toBeInTheDocument(); - expect(queryByText('value')).not.toBeInTheDocument(); - }); - - it('uses defaultHiddenColumns if no hiddenColumns are present in persistentStore for localStoreId', () => { - const { getByText, queryByText } = render( - , - ); - expect(getByText('id')).toBeInTheDocument(); - expect(queryByText('title')).not.toBeInTheDocument(); - }); - - it('uses hiddenColumns from localStorageId in persistentStore if available', () => { - getFromStore.mockImplementationOnce(() => ['title']); - const { getByText, queryByText } = render( - , - ); - expect(getByText('id')).toBeInTheDocument(); - expect(queryByText('title')).not.toBeInTheDocument(); - }); - - it('updates hiddenColumns in persistentStore if localStorageId is provided', async () => { - const { getByTestId } = render(); - const toolbarButton = getByTestId('datagrid-column-toggle'); - fireEvent.click(toolbarButton); - const titleColumnToggle = await waitForElement(() => getByTestId('title-toggle')); - fireEvent.click(titleColumnToggle); - expect(getFromStore).toHaveBeenCalledWith('abc', 'hiddenColumns', []); - expect(saveToStore).toHaveBeenCalledWith('abc', 'hiddenColumns', ['title']); - }); - - it('does not update hiddenColumns in persistentStore if localStorageId is not provided', async () => { - const { getByTestId } = render(); - const toolbarButton = getByTestId('datagrid-column-toggle'); - fireEvent.click(toolbarButton); - const titleColumnToggle = await waitForElement(() => getByTestId('title-toggle')); - fireEvent.click(titleColumnToggle); - expect(getFromStore).not.toHaveBeenCalled(); - expect(saveToStore).not.toHaveBeenCalled(); - }); - - it('calls onRowClick when user clicks on a default rowComponent', () => { - const onRowClick = jest.fn(); - const { getByText } = render( - , - ); - fireEvent.click(getByText('abc')); - expect(onRowClick).toHaveBeenCalled(); - }); - - it('uses supplied rowComponent to render rows', () => { - const rowComponent = jest.fn(() => ); - render(); - expect(rowComponent).toHaveBeenCalled(); - }); - - it('renders expandable cell for columns with expandRow set', () => { - const { getByText } = render(); - expect(getByText('id')).toBeInTheDocument(); - expect(getByText('View')).toBeInTheDocument(); - fireEvent.click(getByText('View')); - expect(getByText('Close')).toBeInTheDocument(); - }); - - it('should not render table header according to table header prop', () => { - const { queryByText } = render( - , - ); - expect(queryByText('dogs')).not.toBeInTheDocument(); - expect(queryByText('staffy')).toBeInTheDocument(); - }); - - it('should render table header according to tableHeader prop', () => { - const { queryByText } = render( - , - ); - expect(queryByText('dogs')).toBeInTheDocument(); - expect(queryByText('corgi')).toBeInTheDocument(); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Plugins/DataGridContext.js b/frontend/packages/shared/components/DataGrid/Plugins/DataGridContext.js deleted file mode 100644 index fed767a103..0000000000 --- a/frontend/packages/shared/components/DataGrid/Plugins/DataGridContext.js +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useState } from 'react'; - -function useDataGridState() { - // DataGridPopover states - const [popoverAnchorEl, setPopoverAnchorEl] = useState(); - const [popoverValue, setPopoverValue] = useState(); - - return { setPopoverAnchorEl, popoverAnchorEl, setPopoverValue, popoverValue }; -} - -/** - * This react context enables children of DataGrid (e.g. Cells) to make use of - * an internal shared state. - * - * @example - * // SomeDataGridChild.js - * import React, { useContext } from 'react'; - * import { DataGridContext } from 'lib/DataGrid/Plugins/DataGridContext'; - * - * const { setSomeValue } = useContext(DataGridContext); - * const handleClick = (value) => { - * setSomeVaule('hello'); - * }; - */ -const DataGridContext = React.createContext(); - -export function DataGridContextProvider(props) { - return {props.children}; -} - -export default DataGridContext; diff --git a/frontend/packages/shared/components/DataGrid/Plugins/DataGridPopover.js b/frontend/packages/shared/components/DataGrid/Plugins/DataGridPopover.js deleted file mode 100644 index 7b460ab7fd..0000000000 --- a/frontend/packages/shared/components/DataGrid/Plugins/DataGridPopover.js +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useContext } from 'react'; -import { Popover, Typography, withStyles } from '@material-ui/core'; -import DataGridContext from 'shared/components/DataGrid/Plugins/DataGridContext'; - -const styles = theme => ({ - typography: { - margin: theme.spacing(1), - }, - popover: { - fontSize: '0.8125rem', - marginLeft: theme.spacing(-1), // aligns the popover text with the cell text - }, -}); - -export function DataGridPopover({ classes }) { - const { popoverAnchorEl, popoverValue, setPopoverAnchorEl, setPopoverValue } = useContext(DataGridContext); - const open = Boolean(popoverAnchorEl); - - const handleClose = () => { - setPopoverAnchorEl(null); - setPopoverValue(null); - }; - - return ( - - {popoverValue} - - ); -} - -export default withStyles(styles)(DataGridPopover); diff --git a/frontend/packages/shared/components/DataGrid/Plugins/RowDetailState.js b/frontend/packages/shared/components/DataGrid/Plugins/RowDetailState.js deleted file mode 100644 index 663d9acb4b..0000000000 --- a/frontend/packages/shared/components/DataGrid/Plugins/RowDetailState.js +++ /dev/null @@ -1,69 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { Plugin, Getter, Action } from '@devexpress/dx-react-core'; - -/* - This plugin is derived from the original RowDetailState plugin made by the Devexpress team. - (https://github.com/DevExpress/devextreme-reactive/blob/master/packages/dx-react-grid/src/plugins/row-detail-state.jsx) - - Contrary to the original implementation, ours allows the user to make any column expandable. - Instead of keeping an array of expanded rows, we keep a dictionary with where the value is - either an empty string or the name of the currently expanded column. This allows users to - have more than one expandable column and show different content based on which column is expanded. -*/ - -class RowDetailState extends PureComponent { - static getDerivedStateFromProps(newProps, prevState) { - if (!prevState.defaultExpandedRows && newProps.defaultExpandedRows) { - return { expandedRows: newProps.defaultExpandedRows, defaultExpandedRows: newProps.defaultExpandedRows }; - } - return null; - } - - static propTypes = { - expandedRows: PropTypes.object, - defaultExpandedRows: PropTypes.object, - onExpandedRowsChange: PropTypes.func, - }; - - static defaultProps = { - expandedRows: {}, - }; - - constructor(props) { - super(props); - this.toggleDetailRowExpanded = this.toggleDetailRowExpanded.bind(this); - } - - state = { - expandedRows: this.props.expandedRows || this.props.defaultExpandedRows, - }; - - toggleDetailRowExpanded({ rowId, column }) { - const expandedRows = { ...this.state.expandedRows }; - - if (expandedRows[rowId] !== column) { - expandedRows[rowId] = column; - } else { - expandedRows[rowId] = ''; - } - - this.setState({ expandedRows }); - if (this.props.onExpandedRowsChange) { - this.props.onExpandedRowsChange(expandedRows); - } - } - - render() { - const { expandedRows } = this.state; - - return ( - - - - - ); - } -} - -export default RowDetailState; diff --git a/frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.js b/frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.js deleted file mode 100644 index 54838bd124..0000000000 --- a/frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.js +++ /dev/null @@ -1,178 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { Plugin, Template, Getter, TemplateConnector } from '@devexpress/dx-react-core'; -import { - tableDetailCellColSpanGetter, - isDetailTableCell, - isDetailTableRow, - TABLE_DETAIL_TYPE, - TABLE_DATA_TYPE, -} from '@devexpress/dx-grid-core'; -import { Table } from '@devexpress/dx-react-grid-material-ui'; -import Button from 'shared/components/Button'; -import { Icon, withStyles } from '@material-ui/core'; - -/* - This plugin is derived from the original TableRowDetail plugin made by the Devexpress team. - (https://github.com/DevExpress/devextreme-reactive/blob/master/packages/dx-react-grid/src/plugins/table-row-detail.jsx) - - Contrary to the original implementation, ours allows the user to make any column expandable. - A contentComponent is still necessary, and is supplied to and passed down from the DataGrid - as "detailContentComponent". In the detailContentComponent you get the expandedColumn in addition - to the default props, so you can conditionally render something based on which column is expanded. -*/ - -const styles = theme => ({ - detailRow: { - backgroundColor: 'rgba(125,125,125,0.1)', - borderTop: `1px solid ${theme.palette.textVerySubtle}`, - borderBottom: `1px solid ${theme.palette.textVerySubtle}`, - }, -}); - -const DefaultToggleContent = props => { - const action = props.expanded ? 'Close' : 'View'; - const label = `${action} ${props.column.title || props.column.name}`; - - return ( - - ); -}; - -class TableRowDetail extends PureComponent { - static propTypes = { - contentComponent: PropTypes.elementType, - toggleCellComponent: PropTypes.elementType.isRequired, - cellComponent: PropTypes.elementType.isRequired, - rowComponent: PropTypes.elementType.isRequired, - rowHeight: PropTypes.number, - }; - - static defaultProps = { - toggleCellComponent: Table.Cell, - cellComponent: Table.Cell, - rowComponent: Table.Row, - detailContentComponent: () => null, - }; - - static pluginDependencies = [{ name: 'RowDetailState' }, { name: 'Table' }]; - - isDetailRowExpanded(expandedRows, rowId) { - return Boolean(expandedRows[rowId]); - } - - isColumnExpanded(expandedRows, rowId, columnName) { - return Boolean(expandedRows[rowId] === columnName); - } - - tableBodyRowsComputed = ({ tableBodyRows, expandedRows }) => { - const { rowHeight } = this.props; - let result = [...tableBodyRows]; - for (let id in expandedRows) { - if (this.isDetailRowExpanded(expandedRows, id)) { - const rowIndex = result.findIndex(tableRow => { - return tableRow.type === TABLE_DATA_TYPE && `${tableRow.rowId}` === `${id}`; - }); - if (rowIndex !== -1) { - const insertIndex = rowIndex + 1; - const resultRowIndex = result[rowIndex]; - const row = resultRowIndex.row; - const rowId = resultRowIndex.rowId; - result = [ - ...result.slice(0, insertIndex), - { - key: `${TABLE_DETAIL_TYPE.toString()}_${rowId}`, - type: TABLE_DETAIL_TYPE, - rowId, - row, - colSpanStart: 0, - height: rowHeight, - }, - ...result.slice(insertIndex), - ]; - } - } - } - return result; - }; - - getCellColSpanComputed = ({ getTableCellColSpan }) => tableDetailCellColSpanGetter(getTableCellColSpan); - - isColumnExpandable({ tableColumn, tableRow }) { - return tableColumn.column && tableColumn.column.expandRow && tableRow.type !== 'heading'; - } - - render() { - const { - toggleCellComponent: ToggleCell, - cellComponent: Cell, - rowComponent: Row, - detailContentComponent: Content, - } = this.props; - - return ( - - - - - - - - ); - } -} - -export default withStyles(styles)(TableRowDetail); diff --git a/frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.test.js b/frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.test.js deleted file mode 100644 index bc7dc18429..0000000000 --- a/frontend/packages/shared/components/DataGrid/Plugins/TableRowDetail.test.js +++ /dev/null @@ -1,141 +0,0 @@ -import React from 'react'; -import { render, fireEvent, cleanup } from '@testing-library/react'; -import { IntegratedPaging, PagingState } from '@devexpress/dx-react-grid'; -import { Grid, PagingPanel, Table, TableHeaderRow, VirtualTable } from '@devexpress/dx-react-grid-material-ui'; -import TableRowDetail from './TableRowDetail'; -import RowDetailState from './RowDetailState'; - -const minProps = { - columns: [{ name: 'id' }, { name: 'info', expandRow: true }], - rows: [{ id: 'a', info: 'information for a' }], -}; - -function withTable(gridProps = {}, rowDetailStateProps = {}, tableRowDetailProps = {}) { - return render( - - -
    - - - , - ); -} - -function withPaginatedTable(gridProps = {}, rowDetailStateProps = {}, tableRowDetailProps = {}) { - return render( - - - - -
    - - - - , - ); -} - -function withVirtualTable(gridProps = {}, rowDetailStateProps = {}, tableRowDetailProps = {}) { - return render( - - - - - - , - ); -} - -const tables = [withTable, withPaginatedTable, withVirtualTable]; - -describe('TableRowDetail', () => { - it('does not explode when supplied to the grid', () => { - expect(withTable()).toBeTruthy(); - expect(withPaginatedTable()).toBeTruthy(); - expect(withVirtualTable()).toBeTruthy(); - }); - - it('renders columns with expandRow: true as default toggleable cells', () => { - tables.forEach(table => { - const { getByText } = table(); - expect(getByText('View')).toBeInTheDocument(); - fireEvent.click(getByText('View')); - expect(getByText('Close')).toBeInTheDocument(); - cleanup(); - }); - }); - - it('does not render a toggle in header cells', () => { - tables.forEach(table => { - const { getByText } = table(); - expect(getByText('id')).toBeInTheDocument(); - expect(getByText('info')).toBeInTheDocument(); - cleanup(); - }); - }); - - it('calls onExpandedRowsChange on user toggle', () => { - tables.forEach(table => { - const onExpandedRowsChange = jest.fn(); - const { getByText } = table({}, { onExpandedRowsChange }); - fireEvent.click(getByText('View')); - expect(onExpandedRowsChange).toHaveBeenCalledTimes(1); - cleanup(); - }); - }); - - it('renders custom toggle button if column has renderer function', () => { - tables.forEach(table => { - const columns = [{ name: 'custom', expandRow: true, renderer: () => TOGGLE }]; - const { getByText } = table({ columns }); - expect(getByText('custom')).toBeInTheDocument(); - expect(getByText('TOGGLE')).toBeInTheDocument(); - cleanup(); - }); - }); - - it('detailContentComponent is shown/hidden when column is toggled', () => { - tables.forEach(table => { - const detailContentComponent = props =>
    {props.row.info}
    ; - const { getByText, queryByText } = table({}, {}, { detailContentComponent }); - fireEvent.click(getByText('View')); - expect(getByText('information for a')).toBeInTheDocument(); - fireEvent.click(getByText('Close')); - expect(queryByText('information for a')).not.toBeInTheDocument(); - cleanup(); - }); - }); - - it('propagates the name of the expanded column to detailContentComponent', () => { - const expectedProps = { - rowId: 0, - row: minProps.rows[0], - expandedColumn: 'info', - }; - tables.forEach(table => { - const detailContentComponent = jest.fn(() => open); - const { getByText } = table({}, {}, { detailContentComponent }); - - fireEvent.click(getByText('View')); - expect(detailContentComponent.mock.calls[0][0]).toEqual(expectedProps); - cleanup(); - }); - }); - - it('only expands the toggled row(s)', () => { - const rows = [...minProps.rows, { id: 'b', info: 'information for b' }]; - tables.forEach(table => { - const onExpandedRowsChange = jest.fn(); - const { queryAllByText } = table({ rows }, { onExpandedRowsChange }); - const toggleButtons = queryAllByText('View'); - expect(toggleButtons).toHaveLength(2); - fireEvent.click(toggleButtons[1]); - expect(onExpandedRowsChange.mock.calls[0][0]).toEqual({ 1: 'info' }); - fireEvent.click(toggleButtons[0]); - expect(onExpandedRowsChange.mock.calls[1][0]).toEqual({ 0: 'info', 1: 'info' }); - fireEvent.click(toggleButtons[1]); - expect(onExpandedRowsChange.mock.calls[2][0]).toEqual({ 0: 'info', 1: '' }); - cleanup(); - }); - }); -}); diff --git a/frontend/packages/shared/components/DataGrid/Plugins/Toolbar.js b/frontend/packages/shared/components/DataGrid/Plugins/Toolbar.js deleted file mode 100644 index 0cc5016a9e..0000000000 --- a/frontend/packages/shared/components/DataGrid/Plugins/Toolbar.js +++ /dev/null @@ -1,155 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { findDOMNode } from 'react-dom'; -import { Plugin, Template, TemplatePlaceholder } from '@devexpress/dx-react-core'; -import { - CardHeader, - Checkbox, - Icon, - ListItem, - List, - ListItemText, - Popover, - Toolbar as ToolbarMUI, - withStyles, -} from '@material-ui/core'; -import IconButton from 'shared/components/IconButton'; -import { sendGAEvent, getGAContext } from 'shared/apis/events'; -import GoogleAnalyticsEvent, { - GA_DEFAULT_PLUGIN_OWNER, - GA_DEFAULT_PLUGIN_ID, -} from 'shared/apis/events/GoogleAnalyticsEvent'; -import { withPluginContext } from 'core/app/withPluginContext'; - -const BoldHeader = withStyles({ title: { fontWeight: '700' } })(CardHeader); - -const styles = theme => ({ - checkbox: { - width: 'auto', - height: 'auto', - }, - columnName: { - textTransform: 'capitalize', - }, - toolbar: { - '& > *': { - marginLeft: theme.spacing(1), - }, - '& > *:first-child': { - marginLeft: 0, - }, - '& > *:last-child': { - marginLeft: 'auto', - }, - }, -}); - -class Toolbar extends Component { - static defaultProps = { - manualVisibility: true, - }; - - static propTypes = { - columns: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string.isRequired, - alwaysVisible: PropTypes.bool, - }), - ), - hidden: PropTypes.arrayOf(PropTypes.string), - manualVisibility: PropTypes.bool, - toggleHiddenColumn: PropTypes.func, - }; - - state = { - menuOpen: false, - anchorEl: null, - }; - - button = null; - - openMenu = () => { - this.setState({ anchorEl: findDOMNode(this.button), menuOpen: true }); - }; - - closeMenu = () => { - this.setState({ menuOpen: false }); - }; - - toggleColumn = name => { - const { plugin, toggleHiddenColumn, hidden } = this.props; - toggleHiddenColumn(name); - sendGAEvent( - plugin && plugin.id ? plugin.id : GA_DEFAULT_PLUGIN_ID, - GoogleAnalyticsEvent.BTN_CLICK, - `Toggle ${name} column`, - hidden.includes(name) ? 1 : 0, - plugin && plugin.owner ? plugin.owner : GA_DEFAULT_PLUGIN_OWNER, - getGAContext(this), - ); - }; - - render() { - const { anchorEl, menuOpen } = this.state; - const { children, classes, columns, hidden, manualVisibility, title } = this.props; - - if (!children && !title && !manualVisibility) return null; - const toggleVisibleColumns = manualVisibility && ( - - { - this.button = node; - }} - > - more_vert - - - - {columns.map(column => ( - this.toggleColumn(column.name)} - disabled={column.alwaysVisible} - > - - - - ))} - - - - ); - return ( - - - - ); - } -} - -export default withPluginContext(withStyles(styles)(Toolbar)); diff --git a/frontend/packages/shared/components/DataGrid/RenderingComponents.js b/frontend/packages/shared/components/DataGrid/RenderingComponents.js deleted file mode 100644 index 15d90c7ba2..0000000000 --- a/frontend/packages/shared/components/DataGrid/RenderingComponents.js +++ /dev/null @@ -1,67 +0,0 @@ -import React, { PureComponent } from 'react'; -import { Table, TableHeaderRow, TableFilterRow } from '@devexpress/dx-react-grid-material-ui'; -import { withStyles, TableCell } from '@material-ui/core'; - -const headerCellStyles = { - headerCell: { - textTransform: 'uppercase', - }, -}; - -const HeaderCellSortLabel = ({ column, ...restProps }) => ( - column.tooltip || column.title || column.name || column.id} - /> -); - -class HeaderCell extends PureComponent { - render() { - const { tableColumn, classes, style = {}, ...props } = this.props; - const { ...column } = tableColumn.column; - delete column.wordWrapEnabled; - if (column.align && !style.textAlign) { - style.textAlign = column.align; - } - return ; - } -} -const styledHeaderCell = withStyles(headerCellStyles)(HeaderCell); - -const Cell = props => { - const { column } = props; - if (column.renderer) { - return ( - - {column.renderer(props)} - - ); - } else { - return ; - } -}; - -const FilterCell = props => { - if (props.column.filteringEnabled) { - if (props.column.filterCell) { - return props.column.filterCell(props); - } - return ; - } - return ; -}; - -const containerStyles = { - table: { - overflow: 'auto', - '& table:first-child': { - zIndex: 2, - }, - }, -}; - -const Container = ({ classes, ...restProps }) =>
    ; -const styledContainer = withStyles(containerStyles)(Container); - -export { Cell, styledHeaderCell as HeaderCell, FilterCell, HeaderCellSortLabel, styledContainer as Container }; diff --git a/frontend/packages/shared/components/DataGrid/index.js b/frontend/packages/shared/components/DataGrid/index.js deleted file mode 100644 index 70872e7813..0000000000 --- a/frontend/packages/shared/components/DataGrid/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './DataGrid'; -export { AutoHeightDataGrid, DEFAULT_HEADER_HEIGHT, DEFAULT_ROW_HEIGHT, DataGrid } from './DataGrid'; diff --git a/frontend/packages/shared/components/DataNotFound.js b/frontend/packages/shared/components/DataNotFound.js deleted file mode 100644 index 0f31c80a2c..0000000000 --- a/frontend/packages/shared/components/DataNotFound.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Typography } from '@material-ui/core'; -import PenguinIcon from 'cdn/sadpenguin.png'; -import Link from 'shared/components/Link'; - -class DataNotFound extends React.Component { - static propTypes = { - path: PropTypes.string.isRequired, - error: PropTypes.string, - info: PropTypes.node, - }; - - render() { - const error = this.props.error || 'The data could not be retrieved.'; - const info = this.props.info || ( - - Reach out to us to get it sorted out. - - ); - - return ( -
    -
    - empty - Not found -

    Error: {error}

    -

    {info}

    -
    -
    - ); - } -} - -export default DataNotFound; diff --git a/frontend/packages/shared/components/DebugPrint/DebugPrint.js b/frontend/packages/shared/components/DebugPrint/DebugPrint.js deleted file mode 100644 index f105e755b6..0000000000 --- a/frontend/packages/shared/components/DebugPrint/DebugPrint.js +++ /dev/null @@ -1,69 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import FeatureFlags from 'shared/apis/featureFlags/featureFlags'; - -import { Button } from '@material-ui/core'; - -const styles = { - root: { - fontFamily: 'monospace', - color: '#ffc600', - }, - text: { - margin: 0, - overflow: 'scroll', - backgroundColor: '#193549', - padding: '5px 10px', - fontSize: 11, - }, - button: { - color: 'inherit', - fontSize: '12px', - backgroundColor: '#193549', - padding: 0, - border: '1px solid rgb(0,0,0)', - borderRadius: '8px', - minWidth: '45px', - minHeight: '16px', - height: '20px', - lineHeight: '18px', - }, - label: { - padding: 0, - border: 'none', - fontWeight: 'bold', - fontStyle: 'normal', - lineHeight: '18px', - }, -}; - -class DebugPrint extends Component { - static propTypes = { - data: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), - }; - - state = { - show: false, - }; - - toggle = () => { - this.setState({ show: !this.state.show }); - }; - - render() { - if (!FeatureFlags.getItem('debugmode.enabled')) { - return null; - } - - return ( -
    - - {this.state.show ?
    {JSON.stringify(this.props.data, null, 2)}
    : null} -
    - ); - } -} - -export default DebugPrint; diff --git a/frontend/packages/shared/components/DebugPrint/index.js b/frontend/packages/shared/components/DebugPrint/index.js deleted file mode 100644 index 7399470dfa..0000000000 --- a/frontend/packages/shared/components/DebugPrint/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './DebugPrint'; diff --git a/frontend/packages/shared/components/DismissableBanner.test.js b/frontend/packages/shared/components/DismissableBanner.test.js deleted file mode 100644 index a3c4ebff36..0000000000 --- a/frontend/packages/shared/components/DismissableBanner.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; -import { renderWithEffects, wrapInThemedTestApp } from 'testUtils'; -import DismissableBanner from 'shared/components/DismissableBanner'; -import SettingsCollection from 'shared/apis/settings/SettingCollection'; -import MemorySettingsStore from 'shared/apis/settings/MemorySettingsStore'; - -describe('', () => { - it('renders the message and the popover', async () => { - const settings = new SettingsCollection(); - const mockSetting = settings.register({ - id: 'mockSetting', - defaultValue: true, - }); - const SettingsProvider = settings.bind()(new MemorySettingsStore()); - - const rendered = await renderWithEffects( - wrapInThemedTestApp( - - - , - ), - ); - rendered.getByText('test message'); - - fireEvent.click(rendered.getByTitle('Permanently dismiss this message')); - await waitForElementToBeRemoved(() => rendered.queryByText('test message')); - }); -}); diff --git a/frontend/packages/shared/components/DismissableBanner.tsx b/frontend/packages/shared/components/DismissableBanner.tsx deleted file mode 100644 index 2a7f053539..0000000000 --- a/frontend/packages/shared/components/DismissableBanner.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { FC, ReactNode } from 'react'; -import classNames from 'classnames'; -import { makeStyles, Theme } from '@material-ui/core'; -import Snackbar from '@material-ui/core/Snackbar'; -import SnackbarContent from '@material-ui/core/SnackbarContent'; -import IconButton from '@material-ui/core/IconButton'; -import Close from '@material-ui/icons/Close'; -import Setting from 'shared/apis/settings/Setting'; -import { useSetting } from 'core/settings'; - -const useStyles = makeStyles((theme: Theme) => ({ - root: { - position: 'relative', - padding: theme.spacing(1), - marginBottom: theme.spacing(3), - marginTop: -theme.spacing(3), - zIndex: 'auto', - display: 'flex', - flexFlow: 'row nowrap', - }, - icon: { - fontSize: 20, - }, - content: { - width: '100%', - maxWidth: 'inherit', - }, - message: { - display: 'flex', - alignItems: 'center', - }, - info: { - backgroundColor: theme.palette.primary.main, - }, - error: { - backgroundColor: theme.palette.error.dark, - }, -})); - -type Props = { - variant: 'info' | 'error'; - setting: Setting; - message: ReactNode; -}; - -const DismissableBanner: FC = ({ variant, setting, message }) => { - const [show, setShown, loading] = useSetting(setting); - const classes = useStyles(); - - const handleClick = () => { - if (setShown) { - setShown(false); - } - }; - - return ( - - - - , - ]} - /> - - ); -}; - -export default DismissableBanner; diff --git a/frontend/packages/shared/components/EmptyState/EmptyState.js b/frontend/packages/shared/components/EmptyState/EmptyState.js deleted file mode 100644 index c1fbfb25af..0000000000 --- a/frontend/packages/shared/components/EmptyState/EmptyState.js +++ /dev/null @@ -1,54 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Typography } from '@material-ui/core'; - -import PenguinIcon from 'cdn/sadpenguin.png'; - -const styles = { - container: { - maxWidth: 500, - marginTop: 120, - }, - img: { - width: 160, - opacity: 0.3, - paddingBottom: 10, - }, - bottomText: { - fontSize: '85%', - width: '80%', - color: '#6F6F6F', - }, -}; - -/** - * This component is a generic full page empty state screen. - * For example on how to use it @see {@link src/core/app/AppBar/Notifications/Notifications.js} - */ -export default class EmptyState extends Component { - static propTypes = { - info: PropTypes.shape({ - title: PropTypes.string.isRequired, - image: PropTypes.string, - helperText: PropTypes.oneOfType([PropTypes.element, PropTypes.string]).isRequired, - button: PropTypes.element, - bottomText: PropTypes.oneOfType([PropTypes.element, PropTypes.string]).isRequired, - }).isRequired, - }; - - render() { - const { info } = this.props; - const imgSrc = info.image || PenguinIcon; - return ( -
    -
    - empty - {info.title} -

    {info.helperText}

    - {info.button} -

    {info.bottomText}

    -
    -
    - ); - } -} diff --git a/frontend/packages/shared/components/EmptyState/index.js b/frontend/packages/shared/components/EmptyState/index.js deleted file mode 100644 index 7dfa367bf7..0000000000 --- a/frontend/packages/shared/components/EmptyState/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EmptyState'; diff --git a/frontend/packages/shared/components/Error/Error.js b/frontend/packages/shared/components/Error/Error.js deleted file mode 100644 index 93b75cfe31..0000000000 --- a/frontend/packages/shared/components/Error/Error.js +++ /dev/null @@ -1,346 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core'; -import ERROR_MESSAGE_MAPPING from 'ErrorMessageMapping'; -import { errorReducer } from './util/errorUtil'; -import { safeStringify } from './util/JSON'; -import { consoleStack } from './util/console'; -import { copyTextToClipboard } from './util/clipboard'; -import { getAllReactComponentAncestors } from './util/react'; -import Link from 'shared/components/Link'; -import FeatureFlags from 'shared/apis/featureFlags/featureFlags'; - -import { history } from 'core/store'; - -import moment from 'moment'; -import { env } from 'shared/apis/env'; - -const styles = theme => ({ - container: { - display: 'flex', - flexDirection: 'column', - width: '100%', - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - contents: { - position: 'relative', - overflow: 'hidden', - background: 'white', - border: `5px solid ${theme.palette.errorBackground}`, - borderRadius: 10, - maxWidth: 600, - maxHeight: 600, - display: 'flex', - flexDirection: 'column', - width: '100%', - alignItems: 'center', - justifyContent: 'space-between', - textAlign: 'center', - padding: 20, - paddingTop: 75, - }, - contentsChild: { - margin: '10px 0', - flex: '1 1 auto', - }, - copyToClipboard: { - fontSize: '1.2em', - color: theme.palette.link, - cursor: 'pointer', - fontWeight: 'bold', - border: 'none', - }, - copySuccess: { - fontSize: '1.2em', - color: 'green', - fontWeight: 'bold', - border: 'none', - }, - title: { - maxWidth: '100%', - whiteSpace: 'pre-wrap', - }, - details: { - whiteSpace: 'pre-wrap', - fontFamily: 'monospace', - fontSize: '0.8em', - maxWidth: '100%', - }, - owner: { - display: 'flex', - flexDirection: 'row', - position: 'absolute', - top: 0, - left: 0, - width: '100%', - background: theme.palette.errorBackground, - color: theme.palette.errorText, - justifyContent: 'space-between', - alignItems: 'center', - fontSize: '1.5em', - padding: 10, - }, -}); - -let pluginManager; - -/** - * The Error class displays a error in a stylistic way with the ability to provide a ton of debugging information: - * - * - Timestamp - * - Timezone - * - Console logs - * - Owner (either of active plugin or by checking the 'owner' prop of the next highest react component that has one) - * - Active Route / HREF - * - Error - * - Browser History - * - React Component Stack (to find the Component for Debugging) - */ -export class Error extends Component { - static propTypes = { - error: PropTypes.any, - errorInfo: PropTypes.any, - slackChannel: PropTypes.string, - }; - - state = { - copiedToClipboard: false, - }; - - constructor(props) { - super(props); - - /** - * Code path in tests is short-circuited to not use pluginManager instance. - * This is because Jest invalidates module cache between tests, causing the - * below import to take several seconds and making tests time out. - * - * TODO: figure out a solution for the above so we can't properly test this component. - */ - if (!env.isTest) { - /** - * Okay, here's the deal. I hate this, you hate this, everyone, well... pretty much everyone hates this. - * - * But see here's the thing. If we import the pluginManager normally at the top of the file, this - * creates an cycle in the import graph, because PluginRoute imports SidebarPage which imports - * ErrorBoundary which imports Error which import Link which imports PluginBase which imports PluginRoute... - * - * It's a mess. So we do this so the tests actually run. - * - * The only alternative would be to import the pluginManager at the top of every test. This just will not do. - */ - import('plugins/pluginManagerBootstrap').then(_pluginManager => { - pluginManager = _pluginManager.default; - this.forceUpdate(); - }); - } - } - - renderTitle(mappedError) { - const { classes, error } = this.props; - - if (!mappedError) { - console.error('Error:', error); - - let errorString = 'Something went wrong!'; - - if (error) { - errorString = error.toString(); - } - - return

    {errorString}

    ; - } else { - return

    {mappedError.message}

    ; - } - } - - renderDetails(mappedError) { - if (!mappedError) { - const { classes } = this.props; - - if (this.props.errorInfo) { - return ( -
    -
    {this.props.errorInfo.componentStack}
    -
    - ); - } - } - - return undefined; - } - - renderCopyToClipboardButton() { - const { classes } = this.props; - const { copiedToClipboard } = this.state; - - return ( -
    - {!copiedToClipboard ? ( - - ) : ( - undefined - )} - {copiedToClipboard ?
    Copied to clipboard!
    : undefined} -
    - ); - } - - findSlackChannel() { - const componentStack = getAllReactComponentAncestors(this); - - const componentWithOwner = componentStack.find(component => { - return component.props && component.props.slackChannel; - }); - - if (componentWithOwner) { - return componentWithOwner.props.slackChannel; - } - - if (pluginManager && pluginManager.activePlugin) { - const { manifest, owner } = pluginManager.activePlugin; - return (manifest.facts && manifest.facts['support_channel']) || owner; - } - return 'tools'; - } - - render() { - const { classes, error, skipTestEnvOutput = false } = this.props; - const slackChannel = this.findSlackChannel(); - - let mappedError = errorReducer(error, ERROR_MESSAGE_MAPPING, { - slackChannel, - }); - - if (env.isTest && !skipTestEnvOutput) { - // This code is only for: - // - // 1) When running yarn test-react - // 2) When you have not explicitly told the component to skip this with skipTestEnvOutput - // - // The purpose of this is to make the output in the console prettier if your test triggers - // the ErrorBoundary to show up. - return
    {mappedError.message}
    ; - } - - setTimeout(() => { - // Browsers have a check in user interaction events for how long they take before they - // copy to the clipboard. If the time it takes to copy to clipboard is too long, then deem - // the action unsafe and don't allow the copy to take place. - // We are saving the debugInformation before the users clicks to copy to clipboard so we - // do not accidentally trigger that failure. - // - // We do this on the next frame so we don't lock up the rendering. - this.cacheErrorDumpDebuggingInformation(); - }, 0); - - return ( -
    -
    -
    - Aw snap -
    -
    {this.renderTitle(mappedError)}
    - {this.renderDetails(mappedError)} - {this.renderCopyToClipboardButton()} -
    -
    Error!
    -
    - Contact:  - -
    -
    -
    -
    - ); - } - - cacheErrorDumpDebuggingInformation() { - const { error, errorInfo } = this.props; - - /** - * We filter the console because if huge objects are output to it, they might be too large - * to quickly convert to JSON. So we just try to be smart about picking only Error objects - * or strings at this point and ignoring other stuff. - */ - let filteredConsole = consoleStack.slice(consoleStack.length - 50).filter(c => { - return typeof c.arguments[0] === 'string' || c.arguments[0] instanceof Error; - }); - - /** - * Format the console data with some timestamp information that makes it easier to reason - * about at a later point in time. - */ - filteredConsole = consoleStack.map(c => { - let ts = c.timestamp; - let str = c.arguments.filter(a => typeof a === 'string').join(' | '); - - return `${moment(ts).format('YYYY/MM/DD HH:mm:ss:SSS')}:\t${str}`; - }); - - const reactComponentStack = getAllReactComponentAncestors(this) - .map(c => (c.constructor ? c.constructor.name : c.name) || '[Unknown]') - .filter(n => ['WithStyles'].indexOf(n) === -1); - - const timezone = new Date().getTimezoneOffset() / 60; - - /** - * This is all the information that will be converted to JSON and copied to the clipboard. - * - * Feel free to add more contextual information to this if you think it would be helpful in debugging. - */ - const debugInformation = { - timestamp: moment(new Date()).format('YYYY/MM/DD HH:mm:ss:SSS'), - timezone: timezone > 0 ? `+${timezone}` : timezone, - responsible: { - slackChannel: this.findSlackChannel(), - }, - activeRootPluginRoute: - pluginManager && pluginManager.activeRootPluginRoute - ? pluginManager.activeRootPluginRoute.debug - : { - href: window.location.href, - }, - history: history.stack, - error, - errorInfo, - featureFlags: Object.keys(FeatureFlags.getFlags()), - activePluginManifest: pluginManager ? pluginManager.activePlugin.manifest : undefined, - reactComponentStack, - console: filteredConsole, - }; - - try { - debugInformation.activePluginDebug = - pluginManager && pluginManager.activePlugin.getDebugInfo - ? pluginManager.activePlugin.getDebugInfo() - : undefined; - } catch (e) { - debugInformation.activePluginDebug = `getDebugInfo crashed with message: ${e.message}`; - } - - /** - * safeStringify ensures that if there are cycles in the data, they won't cause a crash. - */ - this.errorDump = safeStringify(debugInformation, 2); - - console.error(debugInformation); - } - - copyToClipboardButton_onClickHandler = () => { - // See comment in copyTextToClipboard for why we aren't using the Clipboard API right now. - const success = copyTextToClipboard(this.errorDump); - - if (success) { - this.setState({ - copiedToClipboard: true, - }); - } - }; -} - -export default withStyles(styles)(Error); diff --git a/frontend/packages/shared/components/Error/Error.test.js b/frontend/packages/shared/components/Error/Error.test.js deleted file mode 100644 index f5f7faa109..0000000000 --- a/frontend/packages/shared/components/Error/Error.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { render } from '@testing-library/react'; -import Error from './Error'; -import React from 'react'; - -describe('', () => { - it('error with mapping', () => { - const { getByText, getByAltText } = render( - , - ); - expect( - getByText( - 'The Backstage backend is updating its cache and should be back online for you within a couple of minutes', - ), - ); - expect(getByAltText('Aw snap')); - }); - - it('error without mapping', () => { - const { getByText, getByAltText } = render(); - expect(getByText('Anything else')); - expect(getByAltText('Aw snap')); - }); - - it('undefined error', () => { - const { getByText, getByAltText } = render(); - expect(getByText('Something went wrong!')); - expect(getByAltText('Aw snap')); - }); - - /** - * When running tests, sometimes the ErrorBoundary can get triggered. - * - * When this happens, it just says an error occurred and to check the console. Instead, when - * running tests we want Error.js to just dump the error directly into the DOM. This makes - * things much easier to debug. - */ - it('outputs the actual JSON error during tests', () => { - const { getByText } = render(); - expect(getByText('Some testing environment error. EXPECTED BEHAVIOR')).toBeTruthy(); - }); -}); diff --git a/frontend/packages/shared/components/Error/index.js b/frontend/packages/shared/components/Error/index.js deleted file mode 100644 index 3edca40045..0000000000 --- a/frontend/packages/shared/components/Error/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Error'; diff --git a/frontend/packages/shared/components/Error/util/JSON.js b/frontend/packages/shared/components/Error/util/JSON.js deleted file mode 100644 index 525a3c252e..0000000000 --- a/frontend/packages/shared/components/Error/util/JSON.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * safeStringify is JSON.stringify that will not stringify cycles in the object graph. - * - * See: https://stackoverflow.com/questions/11616630/json-stringify-avoid-typeerror-converting-circular-structure-to-json - */ -export function safeStringify(object, tab) { - let cache = []; - - return JSON.stringify( - object, - function(key, value) { - if (typeof value === 'object' && value !== null) { - if (cache.indexOf(value) !== -1) { - // Duplicate reference found - try { - // If this value does not reference a parent it can be deduped - return JSON.parse(JSON.stringify(value)); - } catch (error) { - // discard key if value cannot be deduped - return; - } - } - // Store value in our collection - cache.push(value); - } - - return value; - }, - tab, - ); -} diff --git a/frontend/packages/shared/components/Error/util/clipboard.js b/frontend/packages/shared/components/Error/util/clipboard.js deleted file mode 100644 index 3057918253..0000000000 --- a/frontend/packages/shared/components/Error/util/clipboard.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * We are ONLY using this because as of when this was written, the Chrome browser had the Clipboard - * API blocked as experimental: https://developers.google.com/web/updates/2018/03/clipboardapi - * - * Once they unblock it we can get rid of this silly polyfill thingy. - * - * @param text - * @returns {boolean} - */ -export function copyTextToClipboard(text) { - let textArea = document.createElement('textarea'); - - // - // *** This styling is an extra step which is likely not required. *** - // - // Why is it here? To ensure: - // 1. the element is able to have focus and selection. - // 2. if element was to flash render it has minimal visual impact. - // 3. less flakyness with selection and copying which **might** occur if - // the textarea element is not visible. - // - // The likelihood is the element won't even render, not even a flash, - // so some of these are just precautions. However in IE the element - // is visible whilst the popup box asking the user for permission for - // the web page to copy to the clipboard. - // - - // Place in top-left corner of screen regardless of scroll position. - textArea.style.position = 'fixed'; - textArea.style.top = 0; - textArea.style.left = 0; - - // Ensure it has a small width and height. Setting to 1px / 1em - // doesn't work as this gives a negative w/h on some browsers. - textArea.style.width = '2em'; - textArea.style.height = '2em'; - - // We don't need padding, reducing the size if it does flash render. - textArea.style.padding = 0; - - // Clean up any borders. - textArea.style.border = 'none'; - textArea.style.outline = 'none'; - textArea.style.boxShadow = 'none'; - - // Avoid flash of white box if rendered for any reason. - textArea.style.background = 'transparent'; - - textArea.value = text; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - let successful = false; - - try { - successful = document.execCommand('copy'); - } catch (err) { - console.log('Oops, unable to copy'); - } - - document.body.removeChild(textArea); - - return successful; -} diff --git a/frontend/packages/shared/components/Error/util/console.js b/frontend/packages/shared/components/Error/util/console.js deleted file mode 100644 index dad6855848..0000000000 --- a/frontend/packages/shared/components/Error/util/console.js +++ /dev/null @@ -1,36 +0,0 @@ -export const consoleStack = []; - -const _consoleLog = console.log; -const _consoleWarn = console.warn; -const _consoleError = console.error; - -console.log = (...args) => { - consoleStack.push({ - type: 'log', - timestamp: new Date().getTime(), - arguments: args, - url: window.location.href, - }); - - _consoleLog.apply(console, args); -}; - -console.warn = (...args) => { - consoleStack.push({ - timestamp: new Date().getTime(), - arguments: args, - url: window.location.href, - }); - - _consoleWarn.apply(console, args); -}; - -console.error = (...args) => { - consoleStack.push({ - timestamp: new Date().getTime(), - arguments: args, - url: window.location.href, - }); - - _consoleError.apply(console, args); -}; diff --git a/frontend/packages/shared/components/Error/util/errorUtil.js b/frontend/packages/shared/components/Error/util/errorUtil.js deleted file mode 100644 index 1ad8f335fc..0000000000 --- a/frontend/packages/shared/components/Error/util/errorUtil.js +++ /dev/null @@ -1,233 +0,0 @@ -import { replace } from 'prop-replace'; -import { safeStringify } from './JSON'; - -/** - * Assert that an value is defined and/or a valid type and throw an error if it is invalid. - * - * Designed to run live, not during tests. - * - * Examples: - * - * let myVar = undefined; - * validateValue(undefined, true); // Will throw error - * - * let myVar = 0; - * validateValue(myVar, false, 'string') // Will throw error saying should be string - * - * let myVar = new MyClass(); - * validateValue(myVar, false, SomeOtherClass); // Will throw error saying wrong type - * - * @param value The value to test - * @param required True if the value should be defined (e.g. not undefined and not null) - * @param types A single or Array of types (e.g. MyClass, 'string', 'function') - * @param name The debugging name of the argument (e.g. 'myArgumentName') - * @param errorStringsObject Custom error strings to provide when the required or type check fails. - * a Javascript Object with 'wrongType' and 'required' strings - */ -export function validateValue(value, required, types, name = '[no name provided]', errorStringsObject = {}) { - errorStringsObject.wrongTypeMessage = - errorStringsObject.wrongTypeMessage || "Expected '${name}' to be of type '${type}' but was '${actualType}'"; - errorStringsObject.requiredMessage = errorStringsObject.requiredMessage || "Expected '${name}' to be defined"; - - if (required && (value === undefined || value === null)) { - throw new Error( - replace(errorStringsObject.requiredMessage, { - name, - }), - ); - } - - if (types) { - types = types instanceof Array ? types : [types]; - - types.forEach(type => { - if (typeof type === 'string') { - if ((typeof value).toLowerCase() !== type.toLowerCase()) { - throw new Error( - replace(errorStringsObject.wrongTypeMessage, { - type, - name, - actualType: typeof value, - }), - ); - } - } else if (typeof type === 'function' && type.prototype && type.prototype.constructor) { - if (!(value instanceof type)) { - throw new Error( - replace(errorStringsObject.wrongTypeMessage, { - type: type.prototype.constructor.name, - name, - actualType: typeof value, - }), - ); - } - } else { - throw Error('Improper use of validateValue. Invalid types provided.'); - } - }); - } -} - -/** - * Given an error string and an Array of errorMappings, this method checks the error against all - * of the mappings and returns the message associated with a match, if it exists. - * - * The goal of this utility is to provide a way that every error (thrown or server-returned) - * could pass through a single configurable array of test objects and the first match is given - * the responsibility of returning a user-friendly way of displaying the error. - * - * Testing Types: - * - * Basic example (error contains a string): - * - * errorReducer('some error from the server', [{ - * errorContains: 'some error', - * message: 'API V2 just had an error: ${errorString}' - * }]); - * - * Regex matching (error matches a regular expression): - * - * errorReducer('503: some error from the server', [{ - * errorMatchesRegex: /5[0-9][0-9]/g, - * message: 'A critical server error occurred: ${errorString}' - * }]); - * - * Custom match function: - * - * errorReducer({status: 400, error: 'This is some complicated error object'}, [{ - * errorMatchesFunction: (error, context) => error.status === 400, - * message: 'Some gosh-darned thing couldn\'t be found. Sad bananas.' - * }]); - * - * Messaging: - * - * Custom messaging function: - * - * errorReducer('ERROR: some error', [{ - * errorContains: 'ERROR', - * message: error => error.replace('ERROR:', 'Whoops! Things fell apart:') - * }]); - * - * Default Error Message: - * - * Custom messaging function: - * - * errorReducer('ERROR: some error', [{errorContains: 'ERROR'}], - * undefined, - * 'This error will be shown for all mappings that do not have an error message defined'); - * - * Arbitrary context object (e.g. if you want your custom mappings to have access to some other data) - * - * errorReducer('ERROR: some error', [{errorContains: 'ERROR'}], - * { - * ownerEmail: 'monty@python.com' - * }, - * 'Please contact ${ownerEmail}'); - * - * OR - * - * errorReducer('ERROR: some error', [{ - * errorContains: 'ERROR', - * message: (error, context) => `Please contact ${context.ownerEmail}` - * }], - * { - * ownerEmail: 'monty@python.com' - * }); - */ -export function errorReducer( - errorStringOrObject, - errorMappings, - context, - defaultErrorMessage = 'An error occurred: ${errorString}', -) { - if (!errorStringOrObject) { - return undefined; - } - - if (!(errorMappings instanceof Array)) { - throw new Error('errorReducer(): errorMappings is expected to be an Array!'); - } - - let errorString = typeof errorStringOrObject === 'string' ? errorStringOrObject : safeStringify(errorStringOrObject); - - /** - * Example errorMapping: - * - * { - * errorMatchesRegex: /getUserData/g, // OR - * errorContains: 'some text in the error', - * message: 'Some message' - * } - */ - const foundErrorMapping = errorMappings.find(errorMapping => { - try { - if (errorMapping.errorContains) { - // String - return errorString.indexOf(errorMapping.errorContains) !== -1; - } else if (errorMapping.errorMatchesRegex) { - // Regex - return errorMapping.errorMatchesRegex.test(errorString); - } else if (errorMapping.errorMatchesFunction) { - // Function - return errorMapping.errorMatchesFunction(errorStringOrObject, context); - } else { - // Warning if invalid errorMapping provided - console.warn( - 'errorReducer(): error mapping is invalidly configured! Expected' + - ' errorMatchesRegex, errorContains, or errorMatchesFunction', - errorMapping, - ); - } - } catch (error) { - console.error('errorReducer(): error with errorMapping:', errorMapping, error); - } - - return undefined; - }); - - let messageString; - - if (!foundErrorMapping) { - return undefined; - } else if (typeof foundErrorMapping.message === 'string') { - messageString = replace( - foundErrorMapping.message, - Object.assign( - { - errorString, - error: errorStringOrObject, - }, - context, - ), - ); - } else if (typeof foundErrorMapping.message === 'function') { - const message = foundErrorMapping.message(errorStringOrObject, context, foundErrorMapping); - - return Object.assign( - { - errorMapping: foundErrorMapping, - }, - typeof message === 'string' - ? { - message, - } - : message, - ); - } else { - messageString = replace( - defaultErrorMessage, - Object.assign( - { - errorString, - error: errorStringOrObject, - }, - context, - ), - ); - } - - return { - message: messageString, - errorMapping: foundErrorMapping, - }; -} diff --git a/frontend/packages/shared/components/Error/util/errorUtil.test.js b/frontend/packages/shared/components/Error/util/errorUtil.test.js deleted file mode 100644 index a8e2370307..0000000000 --- a/frontend/packages/shared/components/Error/util/errorUtil.test.js +++ /dev/null @@ -1,304 +0,0 @@ -import { validateValue, errorReducer } from 'shared/components/Error/util/errorUtil'; - -describe('errorUtil', () => { - describe('validateValue()', () => { - it('throws proper error when required element is invalid', () => { - expect(() => { - validateValue(undefined, true, undefined, 'myVar'); - }).toThrowError("Expected 'myVar' to be defined"); - }); - - it('throws no error when required element is valid (1/2)', () => { - expect(() => { - validateValue(0, true, undefined, 'myVar'); - }).not.toThrow(); - }); - - it('throws no error when required element is valid (2/2)', () => { - expect(() => { - validateValue('', true, undefined, 'myVar'); - }).not.toThrow(); - }); - - it('throws no error when non-required element is valid', () => { - expect(() => { - validateValue(undefined, false, undefined, 'myVar'); - }).not.toThrow(); - }); - - it('throws error when required element is wrong string type', () => { - expect(() => { - validateValue(0, true, 'string', 'myVar'); - }).toThrowError("Expected 'myVar' to be of type 'string' but was 'number'"); - }); - - it('throws error when required element is wrong Object type', () => { - expect(() => { - validateValue({}, true, 'string', 'myVar'); - }).toThrowError("Expected 'myVar' to be of type 'string' but was 'object'"); - }); - - it('throws error when required element is wrong Array type', () => { - expect(() => { - validateValue([], true, 'string', 'myVar'); - }).toThrowError("Expected 'myVar' to be of type 'string' but was 'object'"); - }); - - it('throws error when required element is wrong Class type', () => { - class TestClass {} - - expect(() => { - validateValue('', true, TestClass, 'myVar'); - }).toThrowError("Expected 'myVar' to be of type 'TestClass' but was 'string'"); - }); - - it('throws no error when required element is correct Class type', () => { - class TestClass {} - - expect(() => { - validateValue(new TestClass(), true, TestClass, 'myVar'); - }).not.toThrow(); - }); - - it('throws error when improper type is provided', () => { - expect(() => { - validateValue('', true, {}, 'myVar'); - }).toThrowError('Improper use of validateValue. Invalid types provided.'); - }); - - it('works with a custom requiredMessage', () => { - expect(() => { - validateValue(undefined, true, 'string', 'myVar', { - requiredMessage: '${name} is required!!!', - }); - }).toThrowError('myVar is required!!!'); - }); - - it('works with a custom wrongTypeMessage', () => { - expect(() => { - validateValue(0, true, 'string', 'myVar', { - wrongTypeMessage: '${name} should be a string. It was a ${actualType} instead.', - }); - }).toThrowError('myVar should be a string. It was a number instead.'); - }); - }); - - describe('errorReducer()', () => { - it('returns undefined if no error provided', () => { - expect(errorReducer(undefined)).toBe(undefined); - }); - - it('throws an error if errorMappings is invalid type', () => { - expect(() => { - errorReducer('some men just want to watch the tests burn'); - }).toThrow(); - - expect(() => { - errorReducer('some men just want to watch the tests burn', 'should not be a string'); - }).toThrow(); - - expect(() => { - errorReducer('some men just want to watch the tests burn', 100); - }).toThrow(); - - expect(() => { - errorReducer('some men just want to watch the tests burn', {}); - }).toThrow(); - }); - - it('works with a string mapping and default message', () => { - expect( - errorReducer('some ERROR text', [ - { - errorContains: 'ERROR', - }, - ]), - ).toEqual({ - message: 'An error occurred: some ERROR text', - errorMapping: { - errorContains: 'ERROR', - }, - }); - }); - - it('works with a regex mapping and default message', () => { - expect( - errorReducer('some ERROR text', [ - { - errorMatchesRegex: /ERROR/g, - }, - ]), - ).toEqual({ - message: 'An error occurred: some ERROR text', - errorMapping: { - errorMatchesRegex: /ERROR/g, - }, - }); - }); - - it('works with a function mapping and default message', () => { - const mapFunction = () => true; - - expect( - errorReducer('some ERROR text', [ - { - errorMatchesFunction: mapFunction, - }, - ]), - ).toEqual({ - message: 'An error occurred: some ERROR text', - errorMapping: { - errorMatchesFunction: mapFunction, - }, - }); - }); - - it('works with a string mapping and custom message', () => { - expect( - errorReducer('some ERROR text', [ - { - errorContains: 'ERROR', - message: 'royal with cheese: ${errorString}', - }, - ]), - ).toEqual({ - message: 'royal with cheese: some ERROR text', - errorMapping: { - errorContains: 'ERROR', - message: 'royal with cheese: ${errorString}', - }, - }); - }); - - it('works with a regex mapping and custom message', () => { - expect( - errorReducer('some ERROR text', [ - { - errorMatchesRegex: /ERROR/g, - message: 'royal with cheese: ${errorString}', - }, - ]), - ).toEqual({ - message: 'royal with cheese: some ERROR text', - errorMapping: { - errorMatchesRegex: /ERROR/g, - message: 'royal with cheese: ${errorString}', - }, - }); - }); - - it('works with a regex mapping and custom message and context', () => { - expect( - errorReducer( - 'some ERROR text', - [ - { - errorMatchesRegex: /ERROR/g, - message: 'royal with cheese: ${someVariableToPassAsContext}', - }, - ], - { - someVariableToPassAsContext: true, - }, - ), - ).toEqual({ - message: 'royal with cheese: true', - errorMapping: { - errorMatchesRegex: /ERROR/g, - message: 'royal with cheese: ${someVariableToPassAsContext}', - }, - }); - }); - - it('works with a function mapping and custom message', () => { - const mapFunction = () => true; - - expect( - errorReducer('some ERROR text', [ - { - errorMatchesFunction: mapFunction, - message: 'royal with cheese: ${errorString}', - }, - ]), - ).toEqual({ - message: 'royal with cheese: some ERROR text', - errorMapping: { - errorMatchesFunction: mapFunction, - message: 'royal with cheese: ${errorString}', - }, - }); - }); - - it('works with a string mapping and custom message function', () => { - const messageFunction = () => { - return 'SOMETHING IS TERRIBLY WRONG'; - }; - - expect( - errorReducer('some ERROR text', [ - { - errorContains: 'ERROR', - message: messageFunction, - }, - ]), - ).toEqual({ - message: 'SOMETHING IS TERRIBLY WRONG', - errorMapping: { - errorContains: 'ERROR', - message: messageFunction, - }, - }); - }); - - it('works with a regex mapping and custom message function', () => { - const messageFunction = () => { - return { - message: 'SOMETHING IS TERRIBLY WRONG', - showThisInAPopupPlease: true, - }; - }; - - expect( - errorReducer('some ERROR text', [ - { - errorMatchesRegex: /ERROR/g, - message: messageFunction, - }, - ]), - ).toEqual({ - message: 'SOMETHING IS TERRIBLY WRONG', - showThisInAPopupPlease: true, - errorMapping: { - errorMatchesRegex: /ERROR/g, - message: messageFunction, - }, - }); - }); - - it('works with a function mapping and custom message function', () => { - const mapFunction = () => true; - const messageFunction = () => { - return { - message: 'SOMETHING IS TERRIBLY WRONG', - showThisInAPopupPlease: true, - }; - }; - - expect( - errorReducer('some ERROR text', [ - { - errorMatchesFunction: mapFunction, - message: messageFunction, - }, - ]), - ).toEqual({ - message: 'SOMETHING IS TERRIBLY WRONG', - showThisInAPopupPlease: true, - errorMapping: { - errorMatchesFunction: mapFunction, - message: messageFunction, - }, - }); - }); - }); -}); diff --git a/frontend/packages/shared/components/Error/util/react.js b/frontend/packages/shared/components/Error/util/react.js deleted file mode 100644 index 4ad7f4a71f..0000000000 --- a/frontend/packages/shared/components/Error/util/react.js +++ /dev/null @@ -1,155 +0,0 @@ -import ReactDOM from 'react-dom'; -import React from 'react'; - -/** - * Walks the dom ancestors until it finds the nearest dom node that has a ref to a React Component and then - * returns that React Component. - * - * @param domNode The DOM node to start with. - * @returns {*} - */ -export function domNodeToNearestReactComponent(domNode) { - while (domNode) { - for (let key in domNode) { - if (key.startsWith('__reactInternalInstance$')) { - // Please don't judge me when you read this code. - - if (React.version.startsWith('16')) { - // In React Fiber, every node in the tree (including dom nodes) is represented in the Fiber tree. So we - // have to search through all the parents to find the first one that has a stateNode that is a React Component - let curFiber = domNode[key]; - - while ( - curFiber && - !(curFiber.stateNode instanceof React.Component || typeof curFiber.stateNode === 'function') - ) { - curFiber = curFiber.return; - } - - return curFiber.stateNode; - } - - // React 15 - return domNode[key]._currentElement._owner._instance; - } - } - - domNode = domNode.parentNode; - } - - return null; -} - -/** - * Walks the dom ancestors until it finds the nearest dom node that has a ref to a React Component and then returns - * that dom node. - * - * @param domNode The DOM node to start with. - * @returns {*} - */ -export function domNodeToNearestReactComponentDomNode(domNode) { - while (domNode) { - for (let key in domNode) { - if (key.startsWith('__reactInternalInstance$')) { - return domNode; - } - } - - domNode = domNode.parentNode; - } - - return null; -} - -/** - * Walks the React Components up through the parent heirarchy. - * - * @param component A React Component instance. - * @param callback A callback to call for each component in the ancestors. - */ -export function walkReactParents(component, callback) { - if (component._reactInternalFiber) { - return _walkReactParents16(component, callback); - } else { - return _walkReactParents15(component, callback); - } -} - -function _walkReactParents16(component, callback) { - let ancestors = []; - - let fiber = component._reactInternalFiber; - - while (fiber) { - let item = fiber.stateNode; - - if (item && (item instanceof React.Component || item instanceof Function)) { - if (ancestors.indexOf(item) === -1) { - ancestors.push(item); - } - } - - fiber = fiber.return; // return is the parent node, go figure (ask Facebook) - } - - if (callback) { - ancestors.forEach(callback); - } - - return ancestors; -} - -function _walkReactParents15(component, callback) { - let ancestors = []; - - component = component._reactInternalInstance; - - while (component) { - let item = component._instance || component._currentElement._owner._instance; - - if (ancestors.indexOf(item) === -1) { - ancestors.push(item); - } - - component = component._hostParent; - } - - if (callback) { - ancestors.forEach(callback); - } - - return ancestors; -} - -/** - * Returns all Ringa.Controller instances that exist in the ancestor tree. - * - * @param component A React Component instance. - * @returns {Array} - */ -export function getAllReactComponentAncestors(component) { - return walkReactParents(component); -} - -export function findComponentRoot(component, refName) { - let domNode; - - // First look for a ref to attach to... - if (!component.refs || !component.refs[refName]) { - // Second use react-dom to find the root node for the component... - - domNode = ReactDOM.findDOMNode(component); - - if (!domNode) { - console.warn( - `attach(): Error finding root DOM node for React Component ${component.constructor.name}. Component ref named '${refName}' does not exist and ReactDOM findDomNode(component) did not return anything. This can happen if the render() method returns null or undefined.`, - ); - } - - return domNode; - } else { - domNode = component.refs[refName]; - } - - return domNode; -} diff --git a/frontend/packages/shared/components/ErrorBoundary.js b/frontend/packages/shared/components/ErrorBoundary.js deleted file mode 100644 index 1bc21765a4..0000000000 --- a/frontend/packages/shared/components/ErrorBoundary.js +++ /dev/null @@ -1,35 +0,0 @@ -import React, { Component } from 'react'; -import Error from 'shared/components/Error'; - -export default class ErrorBoundary extends Component { - constructor(props) { - super(props); - - this.state = { - error: null, - errorInfo: null, - onError: props.onError, - }; - } - - componentDidCatch(error, errorInfo) { - console.error(`ErrorBoundary, error: ${error}, info: ${errorInfo}`); - this.setState({ error, errorInfo }); - - // Exposed for testing - if (ErrorBoundary.onError) { - ErrorBoundary.onError(error, errorInfo); - } - } - - render() { - const { slackChannel } = this.props; - const { error, errorInfo } = this.state; - - if (!errorInfo) { - return this.props.children; - } - - return ; - } -} diff --git a/frontend/packages/shared/components/ErrorPanel/ErrorPanel.js b/frontend/packages/shared/components/ErrorPanel/ErrorPanel.js deleted file mode 100644 index 8587e62e06..0000000000 --- a/frontend/packages/shared/components/ErrorPanel/ErrorPanel.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { Button, withStyles } from '@material-ui/core'; -import { ErrorOutlineStyled } from 'core/app/styledMUIComponents/index'; -import { SnackbarContent } from '@material-ui/core'; - -import { errorLogOpen, errorLogAdd } from 'shared/apis/errorLog/actions'; - -const snackbarContentStyles = theme => ({ - root: { - [theme.breakpoints.up('sm')]: { - flexGrow: 1, - maxWidth: '100%', - }, - backgroundColor: theme.palette.errorBackground, - }, - message: { - color: theme.palette.errorText, - }, -}); -const SnackbarContentStyled = withStyles(snackbarContentStyles)(SnackbarContent); - -/** - * ErrorPanel. Show a user friendly error message to a user. Pass an error object - * and a "view logs" button will show, opening the Error Log that'll contain the error details. - */ -class ErrorPanel extends React.Component { - static propTypes = { - onViewLogsClick: PropTypes.func, - message: PropTypes.string.isRequired, - errorId: PropTypes.string, - longFailureMessage: PropTypes.string, - error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - }; - - constructor(props) { - super(props); - - if (props.errorId) { - this.errorId = props.errorId; - } else { - // TODO: this will be replace by a "new LogItem(...)" - // https://ghe.spotify.net/system-z/system-z-frontend/blob/add-global-log-mgmt/src/core/logging/LogItem.js - this.errorId = Math.random() - .toString(36) - .substring(7); - } - } - - handleViewLogsClick = errorId => { - const { errorLogOpen, onViewLogsClick } = this.props; - errorLogOpen(errorId); - if (onViewLogsClick) { - onViewLogsClick(); - } - }; - - componentDidMount() { - const { message, error = '', errorLogAdd, longFailureMessage } = this.props; - if (error) { - errorLogAdd(message, error, this.errorId, longFailureMessage); - } - } - - render() { - const { message, action, error } = this.props; - return ( - this.handleViewLogsClick(this.errorId, ...args)}> - View logs - - )) - } - message={ - - - {message} - - } - /> - ); - } -} - -export default connect(null, { errorLogOpen, errorLogAdd })(ErrorPanel); diff --git a/frontend/packages/shared/components/ErrorPanel/index.js b/frontend/packages/shared/components/ErrorPanel/index.js deleted file mode 100644 index c550bac514..0000000000 --- a/frontend/packages/shared/components/ErrorPanel/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ErrorPanel'; diff --git a/frontend/packages/shared/components/ExpansionAlertPanel.js b/frontend/packages/shared/components/ExpansionAlertPanel.js deleted file mode 100644 index f8836d062f..0000000000 --- a/frontend/packages/shared/components/ExpansionAlertPanel.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { - ExpansionPanel, - ExpansionPanelDetails, - ExpansionPanelSummary, - Typography, - Box, - makeStyles, -} from '@material-ui/core'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; -import ExpandMore from '@material-ui/icons/ExpandMore'; - -// TODO: Less custom styling, this is a bit ugly -const useStyles = makeStyles(theme => ({ - panel: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - }, - gutterBottom: { - marginBottom: theme.spacing(2), - }, - info: { - backgroundColor: theme.palette.infoBackground, - color: theme.palette.infoText, - }, - warning: { - backgroundColor: theme.palette.warningBackground, - color: theme.palette.warningText, - }, - error: { - backgroundColor: theme.palette.errorBackground, - color: theme.palette.errorText, - }, - details: { - paddingBottom: theme.spacing(1), - }, - icon: { - marginRight: theme.spacing(2), - }, -})); - -const ExpansionAlertPanel = ({ type, title, elevation, gutterBottom, children }) => { - const classes = useStyles(); - - return ( -
    - - } className={`${classes[type]} ${classes.panel}`}> - - - {title} - - - {children} - -
    - ); -}; - -ExpansionAlertPanel.propTypes = { - type: PropTypes.oneOf(['warning', 'error', 'info']), - title: PropTypes.string.isRequired, - children: PropTypes.node, -}; -ExpansionAlertPanel.defaultProps = { - type: 'info', - elevation: 1, -}; - -export default ExpansionAlertPanel; diff --git a/frontend/packages/shared/components/ExpansionAlertPanel.test.js b/frontend/packages/shared/components/ExpansionAlertPanel.test.js deleted file mode 100644 index a922fc5a2a..0000000000 --- a/frontend/packages/shared/components/ExpansionAlertPanel.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { wrapInThemedTestApp } from 'testUtils'; -import ExpansionAlertPanel from 'shared/components/ExpansionAlertPanel'; - -const props = { - type: 'info', - title: 'test title', -}; - -describe('', () => { - it('renders without exploding', () => { - const { getByText } = render(wrapInThemedTestApp(test child)); - getByText('test title'); - getByText('test child'); - }); -}); diff --git a/frontend/packages/shared/components/FilterSettings/FilterSettings.tsx b/frontend/packages/shared/components/FilterSettings/FilterSettings.tsx deleted file mode 100644 index 80e856b670..0000000000 --- a/frontend/packages/shared/components/FilterSettings/FilterSettings.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { FC } from 'react'; -import { Divider, IconButton, InputBase, Paper } from '@material-ui/core'; -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; -import FindInPageIcon from '@material-ui/icons/FindInPage'; -import { RemoveIcon } from 'shared/icons'; - -type Props = { - filterTerm: string; - onFilterChanged: (filter: string) => any; -}; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - padding: '2px 4px', - marginBottom: theme.spacing(4), - display: 'flex', - alignItems: 'center', - width: 400, - }, - input: { - marginLeft: theme.spacing(1), - flex: 1, - }, - iconButton: { - padding: 10, - }, - divider: { - height: 28, - margin: 4, - }, - }), -); - -const FilterSettings: FC = ({ filterTerm, onFilterChanged }) => { - const classes = useStyles(); - return ( - - - - - onFilterChanged(event.target.value)} - /> - - onFilterChanged('')} - onMouseDown={() => onFilterChanged('')} - > - - - - ); -}; - -export default FilterSettings; diff --git a/frontend/packages/shared/components/FilterSettings/index.js b/frontend/packages/shared/components/FilterSettings/index.js deleted file mode 100644 index 99030bf29b..0000000000 --- a/frontend/packages/shared/components/FilterSettings/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './FilterSettings'; diff --git a/frontend/packages/shared/components/Filters/README.md b/frontend/packages/shared/components/Filters/README.md deleted file mode 100644 index d833c5a819..0000000000 --- a/frontend/packages/shared/components/Filters/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# Filters - -_A simple composable side bar filter._ - -## Example - -```jsx - - - - - - {({ handleChildUpdate, ...parentAttributes }) => ( - - - - - )} - - - - - - -``` - -

    - -## Components - -### `` - -_Top level component_ - -#### Props - ---- - -`id` (**required**) - Used as the namespace of your filter in redux. - ---- - -`onChange` (**required**) - Handler for any filter selection changes.
    The funciton is called with a flattened object containing the field id as the keys. - ---- - -`title` (optional) - Title at the top of the filter -


    - -### `` - -_Group of filter fields_ - -#### Props - ---- - -`isCollapsible` (optional) - Allows the filterset to collapse its contents - ---- - -`title` (optional) - Title at the top of the filter set - ---- - -`type` (optional) - enum(`checkbox`, `search` ) - -


    - -### `` - -_Multi select search bar for a list of values_ - -#### Props - ---- - -`id` (required) - Filter id that this set belongs to - ---- - -`values` (required) - Array of object values that you can search and select. Each value object has a `value` and `label` key. - -


    - -### `` - -_Individual filter field (Currently checkbox is the only supported type)_ - -#### Props - ---- - -`label` (optional) - Label for the field. - ---- - -`description` (optional) - Description of the field. - ---- - -`onChange` (optional) - Callback function when called when filter is updated - ---- - -#### Props passed through render props - -_This only applies if field is nested within another FilterField_ - ---- - -`children` (optional) - Function that passes child onChange handler, parent value, and parent clicked. - ---- - -`parentValue` (optional) - Parent value if the filter field is nested. - ---- - -`parentClicked` (optional) - Parent attribute specifying if the parent update was from a click event. diff --git a/frontend/packages/shared/components/Filters/actions.js b/frontend/packages/shared/components/Filters/actions.js deleted file mode 100644 index 3ee49520dc..0000000000 --- a/frontend/packages/shared/components/Filters/actions.js +++ /dev/null @@ -1,21 +0,0 @@ -import { INITIALIZE, UPDATE_VALUE, REPLACE_VALUE, RESET } from './constants'; - -export const initializeFiltersAction = (id, preload = {}) => ({ - type: INITIALIZE, - payload: { id, preload }, -}); - -export const updateFiltersAction = (id, filterSetId, update) => ({ - type: UPDATE_VALUE, - payload: { id, filterSetId, update }, -}); - -export const replaceFiltersAction = (id, filterSetId, update) => ({ - type: REPLACE_VALUE, - payload: { id, filterSetId, update }, -}); - -export const resetFiltersAction = id => ({ - type: RESET, - payload: { id }, -}); diff --git a/frontend/packages/shared/components/Filters/components/FilterField.js b/frontend/packages/shared/components/Filters/components/FilterField.js deleted file mode 100644 index c65da38c17..0000000000 --- a/frontend/packages/shared/components/Filters/components/FilterField.js +++ /dev/null @@ -1,126 +0,0 @@ -import React, { Fragment, useEffect, useState, useContext } from 'react'; -import { FormGroup, withStyles } from '@material-ui/core'; -import { Checkbox, FormControlLabel } from 'core/app/styledMUIComponents'; -import capitalize from 'lodash/capitalize'; -import { FiltersContext } from './Filters'; - -const styles = { - label: { - fontSize: '.875rem', - }, - description: { - display: 'block', - color: '#979797', - fontSize: '12px', - }, - checkbox: { - '& svg': { - width: '.8em', - height: '.8em', - }, - }, -}; - -const FilterField = ({ - classes, - children, - count, - description, - filterSetId, - hasChildren, - id, - label, - onChange, - parentClicked, - parentValue, - preserveCase, - icon, -}) => { - const [value, setValue] = useState(false); - const [clicked, setClicked] = useState(false); - const [childValues, setChildValues] = useState({}); - const { filterId, filterValues, updateFilters } = useContext(FiltersContext); - - useEffect(() => { - // If checkbox filter is a CHILD, - // it needs to toggle along with it's parent - // when the parent is clicked. - if (parentClicked && !hasChildren) { - setValue(parentValue); - if (onChange) { - onChange({ [id]: parentValue }); - } - updateFilters(filterId, filterSetId, { [id]: parentValue }); - } - }, [id, filterId, filterSetId, hasChildren, onChange, parentClicked, parentValue, setValue, updateFilters]); - - useEffect(() => { - // If checkbox filter is a PARENT, - // it needs to keep track of its children's values. - // If ANY children are toggled on, it must be on. - // If ALL children are toggled off, it must be off. - setValue(Object.values(childValues).reduce((acc, value) => acc || value, false)); - }, [childValues]); - - useEffect( - // Sets the value of the checkbox if redux state was preloaded - () => { - const anyChild = Object.values(childValues).includes(true); - const val = (filterValues[filterSetId] && filterValues[filterSetId][id]) || anyChild || false; - if (val !== value) { - setValue(val); - if (onChange) { - onChange({ [id]: val }); - } - } - }, - [filterValues, filterSetId, id, onChange, value, childValues], - ); - - const handleChildUpdate = update => { - setClicked(false); - setChildValues(prevState => { - return { ...prevState, ...update }; - }); - }; - - const checkboxControl = ( - { - if (!hasChildren) { - updateFilters(filterId, filterSetId, { [id]: !!checked }); - } - setValue(checked); - setClicked(true); - if (onChange) { - onChange({ [id]: checked }); - } - }} - /> - ); - - const countString = `(${count})`; - return ( - - - - {icon} - {`${preserveCase ? label : capitalize(label)} ${countString || 0}`} - {description && {description}} - - } - /> - - {children && children({ handleChildUpdate, parentValue: value, parentClicked: clicked })} - - ); -}; - -export default withStyles(styles)(React.memo(FilterField)); diff --git a/frontend/packages/shared/components/Filters/components/FilterSet.js b/frontend/packages/shared/components/Filters/components/FilterSet.js deleted file mode 100644 index 80e2733e41..0000000000 --- a/frontend/packages/shared/components/Filters/components/FilterSet.js +++ /dev/null @@ -1,106 +0,0 @@ -import React, { Fragment } from 'react'; -import classNames from 'classnames'; -import { withStyles } from '@material-ui/core'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import HelpIcon from '@material-ui/icons/HelpOutline'; -import { withState } from 'recompose'; -import Link from 'shared/components/Link'; - -const styles = { - root: { - paddingLeft: 5, - paddingBottom: 10, - paddingTop: 5, - }, - header: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexDirection: 'row', - cursor: 'pointer', - }, - title: { - margin: '10px 0', - display: 'inline-block', - }, - nested: { - paddingLeft: 10, - marginLeft: 13, - borderLeft: '1px solid #ccc', - }, - collapsibleContainer: { - overflow: 'hidden', - }, - collapsible: { - transform: 'translateY(0%)', - transition: 'transform .3s ease-in-out', - }, - collapsed: { - transform: 'translateY(-100%)', - height: 0, - }, - expandIcon: { - transition: 'transform .3s ease-in', - transform: 'rotate(0deg)', - fontSize: 20, - color: '#777', - }, - expandLessIcon: { - transform: 'rotate(180deg)', - }, - divider: { - borderTop: '1px solid #BDBDBD', - }, - helpIcon: { - color: '#000', - fontSize: 16, - display: 'inline-block', - verticalAlign: 'middle', - marginLeft: 5, - }, -}; - -const FilterSet = ({ - classes, - collapsed, - helpUrl, - isCollapsible, - isNested, - children, - title, - toggleCollapse, - hideBorder, -}) => ( - -
    - {title && ( -
    isCollapsible && toggleCollapse(!collapsed)} - onKeyPress={() => isCollapsible && toggleCollapse(!collapsed)} - > -
    -
    {title}
    - {helpUrl && ( - e.stopPropagation()}> - - - )} -
    - {isCollapsible && ( - - )} -
    - )} -
    -
    {children}
    -
    -
    -
    -); - -export default withState('collapsed', 'toggleCollapse', false)(withStyles(styles)(FilterSet)); diff --git a/frontend/packages/shared/components/Filters/components/Filters.js b/frontend/packages/shared/components/Filters/components/Filters.js deleted file mode 100644 index 1de899f97a..0000000000 --- a/frontend/packages/shared/components/Filters/components/Filters.js +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core'; -import { FilterSelectors } from '../reducer'; -import { initializeFiltersAction, updateFiltersAction, replaceFiltersAction } from '../actions'; - -export const FiltersContext = React.createContext({ updateFilters: () => {} }); - -const styles = { - root: { - padding: '15px', - }, - title: { margin: '15px auto' }, -}; - -const Filters = ({ - children, - classes, - initializeFilters, - id, - onChange, - preload, - replaceFilters, - updateFilters, - values, -}) => { - let [initialPreload] = useState(() => preload); - - useEffect(() => { - initializeFilters(id, initialPreload); - }, [id, initialPreload, initializeFilters]); - - useEffect(() => { - if (onChange && values) { - onChange(values); - } - }, [onChange, values]); - - return ( - -
    {children}
    -
    - ); -}; - -Filters.propTypes = { - children: PropTypes.node, - id: PropTypes.string.isRequired, - initializeFilters: PropTypes.func, - onChange: PropTypes.func, - updateFilters: PropTypes.func, - replaceFilters: PropTypes.func, -}; - -const mapStateToProps = (state, ownProps) => ({ - values: FilterSelectors.getValues(state, ownProps.id), -}); - -const mapDispatchToProps = { - initializeFilters: initializeFiltersAction, - updateFilters: updateFiltersAction, - replaceFilters: replaceFiltersAction, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(Filters)); diff --git a/frontend/packages/shared/components/Filters/components/SearchableSet.js b/frontend/packages/shared/components/Filters/components/SearchableSet.js deleted file mode 100644 index 6cab289019..0000000000 --- a/frontend/packages/shared/components/Filters/components/SearchableSet.js +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useContext } from 'react'; -import Select from 'react-select'; -import { FiltersContext } from './Filters'; - -const styles = { - menu: provided => ({ - ...provided, - fontSize: 14, - }), - multiValue: provided => ({ - ...provided, - border: '1px solid black', - borderRadius: 15, - backgroundColor: 'transparent', - overflow: 'hidden', - }), -}; - -const SearchableSet = ({ id, values }) => { - const { filterId, filterValues, replaceFilters } = useContext(FiltersContext); - - return ( -
    - {!dense && ( - - - - - )} - {children} -
    -); - -export const MetadataTableItem = ({ title, children, ...rest }) => ( - - {title && {title}} - - {children} - - -); - -export const MetadataList = withStyles(listStyles)(({ classes, children }) => ( -
      {children}
    -)); - -export const MetadataListItem = withStyles(listItemStyles)(({ classes, children }) => ( -
  • {children}
  • -)); diff --git a/frontend/packages/shared/components/ModalTruncate.js b/frontend/packages/shared/components/ModalTruncate.js deleted file mode 100644 index a151783bbb..0000000000 --- a/frontend/packages/shared/components/ModalTruncate.js +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Modal, Paper } from '@material-ui/core'; -import { withState } from 'recompose'; - -/** - * A component which truncates text that is beyond the provided maximum length and makes - * it so that activating the text will display the entire contents in a modal with - * code/pre-formatting. This is primarily useful for long blobs of content from the backend - * such as stack traces or moderate sized logs. - * - * Truncated content will have a cursor-style applied to make it clear to the user that - * it is interactive. Additionally, aria-label, tabindex, and keyPress attributes - * are defined for non-mouse and non-visual accessibility. - * - * Properties: - * - body: The text to optionally truncate and make clickable/selectable for modal expansion - * - maxLength: The maximum length of the text allowed before truncation. Default: 512 - * - * Any additional properties supplied will be added onto the container element. In some cases - * it may be necessary to supply a "key" property, for example. - */ -const ModalTruncate = withState( - 'expanded', - 'setExpanded', - false, -)(({ expanded, setExpanded, body, maxLength = 512, ...props }) => { - if (body.length > maxLength) { - const onClose = () => setExpanded(false); - const truncated = `${body.slice(0, maxLength)}...`; - const children = [ - truncated, - - - -
    {body}
    -
    -
    -
    , - ]; - const onClick = e => { - e.preventDefault(); - setExpanded(true); - }; - return ( -
    - {children} -
    - ); - } - return
    {body}
    ; -}); - -ModalTruncate.propTypes = { - body: PropTypes.string.isRequired, - maxLength: PropTypes.number, -}; - -export default ModalTruncate; diff --git a/frontend/packages/shared/components/ModalView.js b/frontend/packages/shared/components/ModalView.js deleted file mode 100644 index 0fab9c541b..0000000000 --- a/frontend/packages/shared/components/ModalView.js +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { Modal } from '@material-ui/core'; - -const getModalStyles = () => { - return { - position: 'fixed', - width: '80%', - height: '80%', - top: '10%', - left: '10%', - // margin: '0 auto', - padding: 10, - backgroundColor: '#fff', - }; -}; - -class ModalView extends React.Component { - render() { - const { open, onClose } = this.props; - return ( - -
    {this.props.children}
    -
    - ); - } -} - -export default ModalView; diff --git a/frontend/packages/shared/components/OwnerLink.js b/frontend/packages/shared/components/OwnerLink.js deleted file mode 100644 index 59be23541e..0000000000 --- a/frontend/packages/shared/components/OwnerLink.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import Link from 'shared/components/Link'; -import { SquadIcon, UserIcon } from 'shared/icons'; -import { GA_DEFAULT_OWNER_PROPS } from 'shared/apis/events/GoogleAnalyticsEvent'; -import { paths } from 'plugins/whoami/definitions/config'; -import InlineIcon from 'shared/components/InlineIcon'; - -// GraphQL returns an 'unknown' type for users (fallback) but not for any groups -const ownerLinkBase = { - squad: `${paths.GROUPS_URL}/`, - unknown: `${paths.USERS_URL}/`, - user: `${paths.USERS_URL}/`, -}; - -const ownerIcon = { - squad: SquadIcon, - unknown: UserIcon, - user: UserIcon, -}; - -export function buildOwnerLink(owner) { - if (!owner) { - return null; - } - - const path = ownerLinkBase[owner.type] || ownerLinkBase['squad']; - return `${path}${encodeURIComponent(owner.name)}`; -} - -const OwnerLink = ({ owner }) => { - if (!owner) { - return null; - } - - const IconComponent = ownerIcon[owner.type] || ownerIcon['squad']; - - return ( - - - {owner.name || ''} - - ); -}; - -export default OwnerLink; diff --git a/frontend/packages/shared/components/Page/Page.js b/frontend/packages/shared/components/Page/Page.js deleted file mode 100644 index 0b141cd12f..0000000000 --- a/frontend/packages/shared/components/Page/Page.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, { Component } from 'react'; -import { withStyles } from '@material-ui/core'; - -const styles = theme => ({ - root: { - paddingTop: theme.spacing(3), - paddingBottom: theme.spacing(3), - height: '100%', - overflow: 'auto', - backgroundColor: theme.palette.background.default, - ...theme.mixins.gutters({}), - }, -}); - -/** - * @deprecated Replaced by shared/components/layout/Page; see https://backstage.spotify.net/docs/backstage-layout-components - */ -class Page extends Component { - render() { - const { children, style } = this.props; - return ( -
    - {children} -
    - ); - } -} - -export default withStyles(styles)(Page); diff --git a/frontend/packages/shared/components/Page/Page.test.js b/frontend/packages/shared/components/Page/Page.test.js deleted file mode 100644 index 8b5f4cede5..0000000000 --- a/frontend/packages/shared/components/Page/Page.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { wrapInTestApp } from 'testUtils'; - -import Page from './Page'; - -describe('', () => { - it('renders without exploding', () => { - const rendered = render(wrapInTestApp()); - expect(rendered).toBeDefined(); - }); -}); diff --git a/frontend/packages/shared/components/Page/index.js b/frontend/packages/shared/components/Page/index.js deleted file mode 100644 index 67d67738c9..0000000000 --- a/frontend/packages/shared/components/Page/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as Page } from './Page'; diff --git a/frontend/packages/shared/components/PagerDutyServiceStatus.js b/frontend/packages/shared/components/PagerDutyServiceStatus.js deleted file mode 100644 index b59c4bb0e6..0000000000 --- a/frontend/packages/shared/components/PagerDutyServiceStatus.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { Tooltip } from '@material-ui/core'; -import countBy from 'lodash/countBy'; -import { StatusError, StatusOK, StatusWarning } from 'shared/components/Status'; -import Link from 'shared/components/Link'; - -const PagerDutyServiceStatus = ({ service }) => { - if (!service || !service.activeIncidents) { - return null; - } - - const incidents = service.activeIncidents; - - let StatusMarker; - let title = `PagerDuty service "${service.name}": `; - if (!incidents.length) { - StatusMarker = StatusOK; - title += 'OK'; - } else { - StatusMarker = incidents.some(i => i.status === 'triggered') ? StatusError : StatusWarning; - - const statusCounts = countBy(incidents, 'status'); - title += Object.entries(statusCounts) - .map(([status, count]) => `${count} ${status}`) - .join(', '); - title += ' incidents'; - } - - return ( - - - - - - ); -}; - -export default PagerDutyServiceStatus; diff --git a/frontend/packages/shared/components/PagerDutyServiceStatus.test.js b/frontend/packages/shared/components/PagerDutyServiceStatus.test.js deleted file mode 100644 index 2fdc40edb0..0000000000 --- a/frontend/packages/shared/components/PagerDutyServiceStatus.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { wrapInThemedTestApp } from 'testUtils'; -import PagerDutyServiceStatus from 'shared/components/PagerDutyServiceStatus'; - -const props = { - service: { - name: 'test-service', - activeIncidents: [{ status: 'triggered' }], - }, -}; -describe('', () => { - it('renders an error icon and title for an incident', () => { - const { getByTitle, getByLabelText } = render(wrapInThemedTestApp()); - getByTitle('PagerDuty service "test-service": 1 triggered incidents'); - getByLabelText('Status error'); - }); -}); diff --git a/frontend/packages/shared/components/PartitionPicker/PartitionPicker.js b/frontend/packages/shared/components/PartitionPicker/PartitionPicker.js deleted file mode 100644 index 6f045c8e2f..0000000000 --- a/frontend/packages/shared/components/PartitionPicker/PartitionPicker.js +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { TextField, withStyles, Icon } from '@material-ui/core'; -import moment from 'moment'; - -const HOURS_PARTITIONING = 'HOURS'; - -const iconBaseStyles = { - opacity: 0.5, - '&:hover': { - transform: 'scale(1.2)', - }, -}; - -const styles = { - leftIcon: { - ...iconBaseStyles, - marginRight: '10px', - }, - rightIcon: { - ...iconBaseStyles, - marginLeft: '10px', - }, - datepicker: { - display: 'flex', - alignItems: 'center', - marginBottom: '5px', - }, -}; - -const dateStyles = { - fontSize: '24px', -}; - -export const isHoursPartitioning = p => p === HOURS_PARTITIONING; - -export const getPartitionFromUtc = m => `${m.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS)}Z`; - -export const getInputTypeFromPartitioning = partitioning => - isHoursPartitioning(partitioning) ? 'datetime-local' : 'date'; - -export const formatMomentForDatePicker = (m, partitioning) => - m - .utc() - .startOf('hour') - .format(isHoursPartitioning(partitioning) ? moment.HTML5_FMT.DATETIME_LOCAL : moment.HTML5_FMT.DATE); - -const PartitionPicker = ({ partition, partitioning, handlePartitionChange, setPartition, classes }) => { - const updatePartition = increment => { - const updated = moment(partition) - .utc() - .clone() - .add(increment ? 1 : -1, isHoursPartitioning(partitioning) ? 'hours' : 'days'); - setPartition(getPartitionFromUtc(updated)); - }; - - return ( -
    - updatePartition(false)}> - chevron_left - - - updatePartition(true)}> - chevron_right - -
    - ); -}; - -export default withStyles(styles)(PartitionPicker); diff --git a/frontend/packages/shared/components/PartitionPicker/index.js b/frontend/packages/shared/components/PartitionPicker/index.js deleted file mode 100644 index 437a8fcae1..0000000000 --- a/frontend/packages/shared/components/PartitionPicker/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import PartitionPicker, { - isHoursPartitioning, - getPartitionFromUtc, - getInputTypeFromPartitioning, - formatMomentForDatePicker, -} from './PartitionPicker'; - -export { isHoursPartitioning, getPartitionFromUtc, getInputTypeFromPartitioning, formatMomentForDatePicker }; - -export default PartitionPicker; diff --git a/frontend/packages/shared/components/PrimaryTableLink.js b/frontend/packages/shared/components/PrimaryTableLink.js deleted file mode 100644 index daefdd81ae..0000000000 --- a/frontend/packages/shared/components/PrimaryTableLink.js +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import BreakHints from 'shared/components/BreakHints'; -import Link from 'shared/components/Link'; - -const PrimaryTableLink = ({ to, text }) => { - return ( - - - - ); -}; - -export default PrimaryTableLink; diff --git a/frontend/packages/shared/components/ProgressOverlay/ProgressOverlay.js b/frontend/packages/shared/components/ProgressOverlay/ProgressOverlay.js deleted file mode 100644 index b78f911e96..0000000000 --- a/frontend/packages/shared/components/ProgressOverlay/ProgressOverlay.js +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { CircularProgress, withStyles } from '@material-ui/core'; - -const styles = theme => ({ - root: { - position: 'relative', - }, - wrapper: { - top: 0, - left: 0, - zIndex: theme.zIndex.modal, - display: 'flex', - position: 'absolute', - width: '100%', - height: '100%', - }, - overlay: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - background: theme.palette.background.paper, - opacity: 0.6, - }, - progress: { - margin: 'auto', - }, -}); - -class ProgressOverlay extends React.Component { - static propTypes = { - show: PropTypes.bool.isRequired, - classes: PropTypes.object.isRequired, // withStyles - }; - - render() { - const { show, classes, children } = this.props; - - return ( -
    - {show && ( -
    -
    - -
    - )} -
    {children}
    -
    - ); - } -} - -export default withStyles(styles)(ProgressOverlay); diff --git a/frontend/packages/shared/components/ProgressOverlay/index.js b/frontend/packages/shared/components/ProgressOverlay/index.js deleted file mode 100644 index 064bd1a668..0000000000 --- a/frontend/packages/shared/components/ProgressOverlay/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ProgressOverlay'; diff --git a/frontend/packages/shared/components/SearchableList/SearchableList.js b/frontend/packages/shared/components/SearchableList/SearchableList.js deleted file mode 100644 index a109941ae3..0000000000 --- a/frontend/packages/shared/components/SearchableList/SearchableList.js +++ /dev/null @@ -1,310 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import TrieSearch from 'trie-search'; -import { - IconButton, - Input, - InputAdornment, - List, - ListItem, - ListItemIcon, - ListItemText, - Typography, -} from '@material-ui/core'; -import { SearchIcon } from 'shared/icons'; -import Link from 'shared/components/Link'; -import WarningIcon from '@material-ui/icons/Warning'; - -const RenderSearchIconOrTextInput = ({ displaySearch, searchInput_onChangeHandler, searchIcon_onClickHandler }) => { - if (displaySearch) { - return ( - - - - - - } - /> - ); - } - - return ( - - - - ); -}; - -const SearchableListStyles = { - container: { - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - marginTop: 16, - marginBottom: 16, - position: 'relative', - flex: '1', - }, - header: { - flex: '0 0 auto', - display: 'flex', - justifyContent: 'space-between', - flexDirection: 'row', - marginLeft: 16, - }, - content: { - height: '100%', - flex: '1 1 auto', - overflow: 'auto', - }, -}; - -export default class SearchableList extends Component { - static propTypes = { - title: PropTypes.string, - items: PropTypes.array.isRequired, - itemRenderer: PropTypes.func, // itemRenderer(clickHandler, item, index, allItems) => ListItem[] - onItemClick: PropTypes.func, - enableSearch: PropTypes.bool, - style: PropTypes.object, - showCount: PropTypes.bool, - className: PropTypes.string, - itemHeight: PropTypes.number, - titleStyle: PropTypes.object, - titleClasses: PropTypes.string, - labelField: PropTypes.string, - labelFunction: PropTypes.func, - linkToFunction: PropTypes.func, - globalFilter: PropTypes.string, // startsWith matching only due to trie structure - indexFunction: PropTypes.func, - trieSearchOptions: PropTypes.object, - }; - - static defaultProps = { - items: [], - enableSearch: false, - style: SearchableListStyles.container, - showCount: true, - itemHeight: 35, - }; - - state = { - displaySearch: false, - scrollTop: 0, - filterString: undefined, - }; - - //----------------------------------- - // Constructor - //----------------------------------- - constructor(props) { - super(props); - this.rebuildIndex(props); - } - - //----------------------------------- - // Lifecycle - //----------------------------------- - componentWillUpdate(nextProps) { - if (nextProps.items !== this.props.items) { - this.rebuildIndex(nextProps); - } - } - - render() { - let { - title, - items, - itemRenderer, - onItemClick, - autoDisplaySearch, - enableSearch, - style, - showCount, - className, - itemHeight, - titleStyle, - headerStyle, - titleClasses, - renderItemCount = 50, - } = this.props; - const { displaySearch, scrollTop } = this.state; - - const filteredItems = this.getFilteredItems(items); - - if (!itemRenderer) { - itemRenderer = this.defaultItemRenderer; - } - - if (showCount && title) { - if (filteredItems && filteredItems.length !== items.length) { - title = `${title} (${filteredItems.length} / ${items.length})`; - } else { - title = `${title} (${items.length})`; - } - } - - const searchNoItemsMessage = ( - - - - - - - ); - - const topItemIx = Math.floor(scrollTop / itemHeight); - const result = filteredItems.slice(topItemIx, topItemIx + renderItemCount).map((item, ix, arr) => { - return itemRenderer(onItemClick, item, topItemIx + ix, arr); - }); - const totalHeight = filteredItems.length * itemHeight; - - return ( -
    -
    - - {title} - - {enableSearch ? ( - - ) : null} -
    -
    - - {result.length > 0 ? result : searchNoItemsMessage} - -
    -
    - ); - } - - //----------------------------------- - // Methods - //----------------------------------- - focus() { - if (this.refs.search) { - this.refs.search.focus(); - } - } - - getFilteredItems(items) { - const { filterString } = this.state; - const { globalFilter } = this.props; - - const filter = filterString || globalFilter; - - if (this.trieSearch && filter) { - return this.trieSearch.get(filter); - } - - return items; - } - - getLabelFor(item) { - const { labelField = 'label', labelFunction } = this.props; - - if (labelFunction) { - return labelFunction(item, this); - } - - return typeof item === 'string' ? item : item[labelField]; - } - - defaultItemRenderer = (onItemClick, item, ix) => { - const { linkToFunction, itemHeight } = this.props; - const label = this.getLabelFor(item); - const key = item.id || label; - - const url = linkToFunction ? linkToFunction(item) : ''; - - if (onItemClick) { - return ( - onItemClick.bind(undefined, e, item)()} - style={{ - fontSize: 14, - top: ix * itemHeight, - position: 'absolute', - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - height: itemHeight, - padding: 0, - }} - > -
    {label}
    -
    - ); - } - - return ( - - - {label} - - - ); - }; - - rebuildIndex(props) { - const { items, uniqueField, labelField = 'label', indexFunction, trieSearchOptions } = props; - - this.trieSearch = new TrieSearch( - labelField, - trieSearchOptions || { - splitOnRegEx: /([.\-\s_]|(?=[A-Z][a-z]))/, - splitOnGetRegEx: /([.\-\s_]|(?=[A-Z][a-z]))/, - insertFullUnsplitKey: true, - indexField: uniqueField || labelField, - }, - ); - - if (items) { - items.forEach(item => { - let str = indexFunction ? indexFunction(item) : this.getLabelFor(item); - - if (str) { - this.trieSearch.map(str, item); - } else { - console.warn('List: attempting to index an empty label for', item, labelField); - } - }); - } - } - - //----------------------------------- - // Events - //----------------------------------- - searchIcon_onClickHandler = () => { - this.setState({ displaySearch: true }); - }; - - searchInput_onChangeHandler = event => { - this.setState({ filterString: event.target.value }); - }; - - onScrollHandler = event => { - this.setState({ - scrollTop: event.target.scrollTop, - }); - }; -} diff --git a/frontend/packages/shared/components/SearchableList/SearchableList.test.js b/frontend/packages/shared/components/SearchableList/SearchableList.test.js deleted file mode 100644 index 41449bcbfc..0000000000 --- a/frontend/packages/shared/components/SearchableList/SearchableList.test.js +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react'; -import SearchableList from 'shared/components/SearchableList'; -import { render, fireEvent } from '@testing-library/react'; - -import { wrapInTestApp } from 'testUtils'; -import { ListItem } from '@material-ui/core'; - -describe('', () => { - it('Renders an empty list (without exploding)', () => { - const { getByText, getAllByText } = render(wrapInTestApp()); - expect(getByText('No items found')).toBeVisible(); - expect(getAllByText('No items found').length).toBe(1); - }); - - it('Renders a simple list', () => { - const items = ['Apple', 'Banana', 'Cherry']; - const { getAllByText } = render(wrapInTestApp()); - - expect(getAllByText('Apple').length).toBe(1); - expect(getAllByText('Banana').length).toBe(1); - expect(getAllByText('Cherry').length).toBe(1); - }); - - it('Hides search if not enabled', () => { - const items = ['Apple', 'Banana', 'Cherry']; - const { queryByTestId } = render(wrapInTestApp()); - expect(queryByTestId('search-button')).toBeNull(); - }); - - it('Filters items on search', () => { - const items = ['Apple', 'Banana', 'Cherry']; - const { getAllByText, queryAllByText, getByPlaceholderText, getByTestId } = render( - wrapInTestApp(), - ); - - fireEvent.click(getByTestId('search-button')); - - const input = getByPlaceholderText('Search...'); - expect(input).toBeVisible(); - - fireEvent.change(input, { target: { value: 'che' } }); - - expect(queryAllByText(/Apple|Banana/).length).toBe(0); - expect(getAllByText('Cherry').length).toBe(1); - }); - - it('Uses a custom item renderer if provided', () => { - const items = ['Apple', 'Banana', 'Cherry']; - const customItemRenderer = (clickHandler, item, index) => Kiwi; // Replace all inferior fruit with kiwi - const { getAllByText, queryAllByText } = render( - wrapInTestApp(), - ); - - expect(queryAllByText(/Apple|Banana|Cherry/).length).toBe(0); - expect(getAllByText('Kiwi').length).toBe(3); - }); - - it('Uses a custom click handler if provided', () => { - const itemsAndClickCount = { Apple: 0, Banana: 0, Cherry: 0 }; - const customClickHandler = (e, item) => { - itemsAndClickCount[item]++; - }; - const { getByTestId } = render( - , - ); - - fireEvent.click(getByTestId('ListItem-Apple')); - fireEvent.click(getByTestId('ListItem-Apple')); - fireEvent.click(getByTestId('ListItem-Cherry')); - - expect(itemsAndClickCount['Apple']).toBe(2); - expect(itemsAndClickCount['Banana']).toBe(0); - expect(itemsAndClickCount['Cherry']).toBe(1); - }); - - it('Renders labels using a custom field if provided', () => { - const items = [ - { name: 'Apple', upc: '1234' }, - { name: 'Banana', upc: '5678' }, - { name: 'Cherry', upc: '9012' }, - ]; - const { getAllByText, queryAllByText } = render(wrapInTestApp()); - - expect(queryAllByText(/Apple|Banana|Cherry/).length).toBe(0); - expect(getAllByText('1234').length).toBe(1); - expect(getAllByText('5678').length).toBe(1); - expect(getAllByText('9012').length).toBe(1); - }); - - it('Renders labels using a custom label function if provided', () => { - const items = ['Apple', 'Banana', 'Cherry']; - const customLabelFn = item => `${item}berry`; - - const { getAllByText, queryAllByText } = render( - wrapInTestApp(), - ); - - expect(queryAllByText(/(Apple|Banana|Cherry)$/).length).toBe(0); - expect(getAllByText('Appleberry').length).toBe(1); - expect(getAllByText('Bananaberry').length).toBe(1); - expect(getAllByText('Cherryberry').length).toBe(1); - }); - - it('Filters entries if a global filter is provided', () => { - const items = ['Apple', 'Banana', 'Blackberry', 'Cherry', 'Lingonberry', 'Strawberry']; - const { getAllByText, queryAllByText } = render(wrapInTestApp()); - - expect(queryAllByText(/Apple|Cherry|Lingonberry|Strawberry/).length).toBe(0); - expect(getAllByText('Banana').length).toBe(1); - expect(getAllByText('Blackberry').length).toBe(1); - }); -}); diff --git a/frontend/packages/shared/components/SearchableList/index.js b/frontend/packages/shared/components/SearchableList/index.js deleted file mode 100644 index 27fb09affe..0000000000 --- a/frontend/packages/shared/components/SearchableList/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SearchableList'; diff --git a/frontend/packages/shared/components/Sequence/Sequence.test.js b/frontend/packages/shared/components/Sequence/Sequence.test.js deleted file mode 100644 index e9b34545b4..0000000000 --- a/frontend/packages/shared/components/Sequence/Sequence.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from 'testUtils'; -import Sequence from 'shared/components/Sequence'; - -describe('Sequence', () => { - let steps; - beforeEach(() => { - steps = [ - { - title: 'Step 0', - content:
    step0
    , - }, - { - title: 'Step 1', - content:
    step1
    , - actions: { nextStep: () => 3 }, - }, - { - title: 'Step 2', - content:
    step2
    , - }, - { - title: 'Step 3', - content:
    step3
    , - }, - ]; - }); - - it('Handles nextStep property', () => { - const rendered = render(wrapInTestApp()); - fireEvent.click(rendered.getByText('Next')); - expect(rendered.getByText('step1')).toBeInTheDocument(); - - fireEvent.click(rendered.getByText('Next')); - expect(rendered.getByText('step3')).toBeInTheDocument(); - - fireEvent.click(rendered.getByText('Back')); - expect(rendered.getByText('step1')).toBeInTheDocument(); - }); - - it('Shows controls and content when going back to first step', () => { - const rendered = render(wrapInTestApp()); - fireEvent.click(rendered.getByText('Next')); - expect(rendered.getByText('step1')).toBeInTheDocument(); - - fireEvent.click(rendered.getByText('Next')); - expect(rendered.getByText('step3')).toBeInTheDocument(); - - fireEvent.click(rendered.getByText('Back')); - expect(rendered.getByText('step1')).toBeInTheDocument(); - expect(rendered.getByText('Next')).toBeInTheDocument(); - - fireEvent.click(rendered.getByText('Back')); - expect(rendered.getByText('step0')).toBeInTheDocument(); - expect(rendered.getByText('Next')).toBeInTheDocument(); - }); - - it('uses nextText if specified in all steps', () => { - let nextTextSteps = [ - { - title: 'Step 0', - actions: { - nextText: 'Step0Next', - }, - content:
    step0
    , - }, - { - title: 'Final Step', - actions: { - nextText: 'FinalStepNext', - }, - content:
    final step
    , - }, - ]; - - const rendered = render(wrapInTestApp()); - expect(rendered.getByText('Step0Next')).toBeInTheDocument(); - fireEvent.click(rendered.getByText('Step0Next')); - - expect(rendered.getByText('FinalStepNext')).toBeInTheDocument(); - }); -}); diff --git a/frontend/packages/shared/components/Sequence/Sequence.tsx b/frontend/packages/shared/components/Sequence/Sequence.tsx deleted file mode 100644 index 89f0b654c0..0000000000 --- a/frontend/packages/shared/components/Sequence/Sequence.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { Fragment, FC, useState } from 'react'; -import { Stepper, Step, StepContent, StepLabel, Typography, withStyles } from '@material-ui/core'; -import SequenceFooter from './SequenceFooter'; - -type Orientation = 'horizontal' | 'vertical'; - -export type StepActions = { - showNext?: boolean; - canNext?: () => boolean; - onNext?: () => void; - nextStep?: () => number; - nextText?: string; - - showBack?: boolean; - backText?: string; - onBack?: () => void; - - showRestart?: boolean; - canRestart?: () => boolean; - onRestart?: () => void; - restartText?: string; - - completedButton?: JSX.Element; -}; - -export type StepType = { - title: string; - actions?: StepActions; - content: JSX.Element; -}; -type SequenceProps = { - completed: StepType; - classes: any; - elevation?: boolean; - orientation: Orientation; - onSequenceStepChange?: (prevIndex: number, nextIndex: number) => void; - steps: StepType[]; -}; - -const Sequence: FC = ({ completed, classes, elevation, orientation, onSequenceStepChange, steps }) => { - const [stepIndex, setStepIndex] = useState(0); - const [stepArray, setStepArray] = useState([0]); - const isVertical = orientation === 'vertical'; - const horizontalStep = !isVertical && steps[stepIndex] ? steps[stepIndex].content : false; - - return ( - - - {steps.map((step, i) => ( - - - {step.title} - - {isVertical && ( - - {step.content} - - - )} - - ))} - - {stepIndex === steps.length && !!completed && ( -
    - {completed.title} - {completed.content} - - {completed.actions && completed.actions.completedButton} - -
    - )} - {/* Horizontal steppers have buttons on the bottom of stepper */} - {!isVertical && ( - <> - {horizontalStep} - - - )} -
    - ); -}; - -const styles = (theme: any) => ({ - completionBox: { - padding: theme.spacing(3), - }, -}); - -export default withStyles(styles)(Sequence); diff --git a/frontend/packages/shared/components/Sequence/SequenceFooter.tsx b/frontend/packages/shared/components/Sequence/SequenceFooter.tsx deleted file mode 100644 index 043a7c9106..0000000000 --- a/frontend/packages/shared/components/Sequence/SequenceFooter.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { FC, ReactNode } from 'react'; -import { makeStyles } from '@material-ui/core'; -import { StepActions } from 'shared/components/Sequence/Sequence'; -import Button from 'shared/components/Button'; - -const useStyles = makeStyles(theme => ({ - root: { - display: 'block', - marginTop: theme.spacing(3), - }, -})); - -export const RestartBtn: FC<{ text?: string; handleClick?: () => void; stepIndex: number }> = ({ - text, - handleClick, - stepIndex, -}) => ( - -); -const NextBtn: FC<{ - text?: string; - handleClick?: () => void; - disabled?: boolean; - last?: boolean; - stepIndex: number; -}> = ({ text, handleClick, disabled, last, stepIndex }) => ( - -); -const BackBtn: FC<{ text?: string; handleClick?: () => void; disabled?: boolean; stepIndex: number }> = ({ - text, - handleClick, - disabled, - stepIndex, -}) => ( - -); - -export type SequenceFooterProps = { - stepIndex: number; - setStepIndex: React.Dispatch>; - stepArray: number[]; - setStepArray: React.Dispatch>; - length?: number; - actions: StepActions; - onSequenceStepChange?: (old: number, updated: number) => void; - children?: ReactNode; -}; - -const SequenceFooter: FC = ({ - stepIndex, - setStepIndex, - stepArray, - setStepArray, - length, - onSequenceStepChange, - actions, - children, -}) => { - const classes = useStyles(); - - const onChange = (newIndex: number, callback?: () => void) => { - if (callback) { - callback(); - } - if (onSequenceStepChange) { - onSequenceStepChange(stepIndex, newIndex); - } - - setStepIndex(newIndex); - }; - - const handleNext = () => { - const newIndex = actions.nextStep ? actions.nextStep() : stepIndex + 1; - onChange(newIndex, actions.onNext); - setStepArray([...stepArray, newIndex]); - }; - const handleBack = () => { - stepArray.pop(); - onChange(stepArray[stepArray.length - 1], actions.onBack); - setStepArray([...stepArray]); - }; - const handleRestart = () => { - onChange(0, actions.onRestart); - setStepArray([0]); - }; - - return ( -
    - {[undefined, true].includes(actions.showBack) && stepIndex !== 0 && ( - - )} - {[undefined, true].includes(actions.showNext) && ( - = length) || (!!actions.canNext && !actions.canNext())} - stepIndex={stepIndex} - /> - )} - {actions.showRestart && stepIndex !== 0 && ( - - )} - {children} -
    - ); -}; - -export default SequenceFooter; diff --git a/frontend/packages/shared/components/Sequence/index.js b/frontend/packages/shared/components/Sequence/index.js deleted file mode 100644 index 285eab20bb..0000000000 --- a/frontend/packages/shared/components/Sequence/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Sequence'; diff --git a/frontend/packages/shared/components/Status/Status.js b/frontend/packages/shared/components/Status/Status.js deleted file mode 100644 index 04fff2a552..0000000000 --- a/frontend/packages/shared/components/Status/Status.js +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import { pure } from 'recompose'; -import { withStyles } from '@material-ui/core'; - -const styles = theme => ({ - status: { - width: 12, - height: 12, - display: 'inline-block', - marginRight: 1, - }, - ok: { - backgroundColor: theme.palette.status.ok, - borderRadius: '50%', - }, - warning: { - backgroundColor: theme.palette.status.warning, - }, - error: { - width: '0', - height: '0', - borderLeft: '7px solid transparent', - borderRight: '7px solid transparent', - borderBottom: `14px solid ${theme.palette.status.error}`, - }, - pending: { - backgroundColor: theme.palette.status.pending, - }, - failed: { - backgroundColor: 'rgba(245, 155, 35, 0.5)', - }, - running: { - animation: 'blink 0.8s step-start 0s infinite', - backgroundColor: theme.palette.status.running, - }, - '@keyframes blink': { - '50%': { - backgroundColor: theme.palette.status.background, - }, - }, -}); - -export const StatusOK = pure( - withStyles(styles)(props => ( - - )), -); - -export const StatusWarning = pure( - withStyles(styles)(props => ( - - )), -); - -export const StatusError = pure( - withStyles(styles)(props => ( - - )), -); - -export const StatusNA = pure(() => N/A); - -export const StatusPending = pure( - withStyles(styles)(props => ( - - )), -); - -export const StatusRunning = pure( - withStyles(styles)(props => ( - - )), -); - -export const StatusFailed = pure( - withStyles(styles)(props => ( - - )), -); diff --git a/frontend/packages/shared/components/Status/index.js b/frontend/packages/shared/components/Status/index.js deleted file mode 100644 index 4b037648e3..0000000000 --- a/frontend/packages/shared/components/Status/index.js +++ /dev/null @@ -1 +0,0 @@ -export { StatusError, StatusFailed, StatusNA, StatusOK, StatusPending, StatusRunning, StatusWarning } from './Status'; diff --git a/frontend/packages/shared/components/StructuredMetadataTable/README.md b/frontend/packages/shared/components/StructuredMetadataTable/README.md deleted file mode 100644 index 93cad612cc..0000000000 --- a/frontend/packages/shared/components/StructuredMetadataTable/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Structured MetadataTable - -The `Strucuted MetadataTable` staple is a staple component for displaying basic JSON metadata. - -# API - -There is a very lightweight API around this component - -| property | value | -| :--- | :---: | -| metadata | object/JSON | -| dense | bool | - -## Metadata - -The Metadata property takes in JSON and iterates over it to display the tabled information. - -The component itself only handles the display area, so you can use standard JS to construct an object that fits your desired outcome. No need to configure deeper within the staple. - -``` - -``` - -This will step through each of the keys and based on their types display them in a logical way. - -### Primatives - -Any non complex value will be displayed using `{value}` which will just output the value as text. - -### Objects/Maps - -JSON / Maps are displayed in a `` with its values as formatted key/value pairs. - -### Arrays - -Arrays are displayed similarly to objects, its values in a ``. - -### Custom - -If you want to customize the rendering of your value you can just replace it with a React Element. - -``` -{ - contact: me@email.com -} -``` - -Would display as contact me@email.com - -but if you wanted this to be a mailto you could inject that react into your map: - -``` -{ - contact: me@email.com -} -``` - -Then it would be displayed using the react element. - -# Usage - -For best usage drop this component inside another card. It can be used similarly to the `` and exposes the `dense` for when that is necessary. - - -# More information - -For more information visit [Backstage/Staples](https://backstage.spotify.net/staples), which has examples and explanations. diff --git a/frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.js b/frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.js deleted file mode 100644 index e3e997f3cf..0000000000 --- a/frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.js +++ /dev/null @@ -1,88 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { withStyles } from '@material-ui/core'; -import startCase from 'lodash/startCase'; - -import { MetadataTable, MetadataTableItem, MetadataList, MetadataListItem } from 'shared/components/MetadataTable'; - -// Styles - -const listStyle = { - root: { - margin: '0 0', - listStyleType: 'none', - }, -}; - -const nestedListStyle = { - root: { - ...listStyle.root, - paddingLeft: '8px', - }, -}; - -// Utilities - -function renderList(list, options, nested) { - const values = list.map((item, index) => {toValue(item)}); - return nested ? {values} : {values}; -} - -function renderMap(map, options, nested) { - const values = Object.keys(map).map(key => { - const value = toValue(map[key], true); - const fmtKey = options && options.titleFormat ? options.titleFormat(key) : startCase(key); - return ( - - {`${fmtKey}: `} - {value} - - ); - }); - - return nested ? {values} : {values}; -} - -function toValue(value, options, nested) { - if (React.isValidElement(value)) { - return {value}; - } - - if (typeof value === 'object' && !Array.isArray(value)) { - return renderMap(value, options, nested); - } - - if (Array.isArray(value)) { - return renderList(value, options, nested); - } - - return {value}; -} - -function mapToItems(info, options) { - return Object.keys(info).map(key => ); -} - -// Sub Components -const StyledList = withStyles(listStyle)(({ classes, children }) => ( - {children} -)); -const StyledNestedList = withStyles(nestedListStyle)(({ classes, children }) => ( - {children} -)); -const ItemValue = ({ value, options }) => {toValue(value, options)}; -const TableItem = ({ title, value, options }) => { - return ( - - - - ); -}; - -export default class StructuredMetadataTable extends Component { - render() { - const { metadata, dense, options } = this.props; - const metadataItems = mapToItems(metadata, options || {}); - - return {metadataItems}; - } -} diff --git a/frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.test.js b/frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.test.js deleted file mode 100644 index 9b77f82c65..0000000000 --- a/frontend/packages/shared/components/StructuredMetadataTable/StructuredMetadataTable.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; - -import StructuredMetadataTable from './StructuredMetadataTable'; -import { startCase } from 'lodash'; - -describe('', () => { - it('renders without exploding', () => { - const metadata = { hello: 'world' }; - const { getByText } = render(); - expect(getByText(metadata.hello)).toBeInTheDocument(); - }); - - describe('Item Mappings', () => { - it('Iterates over and displays every field in the map', () => { - const metadata = { field1: 'one', field2: 'two', field3: 'three' }; - const { getByText } = render(); - const keys = Object.keys(metadata); - keys.forEach(value => { - expect(getByText(startCase(value))).toBeInTheDocument(); - expect(getByText(metadata[value])).toBeInTheDocument(); - }); - }); - - it('Supports primative value fields', () => { - const metadata = { strField: 'my field', intField: 1 }; - const { getByText } = render(); - - const keys = Object.keys(metadata); - keys.forEach(value => { - expect(getByText(startCase(value))).toBeInTheDocument(); - expect(getByText(metadata[value].toString())).toBeInTheDocument(); - }); - }); - - it('Supports array fields', () => { - const metadata = { arrayField: ['arrVal1', 'arrVal2'] }; - const { getByText } = render(); - const keys = Object.keys(metadata); - keys.forEach(value => { - expect(getByText(startCase(value))).toBeInTheDocument(); - }); - metadata.arrayField.forEach(value => { - expect(getByText(value)).toBeInTheDocument(); - }); - }); - - it('Supports react elements', () => { - const metadata = { react:
    field
    }; - const { getByText } = render(); - - expect(getByText('field')).toBeInTheDocument(); - }); - - it('Supports object elements', () => { - const metadata = { config: { a: 1, b: 2 } }; - const { getByText } = render(); - - const keys = Object.keys(metadata.config); - keys.forEach(value => { - expect(getByText(startCase(value), { exact: false })).toBeInTheDocument(); - expect(getByText(metadata.config[value].toString(), { exact: false })).toBeInTheDocument(); - }); - }); - }); -}); diff --git a/frontend/packages/shared/components/StructuredMetadataTable/index.js b/frontend/packages/shared/components/StructuredMetadataTable/index.js deleted file mode 100644 index 4ea5da107c..0000000000 --- a/frontend/packages/shared/components/StructuredMetadataTable/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './StructuredMetadataTable'; diff --git a/frontend/packages/shared/components/SvgImageIcon.js b/frontend/packages/shared/components/SvgImageIcon.js deleted file mode 100644 index 7be427520e..0000000000 --- a/frontend/packages/shared/components/SvgImageIcon.js +++ /dev/null @@ -1,19 +0,0 @@ -import React, { Component } from 'react'; - -const styles = { - root: { - width: 24, - height: 24, - padding: 2, - display: 'inline-block', - }, -}; -// A wrapper to make icons stored in svg files look as similar as the material UI SvgIcon as possible -// The icons stored in svg files do not allow styling the icon color -// Assuming the image has viewBox="0 0 20 20" -// Should be replaced with svg icons defined using SvgIcon -export default class SvgImageIcon extends Component { - render() { - return {this.props.alt}; - } -} diff --git a/frontend/packages/shared/components/TabbedContent/TabbedContent.js b/frontend/packages/shared/components/TabbedContent/TabbedContent.js deleted file mode 100644 index 017809837f..0000000000 --- a/frontend/packages/shared/components/TabbedContent/TabbedContent.js +++ /dev/null @@ -1,113 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { Tabs, Tab, withStyles } from '@material-ui/core'; -import { getGAContext, GoogleAnalyticsEvent, sendGAEvent } from 'shared/apis/events'; -import { GA_DEFAULT_PLUGIN_ID, GA_DEFAULT_PLUGIN_OWNER } from 'shared/apis/events/GoogleAnalyticsEvent'; -import { withPluginContext } from 'core/app/withPluginContext'; - -const styles = { - tabs: { - borderBottom: '1px solid #dedede', - margin: '0 -16px 5px', - padding: '0 16px', - }, - tab: { - color: 'rgba(0, 0, 0, 0.5)', - '&:hover': { - color: 'rgba(0, 0, 0, 0.5)', - backgroundColor: 'rgba(0, 0, 0, 0.05)', - }, - }, - tabSelected: { - color: '#000 !important', - }, - indicator: { - backgroundColor: '#af2996', - height: 3, - }, -}; - -const tabType = PropTypes.oneOfType([ - PropTypes.node, - PropTypes.shape({ href: PropTypes.string, label: PropTypes.node }), -]); - -class TabbedContent extends Component { - static propTypes = { - // The names of the tabs - tabs: PropTypes.arrayOf(tabType).isRequired, - // which tab (of the `tabs` prop) should be the initial active one. - initialTab: tabType, - }; - - constructor(props) { - super(props); - let tabValue = props.tabs.indexOf(props.initialTab); - if (tabValue === -1) { - tabValue = 0; - } - - if ((window.location || {}).hash) { - tabValue = props.tabs.findIndex(t => { - if (typeof t === 'string') { - return false; - } - return t.href === window.location.hash; - }); - } - - this.state = { tabValue: tabValue }; - } - - trackEvent = tabValue => { - const { plugin, tabs } = this.props; - - const eventCategory = plugin && plugin.id ? plugin.id : GA_DEFAULT_PLUGIN_ID; - const eventAction = GoogleAnalyticsEvent.TAB_CLICK; - const tab = tabs[tabValue]; - const eventLabel = tab && typeof tab === 'object' ? `${tab.label}` : tab; - const eventValue = tabValue; - const eventOwner = plugin && plugin.owner ? plugin.owner : GA_DEFAULT_PLUGIN_OWNER; - const eventContext = getGAContext(this); - - sendGAEvent(eventCategory, eventAction, eventLabel, eventValue, eventOwner, eventContext); - }; - - handleTabChange = (e, tabValue) => { - this.trackEvent(tabValue); - this.setState({ tabValue }); - }; - - render() { - const { tabs = [], children = [], classes } = this.props; - const { tabValue } = this.state; - - return ( - - - {tabs.map((tab, i) => { - const label = typeof tab === 'object' ? tab.label : tab; - const href = typeof tab === 'object' ? tab.href : null; - return ( - - ); - })} - - {children && (children.length ? children[tabValue] : children)} - - ); - } -} - -export default withStyles(styles)(withPluginContext(TabbedContent)); diff --git a/frontend/packages/shared/components/TabbedContent/TabbedContent.test.js b/frontend/packages/shared/components/TabbedContent/TabbedContent.test.js deleted file mode 100644 index 13a46faed8..0000000000 --- a/frontend/packages/shared/components/TabbedContent/TabbedContent.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import TabbedContent from './TabbedContent'; -import { render, fireEvent } from '@testing-library/react'; - -describe('TabbedContent', () => { - describe('A String Key', () => { - let getByText; - - beforeEach(() => { - getByText = render( - -
    content1
    -
    content2
    -
    , - ).getByText; - }); - - it('renders the first content on mount', () => { - expect(getByText('content1')).toBeInTheDocument(); - }); - - it('renders all the provided tabs', () => { - expect(getByText('tab1')).toBeInTheDocument(); - expect(getByText('tab2')).toBeInTheDocument(); - }); - - it('handles tab change', () => { - fireEvent.click(getByText('tab2')); - expect(getByText('content2')).toBeInTheDocument(); - }); - - it('renders the specified tab when specified', () => { - getByText = render( - -
    content1
    -
    content2
    -
    , - ).getByText; - expect(getByText('content2')).toBeInTheDocument(); - }); - }); - - describe('An Object Key', () => { - let Tabs; - let tabs = [ - { label: 'tab1', href: '#tab1' }, - { label: tab2, href: '#tab2' }, - ]; - let tabContent = [
    content1
    ,
    content2
    ]; - - beforeEach(() => { - Tabs = render(); - }); - - it('renders the first content on mount', () => { - expect(Tabs.getByText('content1')).toBeInTheDocument(); - }); - - it('adds anchors to the tabs for deep linking', () => { - expect(Tabs.container.querySelector('[href="#tab1"]')).toBeInTheDocument(); - expect(Tabs.container.querySelector('[href="#tab2"]')).toBeInTheDocument(); - }); - - it('renders all the provided tabs', () => { - expect(Tabs.getByText('tab1')).toBeInTheDocument(); - expect(Tabs.getByText('tab2')).toBeInTheDocument(); - }); - - it('handles tab change', () => { - fireEvent.click(Tabs.getByText('tab2')); - expect(Tabs.getByText('content2')).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/packages/shared/components/TabbedContent/index.js b/frontend/packages/shared/components/TabbedContent/index.js deleted file mode 100644 index 9c793ff604..0000000000 --- a/frontend/packages/shared/components/TabbedContent/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './TabbedContent'; diff --git a/frontend/packages/shared/components/Tabs.js b/frontend/packages/shared/components/Tabs.js deleted file mode 100644 index 810a7e68c2..0000000000 --- a/frontend/packages/shared/components/Tabs.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { NavLink, withRouter } from 'react-router-dom'; -import { toClass } from 'recompose'; -import { Button } from '@material-ui/core'; -import { COLORS } from 'core/app/Themes'; - -// NavLink is a stateless functional component. Due to a peculiarity in React, we can't use it as -// the container element for something else, because then we'll get a console warning that it's not -// possible to put refs on stateless functional components. So we use this handy toClass helper to -// turn the link into a proper class. -const NavLinkAsClass = toClass(NavLink); - -const styles = { - tabs: { - position: 'relative', - lineHeight: 0, - boxShadow: '0 7px 9px -9px rgba(0,0,0,0.5)', - marginBottom: '16px', - }, - tab: { - display: 'inline-block', - }, - label: { - color: '#509BF5', - }, -}; - -// project-infinite-sharks - This should be removed in favor of MUI Tabs -export const Tabs = ({ children }) =>
    {children}
    ; - -export const _Tab = ({ label, path, exact, match, onClick }) => ( - - - -); - -export const Tab = withRouter(_Tab); diff --git a/frontend/packages/shared/components/TextIconLink.js b/frontend/packages/shared/components/TextIconLink.js deleted file mode 100644 index 4ba1acd533..0000000000 --- a/frontend/packages/shared/components/TextIconLink.js +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core'; -import Link from 'shared/components/Link'; - -// JSS styles which are default in Material design v1. -// https://material-ui-next.com/customization/css-in-js/ -const styles = { - wrapper: { - color: '#509BF5', - display: 'inline-flex', - justifyItems: 'center', - whiteSpace: 'nowrap', - lineHeight: 1, - }, - icon: { - width: '1em', - height: '1em', - display: 'inline-block', - marginRight: 6, - }, - iconSmall: { - width: '0.8em', - height: '0.8em', - display: 'inline-block', - marginRight: 6, - }, - leftText: { - marginRight: 5, - }, -}; - -// A Notification component -const TextIconLink = ({ classes, href, text, icon, target, iconPosition, iconSize }) => { - let iconClass = classes.icon; - if (iconSize && iconSize === 'small') { - iconClass = classes.iconSmall; - } - - return ( - - {iconPosition && iconPosition === 'right' ? {text} : null} - {text} - {!iconPosition || iconPosition !== 'right' ? {text} : null} - - ); -}; - -TextIconLink.propTypes = { - classes: PropTypes.object.isRequired, - href: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - icon: PropTypes.string.isRequired, - iconPosition: PropTypes.string, - iconSize: PropTypes.string, - target: PropTypes.string, -}; - -export default withStyles(styles)(TextIconLink); diff --git a/frontend/packages/shared/components/User.js b/frontend/packages/shared/components/User.js deleted file mode 100644 index 7921f826b6..0000000000 --- a/frontend/packages/shared/components/User.js +++ /dev/null @@ -1,104 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import { findDOMNode } from 'react-dom'; -import { CardContent, CardHeader, Popover } from '@material-ui/core'; -import { InfoIcon } from 'shared/icons'; -import gql from 'graphql-tag'; -import { MetadataList, MetadataListItem, MetadataTable, MetadataTableItem } from 'shared/components/MetadataTable'; -import Link from 'shared/components/Link'; -import { GA_DEFAULT_USERNAME_PROPS } from 'shared/apis/events/GoogleAnalyticsEvent'; - -const UserDetails = ({ user, onClose }) => ( - - - - - - - {user.username} - - - - - {user.email} - - - - - - {!(user.squads && user.squads.length) ? null : ( - - - {user.squads.map(squad => ( - - {squad.name} - - ))} - - - )} - - - -); - -class User extends Component { - static fragment = gql` - fragment User on User { - username - fullName - email - squads { - id - name - } - } - `; - - state = { - dropdownAnchorElement: null, - }; - - onOpenDropdown = event => { - this.setState({ - dropdownAnchorElement: findDOMNode(this.anchor), - }); - event.preventDefault(); - event.target.blur(); - }; - - onCloseDropdown = () => { - this.setState({ - dropdownAnchorElement: null, - }); - }; - - render() { - const { user } = this.props; - const { dropdownAnchorElement } = this.state; - return ( - - {dropdownAnchorElement && ( - - - - )} - (this.anchor = el)} gaprops={GA_DEFAULT_USERNAME_PROPS}> - {user.fullName} - - - ); - } -} - -export default User; diff --git a/frontend/packages/shared/components/WarningPanel/WarningPanel.js b/frontend/packages/shared/components/WarningPanel/WarningPanel.js deleted file mode 100644 index 1fbeadc815..0000000000 --- a/frontend/packages/shared/components/WarningPanel/WarningPanel.js +++ /dev/null @@ -1,62 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Typography, withStyles } from '@material-ui/core'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; - -const errorOutlineStyles = theme => ({ - root: { - marginRight: theme.spacing(1), - fill: theme.palette.warningText, - }, -}); -const ErrorOutlineStyled = withStyles(errorOutlineStyles)(ErrorOutline); - -const styles = theme => ({ - message: { - display: 'flex', - flexDirection: 'column', - padding: theme.spacing(1.5), - backgroundColor: theme.palette.warningBackground, - color: theme.palette.warningText, - verticalAlign: 'middle', - }, - header: { - display: 'flex', - flexDirection: 'row', - marginBottom: theme.spacing(1), - }, - headerText: { - color: theme.palette.warningText, - }, - messageText: { - color: theme.palette.warningText, - }, -}); - -/** - * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel - * only shows the warning message to the user - */ -class WarningPanel extends Component { - static propTypes = { - message: PropTypes.node.isRequired, - }; - - render() { - const { classes, title, message, children } = this.props; - return ( -
    -
    - - - {title} - -
    - {message && {message}} - {children} -
    - ); - } -} - -export default withStyles(styles)(WarningPanel); diff --git a/frontend/packages/shared/components/WarningPanel/index.js b/frontend/packages/shared/components/WarningPanel/index.js deleted file mode 100644 index a7eba39012..0000000000 --- a/frontend/packages/shared/components/WarningPanel/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './WarningPanel'; diff --git a/frontend/packages/shared/components/form/EventDispatcher.js b/frontend/packages/shared/components/form/EventDispatcher.js deleted file mode 100644 index b13f37ecae..0000000000 --- a/frontend/packages/shared/components/form/EventDispatcher.js +++ /dev/null @@ -1,66 +0,0 @@ -export default class EventDispatcher { - //----------------------------------- - // Constructor - //----------------------------------- - constructor() { - this._map = {}; - } - - //----------------------------------- - // Methods - //----------------------------------- - /** - * Adds an event listener handler for a particular event type. - * - * @param type The type of event - * @param handler The handling function - */ - addEventListener(type, handler) { - let map = this._map; - - map[type] = map[type] || []; - map[type].push(handler); - } - - /** - * Returns true if there is at least one handler for the provided type. - * - * @param type Event type. - * @returns {*} True if the event type has an associated handler. - */ - hasListener(type) { - return !!this._map[type]; - } - - /** - * Removes the specific handler associated with the provided event type. - * - * @param type Event type. - * @param handler The handling function to remove. - */ - removeEventListener(type, handler) { - if (this._map[type]) { - let ix = this._map[type].indexOf(handler); - if (ix !== -1) { - this._map[type].splice(ix, 1); - } - } - } - - /** - * Dispatches an event on this bus. - * - * @param event A RingaEvent or similar. - */ - dispatchEvent(event, detail) { - let map = this._map; - detail = detail || {}; - detail.target = this; - - if (map[event]) { - map[event].forEach(handler => { - handler({ event, detail }); - }); - } - } -} diff --git a/frontend/packages/shared/components/form/FormHelperErrorText.js b/frontend/packages/shared/components/form/FormHelperErrorText.js deleted file mode 100644 index 06dabcaec0..0000000000 --- a/frontend/packages/shared/components/form/FormHelperErrorText.js +++ /dev/null @@ -1,10 +0,0 @@ -import { FormHelperText, withStyles } from '@material-ui/core'; - -const style = theme => ({ - root: { - marginTop: 0, - marginBottom: theme.spacing(1), - }, -}); - -export default withStyles(style)(FormHelperText); diff --git a/frontend/packages/shared/components/form/ValidatedCheckbox.js b/frontend/packages/shared/components/form/ValidatedCheckbox.js deleted file mode 100644 index 6bff7af07d..0000000000 --- a/frontend/packages/shared/components/form/ValidatedCheckbox.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Checkbox } from '@material-ui/core'; -import camelcase from 'lodash.camelcase'; - -import { generateId } from './utils/idUtil'; -import { ValidatedFormModel } from 'shared/components'; - -export default class ValidatedCheckbox extends Component { - static contextTypes = { - validatedFormModel: PropTypes.instanceOf(ValidatedFormModel).isRequired, - }; - - get id() { - return this._id || this.props.id || camelcase(this.props.label) || (this._id = generateId()); - } - - set id(value) { - this._id = value; - } - - get validators() { - return this._validators || this.props.validators; - } - - componentDidMount() { - this.context.validatedFormModel.registerElement(this, !!this.props.checked); - } - - componentWillUnmount() { - this.context.validatedFormModel.unregisterElement(this); - } - - render() { - let { ...childProps } = this.props; - const { validatedFormModel } = this.context; - - return ( - - ); - } - - checkbox_onChangeHandler(event, value) { - const { onChange } = this.props; - - this.context.validatedFormModel.userChangeElementValue(this, value); - this.forceUpdate(); - - if (onChange) { - onChange(event); - } - } - - valueChangeHandler() { - this.forceUpdate(); - } -} diff --git a/frontend/packages/shared/components/form/ValidatedForm.js b/frontend/packages/shared/components/form/ValidatedForm.js deleted file mode 100644 index 0730ae8219..0000000000 --- a/frontend/packages/shared/components/form/ValidatedForm.js +++ /dev/null @@ -1,79 +0,0 @@ -import React, { Component } from 'react'; - -import PropTypes from 'prop-types'; - -import ValidatedFormModel from './ValidatedFormModel'; - -export default class ValidatedForm extends Component { - static childContextTypes = { - validatedFormModel: PropTypes.instanceOf(ValidatedFormModel).isRequired, - }; - - constructor(props) { - super(props); - - this.model = props.model || new ValidatedFormModel(); - - this.model.initialize(this, props.validatorContext, props.localStorageId); - - this.model.addEventListener(ValidatedFormModel.VALID_CHANGED, this.model_validChangedHandler); - this.model.addEventListener(ValidatedFormModel.VALUE_CHANGED, this.model_valueChangedHandler); - this.model.addEventListener(ValidatedFormModel.USER_VALUE_CHANGED, this.model_userValueChangedHandler); - this.model.addEventListener(ValidatedFormModel.ERRORS_CHANGED, this.model_errorsChangedHandler); - } - - componentDidMount() { - const { onInitialize } = this.props; - - if (onInitialize) { - onInitialize(this.model); - } - } - - componentWillUnmount() { - this.model.removeEventListener(ValidatedFormModel.VALID_CHANGED, this.model_validChangedHandler); - this.model.removeEventListener(ValidatedFormModel.VALUE_CHANGED, this.model_valueChangedHandler); - this.model.removeEventListener(ValidatedFormModel.USER_VALUE_CHANGED, this.model_userValueChangedHandler); - this.model.removeEventListener(ValidatedFormModel.ERRORS_CHANGED, this.model_errorsChangedHandler); - } - - getChildContext() { - const { model } = this; - - return { validatedFormModel: model }; - } - - render() { - const { children } = this.props; - - return {children}; - } - - model_validChangedHandler = () => { - this.forceUpdate(); - }; - - model_valueChangedHandler = event => { - const { onValueChange } = this.props; - - this.forceUpdate(); - - if (onValueChange) { - onValueChange(this.model, event.detail); - } - }; - - model_userValueChangedHandler = event => { - const { onUserValueChange } = this.props; - - this.forceUpdate(); - - if (onUserValueChange) { - onUserValueChange(this.model, event.detail); - } - }; - - model_errorsChangedHandler = () => { - this.forceUpdate(); - }; -} diff --git a/frontend/packages/shared/components/form/ValidatedFormClearButton.js b/frontend/packages/shared/components/form/ValidatedFormClearButton.js deleted file mode 100644 index 07308cab3e..0000000000 --- a/frontend/packages/shared/components/form/ValidatedFormClearButton.js +++ /dev/null @@ -1,33 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Button } from '@material-ui/core'; - -import { ValidatedFormModel } from 'shared/components'; - -export default class ValidatedFormClearButton extends Component { - static propTypes = { - onClick: PropTypes.func, - }; - - static contextTypes = { - validatedFormModel: PropTypes.instanceOf(ValidatedFormModel).isRequired, - }; - - render() { - return