fix: redesign schedule bumper to handle multiple hours of change at once (#4535)

* Start sketching out new design

* Working but ugly

* Highlight chosen mode

* Format

* Set hours field width

* Alignment on desktop

* Use primary button color

* Make 1 the default change

* Add stepper max

* Fix storybook

* Handle undefined deadline

* Access deadline correctly

* Format

* Fix overflow on mobile
This commit is contained in:
Presley Pizzo
2022-10-14 13:23:00 -04:00
committed by GitHub
parent d898737d6d
commit ccc008eb5e
11 changed files with 260 additions and 73 deletions
+6 -2
View File
@@ -34,10 +34,12 @@ export interface WorkspaceProps {
onExtend: () => void
}
scheduleProps: {
onDeadlinePlus: () => void
onDeadlineMinus: () => void
onDeadlinePlus: (hours: number) => void
onDeadlineMinus: (hours: number) => void
deadlinePlusEnabled: () => boolean
deadlineMinusEnabled: () => boolean
maxDeadlineIncrease: number
maxDeadlineDecrease: number
}
handleStart: () => void
handleStop: () => void
@@ -121,6 +123,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
onDeadlinePlus={scheduleProps.onDeadlinePlus}
deadlineMinusEnabled={scheduleProps.deadlineMinusEnabled}
deadlinePlusEnabled={scheduleProps.deadlinePlusEnabled}
maxDeadlineDecrease={scheduleProps.maxDeadlineDecrease}
maxDeadlineIncrease={scheduleProps.maxDeadlineIncrease}
canUpdateWorkspace={canUpdateWorkspace}
/>
<WorkspaceActions
@@ -0,0 +1,53 @@
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 (
<form onSubmit={() => handleSubmit(hours)}>
<Stack direction="row" alignItems="baseline" spacing={1}>
<TextField
className={styles.inputField}
inputProps={{ min: 0, max, step: 1 }}
label={t("workspaceScheduleButton.hours")}
value={hours}
onChange={(e) => setHours(parseInt(e.target.value))}
type="number"
/>
<Button className={styles.button} type="submit" color="primary">
{t("workspaceScheduleButton.submitDeadline")}
</Button>
</Stack>
</form>
)
}
const useStyles = makeStyles(() => ({
inputField: {
width: "70px",
"& .MuiOutlinedInput-root": {
height: "30px",
},
},
button: {
"&.MuiButton-root": {
minHeight: "30px",
height: "30px",
},
},
}))
@@ -16,6 +16,12 @@ export default {
canUpdateWorkspace: {
defaultValue: true,
},
deadlineMinusEnabled: {
defaultValue: (): boolean => false,
},
deadlinePlusEnabled: {
defaultValue: (): boolean => false,
},
},
}
@@ -64,6 +70,7 @@ WorkspaceOffShort.args = {
export const WorkspaceOffLong = Template.bind({})
WorkspaceOffLong.args = {
deadlinePlusEnabled: () => true,
workspace: {
...Mocks.MockWorkspace,
@@ -2,7 +2,7 @@ import dayjs from "dayjs"
import utc from "dayjs/plugin/utc"
import * as TypesGen from "../../api/typesGenerated"
import * as Mocks from "../../testHelpers/entities"
import { shouldDisplayPlusMinus } from "./WorkspaceScheduleButton"
import { canEditDeadline } from "./WorkspaceScheduleButton"
dayjs.extend(utc)
@@ -13,7 +13,7 @@ describe("WorkspaceScheduleButton", () => {
const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace
// Then: shouldDisplayPlusMinus should be false
expect(shouldDisplayPlusMinus(workspace)).toBeFalsy()
expect(canEditDeadline(workspace)).toBeFalsy()
})
it("should display if the workspace is running", () => {
@@ -21,7 +21,7 @@ describe("WorkspaceScheduleButton", () => {
const workspace: TypesGen.Workspace = Mocks.MockWorkspace
// Then: shouldDisplayPlusMinus should be false
expect(shouldDisplayPlusMinus(workspace)).toBeTruthy()
expect(canEditDeadline(workspace)).toBeTruthy()
})
})
})
@@ -1,11 +1,13 @@
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 { 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"
@@ -17,6 +19,7 @@ import { useTranslation } from "react-i18next"
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
@@ -27,12 +30,12 @@ dayjs.extend(duration)
dayjs.extend(relativeTime)
dayjs.extend(timezone)
export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => {
export const canEditDeadline = (workspace: Workspace): boolean => {
return isWorkspaceOn(workspace) && Boolean(workspace.latest_build.deadline)
}
export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => {
if (shouldDisplayPlusMinus(workspace)) {
if (canEditDeadline(workspace)) {
return true
}
if (isWorkspaceOn(workspace)) {
@@ -43,13 +46,17 @@ export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => {
export interface WorkspaceScheduleButtonProps {
workspace: Workspace
onDeadlinePlus: () => void
onDeadlineMinus: () => void
onDeadlinePlus: (hours: number) => void
onDeadlineMinus: (hours: number) => void
deadlineMinusEnabled: () => boolean
deadlinePlusEnabled: () => boolean
maxDeadlineIncrease: number
maxDeadlineDecrease: number
canUpdateWorkspace: boolean
}
export type EditMode = "add" | "subtract" | "off"
export const WorkspaceScheduleButton: React.FC<
WorkspaceScheduleButtonProps
> = ({
@@ -58,49 +65,93 @@ export const WorkspaceScheduleButton: React.FC<
onDeadlineMinus,
deadlinePlusEnabled,
deadlineMinusEnabled,
maxDeadlineDecrease,
maxDeadlineIncrease,
canUpdateWorkspace,
}) => {
const { t } = useTranslation("workspacePage")
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [editMode, setEditMode] = useState<EditMode>("off")
const id = isOpen ? "schedule-popover" : undefined
const styles = useStyles()
const styles = useStyles({ editMode })
const onClose = () => {
setIsOpen(false)
}
const handleSubmitHours = (hours: number) => {
if (hours !== 0) {
if (editMode === "add") {
onDeadlinePlus(hours)
}
if (editMode === "subtract") {
onDeadlineMinus(hours)
}
}
setEditMode("off")
}
return (
<span className={styles.wrapper}>
{shouldDisplayScheduleLabel(workspace) && (
<span className={styles.label}>
<WorkspaceScheduleLabel workspace={workspace} />
{canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && (
<span className={styles.actions}>
<IconButton
className={styles.iconButton}
size="small"
disabled={!deadlineMinusEnabled()}
onClick={onDeadlineMinus}
>
<Tooltip title={t("workspaceScheduleButton.editDeadlineMinus")}>
<RemoveIcon />
</Tooltip>
</IconButton>
<IconButton
className={styles.iconButton}
size="small"
disabled={!deadlinePlusEnabled()}
onClick={onDeadlinePlus}
>
<Tooltip title={t("workspaceScheduleButton.editDeadlinePlus")}>
<AddIcon />
</Tooltip>
</IconButton>
</span>
)}
</span>
)}
<Maybe condition={shouldDisplayScheduleLabel(workspace)}>
<Stack
className={styles.label}
spacing={1}
direction="row"
alignItems="center"
>
<Stack spacing={1} direction="row" alignItems="center">
<WorkspaceScheduleLabel workspace={workspace} />
<Maybe condition={canUpdateWorkspace && canEditDeadline(workspace)}>
<span className={styles.actions}>
<IconButton
className={styles.subtractButton}
size="small"
disabled={!deadlineMinusEnabled()}
onClick={() => {
setEditMode("subtract")
}}
>
<Tooltip
title={t("workspaceScheduleButton.editDeadlineMinus")}
>
<RemoveIcon />
</Tooltip>
</IconButton>
<IconButton
className={styles.addButton}
size="small"
disabled={!deadlinePlusEnabled()}
onClick={() => {
setEditMode("add")
}}
>
<Tooltip
title={t("workspaceScheduleButton.editDeadlinePlus")}
>
<AddIcon />
</Tooltip>
</IconButton>
</span>
</Maybe>
</Stack>
<Maybe
condition={
canUpdateWorkspace &&
canEditDeadline(workspace) &&
editMode !== "off"
}
>
<EditHours
handleSubmit={handleSubmitHours}
max={
editMode === "add" ? maxDeadlineIncrease : maxDeadlineDecrease
}
/>
</Maybe>
</Stack>
</Maybe>
<>
<Button
ref={anchorRef}
@@ -139,7 +190,11 @@ export const WorkspaceScheduleButton: React.FC<
)
}
const useStyles = makeStyles((theme) => ({
interface StyleProps {
editMode: EditMode
}
const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
wrapper: {
display: "inline-flex",
alignItems: "center",
@@ -151,15 +206,13 @@ const useStyles = makeStyles((theme) => ({
},
},
label: {
borderRight: 0,
padding: "0 8px 0 16px",
padding: theme.spacing(0, 2),
color: theme.palette.text.secondary,
[theme.breakpoints.down("sm")]: {
width: "100%",
display: "flex",
alignItems: "center",
padding: theme.spacing(1.5, 2),
flexDirection: "column",
},
},
actions: {
@@ -190,8 +243,19 @@ const useStyles = makeStyles((theme) => ({
},
},
},
iconButton: {
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(
@@ -1,11 +1,13 @@
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,
Language,
} from "../../util/schedule"
import { isWorkspaceOn } from "../../util/workspace"
@@ -13,33 +15,38 @@ export const WorkspaceScheduleLabel: React.FC<{ workspace: Workspace }> = ({
workspace,
}) => {
const styles = useStyles()
if (isWorkspaceOn(workspace)) {
const stopLabel = autoStopDisplay(workspace)
const shouldDisplayStrongLabel = !isShuttingDown(workspace)
// If it is shutting down, we don't need to display the auto stop label
return (
<span className={combineClasses([styles.labelText, "chromatic-ignore"])}>
{shouldDisplayStrongLabel && <strong>{Language.autoStopLabel}</strong>}{" "}
<span className={styles.value}>{stopLabel}</span>
</span>
)
}
const { t } = useTranslation("common")
return (
<span className={combineClasses([styles.labelText, "chromatic-ignore"])}>
<strong>{Language.autoStartLabel}</strong>{" "}
<span className={styles.value}>
{autoStartDisplay(workspace.autostart_schedule)}
</span>
</span>
<ChooseOne>
<Cond condition={isWorkspaceOn(workspace)}>
<span
className={combineClasses([styles.labelText, "chromatic-ignore"])}
>
<Maybe condition={!isShuttingDown(workspace)}>
<strong>{t("schedule.autoStopLabel")}</strong>
</Maybe>{" "}
<span className={styles.value}>{autoStopDisplay(workspace)}</span>
</span>
</Cond>
<Cond>
<span
className={combineClasses([styles.labelText, "chromatic-ignore"])}
>
<strong>{t("schedule.autoStartLabel")}</strong>{" "}
<span className={styles.value}>
{autoStartDisplay(workspace.autostart_schedule)}
</span>
</span>
</Cond>
</ChooseOne>
)
}
const useStyles = makeStyles((theme) => ({
labelText: {
marginRight: theme.spacing(2),
marginRight: theme.spacing(1),
marginLeft: theme.spacing(1),
lineHeight: "160%",
[theme.breakpoints.down("sm")]: {
+4
View File
@@ -19,6 +19,10 @@
"confirmLabel": "Name of {{entity}} to delete",
"incorrectName": "Incorrect {{entity}} name."
},
"schedule": {
"autoStartLabel": "Starts at",
"autoStopLabel": "Stops at"
},
"ctas": {
"dismissCta": "Dismiss",
"expand": "Click here to learn more",
+4 -2
View File
@@ -4,8 +4,10 @@
},
"workspaceScheduleButton": {
"schedule": "Schedule",
"editDeadlineMinus": "Subtract one hour",
"editDeadlinePlus": "Add one hour"
"editDeadlineMinus": "Subtract hours",
"editDeadlinePlus": "Add hours",
"submitDeadline": "Set",
"hours": "Hours"
},
"ctas": {
"createWorkspaceCta": "Create new workspace",
@@ -4,6 +4,11 @@ import dayjs from "dayjs"
import { useContext } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import {
getMaxDeadline,
getMaxDeadlineChange,
getMinDeadline,
} from "util/schedule"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { StateFrom } from "xstate"
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
@@ -31,6 +36,7 @@ export const WorkspaceReadyPage = ({
const [bannerState, bannerSend] = useActor(
workspaceState.children["scheduleBannerMachine"],
)
const deadline = bannerState.context.deadline
const xServices = useContext(XServiceContext)
const featureVisibility = useSelector(
xServices.entitlementsXService,
@@ -39,6 +45,7 @@ export const WorkspaceReadyPage = ({
const [buildInfoState] = useActor(xServices.buildInfoXService)
const {
workspace,
template,
refreshWorkspaceWarning,
builds,
getBuildsError,
@@ -81,20 +88,30 @@ export const WorkspaceReadyPage = ({
},
}}
scheduleProps={{
onDeadlineMinus: () => {
onDeadlineMinus: (hours: number) => {
bannerSend({
type: "DECREASE_DEADLINE",
hours: 1,
hours,
})
},
onDeadlinePlus: () => {
onDeadlinePlus: (hours: number) => {
bannerSend({
type: "INCREASE_DEADLINE",
hours: 1,
hours,
})
},
deadlineMinusEnabled: () => !bannerState.matches("atMinDeadline"),
deadlinePlusEnabled: () => !bannerState.matches("atMaxDeadline"),
maxDeadlineDecrease: deadline
? getMaxDeadlineChange(deadline, getMinDeadline())
: 0,
maxDeadlineIncrease:
deadline && template
? getMaxDeadlineChange(
getMaxDeadline(workspace, template),
deadline,
)
: 0,
}}
isUpdating={workspaceState.hasTag("updating")}
workspace={workspace}
+17
View File
@@ -9,6 +9,7 @@ import {
deadlineExtensionMin,
extractTimezone,
getMaxDeadline,
getMaxDeadlineChange,
getMinDeadline,
stripTimezone,
} from "./schedule"
@@ -124,3 +125,19 @@ describe("canReduceDeadline", () => {
expect(canReduceDeadline(dayjs().add(100, "years"))).toBeTruthy()
})
})
describe("getMaxDeadlineChange", () => {
it("should return the number of hours you can add before hitting the max deadline", () => {
const deadline = dayjs()
const maxDeadline = dayjs().add(1, "hour").add(40, "minutes")
// you can only add one hour even though the max is 1:40 away
expect(getMaxDeadlineChange(deadline, maxDeadline)).toEqual(1)
})
it("should return the number of hours you can subtract before hitting the min deadline", () => {
const deadline = dayjs().add(2, "hours").add(40, "minutes")
const minDeadline = dayjs()
// you can only subtract 2 hours even though the min is 2:40 less
expect(getMaxDeadlineChange(deadline, minDeadline)).toEqual(2)
})
})
+12
View File
@@ -167,3 +167,15 @@ export function canReduceDeadline(deadline: dayjs.Dayjs): boolean {
export const getDeadline = (workspace: Workspace): dayjs.Dayjs =>
dayjs(workspace.latest_build.deadline).utc()
/**
* Get number of hours you can add or subtract to the current deadline before hitting the max or min deadline.
* @param deadline
* @param workspace
* @param template
* @returns number, in hours
*/
export const getMaxDeadlineChange = (
deadline: dayjs.Dayjs,
extremeDeadline: dayjs.Dayjs,
): number => Math.abs(deadline.diff(extremeDeadline, "hours"))