diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index d979a37f4d616..235061621f711 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -17,6 +17,7 @@ import { RequireAuth } from "./components/RequireAuth/RequireAuth" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout" import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout" +import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout" // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -45,7 +46,10 @@ const WorkspaceBuildPage = lazy( ) const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage")) const WorkspaceSchedulePage = lazy( - () => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"), + () => + import( + "./pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage" + ), ) const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")) const TemplatePermissionsPage = lazy( @@ -260,12 +264,17 @@ export const AppRouter: FC = () => { } /> - } /> } /> - } /> + }> + } /> + } + /> + diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index 082b555318bda..d9ed3bcc37562 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -74,7 +74,7 @@ export const WorkspaceSchedule: FC< {Language.editScheduleLink} diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx index 3a43c8edce029..47972cef3110e 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx @@ -6,8 +6,8 @@ import utc from "dayjs/plugin/utc" import { defaultSchedule, emptySchedule, -} from "pages/WorkspaceSchedulePage/schedule" -import { emptyTTL } from "pages/WorkspaceSchedulePage/ttl" +} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule" +import { emptyTTL } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl" import { makeMockApiError } from "testHelpers/entities" import { WorkspaceScheduleForm, diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index ed3fad4d5cdb9..0ede4239f0a72 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -8,8 +8,13 @@ import MenuItem from "@material-ui/core/MenuItem" import makeStyles from "@material-ui/core/styles/makeStyles" import Switch from "@material-ui/core/Switch" import TextField from "@material-ui/core/TextField" -import { AlertBanner } from "components/AlertBanner/AlertBanner" -import { Section } from "components/Section/Section" +import { + HorizontalForm, + FormFooter, + FormSection, + FormFields, +} from "components/Form/Form" +import { Stack } from "components/Stack/Stack" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import duration from "dayjs/plugin/duration" @@ -20,13 +25,10 @@ import { FormikTouched, useFormik } from "formik" import { defaultSchedule, emptySchedule, -} from "pages/WorkspaceSchedulePage/schedule" +} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule" import { ChangeEvent, FC } from "react" import * as Yup from "yup" import { getFormHelpers } from "../../util/formUtils" -import { FormFooter } from "../FormFooter/FormFooter" -import { FullPageForm } from "../FullPageForm/FullPageForm" -import { Stack } from "../Stack/Stack" import { zones } from "./zones" // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're @@ -48,16 +50,14 @@ export const Language = { errorTtlMax: "Please enter a limit that is less than or equal to 168 hours (7 days).", daysOfWeekLabel: "Days of Week", - daySundayLabel: "Sunday", - dayMondayLabel: "Monday", - dayTuesdayLabel: "Tuesday", - dayWednesdayLabel: "Wednesday", - dayThursdayLabel: "Thursday", - dayFridayLabel: "Friday", - daySaturdayLabel: "Saturday", + daySundayLabel: "Sun", + dayMondayLabel: "Mon", + dayTuesdayLabel: "Tue", + dayWednesdayLabel: "Wed", + dayThursdayLabel: "Thu", + dayFridayLabel: "Fri", + daySaturdayLabel: "Sat", startTimeLabel: "Start time", - startTimeHelperText: "Your workspace will automatically start at this time.", - noStartTimeHelperText: "Your workspace will not automatically start.", timezoneLabel: "Timezone", ttlLabel: "Time until shutdown (hours)", ttlCausesShutdownHelperText: "Your workspace will shut down", @@ -67,13 +67,13 @@ export const Language = { "Your workspace will not automatically shut down.", formTitle: "Workspace schedule", startSection: "Start", - startSwitch: "Autostart", + startSwitch: "Enable Autostart", stopSection: "Stop", - stopSwitch: "Autostop", + stopSwitch: "Enable Autostop", } export interface WorkspaceScheduleFormProps { - submitScheduleError?: Error | unknown + submitScheduleError?: unknown initialValues: WorkspaceScheduleFormValues isLoading: boolean onCancel: () => void @@ -280,31 +280,26 @@ export const WorkspaceScheduleForm: FC< } return ( - -
- - {Boolean(submitScheduleError) && ( - - )} -
- - } - label={Language.startSwitch} - /> + + + + + } + label={Language.startSwitch} + /> + - ))} + - - - {Language.daysOfWeekLabel} - + + + {Language.daysOfWeekLabel} + - - {checkboxes.map((checkbox) => ( - - } - key={checkbox.name} - label={checkbox.label} - /> - ))} - - - {form.errors.monday && ( - {Language.errorNoDayOfWeek} - )} - -
- -
- + {checkboxes.map((checkbox) => ( + + } + key={checkbox.name} + label={checkbox.label} /> - } - label={Language.stopSwitch} - /> - -
+ ))} + + + {form.errors.monday && ( + {Language.errorNoDayOfWeek} + )} + + + - -
-
-
+ + + + } + label={Language.stopSwitch} + /> + + + + + ) } @@ -405,13 +400,14 @@ export const ttlShutdownAt = (formTTL: number): string => { } } -const useStyles = makeStyles({ - form: { - "& input": { - colorScheme: "dark", - }, - }, +const useStyles = makeStyles((theme) => ({ daysOfWeekLabel: { fontSize: 12, }, -}) + daysOfWeekOptions: { + display: "flex", + flexDirection: "row", + flexWrap: "wrap", + paddingTop: theme.spacing(0.5), + }, +})) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx deleted file mode 100644 index ed1150c0c74bb..0000000000000 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles" -import { useMachine } from "@xstate/react" -import { AlertBanner } from "components/AlertBanner/AlertBanner" -import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" -import { Loader } from "components/Loader/Loader" -import { Margins } from "components/Margins/Margins" -import dayjs from "dayjs" -import { scheduleToAutostart } from "pages/WorkspaceSchedulePage/schedule" -import { ttlMsToAutostop } from "pages/WorkspaceSchedulePage/ttl" -import { useEffect, FC } from "react" -import { useTranslation } from "react-i18next" -import { Navigate, useNavigate, useParams } from "react-router-dom" -import { scheduleChanged } from "util/schedule" -import * as TypesGen from "../../api/typesGenerated" -import { WorkspaceScheduleForm } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm" -import { firstOrItem } from "../../util/array" -import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService" -import { - formValuesToAutostartRequest, - formValuesToTTLRequest, -} from "./formToRequest" - -const getAutostart = (workspace?: TypesGen.Workspace) => - scheduleToAutostart(workspace?.autostart_schedule) -const getAutostop = (workspace?: TypesGen.Workspace) => - ttlMsToAutostop(workspace?.ttl_ms) - -const useStyles = makeStyles((theme) => ({ - topMargin: { - marginTop: `${theme.spacing(3)}px`, - }, -})) - -export const WorkspaceSchedulePage: FC = () => { - const { t } = useTranslation("workspaceSchedulePage") - const styles = useStyles() - const { username: usernameQueryParam, workspace: workspaceQueryParam } = - useParams() - const navigate = useNavigate() - const username = firstOrItem(usernameQueryParam, null) - const workspaceName = firstOrItem(workspaceQueryParam, null) - const [scheduleState, scheduleSend] = useMachine(workspaceSchedule) - const { - checkPermissionsError, - submitScheduleError, - getWorkspaceError, - getTemplateError, - permissions, - workspace, - template, - } = scheduleState.context - - // Get workspace on mount and whenever the args for getting a workspace change. - // scheduleSend should not change. - useEffect(() => { - username && - workspaceName && - scheduleSend({ type: "GET_WORKSPACE", username, workspaceName }) - }, [username, workspaceName, scheduleSend]) - - if (!username || !workspaceName) { - return - } - - if (scheduleState.hasTag("loading") || !template) { - return - } - - if (scheduleState.matches("error")) { - return ( - -
- - scheduleSend({ type: "GET_WORKSPACE", username, workspaceName }) - } - /> -
-
- ) - } - - if (!permissions?.updateWorkspace) { - return ( - -
- -
-
- ) - } - - if ( - scheduleState.matches("presentForm") || - scheduleState.matches("submittingSchedule") - ) { - return ( - { - navigate(`/@${username}/${workspaceName}`) - }} - onSubmit={(values) => { - scheduleSend({ - type: "SUBMIT_SCHEDULE", - autostart: formValuesToAutostartRequest(values), - ttl: formValuesToTTLRequest(values), - autostartChanged: scheduleChanged(getAutostart(workspace), values), - autostopChanged: scheduleChanged(getAutostop(workspace), values), - }) - }} - /> - ) - } - - if (scheduleState.matches("showingRestartDialog")) { - return ( - { - scheduleSend("RESTART_WORKSPACE") - }} - onClose={() => { - scheduleSend("APPLY_LATER") - }} - /> - ) - } - - if (scheduleState.matches("done")) { - return - } - - // Theoretically impossible - log and bail - console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState) - return -} - -export default WorkspaceSchedulePage diff --git a/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx b/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx new file mode 100644 index 0000000000000..328b0abc24632 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/Sidebar.tsx @@ -0,0 +1,138 @@ +import { makeStyles } from "@material-ui/core/styles" +import ScheduleIcon from "@material-ui/icons/TimerOutlined" +import { Workspace } from "api/typesGenerated" +import { Stack } from "components/Stack/Stack" +import { FC, ElementType, PropsWithChildren, ReactNode } from "react" +import { Link, NavLink } from "react-router-dom" +import { combineClasses } from "util/combineClasses" +import GeneralIcon from "@material-ui/icons/SettingsOutlined" +import { Avatar } from "components/Avatar/Avatar" + +const SidebarNavItem: FC< + PropsWithChildren<{ href: string; icon: ReactNode }> +> = ({ children, href, icon }) => { + const styles = useStyles() + return ( + + combineClasses([ + styles.sidebarNavItem, + isActive ? styles.sidebarNavItemActive : undefined, + ]) + } + > + + {icon} + {children} + + + ) +} + +const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({ + icon: Icon, +}) => { + const styles = useStyles() + return +} + +export const Sidebar: React.FC<{ username: string; workspace: Workspace }> = ({ + username, + workspace, +}) => { + const styles = useStyles() + + return ( + + ) +} + +const useStyles = makeStyles((theme) => ({ + sidebar: { + width: 245, + flexShrink: 0, + }, + sidebarNavItem: { + color: "inherit", + display: "block", + fontSize: 14, + textDecoration: "none", + padding: theme.spacing(1.5, 1.5, 1.5, 2), + borderRadius: theme.shape.borderRadius / 2, + transition: "background-color 0.15s ease-in-out", + marginBottom: 1, + position: "relative", + + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + }, + sidebarNavItemActive: { + backgroundColor: theme.palette.action.hover, + + "&:before": { + content: '""', + display: "block", + width: 3, + height: "100%", + position: "absolute", + left: 0, + top: 0, + backgroundColor: theme.palette.secondary.dark, + borderTopLeftRadius: theme.shape.borderRadius, + borderBottomLeftRadius: theme.shape.borderRadius, + }, + }, + sidebarNavItemIcon: { + width: theme.spacing(2), + height: theme.spacing(2), + }, + workspaceInfo: { + marginBottom: theme.spacing(2), + }, + workspaceData: { + overflow: "hidden", + }, + name: { + fontWeight: 600, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: theme.palette.text.primary, + textDecoration: "none", + }, + secondary: { + color: theme.palette.text.secondary, + fontSize: 12, + overflow: "hidden", + textOverflow: "ellipsis", + }, +})) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx similarity index 93% rename from site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx rename to site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index d9a8af829dc1d..5e4b36a02ce9e 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -1,16 +1,19 @@ -import { renderWithAuth } from "testHelpers/renderHelpers" +import { renderWithWorkspaceSettingsLayout } from "testHelpers/renderHelpers" import userEvent from "@testing-library/user-event" import { screen } from "@testing-library/react" import { formValuesToAutostartRequest, formValuesToTTLRequest, -} from "pages/WorkspaceSchedulePage/formToRequest" +} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest" import { Autostart, scheduleToAutostart, -} from "pages/WorkspaceSchedulePage/schedule" -import { Autostop, ttlMsToAutostop } from "pages/WorkspaceSchedulePage/ttl" -import * as TypesGen from "../../api/typesGenerated" +} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule" +import { + Autostop, + ttlMsToAutostop, +} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl" +import * as TypesGen from "../../../api/typesGenerated" import { WorkspaceScheduleFormValues, Language as FormLanguage, @@ -257,7 +260,7 @@ describe("WorkspaceSchedulePage", () => { describe("autostop change dialog", () => { it("shows if autostop is changed", async () => { - renderWithAuth(, { + renderWithWorkspaceSettingsLayout(, { route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`, path: "/@:username/:workspace/schedule", }) @@ -276,7 +279,7 @@ describe("WorkspaceSchedulePage", () => { }) it("doesn't show if autostop is not changed", async () => { - renderWithAuth(, { + renderWithWorkspaceSettingsLayout(, { route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`, path: "/@:username/:workspace/schedule", }) @@ -309,7 +312,7 @@ describe("WorkspaceSchedulePage", () => { }, ), ) - renderWithAuth(, { + renderWithWorkspaceSettingsLayout(, { route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`, path: "/@:username/:workspace/schedule", }) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx new file mode 100644 index 0000000000000..abd49bc217848 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -0,0 +1,136 @@ +import { makeStyles } from "@material-ui/core/styles" +import { useMachine } from "@xstate/react" +import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import { Loader } from "components/Loader/Loader" +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" +import dayjs from "dayjs" +import { scheduleToAutostart } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule" +import { ttlMsToAutostop } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl" +import { useWorkspaceSettingsContext } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useTranslation } from "react-i18next" +import { Navigate, useNavigate, useParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { scheduleChanged } from "util/schedule" +import * as TypesGen from "../../../api/typesGenerated" +import { WorkspaceScheduleForm } from "../../../components/WorkspaceScheduleForm/WorkspaceScheduleForm" +import { firstOrItem } from "../../../util/array" +import { workspaceSchedule } from "../../../xServices/workspaceSchedule/workspaceScheduleXService" +import { + formValuesToAutostartRequest, + formValuesToTTLRequest, +} from "./formToRequest" + +const getAutostart = (workspace: TypesGen.Workspace) => + scheduleToAutostart(workspace.autostart_schedule) +const getAutostop = (workspace: TypesGen.Workspace) => + ttlMsToAutostop(workspace.ttl_ms) + +const useStyles = makeStyles((theme) => ({ + topMargin: { + marginTop: `${theme.spacing(3)}px`, + }, + pageHeader: { + paddingTop: 0, + }, +})) + +export const WorkspaceSchedulePage: FC = () => { + const { t } = useTranslation("workspaceSchedulePage") + const styles = useStyles() + const { username: usernameQueryParam, workspace: workspaceQueryParam } = + useParams() + const navigate = useNavigate() + const username = firstOrItem(usernameQueryParam, null) + const workspaceName = firstOrItem(workspaceQueryParam, null) + const { workspace } = useWorkspaceSettingsContext() + const [scheduleState, scheduleSend] = useMachine(workspaceSchedule, { + context: { workspace }, + }) + const { + checkPermissionsError, + submitScheduleError, + getTemplateError, + permissions, + template, + } = scheduleState.context + + if (!username || !workspaceName) { + return + } + + if (scheduleState.matches("done")) { + return + } + + return ( + <> + + {pageTitle([workspaceName, "Schedule"])} + + + Workspace Schedule + + {(scheduleState.hasTag("loading") || !template) && } + {scheduleState.matches("error") && ( + + )} + {permissions && !permissions.updateWorkspace && ( + + )} + {template && + workspace && + (scheduleState.matches("presentForm") || + scheduleState.matches("submittingSchedule")) && ( + { + navigate(`/@${username}/${workspaceName}`) + }} + onSubmit={(values) => { + scheduleSend({ + type: "SUBMIT_SCHEDULE", + autostart: formValuesToAutostartRequest(values), + ttl: formValuesToTTLRequest(values), + autostartChanged: scheduleChanged( + getAutostart(workspace), + values, + ), + autostopChanged: scheduleChanged( + getAutostop(workspace), + values, + ), + }) + }} + /> + )} + { + scheduleSend("RESTART_WORKSPACE") + }} + onClose={() => { + scheduleSend("APPLY_LATER") + }} + /> + + ) +} + +export default WorkspaceSchedulePage diff --git a/site/src/pages/WorkspaceSchedulePage/formToRequest.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest.ts similarity index 100% rename from site/src/pages/WorkspaceSchedulePage/formToRequest.ts rename to site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest.ts diff --git a/site/src/pages/WorkspaceSchedulePage/schedule.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule.ts similarity index 96% rename from site/src/pages/WorkspaceSchedulePage/schedule.ts rename to site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule.ts index 7925b44bd5b9c..7c8ff365969c8 100644 --- a/site/src/pages/WorkspaceSchedulePage/schedule.ts +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule.ts @@ -2,7 +2,7 @@ import * as cronParser from "cron-parser" import dayjs from "dayjs" import timezone from "dayjs/plugin/timezone" import utc from "dayjs/plugin/utc" -import { extractTimezone, stripTimezone } from "../../util/schedule" +import { extractTimezone, stripTimezone } from "../../../util/schedule" // REMARK: timezone plugin depends on UTC // diff --git a/site/src/pages/WorkspaceSchedulePage/ttl.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl.ts similarity index 100% rename from site/src/pages/WorkspaceSchedulePage/ttl.ts rename to site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl.ts diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx new file mode 100644 index 0000000000000..c1582fe564519 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx @@ -0,0 +1,86 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Sidebar } from "./Sidebar" +import { Stack } from "components/Stack/Stack" +import { createContext, FC, Suspense, useContext } from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "../../util/page" +import { Loader } from "components/Loader/Loader" +import { Outlet, useParams } from "react-router-dom" +import { Margins } from "components/Margins/Margins" +import { getWorkspaceByOwnerAndName } from "api/api" +import { useQuery } from "@tanstack/react-query" + +const fetchWorkspaceSettings = async (owner: string, name: string) => { + const workspace = await getWorkspaceByOwnerAndName(owner, name) + + return { + workspace, + } +} + +const useWorkspace = (owner: string, name: string) => { + return useQuery({ + queryKey: ["workspace", name, "settings"], + queryFn: () => fetchWorkspaceSettings(owner, name), + }) +} + +const WorkspaceSettingsContext = createContext< + Awaited> | undefined +>(undefined) + +export const useWorkspaceSettingsContext = () => { + const context = useContext(WorkspaceSettingsContext) + + if (!context) { + throw new Error( + "useWorkspaceSettingsContext must be used within a WorkspaceSettingsContext.Provider", + ) + } + + return context +} + +export const WorkspaceSettingsLayout: FC = () => { + const styles = useStyles() + const { workspace: workspaceName, username } = useParams() as { + workspace: string + username: string + } + const { data: settings } = useWorkspace(username, workspaceName) + + return ( + <> + + {pageTitle([workspaceName, "Settings"])} + + + {settings ? ( + + + + + }> +
+ +
+
+
+
+
+ ) : ( + + )} + + ) +} + +const useStyles = makeStyles((theme) => ({ + wrapper: { + padding: theme.spacing(6, 0), + }, + + content: { + width: "100%", + }, +})) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.test.tsx index a2ca353ec3374..39c48363fbf63 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.test.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event" import { - renderWithAuth, + renderWithWorkspaceSettingsLayout, waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers" import WorkspaceSettingsPage from "./WorkspaceSettingsPage" @@ -41,7 +41,7 @@ test("Submit the workspace settings page successfully", async () => { .mockResolvedValue(MockWorkspaceBuild) // Setup event and rendering const user = userEvent.setup() - renderWithAuth(, { + renderWithWorkspaceSettingsLayout(, { route: "/@test-user/test-workspace/settings", path: "/@:username/:workspace/settings", // Need this because after submit the user is redirected diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx index 87d8cff54e5e3..5f4ba99c3f529 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next" import { useNavigate, useParams } from "react-router-dom" import { pageTitle } from "util/page" import { useUpdateWorkspaceSettings, useWorkspaceSettings } from "./data" +import { useWorkspaceSettingsContext } from "./WorkspaceSettingsLayout" import { WorkspaceSettingsPageView } from "./WorkspaceSettingsPageView" const WorkspaceSettingsPage = () => { @@ -13,13 +14,10 @@ const WorkspaceSettingsPage = () => { username: string workspace: string } - const { - data: settings, - error, - isLoading, - } = useWorkspaceSettings(username, workspaceName) + const { workspace } = useWorkspaceSettingsContext() + const { data: settings, error, isLoading } = useWorkspaceSettings(workspace) const navigate = useNavigate() - const updateSettings = useUpdateWorkspaceSettings(settings?.workspace.id, { + const updateSettings = useUpdateWorkspaceSettings(workspace.id, { onSuccess: ({ name }) => { navigate(`/@${username}/${name}`) }, @@ -30,7 +28,7 @@ const WorkspaceSettingsPage = () => { return ( <> - {pageTitle(t("title"))} + {pageTitle([workspaceName, "Settings"])} { isLoading={isLoading} isSubmitting={updateSettings.isLoading} settings={settings} - onCancel={() => navigate(-1)} + onCancel={() => navigate(`/@${username}/${workspaceName}`)} onSubmit={updateSettings.mutate} /> diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx index ab94200fa9f09..99bf6342ab885 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx @@ -1,6 +1,7 @@ +import { makeStyles } from "@material-ui/core/styles" import { AlertBanner } from "components/AlertBanner/AlertBanner" -import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" import { Loader } from "components/Loader/Loader" +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" import { FC } from "react" import { useTranslation } from "react-i18next" import { WorkspaceSettings, WorkspaceSettingsFormValue } from "./data" @@ -26,22 +27,31 @@ export const WorkspaceSettingsPageView: FC = ({ loadingError, }) => { const { t } = useTranslation("workspaceSettingsPage") + const styles = useStyles() return ( - - <> - {loadingError && } - {isLoading && } - {settings && ( - - )} - - + <> + + {t("title")} + + + {loadingError && } + {isLoading && } + {settings && ( + + )} + ) } + +const useStyles = makeStyles(() => ({ + pageHeader: { + paddingTop: 0, + }, +})) diff --git a/site/src/pages/WorkspaceSettingsPage/data.ts b/site/src/pages/WorkspaceSettingsPage/data.ts index cf64abc9a189e..46e72b5b33616 100644 --- a/site/src/pages/WorkspaceSettingsPage/data.ts +++ b/site/src/pages/WorkspaceSettingsPage/data.ts @@ -1,15 +1,13 @@ import { useMutation, useQuery } from "@tanstack/react-query" import { - getWorkspaceByOwnerAndName, getWorkspaceBuildParameters, getTemplateVersionRichParameters, patchWorkspace, postWorkspaceBuild, } from "api/api" -import { WorkspaceBuildParameter } from "api/typesGenerated" +import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated" -const getWorkspaceSettings = async (owner: string, name: string) => { - const workspace = await getWorkspaceByOwnerAndName(owner, name) +const getWorkspaceSettings = async (workspace: Workspace) => { const latestBuild = workspace.latest_build const [templateVersionRichParameters, buildParameters] = await Promise.all([ getTemplateVersionRichParameters(latestBuild.template_version_id), @@ -22,10 +20,10 @@ const getWorkspaceSettings = async (owner: string, name: string) => { } } -export const useWorkspaceSettings = (owner: string, workspace: string) => { +export const useWorkspaceSettings = (workspace: Workspace) => { return useQuery({ - queryKey: ["workspaceSettings", owner, workspace], - queryFn: () => getWorkspaceSettings(owner, workspace), + queryKey: ["workspaceSettings", workspace.id], + queryFn: () => getWorkspaceSettings(workspace), }) } diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 51287b0118b16..d7e1cc728468a 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -9,6 +9,7 @@ import { DashboardLayout } from "components/Dashboard/DashboardLayout" import { createMemoryHistory } from "history" import { i18n } from "i18n" import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout" +import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout" import { FC, ReactElement } from "react" import { I18nextProvider } from "react-i18next" import { @@ -131,6 +132,50 @@ export function renderWithTemplateSettingsLayout( } } +export function renderWithWorkspaceSettingsLayout( + element: JSX.Element, + { + path = "/", + route = "/", + extraRoutes = [], + nonAuthenticatedRoutes = [], + }: RenderWithAuthOptions = {}, +) { + const routes: RouteObject[] = [ + { + element: , + children: [ + { + element: , + children: [ + { + element: , + children: [{ path, element }, ...extraRoutes], + }, + ], + }, + ], + }, + ...nonAuthenticatedRoutes, + ] + + const router = createMemoryRouter(routes, { initialEntries: [route] }) + + const renderResult = wrappedRender( + + + + + , + ) + + return { + user: MockUser, + router, + ...renderResult, + } +} + export const waitForLoaderToBeRemoved = (): Promise => // Sometimes, we have pages that are doing a lot of requests to get done, so the // default timeout of 1_000 is not enough. We should revisit this when we unify diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts index 87c31935dee81..29d8aec1912f3 100644 --- a/site/src/util/schedule.test.ts +++ b/site/src/util/schedule.test.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs" import duration from "dayjs/plugin/duration" -import { emptySchedule } from "pages/WorkspaceSchedulePage/schedule" -import { emptyTTL } from "pages/WorkspaceSchedulePage/ttl" +import { emptySchedule } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule" +import { emptyTTL } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl" import { Workspace } from "../api/typesGenerated" import * as Mocks from "../testHelpers/entities" import { diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index 494183ce67f1d..02e37516389b9 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -10,8 +10,8 @@ import utc from "dayjs/plugin/utc" import { Workspace } from "../api/typesGenerated" import { isWorkspaceOn } from "./workspace" import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm" -import { Autostop } from "pages/WorkspaceSchedulePage/ttl" -import { Autostart } from "pages/WorkspaceSchedulePage/schedule" +import { Autostop } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl" +import { Autostart } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule" // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're // sorted alphabetically. diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts index a45eed1ec139b..21fcc3141e7bf 100644 --- a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts @@ -15,7 +15,7 @@ export interface WorkspaceScheduleContext { * re-fetch the workspace to ensure we're up-to-date. As a result, this * machine is partially influenced by workspaceXService. */ - workspace?: TypesGen.Workspace + workspace: TypesGen.Workspace template?: TypesGen.Template getTemplateError?: Error | unknown permissions?: Permissions @@ -41,7 +41,6 @@ const permissionsToCheck = (workspace: TypesGen.Workspace) => ({ }) export type WorkspaceScheduleEvent = - | { type: "GET_WORKSPACE"; username: string; workspaceName: string } | { type: "SUBMIT_SCHEDULE" autostart: TypesGen.UpdateWorkspaceAutostartRequest @@ -63,38 +62,13 @@ export const workspaceSchedule = context: {} as WorkspaceScheduleContext, events: {} as WorkspaceScheduleEvent, services: {} as { - getWorkspace: { - data: TypesGen.Workspace - } getTemplate: { data: TypesGen.Template } }, }, - initial: "idle", - on: { - GET_WORKSPACE: "gettingWorkspace", - }, + initial: "gettingPermissions", states: { - idle: { - tags: "loading", - }, - gettingWorkspace: { - entry: ["clearGetWorkspaceError", "clearContext"], - invoke: { - src: "getWorkspace", - id: "getWorkspace", - onDone: { - target: "gettingPermissions", - actions: ["assignWorkspace"], - }, - onError: { - target: "error", - actions: ["assignGetWorkspaceError"], - }, - }, - tags: "loading", - }, gettingPermissions: { entry: "clearGetPermissionsError", invoke: { @@ -167,9 +141,7 @@ export const workspaceSchedule = }, }, error: { - on: { - GET_WORKSPACE: "gettingWorkspace", - }, + type: "final", }, done: { type: "final", @@ -184,12 +156,6 @@ export const workspaceSchedule = assignSubmissionError: assign({ submitScheduleError: (_, event) => event.data, }), - assignWorkspace: assign({ - workspace: (_, event) => event.data, - }), - assignGetWorkspaceError: assign({ - getWorkspaceError: (_, event) => event.data, - }), assignPermissions: assign({ // Setting event.data as Permissions to be more stricted. So we know // what permissions we asked for. @@ -213,12 +179,7 @@ export const workspaceSchedule = clearGetPermissionsError: assign({ checkPermissionsError: (_) => undefined, }), - clearContext: () => { - assign({ workspace: undefined, permissions: undefined }) - }, - clearGetWorkspaceError: (context) => { - assign({ ...context, getWorkspaceError: undefined }) - }, + // action instead of service because we fire and forget so that the // user can return to the workspace page to see the restart restartWorkspace: (context) => { @@ -232,12 +193,6 @@ export const workspaceSchedule = }, services: { - getWorkspace: async (_, event) => { - return await API.getWorkspaceByOwnerAndName( - event.username, - event.workspaceName, - ) - }, getTemplate: async (context) => { if (context.workspace) { return await API.getTemplate(context.workspace.template_id)