mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: add quiet hours settings page (#9676)
This commit is contained in:
@ -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]
|
||||
|
@ -64,6 +64,7 @@ var FeatureNames = []FeatureName{
|
||||
FeatureExternalProvisionerDaemons,
|
||||
FeatureAppearance,
|
||||
FeatureAdvancedTemplateScheduling,
|
||||
FeatureTemplateAutostopRequirement,
|
||||
FeatureWorkspaceProxy,
|
||||
FeatureUserRoleManagement,
|
||||
FeatureExternalTokenEncryption,
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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())),
|
||||
})
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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> => {
|
||||
|
34
site/src/api/queries/settings.ts
Normal file
34
site/src/api/queries/settings.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
};
|
@ -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} />}
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
146
site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx
Normal file
146
site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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 = {
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -1,3 +0,0 @@
|
||||
import tzData from "tzdata";
|
||||
|
||||
export const zones: string[] = Object.keys(tzData.zones).sort();
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
};
|
||||
|
6
site/src/utils/timeZones.ts
Normal file
6
site/src/utils/timeZones.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import tzData from "tzdata";
|
||||
|
||||
export const timeZones = Object.keys(tzData.zones).sort();
|
||||
|
||||
export const getPreferredTimezone = () =>
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
Reference in New Issue
Block a user