From c7d86baf4540d04de6c641614bfe28d4607d7c3a Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 1 Jul 2022 13:14:34 +0000 Subject: [PATCH 1/9] refactor: Move schedule to the header --- site/src/components/Workspace/Workspace.tsx | 38 ++- .../WorkspaceSchedule/WorkspaceSchedule.tsx | 100 +------ .../WorkspaceScheduleButton.tsx | 247 ++++++++++++++++++ site/src/theme/overrides.ts | 8 + 4 files changed, 283 insertions(+), 110 deletions(-) create mode 100644 site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 608a8b096c3b0..8c869feb37622 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -9,8 +9,8 @@ import { Resources } from "../Resources/Resources" import { Stack } from "../Stack/Stack" import { WorkspaceActions } from "../WorkspaceActions/WorkspaceActions" import { WorkspaceDeletedBanner } from "../WorkspaceDeletedBanner/WorkspaceDeletedBanner" -import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" import { WorkspaceScheduleBanner } from "../WorkspaceScheduleBanner/WorkspaceScheduleBanner" +import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" @@ -58,16 +58,22 @@ export const Workspace: FC = ({ return ( + + + + } > {workspace.name} @@ -102,14 +108,6 @@ export const Workspace: FC = ({ - - - - ) @@ -125,10 +123,6 @@ export const useStyles = makeStyles((theme) => { secondColumnSpacer: { flex: `0 0 ${spacerWidth}px`, }, - header: { - // 100% - (the size of sidebar + the space between both ) - maxWidth: `calc(100% - (${spacerWidth}px + ${theme.spacing(3)}px))`, - }, layout: { alignItems: "flex-start", }, diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index 66b8768d261a8..fddf934a18fdc 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -1,11 +1,5 @@ -import IconButton from "@material-ui/core/IconButton" import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" -import Tooltip from "@material-ui/core/Tooltip" -import Typography from "@material-ui/core/Typography" -import AddBoxIcon from "@material-ui/icons/AddBox" -import IndeterminateCheckBoxIcon from "@material-ui/icons/IndeterminateCheckBox" -import ScheduleIcon from "@material-ui/icons/Schedule" import cronstrue from "cronstrue" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" @@ -37,8 +31,8 @@ export const Language = { return "Manual" } }, - autoStartLabel: "START", - autoStopLabel: "SHUTDOWN", + autoStartLabel: "Starts at", + autoStopLabel: "Stops at", autoStopDisplay: (workspace: Workspace): string => { const deadline = dayjs(workspace.latest_build.deadline).utc() // a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"' @@ -70,80 +64,26 @@ export const Language = { } }, editScheduleLink: "Edit schedule", - editDeadlineMinus: "Subtract one hour", - editDeadlinePlus: "Add one hour", - scheduleHeader: (workspace: Workspace): string => { - const tz = workspace.autostart_schedule - ? extractTimezone(workspace.autostart_schedule) - : dayjs.tz.guess() - return `Schedule (${tz})` - }, + timezoneLabel: "Timezone", } export interface WorkspaceScheduleProps { - now?: dayjs.Dayjs workspace: Workspace - onDeadlinePlus: () => void - onDeadlineMinus: () => void -} - -export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => { - if (!isWorkspaceOn(workspace)) { - return false - } - const deadline = dayjs(workspace.latest_build.deadline).utc() - return deadline.year() > 1 -} - -export const deadlineMinusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => { - const delta = dayjs(workspace.latest_build.deadline).diff(now) - return delta <= 30 * 60 * 1000 // 30 minutes -} - -export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => { - const delta = dayjs(workspace.latest_build.deadline).diff(now) - return delta >= 24 * 60 * 60 * 1000 // 24 hours } -export const WorkspaceSchedule: FC = ({ - now, - workspace, - onDeadlineMinus, - onDeadlinePlus, -}) => { +export const WorkspaceSchedule: FC = ({ workspace }) => { const styles = useStyles() - const editDeadlineButtons = shouldDisplayPlusMinus(workspace) ? ( - - - - - - - - - - - - - ) : null + const timezone = workspace.autostart_schedule + ? extractTimezone(workspace.autostart_schedule) + : dayjs.tz.guess() return (
- - - {Language.scheduleHeader(workspace)} - +
+ {Language.timezoneLabel} + {timezone} +
{Language.autoStartLabel} @@ -156,7 +96,6 @@ export const WorkspaceSchedule: FC = ({ {Language.autoStopDisplay(workspace)} - {editDeadlineButtons}
@@ -177,18 +116,6 @@ const useStyles = makeStyles((theme) => ({ schedule: { fontFamily: MONOSPACE_FONT_FAMILY, }, - title: { - fontWeight: 600, - - fontFamily: "inherit", - display: "flex", - alignItems: "center", - }, - scheduleIcon: { - width: 16, - height: 16, - marginRight: theme.spacing(1), - }, scheduleLabel: { fontSize: 12, textTransform: "uppercase", @@ -198,14 +125,11 @@ const useStyles = makeStyles((theme) => ({ }, scheduleValue: { fontSize: 14, - marginTop: theme.spacing(0.75), + marginTop: theme.spacing(0.5), display: "inline-block", color: theme.palette.text.secondary, }, scheduleAction: { cursor: "pointer", }, - editDeadline: { - color: theme.palette.text.secondary, - }, })) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx new file mode 100644 index 0000000000000..fd538021b004e --- /dev/null +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -0,0 +1,247 @@ +import Button from "@material-ui/core/Button" +import IconButton from "@material-ui/core/IconButton" +import Popover from "@material-ui/core/Popover" +import { makeStyles } 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 cronstrue from "cronstrue" +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 { Workspace } from "../../api/typesGenerated" +import { extractTimezone, stripTimezone } from "../../util/schedule" +import { isWorkspaceOn } from "../../util/workspace" +import { Stack } from "../Stack/Stack" +import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" + +// 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 Language = { + autoStartDisplay: (schedule: string | undefined): string => { + if (schedule) { + return ( + cronstrue + .toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) + // We don't want to keep the At because it is on the label + .replace("At", "") + ) + } else { + return "Manual" + } + }, + autoStartLabel: "Starts at", + autoStopLabel: "Stops at", + workspaceShuttingDownLabel: "Workspace is shutting down", + autoStopDisplay: (workspace: Workspace): string => { + const deadline = dayjs(workspace.latest_build.deadline).utc() + // a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"' + // SEE: #1834 + const hasDeadline = deadline.year() > 1 + const ttl = workspace.ttl_ms + + if (isWorkspaceOn(workspace) && hasDeadline) { + // Workspace is on --> derive from latest_build.deadline. Note that the + // user may modify their workspace object (ttl) while the workspace is + // running and depending on system semantics, the deadline may still + // represent the previously defined ttl. Thus, we always derive from the + // deadline as the source of truth. + const now = dayjs().utc() + if (now.isAfter(deadline)) { + return Language.workspaceShuttingDownLabel + } else { + return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A") + } + } else if (!ttl || ttl < 1) { + // If the workspace is not on, and the ttl is 0 or undefined, then the + // workspace is set to manually shutdown. + return "Manual" + } else { + // The workspace has a ttl set, but is either in an unknown state or is + // not running. Therefore, we derive from workspace.ttl. + const duration = dayjs.duration(ttl, "milliseconds") + return `${duration.humanize()} after start` + } + }, + editScheduleLink: "Edit schedule", + editDeadlineMinus: "Subtract one hour", + editDeadlinePlus: "Add one hour", + scheduleHeader: (workspace: Workspace): string => { + const tz = workspace.autostart_schedule + ? extractTimezone(workspace.autostart_schedule) + : dayjs.tz.guess() + return `Schedule (${tz})` + }, +} + +export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => { + if (!isWorkspaceOn(workspace)) { + return false + } + const deadline = dayjs(workspace.latest_build.deadline).utc() + return deadline.year() > 1 +} + +export const deadlineMinusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => { + const delta = dayjs(workspace.latest_build.deadline).diff(now) + return delta <= 30 * 60 * 1000 // 30 minutes +} + +export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => { + const delta = dayjs(workspace.latest_build.deadline).diff(now) + return delta >= 24 * 60 * 60 * 1000 // 24 hours +} + +const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ workspace }) => { + const styles = useStyles() + + if (isWorkspaceOn(workspace)) { + const stopLabel = Language.autoStopDisplay(workspace) + const isShuttingDown = stopLabel === Language.workspaceShuttingDownLabel + + // If it is shutting down, we don't need to display the auto stop label + return ( + + {!isShuttingDown && ( + {Language.autoStopLabel} + )} + {stopLabel} + + ) + } + + return ( + + {Language.autoStartLabel} + {Language.autoStartDisplay(workspace.autostart_schedule)} + + ) +} + +interface WorkspaceScheduleProps { + workspace: Workspace + onDeadlinePlus: () => void + onDeadlineMinus: () => void + now?: dayjs.Dayjs +} + +export const WorkspaceScheduleButton: React.FC = ({ + workspace, + onDeadlinePlus, + onDeadlineMinus, + now, +}) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "schedule-popover" : undefined + const styles = useStyles() + + const onClose = () => { + setIsOpen(false) + } + + return ( +
+
+ + {shouldDisplayPlusMinus(workspace) && ( + + + + + + + + + + + + + )} +
+
+ + + + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + wrapper: { + display: "flex", + alignItems: "center", + }, + + label: { + border: `1px solid ${theme.palette.divider}`, + borderRight: 0, + height: "100%", + display: "flex", + alignItems: "center", + padding: "0 8px 0 16px", + color: theme.palette.text.secondary, + }, + + labelText: { + marginRight: theme.spacing(2), + }, + + labelStrong: { + marginRight: theme.spacing(0.25), + }, + + iconButton: { + borderRadius: 2, + }, + + popoverPaper: { + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, + }, +})) diff --git a/site/src/theme/overrides.ts b/site/src/theme/overrides.ts index 40b672cd6ca17..6f987670967c7 100644 --- a/site/src/theme/overrides.ts +++ b/site/src/theme/overrides.ts @@ -33,6 +33,14 @@ export const getOverrides = (palette: PaletteOptions) => { }, }, }, + MuiIconButton: { + sizeSmall: { + "& .MuiSvgIcon-root": { + width: 20, + height: 20, + }, + }, + }, MuiTableHead: { root: { fontFamily: MONOSPACE_FONT_FAMILY, From b383a0f8b63a0f87e63d019243e5a8d25340292a Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 1 Jul 2022 13:21:00 +0000 Subject: [PATCH 2/9] test: Add storybook --- .../WorkspaceScheduleButton.stories.tsx | 117 ++++++++++++++++++ .../WorkspaceScheduleButton.tsx | 8 +- 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx new file mode 100644 index 0000000000000..ec5a54fd17940 --- /dev/null +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx @@ -0,0 +1,117 @@ +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) + +// REMARK: There's a known problem with storybook and using date libraries that +// call string.toLowerCase +// SEE: https:github.com/storybookjs/storybook/issues/12208#issuecomment-697044557 +const ONE = 1 +const SEVEN = 7 +const THIRTY = 30 + +export default { + title: "components/WorkspaceScheduleButton", + component: WorkspaceScheduleButton, +} + +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, + // a mannual shutdown has a deadline of '"0001-01-01T00:00:00Z"' + // SEE: #1834 + deadline: "0001-01-01T00:00:00Z", + }, + ttl_ms: undefined, + }, +} + +export const ShutdownRealSoon = Template.bind({}) +ShutdownRealSoon.args = { + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(THIRTY, "minute").utc().format(), + transition: "start", + }, + ttl_ms: 2 * 60 * 60 * 1000, // 2 hours + }, +} + +export const ShutdownSoon = Template.bind({}) +ShutdownSoon.args = { + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(ONE, "hour").utc().format(), + transition: "start", + }, + ttl_ms: 2 * 60 * 60 * 1000, // 2 hours + }, +} + +export const ShutdownLong = Template.bind({}) +ShutdownLong.args = { + workspace: { + ...Mocks.MockWorkspace, + + latest_build: { + ...Mocks.MockWorkspaceBuild, + deadline: dayjs().add(SEVEN, "days").utc().format(), + transition: "start", + }, + ttl_ms: 7 * 24 * 60 * 60 * 1000, // 7 days + }, +} + +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 = { + workspace: { + ...Mocks.MockWorkspace, + + latest_build: { + ...Mocks.MockWorkspaceBuild, + transition: "stop", + }, + ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years + }, +} diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx index fd538021b004e..5c68a33a5a3ee 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -129,14 +129,14 @@ const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ workspace ) } -interface WorkspaceScheduleProps { +export interface WorkspaceScheduleButtonProps { workspace: Workspace onDeadlinePlus: () => void onDeadlineMinus: () => void now?: dayjs.Dayjs } -export const WorkspaceScheduleButton: React.FC = ({ +export const WorkspaceScheduleButton: React.FC = ({ workspace, onDeadlinePlus, onDeadlineMinus, @@ -227,6 +227,8 @@ const useStyles = makeStyles((theme) => ({ alignItems: "center", padding: "0 8px 0 16px", color: theme.palette.text.secondary, + // It is from the button props + minHeight: 42, }, labelText: { @@ -234,7 +236,7 @@ const useStyles = makeStyles((theme) => ({ }, labelStrong: { - marginRight: theme.spacing(0.25), + marginRight: theme.spacing(0.5), }, iconButton: { From ead1ff929afd08a23cb9f501e22dc6215f1f68bd Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 1 Jul 2022 13:22:37 +0000 Subject: [PATCH 3/9] Move tests to the schedule button --- .../WorkspaceScheduleButton.test.tsx} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename site/src/components/{WorkspaceSchedule/WorkspaceSchedule.test.tsx => WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx} (98%) diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.test.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx similarity index 98% rename from site/src/components/WorkspaceSchedule/WorkspaceSchedule.test.tsx rename to site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx index b08dfe5e37aeb..1bd8651efd5d7 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.test.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.test.tsx @@ -6,12 +6,12 @@ import { deadlineMinusDisabled, deadlinePlusDisabled, shouldDisplayPlusMinus, -} from "./WorkspaceSchedule" +} from "./WorkspaceScheduleButton" dayjs.extend(utc) const now = dayjs() -describe("WorkspaceSchedule", () => { +describe("WorkspaceScheduleButton", () => { describe("shouldDisplayPlusMinus", () => { it("should not display if the workspace is not running", () => { // Given: a stopped workspace From a564dcb1d5fc8b23e1c0d5af3fd23e51e5e26e51 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 1 Jul 2022 16:32:54 +0000 Subject: [PATCH 4/9] Cleaning up components --- .../WorkspaceSchedule/WorkspaceSchedule.tsx | 56 ++--------- .../WorkspaceScheduleButton.tsx | 95 +------------------ .../WorkspaceScheduleLabel.tsx | 40 ++++++++ site/src/util/schedule.ts | 71 ++++++++++++++ 4 files changed, 124 insertions(+), 138 deletions(-) create mode 100644 site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index fddf934a18fdc..b6730733fe784 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -1,6 +1,5 @@ import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" -import cronstrue from "cronstrue" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import duration from "dayjs/plugin/duration" @@ -11,8 +10,12 @@ import { FC } from "react" import { Link as RouterLink } from "react-router-dom" import { Workspace } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" -import { extractTimezone, stripTimezone } from "../../util/schedule" -import { isWorkspaceOn } from "../../util/workspace" +import { + autoStartDisplay, + autoStopDisplay, + extractTimezone, + Language as ScheduleLanguage, +} from "../../util/schedule" import { Stack } from "../Stack/Stack" // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're @@ -24,45 +27,6 @@ dayjs.extend(relativeTime) dayjs.extend(timezone) export const Language = { - autoStartDisplay: (schedule: string | undefined): string => { - if (schedule) { - return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) - } else { - return "Manual" - } - }, - autoStartLabel: "Starts at", - autoStopLabel: "Stops at", - autoStopDisplay: (workspace: Workspace): string => { - const deadline = dayjs(workspace.latest_build.deadline).utc() - // a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"' - // SEE: #1834 - const hasDeadline = deadline.year() > 1 - const ttl = workspace.ttl_ms - - if (isWorkspaceOn(workspace) && hasDeadline) { - // Workspace is on --> derive from latest_build.deadline. Note that the - // user may modify their workspace object (ttl) while the workspace is - // running and depending on system semantics, the deadline may still - // represent the previously defined ttl. Thus, we always derive from the - // deadline as the source of truth. - const now = dayjs().utc() - if (now.isAfter(deadline)) { - return "Workspace is shutting down" - } else { - return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A") - } - } else if (!ttl || ttl < 1) { - // If the workspace is not on, and the ttl is 0 or undefined, then the - // workspace is set to manually shutdown. - return "Manual" - } else { - // The workspace has a ttl set, but is either in an unknown state or is - // not running. Therefore, we derive from workspace.ttl. - const duration = dayjs.duration(ttl, "milliseconds") - return `${duration.humanize()} after start` - } - }, editScheduleLink: "Edit schedule", timezoneLabel: "Timezone", } @@ -85,16 +49,16 @@ export const WorkspaceSchedule: FC = ({ workspace }) => {timezone}
- {Language.autoStartLabel} + {ScheduleLanguage.autoStartLabel} - {Language.autoStartDisplay(workspace.autostart_schedule)} + {autoStartDisplay(workspace.autostart_schedule)}
- {Language.autoStopLabel} + {ScheduleLanguage.autoStopLabel} - {Language.autoStopDisplay(workspace)} + {autoStopDisplay(workspace)}
diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx index 5c68a33a5a3ee..be7ad14f9bf61 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -6,7 +6,6 @@ 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 cronstrue from "cronstrue" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import duration from "dayjs/plugin/duration" @@ -15,10 +14,10 @@ import timezone from "dayjs/plugin/timezone" import utc from "dayjs/plugin/utc" import { useRef, useState } from "react" import { Workspace } from "../../api/typesGenerated" -import { extractTimezone, stripTimezone } from "../../util/schedule" import { isWorkspaceOn } from "../../util/workspace" import { Stack } from "../Stack/Stack" import { WorkspaceSchedule } from "../WorkspaceSchedule/WorkspaceSchedule" +import { WorkspaceScheduleLabel } from "./WorkspaceScheduleLabel" // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're // sorted alphabetically. @@ -29,60 +28,8 @@ dayjs.extend(relativeTime) dayjs.extend(timezone) export const Language = { - autoStartDisplay: (schedule: string | undefined): string => { - if (schedule) { - return ( - cronstrue - .toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) - // We don't want to keep the At because it is on the label - .replace("At", "") - ) - } else { - return "Manual" - } - }, - autoStartLabel: "Starts at", - autoStopLabel: "Stops at", - workspaceShuttingDownLabel: "Workspace is shutting down", - autoStopDisplay: (workspace: Workspace): string => { - const deadline = dayjs(workspace.latest_build.deadline).utc() - // a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"' - // SEE: #1834 - const hasDeadline = deadline.year() > 1 - const ttl = workspace.ttl_ms - - if (isWorkspaceOn(workspace) && hasDeadline) { - // Workspace is on --> derive from latest_build.deadline. Note that the - // user may modify their workspace object (ttl) while the workspace is - // running and depending on system semantics, the deadline may still - // represent the previously defined ttl. Thus, we always derive from the - // deadline as the source of truth. - const now = dayjs().utc() - if (now.isAfter(deadline)) { - return Language.workspaceShuttingDownLabel - } else { - return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A") - } - } else if (!ttl || ttl < 1) { - // If the workspace is not on, and the ttl is 0 or undefined, then the - // workspace is set to manually shutdown. - return "Manual" - } else { - // The workspace has a ttl set, but is either in an unknown state or is - // not running. Therefore, we derive from workspace.ttl. - const duration = dayjs.duration(ttl, "milliseconds") - return `${duration.humanize()} after start` - } - }, - editScheduleLink: "Edit schedule", editDeadlineMinus: "Subtract one hour", editDeadlinePlus: "Add one hour", - scheduleHeader: (workspace: Workspace): string => { - const tz = workspace.autostart_schedule - ? extractTimezone(workspace.autostart_schedule) - : dayjs.tz.guess() - return `Schedule (${tz})` - }, } export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => { @@ -103,44 +50,16 @@ export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): bo return delta >= 24 * 60 * 60 * 1000 // 24 hours } -const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ workspace }) => { - const styles = useStyles() - - if (isWorkspaceOn(workspace)) { - const stopLabel = Language.autoStopDisplay(workspace) - const isShuttingDown = stopLabel === Language.workspaceShuttingDownLabel - - // If it is shutting down, we don't need to display the auto stop label - return ( - - {!isShuttingDown && ( - {Language.autoStopLabel} - )} - {stopLabel} - - ) - } - - return ( - - {Language.autoStartLabel} - {Language.autoStartDisplay(workspace.autostart_schedule)} - - ) -} - export interface WorkspaceScheduleButtonProps { workspace: Workspace onDeadlinePlus: () => void onDeadlineMinus: () => void - now?: dayjs.Dayjs } export const WorkspaceScheduleButton: React.FC = ({ workspace, onDeadlinePlus, onDeadlineMinus, - now, }) => { const anchorRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -160,7 +79,7 @@ export const WorkspaceScheduleButton: React.FC = ( @@ -170,7 +89,7 @@ export const WorkspaceScheduleButton: React.FC = ( @@ -231,14 +150,6 @@ const useStyles = makeStyles((theme) => ({ minHeight: 42, }, - labelText: { - marginRight: theme.spacing(2), - }, - - labelStrong: { - marginRight: theme.spacing(0.5), - }, - iconButton: { borderRadius: 2, }, diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx new file mode 100644 index 0000000000000..250dfb81a5c33 --- /dev/null +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx @@ -0,0 +1,40 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Workspace } from "../../api/typesGenerated" +import { autoStartDisplay, autoStopDisplay, Language } from "../../util/schedule" +import { isWorkspaceOn } from "../../util/workspace" + +export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ workspace }) => { + const styles = useStyles() + + if (isWorkspaceOn(workspace)) { + const stopLabel = autoStopDisplay(workspace) + const isShuttingDown = stopLabel === Language.workspaceShuttingDownLabel + + // If it is shutting down, we don't need to display the auto stop label + return ( + + {!isShuttingDown && ( + {Language.autoStopLabel} + )} + {stopLabel} + + ) + } + + return ( + + {Language.autoStartLabel} + {autoStartDisplay(workspace.autostart_schedule)} + + ) +} + +const useStyles = makeStyles((theme) => ({ + labelText: { + marginRight: theme.spacing(2), + }, + + labelStrong: { + marginRight: theme.spacing(0.5), + }, +})) diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index 67e25fd8ba0e1..bfda66bbd92ce 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -1,3 +1,21 @@ +import cronstrue from "cronstrue" +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 { Workspace } from "../api/typesGenerated" +import { isWorkspaceOn } from "./workspace" + +// 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) + /** * @fileoverview Client-side counterpart of the coderd/autostart/schedule Go * package. This package is a variation on crontab that uses minute, hour and @@ -30,3 +48,56 @@ export const extractTimezone = (raw: string, defaultTZ = DEFAULT_TIMEZONE): stri return defaultTZ } } + +/** Language used in the schedule components */ +export const Language = { + manual: "Manual", + workspaceShuttingDownLabel: "Workspace is shutting down", + afterStart: "after start", + autoStartLabel: "Starts at", + autoStopLabel: "Stops at", +} + +export const autoStartDisplay = (schedule: string | undefined): string => { + if (schedule) { + return ( + cronstrue + .toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) + // We don't want to keep the At because it is on the label + .replace("At", "") + ) + } else { + return Language.manual + } +} + +export const autoStopDisplay = (workspace: Workspace): string => { + const deadline = dayjs(workspace.latest_build.deadline).utc() + // a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"' + // SEE: #1834 + const hasDeadline = deadline.year() > 1 + const ttl = workspace.ttl_ms + + if (isWorkspaceOn(workspace) && hasDeadline) { + // Workspace is on --> derive from latest_build.deadline. Note that the + // user may modify their workspace object (ttl) while the workspace is + // running and depending on system semantics, the deadline may still + // represent the previously defined ttl. Thus, we always derive from the + // deadline as the source of truth. + const now = dayjs().utc() + if (now.isAfter(deadline)) { + return Language.workspaceShuttingDownLabel + } else { + return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A") + } + } else if (!ttl || ttl < 1) { + // If the workspace is not on, and the ttl is 0 or undefined, then the + // workspace is set to manually shutdown. + return Language.manual + } else { + // The workspace has a ttl set, but is either in an unknown state or is + // not running. Therefore, we derive from workspace.ttl. + const duration = dayjs.duration(ttl, "milliseconds") + return `${duration.humanize()} ${Language.afterStart}` + } +} From f0d1481e08a266f2f41f2797b557bca03842ca40 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 1 Jul 2022 16:46:56 +0000 Subject: [PATCH 5/9] Remove comment --- .../WorkspaceScheduleButton/WorkspaceScheduleButton.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx index be7ad14f9bf61..37dff6205e7ae 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -110,7 +110,6 @@ export const WorkspaceScheduleButton: React.FC = ( Schedule Date: Fri, 1 Jul 2022 17:39:25 +0000 Subject: [PATCH 6/9] Memoize workspace schedule button --- site/src/components/Workspace/Workspace.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 8c869feb37622..785ea7ac5fbbc 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,5 +1,5 @@ import { makeStyles } from "@material-ui/core/styles" -import { FC } from "react" +import React, { FC } from "react" import { useNavigate } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" import { BuildsTable } from "../BuildsTable/BuildsTable" @@ -14,6 +14,12 @@ import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceSch import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" +// The WorkspaceScheduleButton does some calculation to display the date labels +// so to avoid doing that every seconds - because of the workspace pooling - we +// are memoizing this component. More details: +// https://github.com/coder/coder/pull/2775#discussion_r912080377 +const MemoizedWorkspaceScheduleButton = React.memo(WorkspaceScheduleButton) + export interface WorkspaceProps { bannerProps: { isLoading?: boolean @@ -60,7 +66,7 @@ export const Workspace: FC = ({ - Date: Fri, 1 Jul 2022 17:52:07 +0000 Subject: [PATCH 7/9] Extract isShuttingDown logic --- .../WorkspaceScheduleLabel.tsx | 6 +++--- site/src/util/schedule.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx index 250dfb81a5c33..379c3dd01d729 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx @@ -1,6 +1,6 @@ import { makeStyles } from "@material-ui/core/styles" import { Workspace } from "../../api/typesGenerated" -import { autoStartDisplay, autoStopDisplay, Language } from "../../util/schedule" +import { autoStartDisplay, autoStopDisplay, isShuttingDown, Language } from "../../util/schedule" import { isWorkspaceOn } from "../../util/workspace" export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ workspace }) => { @@ -8,12 +8,12 @@ export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ wor if (isWorkspaceOn(workspace)) { const stopLabel = autoStopDisplay(workspace) - const isShuttingDown = stopLabel === Language.workspaceShuttingDownLabel + const shouldDisplayStrongLabel = !isShuttingDown(workspace) // If it is shutting down, we don't need to display the auto stop label return ( - {!isShuttingDown && ( + {shouldDisplayStrongLabel && ( {Language.autoStopLabel} )} {stopLabel} diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index bfda66bbd92ce..ca5dd52a24ad2 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -1,5 +1,5 @@ import cronstrue from "cronstrue" -import dayjs from "dayjs" +import dayjs, { Dayjs } from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import duration from "dayjs/plugin/duration" import relativeTime from "dayjs/plugin/relativeTime" @@ -71,6 +71,15 @@ export const autoStartDisplay = (schedule: string | undefined): string => { } } +export const isShuttingDown = (workspace: Workspace, deadline?: Dayjs): boolean => { + if (!deadline) { + deadline = dayjs(workspace.latest_build.deadline).utc() + } + const hasDeadline = deadline.year() > 1 + const now = dayjs().utc() + return isWorkspaceOn(workspace) && hasDeadline && now.isAfter(deadline) +} + export const autoStopDisplay = (workspace: Workspace): string => { const deadline = dayjs(workspace.latest_build.deadline).utc() // a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"' @@ -84,8 +93,8 @@ export const autoStopDisplay = (workspace: Workspace): string => { // running and depending on system semantics, the deadline may still // represent the previously defined ttl. Thus, we always derive from the // deadline as the source of truth. - const now = dayjs().utc() - if (now.isAfter(deadline)) { + + if (isShuttingDown(workspace, deadline)) { return Language.workspaceShuttingDownLabel } else { return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A") From 700fb675911bdb898cbf8e63e73c46193118dd0c Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 1 Jul 2022 18:02:42 +0000 Subject: [PATCH 8/9] Ignores the label on workspace schedule button --- .../WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx index 379c3dd01d729..e57ec0087ac8a 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleLabel.tsx @@ -1,5 +1,6 @@ import { makeStyles } from "@material-ui/core/styles" import { Workspace } from "../../api/typesGenerated" +import { combineClasses } from "../../util/combineClasses" import { autoStartDisplay, autoStopDisplay, isShuttingDown, Language } from "../../util/schedule" import { isWorkspaceOn } from "../../util/workspace" @@ -12,7 +13,7 @@ export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ wor // If it is shutting down, we don't need to display the auto stop label return ( - + {shouldDisplayStrongLabel && ( {Language.autoStopLabel} )} @@ -22,7 +23,7 @@ export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({ wor } return ( - + {Language.autoStartLabel} {autoStartDisplay(workspace.autostart_schedule)} From 2280693c37aa71985eb2dfd5f0a9fccff8e1d764 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 1 Jul 2022 18:13:11 +0000 Subject: [PATCH 9/9] Remove memoization --- site/src/components/Workspace/Workspace.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 785ea7ac5fbbc..e1f706d3fcfc9 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -14,12 +14,6 @@ import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceSch import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" -// The WorkspaceScheduleButton does some calculation to display the date labels -// so to avoid doing that every seconds - because of the workspace pooling - we -// are memoizing this component. More details: -// https://github.com/coder/coder/pull/2775#discussion_r912080377 -const MemoizedWorkspaceScheduleButton = React.memo(WorkspaceScheduleButton) - export interface WorkspaceProps { bannerProps: { isLoading?: boolean @@ -66,7 +60,7 @@ export const Workspace: FC = ({ -