feat: Add timeline in the workspace page (#1533)

This commit is contained in:
Bruno Quaresma
2022-05-18 13:54:06 +00:00
committed by GitHub
parent 6bed620d6c
commit b7481489b1
12 changed files with 453 additions and 149 deletions
+5
View File
@@ -221,3 +221,8 @@ export const regenerateUserSSHKey = async (userId = "me"): Promise<TypesGen.GitS
const response = await axios.put<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`)
return response.data
}
export const getWorkspaceBuilds = async (workspaceId: string): Promise<TypesGen.WorkspaceBuild[]> => {
const response = await axios.get<TypesGen.WorkspaceBuild[]>(`/api/v2/workspaces/${workspaceId}/builds`)
return response.data
}
@@ -0,0 +1,21 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockBuilds } from "../../testHelpers/entities"
import { BuildsTable, BuildsTableProps } from "./BuildsTable"
export default {
title: "components/BuildsTable",
component: BuildsTable,
} as ComponentMeta<typeof BuildsTable>
const Template: Story<BuildsTableProps> = (args) => <BuildsTable {...args} />
export const Example = Template.bind({})
Example.args = {
builds: MockBuilds,
}
export const Empty = Template.bind({})
Empty.args = {
builds: [],
}
@@ -0,0 +1,97 @@
import Box from "@material-ui/core/Box"
import { Theme } from "@material-ui/core/styles"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import useTheme from "@material-ui/styles/useTheme"
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import React from "react"
import * as TypesGen from "../../api/typesGenerated"
import { getDisplayStatus } from "../../util/workspace"
import { EmptyState } from "../EmptyState/EmptyState"
import { TableLoader } from "../TableLoader/TableLoader"
dayjs.extend(relativeTime)
dayjs.extend(duration)
export const Language = {
emptyMessage: "No builds found",
inProgressLabel: "In progress",
actionLabel: "Action",
durationLabel: "Duration",
startedAtLabel: "Started at",
statusLabel: "Status",
}
const getDurationInSeconds = (build: TypesGen.WorkspaceBuild) => {
let display = Language.inProgressLabel
if (build.job.started_at && build.job.completed_at) {
const startedAt = dayjs(build.job.started_at)
const completedAt = dayjs(build.job.completed_at)
const diff = completedAt.diff(startedAt, "seconds")
display = `${diff} seconds`
}
return display
}
export interface BuildsTableProps {
builds?: TypesGen.WorkspaceBuild[]
className?: string
}
export const BuildsTable: React.FC<BuildsTableProps> = ({ builds, className }) => {
const isLoading = !builds
const theme: Theme = useTheme()
return (
<Table className={className}>
<TableHead>
<TableRow>
<TableCell width="20%">{Language.actionLabel}</TableCell>
<TableCell width="20%">{Language.durationLabel}</TableCell>
<TableCell width="40%">{Language.startedAtLabel}</TableCell>
<TableCell width="20%">{Language.statusLabel}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <TableLoader />}
{builds &&
builds.map((b) => {
const status = getDisplayStatus(theme, b)
const duration = getDurationInSeconds(b)
return (
<TableRow key={b.id} data-testid={`build-${b.id}`}>
<TableCell>{b.transition}</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>{duration}</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>{new Date(b.created_at).toLocaleString()}</span>
</TableCell>
<TableCell>
<span style={{ color: status.color }}>{status.status}</span>
</TableCell>
</TableRow>
)
})}
{builds && builds.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>
<EmptyState message={Language.emptyMessage} />
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
+11 -7
View File
@@ -3,6 +3,7 @@ import Typography from "@material-ui/core/Typography"
import React from "react"
import * as TypesGen from "../../api/typesGenerated"
import { WorkspaceStatus } from "../../util/workspace"
import { BuildsTable } from "../BuildsTable/BuildsTable"
import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule"
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
import { WorkspaceStatusBar } from "../WorkspaceStatusBar/WorkspaceStatusBar"
@@ -16,6 +17,7 @@ export interface WorkspaceProps {
handleRetry: () => void
handleUpdate: () => void
workspaceStatus: WorkspaceStatus
builds?: TypesGen.WorkspaceBuild[]
}
/**
@@ -28,6 +30,7 @@ export const Workspace: React.FC<WorkspaceProps> = ({
handleRetry,
handleUpdate,
workspaceStatus,
builds,
}) => {
const styles = useStyles()
@@ -56,13 +59,8 @@ export const Workspace: React.FC<WorkspaceProps> = ({
</WorkspaceSection>
</div>
<div className={styles.timelineContainer}>
<WorkspaceSection title="Timeline">
<div
className={styles.vertical}
style={{ justifyContent: "center", alignItems: "center", height: "300px" }}
>
<Placeholder />
</div>
<WorkspaceSection title="Timeline" contentsProps={{ className: styles.timelineContents }}>
<BuildsTable builds={builds} className={styles.timelineTable} />
</WorkspaceSection>
</div>
</div>
@@ -105,5 +103,11 @@ export const useStyles = makeStyles(() => {
timelineContainer: {
flex: 1,
},
timelineContents: {
margin: 0,
},
timelineTable: {
border: 0,
},
}
})
@@ -1,14 +1,16 @@
import Paper from "@material-ui/core/Paper"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
import React, { HTMLProps } from "react"
import { CardPadding, CardRadius } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
export interface WorkspaceSectionProps {
title?: string
contentsProps?: HTMLProps<HTMLDivElement>
}
export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, children }) => {
export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, children, contentsProps }) => {
const styles = useStyles()
return (
@@ -21,7 +23,9 @@ export const WorkspaceSection: React.FC<WorkspaceSectionProps> = ({ title, child
</div>
)}
<div className={styles.contents}>{children}</div>
<div {...contentsProps} className={combineClasses([styles.contents, contentsProps?.className])}>
{children}
</div>
</Paper>
)
}
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { screen } from "@testing-library/react"
import { fireEvent, screen, waitFor } from "@testing-library/react"
import { rest } from "msw"
import React from "react"
import * as api from "../../api/api"
import { Template, Workspace, WorkspaceBuild } from "../../api/typesGenerated"
import { Workspace } from "../../api/typesGenerated"
import { Language } from "../../components/WorkspaceStatusBar/WorkspaceStatusBar"
import {
MockBuilds,
MockCancelingWorkspace,
MockDeletedWorkspace,
MockDeletingWorkspace,
@@ -22,6 +22,12 @@ import {
import { server } from "../../testHelpers/server"
import { WorkspacePage } from "./WorkspacePage"
// It renders the workspace page and waits for it be loaded
const renderWorkspacePage = async () => {
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
await screen.findByText(MockWorkspace.name)
}
/**
* Requests and responses related to workspace status are unrelated, so we can't test in the usual way.
* Instead, test that button clicks produce the correct requests and that responses produce the correct UI.
@@ -29,16 +35,11 @@ import { WorkspacePage } from "./WorkspacePage"
* workspaceStatus was calculated correctly.
*/
const testButton = async (
label: string,
mock:
| jest.SpyInstance<Promise<WorkspaceBuild>, [workspaceId: string, templateVersionId?: string | undefined]>
| jest.SpyInstance<Promise<Template>, [templateId: string]>,
) => {
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
const testButton = async (label: string, actionMock: jest.SpyInstance) => {
await renderWorkspacePage()
const button = await screen.findByText(label)
button.click()
expect(mock).toHaveBeenCalled()
await waitFor(() => fireEvent.click(button))
expect(actionMock).toBeCalled()
}
const testStatus = async (mock: Workspace, label: string) => {
@@ -47,82 +48,118 @@ const testStatus = async (mock: Workspace, label: string) => {
return res(ctx.status(200), ctx.json(mock))
}),
)
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
await renderWorkspacePage()
const status = await screen.findByRole("status")
expect(status).toHaveTextContent(label)
}
beforeEach(() => {
jest.resetAllMocks()
})
describe("Workspace Page", () => {
it("shows a workspace", async () => {
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
const workspaceName = await screen.findByText(MockWorkspace.name)
await renderWorkspacePage()
const workspaceName = screen.getByText(MockWorkspace.name)
expect(workspaceName).toBeDefined()
})
it("shows the status of the workspace", async () => {
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
const status = await screen.findByRole("status")
await renderWorkspacePage()
const status = screen.getByRole("status")
expect(status).toHaveTextContent("Running")
})
it("requests a stop job when the user presses Stop", async () => {
const stopWorkspaceMock = jest.spyOn(api, "stopWorkspace").mockResolvedValueOnce(MockWorkspaceBuild)
await testButton(Language.stop, stopWorkspaceMock)
})
it("requests a start job when the user presses Start", async () => {
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockStoppedWorkspace))
}),
)
const startWorkspaceMock = jest
.spyOn(api, "startWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
await testButton(Language.start, startWorkspaceMock)
})
it("requests a start job when the user presses Retry after trying to start", async () => {
// Use a workspace that failed during start
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "start",
},
}),
)
}),
)
const startWorkSpaceMock = jest.spyOn(api, "startWorkspace").mockResolvedValueOnce(MockWorkspaceBuild)
await testButton(Language.retry, startWorkSpaceMock)
})
it("requests a stop job when the user presses Retry after trying to stop", async () => {
// Use a workspace that failed during stop
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "stop",
},
}),
)
}),
)
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
testButton(Language.start, stopWorkspaceMock)
}),
it("requests a start job when the user presses Start", async () => {
const startWorkspaceMock = jest
.spyOn(api, "startWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
testButton(Language.start, startWorkspaceMock)
}),
it("requests a start job when the user presses Retry after trying to start", async () => {
const startWorkspaceMock = jest
.spyOn(api, "startWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
testButton(Language.retry, startWorkspaceMock)
}),
it("requests a stop job when the user presses Retry after trying to stop", async () => {
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockStoppedWorkspace))
}),
)
testButton(Language.start, stopWorkspaceMock)
}),
it("requests a template when the user presses Update", async () => {
const getTemplateMock = jest.spyOn(api, "getTemplate").mockImplementation(() => Promise.resolve(MockTemplate))
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockOutdatedWorkspace))
}),
)
testButton(Language.update, getTemplateMock)
}),
it("shows the Stopping status when the workspace is stopping", async () => {
testStatus(MockStoppingWorkspace, Language.stopping)
})
await testButton(Language.retry, stopWorkspaceMock)
})
it("requests a template when the user presses Update", async () => {
const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockOutdatedWorkspace))
}),
)
await testButton(Language.update, getTemplateMock)
})
it("shows the Stopping status when the workspace is stopping", async () => {
await testStatus(MockStoppingWorkspace, Language.stopping)
})
it("shows the Stopped status when the workspace is stopped", async () => {
testStatus(MockStoppedWorkspace, Language.stopped)
await testStatus(MockStoppedWorkspace, Language.stopped)
})
it("shows the Building status when the workspace is starting", async () => {
testStatus(MockStartingWorkspace, Language.starting)
await testStatus(MockStartingWorkspace, Language.starting)
})
it("shows the Running status when the workspace is started", async () => {
testStatus(MockWorkspace, Language.started)
await testStatus(MockWorkspace, Language.started)
})
it("shows the Error status when the workspace is failed or canceled", async () => {
testStatus(MockFailedWorkspace, Language.error)
await testStatus(MockFailedWorkspace, Language.error)
})
it("shows the Loading status when the workspace is canceling", async () => {
testStatus(MockCancelingWorkspace, Language.canceling)
await testStatus(MockCancelingWorkspace, Language.canceling)
})
it("shows the Deleting status when the workspace is deleting", async () => {
testStatus(MockDeletingWorkspace, Language.canceling)
await testStatus(MockDeletingWorkspace, Language.deleting)
})
it("shows the Deleted status when the workspace is deleted", async () => {
testStatus(MockDeletedWorkspace, Language.canceling)
await testStatus(MockDeletedWorkspace, Language.deleted)
})
it("shows the timeline build", async () => {
await renderWorkspacePage()
const table = await screen.findByRole("table")
const rows = table.querySelectorAll("tbody > tr")
expect(rows).toHaveLength(MockBuilds.length)
})
})
@@ -16,7 +16,7 @@ export const WorkspacePage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService)
const { workspace, getWorkspaceError, getTemplateError, getOrganizationError } = workspaceState.context
const { workspace, getWorkspaceError, getTemplateError, getOrganizationError, builds } = workspaceState.context
const workspaceStatus = useSelector(xServices.workspaceXService, (state) => {
return getWorkspaceStatus(state.context.workspace?.latest_build)
})
@@ -44,6 +44,7 @@ export const WorkspacePage: React.FC = () => {
handleRetry={() => workspaceSend("RETRY")}
handleUpdate={() => workspaceSend("UPDATE")}
workspaceStatus={workspaceStatus}
builds={builds}
/>
</Stack>
</Margins>
@@ -14,11 +14,10 @@ import relativeTime from "dayjs/plugin/relativeTime"
import React from "react"
import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { WorkspaceBuild } from "../../api/typesGenerated"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { firstLetter } from "../../util/firstLetter"
import { getWorkspaceStatus } from "../../util/workspace"
import { getDisplayStatus } from "../../util/workspace"
dayjs.extend(relativeTime)
@@ -68,7 +67,7 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
</TableRow>
)}
{props.workspaces?.map((workspace) => {
const status = getStatus(theme, workspace.latest_build)
const status = getDisplayStatus(theme, workspace.latest_build)
return (
<TableRow key={workspace.id} className={styles.workspaceRow}>
<TableCell>
@@ -108,74 +107,6 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
)
}
const getStatus = (
theme: Theme,
build: WorkspaceBuild,
): {
color: string
status: string
} => {
const status = getWorkspaceStatus(build)
switch (status) {
case undefined:
return {
color: theme.palette.text.secondary,
status: "Loading...",
}
case "started":
return {
color: theme.palette.success.main,
status: "⦿ Running",
}
case "starting":
return {
color: theme.palette.success.main,
status: "⦿ Starting",
}
case "stopping":
return {
color: theme.palette.text.secondary,
status: "◍ Stopping",
}
case "stopped":
return {
color: theme.palette.text.secondary,
status: "◍ Stopped",
}
case "deleting":
return {
color: theme.palette.text.secondary,
status: "⦸ Deleting",
}
case "deleted":
return {
color: theme.palette.text.secondary,
status: "⦸ Deleted",
}
case "canceling":
return {
color: theme.palette.warning.light,
status: "◍ Canceling",
}
case "canceled":
return {
color: theme.palette.text.secondary,
status: "◍ Canceled",
}
case "error":
return {
color: theme.palette.error.main,
status: "ⓧ Failed",
}
case "queued":
return {
color: theme.palette.text.secondary,
status: "◍ Queued",
}
}
throw new Error("unknown status " + status)
}
const useStyles = makeStyles((theme) => ({
actions: {
marginTop: theme.spacing(3),
+6 -2
View File
@@ -113,26 +113,30 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
after_id: "",
before_id: "",
created_at: new Date().toString(),
id: "test-workspace-build",
id: "1",
initiator_id: "",
job: MockProvisionerJob,
name: "a-workspace-build",
template_version_id: "",
transition: "start",
updated_at: "",
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_id: "test-workspace",
}
export const MockWorkspaceBuildStop = {
...MockWorkspaceBuild,
id: "2",
transition: "stop",
}
export const MockWorkspaceBuildDelete = {
...MockWorkspaceBuild,
id: "3",
transition: "delete",
}
export const MockBuilds = [MockWorkspaceBuild, MockWorkspaceBuildStop, MockWorkspaceBuildDelete]
export const MockWorkspace: TypesGen.Workspace = {
id: "test-workspace",
name: "Test-Workspace",
+3
View File
@@ -98,6 +98,9 @@ export const handlers = [
const result = transitionToBuild[transition as WorkspaceBuildTransition]
return res(ctx.status(200), ctx.json(result))
}),
rest.get("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockBuilds))
}),
// workspace builds
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => {
+69
View File
@@ -1,3 +1,4 @@
import { Theme } from "@material-ui/core/styles"
import { WorkspaceBuildTransition } from "../api/types"
import { WorkspaceBuild } from "../api/typesGenerated"
@@ -47,3 +48,71 @@ export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceSt
return "error"
}
}
export const getDisplayStatus = (
theme: Theme,
build: WorkspaceBuild,
): {
color: string
status: string
} => {
const status = getWorkspaceStatus(build)
switch (status) {
case undefined:
return {
color: theme.palette.text.secondary,
status: "Loading...",
}
case "started":
return {
color: theme.palette.success.main,
status: "⦿ Running",
}
case "starting":
return {
color: theme.palette.success.main,
status: "⦿ Starting",
}
case "stopping":
return {
color: theme.palette.text.secondary,
status: "◍ Stopping",
}
case "stopped":
return {
color: theme.palette.text.secondary,
status: "◍ Stopped",
}
case "deleting":
return {
color: theme.palette.text.secondary,
status: "⦸ Deleting",
}
case "deleted":
return {
color: theme.palette.text.secondary,
status: "⦸ Deleted",
}
case "canceling":
return {
color: theme.palette.warning.light,
status: "◍ Canceling",
}
case "canceled":
return {
color: theme.palette.text.secondary,
status: "◍ Canceled",
}
case "error":
return {
color: theme.palette.error.main,
status: "ⓧ Failed",
}
case "queued":
return {
color: theme.palette.text.secondary,
status: "◍ Queued",
}
}
throw new Error("unknown status " + status)
}
@@ -1,8 +1,16 @@
import { assign, createMachine } from "xstate"
import { assign, createMachine, send } from "xstate"
import { pure } from "xstate/lib/actions"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
import { displayError } from "../../components/GlobalSnackbar/utils"
const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => {
// Cloning builds to not change the origin object with the sort()
return [...builds].sort((a, b) => {
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
})[0]
}
const Language = {
refreshTemplateError: "Error updating workspace: latest template could not be fetched.",
buildError: "Workspace action failed.",
@@ -21,6 +29,10 @@ export interface WorkspaceContext {
// these are separate from getX errors because they don't make the page unusable
refreshWorkspaceError: Error | unknown
refreshTemplateError: Error | unknown
// Builds
builds?: TypesGen.WorkspaceBuild[]
getBuildsError?: Error | unknown
loadMoreBuildsError?: Error | unknown
}
export type WorkspaceEvent =
@@ -29,6 +41,8 @@ export type WorkspaceEvent =
| { type: "STOP" }
| { type: "RETRY" }
| { type: "UPDATE" }
| { type: "LOAD_MORE_BUILDS" }
| { type: "REFRESH_TIMELINE" }
export const workspaceMachine = createMachine(
{
@@ -55,6 +69,12 @@ export const workspaceMachine = createMachine(
refreshWorkspace: {
data: TypesGen.Workspace | undefined
}
getBuilds: {
data: TypesGen.WorkspaceBuild[]
}
loadMoreBuilds: {
data: TypesGen.WorkspaceBuild[]
}
},
},
id: "workspaceState",
@@ -94,7 +114,7 @@ export const workspaceMachine = createMachine(
invoke: {
id: "refreshWorkspace",
src: "refreshWorkspace",
onDone: { target: "waiting", actions: "assignWorkspace" },
onDone: { target: "waiting", actions: ["refreshTimeline", "assignWorkspace"] },
onError: { target: "waiting", actions: "assignRefreshWorkspaceError" },
},
},
@@ -160,7 +180,7 @@ export const workspaceMachine = createMachine(
src: "startWorkspace",
onDone: {
target: "idle",
actions: "assignBuild",
actions: ["assignBuild", "refreshTimeline"],
},
onError: {
target: "idle",
@@ -175,7 +195,7 @@ export const workspaceMachine = createMachine(
src: "stopWorkspace",
onDone: {
target: "idle",
actions: "assignBuild",
actions: ["assignBuild", "refreshTimeline"],
},
onError: {
target: "idle",
@@ -200,6 +220,55 @@ export const workspaceMachine = createMachine(
},
},
},
timeline: {
initial: "gettingBuilds",
states: {
idle: {},
gettingBuilds: {
entry: "clearGetBuildsError",
invoke: {
src: "getBuilds",
onDone: {
actions: ["assignBuilds"],
target: "loadedBuilds",
},
onError: {
actions: ["assignGetBuildsError"],
target: "idle",
},
},
},
loadedBuilds: {
initial: "idle",
states: {
idle: {
on: {
LOAD_MORE_BUILDS: {
target: "loadingMoreBuilds",
cond: "hasMoreBuilds",
},
REFRESH_TIMELINE: "#workspaceState.ready.timeline.gettingBuilds",
},
},
loadingMoreBuilds: {
entry: "clearLoadMoreBuildsError",
invoke: {
src: "loadMoreBuilds",
onDone: {
actions: ["assignNewBuilds"],
target: "idle",
},
onError: {
actions: ["assignLoadMoreBuildsError"],
target: "idle",
},
},
},
},
},
},
},
},
},
error: {
@@ -274,9 +343,54 @@ export const workspaceMachine = createMachine(
assign({
refreshTemplateError: undefined,
}),
// Timeline
assignBuilds: assign({
builds: (_, event) => event.data,
}),
assignGetBuildsError: assign({
getBuildsError: (_, event) => event.data,
}),
clearGetBuildsError: assign({
getBuildsError: (_) => undefined,
}),
assignNewBuilds: assign({
builds: (context, event) => {
const oldBuilds = context.builds
if (!oldBuilds) {
throw new Error("Builds not loaded")
}
return [...oldBuilds, ...event.data]
},
}),
assignLoadMoreBuildsError: assign({
loadMoreBuildsError: (_, event) => event.data,
}),
clearLoadMoreBuildsError: assign({
loadMoreBuildsError: (_) => undefined,
}),
refreshTimeline: pure((context, event) => {
// No need to refresh the timeline if it is not loaded
if (!context.builds) {
return
}
// When it is a refresh workspace event, we want to check if the latest
// build was updated to not over fetch the builds
if (event.type === "done.invoke.refreshWorkspace") {
const latestBuildInTimeline = latestBuild(context.builds)
const isUpdated = event.data?.latest_build.updated_at !== latestBuildInTimeline.updated_at
if (isUpdated) {
return send({ type: "REFRESH_TIMELINE" })
}
} else {
return send({ type: "REFRESH_TIMELINE" })
}
}),
},
guards: {
triedToStart: (context) => context.workspace?.latest_build.transition === "start",
hasMoreBuilds: (_) => false,
},
services: {
getWorkspace: async (_, event) => {
@@ -317,6 +431,20 @@ export const workspaceMachine = createMachine(
throw Error("Cannot refresh workspace without id")
}
},
getBuilds: async (context) => {
if (context.workspace) {
return await API.getWorkspaceBuilds(context.workspace.id)
} else {
throw Error("Cannot refresh workspace without id")
}
},
loadMoreBuilds: async (context) => {
if (context.workspace) {
return await API.getWorkspaceBuilds(context.workspace.id)
} else {
throw Error("Cannot refresh workspace without id")
}
},
},
},
)