mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +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
|
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.
|
// Time returns a humanized form of the minute and hour fields.
|
||||||
func (s Schedule) Time() string {
|
func (s Schedule) Time() string {
|
||||||
minute := strings.Fields(s.cronStr)[0]
|
minute := strings.Fields(s.cronStr)[0]
|
||||||
|
@ -64,6 +64,7 @@ var FeatureNames = []FeatureName{
|
|||||||
FeatureExternalProvisionerDaemons,
|
FeatureExternalProvisionerDaemons,
|
||||||
FeatureAppearance,
|
FeatureAppearance,
|
||||||
FeatureAdvancedTemplateScheduling,
|
FeatureAdvancedTemplateScheduling,
|
||||||
|
FeatureTemplateAutostopRequirement,
|
||||||
FeatureWorkspaceProxy,
|
FeatureWorkspaceProxy,
|
||||||
FeatureUserRoleManagement,
|
FeatureUserRoleManagement,
|
||||||
FeatureExternalTokenEncryption,
|
FeatureExternalTokenEncryption,
|
||||||
|
@ -472,7 +472,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
|||||||
codersdk.FeatureAdvancedTemplateScheduling: true,
|
codersdk.FeatureAdvancedTemplateScheduling: true,
|
||||||
// FeatureTemplateAutostopRequirement depends on
|
// FeatureTemplateAutostopRequirement depends on
|
||||||
// FeatureAdvancedTemplateScheduling.
|
// FeatureAdvancedTemplateScheduling.
|
||||||
codersdk.FeatureTemplateAutostopRequirement: api.DefaultQuietHoursSchedule != "",
|
codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "",
|
||||||
codersdk.FeatureWorkspaceProxy: true,
|
codersdk.FeatureWorkspaceProxy: true,
|
||||||
codersdk.FeatureUserRoleManagement: 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{
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
|
||||||
RawSchedule: opts.Schedule.String(),
|
RawSchedule: opts.Schedule.String(),
|
||||||
UserSet: opts.UserSet,
|
UserSet: opts.UserSet,
|
||||||
Time: opts.Schedule.Time(),
|
Time: opts.Schedule.TimeParsed().Format("15:40"),
|
||||||
Timezone: opts.Schedule.Location().String(),
|
Timezone: opts.Schedule.Location().String(),
|
||||||
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
|
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{
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
|
||||||
RawSchedule: opts.Schedule.String(),
|
RawSchedule: opts.Schedule.String(),
|
||||||
UserSet: opts.UserSet,
|
UserSet: opts.UserSet,
|
||||||
Time: opts.Schedule.Time(),
|
Time: opts.Schedule.TimeParsed().Format("15:40"),
|
||||||
Timezone: opts.Schedule.Location().String(),
|
Timezone: opts.Schedule.Location().String(),
|
||||||
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
|
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.Run("OK", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 0 * * *"
|
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 1 * * *"
|
||||||
defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule)
|
defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location()))
|
nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location()))
|
||||||
if time.Until(nextTime) < time.Hour {
|
if time.Until(nextTime) < time.Hour {
|
||||||
// Use a different default schedule instead, because we want to avoid
|
// Use a different default schedule instead, because we want to avoid
|
||||||
// the schedule "ticking over" during this test run.
|
// 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)
|
defaultScheduleParsed, err = cron.Daily(defaultQuietHoursSchedule)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
@ -55,7 +55,7 @@ func TestUserQuietHours(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule)
|
require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule)
|
||||||
require.False(t, sched1.UserSet)
|
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.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone)
|
||||||
require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second)
|
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.NoError(t, err)
|
||||||
require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule)
|
require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule)
|
||||||
require.True(t, sched2.UserSet)
|
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.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone)
|
||||||
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second)
|
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.NoError(t, err)
|
||||||
require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule)
|
require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule)
|
||||||
require.True(t, sched3.UserSet)
|
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.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone)
|
||||||
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second)
|
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second)
|
||||||
|
|
||||||
|
@ -33,6 +33,9 @@ const CliAuthenticationPage = lazy(
|
|||||||
const AccountPage = lazy(
|
const AccountPage = lazy(
|
||||||
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
|
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
|
||||||
);
|
);
|
||||||
|
const SchedulePage = lazy(
|
||||||
|
() => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"),
|
||||||
|
);
|
||||||
const SecurityPage = lazy(
|
const SecurityPage = lazy(
|
||||||
() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
|
() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
|
||||||
);
|
);
|
||||||
@ -292,6 +295,7 @@ export const AppRouter: FC = () => {
|
|||||||
|
|
||||||
<Route path="settings" element={<SettingsLayout />}>
|
<Route path="settings" element={<SettingsLayout />}>
|
||||||
<Route path="account" element={<AccountPage />} />
|
<Route path="account" element={<AccountPage />} />
|
||||||
|
<Route path="schedule" element={<SchedulePage />} />
|
||||||
<Route path="security" element={<SecurityPage />} />
|
<Route path="security" element={<SecurityPage />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
<Route path="tokens">
|
<Route path="tokens">
|
||||||
|
@ -665,6 +665,21 @@ export const updateProfile = async (
|
|||||||
return response.data;
|
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 (
|
export const activateUser = async (
|
||||||
userId: TypesGen.User["id"],
|
userId: TypesGen.User["id"],
|
||||||
): Promise<TypesGen.User> => {
|
): 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 { NavLink } from "react-router-dom";
|
||||||
import { combineClasses } from "utils/combineClasses";
|
import { combineClasses } from "utils/combineClasses";
|
||||||
import AccountIcon from "@mui/icons-material/Person";
|
import AccountIcon from "@mui/icons-material/Person";
|
||||||
|
import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined";
|
||||||
import SecurityIcon from "@mui/icons-material/LockOutlined";
|
import SecurityIcon from "@mui/icons-material/LockOutlined";
|
||||||
|
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||||
|
|
||||||
const SidebarNavItem: FC<
|
const SidebarNavItem: FC<
|
||||||
PropsWithChildren<{ href: string; icon: ReactNode }>
|
PropsWithChildren<{ href: string; icon: ReactNode }>
|
||||||
@ -41,6 +43,9 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
|
|||||||
|
|
||||||
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const { entitlements } = useDashboard();
|
||||||
|
const allowAutostopRequirement =
|
||||||
|
entitlements.features.template_autostop_requirement.enabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.sidebar}>
|
<nav className={styles.sidebar}>
|
||||||
@ -58,6 +63,14 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
>
|
>
|
||||||
Account
|
Account
|
||||||
</SidebarNavItem>
|
</SidebarNavItem>
|
||||||
|
{allowAutostopRequirement && (
|
||||||
|
<SidebarNavItem
|
||||||
|
href="schedule"
|
||||||
|
icon={<SidebarNavItemIcon icon={ScheduleIcon} />}
|
||||||
|
>
|
||||||
|
Schedule
|
||||||
|
</SidebarNavItem>
|
||||||
|
)}
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
href="security"
|
href="security"
|
||||||
icon={<SidebarNavItemIcon icon={SecurityIcon} />}
|
icon={<SidebarNavItemIcon icon={SecurityIcon} />}
|
||||||
|
@ -34,16 +34,15 @@ const CreateTemplatePage: FC = () => {
|
|||||||
const { starterTemplate, error, file, jobError, jobLogs, variables } =
|
const { starterTemplate, error, file, jobError, jobLogs, variables } =
|
||||||
state.context;
|
state.context;
|
||||||
const shouldDisplayForm = !state.hasTag("loading");
|
const shouldDisplayForm = !state.hasTag("loading");
|
||||||
const { entitlements, experiments } = useDashboard();
|
const { entitlements } = useDashboard();
|
||||||
const allowAdvancedScheduling =
|
const allowAdvancedScheduling =
|
||||||
entitlements.features["advanced_template_scheduling"].enabled;
|
entitlements.features["advanced_template_scheduling"].enabled;
|
||||||
// Requires the template RBAC feature, otherwise disabling everyone access
|
// Requires the template RBAC feature, otherwise disabling everyone access
|
||||||
// means no one can access.
|
// means no one can access.
|
||||||
const allowDisableEveryoneAccess =
|
const allowDisableEveryoneAccess =
|
||||||
entitlements.features["template_rbac"].enabled;
|
entitlements.features["template_rbac"].enabled;
|
||||||
const allowAutostopRequirement = experiments.includes(
|
const allowAutostopRequirement =
|
||||||
"template_autostop_requirement",
|
entitlements.features["template_autostop_requirement"].enabled;
|
||||||
);
|
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
|
@ -24,9 +24,8 @@ const TemplateSchedulePage: FC = () => {
|
|||||||
// This check can be removed when https://github.com/coder/coder/milestone/19
|
// This check can be removed when https://github.com/coder/coder/milestone/19
|
||||||
// is merged up
|
// is merged up
|
||||||
const allowWorkspaceActions = experiments.includes("workspace_actions");
|
const allowWorkspaceActions = experiments.includes("workspace_actions");
|
||||||
const allowAutostopRequirement = experiments.includes(
|
const allowAutostopRequirement =
|
||||||
"template_autostop_requirement",
|
entitlements.features["template_autostop_requirement"].enabled;
|
||||||
);
|
|
||||||
const { clearLocal } = useLocalStorage();
|
const { clearLocal } = useLocalStorage();
|
||||||
|
|
||||||
const {
|
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,
|
validationSchema,
|
||||||
WorkspaceScheduleFormValues,
|
WorkspaceScheduleFormValues,
|
||||||
} from "./WorkspaceScheduleForm";
|
} from "./WorkspaceScheduleForm";
|
||||||
import { zones } from "./zones";
|
import { timeZones } from "utils/timeZones";
|
||||||
|
|
||||||
const valid: WorkspaceScheduleFormValues = {
|
const valid: WorkspaceScheduleFormValues = {
|
||||||
autostartEnabled: true,
|
autostartEnabled: true,
|
||||||
@ -137,7 +137,7 @@ describe("validationSchema", () => {
|
|||||||
expect(validate).toThrowError(Language.errorTimezone);
|
expect(validate).toThrowError(Language.errorTimezone);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each<[string]>(zones.map((zone) => [zone]))(
|
it.each<[string]>(timeZones.map((zone) => [zone]))(
|
||||||
`validation passes for tz=%p`,
|
`validation passes for tz=%p`,
|
||||||
(zone) => {
|
(zone) => {
|
||||||
const values: WorkspaceScheduleFormValues = {
|
const values: WorkspaceScheduleFormValues = {
|
||||||
|
@ -29,7 +29,7 @@ import {
|
|||||||
import { ChangeEvent, FC } from "react";
|
import { ChangeEvent, FC } from "react";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import { getFormHelpers } from "utils/formUtils";
|
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
|
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
|
||||||
// sorted alphabetically.
|
// sorted alphabetically.
|
||||||
@ -43,7 +43,7 @@ export const Language = {
|
|||||||
errorNoDayOfWeek:
|
errorNoDayOfWeek:
|
||||||
"Must set at least one day of week if autostart is enabled.",
|
"Must set at least one day of week if autostart is enabled.",
|
||||||
errorNoTime: "Start time is required when 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.",
|
errorTimezone: "Invalid timezone.",
|
||||||
errorNoStop:
|
errorNoStop:
|
||||||
"Time until shutdown must be greater than zero when autostop is enabled.",
|
"Time until shutdown must be greater than zero when autostop is enabled.",
|
||||||
@ -312,7 +312,7 @@ export const WorkspaceScheduleForm: FC<
|
|||||||
select
|
select
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{zones.map((zone) => (
|
{timeZones.map((zone) => (
|
||||||
<MenuItem key={zone} value={zone}>
|
<MenuItem key={zone} value={zone}>
|
||||||
{zone}
|
{zone}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
@ -1,27 +1,20 @@
|
|||||||
import { renderWithWorkspaceSettingsLayout } from "testHelpers/renderHelpers";
|
import { renderWithWorkspaceSettingsLayout } from "testHelpers/renderHelpers";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
|
import { rest } from "msw";
|
||||||
|
import { server } from "testHelpers/server";
|
||||||
|
import { MockUser, MockWorkspace } from "testHelpers/entities";
|
||||||
import {
|
import {
|
||||||
formValuesToAutostartRequest,
|
formValuesToAutostartRequest,
|
||||||
formValuesToTTLRequest,
|
formValuesToTTLRequest,
|
||||||
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest";
|
} from "./formToRequest";
|
||||||
import {
|
import { scheduleToAutostart } from "./schedule";
|
||||||
Autostart,
|
import { ttlMsToAutostop } from "./ttl";
|
||||||
scheduleToAutostart,
|
|
||||||
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule";
|
|
||||||
import {
|
|
||||||
Autostop,
|
|
||||||
ttlMsToAutostop,
|
|
||||||
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl";
|
|
||||||
import * as TypesGen from "../../../api/typesGenerated";
|
|
||||||
import {
|
import {
|
||||||
WorkspaceScheduleFormValues,
|
WorkspaceScheduleFormValues,
|
||||||
Language as FormLanguage,
|
Language as FormLanguage,
|
||||||
} from "./WorkspaceScheduleForm";
|
} from "./WorkspaceScheduleForm";
|
||||||
import { WorkspaceSchedulePage } from "./WorkspaceSchedulePage";
|
import { WorkspaceSchedulePage } from "./WorkspaceSchedulePage";
|
||||||
import { server } from "testHelpers/server";
|
|
||||||
import { rest } from "msw";
|
|
||||||
import { MockUser, MockWorkspace } from "testHelpers/entities";
|
|
||||||
|
|
||||||
const validValues: WorkspaceScheduleFormValues = {
|
const validValues: WorkspaceScheduleFormValues = {
|
||||||
autostartEnabled: true,
|
autostartEnabled: true,
|
||||||
@ -40,9 +33,7 @@ const validValues: WorkspaceScheduleFormValues = {
|
|||||||
|
|
||||||
describe("WorkspaceSchedulePage", () => {
|
describe("WorkspaceSchedulePage", () => {
|
||||||
describe("formValuesToAutostartRequest", () => {
|
describe("formValuesToAutostartRequest", () => {
|
||||||
it.each<
|
it.each([
|
||||||
[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceAutostartRequest]
|
|
||||||
>([
|
|
||||||
[
|
[
|
||||||
// Empty case
|
// Empty case
|
||||||
{
|
{
|
||||||
@ -143,13 +134,16 @@ describe("WorkspaceSchedulePage", () => {
|
|||||||
schedule: "20 16 * * 1,3,5",
|
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);
|
expect(formValuesToAutostartRequest(values)).toEqual(request);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formValuesToTTLRequest", () => {
|
describe("formValuesToTTLRequest", () => {
|
||||||
it.each<[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceTTLRequest]>([
|
it.each([
|
||||||
[
|
[
|
||||||
// 0 case
|
// 0 case
|
||||||
{
|
{
|
||||||
@ -180,13 +174,13 @@ describe("WorkspaceSchedulePage", () => {
|
|||||||
ttl_ms: 28_800_000,
|
ttl_ms: 28_800_000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])(`formValuesToTTLRequest(%p) returns %p`, (values, request) => {
|
] as const)(`formValuesToTTLRequest(%p) returns %p`, (values, request) => {
|
||||||
expect(formValuesToTTLRequest(values)).toEqual(request);
|
expect(formValuesToTTLRequest(values)).toEqual(request);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scheduleToAutostart", () => {
|
describe("scheduleToAutostart", () => {
|
||||||
it.each<[string | undefined, Autostart]>([
|
it.each([
|
||||||
// Empty case
|
// Empty case
|
||||||
[
|
[
|
||||||
undefined,
|
undefined,
|
||||||
@ -237,20 +231,20 @@ describe("WorkspaceSchedulePage", () => {
|
|||||||
timezone: "Canada/Eastern",
|
timezone: "Canada/Eastern",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])(`scheduleToAutostart(%p) returns %p`, (schedule, autostart) => {
|
] as const)(`scheduleToAutostart(%p) returns %p`, (schedule, autostart) => {
|
||||||
expect(scheduleToAutostart(schedule)).toEqual(autostart);
|
expect(scheduleToAutostart(schedule)).toEqual(autostart);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ttlMsToAutostop", () => {
|
describe("ttlMsToAutostop", () => {
|
||||||
it.each<[number | undefined, Autostop]>([
|
it.each([
|
||||||
// empty case
|
// empty case
|
||||||
[undefined, { autostopEnabled: false, ttl: 0 }],
|
[undefined, { autostopEnabled: false, ttl: 0 }],
|
||||||
// zero
|
// zero
|
||||||
[0, { autostopEnabled: false, ttl: 0 }],
|
[0, { autostopEnabled: false, ttl: 0 }],
|
||||||
// basic case
|
// basic case
|
||||||
[28_800_000, { autostopEnabled: true, ttl: 8 }],
|
[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);
|
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,
|
getMaxDeadlineChange,
|
||||||
getMinDeadline,
|
getMinDeadline,
|
||||||
stripTimezone,
|
stripTimezone,
|
||||||
|
quietHoursDisplay,
|
||||||
} from "./schedule";
|
} from "./schedule";
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
@ -36,7 +37,6 @@ describe("util/schedule", () => {
|
|||||||
expect(extractTimezone(input)).toBe(expected);
|
expect(extractTimezone(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("maxDeadline", () => {
|
describe("maxDeadline", () => {
|
||||||
const workspace: Workspace = {
|
const workspace: Workspace = {
|
||||||
@ -55,7 +55,9 @@ describe("maxDeadline", () => {
|
|||||||
describe("minDeadline", () => {
|
describe("minDeadline", () => {
|
||||||
it("should never be less than 30 minutes", () => {
|
it("should never be less than 30 minutes", () => {
|
||||||
const delta = getMinDeadline().diff(now);
|
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);
|
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 cronstrue from "cronstrue";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
|
||||||
import duration from "dayjs/plugin/duration";
|
import duration from "dayjs/plugin/duration";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { Workspace } from "../api/typesGenerated";
|
import { Workspace } from "api/typesGenerated";
|
||||||
import { isWorkspaceOn } from "./workspace";
|
import { isWorkspaceOn } from "./workspace";
|
||||||
|
import cronParser from "cron-parser";
|
||||||
|
|
||||||
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
|
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
|
||||||
// sorted alphabetically.
|
// sorted alphabetically.
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(advancedFormat);
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fileoverview Client-side counterpart of the coderd/autostart/schedule Go
|
* @fileoverview Client-side counterpart of the coderd/autostart/schedule Go
|
||||||
* package. This package is a variation on crontab that uses minute, hour and
|
* 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)) {
|
if (isShuttingDown(workspace, deadline)) {
|
||||||
return Language.workspaceShuttingDownLabel;
|
return Language.workspaceShuttingDownLabel;
|
||||||
} else {
|
} 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) {
|
} else if (!ttl || ttl < 1) {
|
||||||
// If the workspace is not on, and the ttl is 0 or undefined, then the
|
// 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,
|
deadline: dayjs.Dayjs,
|
||||||
extremeDeadline: dayjs.Dayjs,
|
extremeDeadline: dayjs.Dayjs,
|
||||||
): number => Math.abs(deadline.diff(extremeDeadline, "hours"));
|
): 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