diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 2a17c9dc9a62b..c7c0e4f814ce8 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -49,6 +49,8 @@ global.TextDecoder = TextDecoder as any; global.Blob = Blob as any; global.scrollTo = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = function () {}; + // Polyfill the getRandomValues that is used on utils/random.ts Object.defineProperty(global.self, "crypto", { value: { diff --git a/site/src/@types/storybook.d.ts b/site/src/@types/storybook.d.ts index 8a5b490987860..8aada49bf9e3f 100644 --- a/site/src/@types/storybook.d.ts +++ b/site/src/@types/storybook.d.ts @@ -7,5 +7,8 @@ declare module "@storybook/react" { features?: FeatureName[]; experiments?: Experiments; queries?: { key: QueryKey; data: unknown }[]; + webSocket?: { + messages: string[]; + }; } } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 886b4d8f4cbc9..4acd2b3f155a9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1543,7 +1543,7 @@ export const watchAgentMetadata = (agentId: string): EventSource => { type WatchBuildLogsByTemplateVersionIdOptions = { after?: number; onMessage: (log: TypesGen.ProvisionerJobLog) => void; - onDone: () => void; + onDone?: () => void; onError: (error: Error) => void; }; export const watchBuildLogsByTemplateVersionId = ( @@ -1575,7 +1575,7 @@ export const watchBuildLogsByTemplateVersionId = ( }); socket.addEventListener("close", () => { // When the socket closes, logs have finished streaming! - onDone(); + onDone?.(); }); return socket; }; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index c0ba39e7cb9e2..532ba0c9f7f22 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -206,16 +206,21 @@ export const createTemplate = () => { }; }; -const createTemplateFn = async (options: { +export type CreateTemplateOptions = { organizationId: string; version: CreateTemplateVersionRequest; template: Omit; -}) => { + onCreateVersion?: (version: TemplateVersion) => void; + onTemplateVersionChanges?: (version: TemplateVersion) => void; +}; + +const createTemplateFn = async (options: CreateTemplateOptions) => { const version = await API.createTemplateVersion( options.organizationId, options.version, ); - await waitBuildToBeFinished(version); + options.onCreateVersion?.(version); + await waitBuildToBeFinished(version, options.onTemplateVersionChanges); return API.createTemplate(options.organizationId, { ...options.template, template_version_id: version.id, @@ -278,12 +283,17 @@ export const previousTemplateVersion = ( }; }; -const waitBuildToBeFinished = async (version: TemplateVersion) => { +const waitBuildToBeFinished = async ( + version: TemplateVersion, + onRequest?: (data: TemplateVersion) => void, +) => { let data: TemplateVersion; - let jobStatus: ProvisionerJobStatus; + let jobStatus: ProvisionerJobStatus | undefined = undefined; do { - await delay(1000); + // When pending we want to poll more frequently + await delay(jobStatus === "pending" ? 250 : 1000); data = await API.getTemplateVersion(version.id); + onRequest?.(data); jobStatus = data.job.status; if (jobStatus === "succeeded") { diff --git a/site/src/components/Form/Form.tsx b/site/src/components/Form/Form.tsx index c9fff001b0814..fcbc27a2511f3 100644 --- a/site/src/components/Form/Form.tsx +++ b/site/src/components/Form/Form.tsx @@ -6,6 +6,7 @@ import { useContext, ReactNode, ComponentProps, + forwardRef, } from "react"; import { AlphaBadge, DeprecatedBadge } from "components/Badges/Badges"; import { Stack } from "components/Stack/Stack"; @@ -81,59 +82,49 @@ interface FormSectionProps { deprecated?: boolean; } -export const FormSection: FC = ({ - children, - title, - description, - classes = {}, - alpha = false, - deprecated = false, -}) => { - const { direction } = useContext(FormContext); - const theme = useTheme(); - - return ( -
-
( + ( + { + children, + title, + description, + classes = {}, + alpha = false, + deprecated = false, + }, + ref, + ) => { + const { direction } = useContext(FormContext); + + return ( +
-

- {title} - {alpha && } - {deprecated && } -

-
{description}
-
- - {children} -
- ); -}; +
+

+ {title} + {alpha && } + {deprecated && } +

+
{description}
+
+ + {children} + + ); + }, +); export const FormFields: FC> = (props) => { return ( @@ -147,6 +138,35 @@ export const FormFields: FC> = (props) => { }; const styles = { + formSection: (theme) => ({ + display: "flex", + alignItems: "flex-start", + flexDirection: "column", + gap: 24, + + [theme.breakpoints.down("md")]: { + flexDirection: "column", + gap: 16, + }, + }), + formSectionHorizontal: { + flexDirection: "row", + gap: 120, + }, + formSectionInfo: (theme) => ({ + width: "100%", + flexShrink: 0, + top: 24, + + [theme.breakpoints.down("md")]: { + width: "100%", + position: "initial" as const, + }, + }), + formSectionInfoHorizontal: { + maxWidth: 312, + position: "sticky", + }, formSectionInfoTitle: (theme) => ({ fontSize: 20, color: theme.palette.text.primary, diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index fe669ca5efe78..9d7496e083b6f 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -19,10 +19,12 @@ export interface FormFooterProps { styles?: FormFooterStyles; submitLabel?: string; submitDisabled?: boolean; + extraActions?: React.ReactNode; } export const FormFooter: FC = ({ onCancel, + extraActions, isLoading, submitDisabled, submitLabel = Language.defaultSubmitLabel, @@ -52,6 +54,7 @@ export const FormFooter: FC = ({ > {Language.cancelLabel} + {extraActions} ); }; diff --git a/site/src/modules/templates/useWatchVersionLogs.ts b/site/src/modules/templates/useWatchVersionLogs.ts new file mode 100644 index 0000000000000..62ac4a3a16412 --- /dev/null +++ b/site/src/modules/templates/useWatchVersionLogs.ts @@ -0,0 +1,42 @@ +import { watchBuildLogsByTemplateVersionId } from "api/api"; +import { ProvisionerJobLog, TemplateVersion } from "api/typesGenerated"; +import { useState, useEffect } from "react"; + +export const useWatchVersionLogs = ( + templateVersion: TemplateVersion | undefined, + options?: { onDone: () => Promise }, +) => { + const [logs, setLogs] = useState(); + const templateVersionId = templateVersion?.id; + const templateVersionStatus = templateVersion?.job.status; + + useEffect(() => { + setLogs(undefined); + }, [templateVersionId]); + + useEffect(() => { + if (!templateVersionId || !templateVersionStatus) { + return; + } + + if (templateVersionStatus !== "running") { + return; + } + + const socket = watchBuildLogsByTemplateVersionId(templateVersionId, { + onMessage: (log) => { + setLogs((logs) => (logs ? [...logs, log] : [log])); + }, + onDone: options?.onDone, + onError: (error) => { + console.error(error); + }, + }); + + return () => { + socket.close(); + }; + }, [options?.onDone, templateVersionId, templateVersionStatus]); + + return logs; +}; diff --git a/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index 43a85dccfcd3f..3e9490ba1cbd1 100644 --- a/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -112,7 +112,7 @@ const styles = { borderRadius: "0 0 8px 8px", }, - "&:first-child": { + "&:first-of-type": { borderRadius: "8px 8px 0 0", }, }), diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx new file mode 100644 index 0000000000000..8ecd2fe712b6a --- /dev/null +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx @@ -0,0 +1,53 @@ +import { JobError } from "api/queries/templates"; +import { BuildLogsDrawer } from "./BuildLogsDrawer"; +import type { Meta, StoryObj } from "@storybook/react"; +import { + MockProvisionerJob, + MockTemplateVersion, + MockWorkspaceBuildLogs, +} from "testHelpers/entities"; +import { withWebSocket } from "testHelpers/storybook"; + +const meta: Meta = { + title: "pages/CreateTemplatePage/BuildLogsDrawer", + component: BuildLogsDrawer, + args: { + open: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = {}; + +export const MissingVariables: Story = { + args: { + templateVersion: MockTemplateVersion, + error: new JobError( + { + ...MockProvisionerJob, + error_code: "REQUIRED_TEMPLATE_VARIABLES", + }, + MockTemplateVersion, + ), + }, +}; + +export const Logs: Story = { + args: { + templateVersion: { + ...MockTemplateVersion, + job: { + ...MockTemplateVersion.job, + status: "running", + }, + }, + }, + decorators: [withWebSocket], + parameters: { + webSocket: { + messages: MockWorkspaceBuildLogs.map((log) => JSON.stringify(log)), + }, + }, +}; diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx new file mode 100644 index 0000000000000..65283b640bae0 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx @@ -0,0 +1,179 @@ +import Drawer from "@mui/material/Drawer"; +import Close from "@mui/icons-material/Close"; +import IconButton from "@mui/material/IconButton"; +import { visuallyHidden } from "@mui/utils"; +import { FC, useLayoutEffect, useRef } from "react"; +import { TemplateVersion } from "api/typesGenerated"; +import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; +import { Interpolation, Theme } from "@emotion/react"; +import { navHeight } from "theme/constants"; +import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; +import { JobError } from "api/queries/templates"; +import Button from "@mui/material/Button"; +import { Loader } from "components/Loader/Loader"; +import WarningOutlined from "@mui/icons-material/WarningOutlined"; + +type BuildLogsDrawerProps = { + error: unknown; + open: boolean; + onClose: () => void; + templateVersion: TemplateVersion | undefined; + variablesSectionRef: React.RefObject; +}; + +export const BuildLogsDrawer: FC = ({ + templateVersion, + error, + variablesSectionRef, + ...drawerProps +}) => { + const logs = useWatchVersionLogs(templateVersion); + const logsContainer = useRef(null); + + const scrollToBottom = () => { + setTimeout(() => { + if (logsContainer.current) { + logsContainer.current.scrollTop = logsContainer.current.scrollHeight; + } + }, 0); + }; + + useLayoutEffect(() => { + scrollToBottom(); + }, [logs]); + + useLayoutEffect(() => { + if (drawerProps.open) { + scrollToBottom(); + } + }, [drawerProps.open]); + + const isMissingVariables = + error instanceof JobError && + error.job.error_code === "REQUIRED_TEMPLATE_VARIABLES"; + + return ( + +
+
+

Creating template...

+ + + Close build logs + +
+ + {isMissingVariables ? ( + { + variablesSectionRef.current?.scrollIntoView({ + behavior: "smooth", + }); + const firstVariableInput = + variablesSectionRef.current?.querySelector("input"); + setTimeout(() => firstVariableInput?.focus(), 0); + drawerProps.onClose(); + }} + /> + ) : logs ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +}; + +const MissingVariablesBanner: FC<{ onFillVariables: () => void }> = ({ + onFillVariables, +}) => { + return ( +
+
+ +

Missing variables

+

+ During the build process, we identified some missing variables. Rest + assured, we have automatically added them to the form for you. +

+ +
+
+ ); +}; + +const styles = { + root: { + width: 800, + height: "100%", + display: "flex", + flexDirection: "column", + }, + header: (theme) => ({ + height: navHeight, + padding: "0 24px", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + borderBottom: `1px solid ${theme.palette.divider}`, + }), + title: { + margin: 0, + fontWeight: 500, + fontSize: 16, + }, + closeIcon: { + fontSize: 20, + }, + logs: (theme) => ({ + flex: 1, + overflow: "auto", + backgroundColor: theme.palette.background.default, + }), +} satisfies Record>; + +const bannerStyles = { + root: { + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 40, + }, + content: { + display: "flex", + flexDirection: "column", + alignItems: "center", + textAlign: "center", + maxWidth: 360, + }, + icon: (theme) => ({ + fontSize: 32, + color: theme.roles.warning.fill.outline, + }), + title: { + fontWeight: 500, + lineHeight: "1", + margin: 0, + marginTop: 16, + }, + description: (theme) => ({ + color: theme.palette.text.secondary, + fontSize: 14, + margin: 0, + marginTop: 8, + lineHeight: "1.5", + }), + button: { + marginTop: 16, + }, +} satisfies Record>; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx index eb0c8d9a5c847..583a3e8cc4172 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx @@ -11,7 +11,7 @@ import { CreateTemplateForm } from "./CreateTemplateForm"; import type { Meta, StoryObj } from "@storybook/react"; const meta: Meta = { - title: "pages/CreateTemplatePage", + title: "pages/CreateTemplatePage/CreateTemplateForm", component: CreateTemplateForm, args: { isSubmitting: false, @@ -50,320 +50,3 @@ export const DuplicateTemplateWithVariables: Story = { ], }, }; - -export const WithJobError: Story = { - args: { - copiedTemplate: MockTemplate, - jobError: - "template import provision for start: recv import provision: plan terraform: terraform plan: exit status 1", - logs: [ - { - id: 461061, - created_at: "2023-03-06T14:47:32.501Z", - log_source: "provisioner_daemon", - log_level: "info", - stage: "Adding README.md...", - output: "", - }, - { - id: 461062, - created_at: "2023-03-06T14:47:32.501Z", - log_source: "provisioner_daemon", - log_level: "info", - stage: "Setting up", - output: "", - }, - { - id: 461063, - created_at: "2023-03-06T14:47:32.528Z", - log_source: "provisioner_daemon", - log_level: "info", - stage: "Parsing template parameters", - output: "", - }, - { - id: 461064, - created_at: "2023-03-06T14:47:32.552Z", - log_source: "provisioner_daemon", - log_level: "info", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461065, - created_at: "2023-03-06T14:47:32.633Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461066, - created_at: "2023-03-06T14:47:32.633Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "Initializing the backend...", - }, - { - id: 461067, - created_at: "2023-03-06T14:47:32.71Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461068, - created_at: "2023-03-06T14:47:32.711Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "Initializing provider plugins...", - }, - { - id: 461069, - created_at: "2023-03-06T14:47:32.712Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: '- Finding coder/coder versions matching "~\u003e 0.6.12"...', - }, - { - id: 461070, - created_at: "2023-03-06T14:47:32.922Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: '- Finding hashicorp/aws versions matching "~\u003e 4.55"...', - }, - { - id: 461071, - created_at: "2023-03-06T14:47:33.132Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "- Installing hashicorp/aws v4.57.0...", - }, - { - id: 461072, - created_at: "2023-03-06T14:47:37.364Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "- Installed hashicorp/aws v4.57.0 (signed by HashiCorp)", - }, - { - id: 461073, - created_at: "2023-03-06T14:47:38.142Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "- Installing coder/coder v0.6.15...", - }, - { - id: 461074, - created_at: "2023-03-06T14:47:39.083Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "- Installed coder/coder v0.6.15 (signed by a HashiCorp partner, key ID 93C75807601AA0EC)", - }, - { - id: 461075, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461076, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "Partner and community providers are signed by their developers.", - }, - { - id: 461077, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "If you'd like to know more about provider signing, you can read about it here:", - }, - { - id: 461078, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "https://www.terraform.io/docs/cli/plugins/signing.html", - }, - { - id: 461079, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461080, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "Terraform has created a lock file .terraform.lock.hcl to record the provider", - }, - { - id: 461081, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "selections it made above. Include this file in your version control repository", - }, - { - id: 461082, - created_at: "2023-03-06T14:47:39.394Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "so that Terraform can guarantee to make the same selections by default when", - }, - { - id: 461083, - created_at: "2023-03-06T14:47:39.395Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: 'you run "terraform init" in the future.', - }, - { - id: 461084, - created_at: "2023-03-06T14:47:39.395Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461085, - created_at: "2023-03-06T14:47:39.395Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "Terraform has been successfully initialized!", - }, - { - id: 461086, - created_at: "2023-03-06T14:47:39.395Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461087, - created_at: "2023-03-06T14:47:39.395Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - 'You may now begin working with Terraform. Try running "terraform plan" to see', - }, - { - id: 461088, - created_at: "2023-03-06T14:47:39.395Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "any changes that are required for your infrastructure. All Terraform commands", - }, - { - id: 461089, - created_at: "2023-03-06T14:47:39.395Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "should now work.", - }, - { - id: 461090, - created_at: "2023-03-06T14:47:39.397Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461091, - created_at: "2023-03-06T14:47:39.397Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "If you ever set or change modules or backend configuration for Terraform,", - }, - { - id: 461092, - created_at: "2023-03-06T14:47:39.397Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: - "rerun this command to reinitialize your working directory. If you forget, other", - }, - { - id: 461093, - created_at: "2023-03-06T14:47:39.397Z", - log_source: "provisioner", - log_level: "debug", - stage: "Detecting persistent resources", - output: "commands will detect it and remind you to do so if necessary.", - }, - { - id: 461094, - created_at: "2023-03-06T14:47:39.431Z", - log_source: "provisioner", - log_level: "info", - stage: "Detecting persistent resources", - output: "Terraform 1.1.9", - }, - { - id: 461095, - created_at: "2023-03-06T14:47:43.759Z", - log_source: "provisioner", - log_level: "error", - stage: "Detecting persistent resources", - output: - "Error: configuring Terraform AWS Provider: no valid credential sources for Terraform AWS Provider found.\n\nPlease see https://registry.terraform.io/providers/hashicorp/aws\nfor more information about providing credentials.\n\nError: failed to refresh cached credentials, no EC2 IMDS role found, operation error ec2imds: GetMetadata, http response error StatusCode: 404, request to EC2 IMDS failed\n", - }, - { - id: 461096, - created_at: "2023-03-06T14:47:43.759Z", - log_source: "provisioner", - log_level: "error", - stage: "Detecting persistent resources", - output: "", - }, - { - id: 461097, - created_at: "2023-03-06T14:47:43.777Z", - log_source: "provisioner_daemon", - log_level: "info", - stage: "Cleaning Up", - output: "", - }, - ], - }, -}; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 22eb2ee16c26c..b4e5677525488 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -1,7 +1,6 @@ -import { type Interpolation, type Theme } from "@emotion/react"; import TextField from "@mui/material/TextField"; import { useFormik } from "formik"; -import { type FC, useEffect } from "react"; +import { type FC } from "react"; import camelCase from "lodash/camelCase"; import capitalize from "lodash/capitalize"; import * as Yup from "yup"; @@ -12,7 +11,6 @@ import type { TemplateVersionVariable, VariableValue, } from "api/typesGenerated"; -import { Stack } from "components/Stack/Stack"; import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"; import { nameValidator, @@ -25,7 +23,6 @@ import { type TemplateAutostopRequirementDaysValue, } from "utils/schedule"; import { sortedDays } from "modules/templates/TemplateScheduleAutostart/TemplateScheduleAutostart"; -import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { IconField } from "components/IconField/IconField"; import { HorizontalForm, @@ -166,24 +163,28 @@ export type CreateTemplateFormProps = ( ) & { onCancel: () => void; onSubmit: (data: CreateTemplateData) => void; + onOpenBuildLogsDrawer: () => void; isSubmitting: boolean; variables?: TemplateVersionVariable[]; error?: unknown; jobError?: string; logs?: ProvisionerJobLog[]; allowAdvancedScheduling: boolean; + variablesSectionRef: React.RefObject; }; export const CreateTemplateForm: FC = (props) => { const { onCancel, onSubmit, + onOpenBuildLogsDrawer, variables, isSubmitting, error, jobError, logs, allowAdvancedScheduling, + variablesSectionRef, } = props; const form = useFormik({ initialValues: getInitialValues({ @@ -198,18 +199,6 @@ export const CreateTemplateForm: FC = (props) => { }); const getFieldHelpers = getFormHelpers(form, error); - useEffect(() => { - if (error) { - window.scrollTo(0, 0); - } - }, [error]); - - useEffect(() => { - if (jobError) { - window.scrollTo(0, document.body.scrollHeight); - } - }, [logs, jobError]); - return ( {/* General info */} @@ -283,6 +272,7 @@ export const CreateTemplateForm: FC = (props) => { {/* Variables */} {variables && variables.length > 0 && ( @@ -305,27 +295,37 @@ export const CreateTemplateForm: FC = (props) => { )} - {jobError && ( - -
-
Error during provisioning
-

- Looks like we found an error during the template provisioning. You - can see the logs bellow. -

- - {jobError} -
- - -
- )} - - +
+ ({ + backgroundColor: "transparent", + border: 0, + fontWeight: 500, + fontSize: 14, + cursor: "pointer", + color: theme.palette.text.secondary, + + "&:hover": { + textDecoration: "underline", + textUnderlineOffset: 4, + color: theme.palette.text.primary, + }, + })} + > + Show build logs + + ) + } + onCancel={onCancel} + isLoading={isSubmitting} + submitLabel={jobError ? "Retry" : "Create template"} + /> +
); }; @@ -344,44 +344,3 @@ const fillNameAndDisplayWithFilename = async ( form.setFieldValue("display_name", capitalize(name)), ]); }; - -const styles = { - ttlFields: { - width: "100%", - }, - - optionText: (theme) => ({ - fontSize: 16, - color: theme.palette.text.primary, - }), - - optionHelperText: (theme) => ({ - fontSize: 12, - color: theme.palette.text.secondary, - }), - - error: (theme) => ({ - padding: 24, - borderRadius: 8, - background: theme.palette.background.paper, - border: `1px solid ${theme.palette.error.main}`, - }), - - errorTitle: { - fontSize: 16, - margin: 0, - }, - - errorDescription: (theme) => ({ - margin: 0, - color: theme.palette.text.secondary, - marginTop: 4, - }), - - errorDetails: (theme) => ({ - display: "block", - marginTop: 8, - color: theme.palette.error.light, - fontSize: 16, - }), -} satisfies Record>; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx index 9301701365ec0..19883db6487a5 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx @@ -19,7 +19,7 @@ const renderPage = async (searchParams: URLSearchParams) => { route: `/templates/new?${searchParams.toString()}`, path: "/templates/new", // We need this because after creation, the user will be redirected to here - extraRoutes: [{ path: "templates/:template", element: <> }], + extraRoutes: [{ path: "templates/:template/files", element: <> }], }); // It is lazy loaded, so we have to wait for it to be rendered to not get an // act error @@ -62,6 +62,12 @@ test("Create template from starter template", async () => { within(form).getByRole("button", { name: /create template/i }), ); + // Wait for the drawer error to be rendered + await screen.findByRole("heading", { name: /missing variables/i }); + await userEvent.click( + screen.getByRole("button", { name: /fill variables/i }), + ); + // Wait for the variables form to be rendered and fill it await screen.findByText(/Variables/, undefined, { timeout: 5_000 }); diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index c5440e0929701..6f260933c1505 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -1,4 +1,4 @@ -import { type FC } from "react"; +import { useState, type FC, useRef } from "react"; import { Helmet } from "react-helmet-async"; import { useNavigate, useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; @@ -6,20 +6,40 @@ import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizont import { DuplicateTemplateView } from "./DuplicateTemplateView"; import { ImportStarterTemplateView } from "./ImportStarterTemplateView"; import { UploadTemplateView } from "./UploadTemplateView"; -import { Template } from "api/typesGenerated"; +import { BuildLogsDrawer } from "./BuildLogsDrawer"; +import { useMutation } from "react-query"; +import { createTemplate } from "api/queries/templates"; +import { CreateTemplatePageViewProps } from "./types"; +import { TemplateVersion } from "api/typesGenerated"; const CreateTemplatePage: FC = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - - const onSuccess = (template: Template) => { - navigate(`/templates/${template.name}/files`); - }; + const [isBuildLogsOpen, setIsBuildLogsOpen] = useState(false); + const [templateVersion, setTemplateVersion] = useState(); + const createTemplateMutation = useMutation(createTemplate()); + const variablesSectionRef = useRef(null); const onCancel = () => { navigate(-1); }; + const pageViewProps: CreateTemplatePageViewProps = { + onCreateTemplate: async (options) => { + setIsBuildLogsOpen(true); + const template = await createTemplateMutation.mutateAsync({ + ...options, + onCreateVersion: setTemplateVersion, + onTemplateVersionChanges: setTemplateVersion, + }); + navigate(`/templates/${template.name}/files`); + }, + onOpenBuildLogsDrawer: () => setIsBuildLogsOpen(true), + error: createTemplateMutation.error, + isCreating: createTemplateMutation.isLoading, + variablesSectionRef, + }; + return ( <> @@ -28,13 +48,21 @@ const CreateTemplatePage: FC = () => { {searchParams.has("fromTemplate") ? ( - + ) : searchParams.has("exampleId") ? ( - + ) : ( - + )} + + setIsBuildLogsOpen(false)} + templateVersion={templateVersion} + variablesSectionRef={variablesSectionRef} + /> ); }; diff --git a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx index 62540343c9c1d..933b2ef36df39 100644 --- a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx @@ -1,5 +1,5 @@ import { type FC } from "react"; -import { useQuery, useMutation } from "react-query"; +import { useQuery } from "react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { templateVersionLogs, @@ -7,7 +7,6 @@ import { templateVersion, templateVersionVariables, JobError, - createTemplate, } from "api/queries/templates"; import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { useDashboard } from "modules/dashboard/useDashboard"; @@ -15,14 +14,14 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; import { CreateTemplateForm } from "./CreateTemplateForm"; import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; -import { Template } from "api/typesGenerated"; +import { CreateTemplatePageViewProps } from "./types"; -type DuplicateTemplateViewProps = { - onSuccess: (template: Template) => void; -}; - -export const DuplicateTemplateView: FC = ({ - onSuccess, +export const DuplicateTemplateView: FC = ({ + onCreateTemplate, + onOpenBuildLogsDrawer, + variablesSectionRef, + error, + isCreating, }) => { const navigate = useNavigate(); const organizationId = useOrganizationId(); @@ -51,11 +50,9 @@ export const DuplicateTemplateView: FC = ({ const dashboard = useDashboard(); const formPermissions = getFormPermissions(dashboard.entitlements); - const createTemplateMutation = useMutation(createTemplate()); - const createError = createTemplateMutation.error; - const isJobError = createError instanceof JobError; + const isJobError = error instanceof JobError; const templateVersionLogsQuery = useQuery({ - ...templateVersionLogs(isJobError ? createError.version.id : ""), + ...templateVersionLogs(isJobError ? error.version.id : ""), enabled: isJobError, }); @@ -70,15 +67,17 @@ export const DuplicateTemplateView: FC = ({ return ( navigate(-1)} - jobError={isJobError ? createError.job.error : undefined} + jobError={isJobError ? error.job.error : undefined} logs={templateVersionLogsQuery.data} onSubmit={async (formData) => { - const template = await createTemplateMutation.mutateAsync({ + await onCreateTemplate({ organizationId, version: firstVersionFromFile( templateVersionQuery.data!.job.file_id, @@ -86,7 +85,6 @@ export const DuplicateTemplateView: FC = ({ ), template: newTemplate(formData), }); - onSuccess(template); }} /> ); diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx index 5075b0ecae915..ec3e9c1008d23 100644 --- a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -1,10 +1,9 @@ import { type FC } from "react"; -import { useQuery, useMutation } from "react-query"; +import { useQuery } from "react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { templateVersionLogs, JobError, - createTemplate, templateExamples, templateVersionVariables, } from "api/queries/templates"; @@ -18,14 +17,14 @@ import { getFormPermissions, newTemplate, } from "./utils"; -import { Template } from "api/typesGenerated"; +import { CreateTemplatePageViewProps } from "./types"; -type ImportStarterTemplateViewProps = { - onSuccess: (template: Template) => void; -}; - -export const ImportStarterTemplateView: FC = ({ - onSuccess, +export const ImportStarterTemplateView: FC = ({ + onCreateTemplate, + onOpenBuildLogsDrawer, + variablesSectionRef, + error, + isCreating, }) => { const navigate = useNavigate(); const organizationId = useOrganizationId(); @@ -41,19 +40,17 @@ export const ImportStarterTemplateView: FC = ({ const dashboard = useDashboard(); const formPermissions = getFormPermissions(dashboard.entitlements); - const createTemplateMutation = useMutation(createTemplate()); - const createError = createTemplateMutation.error; - const isJobError = createError instanceof JobError; + const isJobError = error instanceof JobError; const templateVersionLogsQuery = useQuery({ - ...templateVersionLogs(isJobError ? createError.version.id : ""), + ...templateVersionLogs(isJobError ? error.version.id : ""), enabled: isJobError, }); const missedVariables = useQuery({ - ...templateVersionVariables(isJobError ? createError.version.id : ""), + ...templateVersionVariables(isJobError ? error.version.id : ""), + keepPreviousData: true, enabled: - isJobError && - createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", + isJobError && error.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", }); if (isLoading) { @@ -67,15 +64,17 @@ export const ImportStarterTemplateView: FC = ({ return ( navigate(-1)} - jobError={isJobError ? createError.job.error : undefined} + jobError={isJobError ? error.job.error : undefined} logs={templateVersionLogsQuery.data} onSubmit={async (formData) => { - const template = await createTemplateMutation.mutateAsync({ + await onCreateTemplate({ organizationId, version: firstVersionFromExample( templateExample!, @@ -83,7 +82,6 @@ export const ImportStarterTemplateView: FC = ({ ), template: newTemplate(formData), }); - onSuccess(template); }} /> ); diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx index d228e484d0e4d..da2b737d75a40 100644 --- a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom"; import { templateVersionLogs, JobError, - createTemplate, templateVersionVariables, } from "api/queries/templates"; import { uploadFile } from "api/queries/files"; @@ -11,15 +10,15 @@ import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { useDashboard } from "modules/dashboard/useDashboard"; import { CreateTemplateForm } from "./CreateTemplateForm"; import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; -import { Template } from "api/typesGenerated"; import { FC } from "react"; +import { CreateTemplatePageViewProps } from "./types"; -type UploadTemplateViewProps = { - onSuccess: (template: Template) => void; -}; - -export const UploadTemplateView: FC = ({ - onSuccess, +export const UploadTemplateView: FC = ({ + onCreateTemplate, + onOpenBuildLogsDrawer, + variablesSectionRef, + isCreating, + error, }) => { const navigate = useNavigate(); const organizationId = useOrganizationId(); @@ -30,29 +29,28 @@ export const UploadTemplateView: FC = ({ const uploadFileMutation = useMutation(uploadFile()); const uploadedFile = uploadFileMutation.data; - const createTemplateMutation = useMutation(createTemplate()); - const createError = createTemplateMutation.error; - const isJobError = createError instanceof JobError; + const isJobError = error instanceof JobError; const templateVersionLogsQuery = useQuery({ - ...templateVersionLogs(isJobError ? createError.version.id : ""), + ...templateVersionLogs(isJobError ? error.version.id : ""), enabled: isJobError, }); const missedVariables = useQuery({ - ...templateVersionVariables(isJobError ? createError.version.id : ""), + ...templateVersionVariables(isJobError ? error.version.id : ""), enabled: - isJobError && - createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", + isJobError && error.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", }); return ( navigate(-1)} - jobError={isJobError ? createError.job.error : undefined} + jobError={isJobError ? error.job.error : undefined} logs={templateVersionLogsQuery.data} upload={{ onUpload: uploadFileMutation.mutateAsync, @@ -61,7 +59,7 @@ export const UploadTemplateView: FC = ({ file: uploadFileMutation.variables, }} onSubmit={async (formData) => { - const template = await createTemplateMutation.mutateAsync({ + await onCreateTemplate({ organizationId, version: firstVersionFromFile( uploadedFile!.hash, @@ -69,7 +67,6 @@ export const UploadTemplateView: FC = ({ ), template: newTemplate(formData), }); - onSuccess(template); }} /> ); diff --git a/site/src/pages/CreateTemplatePage/types.ts b/site/src/pages/CreateTemplatePage/types.ts new file mode 100644 index 0000000000000..d635d66a31bd1 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/types.ts @@ -0,0 +1,9 @@ +import { CreateTemplateOptions } from "api/queries/templates"; + +export type CreateTemplatePageViewProps = { + onCreateTemplate: (options: CreateTemplateOptions) => Promise; + onOpenBuildLogsDrawer: () => void; + variablesSectionRef: React.RefObject; + error: unknown; + isCreating: boolean; +}; diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx index 265b058e10881..853f41d2ed360 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx @@ -30,7 +30,6 @@ const meta: Meta = { template: MockTemplate, templateVersion: MockTemplateVersion, defaultFileTree: MockTemplateVersionFileTree, - onPreview: action("onPreview"), onPublish: action("onPublish"), onConfirmPublish: action("onConfirmPublish"), onCancelPublish: action("onCancelPublish"), diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index bbf605a6eda7a..879ddc3abbc76 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -67,7 +67,7 @@ export interface TemplateVersionEditorProps { resources?: WorkspaceResource[]; disablePreview?: boolean; disableUpdate?: boolean; - onPreview: (files: FileTree) => void; + onPreview: (files: FileTree) => Promise; onPublish: () => void; onConfirmPublish: (data: PublishVersionData) => void; onCancelPublish: () => void; @@ -122,14 +122,14 @@ export const TemplateVersionEditor: FC = ({ const [renameFileOpen, setRenameFileOpen] = useState(); const [dirty, setDirty] = useState(false); - const triggerPreview = useCallback(() => { - onPreview(fileTree); + const triggerPreview = useCallback(async () => { + await onPreview(fileTree); setSelectedTab("logs"); }, [fileTree, onPreview]); // Stop ctrl+s from saving files and make ctrl+enter trigger a preview. useEffect(() => { - const keyListener = (event: KeyboardEvent) => { + const keyListener = async (event: KeyboardEvent) => { if (!(navigator.platform.match("Mac") ? event.metaKey : event.ctrlKey)) { return; } @@ -140,7 +140,7 @@ export const TemplateVersionEditor: FC = ({ break; case "Enter": event.preventDefault(); - triggerPreview(); + await triggerPreview(); break; } }; @@ -252,8 +252,8 @@ export const TemplateVersionEditor: FC = ({ } title="Build template (Ctrl + Enter)" disabled={disablePreview} - onClick={() => { - triggerPreview(); + onClick={async () => { + await triggerPreview(); }} > Build @@ -719,7 +719,7 @@ const styles = { // Hack to update logs header and lines "& .logs-header": { border: 0, - padding: "0 16px", + padding: "8px 16px", fontFamily: MONOSPACE_FONT_FAMILY, "&:first-of-type": { diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index 491c004bf971b..6fbd90ccf3ac5 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -4,7 +4,7 @@ import { } from "testHelpers/renderHelpers"; import TemplateVersionEditorPage from "./TemplateVersionEditorPage"; import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import userEvent, { UserEvent } from "@testing-library/user-event"; import * as api from "api/api"; import { MockTemplate, @@ -21,6 +21,7 @@ import { RequireAuth } from "contexts/auth/RequireAuth"; import { server } from "testHelpers/server"; import { rest } from "msw"; import { AppProviders } from "App"; +import { TemplateVersion } from "api/typesGenerated"; // For some reason this component in Jest is throwing a MUI style warning so, // since we don't need it for this test, we can mock it out @@ -50,35 +51,50 @@ const renderTemplateEditorPage = () => { }); }; -test("Use custom name, message and set it as active when publishing", async () => { - const user = userEvent.setup(); - renderTemplateEditorPage(); - const topbar = await screen.findByTestId("topbar"); - - // Build Template +const buildTemplateVersion = async ( + templateVersion: TemplateVersion, + user: UserEvent, + topbar: HTMLElement, +) => { jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" }); - const newTemplateVersion = { - ...MockTemplateVersion, - id: "new-version-id", - name: "new-version", - }; - jest - .spyOn(api, "createTemplateVersion") - .mockResolvedValue(newTemplateVersion); + jest.spyOn(api, "createTemplateVersion").mockResolvedValue({ + ...templateVersion, + job: { + ...templateVersion.job, + status: "running", + }, + }); jest .spyOn(api, "getTemplateVersionByName") - .mockResolvedValue(newTemplateVersion); + .mockResolvedValue(templateVersion); jest .spyOn(api, "watchBuildLogsByTemplateVersionId") .mockImplementation((_, options) => { options.onMessage(MockWorkspaceBuildLogs[0]); - options.onDone(); - return jest.fn() as never; + options.onDone?.(); + const wsMock = { + close: jest.fn(), + } as unknown; + return wsMock as WebSocket; }); const buildButton = within(topbar).getByRole("button", { name: "Build", }); await user.click(buildButton); + await within(topbar).findByText("Success"); +}; + +test("Use custom name, message and set it as active when publishing", async () => { + const user = userEvent.setup(); + renderTemplateEditorPage(); + const topbar = await screen.findByTestId("topbar"); + + const newTemplateVersion: TemplateVersion = { + ...MockTemplateVersion, + id: "new-version-id", + name: "new-version", + }; + await buildTemplateVersion(newTemplateVersion, user, topbar); // Publish const patchTemplateVersion = jest @@ -87,7 +103,6 @@ test("Use custom name, message and set it as active when publishing", async () = const updateActiveTemplateVersion = jest .spyOn(api, "updateActiveTemplateVersion") .mockResolvedValue({ message: "" }); - await within(topbar).findByText("Success"); const publishButton = within(topbar).getByRole("button", { name: "Publish", }); @@ -118,30 +133,12 @@ test("Do not mark as active if promote is not checked", async () => { renderTemplateEditorPage(); const topbar = await screen.findByTestId("topbar"); - // Build Template - jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" }); const newTemplateVersion = { ...MockTemplateVersion, id: "new-version-id", name: "new-version", }; - jest - .spyOn(api, "createTemplateVersion") - .mockResolvedValue(newTemplateVersion); - jest - .spyOn(api, "getTemplateVersionByName") - .mockResolvedValue(newTemplateVersion); - jest - .spyOn(api, "watchBuildLogsByTemplateVersionId") - .mockImplementation((_, options) => { - options.onMessage(MockWorkspaceBuildLogs[0]); - options.onDone(); - return jest.fn() as never; - }); - const buildButton = within(topbar).getByRole("button", { - name: "Build", - }); - await user.click(buildButton); + await buildTemplateVersion(newTemplateVersion, user, topbar); // Publish const patchTemplateVersion = jest @@ -150,7 +147,6 @@ test("Do not mark as active if promote is not checked", async () => { const updateActiveTemplateVersion = jest .spyOn(api, "updateActiveTemplateVersion") .mockResolvedValue({ message: "" }); - await within(topbar).findByText("Success"); const publishButton = within(topbar).getByRole("button", { name: "Publish", }); @@ -175,44 +171,22 @@ test("Do not mark as active if promote is not checked", async () => { }); test("Patch request is not send when there are no changes", async () => { + const user = userEvent.setup(); + renderTemplateEditorPage(); + const topbar = await screen.findByTestId("topbar"); + const newTemplateVersion = { ...MockTemplateVersion, id: "new-version-id", name: "new-version", - }; - const MockTemplateVersionWithEmptyMessage = { - ...newTemplateVersion, message: "", }; - const user = userEvent.setup(); - renderTemplateEditorPage(); - const topbar = await screen.findByTestId("topbar"); - - // Build Template - jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" }); - jest - .spyOn(api, "createTemplateVersion") - .mockResolvedValue(MockTemplateVersionWithEmptyMessage); - jest - .spyOn(api, "getTemplateVersionByName") - .mockResolvedValue(MockTemplateVersionWithEmptyMessage); - jest - .spyOn(api, "watchBuildLogsByTemplateVersionId") - .mockImplementation((_, options) => { - options.onMessage(MockWorkspaceBuildLogs[0]); - options.onDone(); - return jest.fn() as never; - }); - const buildButton = within(topbar).getByRole("button", { - name: "Build", - }); - await user.click(buildButton); + await buildTemplateVersion(newTemplateVersion, user, topbar); // Publish const patchTemplateVersion = jest .spyOn(api, "patchTemplateVersion") - .mockResolvedValue(MockTemplateVersionWithEmptyMessage); - await within(topbar).findByText("Success"); + .mockResolvedValue(newTemplateVersion); const publishButton = within(topbar).getByRole("button", { name: "Publish", }); @@ -220,7 +194,7 @@ test("Patch request is not send when there are no changes", async () => { const publishDialog = await screen.findByTestId("dialog"); // It is using the name from the template const nameField = within(publishDialog).getByLabelText("Version name"); - expect(nameField).toHaveValue(MockTemplateVersionWithEmptyMessage.name); + expect(nameField).toHaveValue(newTemplateVersion.name); // Publish await user.click( within(publishDialog).getByRole("button", { name: "Publish" }), diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 25afb46179b58..9a96c1ae1798b 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -5,14 +5,9 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { TemplateVersionEditor } from "./TemplateVersionEditor"; import { useOrganizationId } from "contexts/auth/useOrganizationId"; import { pageTitle } from "utils/page"; -import { - patchTemplateVersion, - updateActiveTemplateVersion, - watchBuildLogsByTemplateVersionId, -} from "api/api"; +import { patchTemplateVersion, updateActiveTemplateVersion } from "api/api"; import type { PatchTemplateVersionRequest, - ProvisionerJobLog, TemplateVersion, } from "api/typesGenerated"; import { @@ -28,6 +23,7 @@ import { FileTree, traverse } from "utils/filetree"; import { createTemplateVersionFileTree } from "utils/templateVersion"; import { displayError } from "components/GlobalSnackbar/utils"; import { FullScreenLoader } from "components/Loader/FullScreenLoader"; +import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; type Params = { version: string; @@ -58,7 +54,7 @@ export const TemplateVersionEditorPage: FC = () => { ...resources(templateVersionQuery.data?.id ?? ""), enabled: templateVersionQuery.data?.job.status === "succeeded", }); - const { logs, setLogs } = useVersionLogs(templateVersionQuery.data, { + const logs = useWatchVersionLogs(templateVersionQuery.data, { onDone: templateVersionQuery.refetch, }); const { fileTree, tarFile } = useFileTree(templateVersionQuery.data); @@ -97,22 +93,6 @@ export const TemplateVersionEditorPage: FC = () => { ); }; - // Optimistically update the template version data job status to make the - // build action feels faster - const onBuildStart = () => { - setLogs([]); - - queryClient.setQueryData(templateVersionOptions.queryKey, () => { - return { - ...templateVersionQuery.data, - job: { - ...templateVersionQuery.data?.job, - status: "pending", - }, - }; - }); - }; - const onBuildEnds = (newVersion: TemplateVersion) => { queryClient.setQueryData(templateVersionOptions.queryKey, newVersion); navigateToVersion(newVersion); @@ -145,7 +125,6 @@ export const TemplateVersionEditorPage: FC = () => { if (!tarFile) { return; } - onBuildStart(); const newVersionFile = await generateVersionFiles( tarFile, newFileTree, @@ -159,6 +138,7 @@ export const TemplateVersionEditorPage: FC = () => { template_id: templateQuery.data.id, file_id: serverFile.hash, }); + onBuildEnds(newVersion); }} onPublish={() => { @@ -218,7 +198,6 @@ export const TemplateVersionEditorPage: FC = () => { if (!uploadFileMutation.data) { return; } - onBuildStart(); const newVersion = await createTemplateVersionMutation.mutateAsync({ provisioner: "terraform", storage_method: "file", @@ -276,45 +255,6 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { return state; }; -const useVersionLogs = ( - templateVersion: TemplateVersion | undefined, - options: { onDone: () => Promise }, -) => { - const [logs, setLogs] = useState(); - const templateVersionId = templateVersion?.id; - const refetchTemplateVersion = options.onDone; - const templateVersionStatus = templateVersion?.job.status; - - useEffect(() => { - if (!templateVersionId || !templateVersionStatus) { - return; - } - - if (templateVersionStatus !== "running") { - return; - } - - const socket = watchBuildLogsByTemplateVersionId(templateVersionId, { - onMessage: (log) => { - setLogs((logs) => (logs ? [...logs, log] : [log])); - }, - onDone: refetchTemplateVersion, - onError: (error) => { - console.error(error); - }, - }); - - return () => { - socket.close(); - }; - }, [refetchTemplateVersion, templateVersionId, templateVersionStatus]); - - return { - logs, - setLogs, - }; -}; - const useMissingVariables = (templateVersion: TemplateVersion | undefined) => { const isRequiringVariables = templateVersion?.job.error_code === "REQUIRED_TEMPLATE_VARIABLES"; diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index f613eaf028575..d2482b8494cb4 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -33,7 +33,7 @@ const renderWorkspacePage = async (workspace: Workspace) => { jest .spyOn(api, "watchWorkspaceAgentLogs") .mockImplementation((_, options) => { - options.onDone && options.onDone(); + options.onDone?.(); return new WebSocket(""); }); diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 61f33d5cc1ac2..604695977d7b0 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -373,6 +373,15 @@ export const handlers = [ }, ), + rest.post("/api/v2/files", (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + hash: "some-file-hash", + }), + ); + }), + rest.get("/api/v2/files/:fileId", (_, res, ctx) => { const fileBuffer = fs.readFileSync( path.resolve(__dirname, "./templateFiles.tar"), diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 3f8539d6d513b..47bea51065cf6 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -44,3 +44,30 @@ export const withDashboardProvider = ( ); }; + +export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { + if (!parameters.webSocket) { + console.warn( + "Looks like you forgot to add websocket messages to the story", + ); + } + + // @ts-expect-error -- TS doesn't know about the global WebSocket + window.WebSocket = function () { + return { + addEventListener: ( + type: string, + callback: (ev: Record<"data", string>) => void, + ) => { + if (type === "message") { + parameters.webSocket?.messages.forEach((message) => { + callback({ data: message }); + }); + } + }, + close: () => {}, + }; + }; + + return ; +};