diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5151166e8f1a9..bb8b782509d31 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -221,3 +221,8 @@ export const regenerateUserSSHKey = async (userId = "me"): Promise(`/api/v2/users/${userId}/gitsshkey`) return response.data } + +export const getWorkspaceBuilds = async (workspaceId: string): Promise => { + const response = await axios.get(`/api/v2/workspaces/${workspaceId}/builds`) + return response.data +} diff --git a/site/src/components/BuildsTable/BuildsTable.stories.tsx b/site/src/components/BuildsTable/BuildsTable.stories.tsx new file mode 100644 index 0000000000000..4626b8723cd87 --- /dev/null +++ b/site/src/components/BuildsTable/BuildsTable.stories.tsx @@ -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 + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + builds: MockBuilds, +} + +export const Empty = Template.bind({}) +Empty.args = { + builds: [], +} diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx new file mode 100644 index 0000000000000..3e26910894e75 --- /dev/null +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -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 = ({ builds, className }) => { + const isLoading = !builds + const theme: Theme = useTheme() + + return ( + + + + {Language.actionLabel} + {Language.durationLabel} + {Language.startedAtLabel} + {Language.statusLabel} + + + + {isLoading && } + {builds && + builds.map((b) => { + const status = getDisplayStatus(theme, b) + const duration = getDurationInSeconds(b) + + return ( + + {b.transition} + + {duration} + + + {new Date(b.created_at).toLocaleString()} + + + {status.status} + + + ) + })} + + {builds && builds.length === 0 && ( + + + + + + + + )} + +
+ ) +} diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index ab880a1d4169d..5f94b749463ce 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -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 = ({ handleRetry, handleUpdate, workspaceStatus, + builds, }) => { const styles = useStyles() @@ -56,13 +59,8 @@ export const Workspace: React.FC = ({
- -
- -
+ +
@@ -105,5 +103,11 @@ export const useStyles = makeStyles(() => { timelineContainer: { flex: 1, }, + timelineContents: { + margin: 0, + }, + timelineTable: { + border: 0, + }, } }) diff --git a/site/src/components/WorkspaceSection/WorkspaceSection.tsx b/site/src/components/WorkspaceSection/WorkspaceSection.tsx index bcdb90c03463c..73dac822eb8d6 100644 --- a/site/src/components/WorkspaceSection/WorkspaceSection.tsx +++ b/site/src/components/WorkspaceSection/WorkspaceSection.tsx @@ -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 } -export const WorkspaceSection: React.FC = ({ title, children }) => { +export const WorkspaceSection: React.FC = ({ title, children, contentsProps }) => { const styles = useStyles() return ( @@ -21,7 +23,9 @@ export const WorkspaceSection: React.FC = ({ title, child )} -
{children}
+
+ {children} +
) } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 2686316362320..ad23e0cd4ed9c 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -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(, { 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, [workspaceId: string, templateVersionId?: string | undefined]> - | jest.SpyInstance, [templateId: string]>, -) => { - renderWithAuth(, { 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(, { 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(, { 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(, { 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) }) }) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 4b0a412865ab8..fd0c5fe793938 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -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} /> diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 355d709c7a7b0..e71ff2b69def7 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -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 = (props) => )} {props.workspaces?.map((workspace) => { - const status = getStatus(theme, workspace.latest_build) + const status = getDisplayStatus(theme, workspace.latest_build) return ( @@ -108,74 +107,6 @@ export const WorkspacesPageView: React.FC = (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), diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 5954c69a8de4f..d4ed7a0e91136 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 1f65874616dc1..ee5f2eb5c9de1 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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) => { diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index f4b844cdd3665..1c36ce958309a 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -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) +} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index a64633595466c..782d4f847a459 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -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") + } + }, }, }, )