diff --git a/site/e2e/tests/listTemplates.spec.ts b/site/e2e/tests/listTemplates.spec.ts index 429ee7ba07db1..4d3569bfb47f3 100644 --- a/site/e2e/tests/listTemplates.spec.ts +++ b/site/e2e/tests/listTemplates.spec.ts @@ -5,5 +5,5 @@ test.use({ storageState: getStatePath("authState") }) test("list templates", async ({ page, baseURL }) => { await page.goto(`${baseURL}/templates`, { waitUntil: "networkidle" }) - await expect(page).toHaveTitle("Templates – Coder") + await expect(page).toHaveTitle("Templates - Coder") }) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 6bce538c20373..1910440f0ad08 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -6,7 +6,7 @@ import AuditPage from "pages/AuditPage/AuditPage" import GroupsPage from "pages/GroupsPage/GroupsPage" import LoginPage from "pages/LoginPage/LoginPage" import { SetupPage } from "pages/SetupPage/SetupPage" -import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" +import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage" import TemplatesPage from "pages/TemplatesPage/TemplatesPage" import UsersPage from "pages/UsersPage/UsersPage" import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage" @@ -16,6 +16,7 @@ import { DashboardLayout } from "./components/Dashboard/DashboardLayout" import { RequireAuth } from "./components/RequireAuth/RequireAuth" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout" +import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout" // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -50,7 +51,7 @@ const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")) const TemplatePermissionsPage = lazy( () => import( - "./pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage" + "./pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage" ), ) const TemplateSummaryPage = lazy( @@ -120,7 +121,10 @@ const CreateTemplatePage = lazy( () => import("./pages/CreateTemplatePage/CreateTemplatePage"), ) const TemplateVariablesPage = lazy( - () => import("./pages/TemplateVariablesPage/TemplateVariablesPage"), + () => + import( + "./pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage" + ), ) const WorkspaceSettingsPage = lazy( () => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"), @@ -129,7 +133,13 @@ const CreateTokenPage = lazy( () => import("./pages/CreateTokenPage/CreateTokenPage"), ) const TemplateFilesPage = lazy( - () => import("./pages/TemplateFilesPage/TemplateFilesPage"), + () => import("./pages/TemplatePage/TemplateFilesPage/TemplateFilesPage"), +) +const TemplateSchedulePage = lazy( + () => + import( + "./pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage" + ), ) export const AppRouter: FC = () => { @@ -160,16 +170,25 @@ export const AppRouter: FC = () => { }> } /> + + } /> + + + } /> + + }> + } /> } /> - } /> + } + /> + } /> - } /> - } /> - } /> } /> diff --git a/site/src/components/GoBackButton/GoBackButton.tsx b/site/src/components/GoBackButton/GoBackButton.tsx deleted file mode 100644 index 36818eb6458ae..0000000000000 --- a/site/src/components/GoBackButton/GoBackButton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Button from "@material-ui/core/Button" - -interface GoBackButtonProps { - onClick: () => void -} - -export const Language = { - ariaLabel: "Go back", -} - -export const GoBackButton: React.FC< - React.PropsWithChildren -> = ({ onClick }) => { - return ( - - ) -} diff --git a/site/src/components/IconField/LazyIconField.tsx b/site/src/components/IconField/LazyIconField.tsx index a196ac6cf22b0..b9eafa3b42b4d 100644 --- a/site/src/components/IconField/LazyIconField.tsx +++ b/site/src/components/IconField/LazyIconField.tsx @@ -5,7 +5,7 @@ const IconField = lazy(() => import("./IconField")) export const LazyIconField: FC = (props) => { return ( - + }> ) diff --git a/site/src/components/SettingsLayout/Sidebar.tsx b/site/src/components/SettingsLayout/Sidebar.tsx index 99e9f471983ae..4113067ade30c 100644 --- a/site/src/components/SettingsLayout/Sidebar.tsx +++ b/site/src/components/SettingsLayout/Sidebar.tsx @@ -130,6 +130,7 @@ const useStyles = makeStyles((theme) => ({ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", + whiteSpace: "nowrap", }, email: { color: theme.palette.text.secondary, diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index 547c927b9395a..ed1ce35093e1f 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -42,13 +42,6 @@ const fetchTemplate = async (orgId: string, templateName: string) => { } } -const useTemplateData = (orgId: string, templateName: string) => { - return useQuery({ - queryKey: ["template", templateName], - queryFn: () => fetchTemplate(orgId, templateName), - }) -} - type TemplateLayoutContextValue = Awaited> const TemplateLayoutContext = createContext< @@ -71,28 +64,31 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ const navigate = useNavigate() const styles = useStyles() const orgId = useOrganizationId() - const { template } = useParams() as { template: string } - const templateData = useTemplateData(orgId, template) + const { template: templateName } = useParams() as { template: string } + const { data, error, isLoading } = useQuery({ + queryKey: ["template", templateName], + queryFn: () => fetchTemplate(orgId, templateName), + }) const dashboard = useDashboard() - if (templateData.error) { + if (error) { return (
- +
) } - if (templateData.isLoading || !templateData.data) { + if (isLoading || !data) { return } return ( <> { navigate("/templates") @@ -104,7 +100,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ combineClasses([ styles.tabItem, @@ -115,18 +111,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ Summary - combineClasses([ - styles.tabItem, - isActive ? styles.tabItemActive : undefined, - ]) - } - > - Permissions - - combineClasses([ styles.tabItem, @@ -141,7 +126,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ - + }>{children} diff --git a/site/src/components/TemplateLayout/TemplatePageHeader.tsx b/site/src/components/TemplateLayout/TemplatePageHeader.tsx index 503c09e944d51..11fc83223a980 100644 --- a/site/src/components/TemplateLayout/TemplatePageHeader.tsx +++ b/site/src/components/TemplateLayout/TemplatePageHeader.tsx @@ -67,13 +67,6 @@ const TemplateMenu: FC<{ > {Language.settingsButton} - - {Language.variablesButton} - {canEditFiles && ( +> = ({ 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<{ template: Template }> = ({ template }) => { + 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), + }, + templateInfo: { + marginBottom: theme.spacing(2), + }, + templateData: { + 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/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx similarity index 61% rename from site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx rename to site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 475a99d148344..f0172ec837ffd 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -11,7 +11,6 @@ import { import * as Yup from "yup" import i18next from "i18next" import { useTranslation } from "react-i18next" -import { Maybe } from "components/Conditionals/Maybe" import { LazyIconField } from "components/IconField/LazyIconField" import { FormFields, @@ -23,28 +22,8 @@ import { Stack } from "components/Stack/Stack" import Checkbox from "@material-ui/core/Checkbox" import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { makeStyles } from "@material-ui/core/styles" -import Link from "@material-ui/core/Link" - -const TTLHelperText = ({ - ttl, - translationName, -}: { - ttl?: number - translationName: string -}) => { - const { t } = useTranslation("templateSettingsPage") - const count = typeof ttl !== "number" ? 0 : ttl - return ( - // no helper text if ttl is negative - error will show once field is considered touched - = 0}> - {t(translationName, { count })} - - ) -} const MAX_DESCRIPTION_CHAR_LIMIT = 128 -const MAX_TTL_DAYS = 7 -const MS_HOUR_CONVERSION = 3600000 export const getValidationSchema = (): Yup.AnyObjectSchema => Yup.object({ @@ -58,20 +37,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => MAX_DESCRIPTION_CHAR_LIMIT, i18next.t("descriptionMaxError", { ns: "templateSettingsPage" }), ), - default_ttl_ms: Yup.number() - .integer() - .min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" })) - .max( - 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }), - ), - max_ttl_ms: Yup.number() - .integer() - .min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" })) - .max( - 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }), - ), + allow_user_cancel_workspace_jobs: Yup.boolean(), }) @@ -81,7 +47,6 @@ export interface TemplateSettingsForm { onCancel: () => void isSubmitting: boolean error?: unknown - canSetMaxTTL: boolean // Helpful to show field errors on Storybook initialTouched?: FormikTouched } @@ -91,11 +56,9 @@ export const TemplateSettingsForm: FC = ({ onSubmit, onCancel, error, - canSetMaxTTL, isSubmitting, initialTouched, }) => { - const { t: commonT } = useTranslation("common") const validationSchema = getValidationSchema() const form: FormikContextType = useFormik({ @@ -103,28 +66,12 @@ export const TemplateSettingsForm: FC = ({ name: template.name, display_name: template.display_name, description: template.description, - // on display, convert from ms => hours - default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, - // the API ignores this value, but to avoid tripping up validation set - // it to zero if the user can't set the field. - max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0, icon: template.icon, allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, }, validationSchema, - onSubmit: (formData) => { - // on submit, convert from hours => ms - onSubmit({ - ...formData, - default_ttl_ms: formData.default_ttl_ms - ? formData.default_ttl_ms * MS_HOUR_CONVERSION - : undefined, - max_ttl_ms: formData.max_ttl_ms - ? formData.max_ttl_ms * MS_HOUR_CONVERSION - : undefined, - }) - }, + onSubmit, initialTouched, }) const getFieldHelpers = getFormHelpers(form, error) @@ -188,55 +135,6 @@ export const TemplateSettingsForm: FC = ({ - - - , - )} - disabled={isSubmitting} - fullWidth - inputProps={{ min: 0, step: 1 }} - label={t("defaultTtlLabel")} - variant="outlined" - type="number" - /> - - - ) : ( - <> - {commonT("licenseFieldTextHelper")}{" "} - - {commonT("learnMore")} - - . - - ), - )} - disabled={isSubmitting || !canSetMaxTTL} - fullWidth - inputProps={{ min: 0, step: 1 }} - label={t("maxTtlLabel")} - variant="outlined" - type="number" - /> - - - ({ fontSize: theme.spacing(1.5), color: theme.palette.text.secondary, }, - - ttlFields: { - width: "100%", - }, })) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx similarity index 52% rename from site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx rename to site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 52b601a7c725e..e9b36e8e6b9cc 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -3,8 +3,11 @@ import userEvent from "@testing-library/user-event" import * as API from "api/api" import { UpdateTemplateMeta } from "api/typesGenerated" import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" -import { MockTemplate } from "../../testHelpers/entities" -import { renderWithAuth } from "../../testHelpers/renderHelpers" +import { MockTemplate } from "../../../testHelpers/entities" +import { + renderWithTemplateSettingsLayout, + waitForLoaderToBeRemoved, +} from "../../../testHelpers/renderHelpers" import { getValidationSchema } from "./TemplateSettingsForm" import { TemplateSettingsPage } from "./TemplateSettingsPage" import i18next from "i18next" @@ -16,32 +19,24 @@ const validFormValues = { display_name: "A display name", description: "A description", icon: "vscode.png", - // these are the form values which are actually hours - default_ttl_ms: 1, - max_ttl_ms: 2, allow_user_cancel_workspace_jobs: false, } const renderTemplateSettingsPage = async () => { - renderWithAuth(, { + renderWithTemplateSettingsLayout(, { route: `/templates/${MockTemplate.name}/settings`, path: `/templates/:template/settings`, - extraRoutes: [{ path: "templates/:template", element: <> }], }) - // Wait the form to be rendered - const label = t("nameLabel", { ns: "templateSettingsPage" }) - await screen.findAllByLabelText(label) + await waitForLoaderToBeRemoved() } const fillAndSubmitForm = async ({ name, display_name, description, - default_ttl_ms, - max_ttl_ms, icon, allow_user_cancel_workspace_jobs, -}: Required) => { +}: Required>) => { const label = t("nameLabel", { ns: "templateSettingsPage" }) const nameField = await screen.findByLabelText(label) await userEvent.clear(nameField) @@ -63,19 +58,6 @@ const fillAndSubmitForm = async ({ await userEvent.clear(iconField) await userEvent.type(iconField, icon) - const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) - const defaultTtlField = await screen.findByLabelText(defaultTtlLabel) - await userEvent.clear(defaultTtlField) - await userEvent.type(defaultTtlField, default_ttl_ms.toString()) - - const entitlements = await API.getEntitlements() - if (entitlements.features["advanced_template_scheduling"].enabled) { - const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" }) - const maxTtlField = await screen.findByLabelText(maxTtlLabel) - await userEvent.clear(maxTtlField) - await userEvent.type(maxTtlField, max_ttl_ms.toString()) - } - const allowCancelJobsField = screen.getByRole("checkbox") // checkbox is checked by default, so it must be clicked to get unchecked if (!allow_user_cancel_workspace_jobs) { @@ -89,79 +71,14 @@ const fillAndSubmitForm = async ({ } describe("TemplateSettingsPage", () => { - it("renders", async () => { - const { t } = i18next - const pageTitle = t("title", { - ns: "templateSettingsPage", - }) - await renderTemplateSettingsPage() - const element = await screen.findByText(pageTitle) - expect(element).toBeDefined() - }) - it("succeeds", async () => { await renderTemplateSettingsPage() - - jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ - ...MockTemplate, - ...validFormValues, - }) - await fillAndSubmitForm(validFormValues) - - await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) - }) - - test("ttl is converted to and from hours", async () => { - await renderTemplateSettingsPage() - jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ ...MockTemplate, ...validFormValues, }) - await fillAndSubmitForm(validFormValues) await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) - await waitFor(() => - expect(API.updateTemplateMeta).toBeCalledWith( - "test-template", - expect.objectContaining({ - ...validFormValues, - // convert from the display value (hours) to ms - default_ttl_ms: validFormValues.default_ttl_ms * 3600000, - // this value is undefined if not entitled - max_ttl_ms: undefined, - }), - ), - ) - }) - - it("allows a ttl of 7 days", () => { - const values: UpdateTemplateMeta = { - ...validFormValues, - default_ttl_ms: 24 * 7, - } - const validate = () => getValidationSchema().validateSync(values) - expect(validate).not.toThrowError() - }) - - it("allows ttl of 0", () => { - const values: UpdateTemplateMeta = { - ...validFormValues, - default_ttl_ms: 0, - } - const validate = () => getValidationSchema().validateSync(values) - expect(validate).not.toThrowError() - }) - - it("disallows a ttl of 7 days + 1 hour", () => { - const values: UpdateTemplateMeta = { - ...validFormValues, - default_ttl_ms: 24 * 7 + 1, - } - const validate = () => getValidationSchema().validateSync(values) - expect(validate).toThrowError( - t("defaultTTLMaxError", { ns: "templateSettingsPage" }), - ) }) it("allows a description of 128 chars", () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx similarity index 50% rename from site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx rename to site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx index 9dca8255017e4..bc36a6df3643d 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx @@ -1,57 +1,55 @@ -import { useMachine } from "@xstate/react" +import { useMutation } from "@tanstack/react-query" +import { updateTemplateMeta } from "api/api" +import { UpdateTemplateMeta } from "api/typesGenerated" import { useDashboard } from "components/Dashboard/DashboardProvider" -import { useOrganizationId } from "hooks/useOrganizationId" +import { displaySuccess } from "components/GlobalSnackbar/utils" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useTranslation } from "react-i18next" import { useNavigate, useParams } from "react-router-dom" import { pageTitle } from "util/page" -import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService" +import { useTemplateSettingsContext } from "../TemplateSettingsLayout" import { TemplateSettingsPageView } from "./TemplateSettingsPageView" export const TemplateSettingsPage: FC = () => { const { template: templateName } = useParams() as { template: string } const { t } = useTranslation("templateSettingsPage") const navigate = useNavigate() - const organizationId = useOrganizationId() - const [state, send] = useMachine(templateSettingsMachine, { - context: { templateName, organizationId }, - actions: { - onSave: (_, { data }) => { - // Use the data.name because the template name can be changed. Since the - // API can return 304 if the template name is not changed, we use the - // templateName from the URL as default. - navigate(`/templates/${data.name ?? templateName}`) - }, - }, - }) - const { - templateSettings: template, - saveTemplateSettingsError, - getTemplateError, - } = state.context + const { template } = useTemplateSettingsContext() const { entitlements } = useDashboard() const canSetMaxTTL = entitlements.features["advanced_template_scheduling"].enabled + const { + mutate: updateTemplate, + isLoading: isSubmitting, + error: submitError, + } = useMutation( + (data: UpdateTemplateMeta) => updateTemplateMeta(template.id, data), + { + onSuccess: async () => { + displaySuccess("Template updated successfully") + }, + }, + ) return ( <> - Codestin Search App + Codestin Search App { navigate(`/templates/${templateName}`) }} onSubmit={(templateSettings) => { - send({ type: "SAVE", templateSettings }) + updateTemplate({ + ...template, + ...templateSettings, + }) }} /> diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx new file mode 100644 index 0000000000000..2d16618c03dfe --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx @@ -0,0 +1,41 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import * as Mocks from "../../../testHelpers/renderHelpers" +import { makeMockApiError } from "../../../testHelpers/renderHelpers" +import { + TemplateSettingsPageView, + TemplateSettingsPageViewProps, +} from "./TemplateSettingsPageView" + +export default { + title: "pages/TemplateSettingsPageView", + component: TemplateSettingsPageView, + args: { + template: Mocks.MockTemplate, + onSubmit: action("onSubmit"), + onCancel: action("cancel"), + }, +} + +const Template: Story = (args) => ( + +) + +export const Example = Template.bind({}) +Example.args = {} + +export const SaveTemplateSettingsError = Template.bind({}) +SaveTemplateSettingsError.args = { + submitError: makeMockApiError({ + message: 'Template "test" already exists.', + validations: [ + { + field: "name", + detail: "This value is already in use and should be unique.", + }, + ], + }), + initialTouched: { + allow_user_cancel_workspace_jobs: true, + }, +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx new file mode 100644 index 0000000000000..e35cb6aa568b8 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx @@ -0,0 +1,50 @@ +import { Template, UpdateTemplateMeta } from "api/typesGenerated" +import { ComponentProps, FC } from "react" +import { TemplateSettingsForm } from "./TemplateSettingsForm" +import { useTranslation } from "react-i18next" +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" +import { makeStyles } from "@material-ui/core/styles" + +export interface TemplateSettingsPageViewProps { + template: Template + onSubmit: (data: UpdateTemplateMeta) => void + onCancel: () => void + isSubmitting: boolean + submitError?: unknown + initialTouched?: ComponentProps["initialTouched"] +} + +export const TemplateSettingsPageView: FC = ({ + template, + onCancel, + onSubmit, + isSubmitting, + submitError, + initialTouched, +}) => { + const { t } = useTranslation("templateSettingsPage") + const styles = useStyles() + + return ( + <> + + {t("title")} + + + + + ) +} + +const useStyles = makeStyles(() => ({ + pageHeader: { + paddingTop: 0, + }, +})) diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx similarity index 94% rename from site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx rename to site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 56b4e36793e75..8208f792df8ef 100644 --- a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -5,20 +5,20 @@ import { useMachine } from "@xstate/react" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { Paywall } from "components/Paywall/Paywall" import { Stack } from "components/Stack/Stack" -import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout" import { useFeatureVisibility } from "hooks/useFeatureVisibility" import { useOrganizationId } from "hooks/useOrganizationId" import { FC } from "react" import { Helmet } from "react-helmet-async" import { pageTitle } from "util/page" import { templateACLMachine } from "xServices/template/templateACLXService" +import { useTemplateSettingsContext } from "../TemplateSettingsLayout" import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView" export const TemplatePermissionsPage: FC< React.PropsWithChildren > = () => { const organizationId = useOrganizationId() - const { template, permissions } = useTemplateLayoutContext() + const { template, permissions } = useTemplateSettingsContext() const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility() const [state, send] = useMachine(templateACLMachine, { context: { templateId: template.id }, @@ -28,7 +28,7 @@ export const TemplatePermissionsPage: FC< return ( <> - Codestin Search App + Codestin Search App diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx similarity index 100% rename from site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx rename to site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx similarity index 100% rename from site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx rename to site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx new file mode 100644 index 0000000000000..a8a44f5aa91e8 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -0,0 +1,165 @@ +import TextField from "@material-ui/core/TextField" +import { Template, UpdateTemplateMeta } from "api/typesGenerated" +import { FormikContextType, FormikTouched, useFormik } from "formik" +import { FC } from "react" +import { getFormHelpers } from "util/formUtils" +import * as Yup from "yup" +import i18next from "i18next" +import { useTranslation } from "react-i18next" +import { Maybe } from "components/Conditionals/Maybe" +import { FormSection, HorizontalForm, FormFooter } from "components/Form/Form" +import { Stack } from "components/Stack/Stack" +import { makeStyles } from "@material-ui/core/styles" +import Link from "@material-ui/core/Link" + +const TTLHelperText = ({ + ttl, + translationName, +}: { + ttl?: number + translationName: string +}) => { + const { t } = useTranslation("templateSettingsPage") + const count = typeof ttl !== "number" ? 0 : ttl + return ( + // no helper text if ttl is negative - error will show once field is considered touched + = 0}> + {t(translationName, { count })} + + ) +} + +const MAX_TTL_DAYS = 7 +const MS_HOUR_CONVERSION = 3600000 + +export const getValidationSchema = (): Yup.AnyObjectSchema => + Yup.object({ + default_ttl_ms: Yup.number() + .integer() + .min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }), + ), + max_ttl_ms: Yup.number() + .integer() + .min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }), + ), + }) + +export interface TemplateScheduleForm { + template: Template + onSubmit: (data: UpdateTemplateMeta) => void + onCancel: () => void + isSubmitting: boolean + error?: unknown + canSetMaxTTL: boolean + // Helpful to show field errors on Storybook + initialTouched?: FormikTouched +} + +export const TemplateScheduleForm: FC = ({ + template, + onSubmit, + onCancel, + error, + canSetMaxTTL, + isSubmitting, + initialTouched, +}) => { + const { t: commonT } = useTranslation("common") + const validationSchema = getValidationSchema() + const form: FormikContextType = + useFormik({ + initialValues: { + // on display, convert from ms => hours + default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, + // the API ignores this value, but to avoid tripping up validation set + // it to zero if the user can't set the field. + max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0, + }, + validationSchema, + onSubmit: (formData) => { + // on submit, convert from hours => ms + onSubmit({ + default_ttl_ms: formData.default_ttl_ms + ? formData.default_ttl_ms * MS_HOUR_CONVERSION + : undefined, + max_ttl_ms: formData.max_ttl_ms + ? formData.max_ttl_ms * MS_HOUR_CONVERSION + : undefined, + }) + }, + initialTouched, + }) + const getFieldHelpers = getFormHelpers(form, error) + const { t } = useTranslation("templateSettingsPage") + const styles = useStyles() + + return ( + + + + , + )} + disabled={isSubmitting} + fullWidth + inputProps={{ min: 0, step: 1 }} + label={t("defaultTtlLabel")} + variant="outlined" + type="number" + /> + + + ) : ( + <> + {commonT("licenseFieldTextHelper")}{" "} + + {commonT("learnMore")} + + . + + ), + )} + disabled={isSubmitting || !canSetMaxTTL} + fullWidth + inputProps={{ min: 0, step: 1 }} + label={t("maxTtlLabel")} + variant="outlined" + type="number" + /> + + + + + + ) +} + +const useStyles = makeStyles(() => ({ + ttlFields: { + width: "100%", + }, +})) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx new file mode 100644 index 0000000000000..b902002a159da --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -0,0 +1,123 @@ +import { screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import * as API from "api/api" +import { UpdateTemplateMeta } from "api/typesGenerated" +import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" +import { + MockEntitlementsWithScheduling, + MockTemplate, +} from "../../../testHelpers/entities" +import { + renderWithTemplateSettingsLayout, + waitForLoaderToBeRemoved, +} from "../../../testHelpers/renderHelpers" +import { getValidationSchema } from "./TemplateScheduleForm" +import TemplateSchedulePage from "./TemplateSchedulePage" +import i18next from "i18next" + +const { t } = i18next + +const validFormValues = { + default_ttl_ms: 1, + max_ttl_ms: 2, +} + +const renderTemplateSchedulePage = async () => { + renderWithTemplateSettingsLayout(, { + route: `/templates/${MockTemplate.name}/settings/schedule`, + path: `/templates/:template/settings/schedule`, + }) + await waitForLoaderToBeRemoved() +} + +const fillAndSubmitForm = async ({ + default_ttl_ms, + max_ttl_ms, +}: { + default_ttl_ms: number + max_ttl_ms: number +}) => { + const user = userEvent.setup() + const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) + const defaultTtlField = await screen.findByLabelText(defaultTtlLabel) + await user.clear(defaultTtlField) + await user.type(defaultTtlField, default_ttl_ms.toString()) + + const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" }) + const maxTtlField = await screen.findByLabelText(maxTtlLabel) + await user.clear(maxTtlField) + await user.type(maxTtlField, max_ttl_ms.toString()) + + const submitButton = await screen.findByText( + FooterFormLanguage.defaultSubmitLabel, + ) + await user.click(submitButton) +} + +describe("TemplateSchedulePage", () => { + beforeEach(() => { + jest + .spyOn(API, "getEntitlements") + .mockResolvedValue(MockEntitlementsWithScheduling) + }) + + it("succeeds", async () => { + await renderTemplateSchedulePage() + jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ + ...MockTemplate, + ...validFormValues, + }) + await fillAndSubmitForm(validFormValues) + await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) + }) + + test("ttl is converted to and from hours", async () => { + await renderTemplateSchedulePage() + + jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ + ...MockTemplate, + ...validFormValues, + }) + + await fillAndSubmitForm(validFormValues) + await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) + await waitFor(() => + expect(API.updateTemplateMeta).toBeCalledWith( + "test-template", + expect.objectContaining({ + default_ttl_ms: validFormValues.default_ttl_ms * 3600000, + max_ttl_ms: validFormValues.max_ttl_ms * 3600000, + }), + ), + ) + }) + + it("allows a ttl of 7 days", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + default_ttl_ms: 24 * 7, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("allows ttl of 0", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + default_ttl_ms: 0, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("disallows a ttl of 7 days + 1 hour", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + default_ttl_ms: 24 * 7 + 1, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).toThrowError( + t("defaultTTLMaxError", { ns: "templateSettingsPage" }), + ) + }) +}) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx new file mode 100644 index 0000000000000..c9e1e33c5b3b5 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx @@ -0,0 +1,57 @@ +import { useMutation } from "@tanstack/react-query" +import { updateTemplateMeta } from "api/api" +import { UpdateTemplateMeta } from "api/typesGenerated" +import { useDashboard } from "components/Dashboard/DashboardProvider" +import { displaySuccess } from "components/GlobalSnackbar/utils" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useNavigate, useParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { useTemplateSettingsContext } from "../TemplateSettingsLayout" +import { TemplateSchedulePageView } from "./TemplateSchedulePageView" + +const TemplateSchedulePage: FC = () => { + const { template: templateName } = useParams() as { template: string } + const navigate = useNavigate() + const { template } = useTemplateSettingsContext() + const { entitlements } = useDashboard() + const canSetMaxTTL = + entitlements.features["advanced_template_scheduling"].enabled + const { + mutate: updateTemplate, + isLoading: isSubmitting, + error: submitError, + } = useMutation( + (data: UpdateTemplateMeta) => updateTemplateMeta(template.id, data), + { + onSuccess: () => { + displaySuccess("Template updated successfully") + }, + }, + ) + + return ( + <> + + Codestin Search App + + { + navigate(`/templates/${templateName}`) + }} + onSubmit={(templateScheduleSettings) => { + updateTemplate({ + ...template, + ...templateScheduleSettings, + }) + }} + /> + + ) +} + +export default TemplateSchedulePage diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx new file mode 100644 index 0000000000000..a9f5b94239bfc --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx @@ -0,0 +1,30 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import * as Mocks from "../../../testHelpers/renderHelpers" +import { + TemplateSchedulePageView, + TemplateSchedulePageViewProps, +} from "./TemplateSchedulePageView" + +export default { + title: "pages/TemplateSchedulePageView", + component: TemplateSchedulePageView, + args: { + canSetMaxTTL: true, + template: Mocks.MockTemplate, + onSubmit: action("onSubmit"), + onCancel: action("cancel"), + }, +} + +const Template: Story = (args) => ( + +) + +export const Example = Template.bind({}) +Example.args = {} + +export const CantSetMaxTTL = Template.bind({}) +CantSetMaxTTL.args = { + canSetMaxTTL: false, +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx new file mode 100644 index 0000000000000..cef33c37e275e --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx @@ -0,0 +1,51 @@ +import { Template, UpdateTemplateMeta } from "api/typesGenerated" +import { ComponentProps, FC } from "react" +import { TemplateScheduleForm } from "./TemplateScheduleForm" +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" +import { makeStyles } from "@material-ui/core/styles" + +export interface TemplateSchedulePageViewProps { + template: Template + onSubmit: (data: UpdateTemplateMeta) => void + onCancel: () => void + isSubmitting: boolean + submitError?: unknown + initialTouched?: ComponentProps["initialTouched"] + canSetMaxTTL: boolean +} + +export const TemplateSchedulePageView: FC = ({ + template, + onCancel, + onSubmit, + isSubmitting, + canSetMaxTTL, + submitError, + initialTouched, +}) => { + const styles = useStyles() + + return ( + <> + + Template schedule + + + + + ) +} + +const useStyles = makeStyles(() => ({ + pageHeader: { + paddingTop: 0, + }, +})) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx new file mode 100644 index 0000000000000..dfb5a9046a178 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx @@ -0,0 +1,99 @@ +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 { checkAuthorization, getTemplateByName } from "api/api" +import { useQuery } from "@tanstack/react-query" +import { useOrganizationId } from "hooks/useOrganizationId" + +const templatePermissions = (templateId: string) => ({ + canUpdateTemplate: { + object: { + resource_type: "template", + resource_id: templateId, + }, + action: "update", + }, +}) + +const fetchTemplateSettings = async (orgId: string, name: string) => { + const template = await getTemplateByName(orgId, name) + const permissions = await checkAuthorization({ + checks: templatePermissions(template.id), + }) + + return { + template, + permissions, + } +} + +const useTemplate = (orgId: string, name: string) => { + return useQuery({ + queryKey: ["template", name, "settings"], + queryFn: () => fetchTemplateSettings(orgId, name), + }) +} + +const TemplateSettingsContext = createContext< + Awaited> | undefined +>(undefined) + +export const useTemplateSettingsContext = () => { + const context = useContext(TemplateSettingsContext) + + if (!context) { + throw new Error( + "useTemplateSettingsContext must be used within a TemplateSettingsContext.Provider", + ) + } + + return context +} + +export const TemplateSettingsLayout: FC = () => { + const styles = useStyles() + const orgId = useOrganizationId() + const { template: templateName } = useParams() as { template: string } + const { data: settings } = useTemplate(orgId, templateName) + + return ( + <> + + Codestin Search App + + + {settings ? ( + + + + + }> +
+ +
+
+
+
+
+ ) : ( + + )} + + ) +} + +const useStyles = makeStyles((theme) => ({ + wrapper: { + padding: theme.spacing(6, 0), + }, + + content: { + width: "100%", + }, +})) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx deleted file mode 100644 index fb9a315f50846..0000000000000 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { action } from "@storybook/addon-actions" -import { Story } from "@storybook/react" -import * as Mocks from "../../testHelpers/renderHelpers" -import { makeMockApiError } from "../../testHelpers/renderHelpers" -import { - TemplateSettingsPageView, - TemplateSettingsPageViewProps, -} from "./TemplateSettingsPageView" - -export default { - title: "pages/TemplateSettingsPageView", - component: TemplateSettingsPageView, - args: { - canSetMaxTTL: true, - template: Mocks.MockTemplate, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), - }, -} - -const Template: Story = (args) => ( - -) - -export const Example = Template.bind({}) -Example.args = {} - -export const CantSetMaxTTL = Template.bind({}) -CantSetMaxTTL.args = { - canSetMaxTTL: false, -} - -export const GetTemplateError = Template.bind({}) -GetTemplateError.args = { - template: undefined, - errors: { - getTemplateError: makeMockApiError({ - message: "Failed to fetch the template.", - detail: "You do not have permission to access this resource.", - }), - }, -} - -export const SaveTemplateSettingsError = Template.bind({}) -SaveTemplateSettingsError.args = { - errors: { - saveTemplateSettingsError: makeMockApiError({ - message: 'Template "test" already exists.', - validations: [ - { - field: "name", - detail: "This value is already in use and should be unique.", - }, - ], - }), - }, - initialTouched: { - allow_user_cancel_workspace_jobs: true, - }, -} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx deleted file mode 100644 index 14eec2798bb95..0000000000000 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Template, UpdateTemplateMeta } from "api/typesGenerated" -import { AlertBanner } from "components/AlertBanner/AlertBanner" -import { Loader } from "components/Loader/Loader" -import { ComponentProps, FC } from "react" -import { TemplateSettingsForm } from "./TemplateSettingsForm" -import { Stack } from "components/Stack/Stack" -import { makeStyles } from "@material-ui/core/styles" -import { useTranslation } from "react-i18next" -import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" - -export interface TemplateSettingsPageViewProps { - template?: Template - onSubmit: (data: UpdateTemplateMeta) => void - onCancel: () => void - isSubmitting: boolean - errors?: { - getTemplateError?: unknown - saveTemplateSettingsError?: unknown - } - initialTouched?: ComponentProps["initialTouched"] - canSetMaxTTL: boolean -} - -export const TemplateSettingsPageView: FC = ({ - template, - onCancel, - onSubmit, - isSubmitting, - canSetMaxTTL, - errors = {}, - initialTouched, -}) => { - const classes = useStyles() - const isLoading = !template && !errors.getTemplateError - const { t } = useTranslation("templateSettingsPage") - - return ( - - {Boolean(errors.getTemplateError) && ( - - - - )} - {isLoading && } - {template && ( - <> - - - )} - - ) -} - -const useStyles = makeStyles((theme) => ({ - errorContainer: { - marginBottom: theme.spacing(2), - }, -})) diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesForm.tsx similarity index 100% rename from site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx rename to site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesForm.tsx diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.test.tsx similarity index 80% rename from site/src/pages/TemplateVariablesPage/TemplateVariablesPage.test.tsx rename to site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.test.tsx index 135f980fd1238..1bc3f1b32998c 100644 --- a/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from "@testing-library/react" +import { screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { MockTemplate, @@ -6,16 +6,14 @@ import { MockTemplateVersion, MockTemplateVersionVariable1, MockTemplateVersionVariable2, - renderWithAuth, MockTemplateVersionVariable5, + renderWithTemplateSettingsLayout, + waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers" import * as API from "api/api" import i18next from "i18next" import TemplateVariablesPage from "./TemplateVariablesPage" import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" -import * as router from "react-router" - -const navigate = jest.fn() const { t } = i18next @@ -30,12 +28,13 @@ const validationRequiredField = t("validationRequiredVariable", { ns: "templateVariablesPage", }) -const renderTemplateVariablesPage = () => { - return renderWithAuth(, { +const renderTemplateVariablesPage = async () => { + renderWithTemplateSettingsLayout(, { route: `/templates/${MockTemplate.name}/variables`, path: `/templates/:template/variables`, extraRoutes: [{ path: `/templates/${MockTemplate.name}`, element: <> }], }) + await waitForLoaderToBeRemoved() } describe("TemplateVariablesPage", () => { @@ -51,7 +50,7 @@ describe("TemplateVariablesPage", () => { MockTemplateVersionVariable2, ]) - renderTemplateVariablesPage() + await renderTemplateVariablesPage() const element = await screen.findByText(pageTitleText) expect(element).toBeDefined() @@ -84,9 +83,8 @@ describe("TemplateVariablesPage", () => { jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({ message: "done", }) - jest.spyOn(router, "useNavigate").mockImplementation(() => navigate) - renderTemplateVariablesPage() + await renderTemplateVariablesPage() const element = await screen.findByText(pageTitleText) expect(element).toBeDefined() @@ -120,10 +118,8 @@ describe("TemplateVariablesPage", () => { ) await userEvent.click(submitButton) - // Wait for redirect - await waitFor(() => - expect(navigate).toHaveBeenCalledWith(`/templates/${MockTemplate.name}`), - ) + // Wait for the success message + await screen.findByText("Template updated successfully") }) it("user forgets to fill the required field", async () => { @@ -143,9 +139,8 @@ describe("TemplateVariablesPage", () => { jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({ message: "done", }) - jest.spyOn(router, "useNavigate").mockImplementation(() => navigate) - renderTemplateVariablesPage() + await renderTemplateVariablesPage() const element = await screen.findByText(pageTitleText) expect(element).toBeDefined() @@ -170,20 +165,4 @@ describe("TemplateVariablesPage", () => { const validationError = await screen.findByText(validationRequiredField) expect(validationError).toBeDefined() }) - - it("no managed variables", async () => { - jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate) - jest - .spyOn(API, "getTemplateVersion") - .mockResolvedValueOnce(MockTemplateVersion) - jest.spyOn(API, "getTemplateVersionVariables").mockResolvedValueOnce([]) - - renderTemplateVariablesPage() - - const element = await screen.findByText(pageTitleText) - expect(element).toBeDefined() - - const goBackButton = await screen.findByText("Go back") - expect(goBackButton).toBeDefined() - }) }) diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx similarity index 87% rename from site/src/pages/TemplateVariablesPage/TemplateVariablesPage.tsx rename to site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx index 93c4379571e60..f35a9c800ce0f 100644 --- a/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx @@ -4,13 +4,15 @@ import { TemplateVersionVariable, VariableValue, } from "api/typesGenerated" +import { displaySuccess } from "components/GlobalSnackbar/utils" import { useOrganizationId } from "hooks/useOrganizationId" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useTranslation } from "react-i18next" import { useNavigate, useParams } from "react-router-dom" import { templateVariablesMachine } from "xServices/template/templateVariablesXService" -import { pageTitle } from "../../util/page" +import { pageTitle } from "../../../util/page" +import { useTemplateSettingsContext } from "../TemplateSettingsLayout" import { TemplateVariablesPageView } from "./TemplateVariablesPageView" export const TemplateVariablesPage: FC = () => { @@ -19,15 +21,16 @@ export const TemplateVariablesPage: FC = () => { template: string } const organizationId = useOrganizationId() + const { template } = useTemplateSettingsContext() const navigate = useNavigate() const [state, send] = useMachine(templateVariablesMachine, { context: { organizationId, - templateName, + template, }, actions: { onUpdateTemplate: () => { - navigate(`/templates/${templateName}`) + displaySuccess("Template updated successfully") }, }, }) @@ -43,7 +46,7 @@ export const TemplateVariablesPage: FC = () => { return ( <> - Codestin Search App + Codestin Search App void + onCancel: () => void + isSubmitting: boolean + errors?: { + getTemplateDataError?: unknown + updateTemplateError?: unknown + jobError?: TemplateVersion["job"]["error"] + } + initialTouched?: ComponentProps< + typeof TemplateVariablesForm + >["initialTouched"] +} + +export const TemplateVariablesPageView: FC = ({ + templateVersion, + templateVariables, + onCancel, + onSubmit, + isSubmitting, + errors = {}, + initialTouched, +}) => { + const classes = useStyles() + const isLoading = + !templateVersion && + !templateVariables && + !errors.getTemplateDataError && + !errors.updateTemplateError + const { t } = useTranslation("templateVariablesPage") + + return ( + <> + + {t("title")} + + {Boolean(errors.getTemplateDataError) && ( + + + + )} + {Boolean(errors.updateTemplateError) && ( + + + + )} + {Boolean(errors.jobError) && ( + + + + )} + {isLoading && } + {templateVersion && templateVariables && templateVariables.length > 0 && ( + + )} + {templateVariables && templateVariables.length === 0 && ( + + )} + + ) +} + +const useStyles = makeStyles((theme) => ({ + errorContainer: { + marginBottom: theme.spacing(8), + }, + goBackSection: { + display: "flex", + width: "100%", + marginTop: 32, + }, + pageHeader: { + paddingTop: 0, + }, +})) diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.tsx b/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.tsx deleted file mode 100644 index d59675f5ac903..0000000000000 --- a/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { - CreateTemplateVersionRequest, - TemplateVersion, - TemplateVersionVariable, -} from "api/typesGenerated" -import { AlertBanner } from "components/AlertBanner/AlertBanner" -import { Loader } from "components/Loader/Loader" -import { ComponentProps, FC } from "react" -import { TemplateVariablesForm } from "./TemplateVariablesForm" -import { Stack } from "components/Stack/Stack" -import { makeStyles } from "@material-ui/core/styles" -import { useTranslation } from "react-i18next" -import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" -import { GoBackButton } from "components/GoBackButton/GoBackButton" - -export interface TemplateVariablesPageViewProps { - templateVersion?: TemplateVersion - templateVariables?: TemplateVersionVariable[] - onSubmit: (data: CreateTemplateVersionRequest) => void - onCancel: () => void - isSubmitting: boolean - errors?: { - getTemplateDataError?: unknown - updateTemplateError?: unknown - jobError?: TemplateVersion["job"]["error"] - } - initialTouched?: ComponentProps< - typeof TemplateVariablesForm - >["initialTouched"] -} - -export const TemplateVariablesPageView: FC = ({ - templateVersion, - templateVariables, - onCancel, - onSubmit, - isSubmitting, - errors = {}, - initialTouched, -}) => { - const classes = useStyles() - const isLoading = - !templateVersion && - !templateVariables && - !errors.getTemplateDataError && - !errors.updateTemplateError - const { t } = useTranslation("templateVariablesPage") - - return ( - <> - - {Boolean(errors.getTemplateDataError) && ( - - - - )} - {Boolean(errors.updateTemplateError) && ( - - - - )} - {Boolean(errors.jobError) && ( - - - - )} - {isLoading && } - {templateVersion && - templateVariables && - templateVariables.length > 0 && ( - - )} - {templateVariables && templateVariables.length === 0 && ( -
- -
- -
-
- )} -
- - ) -} - -const useStyles = makeStyles((theme) => ({ - errorContainer: { - marginBottom: theme.spacing(8), - }, - goBackSection: { - display: "flex", - width: "100%", - marginTop: 32, - }, -})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 478e841ddd29b..d462a33bd102d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -657,9 +657,10 @@ export const MockWorkspace: TypesGen.Workspace = { owner_id: MockUser.id, owner_name: MockUser.username, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds + ttl_ms: 2 * 60 * 60 * 1000, latest_build: MockWorkspaceBuild, last_used_at: "", + organization_id: MockOrganization.id, } export const MockStoppedWorkspace: TypesGen.Workspace = { @@ -1270,6 +1271,20 @@ export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { }), } +export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: true, + require_telemetry: false, + trial: false, + features: withDefaultFeatures({ + advanced_template_scheduling: { + enabled: true, + entitlement: "entitled", + }, + }), +} + export const MockExperiments: TypesGen.Experiment[] = [] export const MockAuditLog: TypesGen.AuditLog = { diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 033fe0f7bc7ff..52995e3f3cbd9 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -8,6 +8,7 @@ import { AppProviders } from "app" import { DashboardLayout } from "components/Dashboard/DashboardLayout" import { createMemoryHistory } from "history" import { i18n } from "i18n" +import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout" import { FC, ReactElement } from "react" import { I18nextProvider } from "react-i18next" import { @@ -86,6 +87,50 @@ export function renderWithAuth( } } +export function renderWithTemplateSettingsLayout( + 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 => waitForElementToBeRemoved(() => screen.getByTestId("loader")) diff --git a/site/src/theme/overrides.ts b/site/src/theme/overrides.ts index e52681da016bb..e9ded0bf46218 100644 --- a/site/src/theme/overrides.ts +++ b/site/src/theme/overrides.ts @@ -242,5 +242,15 @@ export const getOverrides = ({ minWidth: 120, }, }, + MuiSnackbar: { + anchorOriginBottomRight: { + bottom: `${24 + 36}px !important`, // 36 is the bottom bar height + }, + }, + MuiSnackbarContent: { + root: { + borderRadius: "4px !important", + }, + }, } } diff --git a/site/src/util/page.ts b/site/src/util/page.ts index e7dcac0f78285..96f67e85968f9 100644 --- a/site/src/util/page.ts +++ b/site/src/util/page.ts @@ -1,3 +1,4 @@ -export const pageTitle = (prefix: string): string => { - return `${prefix} – Coder` +export const pageTitle = (prefix: string | string[]): string => { + const title = Array.isArray(prefix) ? prefix.join(" · ") : prefix + return `${title} - Coder` } diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts index 783bacc6c8358..11fb7f2a3008b 100644 --- a/site/src/xServices/entitlements/entitlementsXService.ts +++ b/site/src/xServices/entitlements/entitlementsXService.ts @@ -58,7 +58,7 @@ export const entitlementsMachine = createMachine( }), }, services: { - getEntitlements: API.getEntitlements, + getEntitlements: () => API.getEntitlements(), }, }, ) diff --git a/site/src/xServices/template/templateVariablesXService.ts b/site/src/xServices/template/templateVariablesXService.ts index bdeda05c84f76..361ce23af0704 100644 --- a/site/src/xServices/template/templateVariablesXService.ts +++ b/site/src/xServices/template/templateVariablesXService.ts @@ -1,6 +1,5 @@ import { createTemplateVersion, - getTemplateByName, getTemplateVersion, getTemplateVersionVariables, updateActiveTemplateVersion, @@ -17,9 +16,8 @@ import { Message } from "api/types" type TemplateVariablesContext = { organizationId: string - templateName: string - template?: Template + template: Template activeTemplateVersion?: TemplateVersion templateVariables?: TemplateVersionVariable[] @@ -46,9 +44,6 @@ export const templateVariablesMachine = createMachine( context: {} as TemplateVariablesContext, events: {} as UpdateTemplateEvent, services: {} as { - getTemplate: { - data: Template - } getActiveTemplateVersion: { data: TemplateVersion } @@ -66,24 +61,8 @@ export const templateVariablesMachine = createMachine( } }, }, - initial: "gettingTemplate", + initial: "gettingActiveTemplateVersion", states: { - gettingTemplate: { - entry: "clearGetTemplateDataError", - invoke: { - src: "getTemplate", - onDone: [ - { - actions: ["assignTemplate"], - target: "gettingActiveTemplateVersion", - }, - ], - onError: { - actions: ["assignGetTemplateDataError"], - target: "error", - }, - }, - }, gettingActiveTemplateVersion: { entry: "clearGetTemplateDataError", invoke: { @@ -183,19 +162,10 @@ export const templateVariablesMachine = createMachine( }, { services: { - getTemplate: ({ organizationId, templateName }) => { - return getTemplateByName(organizationId, templateName) - }, getActiveTemplateVersion: ({ template }) => { - if (!template) { - throw new Error("No template selected") - } return getTemplateVersion(template.active_version_id) }, getTemplateVariables: ({ template }) => { - if (!template) { - throw new Error("No template selected") - } return getTemplateVersionVariables(template.active_version_id) }, createNewTemplateVersion: ({ @@ -224,10 +194,6 @@ export const templateVariablesMachine = createMachine( return newTemplateVersion }, updateTemplate: ({ template, newTemplateVersion }) => { - if (!template) { - throw new Error("No template selected") - } - if (!newTemplateVersion) { throw new Error("New template version is undefined") } @@ -238,9 +204,6 @@ export const templateVariablesMachine = createMachine( }, }, actions: { - assignTemplate: assign({ - template: (_, event) => event.data, - }), assignActiveTemplateVersion: assign({ activeTemplateVersion: (_, event) => event.data, }), diff --git a/site/src/xServices/templateSettings/templateSettingsXService.ts b/site/src/xServices/templateSettings/templateSettingsXService.ts deleted file mode 100644 index f8420a9625c71..0000000000000 --- a/site/src/xServices/templateSettings/templateSettingsXService.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { getTemplateByName, updateTemplateMeta, deleteTemplate } from "api/api" -import { Template, UpdateTemplateMeta } from "api/typesGenerated" -import { createMachine } from "xstate" -import { assign } from "xstate/lib/actions" -import { displaySuccess } from "components/GlobalSnackbar/utils" -import { t } from "i18next" - -export const templateSettingsMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QBcwFsAOAbAhqgymMsgJYB2UsAdFgPY4TlQDEEtZYV5AbrQNadUmXASKkK1OgyYIetAMZ4S7ANoAGALqJQGWrBKl22kAA9EANgCMVAKwB2AByWATJbWWAnABYv55w-MAGhAAT0RnAGZnKgibGw9nD3M1JJsHNQiAX0zgoWw8MEJiJmpIAyZmfABBADUAUWNdfUMyYzMESx9bSK8IhwdncwcbF2dgsIRejypzX2dXf09zCLUbbNz0fNFiiSpYHG4Ktg4uMl4BKjyRQrESvYOZOUUW9S0kECbyo3f25KoHOxeDwODx9EYeNTzcYWGxqKgeGw+SJ2cx+VZebI5EBkWgQODGK4FIriSg0eiMCiNPRfVo-RBeMahRAQqhqNnuWERFFeOyedYgQnbEmlRgkqnNZS00DtSwRcz-EY2PzeeYOLk2aEIWJ2WwOIEBNIeBG8-mCm47Un7Q6U96fFptenWRJDIZy+y9AFBJkIEbRbxeWUBRwIyx2U2ba7Eu5WyDimkOhAI2yxSx+foMpWWTV2RLJgIrPoA4bh4RE24SOP2ukdHXOgJq8zuvoozWWBys9nzSydNt2NQYzFAA */ - createMachine( - { - id: "templateSettings", - predictableActionArguments: true, - tsTypes: {} as import("./templateSettingsXService.typegen").Typegen0, - schema: {} as { - context: { - organizationId: string - templateName: string - templateSettings?: Template - getTemplateError?: unknown - saveTemplateSettingsError?: unknown - deleteTemplateError?: Error | unknown - } - services: { - getTemplateSettings: { - data: Template - } - saveTemplateSettings: { - data: Template - } - } - events: - | { type: "SAVE"; templateSettings: UpdateTemplateMeta } - | { type: "DELETE" } - | { type: "CONFIRM_DELETE" } - | { type: "CANCEL_DELETE" } - }, - initial: "loading", - states: { - loading: { - invoke: { - src: "getTemplateSettings", - onDone: [ - { - actions: "assignTemplateSettings", - target: "editing", - }, - ], - onError: { - target: "error", - actions: "assignGetTemplateError", - }, - }, - }, - editing: { - on: { - SAVE: { - target: "saving", - }, - DELETE: { - target: "confirmingDelete", - }, - }, - }, - confirmingDelete: { - on: { - CONFIRM_DELETE: { - target: "deleting", - }, - CANCEL_DELETE: { - target: "editing", - }, - }, - }, - deleting: { - entry: "clearDeleteTemplateError", - invoke: { - src: "deleteTemplate", - id: "deleteTemplate", - onDone: [ - { - target: "deleted", - actions: "displayDeleteSuccess", - }, - ], - onError: [ - { - actions: "assignDeleteTemplateError", - target: "editing", - }, - ], - }, - }, - deleted: { - type: "final", - }, - saving: { - invoke: { - src: "saveTemplateSettings", - onDone: [ - { - target: "saved", - }, - ], - onError: [ - { - target: "editing", - actions: ["assignSaveTemplateSettingsError"], - }, - ], - }, - tags: ["submitting"], - }, - saved: { - entry: "onSave", - type: "final", - tags: ["submitting"], - }, - error: { - type: "final", - }, - }, - }, - { - services: { - getTemplateSettings: async ({ organizationId, templateName }) => { - return getTemplateByName(organizationId, templateName) - }, - saveTemplateSettings: async ( - { templateSettings }, - { templateSettings: newTemplateSettings }, - ) => { - if (!templateSettings) { - throw new Error("templateSettings is not loaded yet.") - } - - return updateTemplateMeta(templateSettings.id, newTemplateSettings) - }, - deleteTemplate: (ctx) => { - if (!ctx.templateSettings) { - throw new Error("Template not loaded") - } - return deleteTemplate(ctx.templateSettings.id) - }, - }, - actions: { - assignTemplateSettings: assign({ - templateSettings: (_, { data }) => data, - }), - assignGetTemplateError: assign({ - getTemplateError: (_, { data }) => data, - }), - assignSaveTemplateSettingsError: assign({ - saveTemplateSettingsError: (_, { data }) => data, - }), - assignDeleteTemplateError: assign({ - deleteTemplateError: (_, event) => event.data, - }), - clearDeleteTemplateError: assign({ - deleteTemplateError: (_) => undefined, - }), - displayDeleteSuccess: () => - displaySuccess(t("deleteSuccess", { ns: "templatePage" })), - }, - }, - )