Skip to content

Commit efe8044

Browse files
authored
feat: add quiet hours settings page (#9676)
1 parent 72dff7f commit efe8044

File tree

22 files changed

+609
-82
lines changed

22 files changed

+609
-82
lines changed

coderd/schedule/cron/cron.go

+13
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,19 @@ func (s Schedule) Min() time.Duration {
173173
return durMin
174174
}
175175

176+
// TimeParsed returns the parsed time.Time of the minute and hour fields. If the
177+
// time cannot be represented in a valid time.Time, a zero time is returned.
178+
func (s Schedule) TimeParsed() time.Time {
179+
minute := strings.Fields(s.cronStr)[0]
180+
hour := strings.Fields(s.cronStr)[1]
181+
maybeTime := fmt.Sprintf("%s:%s", hour, minute)
182+
t, err := time.ParseInLocation("15:4", maybeTime, s.sched.Location)
183+
if err != nil {
184+
return time.Time{}
185+
}
186+
return t
187+
}
188+
176189
// Time returns a humanized form of the minute and hour fields.
177190
func (s Schedule) Time() string {
178191
minute := strings.Fields(s.cronStr)[0]

codersdk/deployment.go

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ var FeatureNames = []FeatureName{
6464
FeatureExternalProvisionerDaemons,
6565
FeatureAppearance,
6666
FeatureAdvancedTemplateScheduling,
67+
FeatureTemplateAutostopRequirement,
6768
FeatureWorkspaceProxy,
6869
FeatureUserRoleManagement,
6970
FeatureExternalTokenEncryption,

enterprise/coderd/coderd.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
472472
codersdk.FeatureAdvancedTemplateScheduling: true,
473473
// FeatureTemplateAutostopRequirement depends on
474474
// FeatureAdvancedTemplateScheduling.
475-
codersdk.FeatureTemplateAutostopRequirement: api.DefaultQuietHoursSchedule != "",
475+
codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "",
476476
codersdk.FeatureWorkspaceProxy: true,
477477
codersdk.FeatureUserRoleManagement: true,
478478
})

enterprise/coderd/users.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request)
6868
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
6969
RawSchedule: opts.Schedule.String(),
7070
UserSet: opts.UserSet,
71-
Time: opts.Schedule.Time(),
71+
Time: opts.Schedule.TimeParsed().Format("15:40"),
7272
Timezone: opts.Schedule.Location().String(),
7373
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
7474
})
@@ -114,7 +114,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques
114114
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
115115
RawSchedule: opts.Schedule.String(),
116116
UserSet: opts.UserSet,
117-
Time: opts.Schedule.Time(),
117+
Time: opts.Schedule.TimeParsed().Format("15:40"),
118118
Timezone: opts.Schedule.Location().String(),
119119
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
120120
})

enterprise/coderd/users_test.go

+5-5
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ func TestUserQuietHours(t *testing.T) {
2121
t.Run("OK", func(t *testing.T) {
2222
t.Parallel()
2323

24-
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 0 * * *"
24+
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 1 * * *"
2525
defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule)
2626
require.NoError(t, err)
2727
nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location()))
2828
if time.Until(nextTime) < time.Hour {
2929
// Use a different default schedule instead, because we want to avoid
3030
// the schedule "ticking over" during this test run.
31-
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 12 * * *"
31+
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 13 * * *"
3232
defaultScheduleParsed, err = cron.Daily(defaultQuietHoursSchedule)
3333
require.NoError(t, err)
3434
}
@@ -55,7 +55,7 @@ func TestUserQuietHours(t *testing.T) {
5555
require.NoError(t, err)
5656
require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule)
5757
require.False(t, sched1.UserSet)
58-
require.Equal(t, defaultScheduleParsed.Time(), sched1.Time)
58+
require.Equal(t, defaultScheduleParsed.TimeParsed().Format("15:40"), sched1.Time)
5959
require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone)
6060
require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second)
6161

@@ -78,7 +78,7 @@ func TestUserQuietHours(t *testing.T) {
7878
require.NoError(t, err)
7979
require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule)
8080
require.True(t, sched2.UserSet)
81-
require.Equal(t, customScheduleParsed.Time(), sched2.Time)
81+
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched2.Time)
8282
require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone)
8383
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second)
8484

@@ -87,7 +87,7 @@ func TestUserQuietHours(t *testing.T) {
8787
require.NoError(t, err)
8888
require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule)
8989
require.True(t, sched3.UserSet)
90-
require.Equal(t, customScheduleParsed.Time(), sched3.Time)
90+
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched3.Time)
9191
require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone)
9292
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second)
9393

site/src/AppRouter.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const CliAuthenticationPage = lazy(
3333
const AccountPage = lazy(
3434
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
3535
);
36+
const SchedulePage = lazy(
37+
() => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"),
38+
);
3639
const SecurityPage = lazy(
3740
() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
3841
);
@@ -292,6 +295,7 @@ export const AppRouter: FC = () => {
292295

293296
<Route path="settings" element={<SettingsLayout />}>
294297
<Route path="account" element={<AccountPage />} />
298+
<Route path="schedule" element={<SchedulePage />} />
295299
<Route path="security" element={<SecurityPage />} />
296300
<Route path="ssh-keys" element={<SSHKeysPage />} />
297301
<Route path="tokens">

site/src/api/api.ts

+15
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,21 @@ export const updateProfile = async (
665665
return response.data;
666666
};
667667

668+
export const getUserQuietHoursSchedule = async (
669+
userId: TypesGen.User["id"],
670+
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {
671+
const response = await axios.get(`/api/v2/users/${userId}/quiet-hours`);
672+
return response.data;
673+
};
674+
675+
export const updateUserQuietHoursSchedule = async (
676+
userId: TypesGen.User["id"],
677+
data: TypesGen.UpdateUserQuietHoursScheduleRequest,
678+
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {
679+
const response = await axios.put(`/api/v2/users/${userId}/quiet-hours`, data);
680+
return response.data;
681+
};
682+
668683
export const activateUser = async (
669684
userId: TypesGen.User["id"],
670685
): Promise<TypesGen.User> => {

site/src/api/queries/settings.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as API from "api/api";
2+
import {
3+
type UserQuietHoursScheduleResponse,
4+
type UpdateUserQuietHoursScheduleRequest,
5+
} from "api/typesGenerated";
6+
import { type QueryClient, type QueryOptions } from "@tanstack/react-query";
7+
8+
export const userQuietHoursScheduleKey = (userId: string) => [
9+
"settings",
10+
userId,
11+
"quietHours",
12+
];
13+
14+
export const userQuietHoursSchedule = (
15+
userId: string,
16+
): QueryOptions<UserQuietHoursScheduleResponse> => {
17+
return {
18+
queryKey: userQuietHoursScheduleKey(userId),
19+
queryFn: () => API.getUserQuietHoursSchedule(userId),
20+
};
21+
};
22+
23+
export const updateUserQuietHoursSchedule = (
24+
userId: string,
25+
queryClient: QueryClient,
26+
) => {
27+
return {
28+
mutationFn: (request: UpdateUserQuietHoursScheduleRequest) =>
29+
API.updateUserQuietHoursSchedule(userId, request),
30+
onSuccess: async () => {
31+
await queryClient.invalidateQueries(userQuietHoursScheduleKey(userId));
32+
},
33+
};
34+
};

site/src/components/SettingsLayout/Sidebar.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { FC, ElementType, PropsWithChildren, ReactNode } from "react";
88
import { NavLink } from "react-router-dom";
99
import { combineClasses } from "utils/combineClasses";
1010
import AccountIcon from "@mui/icons-material/Person";
11+
import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined";
1112
import SecurityIcon from "@mui/icons-material/LockOutlined";
13+
import { useDashboard } from "components/Dashboard/DashboardProvider";
1214

1315
const SidebarNavItem: FC<
1416
PropsWithChildren<{ href: string; icon: ReactNode }>
@@ -41,6 +43,9 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
4143

4244
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
4345
const styles = useStyles();
46+
const { entitlements } = useDashboard();
47+
const allowAutostopRequirement =
48+
entitlements.features.template_autostop_requirement.enabled;
4449

4550
return (
4651
<nav className={styles.sidebar}>
@@ -58,6 +63,14 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
5863
>
5964
Account
6065
</SidebarNavItem>
66+
{allowAutostopRequirement && (
67+
<SidebarNavItem
68+
href="schedule"
69+
icon={<SidebarNavItemIcon icon={ScheduleIcon} />}
70+
>
71+
Schedule
72+
</SidebarNavItem>
73+
)}
6174
<SidebarNavItem
6275
href="security"
6376
icon={<SidebarNavItemIcon icon={SecurityIcon} />}

site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,15 @@ const CreateTemplatePage: FC = () => {
3434
const { starterTemplate, error, file, jobError, jobLogs, variables } =
3535
state.context;
3636
const shouldDisplayForm = !state.hasTag("loading");
37-
const { entitlements, experiments } = useDashboard();
37+
const { entitlements } = useDashboard();
3838
const allowAdvancedScheduling =
3939
entitlements.features["advanced_template_scheduling"].enabled;
4040
// Requires the template RBAC feature, otherwise disabling everyone access
4141
// means no one can access.
4242
const allowDisableEveryoneAccess =
4343
entitlements.features["template_rbac"].enabled;
44-
const allowAutostopRequirement = experiments.includes(
45-
"template_autostop_requirement",
46-
);
44+
const allowAutostopRequirement =
45+
entitlements.features["template_autostop_requirement"].enabled;
4746

4847
const onCancel = () => {
4948
navigate(-1);

site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx

+2-3
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ const TemplateSchedulePage: FC = () => {
2424
// This check can be removed when https://github.com/coder/coder/milestone/19
2525
// is merged up
2626
const allowWorkspaceActions = experiments.includes("workspace_actions");
27-
const allowAutostopRequirement = experiments.includes(
28-
"template_autostop_requirement",
29-
);
27+
const allowAutostopRequirement =
28+
entitlements.features["template_autostop_requirement"].enabled;
3029
const { clearLocal } = useLocalStorage();
3130

3231
const {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { ScheduleForm } from "./ScheduleForm";
3+
import { mockApiError } from "testHelpers/entities";
4+
import { action } from "@storybook/addon-actions";
5+
6+
const defaultArgs = {
7+
submitting: false,
8+
initialValues: {
9+
raw_schedule: "CRON_TZ=Australia/Sydney 0 2 * * *",
10+
user_set: false,
11+
time: "02:00",
12+
timezone: "Australia/Sydney",
13+
next: "2023-09-05T02:00:00+10:00",
14+
},
15+
updateErr: undefined,
16+
now: new Date("2023-09-04T15:00:00+10:00"),
17+
onSubmit: action("onSubmit"),
18+
};
19+
20+
const meta: Meta<typeof ScheduleForm> = {
21+
title: "pages/UserSettingsPage/ScheduleForm",
22+
component: ScheduleForm,
23+
args: defaultArgs,
24+
};
25+
export default meta;
26+
27+
type Story = StoryObj<typeof ScheduleForm>;
28+
29+
export const ExampleDefault: Story = {};
30+
31+
export const ExampleUserSet: Story = {
32+
args: {
33+
initialValues: {
34+
raw_schedule: "CRON_TZ=America/Chicago 0 2 * * *",
35+
user_set: true,
36+
time: "02:00",
37+
timezone: "America/Chicago",
38+
next: "2023-09-05T02:00:00-05:00",
39+
},
40+
now: new Date("2023-09-04T15:00:00-05:00"),
41+
},
42+
};
43+
44+
export const Submitting: Story = {
45+
args: {
46+
isLoading: true,
47+
},
48+
};
49+
50+
export const WithError: Story = {
51+
args: {
52+
submitError: mockApiError({
53+
message: "Invalid schedule",
54+
validations: [
55+
{
56+
field: "schedule",
57+
detail: "Could not validate cron schedule.",
58+
},
59+
],
60+
}),
61+
},
62+
};

0 commit comments

Comments
 (0)