feat(site): display build logs on template creation (#12271)

This commit is contained in:
Bruno Quaresma
2024-02-23 16:23:52 -03:00
committed by GitHub
parent 13359aa16f
commit 79480ca587
25 changed files with 606 additions and 667 deletions

View File

@ -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: {

View File

@ -7,5 +7,8 @@ declare module "@storybook/react" {
features?: FeatureName[];
experiments?: Experiments;
queries?: { key: QueryKey; data: unknown }[];
webSocket?: {
messages: string[];
};
}
}

View File

@ -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;
};

View File

@ -206,16 +206,21 @@ export const createTemplate = () => {
};
};
const createTemplateFn = async (options: {
export type CreateTemplateOptions = {
organizationId: string;
version: CreateTemplateVersionRequest;
template: Omit<CreateTemplateRequest, "template_version_id">;
}) => {
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") {

View File

@ -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<FormSectionProps> = ({
children,
title,
description,
classes = {},
alpha = false,
deprecated = false,
}) => {
const { direction } = useContext(FormContext);
const theme = useTheme();
export const FormSection = forwardRef<HTMLDivElement, FormSectionProps>(
(
{
children,
title,
description,
classes = {},
alpha = false,
deprecated = false,
},
ref,
) => {
const { direction } = useContext(FormContext);
return (
<div
css={{
display: "flex",
alignItems: "flex-start",
flexDirection: direction === "horizontal" ? "row" : "column",
gap: direction === "horizontal" ? 120 : 24,
[theme.breakpoints.down("md")]: {
flexDirection: "column",
gap: 16,
},
}}
className={classes.root}
>
<div
css={{
width: "100%",
maxWidth: direction === "horizontal" ? 312 : undefined,
flexShrink: 0,
position: direction === "horizontal" ? "sticky" : undefined,
top: 24,
[theme.breakpoints.down("md")]: {
width: "100%",
position: "initial" as const,
},
}}
className={classes.sectionInfo}
return (
<section
ref={ref}
css={[
styles.formSection,
direction === "horizontal" && styles.formSectionHorizontal,
]}
className={classes.root}
>
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
{title}
{alpha && <AlphaBadge />}
{deprecated && <DeprecatedBadge />}
</h2>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>
<div
css={[
styles.formSectionInfo,
direction === "horizontal" && styles.formSectionInfoHorizontal,
]}
className={classes.sectionInfo}
>
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
{title}
{alpha && <AlphaBadge />}
{deprecated && <DeprecatedBadge />}
</h2>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>
{children}
</div>
);
};
{children}
</section>
);
},
);
export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
return (
@ -147,6 +138,35 @@ export const FormFields: FC<ComponentProps<typeof Stack>> = (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,

View File

@ -19,10 +19,12 @@ export interface FormFooterProps {
styles?: FormFooterStyles;
submitLabel?: string;
submitDisabled?: boolean;
extraActions?: React.ReactNode;
}
export const FormFooter: FC<FormFooterProps> = ({
onCancel,
extraActions,
isLoading,
submitDisabled,
submitLabel = Language.defaultSubmitLabel,
@ -52,6 +54,7 @@ export const FormFooter: FC<FormFooterProps> = ({
>
{Language.cancelLabel}
</Button>
{extraActions}
</div>
);
};

View File

@ -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<unknown> },
) => {
const [logs, setLogs] = useState<ProvisionerJobLog[] | undefined>();
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;
};

View File

@ -112,7 +112,7 @@ const styles = {
borderRadius: "0 0 8px 8px",
},
"&:first-child": {
"&:first-of-type": {
borderRadius: "8px 8px 0 0",
},
}),

View File

@ -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<typeof BuildLogsDrawer> = {
title: "pages/CreateTemplatePage/BuildLogsDrawer",
component: BuildLogsDrawer,
args: {
open: true,
},
};
export default meta;
type Story = StoryObj<typeof BuildLogsDrawer>;
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)),
},
},
};

View File

@ -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<HTMLDivElement>;
};
export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
templateVersion,
error,
variablesSectionRef,
...drawerProps
}) => {
const logs = useWatchVersionLogs(templateVersion);
const logsContainer = useRef<HTMLDivElement>(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 (
<Drawer anchor="right" {...drawerProps}>
<div css={styles.root}>
<header css={styles.header}>
<h3 css={styles.title}>Creating template...</h3>
<IconButton size="small" onClick={drawerProps.onClose}>
<Close css={styles.closeIcon} />
<span style={visuallyHidden}>Close build logs</span>
</IconButton>
</header>
{isMissingVariables ? (
<MissingVariablesBanner
onFillVariables={() => {
variablesSectionRef.current?.scrollIntoView({
behavior: "smooth",
});
const firstVariableInput =
variablesSectionRef.current?.querySelector("input");
setTimeout(() => firstVariableInput?.focus(), 0);
drawerProps.onClose();
}}
/>
) : logs ? (
<section ref={logsContainer} css={styles.logs}>
<WorkspaceBuildLogs logs={logs} css={{ border: 0 }} />
</section>
) : (
<Loader />
)}
</div>
</Drawer>
);
};
const MissingVariablesBanner: FC<{ onFillVariables: () => void }> = ({
onFillVariables,
}) => {
return (
<div css={bannerStyles.root}>
<div css={bannerStyles.content}>
<WarningOutlined css={bannerStyles.icon} />
<h4 css={bannerStyles.title}>Missing variables</h4>
<p css={bannerStyles.description}>
During the build process, we identified some missing variables. Rest
assured, we have automatically added them to the form for you.
</p>
<Button
css={bannerStyles.button}
size="small"
variant="outlined"
onClick={onFillVariables}
>
Fill variables
</Button>
</div>
</div>
);
};
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<string, Interpolation<Theme>>;
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<string, Interpolation<Theme>>;

View File

@ -11,7 +11,7 @@ import { CreateTemplateForm } from "./CreateTemplateForm";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof CreateTemplateForm> = {
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: "",
},
],
},
};

View File

@ -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<HTMLDivElement>;
};
export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
const {
onCancel,
onSubmit,
onOpenBuildLogsDrawer,
variables,
isSubmitting,
error,
jobError,
logs,
allowAdvancedScheduling,
variablesSectionRef,
} = props;
const form = useFormik<CreateTemplateData>({
initialValues: getInitialValues({
@ -198,18 +199,6 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
});
const getFieldHelpers = getFormHelpers<CreateTemplateData>(form, error);
useEffect(() => {
if (error) {
window.scrollTo(0, 0);
}
}, [error]);
useEffect(() => {
if (jobError) {
window.scrollTo(0, document.body.scrollHeight);
}
}, [logs, jobError]);
return (
<HorizontalForm onSubmit={form.handleSubmit}>
{/* General info */}
@ -283,6 +272,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
{/* Variables */}
{variables && variables.length > 0 && (
<FormSection
ref={variablesSectionRef}
title="Variables"
description="Input variables allow you to customize templates without altering their source code."
>
@ -305,27 +295,37 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</FormSection>
)}
{jobError && (
<Stack>
<div css={styles.error}>
<h5 css={styles.errorTitle}>Error during provisioning</h5>
<p css={styles.errorDescription}>
Looks like we found an error during the template provisioning. You
can see the logs bellow.
</p>
<div className="flex items-center">
<FormFooter
extraActions={
logs && (
<button
type="button"
onClick={onOpenBuildLogsDrawer}
css={(theme) => ({
backgroundColor: "transparent",
border: 0,
fontWeight: 500,
fontSize: 14,
cursor: "pointer",
color: theme.palette.text.secondary,
<code css={styles.errorDetails}>{jobError}</code>
</div>
<WorkspaceBuildLogs logs={logs ?? []} />
</Stack>
)}
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
"&:hover": {
textDecoration: "underline",
textUnderlineOffset: 4,
color: theme.palette.text.primary,
},
})}
>
Show build logs
</button>
)
}
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
</div>
</HorizontalForm>
);
};
@ -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<string, Interpolation<Theme>>;

View File

@ -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 });

View File

@ -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<TemplateVersion>();
const createTemplateMutation = useMutation(createTemplate());
const variablesSectionRef = useRef<HTMLDivElement>(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 (
<>
<Helmet>
@ -28,13 +48,21 @@ const CreateTemplatePage: FC = () => {
<FullPageHorizontalForm title="Create Template" onCancel={onCancel}>
{searchParams.has("fromTemplate") ? (
<DuplicateTemplateView onSuccess={onSuccess} />
<DuplicateTemplateView {...pageViewProps} />
) : searchParams.has("exampleId") ? (
<ImportStarterTemplateView onSuccess={onSuccess} />
<ImportStarterTemplateView {...pageViewProps} />
) : (
<UploadTemplateView onSuccess={onSuccess} />
<UploadTemplateView {...pageViewProps} />
)}
</FullPageHorizontalForm>
<BuildLogsDrawer
error={createTemplateMutation.error}
open={isBuildLogsOpen}
onClose={() => setIsBuildLogsOpen(false)}
templateVersion={templateVersion}
variablesSectionRef={variablesSectionRef}
/>
</>
);
};

View File

@ -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<DuplicateTemplateViewProps> = ({
onSuccess,
export const DuplicateTemplateView: FC<CreateTemplatePageViewProps> = ({
onCreateTemplate,
onOpenBuildLogsDrawer,
variablesSectionRef,
error,
isCreating,
}) => {
const navigate = useNavigate();
const organizationId = useOrganizationId();
@ -51,11 +50,9 @@ export const DuplicateTemplateView: FC<DuplicateTemplateViewProps> = ({
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<DuplicateTemplateViewProps> = ({
return (
<CreateTemplateForm
{...formPermissions}
variablesSectionRef={variablesSectionRef}
onOpenBuildLogsDrawer={onOpenBuildLogsDrawer}
copiedTemplate={templateByNameQuery.data!}
error={createTemplateMutation.error}
isSubmitting={createTemplateMutation.isLoading}
error={error}
isSubmitting={isCreating}
variables={templateVersionVariablesQuery.data}
onCancel={() => 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<DuplicateTemplateViewProps> = ({
),
template: newTemplate(formData),
});
onSuccess(template);
}}
/>
);

View File

@ -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<ImportStarterTemplateViewProps> = ({
onSuccess,
export const ImportStarterTemplateView: FC<CreateTemplatePageViewProps> = ({
onCreateTemplate,
onOpenBuildLogsDrawer,
variablesSectionRef,
error,
isCreating,
}) => {
const navigate = useNavigate();
const organizationId = useOrganizationId();
@ -41,19 +40,17 @@ export const ImportStarterTemplateView: FC<ImportStarterTemplateViewProps> = ({
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<ImportStarterTemplateViewProps> = ({
return (
<CreateTemplateForm
{...formPermissions}
variablesSectionRef={variablesSectionRef}
onOpenBuildLogsDrawer={onOpenBuildLogsDrawer}
starterTemplate={templateExample!}
variables={missedVariables.data}
error={createTemplateMutation.error}
isSubmitting={createTemplateMutation.isLoading}
error={error}
isSubmitting={isCreating}
onCancel={() => 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<ImportStarterTemplateViewProps> = ({
),
template: newTemplate(formData),
});
onSuccess(template);
}}
/>
);

View File

@ -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<UploadTemplateViewProps> = ({
onSuccess,
export const UploadTemplateView: FC<CreateTemplatePageViewProps> = ({
onCreateTemplate,
onOpenBuildLogsDrawer,
variablesSectionRef,
isCreating,
error,
}) => {
const navigate = useNavigate();
const organizationId = useOrganizationId();
@ -30,29 +29,28 @@ export const UploadTemplateView: FC<UploadTemplateViewProps> = ({
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 (
<CreateTemplateForm
{...formPermissions}
onOpenBuildLogsDrawer={onOpenBuildLogsDrawer}
variablesSectionRef={variablesSectionRef}
variables={missedVariables.data}
error={createTemplateMutation.error}
isSubmitting={createTemplateMutation.isLoading}
error={error}
isSubmitting={isCreating}
onCancel={() => 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<UploadTemplateViewProps> = ({
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<UploadTemplateViewProps> = ({
),
template: newTemplate(formData),
});
onSuccess(template);
}}
/>
);

View File

@ -0,0 +1,9 @@
import { CreateTemplateOptions } from "api/queries/templates";
export type CreateTemplatePageViewProps = {
onCreateTemplate: (options: CreateTemplateOptions) => Promise<void>;
onOpenBuildLogsDrawer: () => void;
variablesSectionRef: React.RefObject<HTMLDivElement>;
error: unknown;
isCreating: boolean;
};

View File

@ -30,7 +30,6 @@ const meta: Meta<typeof TemplateVersionEditor> = {
template: MockTemplate,
templateVersion: MockTemplateVersion,
defaultFileTree: MockTemplateVersionFileTree,
onPreview: action("onPreview"),
onPublish: action("onPublish"),
onConfirmPublish: action("onConfirmPublish"),
onCancelPublish: action("onCancelPublish"),

View File

@ -67,7 +67,7 @@ export interface TemplateVersionEditorProps {
resources?: WorkspaceResource[];
disablePreview?: boolean;
disableUpdate?: boolean;
onPreview: (files: FileTree) => void;
onPreview: (files: FileTree) => Promise<void>;
onPublish: () => void;
onConfirmPublish: (data: PublishVersionData) => void;
onCancelPublish: () => void;
@ -122,14 +122,14 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
const [renameFileOpen, setRenameFileOpen] = useState<string>();
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<TemplateVersionEditorProps> = ({
break;
case "Enter":
event.preventDefault();
triggerPreview();
await triggerPreview();
break;
}
};
@ -252,8 +252,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
}
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": {

View File

@ -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 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);
const newTemplateVersion = {
...MockTemplateVersion,
id: "new-version-id",
name: "new-version",
message: "",
};
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" }),

View File

@ -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<unknown> },
) => {
const [logs, setLogs] = useState<ProvisionerJobLog[]>();
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";

View File

@ -33,7 +33,7 @@ const renderWorkspacePage = async (workspace: Workspace) => {
jest
.spyOn(api, "watchWorkspaceAgentLogs")
.mockImplementation((_, options) => {
options.onDone && options.onDone();
options.onDone?.();
return new WebSocket("");
});

View File

@ -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"),

View File

@ -44,3 +44,30 @@ export const withDashboardProvider = (
</DashboardContext.Provider>
);
};
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 <Story />;
};