Skip to content

feat: add quiet hours settings page #9676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
YAY
  • Loading branch information
aslilac committed Sep 14, 2023
commit 1cb15db5da296f726bce2dc79ff48320a49579cf
9 changes: 3 additions & 6 deletions site/src/api/queries/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,10 @@ export const userQuietHoursSchedule = (
export const updateUserQuietHoursSchedule = (
userId: string,
queryClient: QueryClient,
): MutationOptions<
UserQuietHoursScheduleResponse,
unknown,
UpdateUserQuietHoursScheduleRequest
> => {
) => {
return {
mutationFn: (request) => API.updateUserQuietHoursSchedule(userId, request),
mutationFn: (request: UpdateUserQuietHoursScheduleRequest) =>
API.updateUserQuietHoursSchedule(userId, request),
onSuccess: async () => {
await queryClient.invalidateQueries(userQuietHoursScheduleKey(userId));
},
Expand Down
4 changes: 0 additions & 4 deletions site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ const validationSchema = Yup.object({
export interface ScheduleFormProps {
isLoading: boolean;
initialValues: UserQuietHoursScheduleResponse;
refetch: () => Promise<void>;
mutationError: unknown;
onSubmit: (data: UpdateUserQuietHoursScheduleRequest) => void;
// now can be set to force the time used for "Next occurrence" in tests.
Expand All @@ -52,7 +51,6 @@ export interface ScheduleFormProps {
export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
isLoading,
initialValues,
refetch,
mutationError,
onSubmit,
now,
Expand Down Expand Up @@ -87,8 +85,6 @@ export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
onSubmit({
schedule: timeToCron(values.startTime, values.timezone),
});

await refetch();
},
});
const getFieldHelpers = getFormHelpers<ScheduleFormValues>(
Expand Down
167 changes: 84 additions & 83 deletions site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,37 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import * as API from "../../../api/api";
import { renderWithAuth } from "../../../testHelpers/renderHelpers";
import userEvent from "@testing-library/user-event";
import { renderWithAuth } from "testHelpers/renderHelpers";
import { SchedulePage } from "./SchedulePage";

const renderPage = () => {
return renderWithAuth(<SchedulePage />);
};
import { server } from "testHelpers/server";
import { MockUser } from "testHelpers/entities";
import { rest } from "msw";

const fillForm = async ({
hours,
minutes,
hour,
minute,
timezone,
}: {
hours: number;
minutes: number;
hour: number;
minute: number;
timezone: string;
}) => {
await waitFor(() => screen.findByLabelText("Hours"));
await waitFor(() => screen.findByLabelText("Minutes"));
fireEvent.change(screen.getByLabelText("Hours"), {
target: { value: hours },
});
fireEvent.change(screen.getByLabelText("Minutes"), {
target: { value: minutes },
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}` },
});

await waitFor(() => screen.findByLabelText("Timezone"));
fireEvent.click(screen.getByLabelText("Timezone"));
// TODO: fix the options targeting
const optionsList = screen.getByRole("listbox");
const option = within(optionsList).getByText(timezone);
fireEvent.click(option);
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 readCronExpression = () => {
return screen.getByLabelText("Cron schedule").getAttribute("value");
};

const readNextOccurrence = () => {
return screen.getByLabelText("Next occurrence").getAttribute("value");
return;
};

const submitForm = async () => {
Expand All @@ -56,75 +49,83 @@ const defaultQuietHoursResponse = {
const cronTests = [
{
timezone: "Australia/Sydney",
hours: 0,
minutes: 0,
currentTime: new Date("2023-09-06T15:00:00.000+10:00Z"),
expectedNext: "12:00AM tomorrow (in 9 hours)",
hour: 0,
minute: 0,
},
];
] as const;

describe("SchedulePage", () => {
describe("cron tests", () => {
for (let i = 0; i < cronTests.length; i++) {
const test = cronTests[i];
describe(`case ${i}`, () => {
it("has the correct expected time", async () => {
jest
.spyOn(API, "getUserQuietHoursSchedule")
.mockImplementationOnce(() =>
Promise.resolve(defaultQuietHoursResponse),
);
jest
.spyOn(API, "updateUserQuietHoursSchedule")
.mockImplementationOnce((userId, data) => {
return Promise.resolve({
raw_schedule: data.schedule,
user_set: true,
time: `${test.hours.toString().padStart(2, "0")}:${test.minutes
.toString()
.padStart(2, "0")}`,
timezone: test.timezone,
next: "", // This value isn't used in the UI, the UI generates it.
});
});
const { user } = renderPage();
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));
}),
);
});

await fillForm(test);
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.minutes} ${test.hours} * * *`;
expect(readCronExpression()).toEqual(expectedCronSchedule);
expect(readNextOccurrence()).toEqual(test.expectedNext);
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();
expect(API.updateUserQuietHoursSchedule).toBeCalledTimes(1);
expect(API.updateUserQuietHoursSchedule).toBeCalledWith(user.id, {
schedule: 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 () => {
jest
.spyOn(API, "getUserQuietHoursSchedule")
.mockImplementationOnce(() =>
Promise.resolve(defaultQuietHoursResponse),
);
jest.spyOn(API, "updateUserQuietHoursSchedule").mockRejectedValueOnce({
data: "unknown error",
});
server.use(
rest.put(
`/api/v2/users/${MockUser.id}/quiet-hours`,
(req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({
message: "oh no!",
}),
);
},
),
);

renderPage();
renderWithAuth(<SchedulePage />);
await fillForm(cronTests[0]);
await submitForm();

const errorMessage = await screen.findByText("Something went wrong");
const errorMessage = await screen.findByText("oh no!");
expect(errorMessage).toBeDefined();
});
});
Expand Down
14 changes: 9 additions & 5 deletions site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
updateUserQuietHoursSchedule,
userQuietHoursSchedule,
userQuietHoursScheduleKey,
} from "api/queries/settings";
import { displaySuccess } from "components/GlobalSnackbar/utils";

export const SchedulePage: FC = () => {
const me = useMe();
Expand All @@ -22,11 +22,18 @@ export const SchedulePage: FC = () => {
isError,
} = useQuery(userQuietHoursSchedule(me.id));

const updateSchedule = updateUserQuietHoursSchedule(me.id, queryClient);
const {
mutate: onSubmit,
error: mutationError,
isLoading: mutationLoading,
} = useMutation(updateUserQuietHoursSchedule(me.id, queryClient));
} = useMutation({
...updateSchedule,
onSuccess: async () => {
await updateSchedule.onSuccess();
displaySuccess("Schedule updated successfully");
},
});

if (isLoading) {
return <Loader />;
Expand All @@ -45,9 +52,6 @@ export const SchedulePage: FC = () => {
<ScheduleForm
isLoading={mutationLoading}
initialValues={quietHoursSchedule}
refetch={async () => {
await queryClient.invalidateQueries(userQuietHoursScheduleKey(me.id));
}}
mutationError={mutationError}
onSubmit={onSubmit}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,8 @@ 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";
import { scheduleToAutostart } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule";
import { ttlMsToAutostop } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl";
import {
WorkspaceScheduleFormValues,
Language as FormLanguage,
Expand All @@ -40,9 +33,7 @@ const validValues: WorkspaceScheduleFormValues = {

describe("WorkspaceSchedulePage", () => {
describe("formValuesToAutostartRequest", () => {
it.each<
[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceAutostartRequest]
>([
it.each([
[
// Empty case
{
Expand Down Expand Up @@ -143,13 +134,16 @@ describe("WorkspaceSchedulePage", () => {
schedule: "20 16 * * 1,3,5",
},
],
])(`formValuesToAutostartRequest(%p) return %p`, (values, request) => {
expect(formValuesToAutostartRequest(values)).toEqual(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
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
Expand Down
Loading