feat: add quiet hours settings page (#9676)

This commit is contained in:
Kayla Washburn
2023-09-15 11:14:33 -06:00
committed by GitHub
parent 72dff7f188
commit efe804498b
22 changed files with 613 additions and 86 deletions

View File

@ -173,6 +173,19 @@ func (s Schedule) Min() time.Duration {
return durMin
}
// TimeParsed returns the parsed time.Time of the minute and hour fields. If the
// time cannot be represented in a valid time.Time, a zero time is returned.
func (s Schedule) TimeParsed() time.Time {
minute := strings.Fields(s.cronStr)[0]
hour := strings.Fields(s.cronStr)[1]
maybeTime := fmt.Sprintf("%s:%s", hour, minute)
t, err := time.ParseInLocation("15:4", maybeTime, s.sched.Location)
if err != nil {
return time.Time{}
}
return t
}
// Time returns a humanized form of the minute and hour fields.
func (s Schedule) Time() string {
minute := strings.Fields(s.cronStr)[0]

View File

@ -64,6 +64,7 @@ var FeatureNames = []FeatureName{
FeatureExternalProvisionerDaemons,
FeatureAppearance,
FeatureAdvancedTemplateScheduling,
FeatureTemplateAutostopRequirement,
FeatureWorkspaceProxy,
FeatureUserRoleManagement,
FeatureExternalTokenEncryption,

View File

@ -472,7 +472,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureAdvancedTemplateScheduling: true,
// FeatureTemplateAutostopRequirement depends on
// FeatureAdvancedTemplateScheduling.
codersdk.FeatureTemplateAutostopRequirement: api.DefaultQuietHoursSchedule != "",
codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "",
codersdk.FeatureWorkspaceProxy: true,
codersdk.FeatureUserRoleManagement: true,
})

View File

@ -68,7 +68,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
Time: opts.Schedule.Time(),
Time: opts.Schedule.TimeParsed().Format("15:40"),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})
@ -114,7 +114,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
Time: opts.Schedule.Time(),
Time: opts.Schedule.TimeParsed().Format("15:40"),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})

View File

@ -21,14 +21,14 @@ func TestUserQuietHours(t *testing.T) {
t.Run("OK", func(t *testing.T) {
t.Parallel()
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 0 * * *"
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 1 * * *"
defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location()))
if time.Until(nextTime) < time.Hour {
// Use a different default schedule instead, because we want to avoid
// the schedule "ticking over" during this test run.
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 12 * * *"
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 13 * * *"
defaultScheduleParsed, err = cron.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
}
@ -55,7 +55,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule)
require.False(t, sched1.UserSet)
require.Equal(t, defaultScheduleParsed.Time(), sched1.Time)
require.Equal(t, defaultScheduleParsed.TimeParsed().Format("15:40"), sched1.Time)
require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone)
require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second)
@ -78,7 +78,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule)
require.True(t, sched2.UserSet)
require.Equal(t, customScheduleParsed.Time(), sched2.Time)
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched2.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second)
@ -87,7 +87,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule)
require.True(t, sched3.UserSet)
require.Equal(t, customScheduleParsed.Time(), sched3.Time)
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched3.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second)

View File

@ -33,6 +33,9 @@ const CliAuthenticationPage = lazy(
const AccountPage = lazy(
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
);
const SchedulePage = lazy(
() => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"),
);
const SecurityPage = lazy(
() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
);
@ -292,6 +295,7 @@ export const AppRouter: FC = () => {
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="schedule" element={<SchedulePage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tokens">

View File

@ -665,6 +665,21 @@ export const updateProfile = async (
return response.data;
};
export const getUserQuietHoursSchedule = async (
userId: TypesGen.User["id"],
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {
const response = await axios.get(`/api/v2/users/${userId}/quiet-hours`);
return response.data;
};
export const updateUserQuietHoursSchedule = async (
userId: TypesGen.User["id"],
data: TypesGen.UpdateUserQuietHoursScheduleRequest,
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {
const response = await axios.put(`/api/v2/users/${userId}/quiet-hours`, data);
return response.data;
};
export const activateUser = async (
userId: TypesGen.User["id"],
): Promise<TypesGen.User> => {

View File

@ -0,0 +1,34 @@
import * as API from "api/api";
import {
type UserQuietHoursScheduleResponse,
type UpdateUserQuietHoursScheduleRequest,
} from "api/typesGenerated";
import { type QueryClient, type QueryOptions } from "@tanstack/react-query";
export const userQuietHoursScheduleKey = (userId: string) => [
"settings",
userId,
"quietHours",
];
export const userQuietHoursSchedule = (
userId: string,
): QueryOptions<UserQuietHoursScheduleResponse> => {
return {
queryKey: userQuietHoursScheduleKey(userId),
queryFn: () => API.getUserQuietHoursSchedule(userId),
};
};
export const updateUserQuietHoursSchedule = (
userId: string,
queryClient: QueryClient,
) => {
return {
mutationFn: (request: UpdateUserQuietHoursScheduleRequest) =>
API.updateUserQuietHoursSchedule(userId, request),
onSuccess: async () => {
await queryClient.invalidateQueries(userQuietHoursScheduleKey(userId));
},
};
};

View File

@ -8,7 +8,9 @@ import { FC, ElementType, PropsWithChildren, ReactNode } from "react";
import { NavLink } from "react-router-dom";
import { combineClasses } from "utils/combineClasses";
import AccountIcon from "@mui/icons-material/Person";
import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined";
import SecurityIcon from "@mui/icons-material/LockOutlined";
import { useDashboard } from "components/Dashboard/DashboardProvider";
const SidebarNavItem: FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
@ -41,6 +43,9 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
const styles = useStyles();
const { entitlements } = useDashboard();
const allowAutostopRequirement =
entitlements.features.template_autostop_requirement.enabled;
return (
<nav className={styles.sidebar}>
@ -58,6 +63,14 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
>
Account
</SidebarNavItem>
{allowAutostopRequirement && (
<SidebarNavItem
href="schedule"
icon={<SidebarNavItemIcon icon={ScheduleIcon} />}
>
Schedule
</SidebarNavItem>
)}
<SidebarNavItem
href="security"
icon={<SidebarNavItemIcon icon={SecurityIcon} />}

View File

@ -34,16 +34,15 @@ const CreateTemplatePage: FC = () => {
const { starterTemplate, error, file, jobError, jobLogs, variables } =
state.context;
const shouldDisplayForm = !state.hasTag("loading");
const { entitlements, experiments } = useDashboard();
const { entitlements } = useDashboard();
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled;
// Requires the template RBAC feature, otherwise disabling everyone access
// means no one can access.
const allowDisableEveryoneAccess =
entitlements.features["template_rbac"].enabled;
const allowAutostopRequirement = experiments.includes(
"template_autostop_requirement",
);
const allowAutostopRequirement =
entitlements.features["template_autostop_requirement"].enabled;
const onCancel = () => {
navigate(-1);

View File

@ -24,9 +24,8 @@ const TemplateSchedulePage: FC = () => {
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions");
const allowAutostopRequirement = experiments.includes(
"template_autostop_requirement",
);
const allowAutostopRequirement =
entitlements.features["template_autostop_requirement"].enabled;
const { clearLocal } = useLocalStorage();
const {

View File

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ScheduleForm } from "./ScheduleForm";
import { mockApiError } from "testHelpers/entities";
import { action } from "@storybook/addon-actions";
const defaultArgs = {
submitting: false,
initialValues: {
raw_schedule: "CRON_TZ=Australia/Sydney 0 2 * * *",
user_set: false,
time: "02:00",
timezone: "Australia/Sydney",
next: "2023-09-05T02:00:00+10:00",
},
updateErr: undefined,
now: new Date("2023-09-04T15:00:00+10:00"),
onSubmit: action("onSubmit"),
};
const meta: Meta<typeof ScheduleForm> = {
title: "pages/UserSettingsPage/ScheduleForm",
component: ScheduleForm,
args: defaultArgs,
};
export default meta;
type Story = StoryObj<typeof ScheduleForm>;
export const ExampleDefault: Story = {};
export const ExampleUserSet: Story = {
args: {
initialValues: {
raw_schedule: "CRON_TZ=America/Chicago 0 2 * * *",
user_set: true,
time: "02:00",
timezone: "America/Chicago",
next: "2023-09-05T02:00:00-05:00",
},
now: new Date("2023-09-04T15:00:00-05:00"),
},
};
export const Submitting: Story = {
args: {
isLoading: true,
},
};
export const WithError: Story = {
args: {
submitError: mockApiError({
message: "Invalid schedule",
validations: [
{
field: "schedule",
detail: "Could not validate cron schedule.",
},
],
}),
},
};

View File

@ -0,0 +1,146 @@
import TextField from "@mui/material/TextField";
import { FormikContextType, useFormik } from "formik";
import { FC, useEffect, useState } from "react";
import * as Yup from "yup";
import { getFormHelpers } from "utils/formUtils";
import { LoadingButton } from "components/LoadingButton/LoadingButton";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Form, FormFields } from "components/Form/Form";
import {
UpdateUserQuietHoursScheduleRequest,
UserQuietHoursScheduleResponse,
} from "api/typesGenerated";
import MenuItem from "@mui/material/MenuItem";
import { Stack } from "components/Stack/Stack";
import { timeZones, getPreferredTimezone } from "utils/timeZones";
import { Alert } from "components/Alert/Alert";
import { timeToCron, quietHoursDisplay } from "utils/schedule";
export interface ScheduleFormValues {
time: string;
timezone: string;
}
const validationSchema = Yup.object({
time: Yup.string()
.ensure()
.test("is-time-string", "Time must be in HH:mm format.", (value) => {
if (!/^[0-9][0-9]:[0-9][0-9]$/.test(value)) {
return false;
}
const parts = value.split(":");
const HH = Number(parts[0]);
const mm = Number(parts[1]);
return HH >= 0 && HH <= 23 && mm >= 0 && mm <= 59;
}),
timezone: Yup.string().required(),
});
export interface ScheduleFormProps {
isLoading: boolean;
initialValues: UserQuietHoursScheduleResponse;
submitError: unknown;
onSubmit: (data: UpdateUserQuietHoursScheduleRequest) => void;
// now can be set to force the time used for "Next occurrence" in tests.
now?: Date;
}
export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
isLoading,
initialValues,
submitError,
onSubmit,
now,
}) => {
// Update every 15 seconds to update the "Next occurrence" field.
const [, setTime] = useState<number>(Date.now());
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 15000);
return () => {
clearInterval(interval);
};
}, []);
// If the user has a custom schedule, use that as the initial values.
// Otherwise, use the default time, with their local timezone.
const formInitialValues = { ...initialValues };
if (!initialValues.user_set) {
formInitialValues.timezone = getPreferredTimezone();
}
const form: FormikContextType<ScheduleFormValues> =
useFormik<ScheduleFormValues>({
initialValues: formInitialValues,
validationSchema,
onSubmit: (values) => {
onSubmit({
schedule: timeToCron(values.time, values.timezone),
});
},
});
const getFieldHelpers = getFormHelpers<ScheduleFormValues>(form, submitError);
return (
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(submitError) && <ErrorAlert error={submitError} />}
{!initialValues.user_set && (
<Alert severity="info">
You are currently using the default quiet hours schedule, which
starts every day at <code>{initialValues.time}</code> in{" "}
<code>{initialValues.timezone}</code>.
</Alert>
)}
<Stack direction="row">
<TextField
{...getFieldHelpers("time")}
disabled={isLoading}
label="Start time"
type="time"
fullWidth
/>
<TextField
{...getFieldHelpers("timezone")}
disabled={isLoading}
label="Timezone"
select
fullWidth
>
{timeZones.map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>
))}
</TextField>
</Stack>
<TextField
disabled
fullWidth
label="Cron schedule"
value={timeToCron(form.values.time, form.values.timezone)}
/>
<TextField
disabled
fullWidth
label="Next occurrence"
value={quietHoursDisplay(form.values.time, form.values.timezone, now)}
/>
<div>
<LoadingButton
loading={isLoading}
disabled={isLoading}
type="submit"
variant="contained"
>
{!isLoading && "Update schedule"}
</LoadingButton>
</div>
</FormFields>
</Form>
);
};

View File

@ -0,0 +1,128 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithAuth } from "testHelpers/renderHelpers";
import { SchedulePage } from "./SchedulePage";
import { server } from "testHelpers/server";
import { MockUser } from "testHelpers/entities";
import { rest } from "msw";
const fillForm = async ({
hour,
minute,
timezone,
}: {
hour: number;
minute: number;
timezone: string;
}) => {
const user = userEvent.setup();
await waitFor(() => screen.findByLabelText("Start time"));
const HH = hour.toString().padStart(2, "0");
const mm = minute.toString().padStart(2, "0");
fireEvent.change(screen.getByLabelText("Start time"), {
target: { value: `${HH}:${mm}` },
});
const timezoneDropdown = screen.getByLabelText("Timezone");
await user.click(timezoneDropdown);
const list = screen.getByRole("listbox");
const option = within(list).getByText(timezone);
await user.click(option);
};
const submitForm = async () => {
fireEvent.click(screen.getByText("Update schedule"));
};
const defaultQuietHoursResponse = {
raw_schedule: "CRON_TZ=America/Chicago 0 0 * * *",
user_set: false,
time: "00:00",
timezone: "America/Chicago",
next: "", // not consumed by the frontend
};
const cronTests = [
{
timezone: "Australia/Sydney",
hour: 0,
minute: 0,
},
] as const;
describe("SchedulePage", () => {
beforeEach(() => {
// appear logged out
server.use(
rest.get(`/api/v2/users/${MockUser.id}/quiet-hours`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(defaultQuietHoursResponse));
}),
);
});
describe("cron tests", () => {
it.each(cronTests)(
"case %# has the correct expected time",
async (test) => {
server.use(
rest.put(
`/api/v2/users/${MockUser.id}/quiet-hours`,
async (req, res, ctx) => {
const data = await req.json();
return res(
ctx.status(200),
ctx.json({
response: {},
raw_schedule: data.schedule,
user_set: true,
time: `${test.hour.toString().padStart(2, "0")}:${test.minute
.toString()
.padStart(2, "0")}`,
timezone: test.timezone,
next: "", // This value isn't used in the UI, the UI generates it.
}),
);
},
),
);
const expectedCronSchedule = `CRON_TZ=${test.timezone} ${test.minute} ${test.hour} * * *`;
renderWithAuth(<SchedulePage />);
await fillForm(test);
const cron = screen.getByLabelText("Cron schedule");
expect(cron.getAttribute("value")).toEqual(expectedCronSchedule);
await submitForm();
const successMessage = await screen.findByText(
"Schedule updated successfully",
);
expect(successMessage).toBeDefined();
},
);
});
describe("when it is an unknown error", () => {
it("shows a generic error message", async () => {
server.use(
rest.put(
`/api/v2/users/${MockUser.id}/quiet-hours`,
(req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({
message: "oh no!",
}),
);
},
),
);
renderWithAuth(<SchedulePage />);
await fillForm(cronTests[0]);
await submitForm();
const errorMessage = await screen.findByText("oh no!");
expect(errorMessage).toBeDefined();
});
});
});

View File

@ -0,0 +1,61 @@
import { FC } from "react";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Section } from "components/SettingsLayout/Section";
import { ScheduleForm } from "./ScheduleForm";
import { useMe } from "hooks/useMe";
import { Loader } from "components/Loader/Loader";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
updateUserQuietHoursSchedule,
userQuietHoursSchedule,
} from "api/queries/settings";
import { displaySuccess } from "components/GlobalSnackbar/utils";
export const SchedulePage: FC = () => {
const me = useMe();
const queryClient = useQueryClient();
const {
data: quietHoursSchedule,
error,
isLoading,
isError,
} = useQuery(userQuietHoursSchedule(me.id));
const {
mutate: onSubmit,
error: submitError,
isLoading: mutationLoading,
} = useMutation(updateUserQuietHoursSchedule(me.id, queryClient));
if (isLoading) {
return <Loader />;
}
if (isError) {
return <ErrorAlert error={error} />;
}
return (
<Section
title="Quiet hours"
layout="fluid"
description="Workspaces may be automatically updated during your quiet hours, as configured by your administrators."
>
<ScheduleForm
isLoading={mutationLoading}
initialValues={quietHoursSchedule}
submitError={submitError}
onSubmit={(values) => {
onSubmit(values, {
onSuccess: () => {
displaySuccess("Schedule updated successfully");
},
});
}}
/>
</Section>
);
};
export default SchedulePage;

View File

@ -4,7 +4,7 @@ import {
validationSchema,
WorkspaceScheduleFormValues,
} from "./WorkspaceScheduleForm";
import { zones } from "./zones";
import { timeZones } from "utils/timeZones";
const valid: WorkspaceScheduleFormValues = {
autostartEnabled: true,
@ -137,7 +137,7 @@ describe("validationSchema", () => {
expect(validate).toThrowError(Language.errorTimezone);
});
it.each<[string]>(zones.map((zone) => [zone]))(
it.each<[string]>(timeZones.map((zone) => [zone]))(
`validation passes for tz=%p`,
(zone) => {
const values: WorkspaceScheduleFormValues = {

View File

@ -29,7 +29,7 @@ import {
import { ChangeEvent, FC } from "react";
import * as Yup from "yup";
import { getFormHelpers } from "utils/formUtils";
import { zones } from "./zones";
import { timeZones } from "utils/timeZones";
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
@ -43,7 +43,7 @@ export const Language = {
errorNoDayOfWeek:
"Must set at least one day of week if autostart is enabled.",
errorNoTime: "Start time is required when autostart is enabled.",
errorTime: "Time must be in HH:mm format (24 hours).",
errorTime: "Time must be in HH:mm format.",
errorTimezone: "Invalid timezone.",
errorNoStop:
"Time until shutdown must be greater than zero when autostop is enabled.",
@ -312,7 +312,7 @@ export const WorkspaceScheduleForm: FC<
select
fullWidth
>
{zones.map((zone) => (
{timeZones.map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>

View File

@ -1,27 +1,20 @@
import { renderWithWorkspaceSettingsLayout } from "testHelpers/renderHelpers";
import userEvent from "@testing-library/user-event";
import { screen } from "@testing-library/react";
import { rest } from "msw";
import { server } from "testHelpers/server";
import { MockUser, MockWorkspace } from "testHelpers/entities";
import {
formValuesToAutostartRequest,
formValuesToTTLRequest,
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest";
import {
Autostart,
scheduleToAutostart,
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule";
import {
Autostop,
ttlMsToAutostop,
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl";
import * as TypesGen from "../../../api/typesGenerated";
} from "./formToRequest";
import { scheduleToAutostart } from "./schedule";
import { ttlMsToAutostop } from "./ttl";
import {
WorkspaceScheduleFormValues,
Language as FormLanguage,
} from "./WorkspaceScheduleForm";
import { WorkspaceSchedulePage } from "./WorkspaceSchedulePage";
import { server } from "testHelpers/server";
import { rest } from "msw";
import { MockUser, MockWorkspace } from "testHelpers/entities";
const validValues: WorkspaceScheduleFormValues = {
autostartEnabled: true,
@ -40,9 +33,7 @@ const validValues: WorkspaceScheduleFormValues = {
describe("WorkspaceSchedulePage", () => {
describe("formValuesToAutostartRequest", () => {
it.each<
[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceAutostartRequest]
>([
it.each([
[
// Empty case
{
@ -143,13 +134,16 @@ describe("WorkspaceSchedulePage", () => {
schedule: "20 16 * * 1,3,5",
},
],
])(`formValuesToAutostartRequest(%p) return %p`, (values, request) => {
] as const)(
`formValuesToAutostartRequest(%p) return %p`,
(values, request) => {
expect(formValuesToAutostartRequest(values)).toEqual(request);
});
},
);
});
describe("formValuesToTTLRequest", () => {
it.each<[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceTTLRequest]>([
it.each([
[
// 0 case
{
@ -180,13 +174,13 @@ describe("WorkspaceSchedulePage", () => {
ttl_ms: 28_800_000,
},
],
])(`formValuesToTTLRequest(%p) returns %p`, (values, request) => {
] as const)(`formValuesToTTLRequest(%p) returns %p`, (values, request) => {
expect(formValuesToTTLRequest(values)).toEqual(request);
});
});
describe("scheduleToAutostart", () => {
it.each<[string | undefined, Autostart]>([
it.each([
// Empty case
[
undefined,
@ -237,20 +231,20 @@ describe("WorkspaceSchedulePage", () => {
timezone: "Canada/Eastern",
},
],
])(`scheduleToAutostart(%p) returns %p`, (schedule, autostart) => {
] as const)(`scheduleToAutostart(%p) returns %p`, (schedule, autostart) => {
expect(scheduleToAutostart(schedule)).toEqual(autostart);
});
});
describe("ttlMsToAutostop", () => {
it.each<[number | undefined, Autostop]>([
it.each([
// empty case
[undefined, { autostopEnabled: false, ttl: 0 }],
// zero
[0, { autostopEnabled: false, ttl: 0 }],
// basic case
[28_800_000, { autostopEnabled: true, ttl: 8 }],
])(`ttlMsToAutostop(%p) returns %p`, (ttlMs, autostop) => {
] as const)(`ttlMsToAutostop(%p) returns %p`, (ttlMs, autostop) => {
expect(ttlMsToAutostop(ttlMs)).toEqual(autostop);
});
});

View File

@ -1,3 +0,0 @@
import tzData from "tzdata";
export const zones: string[] = Object.keys(tzData.zones).sort();

View File

@ -10,6 +10,7 @@ import {
getMaxDeadlineChange,
getMinDeadline,
stripTimezone,
quietHoursDisplay,
} from "./schedule";
dayjs.extend(duration);
@ -36,7 +37,6 @@ describe("util/schedule", () => {
expect(extractTimezone(input)).toBe(expected);
});
});
});
describe("maxDeadline", () => {
const workspace: Workspace = {
@ -55,7 +55,9 @@ describe("maxDeadline", () => {
describe("minDeadline", () => {
it("should never be less than 30 minutes", () => {
const delta = getMinDeadline().diff(now);
expect(delta).toBeGreaterThanOrEqual(deadlineExtensionMin.asMilliseconds());
expect(delta).toBeGreaterThanOrEqual(
deadlineExtensionMin.asMilliseconds(),
);
});
});
@ -74,3 +76,14 @@ describe("getMaxDeadlineChange", () => {
expect(getMaxDeadlineChange(deadline, minDeadline)).toEqual(2);
});
});
describe("quietHoursDisplay", () => {
const quietHoursStart = quietHoursDisplay(
"00:00",
"Australia/Sydney",
new Date("2023-09-06T15:00:00.000+10:00"),
);
expect(quietHoursStart).toBe("12:00AM tomorrow (in 9 hours)");
});
});

View File

@ -1,21 +1,19 @@
import cronstrue from "cronstrue";
import dayjs, { 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 { Workspace } from "api/typesGenerated";
import { isWorkspaceOn } from "./workspace";
import cronParser from "cron-parser";
// 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
@ -104,7 +102,7 @@ export const autostopDisplay = (workspace: Workspace): string => {
if (isShuttingDown(workspace, deadline)) {
return Language.workspaceShuttingDownLabel;
} else {
return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A");
return deadline.tz(dayjs.tz.guess()).format("MMMM 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
@ -157,3 +155,47 @@ export const getMaxDeadlineChange = (
deadline: dayjs.Dayjs,
extremeDeadline: dayjs.Dayjs,
): number => Math.abs(deadline.diff(extremeDeadline, "hours"));
export const timeToCron = (time: string, tz?: string) => {
const [HH, mm] = time.split(":");
let prefix = "";
if (tz) {
prefix = `CRON_TZ=${tz} `;
}
return `${prefix}${Number(mm)} ${Number(HH)} * * *`;
};
export const quietHoursDisplay = (
time: string,
tz: string,
now: Date | undefined,
): string => {
// The cron-parser package doesn't accept a timezone in the cron string, but
// accepts it as an option.
const cron = timeToCron(time);
const parsed = cronParser.parseExpression(cron, {
currentDate: now,
iterator: false,
utc: false,
tz,
});
const today = dayjs(now).tz(tz);
const day = dayjs(parsed.next().toDate()).tz(tz);
let display = day.format("h:mmA");
if (day.isSame(today, "day")) {
display += " today";
} else if (day.isSame(today.add(1, "day"), "day")) {
display += " tomorrow";
} else {
// This case will rarely ever be hit, as we're dealing with only times and
// not dates, but it can be hit due to mismatched browser timezone to cron
// timezone or due to daylight savings changes.
display += ` on ${day.format("dddd, MMMM D")}`;
}
display += ` (${day.from(today)})`;
return display;
};

View File

@ -0,0 +1,6 @@
import tzData from "tzdata";
export const timeZones = Object.keys(tzData.zones).sort();
export const getPreferredTimezone = () =>
Intl.DateTimeFormat().resolvedOptions().timeZone;