mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add "Outdated" tooltip and "Update version" button in the Workspaces page (#2322)
This commit is contained in:
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user