From 7bab1e8938194737d614a06d6f76eb0dda2002fb Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Wed, 17 Mar 2021 09:13:38 +0000 Subject: [PATCH] [dashboard] improved workspaces --- components/dashboard/package.json | 3 +- .../dashboard/src/components/ContextMenu.tsx | 69 ++++++++ .../dashboard/src/components/DropDown.tsx | 35 ++++ components/dashboard/src/components/Modal.tsx | 16 +- .../dashboard/src/components/Separator.tsx | 2 +- .../dashboard/src/components/Toggle.tsx | 28 ---- components/dashboard/src/index.css | 7 +- components/dashboard/src/settings/Account.tsx | 2 +- .../dashboard/src/start/CreateWorkspace.tsx | 2 +- components/dashboard/src/tailwind.output.css | 39 +++-- .../src/workspaces/WorkspaceEntry.tsx | 157 ++++++++++++++---- .../dashboard/src/workspaces/Workspaces.tsx | 62 ++++--- yarn.lock | 5 + 13 files changed, 317 insertions(+), 110 deletions(-) create mode 100644 components/dashboard/src/components/ContextMenu.tsx create mode 100644 components/dashboard/src/components/DropDown.tsx delete mode 100644 components/dashboard/src/components/Toggle.tsx diff --git a/components/dashboard/package.json b/components/dashboard/package.json index dd3ce14cdeb160..47c4621b4ec50b 100644 --- a/components/dashboard/package.json +++ b/components/dashboard/package.json @@ -5,6 +5,7 @@ "private": true, "dependencies": { "@gitpod/gitpod-protocol": "0.1.5", + "moment": "^2.29.1", "react": "^17.0.1", "react-dom": "^17.0.1", "react-router-dom": "^5.2.0" @@ -20,8 +21,8 @@ "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", - "@types/react-router-dom": "^5.1.7", "@types/react-router": "^5.1.12", + "@types/react-router-dom": "^5.1.7", "@typescript-eslint/eslint-plugin": "^4.15.2", "@typescript-eslint/parser": "^4.15.2", "autoprefixer": "^9.8.6", diff --git a/components/dashboard/src/components/ContextMenu.tsx b/components/dashboard/src/components/ContextMenu.tsx new file mode 100644 index 00000000000000..dd6ad1586c1a27 --- /dev/null +++ b/components/dashboard/src/components/ContextMenu.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; + +export interface ContextMenuProps { + children: React.ReactChild[] | React.ReactChild; + menuEntries: ContextMenuEntry[]; +} + +export interface ContextMenuEntry { + title: string; + active?: boolean; + /** + * whether a separator line should be rendered below this item + */ + separator?: boolean; + customFontStyle?: string; + onClick?: ()=>void; + href?: string; +} + +function ContextMenu(props: ContextMenuProps) { + const [expanded, setExpanded] = useState(false); + const toggleExpanded = () => { + setExpanded(!expanded); + } + + if (expanded) { + // HACK! I want to skip the bubbling phase of the current click + setTimeout(() => { + window.addEventListener('click', () => setExpanded(false), { once: true }); + }, 0); + } + + const enhancedEntries = props.menuEntries.map(e => { + return { + ... e, + onClick: () => { + e.onClick && e.onClick(); + toggleExpanded(); + } + } + }) + const font = "text-gray-400 hover:text-gray-800" + return ( +
+
{ + toggleExpanded(); + e.preventDefault(); + }}> + {props.children} +
+ {expanded? +
+ {enhancedEntries.map(e => { + const entry =
+
{e.title}
{e.active ?
: null} +
+ return + {entry} + + })} +
+ : + null + } +
+ ); +} + +export default ContextMenu; \ No newline at end of file diff --git a/components/dashboard/src/components/DropDown.tsx b/components/dashboard/src/components/DropDown.tsx new file mode 100644 index 00000000000000..b01aad75a7be58 --- /dev/null +++ b/components/dashboard/src/components/DropDown.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import ContextMenu from './ContextMenu'; + +export interface DropDownProps { + entries: { + title: string, + onClick: ()=>void + }[]; +} + +function Arrow(props: {up: boolean}) { + return +} + +function DropDown(props: DropDownProps) { + const [current, setCurrent] = useState(props.entries[0].title); + const enhancedEntries = props.entries.map(e => { + return { + ...e, + active: e.title === current, + onClick: () => { + e.onClick(); + setCurrent(e.title); + } + } + }) + const font = "text-gray-400 text-sm leading-1" + return ( + + {current} + + ); +} + +export default DropDown; \ No newline at end of file diff --git a/components/dashboard/src/components/Modal.tsx b/components/dashboard/src/components/Modal.tsx index b010c1ad43ffd6..bdbf80c0007cb0 100644 --- a/components/dashboard/src/components/Modal.tsx +++ b/components/dashboard/src/components/Modal.tsx @@ -1,24 +1,20 @@ -import { useState } from "react"; - export default function Modal(props: { children: React.ReactChild[] | React.ReactChild, visible: boolean, closeable?: boolean, - onClose?: () => void + onClose: () => void }) { - const [visible, setVisible] = useState(props.visible); - const hide = () => setVisible(false); - if (!visible) { + if (!props.visible) { return null; } + setTimeout(() => window.addEventListener('click', props.onClose, {once: true}), 0); return ( -
+
-
+
{props.closeable !== false && ( -
+
)} - {props.children}
diff --git a/components/dashboard/src/components/Separator.tsx b/components/dashboard/src/components/Separator.tsx index 9bd3c0149b0961..0443c4c9e49961 100644 --- a/components/dashboard/src/components/Separator.tsx +++ b/components/dashboard/src/components/Separator.tsx @@ -1,3 +1,3 @@ export default function Separator() { - return
; + return
; } \ No newline at end of file diff --git a/components/dashboard/src/components/Toggle.tsx b/components/dashboard/src/components/Toggle.tsx deleted file mode 100644 index b3022fb94d4e63..00000000000000 --- a/components/dashboard/src/components/Toggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react'; - -function Toggle(props: { entries: { title: string, onActivate: ()=>void}[], active?: string }) { - const [active, setActive] = useState(props.active || props.entries[0].title); - return
- {props.entries.map((e, i) => { - let className = "mt-1 block w-20 text-sm border-2 border-gray-200 focus:outline-none"; - if (active === e.title) { - className += " text-gray-600 bg-gray-200"; - } else { - className += " text-gray-400"; - } - if (i === 0) { - className += " rounded-l-md"; - } - if (i === props.entries.length - 1) { - className += " border-l-0 rounded-r-md"; - } - const onClick = () => { - setActive(e.title); - e.onActivate(); - } - return - })} -
; -} - -export default Toggle; \ No newline at end of file diff --git a/components/dashboard/src/index.css b/components/dashboard/src/index.css index 202124f97e168c..9fc9b60a51108f 100644 --- a/components/dashboard/src/index.css +++ b/components/dashboard/src/index.css @@ -28,6 +28,11 @@ } input[type=text] { - @apply text-xs block w-56 font-medium text-gray-500 rounded-md bg-gray-100 border-gray-300 border-2 focus:border-gray-300 focus:bg-white focus:ring-0; + @apply text-xs block w-56 text-sm text-gray-600 rounded-md focus:border-gray-300 focus:bg-white focus:ring-0; } + + input[type=text]::placeholder { + @apply text-gray-400 + } + } \ No newline at end of file diff --git a/components/dashboard/src/settings/Account.tsx b/components/dashboard/src/settings/Account.tsx index 814b3edc7a22a5..9471692c0d7358 100644 --- a/components/dashboard/src/settings/Account.tsx +++ b/components/dashboard/src/settings/Account.tsx @@ -12,7 +12,7 @@ export default function Account() { const close = () => setModal(false); return
- +

Do you really want to delete your account?

This action will remove all the data associated with your account in Gitpod and cannot be reversed.

diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index 3d12735fbdef57..5a1147441da19a 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -81,7 +81,7 @@ export class CreateWorkspace extends React.Component + statusMessage = {}}>

Running Workspaces

You already have running workspaces with the same context. You can open an existing one or open a new workspace.

diff --git a/components/dashboard/src/tailwind.output.css b/components/dashboard/src/tailwind.output.css index 4b25b292e34c70..1cbeda9dcfef8b 100644 --- a/components/dashboard/src/tailwind.output.css +++ b/components/dashboard/src/tailwind.output.css @@ -858,33 +858,20 @@ button { color: rgba(255, 255, 255, var(--tw-text-opacity)); } -input[type=text] { - --tw-bg-opacity: 1; - background-color: rgba(245, 245, 244, var(--tw-bg-opacity)); -} - input[type=text]:focus { --tw-bg-opacity: 1; background-color: rgba(255, 255, 255, var(--tw-bg-opacity)); -} - -input[type=text] { - --tw-border-opacity: 1; - border-color: rgba(214, 211, 209, var(--tw-border-opacity)); -} - -input[type=text]:focus { --tw-border-opacity: 1; border-color: rgba(214, 211, 209, var(--tw-border-opacity)); } input[type=text] { border-radius: 0.375rem; - border-width: 2px; display: block; - font-weight: 500; font-size: 0.75rem; line-height: 1rem; + font-size: 0.875rem; + line-height: 1.25rem; } input[type=text]:focus { @@ -895,10 +882,30 @@ input[type=text]:focus { input[type=text] { --tw-text-opacity: 1; - color: rgba(120, 113, 108, var(--tw-text-opacity)); + color: rgba(87, 83, 78, var(--tw-text-opacity)); width: 14rem; } +input[type=text]::-webkit-input-placeholder { + --tw-text-opacity: 1; + color: rgba(168, 162, 158, var(--tw-text-opacity)) +} + +input[type=text]:-ms-input-placeholder { + --tw-text-opacity: 1; + color: rgba(168, 162, 158, var(--tw-text-opacity)) +} + +input[type=text]::-ms-input-placeholder { + --tw-text-opacity: 1; + color: rgba(168, 162, 158, var(--tw-text-opacity)) +} + +input[type=text]::placeholder { + --tw-text-opacity: 1; + color: rgba(168, 162, 158, var(--tw-text-opacity)) +} + .space-y-0 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0 !important; margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse))) !important; diff --git a/components/dashboard/src/workspaces/WorkspaceEntry.tsx b/components/dashboard/src/workspaces/WorkspaceEntry.tsx index 98fbed7a519180..61865e6f150c78 100644 --- a/components/dashboard/src/workspaces/WorkspaceEntry.tsx +++ b/components/dashboard/src/workspaces/WorkspaceEntry.tsx @@ -1,64 +1,152 @@ import { CommitContext, Workspace, WorkspaceInfo, WorkspaceInstance, WorkspaceInstancePhase } from '@gitpod/gitpod-protocol'; +import { GitpodHostUrl } from '@gitpod/gitpod-protocol/lib/util/gitpod-host-url'; +import ContextMenu, { ContextMenuEntry } from '../components/ContextMenu'; import ThreeDots from '../icons/ThreeDots.svg'; +import moment from 'moment'; +import { gitpodService } from '../service/service'; +import Modal from '../components/Modal'; +import { MouseEvent, useState } from 'react'; export function WorkspaceEntry(desc: WorkspaceInfo) { + const [isModalVisible, setModalVisible] = useState(false); + const [isChangesModalVisible, setChangesModalVisible] = useState(false); const state: WorkspaceInstancePhase = desc.latestInstance?.status?.phase || 'stopped'; - let stateClassName = 'rounded-full px-3 text-sm leading-relaxed align-middle'; + let stateClassName = 'rounded-full w-3 h-3 text-sm align-middle'; switch (state) { case 'running': { - stateClassName += ' bg-green-200 text-green-700' + stateClassName += ' bg-green-500' break; } case 'stopped': { - stateClassName += ' bg-gray-200 text-gray-600' + stateClassName += ' bg-gray-400' break; } case 'interrupted': { - stateClassName += ' bg-red-200 text-red-600' + stateClassName += ' bg-red-400' + break; + } + default: { + stateClassName += ' bg-gitpod-kumquat' break; } } - let changesClassName = ''; const pendingChanges = getPendingChanges(desc.latestInstance); - if (pendingChanges.length > 0) { - changesClassName += ' text-yellow-700'; + const numberOfChanges = pendingChanges.reduceRight((i, c) => i + c.items.length, 0) + let changesLabel = 'No Changes'; + if (numberOfChanges === 1) { + changesLabel = '1 Change'; + } else if (numberOfChanges > 1) { + changesLabel = numberOfChanges + ' Changes'; } const currentBranch = desc.latestInstance?.status.repo?.branch || Workspace.getBranchName(desc.workspace) || ''; const ws = desc.workspace; - return
-
-   + const startUrl = new GitpodHostUrl(window.location.href).with({ + pathname: '/start/', + hash: '#' + ws.id + }); + const downloadURL = new GitpodHostUrl(window.location.href).with({ + pathname: `/workspace-download/get/${ws.id}` + }).toString(); + const menuEntries: ContextMenuEntry[] = [ + { + title: 'Open', + href: startUrl.toString() + }, + { + title: 'Download', + href: downloadURL + }, + { + title: 'Share', + active: !!ws.shareable, + onClick: () => { + gitpodService.server.controlAdmission(ws.id, ws.shareable ? "owner" : "everyone"); + } + }, + { + title: 'Pin', + active: !!ws.pinned, + separator: true, + onClick: () => { + gitpodService.server.updateWorkspaceUserPin(ws.id, 'toggle') + } + }, + { + title: 'Delete', + customFontStyle: 'text-red-600', + onClick: () => { + setModalVisible(true); + } + } + ]; + const project = getProject(ws); + const startWsOnClick = (event: MouseEvent) => { + window.location.href = startUrl.toString(); + } + const showChanges = (event: MouseEvent) => { + setChangesModalVisible(true); + } + return
+
+
+   +
-
-
{ws.id}
-
{getProject(ws)}
+ -
-
#
+
-
{ws.description}
-
{ws.contextURL}
+
{ws.description}
+
{ws.contextURL}
-
-
- -
+
0 ? showChanges: startWsOnClick}>
-
{currentBranch}
-
{pendingChanges.toString() || 'No Changes'}
+
{currentBranch}
+ { + numberOfChanges > 0 ? +
{changesLabel}
+ : +
No Changes
+ } + setChangesModalVisible(false)}> + {getChangesPopup(pendingChanges)} +
-
-
- Actions -
+
+
{moment(desc.latestInstance?.startedTime).fromNow()}
+
+
+ + Actions +
+ setModalVisible(false)}> +
+

Delete {ws.id}

+
+

Do you really want to delete this workspace?

+
+
+
+ +
+
+
; } +interface PendingChanges { + message: string, items: string[] +} -function getPendingChanges(wsi?: WorkspaceInstance): { message: string, items: string[] }[] { +function getPendingChanges(wsi?: WorkspaceInstance): PendingChanges[] { const pendingChanges: { message: string, items: string[] }[] = []; const repo = wsi?.status.repo; if (repo) { @@ -88,6 +176,17 @@ function getProject(ws: Workspace) { if (CommitContext.is(ws.context)) { return `${ws.context.repository.host}/${ws.context.repository.owner}/${ws.context.repository.name}`; } else { - return 'Unknown'; + return undefined; } +} + +function getChangesPopup(changes: PendingChanges[]) { + return
+ {changes.map(c => { + return
+
{c.message}
+ {c.items.map(i =>
{i}
)} +
; + })} +
} \ No newline at end of file diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index f35120f4b8b8a2..e335428ccfe93a 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -1,7 +1,7 @@ import React from "react"; import { GitpodService, WorkspaceInfo } from "@gitpod/gitpod-protocol"; import Header from "../components/Header"; -import Toggle from "../components/Toggle" +import DropDown from "../components/DropDown" import { WorkspaceModel } from "./workspace-model"; import { WorkspaceEntry } from "./WorkspaceEntry"; @@ -19,7 +19,7 @@ export class Workspaces extends React.Component { @@ -32,25 +32,43 @@ export class Workspaces extends React.Component this.workspaceModel.active = true; const onRecent = () => this.workspaceModel.active = false; return <> -
- -
- {console.log(v)}} /> - - -
-
- { - this.state?.workspaces.map(e => { - return - }) - } -
; +
+ +
+
+
+ + + +
+ { console.log(v) }} /> +
+
+
+ +
+
+
+
+
+
Name
+
Context
+
Pending Changes
+
Last Active
+
+
+ { + this.state?.workspaces.map(e => { + return + }) + } +
; } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 90f590c831d414..38f9fc384d23a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14841,6 +14841,11 @@ moment@^2.18.1: resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== +moment@^2.29.1: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"