diff --git a/site/src/components/Stats/Stats.tsx b/site/src/components/Stats/Stats.tsx index fadcd302e7edd..0b2880979876c 100644 --- a/site/src/components/Stats/Stats.tsx +++ b/site/src/components/Stats/Stats.tsx @@ -30,6 +30,7 @@ const useStyles = makeStyles((theme) => ({ alignItems: "center", color: theme.palette.text.secondary, margin: "0px", + flexWrap: "wrap", [theme.breakpoints.down("sm")]: { display: "block", diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index ceb18f2483107..b0377eeb29847 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -25,7 +25,6 @@ import { Resources } from "../Resources/Resources" import { Stack } from "../Stack/Stack" import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions" import { WorkspaceDeletedBanner } from "../WorkspaceDeletedBanner/WorkspaceDeletedBanner" -import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" export enum WorkspaceErrors { @@ -33,7 +32,6 @@ export enum WorkspaceErrors { BUILD_ERROR = "buildError", CANCELLATION_ERROR = "cancellationError", } - export interface WorkspaceProps { scheduleProps: { onDeadlinePlus: (hours: number) => void @@ -129,14 +127,6 @@ export const Workspace: FC> = ({ - > = ({ workspace={workspace} quota_budget={quota_budget} handleUpdate={handleUpdate} + canUpdateWorkspace={canUpdateWorkspace} + maxDeadlineDecrease={scheduleProps.maxDeadlineDecrease} + maxDeadlineIncrease={scheduleProps.maxDeadlineIncrease} + onDeadlineMinus={scheduleProps.onDeadlineMinus} + onDeadlinePlus={scheduleProps.onDeadlinePlus} /> {failedBuildLogs && ( diff --git a/site/src/components/WorkspaceScheduleButton/EditHours.tsx b/site/src/components/WorkspaceScheduleButton/EditHours.tsx deleted file mode 100644 index 3e9a274d48e26..0000000000000 --- a/site/src/components/WorkspaceScheduleButton/EditHours.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import Button from "@material-ui/core/Button" -import { makeStyles } from "@material-ui/core/styles" -import TextField from "@material-ui/core/TextField" -import { Stack } from "components/Stack/Stack" -import { useState } from "react" -import { useTranslation } from "react-i18next" - -interface EditHoursProps { - handleSubmit: (hours: number) => void - max: number -} - -export const EditHours = ({ - handleSubmit, - max, -}: EditHoursProps): JSX.Element => { - const { t } = useTranslation("workspacePage") - const [hours, setHours] = useState(1) - const styles = useStyles() - - return ( - // hours is NaN when user deletes the value, so treat it as 0 -
handleSubmit(Number.isNaN(hours) ? 0 : hours)}> - - setHours(parseInt(e.target.value))} - type="number" - /> - - -
- ) -} - -const useStyles = makeStyles(() => ({ - inputField: { - width: "70px", - "& .MuiOutlinedInput-root": { - height: "30px", - }, - }, - button: { - "&.MuiButton-root": { - minHeight: "30px", - height: "30px", - }, - }, -})) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx deleted file mode 100644 index 033c9698ced01..0000000000000 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Story } from "@storybook/react" -import dayjs from "dayjs" -import utc from "dayjs/plugin/utc" -import * as Mocks from "../../testHelpers/entities" -import { - WorkspaceScheduleButton, - WorkspaceScheduleButtonProps, -} from "./WorkspaceScheduleButton" - -dayjs.extend(utc) - -export default { - title: "components/WorkspaceScheduleButton", - component: WorkspaceScheduleButton, - argTypes: { - canUpdateWorkspace: { - defaultValue: true, - }, - deadlineMinusEnabled: { - defaultValue: (): boolean => false, - }, - deadlinePlusEnabled: { - defaultValue: (): boolean => false, - }, - }, -} - -const Template: Story = (args) => ( - -) - -export const NoScheduleNoTTL = Template.bind({}) -NoScheduleNoTTL.args = { - workspace: { - ...Mocks.MockWorkspace, - - latest_build: { - ...Mocks.MockWorkspaceBuild, - transition: "stop", - }, - autostart_schedule: undefined, - ttl_ms: undefined, - }, -} - -export const NoTTL = Template.bind({}) -NoTTL.args = { - workspace: { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: undefined, - }, - ttl_ms: undefined, - }, -} - -export const WorkspaceOffShort = Template.bind({}) -WorkspaceOffShort.args = { - workspace: { - ...Mocks.MockWorkspace, - - latest_build: { - ...Mocks.MockWorkspaceBuild, - transition: "stop", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours - }, -} - -export const WorkspaceOffLong = Template.bind({}) -WorkspaceOffLong.args = { - deadlinePlusEnabled: () => true, - workspace: { - ...Mocks.MockWorkspace, - - latest_build: { - ...Mocks.MockWorkspaceBuild, - transition: "stop", - }, - ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years - }, -} - -export const WorkspaceOn = Template.bind({}) -WorkspaceOn.args = { - deadlineMinusEnabled: () => true, - deadlinePlusEnabled: () => true, - workspace: { - ...Mocks.MockWorkspace, - - latest_build: { - ...Mocks.MockWorkspaceBuild, - transition: "start", - deadline: "2022-05-17T23:59:00.00Z", - }, - ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years - }, -} - -export const CannotEdit = Template.bind({}) -CannotEdit.args = { - workspace: { - ...Mocks.MockWorkspace, - - latest_build: { - ...Mocks.MockWorkspaceBuild, - transition: "stop", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours - }, - canUpdateWorkspace: false, -} - -export const SmallViewport = Template.bind({}) -SmallViewport.args = { - ...WorkspaceOffShort.args, -} -SmallViewport.parameters = { - chromatic: { viewports: [320] }, -} diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx deleted file mode 100644 index 8e504bfa115b4..0000000000000 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { screen } from "@testing-library/react" -import dayjs from "dayjs" -import utc from "dayjs/plugin/utc" -import { render } from "testHelpers/renderHelpers" -import * as TypesGen from "../../api/typesGenerated" -import * as Mocks from "../../testHelpers/entities" -import { - canEditDeadline, - WorkspaceScheduleButton, -} from "./WorkspaceScheduleButton" - -dayjs.extend(utc) - -describe("WorkspaceScheduleButton", () => { - describe("shouldDisplayPlusMinus", () => { - it("should not display if the workspace is not running", () => { - // Given: a stopped workspace - const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace - - // Then: shouldDisplayPlusMinus should be false - expect(canEditDeadline(workspace)).toBeFalsy() - }) - - it("should display if the workspace is running", () => { - // Given: a stopped workspace - const workspace: TypesGen.Workspace = Mocks.MockWorkspace - - // Then: shouldDisplayPlusMinus should be false - expect(canEditDeadline(workspace)).toBeTruthy() - }) - }) - describe("enabling plus and minus buttons", () => { - it("should enable plus and minus buttons when deadline can be changed in either direction", async () => { - render( - , - ) - const plusButton = await screen.findByLabelText("Add hours to deadline") - const minusButton = await screen.findByLabelText( - "Subtract hours from deadline", - ) - expect(plusButton).toBeEnabled() - expect(minusButton).toBeEnabled() - }) - it("should disable plus button when deadline can't be extended", async () => { - render( - , - ) - const plusButton = await screen.findByLabelText("Add hours to deadline") - const minusButton = await screen.findByLabelText( - "Subtract hours from deadline", - ) - expect(plusButton).toBeDisabled() - expect(minusButton).toBeEnabled() - }) - it("should disable minus button when deadline can't be reduced", async () => { - render( - , - ) - const plusButton = await screen.findByLabelText("Add hours to deadline") - const minusButton = await screen.findByLabelText( - "Subtract hours from deadline", - ) - expect(plusButton).toBeEnabled() - expect(minusButton).toBeDisabled() - }) - }) -}) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx deleted file mode 100644 index 9493a22481472..0000000000000 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import Button from "@material-ui/core/Button" -import IconButton from "@material-ui/core/IconButton" -import Popover from "@material-ui/core/Popover" -import { makeStyles, Theme } from "@material-ui/core/styles" -import Tooltip from "@material-ui/core/Tooltip" -import AddIcon from "@material-ui/icons/Add" -import RemoveIcon from "@material-ui/icons/Remove" -import ScheduleIcon from "@material-ui/icons/Schedule" -import { Maybe } from "components/Conditionals/Maybe" -import { Stack } from "components/Stack/Stack" -import dayjs from "dayjs" -import advancedFormat from "dayjs/plugin/advancedFormat" -import duration from "dayjs/plugin/duration" -import relativeTime from "dayjs/plugin/relativeTime" -import timezone from "dayjs/plugin/timezone" -import utc from "dayjs/plugin/utc" -import { useRef, useState } from "react" -import { useTranslation } from "react-i18next" -import { colors } from "theme/colors" -import { Workspace } from "../../api/typesGenerated" -import { isWorkspaceOn } from "../../util/workspace" -import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" -import { EditHours } from "./EditHours" -import { WorkspaceScheduleLabel } from "./WorkspaceScheduleLabel" - -// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're -// sorted alphabetically. -dayjs.extend(utc) -dayjs.extend(advancedFormat) -dayjs.extend(duration) -dayjs.extend(relativeTime) -dayjs.extend(timezone) - -export const canEditDeadline = (workspace: Workspace): boolean => { - return isWorkspaceOn(workspace) && Boolean(workspace.latest_build.deadline) -} - -export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => { - if (canEditDeadline(workspace)) { - return true - } - if (isWorkspaceOn(workspace)) { - return false - } - return Boolean(workspace.autostart_schedule) -} - -export interface WorkspaceScheduleButtonProps { - workspace: Workspace - onDeadlinePlus: (hours: number) => void - onDeadlineMinus: (hours: number) => void - maxDeadlineIncrease: number - maxDeadlineDecrease: number - canUpdateWorkspace: boolean -} - -export type EditMode = "add" | "subtract" | "off" - -export const WorkspaceScheduleButton: React.FC< - WorkspaceScheduleButtonProps -> = ({ - workspace, - onDeadlinePlus, - onDeadlineMinus, - maxDeadlineDecrease, - maxDeadlineIncrease, - canUpdateWorkspace, -}) => { - const { t } = useTranslation("workspacePage") - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const [editMode, setEditMode] = useState("off") - const id = isOpen ? "schedule-popover" : undefined - const styles = useStyles({ editMode }) - const deadlinePlusEnabled = maxDeadlineIncrease >= 1 - const deadlineMinusEnabled = maxDeadlineDecrease >= 1 - - const onClose = () => { - setIsOpen(false) - } - - const handleSubmitHours = (hours: number) => { - if (hours !== 0) { - if (editMode === "add") { - onDeadlinePlus(hours) - } - if (editMode === "subtract") { - onDeadlineMinus(hours) - } - } - setEditMode("off") - } - - return ( - - - - - - - - { - setEditMode("subtract") - }} - > - - - - - { - setEditMode("add") - }} - > - - - - - - - - - - - - - <> - - - - - - - ) -} - -interface StyleProps { - editMode: EditMode -} - -const useStyles = makeStyles((theme) => ({ - wrapper: { - display: "inline-flex", - alignItems: "center", - - [theme.breakpoints.down("sm")]: { - flexDirection: "column", - }, - }, - label: { - padding: theme.spacing(0, 2), - color: theme.palette.text.secondary, - borderRadius: theme.shape.borderRadius, - border: `1px solid ${colors.gray[11]}`, // Same as outlined button - display: "flex", - height: theme.spacing(5), // Same as button - alignItems: "center", - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - borderRight: 0, - - [theme.breakpoints.down("sm")]: { - width: "100%", - padding: theme.spacing(1.5, 2), - flexDirection: "column", - }, - }, - actions: { - [theme.breakpoints.down("sm")]: { - marginLeft: "auto", - display: "flex", - paddingLeft: theme.spacing(1), - marginRight: -theme.spacing(1), - }, - }, - scheduleButton: { - flexShrink: 0, - - "&.label": { - borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, - }, - - [theme.breakpoints.down("sm")]: { - width: "100%", - - "&.label": { - borderRadius: `0 0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px`, - borderLeft: 0, - borderTop: `1px solid ${theme.palette.divider}`, - }, - }, - }, - addButton: { - borderRadius: theme.shape.borderRadius, - border: ({ editMode }) => - editMode === "add" - ? `2px solid ${theme.palette.primary.main}` - : "2px solid transparent", - }, - subtractButton: { - borderRadius: theme.shape.borderRadius, - border: ({ editMode }) => - editMode === "subtract" - ? `2px solid ${theme.palette.primary.main}` - : "2px solid transparent", - }, - popoverPaper: { - padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing( - 3, - )}px`, - }, -})) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx deleted file mode 100644 index 24bec420d9863..0000000000000 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles" -import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" -import { Maybe } from "components/Conditionals/Maybe" -import { useTranslation } from "react-i18next" -import { Workspace } from "../../api/typesGenerated" -import { combineClasses } from "../../util/combineClasses" -import { - autostartDisplay, - autostopDisplay, - isShuttingDown, -} from "../../util/schedule" -import { isWorkspaceOn } from "../../util/workspace" - -export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ - workspace, -}) => { - const styles = useStyles() - const { t } = useTranslation("common") - - return ( - - - - - {t("schedule.autostopLabel")} - {" "} - {autostopDisplay(workspace)} - - - - - {t("schedule.autostartLabel")}{" "} - - {autostartDisplay(workspace.autostart_schedule)} - - - - - ) -} - -const useStyles = makeStyles((theme) => ({ - labelText: { - marginRight: theme.spacing(1), - marginLeft: theme.spacing(1), - lineHeight: "160%", - - [theme.breakpoints.down("sm")]: { - marginRight: 0, - width: "100%", - }, - }, - - value: { - [theme.breakpoints.down("sm")]: { - whiteSpace: "nowrap", - }, - }, -})) diff --git a/site/src/components/WorkspaceStats/WorkspaceStats.tsx b/site/src/components/WorkspaceStats/WorkspaceStats.tsx index d23f007b80d83..e699435f4f194 100644 --- a/site/src/components/WorkspaceStats/WorkspaceStats.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceStats.tsx @@ -1,14 +1,24 @@ import Link from "@material-ui/core/Link" import { OutdatedHelpTooltip } from "components/Tooltips" -import { FC } from "react" +import { FC, useRef, useState } from "react" import { Link as RouterLink } from "react-router-dom" import { createDayString } from "util/createDayString" import { getDisplayWorkspaceBuildInitiatedBy, getDisplayWorkspaceTemplateName, + isWorkspaceOn, } from "util/workspace" import { Workspace } from "../../api/typesGenerated" import { Stats, StatsItem } from "components/Stats/Stats" +import upperFirst from "lodash/upperFirst" +import { autostartDisplay, autostopDisplay } from "util/schedule" +import IconButton from "@material-ui/core/IconButton" +import RemoveIcon from "@material-ui/icons/RemoveOutlined" +import { makeStyles } from "@material-ui/core/styles" +import AddIcon from "@material-ui/icons/AddOutlined" +import Popover from "@material-ui/core/Popover" +import TextField from "@material-ui/core/TextField" +import Button from "@material-ui/core/Button" const Language = { workspaceDetails: "Workspace Details", @@ -24,66 +34,329 @@ const Language = { export interface WorkspaceStatsProps { workspace: Workspace + maxDeadlineIncrease: number + maxDeadlineDecrease: number + canUpdateWorkspace: boolean quota_budget?: number + onDeadlinePlus: (hours: number) => void + onDeadlineMinus: (hours: number) => void handleUpdate: () => void } export const WorkspaceStats: FC = ({ workspace, quota_budget, + maxDeadlineDecrease, + maxDeadlineIncrease, + canUpdateWorkspace, handleUpdate, + onDeadlineMinus, + onDeadlinePlus, }) => { const initiatedBy = getDisplayWorkspaceBuildInitiatedBy( workspace.latest_build, ) const displayTemplateName = getDisplayWorkspaceTemplateName(workspace) + const styles = useStyles() + const deadlinePlusEnabled = maxDeadlineIncrease >= 1 + const deadlineMinusEnabled = maxDeadlineDecrease >= 1 + const addButtonRef = useRef(null) + const subButtonRef = useRef(null) + const [isAddingTime, setIsAddingTime] = useState(false) + const [isSubTime, setIsSubTime] = useState(false) return ( - - - {displayTemplateName} - - } - /> - + <> + + - {workspace.latest_build.template_version_name} + {displayTemplateName} + } + /> + + + {workspace.latest_build.template_version_name} + - {workspace.outdated && ( - - )} - - } - /> - - - {workspace.latest_build.daily_cost > 0 && ( + {workspace.outdated && ( + + )} + + } + /> + {upperFirst(createDayString(workspace.latest_build.created_at))}{" "} + by {initiatedBy} + + } /> - )} - + {shouldDisplayScheduleLabel(workspace) && ( + + + {isWorkspaceOn(workspace) + ? autostopDisplay(workspace) + : autostartDisplay(workspace.autostart_schedule)} + + {canUpdateWorkspace && canEditDeadline(workspace) && ( + + setIsSubTime(true)} + > + + + setIsAddingTime(true)} + > + + + + )} + + } + /> + )} + {workspace.latest_build.daily_cost > 0 && ( + + )} + + + setIsAddingTime(false)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + Add hours to deadline + + Delay the shutdown of this workspace for a few more hours. This is + only applied once. + +
{ + e.preventDefault() + const formData = new FormData(e.currentTarget) + const hours = Number(formData.get("hours")) + onDeadlinePlus(hours) + setIsAddingTime(false) + }} + > + + + + +
+ + setIsSubTime(false)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + + Subtract hours to deadline + + + Anticipate the shutdown of this workspace for a few more hours. This + is only applied once. + +
{ + e.preventDefault() + const formData = new FormData(e.currentTarget) + const hours = Number(formData.get("hours")) + onDeadlineMinus(hours) + setIsSubTime(false) + }} + > + + + + +
+ ) } + +export const canEditDeadline = (workspace: Workspace): boolean => { + return isWorkspaceOn(workspace) && Boolean(workspace.latest_build.deadline) +} + +export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => { + if (canEditDeadline(workspace)) { + return true + } + if (isWorkspaceOn(workspace)) { + return false + } + return Boolean(workspace.autostart_schedule) +} + +const getScheduleLabel = (workspace: Workspace) => { + return isWorkspaceOn(workspace) ? "Stops at" : "Starts at" +} + +const useStyles = makeStyles((theme) => ({ + scheduleValue: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + }, + + scheduleControls: { + display: "flex", + alignItems: "center", + gap: theme.spacing(0.5), + }, + + scheduleButton: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: 4, + + "& svg.MuiSvgIcon-root": { + width: theme.spacing(1.5), + height: theme.spacing(1.5), + }, + }, + + timePopoverPaper: { + padding: theme.spacing(3), + maxWidth: theme.spacing(36), + marginTop: theme.spacing(1), + borderRadius: 4, + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + }, + + timePopoverTitle: { + fontWeight: 600, + }, + + timePopoverDescription: { + color: theme.palette.text.secondary, + }, + + timePopoverForm: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + padding: theme.spacing(1, 0), + }, + + timePopoverField: { + margin: 0, + }, + + timePopoverFieldInput: { + fontSize: 14, + padding: theme.spacing(0), + borderRadius: 4, + }, + + timePopoverButton: { + borderRadius: 4, + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + flexShrink: 0, + }, +}))