feat: Add "Outdated" tooltip and "Update version" button in the Workspaces page (#2322)

This commit is contained in:
Bruno Quaresma
2022-06-15 13:52:05 +00:00
committed by GitHub
parent e9f87f12ec
commit b225953f68
6 changed files with 460 additions and 102 deletions
@@ -72,6 +72,7 @@ export const GlobalSnackbar: React.FC = () => {
return (
<EnterpriseSnackbar
key={notification.msg}
open={open}
variant={variantFromMsgType(notification.msgType)}
message={
@@ -3,9 +3,11 @@ import Popover from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import HelpIcon from "@material-ui/icons/HelpOutline"
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
import { useState } from "react"
import React, { createContext, useContext, useState } from "react"
import { Stack } from "../Stack/Stack"
type Icon = typeof HelpIcon
type Size = "small" | "medium"
export interface HelpTooltipProps {
// Useful to test on storybook
@@ -13,15 +15,38 @@ export interface HelpTooltipProps {
size?: Size
}
const HelpTooltipContext = createContext<{ open: boolean; onClose: () => void } | undefined>(undefined)
const useHelpTooltip = () => {
const helpTooltipContext = useContext(HelpTooltipContext)
if (!helpTooltipContext) {
throw new Error("This hook should be used in side of the HelpTooltipContext.")
}
return helpTooltipContext
}
export const HelpTooltip: React.FC<HelpTooltipProps> = ({ children, open, size = "medium" }) => {
const styles = useStyles({ size })
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
open = open ?? Boolean(anchorEl)
const id = open ? "help-popover" : undefined
const onClose = () => {
setAnchorEl(null)
}
return (
<>
<button aria-describedby={id} className={styles.button} onClick={(event) => setAnchorEl(event.currentTarget)}>
<button
aria-describedby={id}
className={styles.button}
onClick={(event) => {
event.stopPropagation()
setAnchorEl(event.currentTarget)
}}
>
<HelpIcon className={styles.icon} />
</button>
<Popover
@@ -29,9 +54,7 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({ children, open, size =
id={id}
open={open}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null)
}}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
@@ -41,7 +64,7 @@ export const HelpTooltip: React.FC<HelpTooltipProps> = ({ children, open, size =
horizontal: "left",
}}
>
{children}
<HelpTooltipContext.Provider value={{ open, onClose }}>{children}</HelpTooltipContext.Provider>
</Popover>
</>
)
@@ -70,6 +93,25 @@ export const HelpTooltipLink: React.FC<{ href: string }> = ({ children, href })
)
}
export const HelpTooltipAction: React.FC<{ icon: Icon; onClick: () => void }> = ({ children, icon: Icon, onClick }) => {
const styles = useStyles()
const tooltip = useHelpTooltip()
return (
<button
className={styles.action}
onClick={(event) => {
event.stopPropagation()
onClick()
tooltip.onClose()
}}
>
<Icon className={styles.actionIcon} />
{children}
</button>
)
}
export const HelpTooltipLinksGroup: React.FC = ({ children }) => {
const styles = useStyles()
@@ -110,11 +152,12 @@ const useStyles = makeStyles((theme) => ({
padding: 0,
border: 0,
background: "transparent",
color: theme.palette.text.secondary,
color: theme.palette.text.primary,
opacity: 0.5,
cursor: "pointer",
"&:hover": {
color: theme.palette.text.primary,
opacity: 0.75,
},
},
@@ -156,4 +199,22 @@ const useStyles = makeStyles((theme) => ({
linksGroup: {
marginTop: theme.spacing(2),
},
action: {
display: "flex",
alignItems: "center",
background: "none",
border: 0,
color: theme.palette.primary.light,
padding: 0,
cursor: "pointer",
fontSize: 14,
},
actionIcon: {
color: "inherit",
width: 14,
height: 14,
marginRight: theme.spacing(1),
},
}))
@@ -10,13 +10,14 @@ import { WorkspacesPageView } from "./WorkspacesPageView"
const WorkspacesPage: FC = () => {
const [workspacesState, send] = useMachine(workspacesMachine)
const [searchParams, setSearchParams] = useSearchParams()
const { workspaceRefs } = workspacesState.context
useEffect(() => {
const filter = searchParams.get("filter")
const query = filter !== null ? filter : workspaceFilterQuery.me
send({
type: "SET_FILTER",
type: "GET_WORKSPACES",
query,
})
}, [searchParams, send])
@@ -30,7 +31,7 @@ const WorkspacesPage: FC = () => {
<WorkspacesPageView
filter={workspacesState.context.filter}
loading={workspacesState.hasTag("loading")}
workspaces={workspacesState.context.workspaces}
workspaceRefs={workspaceRefs}
onFilter={(query) => {
searchParams.set("filter", query)
setSearchParams(searchParams)
@@ -1,7 +1,9 @@
import { ComponentMeta, Story } from "@storybook/react"
import { spawn } from "xstate"
import { ProvisionerJobStatus, Workspace, WorkspaceTransition } from "../../api/typesGenerated"
import { MockWorkspace } from "../../testHelpers/entities"
import { workspaceFilterQuery } from "../../util/workspace"
import { workspaceItemMachine } from "../../xServices/workspaces/workspacesXService"
import { WorkspacesPageView, WorkspacesPageViewProps } from "./WorkspacesPageView"
export default {
@@ -14,9 +16,11 @@ const Template: Story<WorkspacesPageViewProps> = (args) => <WorkspacesPageView {
const createWorkspaceWithStatus = (
status: ProvisionerJobStatus,
transition: WorkspaceTransition = "start",
outdated = false,
): Workspace => {
return {
...MockWorkspace,
outdated,
latest_build: {
...MockWorkspace.latest_build,
transition,
@@ -41,22 +45,29 @@ const workspaces: { [key in ProvisionerJobStatus]: Workspace } = {
export const AllStates = Template.bind({})
AllStates.args = {
workspaces: [
workspaceRefs: [
...Object.values(workspaces),
createWorkspaceWithStatus("running", "stop"),
createWorkspaceWithStatus("succeeded", "stop"),
createWorkspaceWithStatus("running", "delete"),
],
].map((data) => spawn(workspaceItemMachine.withContext({ data }))),
}
export const Outdated = Template.bind({})
Outdated.args = {
workspaceRefs: [createWorkspaceWithStatus("running", "stop", true)].map((data) =>
spawn(workspaceItemMachine.withContext({ data })),
),
}
export const OwnerHasNoWorkspaces = Template.bind({})
OwnerHasNoWorkspaces.args = {
workspaces: [],
workspaceRefs: [],
filter: workspaceFilterQuery.me,
}
export const NoResults = Template.bind({})
NoResults.args = {
workspaces: [],
workspaceRefs: [],
filter: "searchtearmwithnoresults",
}
@@ -13,19 +13,21 @@ import TableRow from "@material-ui/core/TableRow"
import TextField from "@material-ui/core/TextField"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import RefreshIcon from "@material-ui/icons/Refresh"
import SearchIcon from "@material-ui/icons/Search"
import useTheme from "@material-ui/styles/useTheme"
import { useActor } from "@xstate/react"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { FormikErrors, useFormik } from "formik"
import { FC, useState } from "react"
import { Link as RouterLink, useNavigate } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData"
import { CloseDropdown, OpenDropdown } from "../../components/DropdownArrows/DropdownArrows"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import {
HelpTooltip,
HelpTooltipAction,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
@@ -37,6 +39,7 @@ import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { getDisplayStatus, workspaceFilterQuery } from "../../util/workspace"
import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService"
dayjs.extend(relativeTime)
@@ -54,6 +57,10 @@ export const Language = {
workspaceTooltipLink1: "Create workspaces",
workspaceTooltipLink2: "Connect with SSH",
workspaceTooltipLink3: "Editors and IDEs",
outdatedLabel: "Outdated",
upToDateLabel: "Up to date",
versionTooltipText: "This workspace version is outdated and a newer version is available.",
updateVersionLabel: "Update version",
}
const WorkspaceHelpTooltip: React.FC = () => {
@@ -76,6 +83,78 @@ const WorkspaceHelpTooltip: React.FC = () => {
)
}
const OutdatedHelpTooltip: React.FC<{ onUpdateVersion: () => void }> = ({ onUpdateVersion }) => {
return (
<HelpTooltip size="small">
<HelpTooltipTitle>{Language.outdatedLabel}</HelpTooltipTitle>
<HelpTooltipText>{Language.versionTooltipText}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipAction icon={RefreshIcon} onClick={onUpdateVersion}>
{Language.updateVersionLabel}
</HelpTooltipAction>
</HelpTooltipLinksGroup>
</HelpTooltip>
)
}
const WorkspaceRow: React.FC<{ workspaceRef: WorkspaceItemMachineRef }> = ({ workspaceRef }) => {
const styles = useStyles()
const navigate = useNavigate()
const theme: Theme = useTheme()
const [workspaceState, send] = useActor(workspaceRef)
const { data: workspace } = workspaceState.context
const status = getDisplayStatus(theme, workspace.latest_build)
const navigateToWorkspacePage = () => {
navigate(`/@${workspace.owner_name}/${workspace.name}`)
}
return (
<TableRow
hover
data-testid={`workspace-${workspace.id}`}
tabIndex={0}
onClick={navigateToWorkspacePage}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigateToWorkspacePage()
}
}}
className={styles.clickableTableRow}
>
<TableCell>
<AvatarData title={workspace.name} subtitle={workspace.owner_name} />
</TableCell>
<TableCell>{workspace.template_name}</TableCell>
<TableCell>
{workspace.outdated ? (
<span className={styles.outdatedLabel}>
{Language.outdatedLabel}
<OutdatedHelpTooltip
onUpdateVersion={() => {
send("UPDATE_VERSION")
}}
/>
</span>
) : (
<span style={{ color: theme.palette.text.secondary }}>{Language.upToDateLabel}</span>
)}
</TableCell>
<TableCell>
<span data-chromatic="ignore" style={{ color: theme.palette.text.secondary }}>
{dayjs().to(dayjs(workspace.latest_build.created_at))}
</span>
</TableCell>
<TableCell>
<span style={{ color: status.color }}>{status.status}</span>
</TableCell>
<TableCell>
<div className={styles.arrowCell}>
<KeyboardArrowRight className={styles.arrowRight} />
</div>
</TableCell>
</TableRow>
)
}
interface FilterFormValues {
query: string
}
@@ -84,15 +163,13 @@ export type FilterFormErrors = FormikErrors<FilterFormValues>
export interface WorkspacesPageViewProps {
loading?: boolean
workspaces?: TypesGen.Workspace[]
workspaceRefs?: WorkspaceItemMachineRef[]
filter?: string
onFilter: (query: string) => void
}
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, workspaces, filter, onFilter }) => {
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, workspaceRefs, filter, onFilter }) => {
const styles = useStyles()
const navigate = useNavigate()
const theme: Theme = useTheme()
const form = useFormik<FilterFormValues>({
enableReinitialize: true,
@@ -193,17 +270,17 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, works
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Template</TableCell>
<TableCell>Version</TableCell>
<TableCell>Last Built</TableCell>
<TableCell>Status</TableCell>
<TableCell width="35%">Name</TableCell>
<TableCell width="15%">Template</TableCell>
<TableCell width="15%">Version</TableCell>
<TableCell width="20%">Last Built</TableCell>
<TableCell width="15%">Status</TableCell>
<TableCell width="1%"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{!workspaces && loading && <TableLoader />}
{workspaces && workspaces.length === 0 && (
{!workspaceRefs && loading && <TableLoader />}
{workspaceRefs && workspaceRefs.length === 0 && (
<>
{filter === workspaceFilterQuery.me || filter === workspaceFilterQuery.all ? (
<TableRow>
@@ -228,53 +305,8 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, works
)}
</>
)}
{workspaces &&
workspaces.map((workspace) => {
const status = getDisplayStatus(theme, workspace.latest_build)
const navigateToWorkspacePage = () => {
navigate(`/@${workspace.owner_name}/${workspace.name}`)
}
return (
<TableRow
key={workspace.id}
hover
data-testid={`workspace-${workspace.id}`}
tabIndex={0}
onClick={navigateToWorkspacePage}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigateToWorkspacePage()
}
}}
className={styles.clickableTableRow}
>
<TableCell>
<AvatarData title={workspace.name} subtitle={workspace.owner_name} />
</TableCell>
<TableCell>{workspace.template_name}</TableCell>
<TableCell>
{workspace.outdated ? (
<span style={{ color: theme.palette.error.main }}>outdated</span>
) : (
<span style={{ color: theme.palette.text.secondary }}>up to date</span>
)}
</TableCell>
<TableCell>
<span data-chromatic="ignore" style={{ color: theme.palette.text.secondary }}>
{dayjs().to(dayjs(workspace.latest_build.created_at))}
</span>
</TableCell>
<TableCell>
<span style={{ color: status.color }}>{status.status}</span>
</TableCell>
<TableCell>
<div className={styles.arrowCell}>
<KeyboardArrowRight className={styles.arrowRight} />
</div>
</TableCell>
</TableRow>
)
})}
{workspaceRefs &&
workspaceRefs.map((workspaceRef) => <WorkspaceRow workspaceRef={workspaceRef} key={workspaceRef.id} />)}
</TableBody>
</Table>
</Margins>
@@ -339,4 +371,10 @@ const useStyles = makeStyles((theme) => ({
arrowCell: {
display: "flex",
},
outdatedLabel: {
color: theme.palette.error.main,
display: "flex",
alignItems: "center",
gap: theme.spacing(0.5),
},
}))
@@ -1,64 +1,263 @@
import { assign, createMachine } from "xstate"
import { ActorRefFrom, assign, createMachine, spawn } from "xstate"
import * as API from "../../api/api"
import { getErrorMessage } from "../../api/errors"
import * as TypesGen from "../../api/typesGenerated"
import { displayError, displayMsg, displaySuccess } from "../../components/GlobalSnackbar/utils"
import { workspaceQueryToFilter } from "../../util/workspace"
interface WorkspaceContext {
workspaces?: TypesGen.Workspace[]
/**
* Workspace item machine
*
* It is used to control the state and actions of each workspace in the
* workspaces page view
**/
interface WorkspaceItemContext {
data: TypesGen.Workspace
updatedTemplate?: TypesGen.Template
}
type WorkspaceItemEvent =
| {
type: "UPDATE_VERSION"
}
| {
type: "UPDATE_DATA"
data: TypesGen.Workspace
}
export const workspaceItemMachine = createMachine(
{
id: "workspaceItemMachine",
schema: {
context: {} as WorkspaceItemContext,
events: {} as WorkspaceItemEvent,
services: {} as {
getTemplate: {
data: TypesGen.Template
}
startWorkspace: {
data: TypesGen.WorkspaceBuild
}
getWorkspace: {
data: TypesGen.Workspace
}
},
},
tsTypes: {} as import("./workspacesXService.typegen").Typegen0,
type: "parallel",
states: {
updateVersion: {
initial: "idle",
states: {
idle: {
on: {
UPDATE_VERSION: {
target: "gettingUpdatedTemplate",
// We can improve the UI by optimistically updating the workspace status
// to "Queued" so the UI can display the updated state right away and we
// don't need to display an extra spinner.
actions: ["assignQueuedStatus", "displayUpdatingVersionMessage"],
},
UPDATE_DATA: {
actions: "assignUpdatedData",
},
},
},
gettingUpdatedTemplate: {
invoke: {
id: "getTemplate",
src: "getTemplate",
onDone: {
actions: "assignUpdatedTemplate",
target: "restartingWorkspace",
},
onError: {
target: "idle",
actions: "displayUpdateVersionError",
},
},
},
restartingWorkspace: {
invoke: {
id: "startWorkspace",
src: "startWorkspace",
onDone: {
actions: "assignLatestBuild",
target: "waitingToBeUpdated",
},
onError: {
target: "idle",
actions: "displayUpdateVersionError",
},
},
},
waitingToBeUpdated: {
after: {
5000: "gettingUpdatedWorkspaceData",
},
},
gettingUpdatedWorkspaceData: {
invoke: {
id: "getWorkspace",
src: "getWorkspace",
onDone: [
{
target: "waitingToBeUpdated",
cond: "isOutdated",
actions: ["assignUpdatedData"],
},
{
target: "idle",
actions: ["assignUpdatedData", "displayUpdatedSuccessMessage"],
},
],
},
},
},
},
},
},
{
guards: {
isOutdated: (_, event) => event.data.outdated,
},
services: {
getTemplate: (context) => API.getTemplate(context.data.template_id),
startWorkspace: (context) => {
if (!context.updatedTemplate) {
throw new Error("Updated template is not loaded.")
}
return API.startWorkspace(context.data.id, context.updatedTemplate.active_version_id)
},
getWorkspace: (context) => API.getWorkspace(context.data.id),
},
actions: {
assignUpdatedTemplate: assign({
updatedTemplate: (_, event) => event.data,
}),
assignLatestBuild: assign({
data: (context, event) => {
return {
...context.data,
latest_build: event.data,
}
},
}),
displayUpdateVersionError: (_, event) => {
const message = getErrorMessage(event.data, "Error on update workspace version.")
displayError(message)
},
displayUpdatingVersionMessage: () => {
displayMsg("Updating workspace...")
},
assignQueuedStatus: assign({
data: (ctx) => {
return {
...ctx.data,
latest_build: {
...ctx.data.latest_build,
job: {
...ctx.data.latest_build.job,
status: "pending" as TypesGen.ProvisionerJobStatus,
},
},
}
},
}),
displayUpdatedSuccessMessage: () => {
displaySuccess("Workspace updated successfully.")
},
assignUpdatedData: assign({
data: (_, event) => event.data,
}),
},
},
)
/**
* Workspaces machine
*
* It is used to control the state of the workspace list
**/
export type WorkspaceItemMachineRef = ActorRefFrom<typeof workspaceItemMachine>
interface WorkspacesContext {
workspaceRefs?: WorkspaceItemMachineRef[]
filter?: string
getWorkspacesError?: Error | unknown
}
type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } | { type: "SET_FILTER"; query: string }
type WorkspacesEvent = { type: "GET_WORKSPACES"; query: string } | { type: "UPDATE_VERSION"; workspaceId: string }
export const workspacesMachine = createMachine(
{
tsTypes: {} as import("./workspacesXService.typegen").Typegen0,
tsTypes: {} as import("./workspacesXService.typegen").Typegen1,
schema: {
context: {} as WorkspaceContext,
events: {} as WorkspaceEvent,
context: {} as WorkspacesContext,
events: {} as WorkspacesEvent,
services: {} as {
getWorkspaces: {
data: TypesGen.Workspace[]
}
},
},
id: "workspaceState",
initial: "ready",
states: {
ready: {
on: {
SET_FILTER: "extractingFilter",
},
id: "workspacesState",
on: {
GET_WORKSPACES: {
actions: "assignFilter",
target: "gettingWorkspaces",
},
extractingFilter: {
entry: "assignFilter",
always: {
target: "gettingWorkspaces",
},
UPDATE_VERSION: {
actions: "triggerUpdateVersion",
},
},
initial: "idle",
states: {
idle: {
tags: ["loading"],
},
gettingWorkspaces: {
entry: "clearGetWorkspacesError",
invoke: {
src: "getWorkspaces",
id: "getWorkspaces",
onDone: {
target: "ready",
actions: ["assignWorkspaces", "clearGetWorkspacesError"],
},
onDone: [
{
target: "waitToRefreshWorkspaces",
actions: ["assignWorkspaceRefs"],
cond: "isEmpty",
},
{
target: "waitToRefreshWorkspaces",
actions: ["updateWorkspaceRefs"],
},
],
onError: {
target: "ready",
actions: ["assignGetWorkspacesError", "clearWorkspaces"],
target: "waitToRefreshWorkspaces",
actions: ["assignGetWorkspacesError"],
},
},
tags: "loading",
},
waitToRefreshWorkspaces: {
after: {
5000: "gettingWorkspaces",
},
},
},
},
{
guards: {
isEmpty: (context) => !context.workspaceRefs,
},
actions: {
assignWorkspaces: assign({
workspaces: (_, event) => event.data,
assignWorkspaceRefs: assign({
workspaceRefs: (_, event) =>
event.data.map((data) => {
return spawn(workspaceItemMachine.withContext({ data }), data.id)
}),
}),
assignFilter: assign({
filter: (_, event) => event.query,
@@ -67,7 +266,54 @@ export const workspacesMachine = createMachine(
getWorkspacesError: (_, event) => event.data,
}),
clearGetWorkspacesError: (context) => assign({ ...context, getWorkspacesError: undefined }),
clearWorkspaces: (context) => assign({ ...context, workspaces: undefined }),
triggerUpdateVersion: (context, event) => {
const workspaceRef = context.workspaceRefs?.find((ref) => ref.id === event.workspaceId)
if (!workspaceRef) {
throw new Error(`No workspace ref found for ${event.workspaceId}.`)
}
workspaceRef.send("UPDATE_VERSION")
},
// Opened discussion on XState https://github.com/statelyai/xstate/discussions/3406
updateWorkspaceRefs: assign({
workspaceRefs: (context, event) => {
let workspaceRefs = context.workspaceRefs
if (!workspaceRefs) {
throw new Error("No workspaces loaded.")
}
// Update the existent workspaces or create the new ones
for (const data of event.data) {
const ref = workspaceRefs.find((ref) => ref.id === data.id)
if (!ref) {
workspaceRefs.push(spawn(workspaceItemMachine.withContext({ data }), data.id))
} else {
ref.send({ type: "UPDATE_DATA", data })
}
}
// Remove workspaces that were deleted
for (const ref of workspaceRefs) {
const refData = event.data.find((workspaceData) => workspaceData.id === ref.id)
// If there is no refData, it is because the workspace was deleted
if (!refData) {
// Stop the actor before remove it from the array
if (ref.stop) {
ref.stop()
}
// Remove ref from the array
workspaceRefs = workspaceRefs.filter((oldRef) => oldRef.id === ref.id)
}
}
return workspaceRefs
},
}),
},
services: {
getWorkspaces: (context) => API.getWorkspaces(workspaceQueryToFilter(context.filter)),