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
Next Next commit
feat: add user quiet hours settings page
  • Loading branch information
deansheather committed Sep 5, 2023
commit 37e26da070d163b87ab3e3d77e3500bdd50d733d
1 change: 1 addition & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ var FeatureNames = []FeatureName{
FeatureExternalProvisionerDaemons,
FeatureAppearance,
FeatureAdvancedTemplateScheduling,
FeatureTemplateAutostopRequirement,
FeatureWorkspaceProxy,
FeatureUserRoleManagement,
FeatureWorkspaceBatchActions,
Expand Down
2 changes: 1 addition & 1 deletion enterprise/coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,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,
})
Expand Down
2 changes: 1 addition & 1 deletion scripts/develop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ fatal() {
trap 'fatal "Script encountered an error"' ERR

cdroot
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 "$@"
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 * * *"

echo '== Waiting for Coder to become ready'
# Start the timeout in the background so interrupting this script
Expand Down
4 changes: 4 additions & 0 deletions site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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"),
)
Expand Down Expand Up @@ -289,6 +292,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">
Expand Down
15 changes: 15 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,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> => {
Expand Down
12 changes: 12 additions & 0 deletions site/src/components/SettingsLayout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>
Expand Down Expand Up @@ -41,6 +43,8 @@ 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}>
Expand All @@ -58,6 +62,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} />}
Expand Down
96 changes: 96 additions & 0 deletions site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import * as API from "../../../api/api"
import * as AccountForm from "../../../components/SettingsAccountForm/SettingsAccountForm"
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import * as AuthXService from "../../../xServices/auth/authXService"
import { SchedulePage } from "./SchedulePage"
import i18next from "i18next"
import { mockApiError } from "testHelpers/entities"

const { t } = i18next

const renderPage = () => {
return renderWithAuth(<SchedulePage />)
}

const newData = {
username: "user",
}

const fillAndSubmitForm = async () => {
await waitFor(() => screen.findByLabelText("Username"))
fireEvent.change(screen.getByLabelText("Username"), {
target: { value: newData.username },
})
fireEvent.click(screen.getByText(AccountForm.Language.updateSettings))
}

describe("SchedulePage", () => {
describe("when it is a success", () => {
it("shows the success message", async () => {
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
Promise.resolve({
id: userId,
email: "user@coder.com",
created_at: new Date().toString(),
status: "active",
organization_ids: ["123"],
roles: [],
avatar_url: "",
last_seen_at: new Date().toString(),
login_type: "password",
...data,
}),
)
const { user } = renderPage()
await fillAndSubmitForm()

const successMessage = await screen.findByText(
AuthXService.Language.successProfileUpdate,
)
expect(successMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})

describe("when the username is already taken", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce(
mockApiError({
message: "Invalid profile",
validations: [
{ detail: "Username is already in use", field: "username" },
],
}),
)

const { user } = renderPage()
await fillAndSubmitForm()

const errorMessage = await screen.findByText("Username is already in use")
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})

describe("when it is an unknown error", () => {
it("shows a generic error message", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
data: "unknown error",
})

const { user } = renderPage()
await fillAndSubmitForm()

const errorText = t("warningsAndErrors.somethingWentWrong", {
ns: "common",
})
const errorMessage = await screen.findByText(errorText)
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})
})
59 changes: 59 additions & 0 deletions site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { FC, useEffect, useState } from "react"
import { Section } from "../../../components/SettingsLayout/Section"
import { AccountForm } from "../../../components/SettingsAccountForm/SettingsAccountForm"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { useMe } from "hooks/useMe"
import { usePermissions } from "hooks/usePermissions"
import { UserQuietHoursScheduleResponse } from "api/typesGenerated"
import * as API from "api/api"

export const SchedulePage: FC = () => {
const [authState, authSend] = useAuth()
const me = useMe()
const permissions = usePermissions()
const { updateProfileError } = authState.context
const canEditUsers = permissions && permissions.updateUsers

const [quietHoursSchedule, setQuietHoursSchedule] = useState<UserQuietHoursScheduleResponse | undefined>(undefined)
const [quietHoursScheduleError, setQuietHoursScheduleError] = useState<string>("")

useEffect(() => {
setQuietHoursSchedule(undefined)
API.getUserQuietHoursSchedule(me.id)
.then(response => {
setQuietHoursSchedule(response)
setQuietHoursScheduleError("")
})
.catch(error => {
setQuietHoursSchedule(undefined)
setQuietHoursScheduleError(error.message)
})
}, [me.id])

return (
<Section title="Schedule" description="Manage your quiet hours schedule">
<pre>
{JSON.stringify(quietHoursSchedule, null, 2)}

{quietHoursScheduleError}
</pre>
<AccountForm
editable={Boolean(canEditUsers)}
email={me.email}
updateProfileError={updateProfileError}
isLoading={authState.matches("signedIn.profile.updatingProfile")}
initialValues={{
username: me.username,
}}
onSubmit={(data) => {
authSend({
type: "UPDATE_PROFILE",
data,
})
}}
/>
</Section>
)
}

export default SchedulePage