diff --git a/site/src/components/BuildsTable/BuildAvatar.tsx b/site/src/components/BuildsTable/BuildAvatar.tsx new file mode 100644 index 0000000000000..b4bb8aa71d4d5 --- /dev/null +++ b/site/src/components/BuildsTable/BuildAvatar.tsx @@ -0,0 +1,71 @@ +import Avatar from "@material-ui/core/Avatar" +import Badge from "@material-ui/core/Badge" +import { Theme, useTheme, withStyles } from "@material-ui/core/styles" +import { FC } from "react" +import PlayArrowOutlined from "@material-ui/icons/PlayArrowOutlined" +import PauseOutlined from "@material-ui/icons/PauseOutlined" +import DeleteOutlined from "@material-ui/icons/DeleteOutlined" +import { WorkspaceBuild, WorkspaceTransition } from "api/typesGenerated" +import { getDisplayWorkspaceBuildStatus } from "util/workspace" +import { PaletteIndex } from "theme/palettes" + +interface StylesBadgeProps { + type: PaletteIndex +} + +const StyledBadge = withStyles((theme) => ({ + badge: { + backgroundColor: ({ type }: StylesBadgeProps) => theme.palette[type].light, + borderRadius: "100%", + width: 8, + minWidth: 8, + height: 8, + display: "block", + padding: 0, + }, +}))(Badge) + +const StyledAvatar = withStyles((theme) => ({ + root: { + background: theme.palette.background.paperLight, + color: theme.palette.text.primary, + border: `2px solid ${theme.palette.divider}`, + + "& svg": { + width: 24, + height: 24, + }, + }, +}))(Avatar) + +export type BuildAvatarProps = { + build: WorkspaceBuild +} + +const iconByTransition: Record = { + start: , + stop: , + delete: , +} + +export const BuildAvatar: FC = ({ build }) => { + const theme = useTheme() + const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build) + + return ( + } + > + {iconByTransition[build.transition]} + + ) +} diff --git a/site/src/components/BuildsTable/BuildDateRow.tsx b/site/src/components/BuildsTable/BuildDateRow.tsx new file mode 100644 index 0000000000000..c7675d16d3348 --- /dev/null +++ b/site/src/components/BuildsTable/BuildDateRow.tsx @@ -0,0 +1,46 @@ +import { makeStyles } from "@material-ui/core/styles" +import TableCell from "@material-ui/core/TableCell" +import TableRow from "@material-ui/core/TableRow" +import formatRelative from "date-fns/formatRelative" +import { FC } from "react" + +export interface BuildDateRow { + date: Date +} + +export const BuildDateRow: FC = ({ date }) => { + const styles = useStyles() + // We only want the message related to the date since the time is displayed + // inside of the build row + const displayDate = formatRelative(date, new Date()).split("at")[0] + + return ( + + + {displayDate} + + + ) +} + +const useStyles = makeStyles((theme) => ({ + buildDateRow: { + background: theme.palette.background.paper, + + "&:not(:first-child) td": { + borderTop: `1px solid ${theme.palette.divider}`, + }, + }, + + buildDateCell: { + padding: `${theme.spacing(1, 4)} !important`, + background: `${theme.palette.background.paperLight} !important`, + fontSize: 12, + position: "relative", + color: theme.palette.text.secondary, + textTransform: "capitalize", + }, +})) diff --git a/site/src/components/BuildsTable/BuildRow.tsx b/site/src/components/BuildsTable/BuildRow.tsx new file mode 100644 index 0000000000000..da9c63ed5cc9c --- /dev/null +++ b/site/src/components/BuildsTable/BuildRow.tsx @@ -0,0 +1,144 @@ +import { makeStyles } from "@material-ui/core/styles" +import TableCell from "@material-ui/core/TableCell" +import TableRow from "@material-ui/core/TableRow" +import { WorkspaceBuild } from "api/typesGenerated" +import { Stack } from "components/Stack/Stack" +import { useClickable } from "hooks/useClickable" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import { + displayWorkspaceBuildDuration, + getDisplayWorkspaceBuildInitiatedBy, +} from "util/workspace" +import { BuildAvatar } from "./BuildAvatar" + +export interface BuildRowProps { + build: WorkspaceBuild +} + +export const BuildRow: React.FC = ({ build }) => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + const initiatedBy = getDisplayWorkspaceBuildInitiatedBy(build) + const navigate = useNavigate() + const clickableProps = useClickable(() => + navigate(`builds/${build.build_number}`), + ) + + return ( + + + + + +
+ + + {initiatedBy}{" "} + {build.reason !== "initiator" + ? t("buildMessage.automatically") + : ""} + {t(`buildMessage.${build.transition}`)}{" "} + {t("buildMessage.theWorkspace")} + + + + {new Date(build.created_at).toLocaleTimeString()} + + + + + + {t("buildData.reason")}: {build.reason} + + + + {t("buildData.duration")}:{" "} + {displayWorkspaceBuildDuration(build)} + + +
+
+
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + buildRow: { + cursor: "pointer", + + "&:focus": { + outlineStyle: "solid", + outlineOffset: -1, + outlineWidth: 2, + outlineColor: theme.palette.secondary.dark, + }, + + "&:not(:last-child) td:before": { + position: "absolute", + top: 20, + left: 50, + display: "block", + content: "''", + height: "100%", + width: 2, + background: theme.palette.divider, + }, + }, + + buildWrapper: { + padding: theme.spacing(2, 4), + }, + + buildCell: { + padding: "0 !important", + position: "relative", + borderBottom: 0, + }, + + buildResume: { + ...theme.typography.body1, + fontFamily: "inherit", + }, + + buildInfo: { + ...theme.typography.body2, + fontSize: 12, + fontFamily: "inherit", + color: theme.palette.text.secondary, + display: "block", + }, + + buildTime: { + color: theme.palette.text.secondary, + fontSize: 12, + }, + + buildRight: { + width: "auto", + }, + + buildExtraInfo: { + ...theme.typography.body2, + fontFamily: MONOSPACE_FONT_FAMILY, + color: theme.palette.text.secondary, + whiteSpace: "nowrap", + }, +})) diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 25aeddbd7442c..483af405a5a60 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -1,23 +1,15 @@ import Box from "@material-ui/core/Box" -import { makeStyles, 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 TableContainer from "@material-ui/core/TableContainer" -import TableHead from "@material-ui/core/TableHead" import TableRow from "@material-ui/core/TableRow" -import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" -import useTheme from "@material-ui/styles/useTheme" -import { FC } from "react" -import { useNavigate, useParams } from "react-router-dom" +import { FC, Fragment } from "react" import * as TypesGen from "../../api/typesGenerated" -import { - displayWorkspaceBuildDuration, - getDisplayWorkspaceBuildStatus, -} from "../../util/workspace" import { EmptyState } from "../EmptyState/EmptyState" -import { TableCellLink } from "../TableCellLink/TableCellLink" import { TableLoader } from "../TableLoader/TableLoader" +import { BuildDateRow } from "./BuildDateRow" +import { BuildRow } from "./BuildRow" export const Language = { emptyMessage: "No builds found", @@ -33,75 +25,51 @@ export interface BuildsTableProps { className?: string } +const groupBuildsByDate = (builds?: TypesGen.WorkspaceBuild[]) => { + const buildsByDate: Record = {} + + if (!builds) { + return + } + + builds.forEach((build) => { + const dateKey = new Date(build.created_at).toDateString() + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (buildsByDate[dateKey]) { + buildsByDate[dateKey].push(build) + } else { + buildsByDate[dateKey] = [build] + } + }) + + return buildsByDate +} + export const BuildsTable: FC> = ({ builds, className, }) => { - const { username, workspace: workspaceName } = useParams() const isLoading = !builds - const theme: Theme = useTheme() - const navigate = useNavigate() - const styles = useStyles() + const buildsByDate = groupBuildsByDate(builds) return ( - - - {Language.actionLabel} - {Language.durationLabel} - {Language.startedAtLabel} - {Language.statusLabel} - - - {isLoading && } - {builds && - builds.map((build) => { - const status = getDisplayWorkspaceBuildStatus(theme, build) - const buildPageLink = `/@${username}/${workspaceName}/builds/${build.build_number}` + + {buildsByDate && + Object.keys(buildsByDate).map((dateStr) => { + const builds = buildsByDate[dateStr] return ( - { - if (event.key === "Enter") { - navigate(buildPageLink) - } - }} - className={styles.clickableTableRow} - > - - {build.transition} - - - - {displayWorkspaceBuildDuration(build)} - - - - - {new Date(build.created_at).toLocaleString()} - - - - - {status.status} - - - -
- -
-
-
+ + + {builds.map((build) => ( + + ))} + ) })} @@ -119,30 +87,3 @@ export const BuildsTable: FC> = ({ ) } - -const useStyles = makeStyles((theme) => ({ - clickableTableRow: { - "&:hover td": { - backgroundColor: theme.palette.action.hover, - }, - - "&:focus": { - outline: `1px solid ${theme.palette.secondary.dark}`, - }, - - "& .MuiTableCell-root:last-child": { - paddingRight: theme.spacing(2), - }, - }, - arrowRight: { - color: theme.palette.text.secondary, - width: 20, - height: 20, - }, - arrowCell: { - display: "flex", - }, - status: { - whiteSpace: "nowrap", - }, -})) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 2351f6ccba68a..8e7957d15b0f0 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -93,11 +93,12 @@ export const Resources: FC> = ({ ) } -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles((theme) => ({ buttonWrapper: { display: "flex", alignItems: "center", justifyContent: "center", + marginTop: theme.spacing(2), }, showMoreButton: { diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index f2c22d3864c13..564c1c910cd75 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -179,7 +179,7 @@ export const Workspace: FC> = ({ {buildError} {cancellationError} @@ -220,7 +220,6 @@ export const Workspace: FC> = ({ )} {workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? ( diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index a0901d88bf857..d6a911964c320 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -38,5 +38,16 @@ "connected": "Connected", "connecting": "Connecting...", "disconnected": "Disconnected" + }, + "buildMessage": { + "start": "started", + "stop": "stopped", + "delete": "deleted", + "theWorkspace": "the workspace", + "automatically": "automatically " + }, + "buildData": { + "reason": "Reason", + "duration": "Duration" } } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 55c36b4042f76..0c55b342f0684 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -92,16 +92,6 @@ afterAll(() => { }) describe("WorkspacePage", () => { - it("shows a workspace", async () => { - await renderWorkspacePage() - const workspaceName = await screen.findByText(MockWorkspace.name) - expect(workspaceName).toBeDefined() - const header = screen.getByTestId("header") - const status = await within(header).findByRole("status") - expect(status).toHaveTextContent("Running") - // wait for workspace page to finish loading - await screen.findByText("stop") - }) it("requests a stop job when the user presses Stop", async () => { const stopWorkspaceMock = jest .spyOn(api, "stopWorkspace") @@ -288,7 +278,8 @@ describe("WorkspacePage", () => { // Wait for the results to be loaded await waitFor(async () => { const rows = table.querySelectorAll("tbody > tr") - expect(rows).toHaveLength(MockBuilds.length) + // Added +1 because of the date row + expect(rows).toHaveLength(MockBuilds.length + 1) }) }) }) diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index 8f2a6893d13f8..333eebfd13e3f 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -82,14 +82,14 @@ describe("util > workspace", () => { ...Mocks.MockWorkspaceBuild, reason: "autostart", }, - "system/autostart", + "Coder", ], [ { ...Mocks.MockWorkspaceBuild, reason: "autostop", }, - "system/autostop", + "Coder", ], ])( `getDisplayWorkspaceBuildInitiatedBy(%p) returns %p`, diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 1e23a1cfe3717..22d74d826f7f1 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -4,6 +4,7 @@ import duration from "dayjs/plugin/duration" import minMax from "dayjs/plugin/minMax" import utc from "dayjs/plugin/utc" import semver from "semver" +import { PaletteIndex } from "theme/palettes" import * as TypesGen from "../api/typesGenerated" dayjs.extend(duration) @@ -29,46 +30,48 @@ export const getDisplayWorkspaceBuildStatus = ( ): { color: string status: string + type: PaletteIndex } => { switch (build.job.status) { case "succeeded": return { + type: "success", color: theme.palette.success.main, - status: `⦿ ${DisplayWorkspaceBuildStatusLanguage.succeeded}`, + status: DisplayWorkspaceBuildStatusLanguage.succeeded, } case "pending": return { + type: "secondary", color: theme.palette.text.secondary, - status: `⦿ ${DisplayWorkspaceBuildStatusLanguage.pending}`, + status: DisplayWorkspaceBuildStatusLanguage.pending, } case "running": return { + type: "info", color: theme.palette.primary.main, - status: `⦿ ${DisplayWorkspaceBuildStatusLanguage.running}`, + status: DisplayWorkspaceBuildStatusLanguage.running, } case "failed": return { + type: "error", color: theme.palette.text.secondary, - status: `⦸ ${DisplayWorkspaceBuildStatusLanguage.failed}`, + status: DisplayWorkspaceBuildStatusLanguage.failed, } case "canceling": return { + type: "warning", color: theme.palette.warning.light, - status: `◍ ${DisplayWorkspaceBuildStatusLanguage.canceling}`, + status: DisplayWorkspaceBuildStatusLanguage.canceling, } case "canceled": return { + type: "secondary", color: theme.palette.text.secondary, - status: `◍ ${DisplayWorkspaceBuildStatusLanguage.canceled}`, + status: DisplayWorkspaceBuildStatusLanguage.canceled, } } } -export const DisplayWorkspaceBuildInitiatedByLanguage = { - autostart: "system/autostart", - autostop: "system/autostop", -} - export const getDisplayWorkspaceBuildInitiatedBy = ( build: TypesGen.WorkspaceBuild, ): string => { @@ -76,9 +79,8 @@ export const getDisplayWorkspaceBuildInitiatedBy = ( case "initiator": return build.initiator_name case "autostart": - return DisplayWorkspaceBuildInitiatedByLanguage.autostart case "autostop": - return DisplayWorkspaceBuildInitiatedByLanguage.autostop + return "Coder" } }