From 64a26b4b75638c6d9603aee763ca8e139d4f8eab Mon Sep 17 00:00:00 2001 From: William Ghelfi Date: Fri, 21 Jan 2022 13:23:23 +0000 Subject: [PATCH 01/34] build: add Prettier support and configurations --- .gitpod.yml | 3 +- .prettierignore | 4 +++ .prettierrc | 7 ++++ components/dashboard/.eslintrc.js | 12 +++---- components/dashboard/package.json | 1 + gitpod-ws.code-workspace | 11 ++++++- package.json | 55 ++++++++++++++++--------------- yarn.lock | 10 ++++++ 8 files changed, 68 insertions(+), 35 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.gitpod.yml b/.gitpod.yml index 2f0bd0ae8f39fb..91dca3653eb849 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -43,7 +43,8 @@ vscode: extensions: - bajdzis.vscode-database - bradlc.vscode-tailwindcss - - EditorConfig.EditorConfig + - editorconfig.editorconfig + - esbenp.prettier-vscode - golang.go - hashicorp.terraform - ms-azuretools.vscode-docker diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000000000..6b3b6fe240c5ee --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Add files here to ignore them from prettier formatting + +build +dist \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000000000..6ba9a525b90f18 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2 +} diff --git a/components/dashboard/.eslintrc.js b/components/dashboard/.eslintrc.js index 8b0658d9306c4c..6bbc15667e87cc 100644 --- a/components/dashboard/.eslintrc.js +++ b/components/dashboard/.eslintrc.js @@ -5,9 +5,9 @@ */ module.exports = { - root: true, - extends: ['react-app'], - rules: { - "import/no-anonymous-default-export": "off", - } -} + root: true, + extends: ['react-app', 'plugin:prettier/recommended'], + rules: { + 'import/no-anonymous-default-export': 'off', + }, +}; diff --git a/components/dashboard/package.json b/components/dashboard/package.json index b6a3b3f7d041e8..222c14768c0161 100644 --- a/components/dashboard/package.json +++ b/components/dashboard/package.json @@ -37,6 +37,7 @@ "classnames": "^2.3.1", "cypress": "^9.2.1", "eslint": "^7.24.0", + "eslint-config-prettier": "^8.3.0", "eslint-config-react-app": "^6.0.0", "postcss": "^7.0.36", "react-scripts": "^4.0.3", diff --git a/gitpod-ws.code-workspace b/gitpod-ws.code-workspace index 26fab724461135..19ebba3b4b5ff1 100644 --- a/gitpod-ws.code-workspace +++ b/gitpod-ws.code-workspace @@ -31,13 +31,22 @@ ], "settings": { "typescript.tsdk": "gitpod/node_modules/typescript/lib", + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, "[json]": { "editor.insertSpaces": true, "editor.tabSize": 2 }, "[yaml]": { "editor.insertSpaces": true, - "editor.tabSize": 2 + "editor.tabSize": 2, + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[go]": { "editor.formatOnSave": true diff --git a/package.json b/package.json index ac6eda260aff01..eb4270fa4cb2a2 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,30 @@ { - "private": true, - "name": "parent", - "version": "0.0.0", - "license": "UNLICENSED", - "devDependencies": { - "@types/node": "^16.11.0", - "@types/shelljs": "^0.8.9", - "json": "^11.0.0", - "rimraf": "^3.0.2", - "ts-node": "10.4.0", - "typescript": "~4.4.4" - }, - "scripts": { - "build": "leeway exec --filter-type yarn --components -- yarn build", - "watch": "leeway exec --package components:all --transitive-dependencies --filter-type yarn --components --parallel -- tsc -w --preserveWatchOutput", - "clean": "leeway exec --filter-type yarn --components -- yarn clean && rm -rf node_modules" - }, - "workspaces": { - "packages": [ - "components/*", - "components/ee/*", - "components/*/typescript", - "components/*/typescript-*", - "components/supervisor/frontend", - "charts/" - ] - } + "private": true, + "name": "parent", + "version": "0.0.0", + "license": "UNLICENSED", + "devDependencies": { + "@types/node": "^16.11.0", + "@types/shelljs": "^0.8.9", + "json": "^11.0.0", + "prettier": "2.5.1", + "rimraf": "^3.0.2", + "ts-node": "10.4.0", + "typescript": "~4.4.4" + }, + "scripts": { + "build": "leeway exec --filter-type yarn --components -- yarn build", + "watch": "leeway exec --package components:all --transitive-dependencies --filter-type yarn --components --parallel -- tsc -w --preserveWatchOutput", + "clean": "leeway exec --filter-type yarn --components -- yarn clean && rm -rf node_modules" + }, + "workspaces": { + "packages": [ + "components/*", + "components/ee/*", + "components/*/typescript", + "components/*/typescript-*", + "components/supervisor/frontend", + "charts/" + ] + } } diff --git a/yarn.lock b/yarn.lock index 5e395feecdcd9b..8ef7f40824f998 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7321,6 +7321,11 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" +eslint-config-prettier@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz#f7471b20b6fe8a9a9254cc684454202886a2dd7a" + integrity sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew== + eslint-config-react-app@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-6.0.0.tgz#ccff9fc8e36b322902844cbd79197982be355a0e" @@ -14072,6 +14077,11 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +prettier@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" + integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== + pretty-bytes@^5.3.0, pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" From 7231f56fe7e1a6a5c98dcbc8115bdb15b696a806 Mon Sep 17 00:00:00 2001 From: William Ghelfi Date: Fri, 21 Jan 2022 13:49:19 +0000 Subject: [PATCH 02/34] build: add Prettier support and configurations --- .prettierignore | 9 +++++++-- components/dashboard/.eslintrc.js | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.prettierignore b/.prettierignore index 6b3b6fe240c5ee..b4e3136d5b9a88 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,9 @@ # Add files here to ignore them from prettier formatting -build -dist \ No newline at end of file +build/ +dist/ +templates/ + +# YAML is supported, but let's play it safe for now +*.yaml +*.yml \ No newline at end of file diff --git a/components/dashboard/.eslintrc.js b/components/dashboard/.eslintrc.js index 6bbc15667e87cc..929a799ca77e15 100644 --- a/components/dashboard/.eslintrc.js +++ b/components/dashboard/.eslintrc.js @@ -6,7 +6,7 @@ module.exports = { root: true, - extends: ['react-app', 'plugin:prettier/recommended'], + extends: ['react-app', 'prettier'], rules: { 'import/no-anonymous-default-export': 'off', }, From 464d396f073a05081ab67ec73e489808525e5248 Mon Sep 17 00:00:00 2001 From: William Ghelfi Date: Fri, 21 Jan 2022 13:51:50 +0000 Subject: [PATCH 03/34] style(dashboard): batch-format with Prettier --- components/dashboard/README.md | 16 +- components/dashboard/craco.config.js | 2 +- .../dashboard/cypress/fixtures/profile.json | 2 +- .../dashboard/cypress/fixtures/users.json | 2 +- .../dashboard/cypress/integration/app.spec.ts | 27 +- components/dashboard/cypress/plugins/index.js | 2 +- .../dashboard/cypress/support/commands.js | 2 +- components/dashboard/cypress/support/index.js | 2 +- components/dashboard/cypress/tsconfig.json | 24 +- components/dashboard/postcss.config.js | 10 +- .../dashboard/public/complete-auth/index.html | 35 +- components/dashboard/public/index.html | 5 +- components/dashboard/public/tos/index.html | 28 +- components/dashboard/src/Analytics.tsx | 108 +- components/dashboard/src/App.test.ts | 18 +- components/dashboard/src/App.tsx | 691 ++++--- components/dashboard/src/FromReferrer.tsx | 60 +- components/dashboard/src/Login.tsx | 403 ++-- components/dashboard/src/Menu.tsx | 602 +++--- .../dashboard/src/OauthClientApproval.tsx | 77 +- components/dashboard/src/Setup.tsx | 117 +- components/dashboard/src/admin/UserDetail.tsx | 546 +++--- components/dashboard/src/admin/UserSearch.tsx | 221 ++- .../dashboard/src/admin/WorkspaceDetail.tsx | 206 +- .../dashboard/src/admin/WorkspacesSearch.tsx | 268 +-- components/dashboard/src/admin/admin-menu.ts | 13 +- components/dashboard/src/admin/gcp-info.ts | 213 +- .../src/chargebee/chargebee-client.ts | 132 +- .../dashboard/src/chargebee/chargebee.d.ts | 39 +- .../dashboard/src/components/AlertBox.tsx | 12 +- components/dashboard/src/components/Arrow.tsx | 14 +- .../dashboard/src/components/CheckBox.tsx | 63 +- .../dashboard/src/components/CodeText.tsx | 6 +- .../src/components/ConfirmationModal.tsx | 133 +- .../dashboard/src/components/ContextMenu.tsx | 214 +- .../dashboard/src/components/DropDown.tsx | 59 +- .../dashboard/src/components/Header.tsx | 38 +- .../dashboard/src/components/InfoBox.tsx | 16 +- .../dashboard/src/components/ItemsList.tsx | 68 +- components/dashboard/src/components/Modal.tsx | 132 +- .../dashboard/src/components/MonacoEditor.tsx | 20 +- .../src/components/PageWithSubMenu.tsx | 66 +- .../src/components/PendingChangesDropdown.tsx | 30 +- .../dashboard/src/components/PillLabel.tsx | 12 +- .../dashboard/src/components/PillMenuItem.tsx | 31 +- .../dashboard/src/components/PrebuildLogs.tsx | 129 +- .../src/components/SelectableCard.tsx | 44 +- .../dashboard/src/components/Separator.tsx | 4 +- .../dashboard/src/components/TabMenuItem.tsx | 33 +- .../dashboard/src/components/Tooltip.tsx | 38 +- .../src/components/WorkspaceLogs.tsx | 26 +- components/dashboard/src/experiments.ts | 114 +- components/dashboard/src/index.css | 176 +- components/dashboard/src/index.tsx | 32 +- .../src/projects/ConfigureProject.tsx | 578 +++--- .../src/projects/InstallGitHubApp.tsx | 166 +- .../dashboard/src/projects/NewProject.tsx | 1307 ++++++------ .../dashboard/src/projects/Prebuild.tsx | 324 +-- .../dashboard/src/projects/Prebuilds.tsx | 662 ++++--- components/dashboard/src/projects/Project.tsx | 538 ++--- .../src/projects/ProjectSettings.tsx | 156 +- .../src/projects/ProjectVariables.tsx | 275 +-- .../dashboard/src/projects/Projects.tsx | 449 +++-- .../src/projects/project-context.tsx | 16 +- .../dashboard/src/projects/projects.routes.ts | 4 +- .../src/projects/render-utils.test.ts | 10 +- .../dashboard/src/projects/render-utils.tsx | 7 +- components/dashboard/src/provider-utils.tsx | 187 +- .../dashboard/src/service/service-mock.ts | 387 ++-- components/dashboard/src/service/service.tsx | 84 +- components/dashboard/src/settings/Account.tsx | 135 +- .../src/settings/EnvironmentVariables.tsx | 479 +++-- .../dashboard/src/settings/Integrations.tsx | 1358 +++++++------ .../dashboard/src/settings/Notifications.tsx | 194 +- components/dashboard/src/settings/Plans.tsx | 1415 +++++++------ .../dashboard/src/settings/Preferences.tsx | 460 +++-- .../src/settings/SelectAccountModal.tsx | 167 +- components/dashboard/src/settings/Teams.tsx | 1743 +++++++++-------- .../dashboard/src/settings/settings-menu.ts | 58 +- .../dashboard/src/start/CreateWorkspace.tsx | 395 ++-- components/dashboard/src/start/StartPage.tsx | 94 +- .../dashboard/src/start/StartWorkspace.tsx | 278 ++- components/dashboard/src/teams/JoinTeam.tsx | 98 +- components/dashboard/src/teams/Members.tsx | 471 +++-- components/dashboard/src/teams/NewTeam.tsx | 104 +- .../dashboard/src/teams/TeamSettings.tsx | 148 +- .../dashboard/src/teams/teams-context.tsx | 29 +- components/dashboard/src/theme-context.tsx | 46 +- components/dashboard/src/user-context.tsx | 16 +- components/dashboard/src/utils.ts | 71 +- .../src/whatsnew/WhatsNew-2021-04.tsx | 107 +- .../src/whatsnew/WhatsNew-2021-06.tsx | 61 +- .../dashboard/src/whatsnew/WhatsNew.tsx | 154 +- .../src/workspaces/StartWorkspaceModal.tsx | 124 +- .../src/workspaces/WorkspaceEntry.tsx | 426 ++-- .../dashboard/src/workspaces/Workspaces.tsx | 334 ++-- .../src/workspaces/workspace-model.ts | 268 +-- .../src/workspaces/workspaces.routes.ts | 2 +- components/dashboard/tailwind.config.js | 164 +- components/dashboard/tsconfig.json | 11 +- components/dashboard/tsconfig.lib.json | 20 +- components/dashboard/tsconfig.test.json | 2 +- 102 files changed, 11210 insertions(+), 8753 deletions(-) diff --git a/components/dashboard/README.md b/components/dashboard/README.md index af11a7e1973139..3f87727b268b5e 100644 --- a/components/dashboard/README.md +++ b/components/dashboard/README.md @@ -20,13 +20,13 @@ The `App.tsx` is the entry point for the SPA and it uses React-Router to registe Pages are loaded lazily using `React.lazy` so that not everything needs to be loaded up-front but only when needed: ```ts -const Notifications = React.lazy(() => import("./account/Notifications")); -const Profile = React.lazy(() => import("./account/Profile")); -const Subscriptions = React.lazy(() => import("./account/Subscriptions")); -const DefaultIDE = React.lazy(() => import("./settings/DefaultIDE")); -const EnvVars = React.lazy(() => import("./settings/EnvVars")); -const FeaturePreview = React.lazy(() => import("./settings/FeaturePreview")); -const GitIntegration = React.lazy(() => import("./settings/GitIntegration")); +const Notifications = React.lazy(() => import('./account/Notifications')); +const Profile = React.lazy(() => import('./account/Profile')); +const Subscriptions = React.lazy(() => import('./account/Subscriptions')); +const DefaultIDE = React.lazy(() => import('./settings/DefaultIDE')); +const EnvVars = React.lazy(() => import('./settings/EnvVars')); +const FeaturePreview = React.lazy(() => import('./settings/FeaturePreview')); +const GitIntegration = React.lazy(() => import('./settings/GitIntegration')); ``` Global state is passed through `React.Context`. @@ -98,4 +98,4 @@ When the dashboard app is up and running, open another terminal and launch Cypre yarn test:integration:run ``` -You should see Cypress running in the terminal. \ No newline at end of file +You should see Cypress running in the terminal. diff --git a/components/dashboard/craco.config.js b/components/dashboard/craco.config.js index 435ef4fc8911a5..bc4d666e768436 100644 --- a/components/dashboard/craco.config.js +++ b/components/dashboard/craco.config.js @@ -7,7 +7,7 @@ module.exports = { style: { postcss: { - plugins: [require("tailwindcss"), require("autoprefixer")], + plugins: [require('tailwindcss'), require('autoprefixer')], }, }, }; diff --git a/components/dashboard/cypress/fixtures/profile.json b/components/dashboard/cypress/fixtures/profile.json index b6c355ca5c5b9a..a95e88f9cb7049 100644 --- a/components/dashboard/cypress/fixtures/profile.json +++ b/components/dashboard/cypress/fixtures/profile.json @@ -2,4 +2,4 @@ "id": 8739, "name": "Jane", "email": "jane@example.com" -} \ No newline at end of file +} diff --git a/components/dashboard/cypress/fixtures/users.json b/components/dashboard/cypress/fixtures/users.json index 79b699aa772428..82a0056b3b1e8c 100644 --- a/components/dashboard/cypress/fixtures/users.json +++ b/components/dashboard/cypress/fixtures/users.json @@ -229,4 +229,4 @@ "bs": "target end-to-end models" } } -] \ No newline at end of file +] diff --git a/components/dashboard/cypress/integration/app.spec.ts b/components/dashboard/cypress/integration/app.spec.ts index c277560560bd5f..bb98295e2bde1e 100644 --- a/components/dashboard/cypress/integration/app.spec.ts +++ b/components/dashboard/cypress/integration/app.spec.ts @@ -5,18 +5,29 @@ import { workspacesPathMain } from '../../src/workspaces/workspaces.routes'; describe('The app', () => { before(() => { cy.visit('/'); - }) + }); it('should load and start from the Workspaces page', () => { - cy.location("pathname").should("eq", workspacesPathMain); + cy.location('pathname').should('eq', workspacesPathMain); cy.findByRole('heading', { name: /Workspaces/i, level: 1 }).should('exist'); cy.findAllByRole('navigation').last().as('sections').findAllByRole('link').should('have.length', 3); - cy.get("@sections").findByText(/Workspaces/i).as('workspacesTab').should('exist'); - cy.get('@sections').findByText(/Projects/i).as('projectsTab').should('exist'); - cy.get("@sections").findByText(/Settings/i).as('settingsTab').should('exist'); + cy.get('@sections') + .findByText(/Workspaces/i) + .as('workspacesTab') + .should('exist'); + + cy.get('@sections') + .findByText(/Projects/i) + .as('projectsTab') + .should('exist'); + + cy.get('@sections') + .findByText(/Settings/i) + .as('settingsTab') + .should('exist'); cy.get('@projectsTab').click(); cy.location('pathname').should('eq', projectsPathMain); @@ -29,8 +40,8 @@ describe('The app', () => { // TODO complete checks about the general layout of the app. // The "Workspaces" page will have its own spec file. - }) -}) + }); +}); // This empty export is due to `"isolatedModules": true` in the main `tsconfig.json` 🤷🏻 -export {} \ No newline at end of file +export {}; diff --git a/components/dashboard/cypress/plugins/index.js b/components/dashboard/cypress/plugins/index.js index 59b2bab6e4e605..8229063adc1fda 100644 --- a/components/dashboard/cypress/plugins/index.js +++ b/components/dashboard/cypress/plugins/index.js @@ -19,4 +19,4 @@ module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config -} +}; diff --git a/components/dashboard/cypress/support/commands.js b/components/dashboard/cypress/support/commands.js index 81f1958040b24f..e89f2b3925f75a 100644 --- a/components/dashboard/cypress/support/commands.js +++ b/components/dashboard/cypress/support/commands.js @@ -24,4 +24,4 @@ // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -import "@testing-library/cypress/add-commands"; +import '@testing-library/cypress/add-commands'; diff --git a/components/dashboard/cypress/support/index.js b/components/dashboard/cypress/support/index.js index d68db96df2697e..37a498fb5bf395 100644 --- a/components/dashboard/cypress/support/index.js +++ b/components/dashboard/cypress/support/index.js @@ -14,7 +14,7 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' +import './commands'; // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/components/dashboard/cypress/tsconfig.json b/components/dashboard/cypress/tsconfig.json index 1cd8d86180de00..190e8643e3aefc 100644 --- a/components/dashboard/cypress/tsconfig.json +++ b/components/dashboard/cypress/tsconfig.json @@ -1,16 +1,10 @@ { - "extends": "../tsconfig.json", - "compilerOptions": { - "noEmit": true, - // be explicit about types included - // to avoid clashing with Jest types - "types": [ - "cypress", - "@testing-library/cypress" - ] - }, - "include": [ - "../node_modules/cypress", - "./**/*.ts" - ] -} \ No newline at end of file + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + // be explicit about types included + // to avoid clashing with Jest types + "types": ["cypress", "@testing-library/cypress"] + }, + "include": ["../node_modules/cypress", "./**/*.ts"] +} diff --git a/components/dashboard/postcss.config.js b/components/dashboard/postcss.config.js index 03dd67a4644cab..f89a862dcc8bae 100644 --- a/components/dashboard/postcss.config.js +++ b/components/dashboard/postcss.config.js @@ -5,8 +5,8 @@ */ module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -} \ No newline at end of file + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/components/dashboard/public/complete-auth/index.html b/components/dashboard/public/complete-auth/index.html index c2478233670782..86269408fc2ed3 100644 --- a/components/dashboard/public/complete-auth/index.html +++ b/components/dashboard/public/complete-auth/index.html @@ -6,24 +6,25 @@ --> - - + + Done - - - If this tab is not closed automatically, feel free to close it and proceed. - - \ No newline at end of file + + + If this tab is not closed automatically, feel free to close it and proceed. + + + diff --git a/components/dashboard/public/index.html b/components/dashboard/public/index.html index 4e35091e609647..500fbcf7e8a4c0 100644 --- a/components/dashboard/public/index.html +++ b/components/dashboard/public/index.html @@ -11,10 +11,7 @@ - + Dashboard — Gitpod diff --git a/components/dashboard/public/tos/index.html b/components/dashboard/public/tos/index.html index fcc3b5c957e81c..b961b7e7d158b5 100644 --- a/components/dashboard/public/tos/index.html +++ b/components/dashboard/public/tos/index.html @@ -6,20 +6,18 @@ --> - - + + Terms of Service - - - -
-
- - - -
+ + + +
+
+ + + +
- - \ No newline at end of file + + diff --git a/components/dashboard/src/Analytics.tsx b/components/dashboard/src/Analytics.tsx index 11831cafcaa0f7..771688827cbfed 100644 --- a/components/dashboard/src/Analytics.tsx +++ b/components/dashboard/src/Analytics.tsx @@ -4,56 +4,48 @@ * See License-AGPL.txt in the project root for license information. */ -import { getGitpodService } from "./service/service"; -import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; -import Cookies from "js-cookie"; -import { v4 } from "uuid"; -import { Experiment } from "./experiments"; - - -export type Event = "invite_url_requested" | "organisation_authorised"; -type InternalEvent = Event | "path_changed" | "dashboard_clicked"; - -export type EventProperties = - TrackOrgAuthorised - | TrackInviteUrlRequested -; -type InternalEventProperties = TrackUIExperiments & ( - EventProperties - | TrackDashboardClick - | TrackPathChanged -); +import { getGitpodService } from './service/service'; +import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; +import Cookies from 'js-cookie'; +import { v4 } from 'uuid'; +import { Experiment } from './experiments'; + +export type Event = 'invite_url_requested' | 'organisation_authorised'; +type InternalEvent = Event | 'path_changed' | 'dashboard_clicked'; + +export type EventProperties = TrackOrgAuthorised | TrackInviteUrlRequested; +type InternalEventProperties = TrackUIExperiments & (EventProperties | TrackDashboardClick | TrackPathChanged); export interface TrackOrgAuthorised { - installation_id: string, - setup_action: string | undefined, + installation_id: string; + setup_action: string | undefined; } export interface TrackInviteUrlRequested { - invite_url: string, + invite_url: string; } interface TrackDashboardClick { - dnt?: boolean, - path: string, - button_type?: string, - label?: string, - destination?: string, -}; + dnt?: boolean; + path: string; + button_type?: string; + label?: string; + destination?: string; +} interface TrackPathChanged { - prev: string, - path: string, + prev: string; + path: string; } interface TrackUIExperiments { - ui_experiments?: {}, + ui_experiments?: {}; } //call this to track all events outside of button and anchor clicks export const trackEvent = (event: Event, properties: EventProperties) => { trackEventInternal(event, properties); -} +}; const trackEventInternal = (event: InternalEvent, properties: InternalEventProperties, userKnown?: boolean) => { properties.ui_experiments = Experiment.get(); @@ -66,7 +58,10 @@ const trackEventInternal = (event: InternalEvent, properties: InternalEventPrope }); }; -export const trackButtonOrAnchor = (target: HTMLAnchorElement | HTMLButtonElement | HTMLDivElement, userKnown: boolean) => { +export const trackButtonOrAnchor = ( + target: HTMLAnchorElement | HTMLButtonElement | HTMLDivElement, + userKnown: boolean, +) => { //read manually passed analytics props from 'data-analytics' attribute of event target let passedProps: TrackDashboardClick | undefined; if (target.dataset.analytics) { @@ -78,20 +73,19 @@ export const trackButtonOrAnchor = (target: HTMLAnchorElement | HTMLButtonElemen } catch (error) { log.debug(error); } - } let trackingMsg: TrackDashboardClick = { path: window.location.pathname, - label: target.textContent || undefined + label: target.textContent || undefined, }; if (target instanceof HTMLButtonElement || target instanceof HTMLDivElement) { //parse button data - if (target.classList.contains("secondary")) { - trackingMsg.button_type = "secondary"; + if (target.classList.contains('secondary')) { + trackingMsg.button_type = 'secondary'; } else { - trackingMsg.button_type = "primary"; //primary button is the default if secondary is not specified + trackingMsg.button_type = 'primary'; //primary button is the default if secondary is not specified } //retrieve href if parent is an anchor element if (target.parentElement instanceof HTMLAnchorElement) { @@ -110,29 +104,28 @@ export const trackButtonOrAnchor = (target: HTMLAnchorElement | HTMLButtonElemen return; } const ancestorProps: TrackDashboardClick | undefined = getAncestorProps(curr.parentElement); - const currProps = JSON.parse(curr.dataset.analytics || "{}") as TrackDashboardClick; - return {...ancestorProps, ...currProps}; - } + const currProps = JSON.parse(curr.dataset.analytics || '{}') as TrackDashboardClick; + return { ...ancestorProps, ...currProps }; + }; const ancestorProps = getAncestorProps(target); //props that were passed directly to the event target take precedence over those passed to ancestor elements, which take precedence over those implicitly determined. - trackingMsg = {...trackingMsg, ...ancestorProps, ...passedProps}; + trackingMsg = { ...trackingMsg, ...ancestorProps, ...passedProps }; - trackEventInternal("dashboard_clicked", trackingMsg, userKnown); -} + trackEventInternal('dashboard_clicked', trackingMsg, userKnown); +}; //call this when the path changes. Complete page call is unnecessary for SPA after initial call export const trackPathChange = (props: TrackPathChanged) => { - trackEventInternal("path_changed", props); -} - + trackEventInternal('path_changed', props); +}; type TrackLocationProperties = TrackUIExperiments & { - referrer: string, - path: string, - host: string, - url: string, + referrer: string; + path: string; + host: string; + url: string; }; export const trackLocation = async (userKnown: boolean) => { @@ -147,18 +140,17 @@ export const trackLocation = async (userKnown: boolean) => { getGitpodService().server.trackLocation({ //if the user is authenticated, let server determine the id. else, pass anonymousId explicitly. anonymousId: userKnown ? undefined : getAnonymousId(), - properties: props + properties: props, }); -} +}; const getAnonymousId = (): string => { let anonymousId = Cookies.get('ajs_anonymous_id'); if (anonymousId) { return anonymousId.replace(/^"(.+(?="$))"$/, '$1'); //strip enclosing double quotes before returning - } - else { + } else { anonymousId = v4(); - Cookies.set('ajs_anonymous_id', anonymousId, {domain: '.'+window.location.hostname, expires: 365}); - }; + Cookies.set('ajs_anonymous_id', anonymousId, { domain: '.' + window.location.hostname, expires: 365 }); + } return anonymousId; -} \ No newline at end of file +}; diff --git a/components/dashboard/src/App.test.ts b/components/dashboard/src/App.test.ts index 6e0cc914ef389c..cda958a8a836ad 100644 --- a/components/dashboard/src/App.test.ts +++ b/components/dashboard/src/App.test.ts @@ -4,15 +4,15 @@ * See License-AGPL.txt in the project root for license information. */ -import { getURLHash } from './App' +import { getURLHash } from './App'; test('urlHash', () => { - global.window = Object.create(window); - Object.defineProperty(window, 'location', { - value: { - hash: '#https://example.org/user/repo' - } - }); + global.window = Object.create(window); + Object.defineProperty(window, 'location', { + value: { + hash: '#https://example.org/user/repo', + }, + }); - expect(getURLHash()).toBe('https://example.org/user/repo'); -}); \ No newline at end of file + expect(getURLHash()).toBe('https://example.org/user/repo'); +}); diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index c49cab5dc3b0f6..d838cb2a8eb7d4 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -6,7 +6,7 @@ import React, { Suspense, useContext, useEffect, useState } from 'react'; import Menu from './Menu'; -import { Redirect, Route, Switch } from "react-router"; +import { Redirect, Route, Switch } from 'react-router'; import { Login } from './Login'; import { UserContext } from './user-context'; @@ -22,8 +22,24 @@ import { User } from '@gitpod/gitpod-protocol'; import * as GitpodCookie from '@gitpod/gitpod-protocol/lib/util/gitpod-cookie'; import { Experiment } from './experiments'; import { workspacesPathMain } from './workspaces/workspaces.routes'; -import { settingsPathAccount, settingsPathIntegrations, settingsPathMain, settingsPathNotifications, settingsPathPlans, settingsPathPreferences, settingsPathTeams, settingsPathTeamsJoin, settingsPathTeamsNew, settingsPathVariables } from './settings/settings.routes'; -import { projectsPathInstallGitHubApp, projectsPathMain, projectsPathMainWithParams, projectsPathNew } from './projects/projects.routes'; +import { + settingsPathAccount, + settingsPathIntegrations, + settingsPathMain, + settingsPathNotifications, + settingsPathPlans, + settingsPathPreferences, + settingsPathTeams, + settingsPathTeamsJoin, + settingsPathTeamsNew, + settingsPathVariables, +} from './settings/settings.routes'; +import { + projectsPathInstallGitHubApp, + projectsPathMain, + projectsPathMainWithParams, + projectsPathNew, +} from './projects/projects.routes'; const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup')); const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces')); @@ -55,355 +71,392 @@ const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './ const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ './OauthClientApproval')); function Loading() { - return <> - ; + return <>; } function isGitpodIo() { - return window.location.hostname === 'gitpod.io' || window.location.hostname === 'gitpod-staging.com' || window.location.hostname.endsWith('gitpod-dev.com') || window.location.hostname.endsWith('gitpod-io-dev.com') + return ( + window.location.hostname === 'gitpod.io' || + window.location.hostname === 'gitpod-staging.com' || + window.location.hostname.endsWith('gitpod-dev.com') || + window.location.hostname.endsWith('gitpod-io-dev.com') + ); } function isWebsiteSlug(pathName: string) { - const slugs = [ - 'about', - 'blog', - 'careers', - 'changelog', - 'chat', - 'code-of-conduct', - 'contact', - 'docs', - 'features', - 'for', - 'gitpod-vs-github-codespaces', - 'imprint', - 'media-kit', - 'memes', - 'pricing', - 'privacy', - 'security', - 'screencasts', - 'self-hosted', - 'support', - 'terms', - 'values' - ] - return slugs.some(slug => pathName.startsWith('/' + slug + '/') || pathName === ('/' + slug)); + const slugs = [ + 'about', + 'blog', + 'careers', + 'changelog', + 'chat', + 'code-of-conduct', + 'contact', + 'docs', + 'features', + 'for', + 'gitpod-vs-github-codespaces', + 'imprint', + 'media-kit', + 'memes', + 'pricing', + 'privacy', + 'security', + 'screencasts', + 'self-hosted', + 'support', + 'terms', + 'values', + ]; + return slugs.some((slug) => pathName.startsWith('/' + slug + '/') || pathName === '/' + slug); } export function getURLHash() { - return window.location.hash.replace(/^[#/]+/, ''); + return window.location.hash.replace(/^[#/]+/, ''); } function App() { - const { user, setUser } = useContext(UserContext); - const { teams, setTeams } = useContext(TeamsContext); - const { setIsDark } = useContext(ThemeContext); + const { user, setUser } = useContext(UserContext); + const { teams, setTeams } = useContext(TeamsContext); + const { setIsDark } = useContext(ThemeContext); - const [ loading, setLoading ] = useState(true); - const [ isWhatsNewShown, setWhatsNewShown ] = useState(false); - const [ isSetupRequired, setSetupRequired ] = useState(false); - const history = useHistory(); + const [loading, setLoading] = useState(true); + const [isWhatsNewShown, setWhatsNewShown] = useState(false); + const [isSetupRequired, setSetupRequired] = useState(false); + const history = useHistory(); - useEffect(() => { - (async () => { - var user: User | undefined; - try { - const teamsPromise = getGitpodService().server.getTeams(); + useEffect(() => { + (async () => { + var user: User | undefined; + try { + const teamsPromise = getGitpodService().server.getTeams(); - user = await getGitpodService().server.getLoggedInUser(); - setUser(user); + user = await getGitpodService().server.getLoggedInUser(); + setUser(user); - const teams = await teamsPromise; + const teams = await teamsPromise; - { - // if a team was selected previously and we call the root URL (e.g. "gitpod.io"), - // let's continue with the team page - const hash = getURLHash(); - const isRoot = window.location.pathname === '/' && hash === ''; - if (isRoot) { - try { - const teamSlug = localStorage.getItem('team-selection'); - if (teams.some(t => t.slug === teamSlug)) { - history.push(`/t/${teamSlug}`); - } - } catch { - } - } - } - setTeams(teams); - } catch (error) { - console.error(error); - if (error && "code" in error) { - if (error.code === ErrorCodes.SETUP_REQUIRED) { - setSetupRequired(true); - } - } - } finally { - trackLocation(!!user); - } - setLoading(false); - (window as any)._gp.path = window.location.pathname; //store current path to have access to previous when path changes - })(); - }, []); - - useEffect(() => { - const updateTheme = () => { - const isDark = localStorage.theme === 'dark' || (localStorage.theme !== 'light' && window.matchMedia("(prefers-color-scheme: dark)").matches); - setIsDark(isDark); - } - updateTheme(); - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - if (mediaQuery instanceof EventTarget) { - mediaQuery.addEventListener('change', updateTheme); - } else { - // backward compatibility for Safari < 14 - (mediaQuery as MediaQueryList).addListener(updateTheme); - } - window.addEventListener('storage', updateTheme); - return function cleanup() { - if (mediaQuery instanceof EventTarget) { - mediaQuery.removeEventListener('change', updateTheme); - } else { - // backward compatibility for Safari < 14 - (mediaQuery as MediaQueryList).removeListener(updateTheme); - } - window.removeEventListener('storage', updateTheme); - } - }, []); - - // listen and notify Segment of client-side path updates - useEffect(() => { - if (isGitpodIo()) { - // Choose which experiments to run for this session/user - Experiment.set(Experiment.seed(true)); + { + // if a team was selected previously and we call the root URL (e.g. "gitpod.io"), + // let's continue with the team page + const hash = getURLHash(); + const isRoot = window.location.pathname === '/' && hash === ''; + if (isRoot) { + try { + const teamSlug = localStorage.getItem('team-selection'); + if (teams.some((t) => t.slug === teamSlug)) { + history.push(`/t/${teamSlug}`); + } + } catch {} + } } - }) - - useEffect(() => { - return history.listen((location: any) => { - const path = window.location.pathname; - trackPathChange({ - prev: (window as any)._gp.path, - path: path - }); - (window as any)._gp.path = path; - }) - }, [history]) - - useEffect(() => { - const handleButtonOrAnchorTracking = (props: MouseEvent) => { - var curr = props.target as HTMLElement; - //check if current target or any ancestor up to document is button or anchor - while (!(curr instanceof Document)) { - if (curr instanceof HTMLButtonElement || curr instanceof HTMLAnchorElement || (curr instanceof HTMLDivElement && curr.onclick)) { - trackButtonOrAnchor(curr, !!user); - break; //finding first ancestor is sufficient - } - curr = curr.parentNode as HTMLElement; - } + setTeams(teams); + } catch (error) { + console.error(error); + if (error && 'code' in error) { + if (error.code === ErrorCodes.SETUP_REQUIRED) { + setSetupRequired(true); + } } - window.addEventListener("click", handleButtonOrAnchorTracking, true); - return () => window.removeEventListener("click", handleButtonOrAnchorTracking, true); - }, []); + } finally { + trackLocation(!!user); + } + setLoading(false); + (window as any)._gp.path = window.location.pathname; //store current path to have access to previous when path changes + })(); + }, []); - // redirect to website for any website slugs - if (isGitpodIo() && isWebsiteSlug(window.location.pathname)) { - window.location.host = 'www.gitpod.io'; - return
; + useEffect(() => { + const updateTheme = () => { + const isDark = + localStorage.theme === 'dark' || + (localStorage.theme !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); + setIsDark(isDark); + }; + updateTheme(); + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + if (mediaQuery instanceof EventTarget) { + mediaQuery.addEventListener('change', updateTheme); + } else { + // backward compatibility for Safari < 14 + (mediaQuery as MediaQueryList).addListener(updateTheme); } + window.addEventListener('storage', updateTheme); + return function cleanup() { + if (mediaQuery instanceof EventTarget) { + mediaQuery.removeEventListener('change', updateTheme); + } else { + // backward compatibility for Safari < 14 + (mediaQuery as MediaQueryList).removeListener(updateTheme); + } + window.removeEventListener('storage', updateTheme); + }; + }, []); - if (isGitpodIo() && window.location.pathname === '/' && window.location.hash === '' && !loading && !user) { - if (!GitpodCookie.isPresent(document.cookie)) { - window.location.href = `https://www.gitpod.io`; - return
; - } else { - // explicitly render the Login page when the session is out-of-sync with the Gitpod cookie - return (); - } + // listen and notify Segment of client-side path updates + useEffect(() => { + if (isGitpodIo()) { + // Choose which experiments to run for this session/user + Experiment.set(Experiment.seed(true)); } + }); - if (loading) { - return (); - } - if (isSetupRequired) { - return (}> - - ); - } - if (!user) { - return (); - } - if (window.location.pathname.startsWith('/blocked')) { - return
- Gitpod's logo -

Your account has been blocked.

-

Please contact support if you think this is an error. See also terms of service.

- -
; - } - const shouldWhatsNewShown = shouldSeeWhatsNew(user) - if (shouldWhatsNewShown !== isWhatsNewShown) { - setWhatsNewShown(shouldWhatsNewShown); - } - if (window.location.pathname.startsWith('/oauth-approval')) { - return ( - }> - - - ); - } + useEffect(() => { + return history.listen((location: any) => { + const path = window.location.pathname; + trackPathChange({ + prev: (window as any)._gp.path, + path: path, + }); + (window as any)._gp.path = path; + }); + }, [history]); - window.addEventListener("hashchange", () => { - // Refresh on hash change if the path is '/' (new context URL) - if (window.location.pathname === '/') { - window.location.reload(); + useEffect(() => { + const handleButtonOrAnchorTracking = (props: MouseEvent) => { + var curr = props.target as HTMLElement; + //check if current target or any ancestor up to document is button or anchor + while (!(curr instanceof Document)) { + if ( + curr instanceof HTMLButtonElement || + curr instanceof HTMLAnchorElement || + (curr instanceof HTMLDivElement && curr.onclick) + ) { + trackButtonOrAnchor(curr, !!user); + break; //finding first ancestor is sufficient } - }, false); + curr = curr.parentNode as HTMLElement; + } + }; + window.addEventListener('click', handleButtonOrAnchorTracking, true); + return () => window.removeEventListener('click', handleButtonOrAnchorTracking, true); + }, []); - let toRender: React.ReactElement = -
- - - - - - - - - - - - - + // redirect to website for any website slugs + if (isGitpodIo() && isWebsiteSlug(window.location.pathname)) { + window.location.host = 'www.gitpod.io'; + return
; + } - - + if (isGitpodIo() && window.location.pathname === '/' && window.location.hash === '' && !loading && !user) { + if (!GitpodCookie.isPresent(document.cookie)) { + window.location.href = `https://www.gitpod.io`; + return
; + } else { + // explicitly render the Login page when the session is out-of-sync with the Gitpod cookie + return ; + } + } - - - - - - - - - - - - - - - - -
-

Oh, no! Something went wrong!

-

{decodeURIComponent(getURLHash())}

-
-
- - - { - const { resourceOrPrebuild } = props.match.params; - if (resourceOrPrebuild === "settings") { - return ; - } - if (resourceOrPrebuild === "configure") { - return ; - } - if (resourceOrPrebuild === "variables") { - return ; - } - if (resourceOrPrebuild === "prebuilds") { - return ; - } - return resourceOrPrebuild ? : ; - }} /> - - - - - - - {(teams || []).map(team => - - - - - { - const { maybeProject, resourceOrPrebuild } = props.match.params; - if (maybeProject === "projects") { - return ; - } - if (maybeProject === "workspaces") { - return ; - } - if (maybeProject === "members") { - return ; - } - if (maybeProject === "settings") { - return ; - } - if (resourceOrPrebuild === "settings") { - return ; - } - if (resourceOrPrebuild === "configure") { - return ; - } - if (resourceOrPrebuild === "variables") { - return ; - } - if (resourceOrPrebuild === "prebuilds") { - return ; - } - return resourceOrPrebuild ? : ; - }} /> - )} - { + if (loading) { + return ; + } + if (isSetupRequired) { + return ( + }> + + + ); + } + if (!user) { + return ; + } + if (window.location.pathname.startsWith('/blocked')) { + return ( +
+ Gitpod's logo +

Your account has been blocked.

+

+ Please contact support if you think this is an error. See also{' '} + + terms of service + + . +

+ + + +
+ ); + } + const shouldWhatsNewShown = shouldSeeWhatsNew(user); + if (shouldWhatsNewShown !== isWhatsNewShown) { + setWhatsNewShown(shouldWhatsNewShown); + } + if (window.location.pathname.startsWith('/oauth-approval')) { + return ( + }> + + + ); + } - return isGitpodIo() ? - // delegate to our website to handle the request - (window.location.host = 'www.gitpod.io') : -
-

404

-

Page not found.

-
; - }}> -
-
-
-
; + window.addEventListener( + 'hashchange', + () => { + // Refresh on hash change if the path is '/' (new context URL) + if (window.location.pathname === '/') { + window.location.reload(); + } + }, + false, + ); - const hash = getURLHash(); - if (/^(https:\/\/)?github\.dev\//i.test(hash)) { - window.location.hash = hash.replace(/^(https:\/\/)?github\.dev\//i, 'https://github.com/') - return
- } else if (/^([^\/]+?=[^\/]*?|prebuild)\/(https:\/\/)?github\.dev\//i.test(hash)) { - window.location.hash = hash.replace(/^([^\/]+?=[^\/]*?|prebuild)\/(https:\/\/)?github\.dev\//i, '$1/https://github.com/') - return
- } - const isCreation = window.location.pathname === '/' && hash !== ''; - const isWsStart = /\/start\/?/.test(window.location.pathname) && hash !== ''; - if (isWhatsNewShown) { - toRender = setWhatsNewShown(false)} />; - } else if (isCreation) { - toRender = ; - } else if (isWsStart) { - toRender = ; - } else if (/^(github|gitlab)\.com\/.+?/i.test(window.location.pathname)) { - let url = new URL(window.location.href) - url.hash = url.pathname - url.pathname = '/' - window.location.replace(url) - return
- } + let toRender: React.ReactElement = ( + +
+ + + + + + + + + + + + + - return ( - }> - {toRender} - + + + + + + + + + + + + + + + + + + + +
+

Oh, no! Something went wrong!

+

{decodeURIComponent(getURLHash())}

+
+
+ + + { + const { resourceOrPrebuild } = props.match.params; + if (resourceOrPrebuild === 'settings') { + return ; + } + if (resourceOrPrebuild === 'configure') { + return ; + } + if (resourceOrPrebuild === 'variables') { + return ; + } + if (resourceOrPrebuild === 'prebuilds') { + return ; + } + return resourceOrPrebuild ? : ; + }} + /> + + + + + + + {(teams || []).map((team) => ( + + + + + { + const { maybeProject, resourceOrPrebuild } = props.match.params; + if (maybeProject === 'projects') { + return ; + } + if (maybeProject === 'workspaces') { + return ; + } + if (maybeProject === 'members') { + return ; + } + if (maybeProject === 'settings') { + return ; + } + if (resourceOrPrebuild === 'settings') { + return ; + } + if (resourceOrPrebuild === 'configure') { + return ; + } + if (resourceOrPrebuild === 'variables') { + return ; + } + if (resourceOrPrebuild === 'prebuilds') { + return ; + } + return resourceOrPrebuild ? : ; + }} + /> + + ))} + { + return isGitpodIo() ? ( + // delegate to our website to handle the request + (window.location.host = 'www.gitpod.io') + ) : ( +
+

404

+

Page not found.

+
+ ); + }} + >
+
+
+
+ ); + + const hash = getURLHash(); + if (/^(https:\/\/)?github\.dev\//i.test(hash)) { + window.location.hash = hash.replace(/^(https:\/\/)?github\.dev\//i, 'https://github.com/'); + return
; + } else if (/^([^\/]+?=[^\/]*?|prebuild)\/(https:\/\/)?github\.dev\//i.test(hash)) { + window.location.hash = hash.replace( + /^([^\/]+?=[^\/]*?|prebuild)\/(https:\/\/)?github\.dev\//i, + '$1/https://github.com/', ); + return
; + } + const isCreation = window.location.pathname === '/' && hash !== ''; + const isWsStart = /\/start\/?/.test(window.location.pathname) && hash !== ''; + if (isWhatsNewShown) { + toRender = setWhatsNewShown(false)} />; + } else if (isCreation) { + toRender = ; + } else if (isWsStart) { + toRender = ; + } else if (/^(github|gitlab)\.com\/.+?/i.test(window.location.pathname)) { + let url = new URL(window.location.href); + url.hash = url.pathname; + url.pathname = '/'; + window.location.replace(url); + return
; + } + + return }>{toRender}; } export default App; diff --git a/components/dashboard/src/FromReferrer.tsx b/components/dashboard/src/FromReferrer.tsx index 740d13fc67605c..be38bf1bec76d7 100644 --- a/components/dashboard/src/FromReferrer.tsx +++ b/components/dashboard/src/FromReferrer.tsx @@ -4,35 +4,43 @@ * See License-AGPL.txt in the project root for license information. */ -import { Link } from "react-router-dom"; +import { Link } from 'react-router-dom'; export default function FromReferrer() { - const contextUrl = document.referrer; + const contextUrl = document.referrer; - if (contextUrl && contextUrl !== '' && new URL(contextUrl).pathname !== '/') { - // Redirect to gitpod.io/# to get the same experience as with direct call - const url = new URL(window.location.toString()); - url.pathname = "/"; - url.hash = contextUrl; - window.location.href = url.toString(); - return
; - } + if (contextUrl && contextUrl !== '' && new URL(contextUrl).pathname !== '/') { + // Redirect to gitpod.io/# to get the same experience as with direct call + const url = new URL(window.location.toString()); + url.pathname = '/'; + url.hash = contextUrl; + window.location.href = url.toString(); + return
; + } - return
-
-
-

No Referrer Found

-
-

- It looks like you are trying to open a workspace, but the referrer URL is empty or has an incomplete path. - This happens when the Git hoster or browser doesn't send the referrer header. -
Please prefix the repository URL with

https://{window.location.host}/#
in order to start a workspace. Learn more -

-
- - - -
+ return ( +
+
+
+

No Referrer Found

+
+

+ It looks like you are trying to open a workspace, but the referrer URL is empty or has an incomplete path. + This happens when the Git hoster or browser doesn't send the referrer header. +
Please prefix the repository URL with

https://{window.location.host}/#
in order to start + a workspace.{' '} + + Learn more + +

+
+ + + + +
-
; +
+
+ ); } diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index 9a637bcde22603..6a1cc04150d828 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -4,216 +4,251 @@ * See License-AGPL.txt in the project root for license information. */ -import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; +import { AuthProviderInfo } from '@gitpod/gitpod-protocol'; import * as GitpodCookie from '@gitpod/gitpod-protocol/lib/util/gitpod-cookie'; -import { useContext, useEffect, useState } from "react"; -import { UserContext } from "./user-context"; -import { TeamsContext } from "./teams/teams-context"; -import { getGitpodService } from "./service/service"; -import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from "./provider-utils"; +import { useContext, useEffect, useState } from 'react'; +import { UserContext } from './user-context'; +import { TeamsContext } from './teams/teams-context'; +import { getGitpodService } from './service/service'; +import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from './provider-utils'; import gitpod from './images/gitpod.svg'; import gitpodDark from './images/gitpod-dark.svg'; import gitpodIcon from './icons/gitpod.svg'; -import automate from "./images/welcome/automate.svg"; -import code from "./images/welcome/code.svg"; -import collaborate from "./images/welcome/collaborate.svg"; -import customize from "./images/welcome/customize.svg"; -import fresh from "./images/welcome/fresh.svg"; -import prebuild from "./images/welcome/prebuild.svg"; -import exclamation from "./images/exclamation.svg"; -import { getURLHash } from "./App"; - - -function Item(props: { icon: string, iconSize?: string, text: string }) { - const iconSize = props.iconSize || 28; - return
- -
{props.text}
-
; +import automate from './images/welcome/automate.svg'; +import code from './images/welcome/code.svg'; +import collaborate from './images/welcome/collaborate.svg'; +import customize from './images/welcome/customize.svg'; +import fresh from './images/welcome/fresh.svg'; +import prebuild from './images/welcome/prebuild.svg'; +import exclamation from './images/exclamation.svg'; +import { getURLHash } from './App'; + +function Item(props: { icon: string; iconSize?: string; text: string }) { + const iconSize = props.iconSize || 28; + return ( +
+ +
{props.text}
+
+ ); } export function markLoggedIn() { - document.cookie = GitpodCookie.generateCookie(window.location.hostname); + document.cookie = GitpodCookie.generateCookie(window.location.hostname); } export function hasLoggedInBefore() { - return GitpodCookie.isPresent(document.cookie); + return GitpodCookie.isPresent(document.cookie); } export function hasVisitedMarketingWebsiteBefore() { - return document.cookie.match("gitpod-marketing-website-visited=true"); + return document.cookie.match('gitpod-marketing-website-visited=true'); } export function Login() { - const { setUser } = useContext(UserContext); - const { setTeams } = useContext(TeamsContext); - - const urlHash = getURLHash(); - let hostFromContext: string | undefined; - let repoPathname: string | undefined; - - try { - if (urlHash.length > 0) { - const url = new URL(urlHash); - hostFromContext = url.host; - repoPathname = url.pathname; - } - } catch (error) { - // Hash is not a valid URL + const { setUser } = useContext(UserContext); + const { setTeams } = useContext(TeamsContext); + + const urlHash = getURLHash(); + let hostFromContext: string | undefined; + let repoPathname: string | undefined; + + try { + if (urlHash.length > 0) { + const url = new URL(urlHash); + hostFromContext = url.host; + repoPathname = url.pathname; } - - const [authProviders, setAuthProviders] = useState([]); - const [errorMessage, setErrorMessage] = useState(undefined); - const [providerFromContext, setProviderFromContext] = useState(); - - const showWelcome = !hasLoggedInBefore() && !hasVisitedMarketingWebsiteBefore() && !urlHash.startsWith("https://"); - - useEffect(() => { - (async () => { - setAuthProviders(await getGitpodService().server.getAuthProviders()); - })(); - }, []) - - useEffect(() => { - if (hostFromContext && authProviders) { - const providerFromContext = authProviders.find(provider => provider.host === hostFromContext); - setProviderFromContext(providerFromContext); - } - }, [authProviders]); - - const authorizeSuccessful = async (payload?: string) => { - updateUser().catch(console.error); - - // Check for a valid returnTo in payload - const safeReturnTo = getSafeURLRedirect(payload); - if (safeReturnTo) { - // ... and if it is, redirect to it - window.location.replace(safeReturnTo); - } + } catch (error) { + // Hash is not a valid URL + } + + const [authProviders, setAuthProviders] = useState([]); + const [errorMessage, setErrorMessage] = useState(undefined); + const [providerFromContext, setProviderFromContext] = useState(); + + const showWelcome = !hasLoggedInBefore() && !hasVisitedMarketingWebsiteBefore() && !urlHash.startsWith('https://'); + + useEffect(() => { + (async () => { + setAuthProviders(await getGitpodService().server.getAuthProviders()); + })(); + }, []); + + useEffect(() => { + if (hostFromContext && authProviders) { + const providerFromContext = authProviders.find((provider) => provider.host === hostFromContext); + setProviderFromContext(providerFromContext); } + }, [authProviders]); - const updateUser = async () => { - await getGitpodService().reconnect(); - const [user, teams] = await Promise.all([ - getGitpodService().server.getLoggedInUser(), - getGitpodService().server.getTeams(), - ]); - setUser(user); - setTeams(teams); - markLoggedIn(); - } + const authorizeSuccessful = async (payload?: string) => { + updateUser().catch(console.error); - const openLogin = async (host: string) => { - setErrorMessage(undefined); - - try { - await openAuthorizeWindow({ - login: true, - host, - onSuccess: authorizeSuccessful, - onError: (payload) => { - let errorMessage: string; - if (typeof payload === "string") { - errorMessage = payload; - } else { - errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; - if (payload.error === "email_taken") { - errorMessage = `Email address already used in another account. Please log in with ${(payload as any).host}.`; - } - } - setErrorMessage(errorMessage); - } - }); - } catch (error) { - console.log(error) - } + // Check for a valid returnTo in payload + const safeReturnTo = getSafeURLRedirect(payload); + if (safeReturnTo) { + // ... and if it is, redirect to it + window.location.replace(safeReturnTo); } + }; + + const updateUser = async () => { + await getGitpodService().reconnect(); + const [user, teams] = await Promise.all([ + getGitpodService().server.getLoggedInUser(), + getGitpodService().server.getTeams(), + ]); + setUser(user); + setTeams(teams); + markLoggedIn(); + }; + + const openLogin = async (host: string) => { + setErrorMessage(undefined); - return (
- {showWelcome ?
-
-
-
- Gitpod light theme logo - Gitpod dark theme logo -
-
-

Welcome to Gitpod

-
- Spin up fresh, automated dev environments for each task in the cloud, in seconds. -
-
-
- - - -
-
- - - -
+ try { + await openAuthorizeWindow({ + login: true, + host, + onSuccess: authorizeSuccessful, + onError: (payload) => { + let errorMessage: string; + if (typeof payload === 'string') { + errorMessage = payload; + } else { + errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; + if (payload.error === 'email_taken') { + errorMessage = `Email address already used in another account. Please log in with ${ + (payload as any).host + }.`; + } + } + setErrorMessage(errorMessage); + }, + }); + } catch (error) { + console.log(error); + } + }; + + return ( +
+ {showWelcome ? ( +
+
+
+
+ Gitpod light theme logo + Gitpod dark theme logo +
+
+

Welcome to Gitpod

+
+ Spin up fresh, automated dev environments for each task in the cloud, in seconds.
+
+
+ + + +
+
+ + + +
-
: null} -
-
-
-
-
- Gitpod's logo - Gitpod dark theme logo -
- -
- {providerFromContext - ? <> -

Open a cloud-based developer environment

-

for the repository {repoPathname?.slice(1)}

- - : <> -

Log in{showWelcome ? '' : ' to Gitpod'}

-

ALWAYS READY-TO-CODE

- } -
- - -
- {providerFromContext - ? - - : - authProviders.map(ap => - ) - } -
- - {errorMessage && ( -
-
- -
-
-

{errorMessage}

-
-
- )} - -
-
-
- - By signing in, you agree to our terms of service. +
+
+ ) : null} +
+
+
+
+
+ Gitpod's logo + Gitpod dark theme logo +
+ +
+ {providerFromContext ? ( + <> +

+ Open a cloud-based developer environment +

+

for the repository {repoPathname?.slice(1)}

+ + ) : ( + <> +

Log in{showWelcome ? '' : ' to Gitpod'}

+

ALWAYS READY-TO-CODE

+ + )} +
+ +
+ {providerFromContext ? ( + + ) : ( + authProviders.map((ap) => ( + + )) + )} +
+ + {errorMessage && ( +
+
+ +
+
+

{errorMessage}

+
+ )}
- +
+
+ + By signing in, you agree to our{' '} + + terms of service + + . + +
-
); +
+
+ ); } diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index e9661a1f483c44..929fcc10fd7a0d 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -4,299 +4,365 @@ * See License-AGPL.txt in the project root for license information. */ -import { User, TeamMemberInfo, Project } from "@gitpod/gitpod-protocol"; -import { useContext, useEffect, useState } from "react"; -import { Link } from "react-router-dom"; -import { useLocation, useRouteMatch } from "react-router"; -import { Location } from "history"; +import { User, TeamMemberInfo, Project } from '@gitpod/gitpod-protocol'; +import { useContext, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useLocation, useRouteMatch } from 'react-router'; +import { Location } from 'history'; import gitpodIcon from './icons/gitpod.svg'; -import CaretDown from "./icons/CaretDown.svg"; -import CaretUpDown from "./icons/CaretUpDown.svg"; -import { getGitpodService, gitpodHostUrl } from "./service/service"; -import { UserContext } from "./user-context"; -import { TeamsContext, getCurrentTeam } from "./teams/teams-context"; +import CaretDown from './icons/CaretDown.svg'; +import CaretUpDown from './icons/CaretUpDown.svg'; +import { getGitpodService, gitpodHostUrl } from './service/service'; +import { UserContext } from './user-context'; +import { TeamsContext, getCurrentTeam } from './teams/teams-context'; import settingsMenu from './settings/settings-menu'; import { adminMenu } from './admin/admin-menu'; -import ContextMenu from "./components/ContextMenu"; -import Separator from "./components/Separator"; -import PillMenuItem from "./components/PillMenuItem"; -import TabMenuItem from "./components/TabMenuItem"; -import { getTeamSettingsMenu } from "./teams/TeamSettings"; -import { getProjectSettingsMenu } from "./projects/ProjectSettings"; -import { ProjectContext } from "./projects/project-context"; +import ContextMenu from './components/ContextMenu'; +import Separator from './components/Separator'; +import PillMenuItem from './components/PillMenuItem'; +import TabMenuItem from './components/TabMenuItem'; +import { getTeamSettingsMenu } from './teams/TeamSettings'; +import { getProjectSettingsMenu } from './projects/ProjectSettings'; +import { ProjectContext } from './projects/project-context'; interface Entry { - title: string, - link: string, - alternatives?: string[] + title: string; + link: string; + alternatives?: string[]; } export default function Menu() { - const { user } = useContext(UserContext); - const { teams } = useContext(TeamsContext); - const location = useLocation(); - const team = getCurrentTeam(location, teams); - const { project, setProject } = useContext(ProjectContext); + const { user } = useContext(UserContext); + const { teams } = useContext(TeamsContext); + const location = useLocation(); + const team = getCurrentTeam(location, teams); + const { project, setProject } = useContext(ProjectContext); - const match = useRouteMatch<{ segment1?: string, segment2?: string, segment3?: string }>("/(t/)?:segment1/:segment2?/:segment3?"); - const projectSlug = (() => { - const resource = match?.params?.segment2; - if (resource && !["projects", "members", "users", "workspaces", "settings"].includes(resource)) { - return resource; - } - })(); - const prebuildId = (() => { - const resource = projectSlug && match?.params?.segment3; - if (resource !== "workspaces" && resource !== "prebuilds" && resource !== "settings" && resource !== "configure" && resource !== "variables") { - return resource; - } - })(); - - function isSelected(entry: Entry, location: Location) { - const all = [entry.link, ...(entry.alternatives||[])].map(l => l.toLowerCase()); - const path = location.pathname.toLowerCase(); - return all.some(n => n === path || n+'/' === path); + const match = useRouteMatch<{ segment1?: string; segment2?: string; segment3?: string }>( + '/(t/)?:segment1/:segment2?/:segment3?', + ); + const projectSlug = (() => { + const resource = match?.params?.segment2; + if (resource && !['projects', 'members', 'users', 'workspaces', 'settings'].includes(resource)) { + return resource; } - - const userFullName = user?.fullName || user?.name || '...'; - - { - // updating last team selection - try { - localStorage.setItem('team-selection', team ? team.slug : ""); - } catch { - } + })(); + const prebuildId = (() => { + const resource = projectSlug && match?.params?.segment3; + if ( + resource !== 'workspaces' && + resource !== 'prebuilds' && + resource !== 'settings' && + resource !== 'configure' && + resource !== 'variables' + ) { + return resource; } + })(); - // Hide most of the top menu when in a full-page form. - const isMinimalUI = ['/new', '/teams/new'].includes(location.pathname); + function isSelected(entry: Entry, location: Location) { + const all = [entry.link, ...(entry.alternatives || [])].map((l) => l.toLowerCase()); + const path = location.pathname.toLowerCase(); + return all.some((n) => n === path || n + '/' === path); + } - const [ teamMembers, setTeamMembers ] = useState>({}); - useEffect(() => { - if (!teams) { - return; - } - (async () => { - const members: Record = {}; - await Promise.all(teams.map(async (team) => { - try { - members[team.id] = await getGitpodService().server.getTeamMembers(team.id); - } catch (error) { - console.error('Could not get members of team', team, error); - } - })); - setTeamMembers(members); - })(); - }, [ teams ]); + const userFullName = user?.fullName || user?.name || '...'; - useEffect(() => { - if (!teams || !projectSlug) { - return; - } - (async () => { - const projects = (!!team - ? await getGitpodService().server.getTeamProjects(team.id) - : await getGitpodService().server.getUserProjects()); + { + // updating last team selection + try { + localStorage.setItem('team-selection', team ? team.slug : ''); + } catch {} + } - // Find project matching with slug, otherwise with name - const project = projectSlug && projects.find(p => p.slug ? p.slug === projectSlug : p.name === projectSlug); - if (!project) { - return; - } - setProject(project); - })(); - }, [projectSlug, setProject, team, teams]); + // Hide most of the top menu when in a full-page form. + const isMinimalUI = ['/new', '/teams/new'].includes(location.pathname); - const teamOrUserSlug = !!team ? '/t/' + team.slug : '/projects'; - const leftMenu: Entry[] = (() => { - // Project menu - if (projectSlug) { - return [ - { - title: 'Branches', - link: `${teamOrUserSlug}/${projectSlug}`, - }, - { - title: 'Prebuilds', - link: `${teamOrUserSlug}/${projectSlug}/prebuilds`, - }, - { - title: 'Settings', - link: `${teamOrUserSlug}/${projectSlug}/settings`, - alternatives: getProjectSettingsMenu({ slug: projectSlug } as Project, team).flatMap(e => e.link), - }, - ]; - } - // Team menu - if (team) { - const currentUserInTeam = (teamMembers[team.id] || []).find(m => m.userId === user?.id); + const [teamMembers, setTeamMembers] = useState>({}); + useEffect(() => { + if (!teams) { + return; + } + (async () => { + const members: Record = {}; + await Promise.all( + teams.map(async (team) => { + try { + members[team.id] = await getGitpodService().server.getTeamMembers(team.id); + } catch (error) { + console.error('Could not get members of team', team, error); + } + }), + ); + setTeamMembers(members); + })(); + }, [teams]); - const teamSettingsList = [ - { - title: 'Projects', - link: `/t/${team.slug}/projects`, - alternatives: ([] as string[]) - }, - { - title: 'Members', - link: `/t/${team.slug}/members` - } - ]; - if (currentUserInTeam?.role === "owner") { - teamSettingsList.push({ - title: 'Settings', - link: `/t/${team.slug}/settings`, - alternatives: getTeamSettingsMenu(team).flatMap(e => e.link), - }) - } + useEffect(() => { + if (!teams || !projectSlug) { + return; + } + (async () => { + const projects = !!team + ? await getGitpodService().server.getTeamProjects(team.id) + : await getGitpodService().server.getUserProjects(); - return teamSettingsList; - } - // User menu - return [ - { - title: 'Workspaces', - link: '/workspaces', - alternatives: ['/'] - }, - { - title: 'Projects', - link: '/projects' - }, - { - title: 'Settings', - link: '/settings', - alternatives: settingsMenu.flatMap(e => e.link) - } - ]; + // Find project matching with slug, otherwise with name + const project = projectSlug && projects.find((p) => (p.slug ? p.slug === projectSlug : p.name === projectSlug)); + if (!project) { + return; + } + setProject(project); })(); - const rightMenu: Entry[] = [ - ...(user?.rolesOrPermissions?.includes('admin') ? [{ - title: 'Admin', - link: '/admin', - alternatives: adminMenu.flatMap(e => e.link) - }] : []), + }, [projectSlug, setProject, team, teams]); + + const teamOrUserSlug = !!team ? '/t/' + team.slug : '/projects'; + const leftMenu: Entry[] = (() => { + // Project menu + if (projectSlug) { + return [ + { + title: 'Branches', + link: `${teamOrUserSlug}/${projectSlug}`, + }, + { + title: 'Prebuilds', + link: `${teamOrUserSlug}/${projectSlug}/prebuilds`, + }, + { + title: 'Settings', + link: `${teamOrUserSlug}/${projectSlug}/settings`, + alternatives: getProjectSettingsMenu({ slug: projectSlug } as Project, team).flatMap((e) => e.link), + }, + ]; + } + // Team menu + if (team) { + const currentUserInTeam = (teamMembers[team.id] || []).find((m) => m.userId === user?.id); + + const teamSettingsList = [ { - title: 'Docs', - link: 'https://www.gitpod.io/docs/', + title: 'Projects', + link: `/t/${team.slug}/projects`, + alternatives: [] as string[], }, { - title: 'Help', - link: 'https://www.gitpod.io/support', - } + title: 'Members', + link: `/t/${team.slug}/members`, + }, + ]; + if (currentUserInTeam?.role === 'owner') { + teamSettingsList.push({ + title: 'Settings', + link: `/t/${team.slug}/settings`, + alternatives: getTeamSettingsMenu(team).flatMap((e) => e.link), + }); + } + + return teamSettingsList; + } + // User menu + return [ + { + title: 'Workspaces', + link: '/workspaces', + alternatives: ['/'], + }, + { + title: 'Projects', + link: '/projects', + }, + { + title: 'Settings', + link: '/settings', + alternatives: settingsMenu.flatMap((e) => e.link), + }, ]; + })(); + const rightMenu: Entry[] = [ + ...(user?.rolesOrPermissions?.includes('admin') + ? [ + { + title: 'Admin', + link: '/admin', + alternatives: adminMenu.flatMap((e) => e.link), + }, + ] + : []), + { + title: 'Docs', + link: 'https://www.gitpod.io/docs/', + }, + { + title: 'Help', + link: 'https://www.gitpod.io/support', + }, + ]; - const renderTeamMenu = () => { - return ( -
- { projectSlug &&
- - {team?.name || userFullName} - -
} -
- - {userFullName} - Personal Account -
, - active: !team, - separator: true, - link: '/', - }, - ...(teams || []).map(t => ({ - title: t.name, - customContent:
- {t.name} - {!!teamMembers[t.id] - ? `${teamMembers[t.id].length} member${teamMembers[t.id].length === 1 ? '' : 's'}` - : '...' - } -
, - active: team && team.id === t.id, - separator: true, - link: `/t/${t.slug}`, - })).sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1), - { - title: 'Create a new team', - customContent:
- New Team - -
, - link: '/teams/new', - } - ]}> -
- { !projectSlug && {team?.name || userFullName}} - -
- -
- { projectSlug && ( -
- - {project?.name} - -
- )} - { prebuildId && ( -
- - {prebuildId} + const renderTeamMenu = () => { + return ( +
+ {projectSlug && ( +
+ + + {team?.name || userFullName} + + +
+ )} +
+ + {userFullName} + Personal Account +
+ ), + active: !team, + separator: true, + link: '/', + }, + ...(teams || []) + .map((t) => ({ + title: t.name, + customContent: ( +
+ {t.name} + + {!!teamMembers[t.id] + ? `${teamMembers[t.id].length} member${teamMembers[t.id].length === 1 ? '' : 's'}` + : '...'} +
- )} + ), + active: team && team.id === t.id, + separator: true, + link: `/t/${t.slug}`, + })) + .sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1)), + { + title: 'Create a new team', + customContent: ( +
+ New Team + + + +
+ ), + link: '/teams/new', + }, + ]} + > +
+ {!projectSlug && ( + + {team?.name || userFullName} + + )} +
- ) - } + +
+ {projectSlug && ( +
+ + {project?.name} + +
+ )} + {prebuildId && ( +
+ + {prebuildId} +
+ )} +
+ ); + }; - return <> -
-
-
- - Gitpod's logo - - {!isMinimalUI &&
- {renderTeamMenu()} -
} -
- + return ( + <> +
+
+
+ + Gitpod's logo + + {!isMinimalUI &&
{renderTeamMenu()}
} +
+
- - ; -} \ No newline at end of file +
+
+ {!isMinimalUI && !prebuildId && ( + + )} + + + + ); +} diff --git a/components/dashboard/src/OauthClientApproval.tsx b/components/dashboard/src/OauthClientApproval.tsx index 55841299ae51f6..d9b249438bc4f7 100644 --- a/components/dashboard/src/OauthClientApproval.tsx +++ b/components/dashboard/src/OauthClientApproval.tsx @@ -5,44 +5,51 @@ */ import gitpodIcon from './icons/gitpod.svg'; -import { getSafeURLRedirect } from "./provider-utils"; +import { getSafeURLRedirect } from './provider-utils'; export default function OAuthClientApproval() { - const params = new URLSearchParams(window.location.search); - const clientName = params.get("clientName") || ""; - let redirectToParam = params.get("redirectTo") || undefined; - if (redirectToParam) { - redirectToParam = decodeURIComponent(redirectToParam); - } - const redirectTo = getSafeURLRedirect(redirectToParam) || "/"; - const updateClientApproval = async (isApproved: boolean) => { - if (redirectTo === "/") { - window.location.replace(redirectTo); - } - window.location.replace(`${redirectTo}&approved=${isApproved ? 'yes' : 'no'}`); + const params = new URLSearchParams(window.location.search); + const clientName = params.get('clientName') || ''; + let redirectToParam = params.get('redirectTo') || undefined; + if (redirectToParam) { + redirectToParam = decodeURIComponent(redirectToParam); + } + const redirectTo = getSafeURLRedirect(redirectToParam) || '/'; + const updateClientApproval = async (isApproved: boolean) => { + if (redirectTo === '/') { + window.location.replace(redirectTo); } + window.location.replace(`${redirectTo}&approved=${isApproved ? 'yes' : 'no'}`); + }; - return (
-
-
-
-
-
- Gitpod's logo -
-
-

Authorize {clientName}

-

You are about to authorize {clientName} to access your Gitpod account including data for all workspaces.

-
-
- - -
-
-
+ return ( +
+
+
+
+
+
+ Gitpod's logo +
+
+

Authorize {clientName}

+

+ You are about to authorize {clientName} to access your Gitpod account including data for all + workspaces. +

+
+
+ + +
+
-
); -} \ No newline at end of file +
+
+ ); +} diff --git a/components/dashboard/src/Setup.tsx b/components/dashboard/src/Setup.tsx index 7f2804e0ec83d8..6e851c3def7a6a 100644 --- a/components/dashboard/src/Setup.tsx +++ b/components/dashboard/src/Setup.tsx @@ -4,58 +4,73 @@ * See License-AGPL.txt in the project root for license information. */ -import { useEffect, useState } from "react"; -import Modal from "./components/Modal"; -import { getGitpodService, gitpodHostUrl } from "./service/service"; -import { GitIntegrationModal } from "./settings/Integrations"; +import { useEffect, useState } from 'react'; +import Modal from './components/Modal'; +import { getGitpodService, gitpodHostUrl } from './service/service'; +import { GitIntegrationModal } from './settings/Integrations'; export default function Setup() { + const [showModal, setShowModal] = useState(false); - const [showModal, setShowModal] = useState(false); - - useEffect(() => { - (async () => { - const dynamicAuthProviders = await getGitpodService().server.getOwnAuthProviders(); - const previous = dynamicAuthProviders.filter(ap => ap.ownerId === "no-user")[0]; - if (previous) { - await getGitpodService().server.deleteOwnAuthProvider({ id: previous.id }); - } - })(); - }, []); - - const acceptAndContinue = () => { - setShowModal(true); - } - - const onAuthorize = (payload?: string) => { - // run without await, so the integrated closing of new tab isn't blocked - (async () => { - window.location.href = gitpodHostUrl.asDashboard().toString(); - })(); - } - - const headerText = "Configure a Git integration with a GitLab or GitHub instance." - - return
- {!showModal && ( - { }} closeable={false}> -

Welcome to Gitpod 🎉

-
-

To start using Gitpod, you will need to set up a Git integration.

- -
- - By using Gitpod, you agree to our terms. - -
-
-
- -
-
- )} - {showModal && ( - - )} -
; + useEffect(() => { + (async () => { + const dynamicAuthProviders = await getGitpodService().server.getOwnAuthProviders(); + const previous = dynamicAuthProviders.filter((ap) => ap.ownerId === 'no-user')[0]; + if (previous) { + await getGitpodService().server.deleteOwnAuthProvider({ id: previous.id }); + } + })(); + }, []); + + const acceptAndContinue = () => { + setShowModal(true); + }; + + const onAuthorize = (payload?: string) => { + // run without await, so the integrated closing of new tab isn't blocked + (async () => { + window.location.href = gitpodHostUrl.asDashboard().toString(); + })(); + }; + + const headerText = 'Configure a Git integration with a GitLab or GitHub instance.'; + + return ( +
+ {!showModal && ( + {}} closeable={false}> +

Welcome to Gitpod 🎉

+
+

+ To start using Gitpod, you will need to set up a Git integration. +

+ +
+ + By using Gitpod, you agree to our{' '} + + terms + + . + +
+
+
+ +
+
+ )} + {showModal && ( + + )} +
+ ); } diff --git a/components/dashboard/src/admin/UserDetail.tsx b/components/dashboard/src/admin/UserDetail.tsx index e3a9d2969d25a6..4c15b8a4f7719f 100644 --- a/components/dashboard/src/admin/UserDetail.tsx +++ b/components/dashboard/src/admin/UserDetail.tsx @@ -4,269 +4,357 @@ * See License-AGPL.txt in the project root for license information. */ -import { NamedWorkspaceFeatureFlag, Permissions, RoleOrPermission, Roles, User, WorkspaceFeatureFlags } from "@gitpod/gitpod-protocol" -import { AccountStatement, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; -import { Plans } from "@gitpod/gitpod-protocol/lib/plans"; -import moment from "moment"; -import { ReactChild, useEffect, useRef, useState } from "react"; -import CheckBox from "../components/CheckBox"; -import Modal from "../components/Modal"; -import { PageWithSubMenu } from "../components/PageWithSubMenu" -import { getGitpodService } from "../service/service"; -import { adminMenu } from "./admin-menu" -import { WorkspaceSearch } from "./WorkspacesSearch"; - +import { + NamedWorkspaceFeatureFlag, + Permissions, + RoleOrPermission, + Roles, + User, + WorkspaceFeatureFlags, +} from '@gitpod/gitpod-protocol'; +import { AccountStatement, Subscription } from '@gitpod/gitpod-protocol/lib/accounting-protocol'; +import { Plans } from '@gitpod/gitpod-protocol/lib/plans'; +import moment from 'moment'; +import { ReactChild, useEffect, useRef, useState } from 'react'; +import CheckBox from '../components/CheckBox'; +import Modal from '../components/Modal'; +import { PageWithSubMenu } from '../components/PageWithSubMenu'; +import { getGitpodService } from '../service/service'; +import { adminMenu } from './admin-menu'; +import { WorkspaceSearch } from './WorkspacesSearch'; export default function UserDetail(p: { user: User }) { - const [activity, setActivity] = useState(false); - const [user, setUser] = useState(p.user); - const [accountStatement, setAccountStatement] = useState(); - const [isStudent, setIsStudent] = useState(); - const [editFeatureFlags, setEditFeatureFlags] = useState(false); - const [editRoles, setEditRoles] = useState(false); - const userRef = useRef(user); + const [activity, setActivity] = useState(false); + const [user, setUser] = useState(p.user); + const [accountStatement, setAccountStatement] = useState(); + const [isStudent, setIsStudent] = useState(); + const [editFeatureFlags, setEditFeatureFlags] = useState(false); + const [editRoles, setEditRoles] = useState(false); + const userRef = useRef(user); - const isProfessionalOpenSource = accountStatement && accountStatement.subscriptions.some(s => s.planId === Plans.FREE_OPEN_SOURCE.chargebeeId) + const isProfessionalOpenSource = + accountStatement && accountStatement.subscriptions.some((s) => s.planId === Plans.FREE_OPEN_SOURCE.chargebeeId); - useEffect(() => { - setUser(p.user); - getGitpodService().server.adminGetAccountStatement(p.user.id).then( - as => - setAccountStatement(as) - ).catch(e => { - console.error(e); - }); - getGitpodService().server.adminIsStudent(p.user.id).then( - isStud => setIsStudent(isStud) - ); - }, [p.user]); + useEffect(() => { + setUser(p.user); + getGitpodService() + .server.adminGetAccountStatement(p.user.id) + .then((as) => setAccountStatement(as)) + .catch((e) => { + console.error(e); + }); + getGitpodService() + .server.adminIsStudent(p.user.id) + .then((isStud) => setIsStudent(isStud)); + }, [p.user]); - const email = User.getPrimaryEmail(p.user); - const emailDomain = email.split('@')[ email.split('@').length - 1]; + const email = User.getPrimaryEmail(p.user); + const emailDomain = email.split('@')[email.split('@').length - 1]; - const updateUser: UpdateUserFunction = async fun => { - setActivity(true); - try { - setUser(await fun(userRef.current)); - } finally { - setActivity(false); - } - }; + const updateUser: UpdateUserFunction = async (fun) => { + setActivity(true); + try { + setUser(await fun(userRef.current)); + } finally { + setActivity(false); + } + }; - const addStudentDomain = async () => { - await updateUser(async u => { - await getGitpodService().server.adminAddStudentEmailDomain(u.id, emailDomain); - await getGitpodService().server.adminIsStudent(u.id).then( - isStud => setIsStudent(isStud) - ); - return u; - }); - }; + const addStudentDomain = async () => { + await updateUser(async (u) => { + await getGitpodService().server.adminAddStudentEmailDomain(u.id, emailDomain); + await getGitpodService() + .server.adminIsStudent(u.id) + .then((isStud) => setIsStudent(isStud)); + return u; + }); + }; - const toggleBlockUser = async () => { - await updateUser(async u => { - u.blocked = !u.blocked; - await getGitpodService().server.adminBlockUser({ - blocked: u.blocked, - id: u.id - }); - return u; - }); - } + const toggleBlockUser = async () => { + await updateUser(async (u) => { + u.blocked = !u.blocked; + await getGitpodService().server.adminBlockUser({ + blocked: u.blocked, + id: u.id, + }); + return u; + }); + }; - const deleteUser = async () => { - await updateUser(async u => { - u.markedDeleted = !u.markedDeleted; - await getGitpodService().server.adminDeleteUser(u.id); - return u; - }); - } + const deleteUser = async () => { + await updateUser(async (u) => { + u.markedDeleted = !u.markedDeleted; + await getGitpodService().server.adminDeleteUser(u.id); + return u; + }); + }; - const flags = getFlags(user, updateUser); - const rop = getRopEntries(user, updateUser); + const flags = getFlags(user, updateUser); + const rop = getRopEntries(user, updateUser); - const downloadAccountStatement = async () => { - if (!accountStatement) { - return; - } - try { - const blob = new Blob([JSON.stringify(accountStatement)], { type: 'application/json' }); - const fileDownloadUrl = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = fileDownloadUrl; - link.setAttribute('download', 'AccountStatement.json'); - document.body.appendChild(link); - link.click(); - } catch (error) { - console.error(`Error downloading account statement `, error); - } + const downloadAccountStatement = async () => { + if (!accountStatement) { + return; + } + try { + const blob = new Blob([JSON.stringify(accountStatement)], { type: 'application/json' }); + const fileDownloadUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = fileDownloadUrl; + link.setAttribute('download', 'AccountStatement.json'); + document.body.appendChild(link); + link.click(); + } catch (error) { + console.error(`Error downloading account statement `, error); } + }; - return <> - + return ( + <> + +
+
-
-

{user.fullName}

{user.blocked ?
-

{user.identities.map(i => i.primaryEmail).filter(e => !!e).join(', ')}

-
- - +

{user.fullName}

+ {user.blocked ?
-
-
- {user.fullName} -
-
-
- {moment(user.creationDate).format('MMM D, YYYY')} - downloadAccountStatement() - }, { - label: 'Grant 20 Extra Hours', - onClick: async () => { - await getGitpodService().server.adminGrantExtraHours(user.id, 20); - setAccountStatement(await getGitpodService().server.adminGetAccountStatement(user.id)); - } - }] - } - >{accountStatement?.remainingHours ? accountStatement?.remainingHours.toString() : '---'} - { - await getGitpodService().server.adminSetProfessionalOpenSource(user.id, !isProfessionalOpenSource); - setAccountStatement(await getGitpodService().server.adminGetAccountStatement(user.id)); - } - }]} - >{accountStatement?.subscriptions ? accountStatement.subscriptions.filter(s => Subscription.isActive(s, new Date().toISOString())).map(s => Plans.getById(s.planId)?.name).join(', ') : '---'} -
-
- { - setEditFeatureFlags(true); - } - }]} - >{user.featureFlags?.permanentWSFeatureFlags?.join(', ') || '---'} - { - setEditRoles(true); - } - }]} - >{user.rolesOrPermissions?.join(', ') || '---'} - {isStudent === undefined ? '---' : (isStudent ? 'Enabled' : 'Disabled')} -
-
-
- - - setEditFeatureFlags(false)} title="Edit Feature Flags" buttons={[ - - ]}> -

Edit feature access by adding or removing feature flags for this user.

-
- { - flags.map(e => ) +

+ {user.identities + .map((i) => i.primaryEmail) + .filter((e) => !!e) + .join(', ')} +

+
+ + +
+
+
+ {user.fullName} +
+
+
+ {moment(user.creationDate).format('MMM D, YYYY')} + downloadAccountStatement(), + }, + { + label: 'Grant 20 Extra Hours', + onClick: async () => { + await getGitpodService().server.adminGrantExtraHours(user.id, 20); + setAccountStatement(await getGitpodService().server.adminGetAccountStatement(user.id)); + }, + }, + ] + } + > + {accountStatement?.remainingHours ? accountStatement?.remainingHours.toString() : '---'} + + { + await getGitpodService().server.adminSetProfessionalOpenSource( + user.id, + !isProfessionalOpenSource, + ); + setAccountStatement(await getGitpodService().server.adminGetAccountStatement(user.id)); + }, + }, + ] } + > + {accountStatement?.subscriptions + ? accountStatement.subscriptions + .filter((s) => Subscription.isActive(s, new Date().toISOString())) + .map((s) => Plans.getById(s.planId)?.name) + .join(', ') + : '---'} +
- - setEditRoles(false)} title="Edit Roles" buttons={[ - - ]}> -

Edit user permissions by adding or removing roles for this user.

-
- { - rop.map(e => ) +
+ { + setEditFeatureFlags(true); + }, + }, + ]} + > + {user.featureFlags?.permanentWSFeatureFlags?.join(', ') || '---'} + + { + setEditRoles(true); + }, + }, + ]} + > + {user.rolesOrPermissions?.join(', ') || '---'} + + + {isStudent === undefined ? '---' : isStudent ? 'Enabled' : 'Disabled'} +
- - ; +
+
+ + + setEditFeatureFlags(false)} + title="Edit Feature Flags" + buttons={[ + , + ]} + > +

Edit feature access by adding or removing feature flags for this user.

+
+ {flags.map((e) => ( + + ))} +
+
+ setEditRoles(false)} + title="Edit Roles" + buttons={[ + , + ]} + > +

Edit user permissions by adding or removing roles for this user.

+
+ {rop.map((e) => ( + + ))} +
+
+ + ); } -function Label(p: { text: string, color: string }) { - return
{p.text}
; +function Label(p: { text: string; color: string }) { + return ( +
+ {p.text} +
+ ); } -export function Property(p: { name: string, children: string | ReactChild, actions?: { label: string, onClick: () => void }[] }) { - return
-
- {p.name} -
-
- {p.children} +export function Property(p: { + name: string; + children: string | ReactChild; + actions?: { label: string; onClick: () => void }[]; +}) { + return ( +
+
{p.name}
+
{p.children}
+ {(p.actions || []).map((a) => ( +
+ {a.label || ''}
- {(p.actions || []).map(a => -
- {a.label || ''} -
- )} -
; + ))} +
+ ); } interface Entry { - title: string, - checked: boolean, - onClick: () => void + title: string; + checked: boolean; + onClick: () => void; } type UpdateUserFunction = (fun: (u: User) => Promise) => Promise; function getFlags(user: User, updateUser: UpdateUserFunction): Entry[] { - return Object.entries(WorkspaceFeatureFlags).map(e => e[0] as NamedWorkspaceFeatureFlag).map(name => { - const checked = !!user.featureFlags?.permanentWSFeatureFlags?.includes(name); - return { - title: name, - checked, - onClick: async () => { - await updateUser(async u => { - return await getGitpodService().server.adminModifyPermanentWorkspaceFeatureFlag({ - id: user.id, - changes: [ - { - featureFlag: name, - add: !checked - } - ] - }); - }) - } - }; + return Object.entries(WorkspaceFeatureFlags) + .map((e) => e[0] as NamedWorkspaceFeatureFlag) + .map((name) => { + const checked = !!user.featureFlags?.permanentWSFeatureFlags?.includes(name); + return { + title: name, + checked, + onClick: async () => { + await updateUser(async (u) => { + return await getGitpodService().server.adminModifyPermanentWorkspaceFeatureFlag({ + id: user.id, + changes: [ + { + featureFlag: name, + add: !checked, + }, + ], + }); + }); + }, + }; }); } function getRopEntries(user: User, updateUser: UpdateUserFunction): Entry[] { - const createRopEntry = (name: RoleOrPermission, role?: boolean) => { - const checked = user.rolesOrPermissions?.includes(name)!!; - return { - title: (role ? 'Role: ' : 'Permission: ') + name, - checked, - onClick: async () => { - await updateUser(async u => { - return await getGitpodService().server.adminModifyRoleOrPermission({ - id: user.id, - rpp: [{ - r: name, - add: !checked - }] - }); - }) - } - }; + const createRopEntry = (name: RoleOrPermission, role?: boolean) => { + const checked = user.rolesOrPermissions?.includes(name)!!; + return { + title: (role ? 'Role: ' : 'Permission: ') + name, + checked, + onClick: async () => { + await updateUser(async (u) => { + return await getGitpodService().server.adminModifyRoleOrPermission({ + id: user.id, + rpp: [ + { + r: name, + add: !checked, + }, + ], + }); + }); + }, }; - return [ - ...Object.entries(Permissions).map(e => createRopEntry(e[0] as RoleOrPermission)), - ...Object.entries(Roles).map(e => createRopEntry(e[0] as RoleOrPermission, true)) - ]; -}; + }; + return [ + ...Object.entries(Permissions).map((e) => createRopEntry(e[0] as RoleOrPermission)), + ...Object.entries(Roles).map((e) => createRopEntry(e[0] as RoleOrPermission, true)), + ]; +} diff --git a/components/dashboard/src/admin/UserSearch.tsx b/components/dashboard/src/admin/UserSearch.tsx index 6158f34c98309b..4f4c7934a12b2f 100644 --- a/components/dashboard/src/admin/UserSearch.tsx +++ b/components/dashboard/src/admin/UserSearch.tsx @@ -4,112 +4,145 @@ * See License-AGPL.txt in the project root for license information. */ -import { AdminGetListResult, User } from "@gitpod/gitpod-protocol"; -import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; -import moment from "moment"; -import { useContext, useEffect, useState } from "react"; -import { useLocation } from "react-router"; -import { Link, Redirect } from "react-router-dom"; -import { PageWithSubMenu } from "../components/PageWithSubMenu"; -import { getGitpodService } from "../service/service"; -import { UserContext } from "../user-context"; -import { adminMenu } from "./admin-menu"; -import UserDetail from "./UserDetail"; +import { AdminGetListResult, User } from '@gitpod/gitpod-protocol'; +import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; +import moment from 'moment'; +import { useContext, useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; +import { Link, Redirect } from 'react-router-dom'; +import { PageWithSubMenu } from '../components/PageWithSubMenu'; +import { getGitpodService } from '../service/service'; +import { UserContext } from '../user-context'; +import { adminMenu } from './admin-menu'; +import UserDetail from './UserDetail'; export default function UserSearch() { - const location = useLocation(); - const { user } = useContext(UserContext); - const [searchResult, setSearchResult] = useState>({ rows: [], total: 0 }); - const [searchTerm, setSearchTerm] = useState(''); - const [searching, setSearching] = useState(false); - const [currentUser, setCurrentUserState] = useState(undefined); + const location = useLocation(); + const { user } = useContext(UserContext); + const [searchResult, setSearchResult] = useState>({ rows: [], total: 0 }); + const [searchTerm, setSearchTerm] = useState(''); + const [searching, setSearching] = useState(false); + const [currentUser, setCurrentUserState] = useState(undefined); - useEffect(() => { - const userId = location.pathname.split('/')[3]; - if (userId) { - let user = searchResult.rows.find(u => u.id === userId); - if (user) { - setCurrentUserState(user); - } else { - getGitpodService().server.adminGetUser(userId).then( - user => setCurrentUserState(user) - ).catch(e => console.error(e)); - } - } else { - setCurrentUserState(undefined); - } - }, [location]); - - if (!user || !user?.rolesOrPermissions?.includes('admin')) { - return + useEffect(() => { + const userId = location.pathname.split('/')[3]; + if (userId) { + let user = searchResult.rows.find((u) => u.id === userId); + if (user) { + setCurrentUserState(user); + } else { + getGitpodService() + .server.adminGetUser(userId) + .then((user) => setCurrentUserState(user)) + .catch((e) => console.error(e)); + } + } else { + setCurrentUserState(undefined); } + }, [location]); - if (currentUser) { - return ; - } + if (!user || !user?.rolesOrPermissions?.includes('admin')) { + return ; + } - const search = async () => { - setSearching(true); - try { - const result = await getGitpodService().server.adminGetUsers({ - searchTerm, - limit: 50, - orderBy: 'creationDate', - offset: 0, - orderDir: "desc" - }); - setSearchResult(result); - } finally { - setSearching(false); - } + if (currentUser) { + return ; + } + + const search = async () => { + setSearching(true); + try { + const result = await getGitpodService().server.adminGetUsers({ + searchTerm, + limit: 50, + orderBy: 'creationDate', + offset: 0, + orderDir: 'desc', + }); + setSearchResult(result); + } finally { + setSearching(false); } - return -
-
-
-
- - - -
- ke.key === 'Enter' && search() } onChange={(v) => { setSearchTerm(v.target.value) }} /> -
- + }; + return ( + +
+
+
+
+ + +
+ ke.key === 'Enter' && search()} + onChange={(v) => { + setSearchTerm(v.target.value); + }} + /> +
+
-
-
-
-
Name
-
Created
-
- {searchResult.rows.filter(u => u.identities.length > 0).map(u => )} +
+
+
+
+
Name
+
Created
+ {searchResult.rows + .filter((u) => u.identities.length > 0) + .map((u) => ( + + ))} +
+ ); } function UserEntry(p: { user: User }) { - if (!p) { - return <>; - } - let email = '---'; - try { - email = User.getPrimaryEmail(p.user); - } catch (e) { - log.error(e); - } - return -
-
- {p.user.fullName -
-
-
{p.user.fullName}
-
{email}
-
-
-
{moment(p.user.creationDate).fromNow()}
-
+ if (!p) { + return <>; + } + let email = '---'; + try { + email = User.getPrimaryEmail(p.user); + } catch (e) { + log.error(e); + } + return ( + +
+
+ {p.user.fullName
- ; -} \ No newline at end of file +
+
+ {p.user.fullName} +
+
+ {email} +
+
+
+
{moment(p.user.creationDate).fromNow()}
+
+
+ + ); +} diff --git a/components/dashboard/src/admin/WorkspaceDetail.tsx b/components/dashboard/src/admin/WorkspaceDetail.tsx index 9557372866e983..15fb647d3fc93e 100644 --- a/components/dashboard/src/admin/WorkspaceDetail.tsx +++ b/components/dashboard/src/admin/WorkspaceDetail.tsx @@ -4,96 +4,142 @@ * See License-AGPL.txt in the project root for license information. */ -import { User, WorkspaceAndInstance, ContextURL } from "@gitpod/gitpod-protocol"; +import { User, WorkspaceAndInstance, ContextURL } from '@gitpod/gitpod-protocol'; import { GitpodHostUrl } from '@gitpod/gitpod-protocol/lib/util/gitpod-host-url'; -import moment from "moment"; -import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; -import { getGitpodService } from "../service/service"; -import { getProject, WorkspaceStatusIndicator } from "../workspaces/WorkspaceEntry"; -import { getAdminLinks } from "./gcp-info"; -import { Property } from "./UserDetail"; +import moment from 'moment'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { getGitpodService } from '../service/service'; +import { getProject, WorkspaceStatusIndicator } from '../workspaces/WorkspaceEntry'; +import { getAdminLinks } from './gcp-info'; +import { Property } from './UserDetail'; export default function WorkspaceDetail(props: { workspace: WorkspaceAndInstance }) { - const [workspace, setWorkspace] = useState(props.workspace); - const [activity, setActivity] = useState(false); - const [user, setUser] = useState(); - useEffect(() => { - getGitpodService().server.adminGetUser(props.workspace.ownerId).then(setUser); - }, [props.workspace]); + const [workspace, setWorkspace] = useState(props.workspace); + const [activity, setActivity] = useState(false); + const [user, setUser] = useState(); + useEffect(() => { + getGitpodService().server.adminGetUser(props.workspace.ownerId).then(setUser); + }, [props.workspace]); - const stopWorkspace = async () => { - try { - setActivity(true); - await getGitpodService().server.adminForceStopWorkspace(workspace.workspaceId); - // let's reload in a sec - setTimeout(reload, 2000); - } finally { - setActivity(false); - } + const stopWorkspace = async () => { + try { + setActivity(true); + await getGitpodService().server.adminForceStopWorkspace(workspace.workspaceId); + // let's reload in a sec + setTimeout(reload, 2000); + } finally { + setActivity(false); } + }; - const reload = async () => { - try { - setActivity(true); - const ws = await getGitpodService().server.adminGetWorkspace(workspace.workspaceId); - setWorkspace(ws); - } finally { - setActivity(false); - } + const reload = async () => { + try { + setActivity(true); + const ws = await getGitpodService().server.adminGetWorkspace(workspace.workspaceId); + setWorkspace(ws); + } finally { + setActivity(false); } + }; - const adminLinks = getAdminLinks(workspace); - const adminLink = (i: number) => {adminLinks[i]?.title || ''}; - return <> -
-
-

{workspace.workspaceId}

-

{getProject(WorkspaceAndInstance.toWorkspace(workspace))}

-
- - + const adminLinks = getAdminLinks(workspace); + const adminLink = (i: number) => ( + + + {adminLinks[i]?.title || ''} + + + ); + return ( + <> +
+
+
+

{workspace.workspaceId}

+ + + +
+

{getProject(WorkspaceAndInstance.toWorkspace(workspace))}

-
-
-
- {moment(workspace.workspaceCreationTime).format('MMM D, YYYY')} - {moment(workspace.instanceCreationTime).fromNow()} - {workspace.context.title} -
-
- {user?.name || props.workspace.ownerId} - {workspace.shareable ? 'Enabled' : 'Disabled'} - { + window.location.href = new GitpodHostUrl(window.location.href) + .with({ + pathname: `/workspace-download/get/${workspace.workspaceId}`, + }) + .toString(); + }} + > + Download Workspace + + +
+
+
+
+ {moment(workspace.workspaceCreationTime).format('MMM D, YYYY')} + {moment(workspace.instanceCreationTime).fromNow()} + + + {workspace.context.title} + + +
+
+ + + {user?.name || props.workspace.ownerId} + + + {workspace.shareable ? 'Enabled' : 'Disabled'} + { - await getGitpodService().server.adminRestoreSoftDeletedWorkspace(workspace.workspaceId); - await reload(); - } - }] : undefined}>{workspace.softDeleted ? `'${workspace.softDeleted}' ${moment(workspace.softDeletedTime).fromNow()}` : 'No'} -
-
- -
{workspace.instanceId}
-
- {workspace.region} - {workspace.stoppedTime ? moment(workspace.stoppedTime).fromNow() : '---'} -
-
- { - [0,1,2].map(adminLink) - } -
-
- { - [3,4,5].map(adminLink) - } -
-
+ await getGitpodService().server.adminRestoreSoftDeletedWorkspace(workspace.workspaceId); + await reload(); + }, + }, + ] + : undefined + } + > + {workspace.softDeleted + ? `'${workspace.softDeleted}' ${moment(workspace.softDeletedTime).fromNow()}` + : 'No'} + +
+
+ +
{workspace.instanceId}
+
+ {workspace.region} + + {workspace.stoppedTime ? moment(workspace.stoppedTime).fromNow() : '---'} + +
+
{[0, 1, 2].map(adminLink)}
+
{[3, 4, 5].map(adminLink)}
- ; +
+ + ); } diff --git a/components/dashboard/src/admin/WorkspacesSearch.tsx b/components/dashboard/src/admin/WorkspacesSearch.tsx index ebf4ba734287a2..c83d7906b80994 100644 --- a/components/dashboard/src/admin/WorkspacesSearch.tsx +++ b/components/dashboard/src/admin/WorkspacesSearch.tsx @@ -4,140 +4,182 @@ * See License-AGPL.txt in the project root for license information. */ -import { AdminGetListResult, AdminGetWorkspacesQuery, User, WorkspaceAndInstance } from "@gitpod/gitpod-protocol"; -import { matchesInstanceIdOrLegacyWorkspaceIdExactly, matchesNewWorkspaceIdExactly } from "@gitpod/gitpod-protocol/lib/util/parse-workspace-id"; -import moment from "moment"; -import { useContext, useEffect, useState } from "react"; -import { useLocation } from "react-router"; -import { Link, Redirect } from "react-router-dom"; -import { PageWithSubMenu } from "../components/PageWithSubMenu"; -import { getGitpodService } from "../service/service"; -import { UserContext } from "../user-context"; -import { getProject, WorkspaceStatusIndicator } from "../workspaces/WorkspaceEntry"; -import { adminMenu } from "./admin-menu"; -import WorkspaceDetail from "./WorkspaceDetail"; +import { AdminGetListResult, AdminGetWorkspacesQuery, User, WorkspaceAndInstance } from '@gitpod/gitpod-protocol'; +import { + matchesInstanceIdOrLegacyWorkspaceIdExactly, + matchesNewWorkspaceIdExactly, +} from '@gitpod/gitpod-protocol/lib/util/parse-workspace-id'; +import moment from 'moment'; +import { useContext, useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; +import { Link, Redirect } from 'react-router-dom'; +import { PageWithSubMenu } from '../components/PageWithSubMenu'; +import { getGitpodService } from '../service/service'; +import { UserContext } from '../user-context'; +import { getProject, WorkspaceStatusIndicator } from '../workspaces/WorkspaceEntry'; +import { adminMenu } from './admin-menu'; +import WorkspaceDetail from './WorkspaceDetail'; interface Props { - user?: User + user?: User; } export default function WorkspaceSearchPage() { - return - - ; + return ( + + + + ); } export function WorkspaceSearch(props: Props) { - const location = useLocation(); - const { user } = useContext(UserContext); - const [searchResult, setSearchResult] = useState>({ rows: [], total: 0 }); - const [queryTerm, setQueryTerm] = useState(''); - const [searching, setSearching] = useState(false); - const [currentWorkspace, setCurrentWorkspaceState] = useState(undefined); + const location = useLocation(); + const { user } = useContext(UserContext); + const [searchResult, setSearchResult] = useState>({ rows: [], total: 0 }); + const [queryTerm, setQueryTerm] = useState(''); + const [searching, setSearching] = useState(false); + const [currentWorkspace, setCurrentWorkspaceState] = useState(undefined); - useEffect(() => { - const workspaceId = location.pathname.split('/')[3]; - if (workspaceId) { - let user = searchResult.rows.find(ws => ws.workspaceId === workspaceId); - if (user) { - setCurrentWorkspaceState(user); - } else { - getGitpodService().server.adminGetWorkspace(workspaceId).then( - ws => setCurrentWorkspaceState(ws) - ).catch(e => console.error(e)); - } - } else { - setCurrentWorkspaceState(undefined); - } - }, [location]); - - useEffect(() => { - if (props.user) { - search(); - } - }, [props.user]); - - if (!user || !user?.rolesOrPermissions?.includes('admin')) { - return + useEffect(() => { + const workspaceId = location.pathname.split('/')[3]; + if (workspaceId) { + let user = searchResult.rows.find((ws) => ws.workspaceId === workspaceId); + if (user) { + setCurrentWorkspaceState(user); + } else { + getGitpodService() + .server.adminGetWorkspace(workspaceId) + .then((ws) => setCurrentWorkspaceState(ws)) + .catch((e) => console.error(e)); + } + } else { + setCurrentWorkspaceState(undefined); } + }, [location]); - if (currentWorkspace) { - return ; + useEffect(() => { + if (props.user) { + search(); } + }, [props.user]); + + if (!user || !user?.rolesOrPermissions?.includes('admin')) { + return ; + } - const search = async () => { - setSearching(true); - try { - let searchTerm: string | undefined = queryTerm; - const query: AdminGetWorkspacesQuery = { - ownerId: props?.user?.id, - }; - if (matchesInstanceIdOrLegacyWorkspaceIdExactly(searchTerm)) { - query.instanceIdOrWorkspaceId = searchTerm; - } else if (matchesNewWorkspaceIdExactly(searchTerm)) { - query.workspaceId = searchTerm; - } - if (query.workspaceId || query.instanceId || query.instanceIdOrWorkspaceId) { - searchTerm = undefined; - } + if (currentWorkspace) { + return ; + } - // const searchTerm = searchTerm; - const result = await getGitpodService().server.adminGetWorkspaces({ - limit: 100, - orderBy: 'instanceCreationTime', - offset: 0, - orderDir: "desc", - ...query, - searchTerm, - }); - setSearchResult(result); - } finally { - setSearching(false); - } + const search = async () => { + setSearching(true); + try { + let searchTerm: string | undefined = queryTerm; + const query: AdminGetWorkspacesQuery = { + ownerId: props?.user?.id, + }; + if (matchesInstanceIdOrLegacyWorkspaceIdExactly(searchTerm)) { + query.instanceIdOrWorkspaceId = searchTerm; + } else if (matchesNewWorkspaceIdExactly(searchTerm)) { + query.workspaceId = searchTerm; + } + if (query.workspaceId || query.instanceId || query.instanceIdOrWorkspaceId) { + searchTerm = undefined; + } + + // const searchTerm = searchTerm; + const result = await getGitpodService().server.adminGetWorkspaces({ + limit: 100, + orderBy: 'instanceCreationTime', + offset: 0, + orderDir: 'desc', + ...query, + searchTerm, + }); + setSearchResult(result); + } finally { + setSearching(false); } - return <> -
-
-
-
- - - -
- ke.key === 'Enter' && search() } onChange={(v) => { setQueryTerm(v.target.value) }} /> -
- + }; + return ( + <> +
+
+
+
+ + +
+ ke.key === 'Enter' && search()} + onChange={(v) => { + setQueryTerm(v.target.value); + }} + /> +
+
-
-
-
-
Name
-
Context
-
Last Started
-
- {searchResult.rows.map(ws => )} +
+
+
+
+
Name
+
Context
+
Last Started
+ {searchResult.rows.map((ws) => ( + + ))} +
+ ); } function WorkspaceEntry(p: { ws: WorkspaceAndInstance }) { - return -
-
- -
-
-
{p.ws.workspaceId}
-
{getProject(WorkspaceAndInstance.toWorkspace(p.ws))}
-
-
-
{p.ws.description}
-
{p.ws.contextURL}
-
-
-
{moment(p.ws.instanceCreationTime || p.ws.workspaceCreationTime).fromNow()}
-
+ return ( + +
+
+ +
+
+
+ {p.ws.workspaceId} +
+
+ {getProject(WorkspaceAndInstance.toWorkspace(p.ws))} +
+
+
+
{p.ws.description}
+
{p.ws.contextURL}
+
+
+
+ {moment(p.ws.instanceCreationTime || p.ws.workspaceCreationTime).fromNow()} +
- ; +
+ + ); } diff --git a/components/dashboard/src/admin/admin-menu.ts b/components/dashboard/src/admin/admin-menu.ts index c6d4f6f2ccf00f..5807f2e7852c89 100644 --- a/components/dashboard/src/admin/admin-menu.ts +++ b/components/dashboard/src/admin/admin-menu.ts @@ -4,10 +4,13 @@ * See License-AGPL.txt in the project root for license information. */ -export const adminMenu = [{ +export const adminMenu = [ + { title: 'Users', - link: ['/admin/users', '/admin'] -}, { + link: ['/admin/users', '/admin'], + }, + { title: 'Workspaces', - link: ['/admin/workspaces'] -},]; \ No newline at end of file + link: ['/admin/workspaces'], + }, +]; diff --git a/components/dashboard/src/admin/gcp-info.ts b/components/dashboard/src/admin/gcp-info.ts index 85209a53bd5bfe..0f3022367737b0 100644 --- a/components/dashboard/src/admin/gcp-info.ts +++ b/components/dashboard/src/admin/gcp-info.ts @@ -4,131 +4,128 @@ * See License-AGPL.txt in the project root for license information. */ -import { WorkspaceAndInstance } from "@gitpod/gitpod-protocol"; -import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; +import { WorkspaceAndInstance } from '@gitpod/gitpod-protocol'; +import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; export interface Link { - readonly name: string; - readonly title: string; - readonly url: string; + readonly name: string; + readonly title: string; + readonly url: string; } export function getAdminLinks(workspace: WorkspaceAndInstance): Link[] { - let gcpInfo; - try { - gcpInfo = deriveGcpInfo(workspace.ideUrl, workspace.region); - } catch (e) { - log.error(e); - } - if (gcpInfo === undefined) { - return []; - } - const { baseDomain, gcp } = gcpInfo; + let gcpInfo; + try { + gcpInfo = deriveGcpInfo(workspace.ideUrl, workspace.region); + } catch (e) { + log.error(e); + } + if (gcpInfo === undefined) { + return []; + } + const { baseDomain, gcp } = gcpInfo; - return internalGetAdminLinks(gcp, baseDomain, workspace.status.podName, workspace.status.nodeName); + return internalGetAdminLinks(gcp, baseDomain, workspace.status.podName, workspace.status.nodeName); } -function internalGetAdminLinks(gcpInfo: GcpInfo, - baseDomain: string, - podName?: string, - nodeName?: string): Link[] { - const {clusterName, namespace, projectName, region} = gcpInfo; - return [ - { - name: "GKE Pod", - title: `${podName}`, - url: `https://console.cloud.google.com/kubernetes/pod/${region}/${clusterName}/${namespace}/${podName}/details?project=${projectName}` - }, - { - name: `GKE Node`, - title: `${nodeName}`, - url: `https://console.cloud.google.com/kubernetes/node/${region}/${clusterName}/${nodeName}/summary?project=${projectName}` - }, - { - name: `Workspace Pod Logs`, - title: `See Logs`, - url: `https://console.cloud.google.com/logs/query;query=resource.type%3D%22k8s_container%22%0Aresource.labels.project_id%3D%22${projectName}%22%0Aresource.labels.location%3D%22${region}%22%0Aresource.labels.cluster_name%3D%22${clusterName}%22%0Aresource.labels.namespace_name%3D%22${namespace}%22%0Aresource.labels.pod_name%3D%22${podName}%22?project=${projectName}` - }, - { - name: `Grafana Workspace`, - title: `Pod Metrics`, - url: `https://monitoring.${baseDomain}/d/admin-workspace/admin-workspace?var-workspace=${podName}` - }, - { - name: `Grafana Node`, - title: `Node Metrics`, - url: `https://monitoring.${baseDomain}/d/admin-node/admin-node?var-node=${nodeName}` - }, - ]; +function internalGetAdminLinks(gcpInfo: GcpInfo, baseDomain: string, podName?: string, nodeName?: string): Link[] { + const { clusterName, namespace, projectName, region } = gcpInfo; + return [ + { + name: 'GKE Pod', + title: `${podName}`, + url: `https://console.cloud.google.com/kubernetes/pod/${region}/${clusterName}/${namespace}/${podName}/details?project=${projectName}`, + }, + { + name: `GKE Node`, + title: `${nodeName}`, + url: `https://console.cloud.google.com/kubernetes/node/${region}/${clusterName}/${nodeName}/summary?project=${projectName}`, + }, + { + name: `Workspace Pod Logs`, + title: `See Logs`, + url: `https://console.cloud.google.com/logs/query;query=resource.type%3D%22k8s_container%22%0Aresource.labels.project_id%3D%22${projectName}%22%0Aresource.labels.location%3D%22${region}%22%0Aresource.labels.cluster_name%3D%22${clusterName}%22%0Aresource.labels.namespace_name%3D%22${namespace}%22%0Aresource.labels.pod_name%3D%22${podName}%22?project=${projectName}`, + }, + { + name: `Grafana Workspace`, + title: `Pod Metrics`, + url: `https://monitoring.${baseDomain}/d/admin-workspace/admin-workspace?var-workspace=${podName}`, + }, + { + name: `Grafana Node`, + title: `Node Metrics`, + url: `https://monitoring.${baseDomain}/d/admin-node/admin-node?var-node=${nodeName}`, + }, + ]; } -function deriveGcpInfo(ideUrlStr: string, region: string): { gcp: GcpInfo, baseDomain: string } | undefined { - const ideUrl = new URL(ideUrlStr); - const hostnameParts = ideUrl.hostname.split(".") - const baseDomain = hostnameParts.slice(hostnameParts.length - 2).join("."); - const namespace = hostnameParts[hostnameParts.length - 4]; +function deriveGcpInfo(ideUrlStr: string, region: string): { gcp: GcpInfo; baseDomain: string } | undefined { + const ideUrl = new URL(ideUrlStr); + const hostnameParts = ideUrl.hostname.split('.'); + const baseDomain = hostnameParts.slice(hostnameParts.length - 2).join('.'); + const namespace = hostnameParts[hostnameParts.length - 4]; - const gcp = getGcpInfo(baseDomain, region, namespace); - if (!gcp) { - return undefined; - } - return { - gcp, - baseDomain, - } + const gcp = getGcpInfo(baseDomain, region, namespace); + if (!gcp) { + return undefined; + } + return { + gcp, + baseDomain, + }; } function getGcpInfo(baseDomain: string, regionShort: string, namespace?: string): GcpInfo | undefined { - if (baseDomain === "gitpod.io") { - if (regionShort === "eu03") { - return { - clusterName: "prod--gitpod-io--europe-west1--03", - namespace: 'default', - region: "europe-west1", - projectName: "gitpod-191109", - }; - } - if (regionShort === "us03") { - return { - clusterName: "prod--gitpod-io--us-west1--03", - namespace: 'default', - region: "us-west1", - projectName: "gitpod-191109", - }; - } + if (baseDomain === 'gitpod.io') { + if (regionShort === 'eu03') { + return { + clusterName: 'prod--gitpod-io--europe-west1--03', + namespace: 'default', + region: 'europe-west1', + projectName: 'gitpod-191109', + }; } - if (baseDomain === "gitpod-staging.com") { - if (regionShort === "eu02") { - return { - clusterName: "staging--gitpod-io--eu-west1--02", - namespace: 'default', - region: "europe-west1", - projectName: "gitpod-staging", - }; - } - if (regionShort === "us02") { - return { - clusterName: "staging--gitpod-io--us-west1--02", - namespace: 'default', - region: "us-west1", - projectName: "gitpod-staging", - }; - } + if (regionShort === 'us03') { + return { + clusterName: 'prod--gitpod-io--us-west1--03', + namespace: 'default', + region: 'us-west1', + projectName: 'gitpod-191109', + }; } - if (baseDomain === "gitpod-dev.com") { - return { - clusterName: "dev", - namespace: 'staging-' + namespace, - region: "europe-west1-b", - projectName: "gitpod-core-dev", - }; + } + if (baseDomain === 'gitpod-staging.com') { + if (regionShort === 'eu02') { + return { + clusterName: 'staging--gitpod-io--eu-west1--02', + namespace: 'default', + region: 'europe-west1', + projectName: 'gitpod-staging', + }; } - return undefined; + if (regionShort === 'us02') { + return { + clusterName: 'staging--gitpod-io--us-west1--02', + namespace: 'default', + region: 'us-west1', + projectName: 'gitpod-staging', + }; + } + } + if (baseDomain === 'gitpod-dev.com') { + return { + clusterName: 'dev', + namespace: 'staging-' + namespace, + region: 'europe-west1-b', + projectName: 'gitpod-core-dev', + }; + } + return undefined; } interface GcpInfo { - readonly clusterName: string; - readonly region: string; - readonly projectName: string; - readonly namespace: string; -} \ No newline at end of file + readonly clusterName: string; + readonly region: string; + readonly projectName: string; + readonly namespace: string; +} diff --git a/components/dashboard/src/chargebee/chargebee-client.ts b/components/dashboard/src/chargebee/chargebee-client.ts index 355d74a8b3e27d..da48fb476f6101 100644 --- a/components/dashboard/src/chargebee/chargebee-client.ts +++ b/components/dashboard/src/chargebee/chargebee-client.ts @@ -10,81 +10,89 @@ import { GitpodServer } from '@gitpod/gitpod-protocol'; import { getGitpodService } from '../service/service'; class ChargebeeClientProvider { - protected static client: chargebee.Client; + protected static client: chargebee.Client; - static async get() { - if (!this.client) { - await new Promise((resolve) => { - var scriptTag = document.createElement('script'); - scriptTag.src = "https://js.chargebee.com/v2/chargebee.js"; - scriptTag.async = true; - scriptTag.addEventListener("load", () => { - resolve(); - }); - document.head.appendChild(scriptTag); - }) - const site = await getGitpodService().server.getChargebeeSiteId(); - this.client = (((window as any).Chargebee) as chargebee.Client).init({ - site - }); - } - return this.client; + static async get() { + if (!this.client) { + await new Promise((resolve) => { + var scriptTag = document.createElement('script'); + scriptTag.src = 'https://js.chargebee.com/v2/chargebee.js'; + scriptTag.async = true; + scriptTag.addEventListener('load', () => { + resolve(); + }); + document.head.appendChild(scriptTag); + }); + const site = await getGitpodService().server.getChargebeeSiteId(); + this.client = ((window as any).Chargebee as chargebee.Client).init({ + site, + }); } + return this.client; + } } export interface OpenPortalParams { - loaded?: () => void; - close?: () => void; - visit?: (sectionName: string) => void; + loaded?: () => void; + close?: () => void; + visit?: (sectionName: string) => void; } // https://www.chargebee.com/checkout-portal-docs/api.html export class ChargebeeClient { - constructor(protected readonly client: chargebee.Client) {} + constructor(protected readonly client: chargebee.Client) {} - static async getOrCreate(): Promise { - const create = async () => { - const chargebeeClient = await ChargebeeClientProvider.get(); - const client = new ChargebeeClient(chargebeeClient); - client.createPortalSession(); - return client; - }; + static async getOrCreate(): Promise { + const create = async () => { + const chargebeeClient = await ChargebeeClientProvider.get(); + const client = new ChargebeeClient(chargebeeClient); + client.createPortalSession(); + return client; + }; - const w = window as any; - const _gp = w._gp || (w._gp = {}); - const chargebeeClient = _gp.chargebeeClient || (_gp.chargebeeClient = await create()); - return chargebeeClient; - } + const w = window as any; + const _gp = w._gp || (w._gp = {}); + const chargebeeClient = _gp.chargebeeClient || (_gp.chargebeeClient = await create()); + return chargebeeClient; + } - checkout(hostedPage: (paymentServer: GitpodServer) => Promise<{}>, params: Omit = { success: noOp }) { - const paymentServer = getGitpodService().server; - this.client.openCheckout({ - ...params, - async hostedPage(): Promise { - return hostedPage(paymentServer); - } - }); - } + checkout( + hostedPage: (paymentServer: GitpodServer) => Promise<{}>, + params: Omit = { success: noOp }, + ) { + const paymentServer = getGitpodService().server; + this.client.openCheckout({ + ...params, + async hostedPage(): Promise { + return hostedPage(paymentServer); + }, + }); + } - checkoutExisting(hostedPage: (paymentServer: GitpodServer) => Promise<{}>, params: Omit = { success: noOp }) { - const paymentServer = getGitpodService().server; - this.client.openCheckout({ - ...params, - async hostedPage(): Promise { - return hostedPage(paymentServer); - } - }); - } + checkoutExisting( + hostedPage: (paymentServer: GitpodServer) => Promise<{}>, + params: Omit = { success: noOp }, + ) { + const paymentServer = getGitpodService().server; + this.client.openCheckout({ + ...params, + async hostedPage(): Promise { + return hostedPage(paymentServer); + }, + }); + } - createPortalSession() { - const paymentServer = getGitpodService().server; - this.client.setPortalSession(async () => { - return paymentServer.createPortalSession(); - }); - } + createPortalSession() { + const paymentServer = getGitpodService().server; + this.client.setPortalSession(async () => { + return paymentServer.createPortalSession(); + }); + } - openPortal(params: OpenPortalParams = {}) { - this.client.createChargebeePortal().open(params); - } + openPortal(params: OpenPortalParams = {}) { + this.client.createChargebeePortal().open(params); + } } -const noOp = () => { /* tslint:disable:no-empty */ }; \ No newline at end of file +const noOp = () => { + /* tslint:disable:no-empty */ +}; diff --git a/components/dashboard/src/chargebee/chargebee.d.ts b/components/dashboard/src/chargebee/chargebee.d.ts index bf1710b264a0dd..cfd44da35631cc 100644 --- a/components/dashboard/src/chargebee/chargebee.d.ts +++ b/components/dashboard/src/chargebee/chargebee.d.ts @@ -5,28 +5,25 @@ */ declare module 'chargebee' { + export interface Client { + init(options: object): Client; + openCheckout(callbacks: CheckoutCallbacks); + setPortalSession(callback: () => Promise); + createChargebeePortal(): Portal; + } - export interface Client { - init(options: object): Client; - openCheckout(callbacks: CheckoutCallbacks); - setPortalSession(callback: () => Promise); - createChargebeePortal(): Portal; - } + export interface CheckoutCallbacks { + hostedPage(): Promise; + success(hostedPageId: string); + loaded?: () => void; + error?: (error: any) => void; + step?: (step: string) => void; + close?: () => void; + } - export interface CheckoutCallbacks { - hostedPage(): Promise; - success(hostedPageId: string); - loaded?: () => void; - error?: (error: any) => void; - step?: (step: string) => void; - close?: () => void; - } - - export interface OpenCheckoutParams { + export interface OpenCheckoutParams {} + export interface Portal { + open(callbacks: object); + } } - - export interface Portal { - open(callbacks: object); - } -} \ No newline at end of file diff --git a/components/dashboard/src/components/AlertBox.tsx b/components/dashboard/src/components/AlertBox.tsx index f8bc57d870f50f..431e55399d939b 100644 --- a/components/dashboard/src/components/AlertBox.tsx +++ b/components/dashboard/src/components/AlertBox.tsx @@ -6,9 +6,11 @@ import exclamation from '../images/exclamation.svg'; -export default function AlertBox(p: { className?: string, children?: React.ReactNode }) { - return
- - {p.children} -
; +export default function AlertBox(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ + {p.children} +
+ ); } diff --git a/components/dashboard/src/components/Arrow.tsx b/components/dashboard/src/components/Arrow.tsx index b158d88422fe34..ebc9c7987bc976 100644 --- a/components/dashboard/src/components/Arrow.tsx +++ b/components/dashboard/src/components/Arrow.tsx @@ -5,7 +5,19 @@ */ function Arrow(props: { up: boolean }) { - return + return ( + + ); } export default Arrow; diff --git a/components/dashboard/src/components/CheckBox.tsx b/components/dashboard/src/components/CheckBox.tsx index d16f2ea7cab92b..a54f54adbc2f71 100644 --- a/components/dashboard/src/components/CheckBox.tsx +++ b/components/dashboard/src/components/CheckBox.tsx @@ -4,36 +4,47 @@ * See License-AGPL.txt in the project root for license information. */ - function CheckBox(props: { - name?: string, - title: string | React.ReactNode, - desc: string | React.ReactNode, - checked: boolean, - disabled?: boolean, - onChange?: (e: React.ChangeEvent) => void + name?: string; + title: string | React.ReactNode; + desc: string | React.ReactNode; + checked: boolean; + disabled?: boolean; + onChange?: (e: React.ChangeEvent) => void; }) { - const inputProps: React.InputHTMLAttributes = { - checked: props.checked, - disabled: props.disabled, - onChange: props.onChange, - }; - if (props.name) { - inputProps.name = props.name; - } + const inputProps: React.InputHTMLAttributes = { + checked: props.checked, + disabled: props.disabled, + onChange: props.onChange, + }; + if (props.name) { + inputProps.name = props.name; + } - const checkboxId = `checkbox-${props.title}-${String(Math.random())}`; + const checkboxId = `checkbox-${props.title}-${String(Math.random())}`; - return
- -
- -
{props.desc}
-
+ return ( +
+ +
+ +
{props.desc}
+
+ ); } -export default CheckBox; \ No newline at end of file +export default CheckBox; diff --git a/components/dashboard/src/components/CodeText.tsx b/components/dashboard/src/components/CodeText.tsx index a02d0ae0be407c..9959a5942d4d6a 100644 --- a/components/dashboard/src/components/CodeText.tsx +++ b/components/dashboard/src/components/CodeText.tsx @@ -5,5 +5,9 @@ */ export default function CodeText(p: { children?: React.ReactNode }) { - return {p.children}; + return ( + + {p.children} + + ); } diff --git a/components/dashboard/src/components/ConfirmationModal.tsx b/components/dashboard/src/components/ConfirmationModal.tsx index 60ebf9e12157e6..7a8fd227c87ab4 100644 --- a/components/dashboard/src/components/ConfirmationModal.tsx +++ b/components/dashboard/src/components/ConfirmationModal.tsx @@ -4,83 +4,82 @@ * See License-AGPL.txt in the project root for license information. */ -import AlertBox from "./AlertBox"; -import Modal from "./Modal"; -import { useRef, useEffect } from "react"; +import AlertBox from './AlertBox'; +import Modal from './Modal'; +import { useRef, useEffect } from 'react'; export default function ConfirmationModal(props: { - title?: string; - areYouSureText?: string, - children?: Entity | React.ReactChild[] | React.ReactChild - buttonText?: string, - buttonDisabled?: boolean, - visible?: boolean, - warningText?: string, - onClose: () => void, - onConfirm: () => void, + title?: string; + areYouSureText?: string; + children?: Entity | React.ReactChild[] | React.ReactChild; + buttonText?: string; + buttonDisabled?: boolean; + visible?: boolean; + warningText?: string; + onClose: () => void; + onConfirm: () => void; }) { + const child: React.ReactChild[] = [

{props.areYouSureText}

]; - const child: React.ReactChild[] = [ -

{props.areYouSureText}

, - ] + if (props.warningText) { + child.unshift({props.warningText}); + } - if (props.warningText) { - child.unshift({props.warningText}); + const isEntity = (x: any): x is Entity => typeof x === 'object' && 'name' in x; + if (props.children) { + if (isEntity(props.children)) { + child.push( +
+

{props.children.name}

+ {props.children.description &&

{props.children.description}

} +
, + ); + } else if (Array.isArray(props.children)) { + child.push(...props.children); + } else { + child.push(props.children); } + } + const cancelButtonRef = useRef(null); - const isEntity = (x: any): x is Entity => typeof x === "object" && "name" in x; - if (props.children) { - if (isEntity(props.children)) { - child.push( -
-

{props.children.name}

- {props.children.description &&

{props.children.description}

} -
- ) - } else if (Array.isArray(props.children)) { - child.push(...props.children); - } else { - child.push(props.children); - } - } - const cancelButtonRef = useRef(null); - - const buttons = [ - , - , - ] + const buttons = [ + , + , + ]; - const buttonDisabled = useRef(props.buttonDisabled); - useEffect(() => { - buttonDisabled.current = props.buttonDisabled; - }) + const buttonDisabled = useRef(props.buttonDisabled); + useEffect(() => { + buttonDisabled.current = props.buttonDisabled; + }); - return ( - { - if (cancelButtonRef?.current?.contains(document.activeElement)) { - props.onClose(); - return false; - } - if (buttonDisabled.current) { - return false - } - props.onConfirm(); - return true; - }} - > - {child} - - ); + return ( + { + if (cancelButtonRef?.current?.contains(document.activeElement)) { + props.onClose(); + return false; + } + if (buttonDisabled.current) { + return false; + } + props.onConfirm(); + return true; + }} + > + {child} + + ); } export interface Entity { - name: string, - description?: string, + name: string; + description?: string; } diff --git a/components/dashboard/src/components/ContextMenu.tsx b/components/dashboard/src/components/ContextMenu.tsx index 5fe5f9f74cfd6e..bbbab6edb53492 100644 --- a/components/dashboard/src/components/ContextMenu.tsx +++ b/components/dashboard/src/components/ContextMenu.tsx @@ -9,108 +9,150 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; export interface ContextMenuProps { - children?: React.ReactChild[] | React.ReactChild; - menuEntries: ContextMenuEntry[]; - classes?: string; + children?: React.ReactChild[] | React.ReactChild; + menuEntries: ContextMenuEntry[]; + classes?: string; } export interface ContextMenuEntry { - title: string; - active?: boolean; - /** - * whether a separator line should be rendered below this item - */ - separator?: boolean; - customFontStyle?: string; - customContent?: React.ReactChild; - onClick?: (event: React.MouseEvent) => void; - href?: string; - link?: string; - target?: HTMLAttributeAnchorTarget; + title: string; + active?: boolean; + /** + * whether a separator line should be rendered below this item + */ + separator?: boolean; + customFontStyle?: string; + customContent?: React.ReactChild; + onClick?: (event: React.MouseEvent) => void; + href?: string; + link?: string; + target?: HTMLAttributeAnchorTarget; } function ContextMenu(props: ContextMenuProps) { - const [expanded, setExpanded] = useState(false); - const toggleExpanded = () => { - setExpanded(!expanded); - } + const [expanded, setExpanded] = useState(false); + const toggleExpanded = () => { + setExpanded(!expanded); + }; - const handler = (evt: KeyboardEvent) => { - if (evt.key === 'Escape') { - setExpanded(false); - } + const handler = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + setExpanded(false); } + }; - const skipClickHandlerRef = React.useRef(false); - const setSkipClickHandler = (data: boolean) => { - skipClickHandlerRef.current = data; - } - const clickHandler = (evt: MouseEvent) => { - if (skipClickHandlerRef.current) { - // skip only once - setSkipClickHandler(false); - } else { - setExpanded(false); - } + const skipClickHandlerRef = React.useRef(false); + const setSkipClickHandler = (data: boolean) => { + skipClickHandlerRef.current = data; + }; + const clickHandler = (evt: MouseEvent) => { + if (skipClickHandlerRef.current) { + // skip only once + setSkipClickHandler(false); + } else { + setExpanded(false); } + }; - useEffect(() => { - window.addEventListener('keydown', handler); - window.addEventListener('click', clickHandler); - // Remove event listeners on cleanup - return () => { - window.removeEventListener('keydown', handler); - window.removeEventListener('click', clickHandler); - }; - }, []); // Empty array ensures that effect is only run on mount and unmount - + useEffect(() => { + window.addEventListener('keydown', handler); + window.addEventListener('click', clickHandler); + // Remove event listeners on cleanup + return () => { + window.removeEventListener('keydown', handler); + window.removeEventListener('click', clickHandler); + }; + }, []); // Empty array ensures that effect is only run on mount and unmount - const font = "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" + const font = 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100'; - const menuId = String(Math.random()); + const menuId = String(Math.random()); - // Default 'children' is the three dots hamburger button. - const children = props.children || Actions; + // Default 'children' is the three dots hamburger button. + const children = props.children || ( + + Actions + + + + + + + ); - return ( -
-
{ - toggleExpanded(); - // Don't use `e.stopPropagation();` because that prevents that clicks on other context menus closes this one. - setSkipClickHandler(true); - }}> - {children} -
- {expanded ? -
- {props.menuEntries.length === 0 - ?

No actions available

- : props.menuEntries.map((e, index) => { - const clickable = e.href || e.onClick || e.link; - const entry =
- {e.customContent || <>
{e.title}
{e.active ?
: null}} -
- const key = `entry-${menuId}-${index}-${e.title}`; - if (e.link) { - return - {entry} - ; - } else if (e.href) { - return - {entry} - ; - } else { - return
- {entry} -
- } - })} + return ( +
+
{ + toggleExpanded(); + // Don't use `e.stopPropagation();` because that prevents that clicks on other context menus closes this one. + setSkipClickHandler(true); + }} + > + {children} +
+ {expanded ? ( +
+ {props.menuEntries.length === 0 ? ( +

No actions available

+ ) : ( + props.menuEntries.map((e, index) => { + const clickable = e.href || e.onClick || e.link; + const entry = ( +
+ {e.customContent || ( + <> +
{e.title}
+
+ {e.active ?
: null} + + )}
- : - null - } + ); + const key = `entry-${menuId}-${index}-${e.title}`; + if (e.link) { + return ( + + {entry} + + ); + } else if (e.href) { + return ( + + {entry} + + ); + } else { + return ( +
+ {entry} +
+ ); + } + }) + )}
- ); + ) : null} +
+ ); } -export default ContextMenu; \ No newline at end of file +export default ContextMenu; diff --git a/components/dashboard/src/components/DropDown.tsx b/components/dashboard/src/components/DropDown.tsx index 118bafe0fec1fb..46ef703d77835f 100644 --- a/components/dashboard/src/components/DropDown.tsx +++ b/components/dashboard/src/components/DropDown.tsx @@ -9,38 +9,43 @@ import Arrow from './Arrow'; import ContextMenu from './ContextMenu'; export interface DropDownProps { - prefix?: string; - contextMenuWidth?: string; - activeEntry?: string, - entries: DropDownEntry[]; + prefix?: string; + contextMenuWidth?: string; + activeEntry?: string; + entries: DropDownEntry[]; } export interface DropDownEntry { - title: string, - onClick: ()=>void + title: string; + onClick: () => void; } function DropDown(props: DropDownProps) { - const [current, setCurrent] = useState(props.activeEntry || props.entries[0].title); - useEffect(() => { - setCurrent(props.activeEntry || props.entries[0].title); - }, [props.activeEntry, props.entries]); - const enhancedEntries = props.entries.map(e => { - return { - ...e, - active: e.title === current, - onClick: () => { - e.onClick(); - setCurrent(e.title); - } - } - }) - const font = "text-gray-400 dark:text-gray-500 text-sm leading-1 group hover:text-gray-600 dark:hover:text-gray-400 transition ease-in-out" - return ( - - {props.prefix}{current} - - ); + const [current, setCurrent] = useState(props.activeEntry || props.entries[0].title); + useEffect(() => { + setCurrent(props.activeEntry || props.entries[0].title); + }, [props.activeEntry, props.entries]); + const enhancedEntries = props.entries.map((e) => { + return { + ...e, + active: e.title === current, + onClick: () => { + e.onClick(); + setCurrent(e.title); + }, + }; + }); + const font = + 'text-gray-400 dark:text-gray-500 text-sm leading-1 group hover:text-gray-600 dark:hover:text-gray-400 transition ease-in-out'; + return ( + + + {props.prefix} + {current} + + + + ); } -export default DropDown; \ No newline at end of file +export default DropDown; diff --git a/components/dashboard/src/components/Header.tsx b/components/dashboard/src/components/Header.tsx index a98b60baea24a8..1347b8e838000d 100644 --- a/components/dashboard/src/components/Header.tsx +++ b/components/dashboard/src/components/Header.tsx @@ -4,28 +4,30 @@ * See License-AGPL.txt in the project root for license information. */ -import { useEffect } from "react"; -import Separator from "./Separator"; +import { useEffect } from 'react'; +import Separator from './Separator'; export interface HeaderProps { - title: string | React.ReactElement; - subtitle: string | React.ReactElement; + title: string | React.ReactElement; + subtitle: string | React.ReactElement; } export default function Header(p: HeaderProps) { - useEffect(() => { - if (typeof p.title !== "string") { - return; - } - document.title = `${p.title} — Gitpod`; - }, []); - return
-
-
- {typeof p.title === "string" ? (

{p.title}

) : p.title} - {typeof p.subtitle === "string" ? (

{p.subtitle}

) : p.subtitle} -
+ useEffect(() => { + if (typeof p.title !== 'string') { + return; + } + document.title = `${p.title} — Gitpod`; + }, []); + return ( +
+
+
+ {typeof p.title === 'string' ?

{p.title}

: p.title} + {typeof p.subtitle === 'string' ?

{p.subtitle}

: p.subtitle}
- -
; +
+ +
+ ); } diff --git a/components/dashboard/src/components/InfoBox.tsx b/components/dashboard/src/components/InfoBox.tsx index 330f3e32a2475c..d29e78d3e90003 100644 --- a/components/dashboard/src/components/InfoBox.tsx +++ b/components/dashboard/src/components/InfoBox.tsx @@ -6,9 +6,15 @@ import info from '../images/info.svg'; -export default function InfoBox(p: { className?: string, children?: React.ReactNode }) { - return
- - {p.children} -
; +export default function InfoBox(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ + {p.children} +
+ ); } diff --git a/components/dashboard/src/components/ItemsList.tsx b/components/dashboard/src/components/ItemsList.tsx index 8b555e70e71c9c..136e4326783876 100644 --- a/components/dashboard/src/components/ItemsList.tsx +++ b/components/dashboard/src/components/ItemsList.tsx @@ -4,54 +4,42 @@ * See License-AGPL.txt in the project root for license information. */ -import ContextMenu, { ContextMenuEntry } from "./ContextMenu" +import ContextMenu, { ContextMenuEntry } from './ContextMenu'; -export function ItemsList(props: { - children?: React.ReactNode; - className?: string; -}) { - return
- {props.children} -
; +export function ItemsList(props: { children?: React.ReactNode; className?: string }) { + return
{props.children}
; } -export function Item(props: { - children?: React.ReactNode; - className?: string; - header?: boolean; -}) { - const headerClassName = "text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800"; - const notHeaderClassName = "rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light"; - return
- {props.children} -
; -} - -export function ItemField(props: { - children?: React.ReactNode - className?: string -}) { +export function Item(props: { children?: React.ReactNode; className?: string; header?: boolean }) { + const headerClassName = 'text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800'; + const notHeaderClassName = 'rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light'; return ( -
+
{props.children}
- ) + ); +} + +export function ItemField(props: { children?: React.ReactNode; className?: string }) { + return
{props.children}
; } -export function ItemFieldIcon(props: { - children?: React.ReactNode; - className?: string; -}) { - return
- {props.children} -
; +export function ItemFieldIcon(props: { children?: React.ReactNode; className?: string }) { + return
{props.children}
; } -export function ItemFieldContextMenu(props: { - menuEntries: ContextMenuEntry[]; - className?: string; -}) { - return
- -
; +export function ItemFieldContextMenu(props: { menuEntries: ContextMenuEntry[]; className?: string }) { + return ( +
+ +
+ ); } diff --git a/components/dashboard/src/components/Modal.tsx b/components/dashboard/src/components/Modal.tsx index a53f46392cba96..e6783568f062bc 100644 --- a/components/dashboard/src/components/Modal.tsx +++ b/components/dashboard/src/components/Modal.tsx @@ -4,73 +4,83 @@ * See License-AGPL.txt in the project root for license information. */ -import { useEffect } from "react"; +import { useEffect } from 'react'; export default function Modal(props: { - title?: string; - buttons?: React.ReactChild[] | React.ReactChild, - children: React.ReactChild[] | React.ReactChild, - visible: boolean, - closeable?: boolean, - className?: string, - onClose: () => void, - onEnter?: () => boolean + title?: string; + buttons?: React.ReactChild[] | React.ReactChild; + children: React.ReactChild[] | React.ReactChild; + visible: boolean; + closeable?: boolean; + className?: string; + onClose: () => void; + onEnter?: () => boolean; }) { - - const handler = (evt: KeyboardEvent) => { - if (evt.defaultPrevented) { - return; - } - if (evt.key === 'Escape') { - props.onClose(); - } - if (evt.key === 'Enter') { - if (props.onEnter) { - if (props.onEnter()) { - props.onClose(); - } - } else { - props.onClose(); - } + const handler = (evt: KeyboardEvent) => { + if (evt.defaultPrevented) { + return; + } + if (evt.key === 'Escape') { + props.onClose(); + } + if (evt.key === 'Enter') { + if (props.onEnter) { + if (props.onEnter()) { + props.onClose(); } + } else { + props.onClose(); + } } - // Add event listeners - useEffect(() => { - window.addEventListener('keydown', handler); - // Remove event listeners on cleanup - return () => { - window.removeEventListener('keydown', handler); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.onClose, props.onEnter]); + }; + // Add event listeners + useEffect(() => { + window.addEventListener('keydown', handler); + // Remove event listeners on cleanup + return () => { + window.removeEventListener('keydown', handler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.onClose, props.onEnter]); - if (!props.visible) { - return null; - } + if (!props.visible) { + return null; + } - return ( -
-
-
e.stopPropagation()}> - {props.closeable !== false && ( -
- - - - -
- )} - {props.title ? <>

{props.title}

-
- {props.children} -
-
- {props.buttons} -
: - props.children } -
+ return ( +
+
+
e.stopPropagation()} + > + {props.closeable !== false && ( +
+ + + +
+ )} + {props.title ? ( + <> +

{props.title}

+
+ {props.children} +
+
{props.buttons}
+ + ) : ( + props.children + )}
- ); -} \ No newline at end of file +
+
+ ); +} diff --git a/components/dashboard/src/components/MonacoEditor.tsx b/components/dashboard/src/components/MonacoEditor.tsx index 62f49bcddba388..0235756eb6f360 100644 --- a/components/dashboard/src/components/MonacoEditor.tsx +++ b/components/dashboard/src/components/MonacoEditor.tsx @@ -4,9 +4,9 @@ * See License-AGPL.txt in the project root for license information. */ -import { useContext, useEffect, useRef } from "react"; -import * as monaco from "monaco-editor"; -import { ThemeContext } from "../theme-context"; +import { useContext, useEffect, useRef } from 'react'; +import * as monaco from 'monaco-editor'; +import { ThemeContext } from '../theme-context'; monaco.editor.defineTheme('gitpod', { base: 'vs', @@ -69,7 +69,7 @@ export default function MonacoEditor(props: MonacoEditorProps) { props.onChange(editorRef.current!.getValue()); }); // 8px top margin: https://github.com/Microsoft/monaco-editor/issues/1333 - editorRef.current.changeViewZones(accessor => { + editorRef.current.changeViewZones((accessor) => { accessor.addZone({ afterLineNumber: 0, heightInPx: 8, @@ -84,16 +84,16 @@ export default function MonacoEditor(props: MonacoEditorProps) { if (editorRef.current && editorRef.current.getValue() !== props.value) { editorRef.current.setValue(props.value); } - }, [ props.value ]); + }, [props.value]); useEffect(() => { - monaco.editor.setTheme(props.disabled - ? (isDark ? 'gitpod-dark-disabled' : 'gitpod-disabled') - : (isDark ? 'gitpod-dark' : 'gitpod')); + monaco.editor.setTheme( + props.disabled ? (isDark ? 'gitpod-dark-disabled' : 'gitpod-disabled') : isDark ? 'gitpod-dark' : 'gitpod', + ); if (editorRef.current) { editorRef.current.updateOptions({ readOnly: props.disabled }); } - }, [ props.disabled, isDark ]); + }, [props.disabled, isDark]); return
; -} \ No newline at end of file +} diff --git a/components/dashboard/src/components/PageWithSubMenu.tsx b/components/dashboard/src/components/PageWithSubMenu.tsx index 8e709d1fdd130e..654d28d0e6facd 100644 --- a/components/dashboard/src/components/PageWithSubMenu.tsx +++ b/components/dashboard/src/components/PageWithSubMenu.tsx @@ -4,45 +4,45 @@ * See License-AGPL.txt in the project root for license information. */ -import { useLocation } from "react-router"; -import { Link } from "react-router-dom"; +import { useLocation } from 'react-router'; +import { Link } from 'react-router-dom'; import Header from '../components/Header'; export interface PageWithSubMenuProps { + title: string; + subtitle: string; + subMenu: { title: string; - subtitle: string; - subMenu: { - title: string, - link: string[] - }[]; - children: React.ReactNode; + link: string[]; + }[]; + children: React.ReactNode; } export function PageWithSubMenu(p: PageWithSubMenuProps) { - const location = useLocation(); - return
-
-
-
-
    - {p.subMenu.map(e => { - let classes = "flex block py-2 px-4 rounded-md"; - if (e.link.some(l => l === location.pathname)) { - classes += " bg-gray-800 text-gray-50"; - } else { - classes += " hover:bg-gray-100 dark:hover:bg-gray-800"; - } - return -
  • - {e.title} -
  • - ; - })} -
-
-
- {p.children} -
+ const location = useLocation(); + return ( +
+
+
+
+
    + {p.subMenu.map((e) => { + let classes = 'flex block py-2 px-4 rounded-md'; + if (e.link.some((l) => l === location.pathname)) { + classes += ' bg-gray-800 text-gray-50'; + } else { + classes += ' hover:bg-gray-100 dark:hover:bg-gray-800'; + } + return ( + +
  • {e.title}
  • + + ); + })} +
-
; +
{p.children}
+
+
+ ); } diff --git a/components/dashboard/src/components/PendingChangesDropdown.tsx b/components/dashboard/src/components/PendingChangesDropdown.tsx index e96bd7e45e7826..dd700f05ab697f 100644 --- a/components/dashboard/src/components/PendingChangesDropdown.tsx +++ b/components/dashboard/src/components/PendingChangesDropdown.tsx @@ -4,9 +4,9 @@ * See License-AGPL.txt in the project root for license information. */ -import { WorkspaceInstance } from "@gitpod/gitpod-protocol"; -import ContextMenu, { ContextMenuEntry } from "./ContextMenu"; -import CaretDown from "../icons/CaretDown.svg"; +import { WorkspaceInstance } from '@gitpod/gitpod-protocol'; +import ContextMenu, { ContextMenuEntry } from './ContextMenu'; +import CaretDown from '../icons/CaretDown.svg'; export default function PendingChangesDropdown(props: { workspaceInstance?: WorkspaceInstance }) { const repo = props.workspaceInstance?.status?.repo; @@ -18,26 +18,30 @@ export default function PendingChangesDropdown(props: { workspaceInstance?: Work if ((repo.totalUntrackedFiles || 0) > 0) { totalChanges += repo.totalUntrackedFiles || 0; menuEntries.push({ title: 'Untracked Files', customFontStyle: headingStyle }); - (repo.untrackedFiles || []).forEach(item => menuEntries.push({ title: item, customFontStyle: itemStyle })); + (repo.untrackedFiles || []).forEach((item) => menuEntries.push({ title: item, customFontStyle: itemStyle })); } if ((repo.totalUncommitedFiles || 0) > 0) { totalChanges += repo.totalUncommitedFiles || 0; menuEntries.push({ title: 'Uncommitted Files', customFontStyle: headingStyle }); - (repo.uncommitedFiles || []).forEach(item => menuEntries.push({ title: item, customFontStyle: itemStyle })); + (repo.uncommitedFiles || []).forEach((item) => menuEntries.push({ title: item, customFontStyle: itemStyle })); } if ((repo.totalUnpushedCommits || 0) > 0) { totalChanges += repo.totalUnpushedCommits || 0; menuEntries.push({ title: 'Unpushed Commits', customFontStyle: headingStyle }); - (repo.unpushedCommits || []).forEach(item => menuEntries.push({ title: item, customFontStyle: itemStyle })); + (repo.unpushedCommits || []).forEach((item) => menuEntries.push({ title: item, customFontStyle: itemStyle })); } } if (totalChanges <= 0) { return
No Changes
; } - return -

- {totalChanges} Change{totalChanges === 1 ? '' : 's'} - -

-
; -} \ No newline at end of file + return ( + +

+ + {totalChanges} Change{totalChanges === 1 ? '' : 's'} + + +

+
+ ); +} diff --git a/components/dashboard/src/components/PillLabel.tsx b/components/dashboard/src/components/PillLabel.tsx index 731d74d29d7384..435f45772f36ca 100644 --- a/components/dashboard/src/components/PillLabel.tsx +++ b/components/dashboard/src/components/PillLabel.tsx @@ -14,9 +14,11 @@ * **className**\ * Add additional css classes to style this component. */ -export default function PillLabel(props: { children?: React.ReactNode, type?: "info" | "warn", className?: string }) { - const infoStyle = "bg-blue-50 text-blue-500 dark:bg-blue-500 dark:text-blue-100"; - const warnStyle = "bg-orange-100 text-orange-700 dark:bg-orange-600 dark:text-orange-100"; - const style = `ml-2 px-3 py-1 text-sm uppercase rounded-xl ${props.type === "warn" ? warnStyle : infoStyle} ${props.className}`; - return {props.children}; +export default function PillLabel(props: { children?: React.ReactNode; type?: 'info' | 'warn'; className?: string }) { + const infoStyle = 'bg-blue-50 text-blue-500 dark:bg-blue-500 dark:text-blue-100'; + const warnStyle = 'bg-orange-100 text-orange-700 dark:bg-orange-600 dark:text-orange-100'; + const style = `ml-2 px-3 py-1 text-sm uppercase rounded-xl ${props.type === 'warn' ? warnStyle : infoStyle} ${ + props.className + }`; + return {props.children}; } diff --git a/components/dashboard/src/components/PillMenuItem.tsx b/components/dashboard/src/components/PillMenuItem.tsx index d12fcb1c5e84aa..90f5e569b2c136 100644 --- a/components/dashboard/src/components/PillMenuItem.tsx +++ b/components/dashboard/src/components/PillMenuItem.tsx @@ -4,19 +4,24 @@ * See License-AGPL.txt in the project root for license information. */ -import { Link } from "react-router-dom"; +import { Link } from 'react-router-dom'; export default function PillMenuItem(p: { - name: string, - selected: boolean, - link?: string, - onClick?: (event: React.MouseEvent) => void + name: string; + selected: boolean; + link?: string; + onClick?: (event: React.MouseEvent) => void; }) { - const classes = 'flex block font-medium dark:text-gray-200 px-2 py-1 rounded-lg transition ease-in-out ' + - (p.selected - ? 'bg-gray-200 dark:bg-gray-700' - : 'text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800'); - return ((!p.link || p.link.startsWith('https://')) - ? {p.name} - : {p.name}); -} \ No newline at end of file + const classes = + 'flex block font-medium dark:text-gray-200 px-2 py-1 rounded-lg transition ease-in-out ' + + (p.selected ? 'bg-gray-200 dark:bg-gray-700' : 'text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800'); + return !p.link || p.link.startsWith('https://') ? ( + + {p.name} + + ) : ( + + {p.name} + + ); +} diff --git a/components/dashboard/src/components/PrebuildLogs.tsx b/components/dashboard/src/components/PrebuildLogs.tsx index ebdf9048ace1f6..db745ba78a6805 100644 --- a/components/dashboard/src/components/PrebuildLogs.tsx +++ b/components/dashboard/src/components/PrebuildLogs.tsx @@ -4,10 +4,16 @@ * See License-AGPL.txt in the project root for license information. */ -import EventEmitter from "events"; -import React, { Suspense, useEffect, useState } from "react"; -import { Workspace, WorkspaceInstance, DisposableCollection, WorkspaceImageBuild, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from "@gitpod/gitpod-protocol"; -import { getGitpodService } from "../service/service"; +import EventEmitter from 'events'; +import React, { Suspense, useEffect, useState } from 'react'; +import { + Workspace, + WorkspaceInstance, + DisposableCollection, + WorkspaceImageBuild, + HEADLESS_LOG_STREAM_STATUS_CODE_REGEX, +} from '@gitpod/gitpod-protocol'; +import { getGitpodService } from '../service/service'; const WorkspaceLogs = React.lazy(() => import('./WorkspaceLogs')); @@ -17,10 +23,10 @@ export interface PrebuildLogsProps { } export default function PrebuildLogs(props: PrebuildLogsProps) { - const [ workspace, setWorkspace ] = useState(); - const [ workspaceInstance, setWorkspaceInstance ] = useState(); - const [ error, setError ] = useState(); - const [ logsEmitter ] = useState(new EventEmitter()); + const [workspace, setWorkspace] = useState(); + const [workspaceInstance, setWorkspaceInstance] = useState(); + const [error, setError] = useState(); + const [logsEmitter] = useState(new EventEmitter()); useEffect(() => { const disposables = new DisposableCollection(); @@ -35,23 +41,34 @@ export default function PrebuildLogs(props: PrebuildLogsProps) { setWorkspace(info.workspace); setWorkspaceInstance(info.latestInstance); } - disposables.push(getGitpodService().registerClient({ - onInstanceUpdate: (instance) => { - if (props.workspaceId === instance.workspaceId) { - setWorkspaceInstance(instance); - } - }, - onWorkspaceImageBuildLogs: (info: WorkspaceImageBuild.StateInfo, content?: WorkspaceImageBuild.LogContent) => { - if (!content) { - return; - } - logsEmitter.emit('logs', content.text); - }, - })); + disposables.push( + getGitpodService().registerClient({ + onInstanceUpdate: (instance) => { + if (props.workspaceId === instance.workspaceId) { + setWorkspaceInstance(instance); + } + }, + onWorkspaceImageBuildLogs: ( + info: WorkspaceImageBuild.StateInfo, + content?: WorkspaceImageBuild.LogContent, + ) => { + if (!content) { + return; + } + logsEmitter.emit('logs', content.text); + }, + }), + ); if (info.latestInstance) { - disposables.push(watchHeadlessLogs(info.latestInstance.id, chunk => { - logsEmitter.emit('logs', chunk); - }, async () => workspaceInstance?.status.phase === 'stopped')); + disposables.push( + watchHeadlessLogs( + info.latestInstance.id, + (chunk) => { + logsEmitter.emit('logs', chunk); + }, + async () => workspaceInstance?.status.phase === 'stopped', + ), + ); } } catch (err) { console.error(err); @@ -60,8 +77,8 @@ export default function PrebuildLogs(props: PrebuildLogsProps) { })(); return function cleanUp() { disposables.dispose(); - } - }, [ props.workspaceId ]); + }; + }, [props.workspaceId]); useEffect(() => { if (props.onInstanceUpdate && workspaceInstance) { @@ -70,48 +87,48 @@ export default function PrebuildLogs(props: PrebuildLogsProps) { switch (workspaceInstance?.status.phase) { // unknown indicates an issue within the system in that it cannot determine the actual phase of // a workspace. This phase is usually accompanied by an error. - case "unknown": + case 'unknown': break; // Preparing means that we haven't actually started the workspace instance just yet, but rather // are still preparing for launch. This means we're building the Docker image for the workspace. - case "preparing": + case 'preparing': getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); break; // Pending means the workspace does not yet consume resources in the cluster, but rather is looking for // some space within the cluster. If for example the cluster needs to scale up to accomodate the // workspace, the workspace will be in Pending state until that happened. - case "pending": + case 'pending': break; // Creating means the workspace is currently being created. That includes downloading the images required // to run the workspace over the network. The time spent in this phase varies widely and depends on the current // network speed, image size and cache states. - case "creating": + case 'creating': break; // Initializing is the phase in which the workspace is executing the appropriate workspace initializer (e.g. Git // clone or backup download). After this phase one can expect the workspace to either be Running or Failed. - case "initializing": + case 'initializing': break; // Running means the workspace is able to actively perform work, either by serving a user through Theia, // or as a headless workspace. - case "running": + case 'running': break; // Interrupted is an exceptional state where the container should be running but is temporarily unavailable. // When in this state, we expect it to become running or stopping anytime soon. - case "interrupted": + case 'interrupted': break; // Stopping means that the workspace is currently shutting down. It could go to stopped every moment. - case "stopping": + case 'stopping': break; // Stopped means the workspace ended regularly because it was shut down. - case "stopped": + case 'stopped': getGitpodService().server.watchWorkspaceImageBuildLogs(workspace!.id); break; } @@ -121,14 +138,20 @@ export default function PrebuildLogs(props: PrebuildLogsProps) { if (workspaceInstance?.status.conditions.failed) { setError(new Error(workspaceInstance.status.conditions.failed)); } - }, [ props.workspaceId, workspaceInstance?.status.phase ]); + }, [props.workspaceId, workspaceInstance?.status.phase]); - return }> - - ; + return ( + }> + + + ); } -export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => void, checkIsDone: () => Promise): DisposableCollection { +export function watchHeadlessLogs( + instanceId: string, + onLog: (chunk: string) => void, + checkIsDone: () => Promise, +): DisposableCollection { const disposables = new DisposableCollection(); const startWatchingLogs = async () => { @@ -143,7 +166,7 @@ export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => const maxBackoffSeconds = 5; delayInSeconds = Math.min(delayInSeconds * backoffFactor, maxBackoffSeconds); - console.debug("re-trying headless-logs because: " + reason, err); + console.debug('re-trying headless-logs because: ' + reason, err); await new Promise((resolve) => { setTimeout(resolve, delayInSeconds * 1000); }); @@ -157,24 +180,24 @@ export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => // TODO(gpl) Only listening on first stream for now const streamIds = Object.keys(logSources.streams); if (streamIds.length < 1) { - await retryBackoff("no streams"); + await retryBackoff('no streams'); return; } const streamUrl = logSources.streams[streamIds[0]]; - console.log("fetching from streamUrl: " + streamUrl); + console.log('fetching from streamUrl: ' + streamUrl); response = await fetch(streamUrl, { method: 'GET', cache: 'no-cache', credentials: 'include', keepalive: true, headers: { - 'TE': 'trailers', // necessary to receive stream status code + TE: 'trailers', // necessary to receive stream status code }, }); reader = response.body?.getReader(); if (!reader) { - await retryBackoff("no reader"); + await retryBackoff('no reader'); return; } disposables.push({ dispose: () => reader?.cancel() }); @@ -189,7 +212,7 @@ export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => const matches = msg.match(HEADLESS_LOG_STREAM_STATUS_CODE_REGEX); if (matches) { if (matches.length < 2) { - console.debug("error parsing log stream status code. msg: " + msg); + console.debug('error parsing log stream status code. msg: ' + msg); } else { const code = parseStatusCode(matches[1]); if (code !== 200) { @@ -202,19 +225,19 @@ export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => chunk = await reader.read(); } - reader.cancel() + reader.cancel(); if (await checkIsDone()) { return; } - } catch(err) { + } catch (err) { reader?.cancel().catch(console.debug); if (err.code === 400) { // sth is really off, and we _should not_ retry - console.error("stopped watching headless logs", err); + console.error('stopped watching headless logs', err); return; } - await retryBackoff("error while listening to stream", err); + await retryBackoff('error while listening to stream', err); } }; startWatchingLogs().catch(console.error); @@ -224,7 +247,7 @@ export function watchHeadlessLogs(instanceId: string, onLog: (chunk: string) => class StreamError extends Error { constructor(readonly code?: number) { - super(`stream status code: ${code}`) + super(`stream status code: ${code}`); } } @@ -234,7 +257,7 @@ function parseStatusCode(code: string | undefined): number | undefined { return undefined; } return Number.parseInt(code); - } catch(err) { + } catch (err) { return undefined; } -} \ No newline at end of file +} diff --git a/components/dashboard/src/components/SelectableCard.tsx b/components/dashboard/src/components/SelectableCard.tsx index d6eba08cb12cc2..d75d80fdefccb8 100644 --- a/components/dashboard/src/components/SelectableCard.tsx +++ b/components/dashboard/src/components/SelectableCard.tsx @@ -5,21 +5,41 @@ */ export interface SelectableCardProps { - title: string; - selected: boolean; - className?: string; - onClick: () => void; - children?: React.ReactNode; + title: string; + selected: boolean; + className?: string; + onClick: () => void; + children?: React.ReactNode; } function SelectableCard(props: SelectableCardProps) { - return
-
-

{props.title}

- -
- {props.children} -
; + return ( +
+
+

+ {props.title} +

+ +
+ {props.children} +
+ ); } export default SelectableCard; diff --git a/components/dashboard/src/components/Separator.tsx b/components/dashboard/src/components/Separator.tsx index 9dea3991464f89..32c35bc74472f3 100644 --- a/components/dashboard/src/components/Separator.tsx +++ b/components/dashboard/src/components/Separator.tsx @@ -5,5 +5,5 @@ */ export default function Separator() { - return
; -} \ No newline at end of file + return
; +} diff --git a/components/dashboard/src/components/TabMenuItem.tsx b/components/dashboard/src/components/TabMenuItem.tsx index 7cccdbb4495334..71c516b24996ce 100644 --- a/components/dashboard/src/components/TabMenuItem.tsx +++ b/components/dashboard/src/components/TabMenuItem.tsx @@ -4,19 +4,26 @@ * See License-AGPL.txt in the project root for license information. */ -import { Link } from "react-router-dom"; +import { Link } from 'react-router-dom'; export default function TabMenuItem(p: { - name: string, - selected: boolean, - link?: string, - onClick?: (event: React.MouseEvent) => void + name: string; + selected: boolean; + link?: string; + onClick?: (event: React.MouseEvent) => void; }) { - const classes = 'cursor-pointer py-2 px-4 border-b-4 border-transparent transition ease-in-out ' + - (p.selected - ? 'text-gray-600 dark:text-gray-400 border-gray-700 dark:border-gray-400' - : 'text-gray-400 dark:text-gray-600 hover:border-gray-400 dark:hover:border-gray-600'); - return ((!p.link || p.link.startsWith('https://')) - ? {p.name} - : {p.name}); -} \ No newline at end of file + const classes = + 'cursor-pointer py-2 px-4 border-b-4 border-transparent transition ease-in-out ' + + (p.selected + ? 'text-gray-600 dark:text-gray-400 border-gray-700 dark:border-gray-400' + : 'text-gray-400 dark:text-gray-600 hover:border-gray-400 dark:hover:border-gray-600'); + return !p.link || p.link.startsWith('https://') ? ( + + {p.name} + + ) : ( + + {p.name} + + ); +} diff --git a/components/dashboard/src/components/Tooltip.tsx b/components/dashboard/src/components/Tooltip.tsx index eb01d359a5a360..a51cef1b23298a 100644 --- a/components/dashboard/src/components/Tooltip.tsx +++ b/components/dashboard/src/components/Tooltip.tsx @@ -7,28 +7,30 @@ import { useState } from 'react'; export interface TooltipProps { - children: React.ReactChild[] | React.ReactChild; - content: string; - allowWrap?: boolean; + children: React.ReactChild[] | React.ReactChild; + content: string; + allowWrap?: boolean; } function Tooltip(props: TooltipProps) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(false); - return ( -
setExpanded(false)} onMouseEnter={() => setExpanded(true)} className="relative"> -
- {props.children} -
- {expanded ? -
- {props.content} -
- : - null - } + return ( +
setExpanded(false)} onMouseEnter={() => setExpanded(true)} className="relative"> +
{props.children}
+ {expanded ? ( +
+ {props.content}
- ); + ) : null} +
+ ); } -export default Tooltip; \ No newline at end of file +export default Tooltip; diff --git a/components/dashboard/src/components/WorkspaceLogs.tsx b/components/dashboard/src/components/WorkspaceLogs.tsx index ae6be25bb1957c..ce2a45d265671b 100644 --- a/components/dashboard/src/components/WorkspaceLogs.tsx +++ b/components/dashboard/src/components/WorkspaceLogs.tsx @@ -7,7 +7,7 @@ import EventEmitter from 'events'; import { useContext, useEffect, useRef } from 'react'; import { Terminal, ITerminalOptions, ITheme } from 'xterm'; -import { FitAddon } from 'xterm-addon-fit' +import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; import { ThemeContext } from '../theme-context'; @@ -18,7 +18,7 @@ const lightTheme: ITheme = { background: '#F5F5F4', // Tailwind's warmGray 100 https://tailwindcss.com/docs/customizing-colors foreground: '#78716C', // Tailwind's warmGray 500 https://tailwindcss.com/docs/customizing-colors cursor: '#78716C', // Tailwind's warmGray 500 https://tailwindcss.com/docs/customizing-colors -} +}; export interface WorkspaceLogsProps { logsEmitter: EventEmitter; @@ -48,7 +48,7 @@ export default function WorkspaceLogs(props: WorkspaceLogsProps) { terminal.loadAddon(fitAddon); terminal.open(xTermParentRef.current); terminal.write('Connecting to workspace logs...\r\n'); - props.logsEmitter.on('logs', logs => { + props.logsEmitter.on('logs', (logs) => { if (terminal && logs) { terminal.write(logs); } @@ -56,7 +56,7 @@ export default function WorkspaceLogs(props: WorkspaceLogsProps) { fitAddon.fit(); return function cleanUp() { terminal.dispose(); - } + }; }, []); useEffect(() => { @@ -70,23 +70,29 @@ export default function WorkspaceLogs(props: WorkspaceLogsProps) { return function cleanUp() { clearTimeout(timeout!); window.removeEventListener('resize', onWindowResize); - } + }; }, []); useEffect(() => { if (terminalRef.current && props.errorMessage) { terminalRef.current.write(`\r\n\u001b[38;5;196m${props.errorMessage}\u001b[0m\r\n`); } - }, [ terminalRef.current, props.errorMessage ]); + }, [terminalRef.current, props.errorMessage]); useEffect(() => { if (!terminalRef.current) { return; } terminalRef.current.setOption('theme', isDark ? darkTheme : lightTheme); - }, [ terminalRef.current, isDark ]); + }, [terminalRef.current, isDark]); - return
-
-
; + return ( +
+
+
+ ); } diff --git a/components/dashboard/src/experiments.ts b/components/dashboard/src/experiments.ts index 1270a0d861a72a..8247a60026a3d1 100644 --- a/components/dashboard/src/experiments.ts +++ b/components/dashboard/src/experiments.ts @@ -4,7 +4,7 @@ * See License-AGPL.txt in the project root for license information. */ -const UI_EXPERIMENTS_KEY = "gitpod-ui-experiments"; +const UI_EXPERIMENTS_KEY = 'gitpod-ui-experiments'; /** * This enables UI-experiments: Dashboard-local changes that we'd like to try out and get some feedback on/validate @@ -24,73 +24,73 @@ const UI_EXPERIMENTS_KEY = "gitpod-ui-experiments"; * - multiple experiments per user/time */ const Experiments = { - /** - * Experiment "example" will be activate on login for 10% of all clients. - */ - "example": 0.1, + /** + * Experiment "example" will be activate on login for 10% of all clients. + */ + example: 0.1, }; type Experiments = Partial<{ [e in Experiment]: boolean }>; -export type Experiment = keyof (typeof Experiments); +export type Experiment = keyof typeof Experiments; export namespace Experiment { - /** - * Randomly decides what the set of Experiments is the user participates in - * @param keepCurrent - * @returns Experiments - */ - export function seed(keepCurrent: boolean): Experiments { - const result = keepCurrent ? get() || {} : {}; + /** + * Randomly decides what the set of Experiments is the user participates in + * @param keepCurrent + * @returns Experiments + */ + export function seed(keepCurrent: boolean): Experiments { + const result = keepCurrent ? get() || {} : {}; - for (const experiment of Object.keys(Experiments) as Experiment[]) { - if (!(experiment in result)) { - result[experiment] = Math.random() < Experiments[experiment]; - } - } - - return result; + for (const experiment of Object.keys(Experiments) as Experiment[]) { + if (!(experiment in result)) { + result[experiment] = Math.random() < Experiments[experiment]; + } } - export function set(set: Experiments): void { - try { - window.localStorage.setItem(UI_EXPERIMENTS_KEY, JSON.stringify(set)); - } catch (err) { - console.warn(`error setting ${UI_EXPERIMENTS_KEY}`, err); - } + return result; + } + + export function set(set: Experiments): void { + try { + window.localStorage.setItem(UI_EXPERIMENTS_KEY, JSON.stringify(set)); + } catch (err) { + console.warn(`error setting ${UI_EXPERIMENTS_KEY}`, err); } + } - export function has(experiment: Experiment): boolean { - try { - const set = get(); - if (!set) { - return false; - } - return set[experiment] === true; - } catch (err) { - console.warn(`error checking experiment '${experiment}'`, err); - return false; - } + export function has(experiment: Experiment): boolean { + try { + const set = get(); + if (!set) { + return false; + } + return set[experiment] === true; + } catch (err) { + console.warn(`error checking experiment '${experiment}'`, err); + return false; } + } - /** Retrieves all currently valid Experiments from localStorage */ - export function get(): Experiments | undefined { - try { - const objStr = window.localStorage.getItem(UI_EXPERIMENTS_KEY); - if (objStr === null) { - return undefined; - } + /** Retrieves all currently valid Experiments from localStorage */ + export function get(): Experiments | undefined { + try { + const objStr = window.localStorage.getItem(UI_EXPERIMENTS_KEY); + if (objStr === null) { + return undefined; + } - const obj = JSON.parse(objStr) as Experiments; - // trim to contain only known keys so we're type-safe - for (const e of Object.keys(obj)) { - if (!(e in Experiments)) { - delete (obj as any)[e]; - } - } - return obj; - } catch (err) { - // we definitely don't want to break anybody because of weird errors - console.warn(`error getting ${UI_EXPERIMENTS_KEY}`, err); - return undefined; + const obj = JSON.parse(objStr) as Experiments; + // trim to contain only known keys so we're type-safe + for (const e of Object.keys(obj)) { + if (!(e in Experiments)) { + delete (obj as any)[e]; } + } + return obj; + } catch (err) { + // we definitely don't want to break anybody because of weird errors + console.warn(`error getting ${UI_EXPERIMENTS_KEY}`, err); + return undefined; } -} \ No newline at end of file + } +} diff --git a/components/dashboard/src/index.css b/components/dashboard/src/index.css index 7e5035bb1b0689..3dea14e68f95b1 100644 --- a/components/dashboard/src/index.css +++ b/components/dashboard/src/index.css @@ -9,94 +9,102 @@ @tailwind utilities; @layer base { - html, body { - @apply h-full; - } - body { - @apply bg-white dark:bg-gitpod-black text-black dark:text-white; - } - h1 { - @apply text-gray-900 dark:text-gray-100 font-bold; - line-height: 64px; - font-size: 48px; - } - h2 { - @apply text-base text-gray-500 dark:text-gray-400; - } - h3 { - @apply text-2xl text-gray-800 dark:text-gray-100 leading-9 font-semibold; - } - h4 { - @apply pb-2 text-sm font-semibold text-gray-600 dark:text-gray-400; - } - p { - @apply text-sm text-gray-400 dark:text-gray-600; - } + html, + body { + @apply h-full; + } + body { + @apply bg-white dark:bg-gitpod-black text-black dark:text-white; + } + h1 { + @apply text-gray-900 dark:text-gray-100 font-bold; + line-height: 64px; + font-size: 48px; + } + h2 { + @apply text-base text-gray-500 dark:text-gray-400; + } + h3 { + @apply text-2xl text-gray-800 dark:text-gray-100 leading-9 font-semibold; + } + h4 { + @apply pb-2 text-sm font-semibold text-gray-600 dark:text-gray-400; + } + p { + @apply text-sm text-gray-400 dark:text-gray-600; + } - .app-container { - @apply lg:px-28 px-10; - } - .btn-login { - @apply rounded-md border-none bg-gray-100 hover:bg-gray-200 text-gray-500 dark:text-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600; - } - .dark .dark\:filter-invert { - @apply filter-invert; - } + .app-container { + @apply lg:px-28 px-10; + } + .btn-login { + @apply rounded-md border-none bg-gray-100 hover:bg-gray-200 text-gray-500 dark:text-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600; + } + .dark .dark\:filter-invert { + @apply filter-invert; + } } @layer components { - button { - @apply cursor-pointer px-4 py-2 my-auto bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-600 text-gray-100 dark:text-green-100 text-sm font-medium rounded-md focus:outline-none focus:ring transition ease-in-out; - } - button.secondary { - @apply bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-100 hover:text-gray-600; - } - button.danger { - @apply bg-red-600 hover:bg-red-700 text-gray-100 dark:text-red-100; - } - button.danger.secondary { - @apply bg-red-50 dark:bg-red-300 hover:bg-red-100 dark:hover:bg-red-200 text-red-600 hover:text-red-700; - } - button:disabled { - @apply cursor-default opacity-50 pointer-events-none; - } + button { + @apply cursor-pointer px-4 py-2 my-auto bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-600 text-gray-100 dark:text-green-100 text-sm font-medium rounded-md focus:outline-none focus:ring transition ease-in-out; + } + button.secondary { + @apply bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-100 hover:text-gray-600; + } + button.danger { + @apply bg-red-600 hover:bg-red-700 text-gray-100 dark:text-red-100; + } + button.danger.secondary { + @apply bg-red-50 dark:bg-red-300 hover:bg-red-100 dark:hover:bg-red-200 text-red-600 hover:text-red-700; + } + button:disabled { + @apply cursor-default opacity-50 pointer-events-none; + } - a.gp-link { - @apply text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500; - } + a.gp-link { + @apply text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500; + } - input[type=text], input[type=search], input[type=password], select { - @apply block w-56 text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-md border border-gray-300 dark:border-gray-500 focus:border-gray-400 dark:focus:border-gray-400 focus:ring-0; - } - input[type=text]::placeholder, input[type=search]::placeholder, input[type=password]::placeholder { - @apply text-gray-400 dark:text-gray-500; - } - input[type=text].error, input[type=password].error, select.error { - @apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red; - } - input[disabled] { - @apply bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500; - } - input[type=radio] { - @apply border border-gray-300 focus:border-gray-400 focus:bg-white focus:ring-0; - } - input[type=search] { - @apply border-0 dark:bg-transparent; - } - input[type=checkbox] { - @apply disabled:opacity-50 - } + input[type='text'], + input[type='search'], + input[type='password'], + select { + @apply block w-56 text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-md border border-gray-300 dark:border-gray-500 focus:border-gray-400 dark:focus:border-gray-400 focus:ring-0; + } + input[type='text']::placeholder, + input[type='search']::placeholder, + input[type='password']::placeholder { + @apply text-gray-400 dark:text-gray-500; + } + input[type='text'].error, + input[type='password'].error, + select.error { + @apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red; + } + input[disabled] { + @apply bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500; + } + input[type='radio'] { + @apply border border-gray-300 focus:border-gray-400 focus:bg-white focus:ring-0; + } + input[type='search'] { + @apply border-0 dark:bg-transparent; + } + input[type='checkbox'] { + @apply disabled:opacity-50; + } - progress { - @apply h-2 rounded; - } - progress::-webkit-progress-bar { - @apply rounded-md bg-gray-200; - } - progress::-webkit-progress-value { - @apply rounded-md bg-green-500; - } - progress::-moz-progress-bar { - @apply rounded-md bg-green-500; - } -} \ No newline at end of file + progress { + @apply h-2 rounded; + } + progress::-webkit-progress-bar { + @apply rounded-md bg-gray-200; + } + progress::-webkit-progress-value { + @apply rounded-md bg-green-500; + } + progress::-moz-progress-bar { + @apply rounded-md bg-green-500; + } +} diff --git a/components/dashboard/src/index.tsx b/components/dashboard/src/index.tsx index 8fb34ef8b70765..e15272180ba37a 100644 --- a/components/dashboard/src/index.tsx +++ b/components/dashboard/src/index.tsx @@ -13,21 +13,21 @@ import { ProjectContextProvider } from './projects/project-context'; import { ThemeContextProvider } from './theme-context'; import { BrowserRouter } from 'react-router-dom'; -import "./index.css" +import './index.css'; ReactDOM.render( - - - - - - - - - - - - - , - document.getElementById('root') -); \ No newline at end of file + + + + + + + + + + + + + , + document.getElementById('root'), +); diff --git a/components/dashboard/src/projects/ConfigureProject.tsx b/components/dashboard/src/projects/ConfigureProject.tsx index 729455ca7b2e7f..878ec43a8bdd8a 100644 --- a/components/dashboard/src/projects/ConfigureProject.tsx +++ b/components/dashboard/src/projects/ConfigureProject.tsx @@ -4,31 +4,31 @@ * See License-AGPL.txt in the project root for license information. */ -import React, { Suspense, useContext, useEffect, useState } from "react"; -import { Project, StartPrebuildResult, WorkspaceInstance } from "@gitpod/gitpod-protocol"; -import PrebuildLogs from "../components/PrebuildLogs"; -import TabMenuItem from "../components/TabMenuItem"; -import { getGitpodService } from "../service/service"; -import Spinner from "../icons/Spinner.svg"; -import NoAccess from "../icons/NoAccess.svg"; -import PrebuildLogsEmpty from "../images/prebuild-logs-empty.svg"; -import PrebuildLogsEmptyDark from "../images/prebuild-logs-empty-dark.svg"; -import { ThemeContext } from "../theme-context"; -import { PrebuildInstanceStatus } from "./Prebuilds"; -import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; -import { openAuthorizeWindow } from "../provider-utils"; -import { ProjectSettingsPage } from "./ProjectSettings"; -import { ProjectContext } from "./project-context"; +import React, { Suspense, useContext, useEffect, useState } from 'react'; +import { Project, StartPrebuildResult, WorkspaceInstance } from '@gitpod/gitpod-protocol'; +import PrebuildLogs from '../components/PrebuildLogs'; +import TabMenuItem from '../components/TabMenuItem'; +import { getGitpodService } from '../service/service'; +import Spinner from '../icons/Spinner.svg'; +import NoAccess from '../icons/NoAccess.svg'; +import PrebuildLogsEmpty from '../images/prebuild-logs-empty.svg'; +import PrebuildLogsEmptyDark from '../images/prebuild-logs-empty-dark.svg'; +import { ThemeContext } from '../theme-context'; +import { PrebuildInstanceStatus } from './Prebuilds'; +import { ErrorCodes } from '@gitpod/gitpod-protocol/lib/messaging/error'; +import { openAuthorizeWindow } from '../provider-utils'; +import { ProjectSettingsPage } from './ProjectSettings'; +import { ProjectContext } from './project-context'; const MonacoEditor = React.lazy(() => import('../components/MonacoEditor')); const TASKS = { - Other: `tasks: + Other: `tasks: - init: | echo 'TODO: build project' command: | - echo 'TODO: start app'` -} + echo 'TODO: start app'`, +}; // const IMAGES = { // Default: 'gitpod/workspace-full', @@ -40,234 +40,362 @@ const TASKS = { // } export default function () { - const { project } = useContext(ProjectContext); - const [gitpodYml, setGitpodYml] = useState(''); - const [dockerfile, setDockerfile] = useState(''); - const [editorMessage, setEditorMessage] = useState(null); - const [selectedEditor, setSelectedEditor] = useState<'.gitpod.yml' | '.gitpod.Dockerfile'>('.gitpod.yml'); - const [isEditorDisabled, setIsEditorDisabled] = useState(true); - const [isDetecting, setIsDetecting] = useState(true); - const [prebuildWasTriggered, setPrebuildWasTriggered] = useState(false); - const [prebuildWasCancelled, setPrebuildWasCancelled] = useState(false); - const [startPrebuildResult, setStartPrebuildResult] = useState(); - const [prebuildInstance, setPrebuildInstance] = useState(); - const { isDark } = useContext(ThemeContext); - - const [showAuthBanner, setShowAuthBanner] = useState<{ host: string, scope?: string } | undefined>(undefined); - const [buttonNewWorkspaceEnabled, setButtonNewWorkspaceEnabled] = useState(true); + const { project } = useContext(ProjectContext); + const [gitpodYml, setGitpodYml] = useState(''); + const [dockerfile, setDockerfile] = useState(''); + const [editorMessage, setEditorMessage] = useState(null); + const [selectedEditor, setSelectedEditor] = useState<'.gitpod.yml' | '.gitpod.Dockerfile'>('.gitpod.yml'); + const [isEditorDisabled, setIsEditorDisabled] = useState(true); + const [isDetecting, setIsDetecting] = useState(true); + const [prebuildWasTriggered, setPrebuildWasTriggered] = useState(false); + const [prebuildWasCancelled, setPrebuildWasCancelled] = useState(false); + const [startPrebuildResult, setStartPrebuildResult] = useState(); + const [prebuildInstance, setPrebuildInstance] = useState(); + const { isDark } = useContext(ThemeContext); - useEffect(() => { - // Disable editing while loading, or when the config comes from Git. - setIsDetecting(true); - setIsEditorDisabled(true); - setEditorMessage(null); - (async () => { - if (!project) { - setIsDetecting(false); - setEditorMessage(); - return; - } - try { - await detectProjectConfiguration(project); - } catch (error) { - if (error && error.message && error.message.includes("NotFound")) { - const host = new URL(project.cloneUrl).hostname; - const scope: string | undefined = host === "github.com" ? "repo" : undefined; - setShowAuthBanner({ host: new URL(project.cloneUrl).hostname, scope }); - } else if (error && error.code === ErrorCodes.NOT_AUTHENTICATED) { - setShowAuthBanner({ host: new URL(project.cloneUrl).hostname }); - } else { - console.error('Getting project configuration failed', error); - setIsDetecting(false); - setIsEditorDisabled(true); - setEditorMessage(); - setGitpodYml(TASKS.Other); - } - } - })(); - }, [project]); + const [showAuthBanner, setShowAuthBanner] = useState<{ host: string; scope?: string } | undefined>(undefined); + const [buttonNewWorkspaceEnabled, setButtonNewWorkspaceEnabled] = useState(true); - const detectProjectConfiguration = async (project: Project) => { - const guessedConfigStringPromise = getGitpodService().server.guessProjectConfiguration(project.id); - const repoConfigString = await getGitpodService().server.fetchProjectRepositoryConfiguration(project.id); - if (repoConfigString) { - setIsDetecting(false); - setEditorMessage(); - setGitpodYml(repoConfigString); - return; - } - if (project.config && project.config['.gitpod.yml']) { - setIsDetecting(false); - setIsEditorDisabled(false); - setGitpodYml(project.config['.gitpod.yml']); - return; - } - const guessedConfigString = await guessedConfigStringPromise; + useEffect(() => { + // Disable editing while loading, or when the config comes from Git. + setIsDetecting(true); + setIsEditorDisabled(true); + setEditorMessage(null); + (async () => { + if (!project) { setIsDetecting(false); - setIsEditorDisabled(false); - if (guessedConfigString) { - setEditorMessage(); - setGitpodYml(guessedConfigString); - return; + setEditorMessage( + , + ); + return; + } + try { + await detectProjectConfiguration(project); + } catch (error) { + if (error && error.message && error.message.includes('NotFound')) { + const host = new URL(project.cloneUrl).hostname; + const scope: string | undefined = host === 'github.com' ? 'repo' : undefined; + setShowAuthBanner({ host: new URL(project.cloneUrl).hostname, scope }); + } else if (error && error.code === ErrorCodes.NOT_AUTHENTICATED) { + setShowAuthBanner({ host: new URL(project.cloneUrl).hostname }); + } else { + console.error('Getting project configuration failed', error); + setIsDetecting(false); + setIsEditorDisabled(true); + setEditorMessage( + , + ); + setGitpodYml(TASKS.Other); } - setEditorMessage(); - setGitpodYml(TASKS.Other); - } + } + })(); + }, [project]); - const tryAuthorize = async (params: {host: string, scope?: string, onSuccess: () => void}) => { - try { - await openAuthorizeWindow({ - host: params.host, - onSuccess: params.onSuccess, - scopes: params.scope ? [params.scope] : undefined, - onError: (error) => { - console.log(error); - } - }); - } catch (error) { - console.log(error); - } - }; + const detectProjectConfiguration = async (project: Project) => { + const guessedConfigStringPromise = getGitpodService().server.guessProjectConfiguration(project.id); + const repoConfigString = await getGitpodService().server.fetchProjectRepositoryConfiguration(project.id); + if (repoConfigString) { + setIsDetecting(false); + setEditorMessage( + , + ); + setGitpodYml(repoConfigString); + return; + } + if (project.config && project.config['.gitpod.yml']) { + setIsDetecting(false); + setIsEditorDisabled(false); + setGitpodYml(project.config['.gitpod.yml']); + return; + } + const guessedConfigString = await guessedConfigStringPromise; + setIsDetecting(false); + setIsEditorDisabled(false); + if (guessedConfigString) { + setEditorMessage( + , + ); + setGitpodYml(guessedConfigString); + return; + } + setEditorMessage( + , + ); + setGitpodYml(TASKS.Other); + }; - const onConfirmShowAuthModal = async (host: string, scope?: string) => { - setShowAuthBanner(undefined); - await tryAuthorize({host, scope, onSuccess: async () => { - // update remote session - await getGitpodService().reconnect(); + const tryAuthorize = async (params: { host: string; scope?: string; onSuccess: () => void }) => { + try { + await openAuthorizeWindow({ + host: params.host, + onSuccess: params.onSuccess, + scopes: params.scope ? [params.scope] : undefined, + onError: (error) => { + console.log(error); + }, + }); + } catch (error) { + console.log(error); + } + }; - // retry fetching branches - if (project) { - detectProjectConfiguration(project); - } - }}); - }; + const onConfirmShowAuthModal = async (host: string, scope?: string) => { + setShowAuthBanner(undefined); + await tryAuthorize({ + host, + scope, + onSuccess: async () => { + // update remote session + await getGitpodService().reconnect(); - const buildProject = async () => { - if (!project) { - return; - } - setEditorMessage(null); - if (!!startPrebuildResult) { - setStartPrebuildResult(undefined); - } - if (!!prebuildInstance) { - setPrebuildInstance(undefined); - } - try { - setPrebuildWasTriggered(true); - if (!isEditorDisabled) { - await getGitpodService().server.setProjectConfiguration(project.id, gitpodYml); - } - const result = await getGitpodService().server.triggerPrebuild(project.id, null); - setStartPrebuildResult(result); - } catch (error) { - setPrebuildWasTriggered(false); - setEditorMessage(); + // retry fetching branches + if (project) { + detectProjectConfiguration(project); } - } + }, + }); + }; - const cancelPrebuild = async () => { - if (!project || !startPrebuildResult) { - return; - } - setPrebuildWasCancelled(true); - try { - await getGitpodService().server.cancelPrebuild(project.id, startPrebuildResult.prebuildId); - } catch (error) { - setEditorMessage(); - } finally { - setPrebuildWasCancelled(false); - } + const buildProject = async () => { + if (!project) { + return; + } + setEditorMessage(null); + if (!!startPrebuildResult) { + setStartPrebuildResult(undefined); + } + if (!!prebuildInstance) { + setPrebuildInstance(undefined); } + try { + setPrebuildWasTriggered(true); + if (!isEditorDisabled) { + await getGitpodService().server.setProjectConfiguration(project.id, gitpodYml); + } + const result = await getGitpodService().server.triggerPrebuild(project.id, null); + setStartPrebuildResult(result); + } catch (error) { + setPrebuildWasTriggered(false); + setEditorMessage( + , + ); + } + }; - const onInstanceUpdate = (instance: WorkspaceInstance) => { - setPrebuildInstance(instance); + const cancelPrebuild = async () => { + if (!project || !startPrebuildResult) { + return; + } + setPrebuildWasCancelled(true); + try { + await getGitpodService().server.cancelPrebuild(project.id, startPrebuildResult.prebuildId); + } catch (error) { + setEditorMessage( + , + ); + } finally { + setPrebuildWasCancelled(false); } + }; - useEffect(() => { document.title = 'Configure Project — Gitpod' }, []); + const onInstanceUpdate = (instance: WorkspaceInstance) => { + setPrebuildInstance(instance); + }; - const onNewWorkspace = async () => { - setButtonNewWorkspaceEnabled(false); - const redirectToNewWorkspace = () => { - // instead of `history.push` we want forcibly to redirect here in order to avoid a following redirect from `/` -> `/projects` (cf. App.tsx) - const url = new URL(window.location.toString()); - url.pathname = "/"; - url.hash = project?.cloneUrl!; - window.location.href = url.toString(); - } + useEffect(() => { + document.title = 'Configure Project — Gitpod'; + }, []); - if (prebuildInstance?.status.phase === "stopped" && !prebuildInstance?.status.conditions.failed && !prebuildInstance?.status.conditions.headlessTaskFailed) { - redirectToNewWorkspace(); - return; - } - if (!prebuildWasTriggered) { - await buildProject(); - } - redirectToNewWorkspace(); + const onNewWorkspace = async () => { + setButtonNewWorkspaceEnabled(false); + const redirectToNewWorkspace = () => { + // instead of `history.push` we want forcibly to redirect here in order to avoid a following redirect from `/` -> `/projects` (cf. App.tsx) + const url = new URL(window.location.toString()); + url.pathname = '/'; + url.hash = project?.cloneUrl!; + window.location.href = url.toString(); + }; + + if ( + prebuildInstance?.status.phase === 'stopped' && + !prebuildInstance?.status.conditions.failed && + !prebuildInstance?.status.conditions.headlessTaskFailed + ) { + redirectToNewWorkspace(); + return; + } + if (!prebuildWasTriggered) { + await buildProject(); } + redirectToNewWorkspace(); + }; - return -
-
-
- setSelectedEditor('.gitpod.yml')} /> - {!!dockerfile && setSelectedEditor('.gitpod.Dockerfile')} />} + return ( + +
+
+
+ setSelectedEditor('.gitpod.yml')} + /> + {!!dockerfile && ( + setSelectedEditor('.gitpod.Dockerfile')} + /> + )} +
+ {editorMessage} + }> + {selectedEditor === '.gitpod.yml' && ( + + )} + {selectedEditor === '.gitpod.Dockerfile' && ( + + )} + + {isDetecting && ( +
+ {showAuthBanner ? ( +
+
+ +
No Access
+
+ Authorize {showAuthBanner.host}{' '} + {showAuthBanner.scope ? ( + <> + and grant {showAuthBanner.scope} permission + + ) : ( + '' + )}{' '} +
to access project configuration. +
+ +
- {editorMessage} - }> - {selectedEditor === '.gitpod.yml' && - } - {selectedEditor === '.gitpod.Dockerfile' && - } - - {isDetecting &&
- {showAuthBanner ? ( -
-
- -
- No Access -
-
- Authorize {showAuthBanner.host} {showAuthBanner.scope ? (<>and grant {showAuthBanner.scope} permission) : ""}
to access project configuration. -
- -
-
- ) : (<> - - Detecting project configuration ... - - )} -
} + ) : ( + <> + + Detecting project configuration ... + + )}
-
-
{startPrebuildResult - ? - : (!prebuildWasTriggered &&
- -

No Recent Prebuild

-

Edit the project configuration on the left to get started. Learn more

-
) - }
-
- {prebuildWasTriggered && } -
- {(prebuildWasTriggered && prebuildInstance?.status.phase !== "stopped") - ? - : } - + )} +
+
+
+ {startPrebuildResult ? ( + + ) : ( + !prebuildWasTriggered && ( +
+ +

No Recent Prebuild

+

+ Edit the project configuration on the left to get started.{' '} + + Learn more + +

-
+ ) + )} +
+
+ {prebuildWasTriggered && } +
+ {prebuildWasTriggered && prebuildInstance?.status.phase !== 'stopped' ? ( + + ) : ( + + )} + +
- ; +
+ + ); } -function EditorMessage(props: { heading: string, message: string, type: 'success' | 'warning' }) { - return
- {props.heading} - {props.message} -
; -} \ No newline at end of file +function EditorMessage(props: { heading: string; message: string; type: 'success' | 'warning' }) { + return ( +
+ + {props.heading} + + {props.message} +
+ ); +} diff --git a/components/dashboard/src/projects/InstallGitHubApp.tsx b/components/dashboard/src/projects/InstallGitHubApp.tsx index 315eb965e9073b..20e32ea35058a2 100644 --- a/components/dashboard/src/projects/InstallGitHubApp.tsx +++ b/components/dashboard/src/projects/InstallGitHubApp.tsx @@ -4,84 +4,114 @@ * See License-AGPL.txt in the project root for license information. */ -import { useLocation } from "react-router"; -import InfoBox from "../components/InfoBox"; -import Modal from "../components/Modal"; -import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred"; -import { getGitpodService, gitpodHostUrl } from "../service/service"; -import { useState } from "react"; -import { openAuthorizeWindow } from "../provider-utils"; +import { useLocation } from 'react-router'; +import InfoBox from '../components/InfoBox'; +import Modal from '../components/Modal'; +import { Deferred } from '@gitpod/gitpod-protocol/lib/util/deferred'; +import { getGitpodService, gitpodHostUrl } from '../service/service'; +import { useState } from 'react'; +import { openAuthorizeWindow } from '../provider-utils'; async function registerApp(installationId: string, setModal: (modal: 'done' | string | undefined) => void) { - try { - await getGitpodService().server.registerGithubApp(installationId); + try { + await getGitpodService().server.registerGithubApp(installationId); - const result = new Deferred(1000 * 60 * 10 /* 10 min */); + const result = new Deferred(1000 * 60 * 10 /* 10 min */); - openAuthorizeWindow({ - host: "github.com", - scopes: ["repo"], - onSuccess: () => { - setModal('done'); - result.resolve(); - }, - onError: (payload) => { - let errorMessage: string; - if (typeof payload === "string") { - errorMessage = payload; - } else { - errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; - } - setModal(errorMessage); - } - }) + openAuthorizeWindow({ + host: 'github.com', + scopes: ['repo'], + onSuccess: () => { + setModal('done'); + result.resolve(); + }, + onError: (payload) => { + let errorMessage: string; + if (typeof payload === 'string') { + errorMessage = payload; + } else { + errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; + } + setModal(errorMessage); + }, + }); - return result.promise; - } catch (e) { - setModal(e.message); - } + return result.promise; + } catch (e) { + setModal(e.message); + } } export default function InstallGitHubApp() { - const location = useLocation(); - const [modal, setModal] = useState<'done' | string | undefined>(); - const params = new URLSearchParams(location.search); - const installationId = params.get("installation_id") || undefined; - if (!installationId) { - return
-
-
-

No Installation ID Found

-
Did you come here from the GitHub app's page?
-
-
+ const location = useLocation(); + const [modal, setModal] = useState<'done' | string | undefined>(); + const params = new URLSearchParams(location.search); + const installationId = params.get('installation_id') || undefined; + if (!installationId) { + return ( +
+
+
+

No Installation ID Found

+
Did you come here from the GitHub app's page?
+
- } +
+ ); + } - const goToApp = () => window.location.href = gitpodHostUrl.toString(); + const goToApp = () => (window.location.href = gitpodHostUrl.toString()); - return <> -
-
-
-

Install GitHub App

-
You are about to install the GitHub app for Gitpod.
- This action will also allow Gitpod to access private repositories. You can edit Git provider permissions later in user settings. -
- - -
-
+ return ( + <> +
+
+
+

Install GitHub App

+
You are about to install the GitHub app for Gitpod.
+ + This action will also allow Gitpod to access private repositories. You can edit Git provider permissions + later in user settings. + +
+ +
+
+
+
+ Go to Dashboard} + > +
+ The GitHub app was installed successfully. Have a look at the{' '} + + documentation + {' '} + to find out how to configure it.
- Go to Dashboard}> -
The GitHub app was installed successfully. Have a look at the documentation to find out how to configure it.
-
- Cancel, - - ]}> -
Could not install the GitHub app.
- {modal} -
; -} \ No newline at end of file +
+ + Cancel + , + , + ]} + > +
Could not install the GitHub app.
+ {modal} +
+ + ); +} diff --git a/components/dashboard/src/projects/NewProject.tsx b/components/dashboard/src/projects/NewProject.tsx index 029318414d3e40..7777d317e8a35b 100644 --- a/components/dashboard/src/projects/NewProject.tsx +++ b/components/dashboard/src/projects/NewProject.tsx @@ -4,650 +4,783 @@ * See License-AGPL.txt in the project root for license information. */ -import { useContext, useEffect, useState } from "react"; -import { getGitpodService, gitpodHostUrl } from "../service/service"; -import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "../provider-utils"; -import { AuthProviderInfo, Project, ProviderRepository, Team, TeamMemberInfo, User } from "@gitpod/gitpod-protocol"; -import { TeamsContext } from "../teams/teams-context"; -import { useLocation } from "react-router"; -import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu"; -import CaretDown from "../icons/CaretDown.svg"; -import Plus from "../icons/Plus.svg"; -import Switch from "../icons/Switch.svg"; -import search from "../icons/search.svg"; -import moment from "moment"; -import { UserContext } from "../user-context"; -import { trackEvent } from "../Analytics"; -import exclamation from "../images/exclamation.svg"; +import { useContext, useEffect, useState } from 'react'; +import { getGitpodService, gitpodHostUrl } from '../service/service'; +import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from '../provider-utils'; +import { AuthProviderInfo, Project, ProviderRepository, Team, TeamMemberInfo, User } from '@gitpod/gitpod-protocol'; +import { TeamsContext } from '../teams/teams-context'; +import { useLocation } from 'react-router'; +import ContextMenu, { ContextMenuEntry } from '../components/ContextMenu'; +import CaretDown from '../icons/CaretDown.svg'; +import Plus from '../icons/Plus.svg'; +import Switch from '../icons/Switch.svg'; +import search from '../icons/search.svg'; +import moment from 'moment'; +import { UserContext } from '../user-context'; +import { trackEvent } from '../Analytics'; +import exclamation from '../images/exclamation.svg'; export default function NewProject() { - const location = useLocation(); - const { teams } = useContext(TeamsContext); - const { user, setUser } = useContext(UserContext); - - const [selectedProviderHost, setSelectedProviderHost] = useState(); - const [reposInAccounts, setReposInAccounts] = useState([]); - const [repoSearchFilter, setRepoSearchFilter] = useState(""); - const [selectedAccount, setSelectedAccount] = useState(undefined); - const [showGitProviders, setShowGitProviders] = useState(false); - const [selectedRepo, setSelectedRepo] = useState(undefined); - const [selectedTeamOrUser, setSelectedTeamOrUser] = useState(undefined); - - const [showNewTeam, setShowNewTeam] = useState(false); - const [loaded, setLoaded] = useState(false); - - const [project, setProject] = useState(); - const [guessedConfigString, setGuessedConfigString] = useState(); - const [sourceOfConfig, setSourceOfConfig] = useState<"repo" | "db" | undefined>(); - - const [authProviders, setAuthProviders] = useState([]); - - useEffect(() => { - if (user && selectedProviderHost === undefined) { - if (user.identities.find(i => i.authProviderId === "Public-GitLab")) { - setSelectedProviderHost("gitlab.com"); - } else if (user.identities.find(i => i.authProviderId === "Public-GitHub")) { - setSelectedProviderHost("github.com"); - } else if (user.identities.find(i => i.authProviderId === "Public-Bitbucket")) { - setSelectedProviderHost("bitbucket.org"); - } - (async () => { - setAuthProviders(await getGitpodService().server.getAuthProviders()); - })(); - } - }, [user]); - - useEffect(() => { - const params = new URLSearchParams(location.search); - const teamParam = params.get("team"); - if (teamParam) { - window.history.replaceState({}, '', window.location.pathname); - const team = teams?.find(t => t.slug === teamParam); - setSelectedTeamOrUser(team); - } - if (params.get("user")) { - window.history.replaceState({}, '', window.location.pathname); - setSelectedTeamOrUser(user); - } - }, []); + const location = useLocation(); + const { teams } = useContext(TeamsContext); + const { user, setUser } = useContext(UserContext); + + const [selectedProviderHost, setSelectedProviderHost] = useState(); + const [reposInAccounts, setReposInAccounts] = useState([]); + const [repoSearchFilter, setRepoSearchFilter] = useState(''); + const [selectedAccount, setSelectedAccount] = useState(undefined); + const [showGitProviders, setShowGitProviders] = useState(false); + const [selectedRepo, setSelectedRepo] = useState(undefined); + const [selectedTeamOrUser, setSelectedTeamOrUser] = useState(undefined); + + const [showNewTeam, setShowNewTeam] = useState(false); + const [loaded, setLoaded] = useState(false); + + const [project, setProject] = useState(); + const [guessedConfigString, setGuessedConfigString] = useState(); + const [sourceOfConfig, setSourceOfConfig] = useState<'repo' | 'db' | undefined>(); + + const [authProviders, setAuthProviders] = useState([]); + + useEffect(() => { + if (user && selectedProviderHost === undefined) { + if (user.identities.find((i) => i.authProviderId === 'Public-GitLab')) { + setSelectedProviderHost('gitlab.com'); + } else if (user.identities.find((i) => i.authProviderId === 'Public-GitHub')) { + setSelectedProviderHost('github.com'); + } else if (user.identities.find((i) => i.authProviderId === 'Public-Bitbucket')) { + setSelectedProviderHost('bitbucket.org'); + } + (async () => { + setAuthProviders(await getGitpodService().server.getAuthProviders()); + })(); + } + }, [user]); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const teamParam = params.get('team'); + if (teamParam) { + window.history.replaceState({}, '', window.location.pathname); + const team = teams?.find((t) => t.slug === teamParam); + setSelectedTeamOrUser(team); + } + if (params.get('user')) { + window.history.replaceState({}, '', window.location.pathname); + setSelectedTeamOrUser(user); + } + }, []); - const [ teamMembers, setTeamMembers ] = useState>({}); - useEffect(() => { - if (!teams) { - return; - } - (async () => { - const members: Record = {}; - await Promise.all(teams.map(async (team) => { - try { - members[team.id] = await getGitpodService().server.getTeamMembers(team.id); - } catch (error) { - console.error('Could not get members of team', team, error); - } - })); - setTeamMembers(members); - })(); - }, [teams]); - - useEffect(() => { - if (selectedRepo) { - (async () => { - - try { - const guessedConfigStringPromise = getGitpodService().server.guessRepositoryConfiguration(selectedRepo.cloneUrl); - const repoConfigString = await getGitpodService().server.fetchRepositoryConfiguration(selectedRepo.cloneUrl); - if (repoConfigString) { - setSourceOfConfig("repo"); - } else { - setGuessedConfigString(await guessedConfigStringPromise || `tasks: + const [teamMembers, setTeamMembers] = useState>({}); + useEffect(() => { + if (!teams) { + return; + } + (async () => { + const members: Record = {}; + await Promise.all( + teams.map(async (team) => { + try { + members[team.id] = await getGitpodService().server.getTeamMembers(team.id); + } catch (error) { + console.error('Could not get members of team', team, error); + } + }), + ); + setTeamMembers(members); + })(); + }, [teams]); + + useEffect(() => { + if (selectedRepo) { + (async () => { + try { + const guessedConfigStringPromise = getGitpodService().server.guessRepositoryConfiguration( + selectedRepo.cloneUrl, + ); + const repoConfigString = await getGitpodService().server.fetchRepositoryConfiguration(selectedRepo.cloneUrl); + if (repoConfigString) { + setSourceOfConfig('repo'); + } else { + setGuessedConfigString( + (await guessedConfigStringPromise) || + `tasks: - init: | echo 'TODO: build project' command: | - echo 'TODO: start app'`); - setSourceOfConfig("db"); - } - } catch (error) { - console.error('Getting project configuration failed', error); - setSourceOfConfig(undefined); - } - - })(); - } - }, [selectedRepo]); - - useEffect(() => { - if (selectedTeamOrUser && selectedRepo) { - createProject(selectedTeamOrUser, selectedRepo); - } - }, [selectedTeamOrUser, selectedRepo]); - - useEffect(() => { - if (reposInAccounts.length === 0) { - setSelectedAccount(undefined); - } else { - const first = reposInAccounts[0]; - if (!!first.installationUpdatedAt) { - const mostRecent = reposInAccounts.reduce((prev, current) => (prev.installationUpdatedAt || 0) > (current.installationUpdatedAt || 0) ? prev : current); - setSelectedAccount(mostRecent.account); - } else { - setSelectedAccount(first.account); - } - } - - }, [reposInAccounts]); - - useEffect(() => { - setRepoSearchFilter(""); - }, [selectedAccount]); - - useEffect(() => { - if (!selectedProviderHost) { - return; - } - (async () => { - await updateReposInAccounts(); - })(); - }, [selectedProviderHost]); - - useEffect(() => { - if (project && sourceOfConfig) { - (async () => { - if (guessedConfigString && sourceOfConfig === "db") { - await getGitpodService().server.setProjectConfiguration(project.id, guessedConfigString); - } - await getGitpodService().server.triggerPrebuild(project.id, null); - })(); - } - }, [project, sourceOfConfig]); - - const isGitHub = () => selectedProviderHost === "github.com"; - - const updateReposInAccounts = async (installationId?: string) => { - setLoaded(false); - setReposInAccounts([]); - if (!selectedProviderHost) { - return []; - } - try { - const repos = await getGitpodService().server.getProviderRepositoriesForUser({ provider: selectedProviderHost, hints: { installationId } }); - setReposInAccounts(repos); - setLoaded(true); - return repos; + echo 'TODO: start app'`, + ); + setSourceOfConfig('db'); + } } catch (error) { - console.log(error); + console.error('Getting project configuration failed', error); + setSourceOfConfig(undefined); } - return []; + })(); } + }, [selectedRepo]); - const reconfigure = () => { - openReconfigureWindow({ - account: selectedAccount, - onSuccess: (p: { installationId: string, setupAction?: string }) => { - updateReposInAccounts(p.installationId); - trackEvent("organisation_authorised", { - installation_id: p.installationId, - setup_action: p.setupAction - }); - } - }); + useEffect(() => { + if (selectedTeamOrUser && selectedRepo) { + createProject(selectedTeamOrUser, selectedRepo); } + }, [selectedTeamOrUser, selectedRepo]); - const createProject = async (teamOrUser: Team | User, repo: ProviderRepository) => { - if (!selectedProviderHost) { - return; - } - const repoSlug = repo.path || repo.name; - - try { - const project = await getGitpodService().server.createProject({ - name: repo.name, - slug: repoSlug, - cloneUrl: repo.cloneUrl, - account: repo.account, - provider: selectedProviderHost, - ...(User.is(teamOrUser) ? { userId: teamOrUser.id } : { teamId: teamOrUser.id }), - appInstallationId: String(repo.installationId), - }); - - setProject(project); - } catch (error) { - const message = (error && error?.message) || "Failed to create new project." - window.alert(message); - } - } - - const toSimpleName = (fullName: string) => { - const splitted = fullName.split("/"); - if (splitted.length < 2) { - return fullName; - } - return splitted.shift() && splitted.join("/"); + useEffect(() => { + if (reposInAccounts.length === 0) { + setSelectedAccount(undefined); + } else { + const first = reposInAccounts[0]; + if (!!first.installationUpdatedAt) { + const mostRecent = reposInAccounts.reduce((prev, current) => + (prev.installationUpdatedAt || 0) > (current.installationUpdatedAt || 0) ? prev : current, + ); + setSelectedAccount(mostRecent.account); + } else { + setSelectedAccount(first.account); + } } + }, [reposInAccounts]); - const accounts = new Map(); - reposInAccounts.forEach(r => { if (!accounts.has(r.account)) accounts.set(r.account, { avatarUrl: r.accountAvatarUrl }) }); - - const getDropDownEntries = (accounts: Map) => { - const renderItemContent = (label: string, icon: string, addClasses?: string) => (
- - {label} -
) - const result: ContextMenuEntry[] = []; - - if (!selectedAccount && user && user.name && user.avatarUrl) { - result.push({ - title: "user", - customContent: renderItemContent(user?.name, user?.avatarUrl), - separator: true, - }) - } - for (const [account, props] of accounts.entries()) { - result.push({ - title: account, - customContent: renderItemContent(account, props.avatarUrl, "font-semibold"), - separator: true, - onClick: () => setSelectedAccount(account), - }) - } - if (isGitHub()) { - result.push({ - title: "Add another GitHub account", - customContent: renderItemContent("Add GitHub Orgs or Account", Plus), - separator: true, - onClick: () => reconfigure(), - }) - } - result.push({ - title: "Select another Git Provider to continue with", - customContent: renderItemContent("Select Git Provider", Switch), - onClick: () => setShowGitProviders(true), - }) + useEffect(() => { + setRepoSearchFilter(''); + }, [selectedAccount]); - return result; + useEffect(() => { + if (!selectedProviderHost) { + return; } - - const renderSelectRepository = () => { - - const noReposAvailable = reposInAccounts.length === 0; - const filteredRepos = Array.from(reposInAccounts).filter(r => r.account === selectedAccount && `${r.name}`.toLowerCase().includes(repoSearchFilter.toLowerCase())); - const icon = selectedAccount && accounts.get(selectedAccount)?.avatarUrl; - - const showSearchInput = !!repoSearchFilter || filteredRepos.length > 0; - - const userLink = (r: ProviderRepository) => { - return `https://${new URL(r.cloneUrl).host}/${r.inUse?.userName}` + (async () => { + await updateReposInAccounts(); + })(); + }, [selectedProviderHost]); + + useEffect(() => { + if (project && sourceOfConfig) { + (async () => { + if (guessedConfigString && sourceOfConfig === 'db') { + await getGitpodService().server.setProjectConfiguration(project.id, guessedConfigString); } + await getGitpodService().server.triggerPrebuild(project.id, null); + })(); + } + }, [project, sourceOfConfig]); - const projectText = () => { - return

Projects allow you to manage prebuilds and workspaces for your repository. Learn more

- } - - const renderRepos = () => (<> - {projectText()} -

{loaded && noReposAvailable ? 'Select account on ' : 'Select a Git repository on '}{selectedProviderHost} ( setShowGitProviders(true)}>change)

-
-
- -
- {!selectedAccount && user && user.name && user.avatarUrl && ( - <> - - - - )} - {selectedAccount && ( - <> - - - - )} - -
-
- {showSearchInput && ( -
- - setRepoSearchFilter(e.target.value)}> -
- )} -
-
- {filteredRepos.length > 0 && ( -
- {filteredRepos.map((r, index) => ( -
- -
-
{toSimpleName(r.name)}
-

Updated {moment(r.updatedAt).fromNow()}

-
-
-
- {!r.inUse ? ( - - ) : ( -

- @{r.inUse.userName} already
added this repo -

- )} -
-
-
- ))} -
- )} - {!noReposAvailable && filteredRepos.length === 0 && ( -

No Results

- )} - {loaded && noReposAvailable && isGitHub() && (
-
- - Additional authorization is required for our GitHub App to watch your repositories and trigger prebuilds. - -
- -
-
)} -
- -
- {reposInAccounts.length > 0 && isGitHub() && ( - - )} -

- Teams & Projects are currently in Beta. Send feedback -

- - ); + const isGitHub = () => selectedProviderHost === 'github.com'; - const renderLoadingState = () => ( -
- {projectText()} -
-
-
-

- Loading ... -

-
-
-
-
) - - const onGitProviderSeleted = async (host: string, updateUser?: boolean) => { - if (updateUser) { - setUser(await getGitpodService().server.getLoggedInUser()); - } - setShowGitProviders(false); - setSelectedProviderHost(host); - } + const updateReposInAccounts = async (installationId?: string) => { + setLoaded(false); + setReposInAccounts([]); + if (!selectedProviderHost) { + return []; + } + try { + const repos = await getGitpodService().server.getProviderRepositoriesForUser({ + provider: selectedProviderHost, + hints: { installationId }, + }); + setReposInAccounts(repos); + setLoaded(true); + return repos; + } catch (error) { + console.log(error); + } + return []; + }; + + const reconfigure = () => { + openReconfigureWindow({ + account: selectedAccount, + onSuccess: (p: { installationId: string; setupAction?: string }) => { + updateReposInAccounts(p.installationId); + trackEvent('organisation_authorised', { + installation_id: p.installationId, + setup_action: p.setupAction, + }); + }, + }); + }; - if (!loaded) { - return renderLoadingState(); - } + const createProject = async (teamOrUser: Team | User, repo: ProviderRepository) => { + if (!selectedProviderHost) { + return; + } + const repoSlug = repo.path || repo.name; + + try { + const project = await getGitpodService().server.createProject({ + name: repo.name, + slug: repoSlug, + cloneUrl: repo.cloneUrl, + account: repo.account, + provider: selectedProviderHost, + ...(User.is(teamOrUser) ? { userId: teamOrUser.id } : { teamId: teamOrUser.id }), + appInstallationId: String(repo.installationId), + }); + + setProject(project); + } catch (error) { + const message = (error && error?.message) || 'Failed to create new project.'; + window.alert(message); + } + }; - if (showGitProviders) { - return (); - } + const toSimpleName = (fullName: string) => { + const splitted = fullName.split('/'); + if (splitted.length < 2) { + return fullName; + } + return splitted.shift() && splitted.join('/'); + }; + + const accounts = new Map(); + reposInAccounts.forEach((r) => { + if (!accounts.has(r.account)) accounts.set(r.account, { avatarUrl: r.accountAvatarUrl }); + }); + + const getDropDownEntries = (accounts: Map) => { + const renderItemContent = (label: string, icon: string, addClasses?: string) => ( +
+ + {label} +
+ ); + const result: ContextMenuEntry[] = []; + + if (!selectedAccount && user && user.name && user.avatarUrl) { + result.push({ + title: 'user', + customContent: renderItemContent(user?.name, user?.avatarUrl), + separator: true, + }); + } + for (const [account, props] of accounts.entries()) { + result.push({ + title: account, + customContent: renderItemContent(account, props.avatarUrl, 'font-semibold'), + separator: true, + onClick: () => setSelectedAccount(account), + }); + } + if (isGitHub()) { + result.push({ + title: 'Add another GitHub account', + customContent: renderItemContent('Add GitHub Orgs or Account', Plus), + separator: true, + onClick: () => reconfigure(), + }); + } + result.push({ + title: 'Select another Git Provider to continue with', + customContent: renderItemContent('Select Git Provider', Switch), + onClick: () => setShowGitProviders(true), + }); + + return result; + }; + + const renderSelectRepository = () => { + const noReposAvailable = reposInAccounts.length === 0; + const filteredRepos = Array.from(reposInAccounts).filter( + (r) => r.account === selectedAccount && `${r.name}`.toLowerCase().includes(repoSearchFilter.toLowerCase()), + ); + const icon = selectedAccount && accounts.get(selectedAccount)?.avatarUrl; + + const showSearchInput = !!repoSearchFilter || filteredRepos.length > 0; + + const userLink = (r: ProviderRepository) => { + return `https://${new URL(r.cloneUrl).host}/${r.inUse?.userName}`; + }; - return renderRepos(); + const projectText = () => { + return ( +

+ Projects allow you to manage prebuilds and workspaces for your repository.{' '} + + Learn more + +

+ ); }; - const renderSelectTeam = () => { - const userFullName = user?.fullName || user?.name || '...'; - const teamsToRender = teams || []; - return (<> -

Select team or personal account

-
-