Skip to content

Commit 37e26da

Browse files
committed
feat: add user quiet hours settings page
1 parent 1de6124 commit 37e26da

File tree

8 files changed

+189
-2
lines changed

8 files changed

+189
-2
lines changed

codersdk/deployment.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ var FeatureNames = []FeatureName{
6363
FeatureExternalProvisionerDaemons,
6464
FeatureAppearance,
6565
FeatureAdvancedTemplateScheduling,
66+
FeatureTemplateAutostopRequirement,
6667
FeatureWorkspaceProxy,
6768
FeatureUserRoleManagement,
6869
FeatureWorkspaceBatchActions,

enterprise/coderd/coderd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
434434
codersdk.FeatureAdvancedTemplateScheduling: true,
435435
// FeatureTemplateAutostopRequirement depends on
436436
// FeatureAdvancedTemplateScheduling.
437-
codersdk.FeatureTemplateAutostopRequirement: api.DefaultQuietHoursSchedule != "",
437+
codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "",
438438
codersdk.FeatureWorkspaceProxy: true,
439439
codersdk.FeatureUserRoleManagement: true,
440440
})

scripts/develop.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ fatal() {
136136
trap 'fatal "Script encountered an error"' ERR
137137

138138
cdroot
139-
start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --dangerous-allow-cors-requests=true "$@"
139+
start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --dangerous-allow-cors-requests=true "$@" --experiments template_autostop_requirement --default-quiet-hours-schedule "CRON_TZ=Australia/Sydney 0 0 * * *"
140140

141141
echo '== Waiting for Coder to become ready'
142142
# Start the timeout in the background so interrupting this script

site/src/AppRouter.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ const CliAuthenticationPage = lazy(
2929
const AccountPage = lazy(
3030
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
3131
)
32+
const SchedulePage = lazy(
33+
() => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"),
34+
)
3235
const SecurityPage = lazy(
3336
() => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"),
3437
)
@@ -289,6 +292,7 @@ export const AppRouter: FC = () => {
289292

290293
<Route path="settings" element={<SettingsLayout />}>
291294
<Route path="account" element={<AccountPage />} />
295+
<Route path="schedule" element={<SchedulePage />} />
292296
<Route path="security" element={<SecurityPage />} />
293297
<Route path="ssh-keys" element={<SSHKeysPage />} />
294298
<Route path="tokens">

site/src/api/api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,21 @@ export const updateProfile = async (
667667
return response.data
668668
}
669669

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

site/src/components/SettingsLayout/Sidebar.tsx

Lines changed: 12 additions & 0 deletions
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,8 @@ 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 = entitlements.features.template_autostop_requirement.enabled
4448

4549
return (
4650
<nav className={styles.sidebar}>
@@ -58,6 +62,14 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
5862
>
5963
Account
6064
</SidebarNavItem>
65+
{allowAutostopRequirement && (
66+
<SidebarNavItem
67+
href="schedule"
68+
icon={<SidebarNavItemIcon icon={ScheduleIcon} />}
69+
>
70+
Schedule
71+
</SidebarNavItem>
72+
)}
6173
<SidebarNavItem
6274
href="security"
6375
icon={<SidebarNavItemIcon icon={SecurityIcon} />}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { fireEvent, screen, waitFor } from "@testing-library/react"
2+
import * as API from "../../../api/api"
3+
import * as AccountForm from "../../../components/SettingsAccountForm/SettingsAccountForm"
4+
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
5+
import * as AuthXService from "../../../xServices/auth/authXService"
6+
import { SchedulePage } from "./SchedulePage"
7+
import i18next from "i18next"
8+
import { mockApiError } from "testHelpers/entities"
9+
10+
const { t } = i18next
11+
12+
const renderPage = () => {
13+
return renderWithAuth(<SchedulePage />)
14+
}
15+
16+
const newData = {
17+
username: "user",
18+
}
19+
20+
const fillAndSubmitForm = async () => {
21+
await waitFor(() => screen.findByLabelText("Username"))
22+
fireEvent.change(screen.getByLabelText("Username"), {
23+
target: { value: newData.username },
24+
})
25+
fireEvent.click(screen.getByText(AccountForm.Language.updateSettings))
26+
}
27+
28+
describe("SchedulePage", () => {
29+
describe("when it is a success", () => {
30+
it("shows the success message", async () => {
31+
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
32+
Promise.resolve({
33+
id: userId,
34+
email: "user@coder.com",
35+
created_at: new Date().toString(),
36+
status: "active",
37+
organization_ids: ["123"],
38+
roles: [],
39+
avatar_url: "",
40+
last_seen_at: new Date().toString(),
41+
login_type: "password",
42+
...data,
43+
}),
44+
)
45+
const { user } = renderPage()
46+
await fillAndSubmitForm()
47+
48+
const successMessage = await screen.findByText(
49+
AuthXService.Language.successProfileUpdate,
50+
)
51+
expect(successMessage).toBeDefined()
52+
expect(API.updateProfile).toBeCalledTimes(1)
53+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
54+
})
55+
})
56+
57+
describe("when the username is already taken", () => {
58+
it("shows an error", async () => {
59+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce(
60+
mockApiError({
61+
message: "Invalid profile",
62+
validations: [
63+
{ detail: "Username is already in use", field: "username" },
64+
],
65+
}),
66+
)
67+
68+
const { user } = renderPage()
69+
await fillAndSubmitForm()
70+
71+
const errorMessage = await screen.findByText("Username is already in use")
72+
expect(errorMessage).toBeDefined()
73+
expect(API.updateProfile).toBeCalledTimes(1)
74+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
75+
})
76+
})
77+
78+
describe("when it is an unknown error", () => {
79+
it("shows a generic error message", async () => {
80+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
81+
data: "unknown error",
82+
})
83+
84+
const { user } = renderPage()
85+
await fillAndSubmitForm()
86+
87+
const errorText = t("warningsAndErrors.somethingWentWrong", {
88+
ns: "common",
89+
})
90+
const errorMessage = await screen.findByText(errorText)
91+
expect(errorMessage).toBeDefined()
92+
expect(API.updateProfile).toBeCalledTimes(1)
93+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
94+
})
95+
})
96+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { FC, useEffect, useState } from "react"
2+
import { Section } from "../../../components/SettingsLayout/Section"
3+
import { AccountForm } from "../../../components/SettingsAccountForm/SettingsAccountForm"
4+
import { useAuth } from "components/AuthProvider/AuthProvider"
5+
import { useMe } from "hooks/useMe"
6+
import { usePermissions } from "hooks/usePermissions"
7+
import { UserQuietHoursScheduleResponse } from "api/typesGenerated"
8+
import * as API from "api/api"
9+
10+
export const SchedulePage: FC = () => {
11+
const [authState, authSend] = useAuth()
12+
const me = useMe()
13+
const permissions = usePermissions()
14+
const { updateProfileError } = authState.context
15+
const canEditUsers = permissions && permissions.updateUsers
16+
17+
const [quietHoursSchedule, setQuietHoursSchedule] = useState<UserQuietHoursScheduleResponse | undefined>(undefined)
18+
const [quietHoursScheduleError, setQuietHoursScheduleError] = useState<string>("")
19+
20+
useEffect(() => {
21+
setQuietHoursSchedule(undefined)
22+
API.getUserQuietHoursSchedule(me.id)
23+
.then(response => {
24+
setQuietHoursSchedule(response)
25+
setQuietHoursScheduleError("")
26+
})
27+
.catch(error => {
28+
setQuietHoursSchedule(undefined)
29+
setQuietHoursScheduleError(error.message)
30+
})
31+
}, [me.id])
32+
33+
return (
34+
<Section title="Schedule" description="Manage your quiet hours schedule">
35+
<pre>
36+
{JSON.stringify(quietHoursSchedule, null, 2)}
37+
38+
{quietHoursScheduleError}
39+
</pre>
40+
<AccountForm
41+
editable={Boolean(canEditUsers)}
42+
email={me.email}
43+
updateProfileError={updateProfileError}
44+
isLoading={authState.matches("signedIn.profile.updatingProfile")}
45+
initialValues={{
46+
username: me.username,
47+
}}
48+
onSubmit={(data) => {
49+
authSend({
50+
type: "UPDATE_PROFILE",
51+
data,
52+
})
53+
}}
54+
/>
55+
</Section>
56+
)
57+
}
58+
59+
export default SchedulePage

0 commit comments

Comments
 (0)