From dd4e1f74ff6f5d28a55849f5d2d0971e0b956d28 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 27 Mar 2023 14:26:57 -0300 Subject: [PATCH] feat(site): Ask for version name and if it is active when publishing a new version on editor (#6756) --- coderd/templateversions.go | 3 + site/src/api/api.ts | 34 ++ .../PublishTemplateVersionDialog.tsx | 98 ++++ .../TemplateVersionEditor.tsx | 442 ++++++++++-------- .../TemplateVersionEditorPage.test.tsx | 183 ++++++++ .../TemplateVersionEditorPage.tsx | 28 +- .../TemplateVersionEditorPage/types.ts | 4 + site/src/testHelpers/entities.ts | 3 +- site/src/testHelpers/handlers.ts | 15 + site/src/testHelpers/templateFiles.tar | Bin 0 -> 27648 bytes .../templateVersionEditorXService.ts | 89 ++-- 11 files changed, 651 insertions(+), 248 deletions(-) create mode 100644 site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx create mode 100644 site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx create mode 100644 site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts create mode 100644 site/src/testHelpers/templateFiles.tar diff --git a/coderd/templateversions.go b/coderd/templateversions.go index ebbf4ae79f..55468a510a 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -123,6 +123,9 @@ func (api *API) patchTemplateVersion(rw http.ResponseWriter, r *http.Request) { if errors.Is(err, errTemplateVersionNameConflict) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: err.Error(), + Validations: []codersdk.ValidationError{ + {Field: "name", Detail: "Name is already used"}, + }, }) return } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 73a133f27e..a1763ab75f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -373,6 +373,17 @@ export const updateActiveTemplateVersion = async ( return response.data } +export const patchTemplateVersion = async ( + templateVersionId: string, + data: TypesGen.PatchTemplateVersionRequest, +) => { + const response = await axios.patch( + `/api/v2/templateversions/${templateVersionId}`, + data, + ) + return response.data +} + export const updateTemplateMeta = async ( templateId: string, data: TypesGen.UpdateTemplateMeta, @@ -1020,3 +1031,26 @@ const getMissingParameters = ( return missingParameters } + +export const watchBuildLogs = ( + versionId: string, + onMessage: (log: TypesGen.ProvisionerJobLog) => void, +) => { + return new Promise((resolve, reject) => { + const proto = location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/templateversions/${versionId}/logs?follow=true`, + ) + socket.binaryType = "blob" + socket.addEventListener("message", (event) => + onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog), + ) + socket.addEventListener("error", () => { + reject(new Error("Connection for logs failed.")) + }) + socket.addEventListener("close", () => { + // When the socket closes, logs have finished streaming! + resolve() + }) + }) +} diff --git a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx new file mode 100644 index 0000000000..236a91935d --- /dev/null +++ b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx @@ -0,0 +1,98 @@ +import { DialogProps } from "components/Dialogs/Dialog" +import { FC } from "react" +import { getFormHelpers } from "util/formUtils" +import { FormFields } from "components/Form/Form" +import { useFormik } from "formik" +import * as Yup from "yup" +import { PublishVersionData } from "pages/TemplateVersionPage/TemplateVersionEditorPage/types" +import TextField from "@material-ui/core/TextField" +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import Checkbox from "@material-ui/core/Checkbox" +import FormControlLabel from "@material-ui/core/FormControlLabel" +import { Stack } from "components/Stack/Stack" + +export type PublishTemplateVersionDialogProps = DialogProps & { + defaultName: string + isPublishing: boolean + publishingError?: unknown + onClose: () => void + onConfirm: (data: PublishVersionData) => void +} + +export const PublishTemplateVersionDialog: FC< + PublishTemplateVersionDialogProps +> = ({ + onConfirm, + isPublishing, + onClose, + defaultName, + publishingError, + ...dialogProps +}) => { + const form = useFormik({ + initialValues: { + name: defaultName, + isActiveVersion: false, + }, + validationSchema: Yup.object({ + name: Yup.string().required(), + isActiveVersion: Yup.boolean(), + }), + onSubmit: onConfirm, + }) + const getFieldHelpers = getFormHelpers(form, publishingError) + const handleClose = () => { + form.resetForm() + onClose() + } + + return ( + { + await form.submitForm() + }} + hideCancel={false} + type="success" + cancelText="Cancel" + confirmText="Publish" + title="Publish new version" + description={ + +

You are about to publish a new version of this template.

+ + + + { + await form.setFieldValue( + "isActiveVersion", + e.target.checked, + ) + }} + name="isActiveVersion" + color="primary" + /> + } + /> + +
+ } + /> + ) +} diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 0bcaf8640a..267e6debf9 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -16,6 +16,7 @@ import { AvatarData } from "components/AvatarData/AvatarData" import { bannerHeight } from "components/DeploymentBanner/DeploymentBannerView" import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" +import { PublishVersionData } from "pages/TemplateVersionPage/TemplateVersionEditorPage/types" import { FC, useCallback, useEffect, useRef, useState } from "react" import { navHeight, dashboardContentBottomPadding } from "theme/constants" import { @@ -36,6 +37,7 @@ import { } from "./FileDialog" import { FileTreeView } from "./FileTreeView" import { MonacoEditor } from "./MonacoEditor" +import { PublishTemplateVersionDialog } from "./PublishTemplateVersionDialog" import { getStatus, TemplateVersionStatusBadge, @@ -51,7 +53,12 @@ export interface TemplateVersionEditorProps { disablePreview: boolean disableUpdate: boolean onPreview: (files: FileTree) => void - onUpdate: () => void + onPublish: () => void + onConfirmPublish: (data: PublishVersionData) => void + onCancelPublish: () => void + publishingError: unknown + isAskingPublishParameters: boolean + isPublishing: boolean } const topbarHeight = 80 @@ -76,7 +83,12 @@ export const TemplateVersionEditor: FC = ({ templateVersion, defaultFileTree, onPreview, - onUpdate, + onPublish, + onConfirmPublish, + onCancelPublish, + publishingError, + isAskingPublishParameters, + isPublishing, buildLogs, resources, }) => { @@ -156,233 +168,249 @@ export const TemplateVersionEditor: FC = ({ }) return ( -
-
-
- - ) - } - /> -
- -
- {/* Only start to show the build when a new template version is building */} - {templateVersion.id !== firstTemplateVersionOnEditor.current.id && ( -
- -
- )} - - - - -
-
- -
-
-
- Template files -
- - { - setCreateFileOpen(true) - event.currentTarget.blur() - }} - > - - - -
- { - setCreateFileOpen(false) - }} - checkExists={(path) => existsFile(path, fileTree)} - onConfirm={(path) => { - setFileTree((fileTree) => createFile(path, fileTree, "")) - setActivePath(path) - setCreateFileOpen(false) - setDirty(true) - }} - /> - { - if (!deleteFileOpen) { - throw new Error("delete file must be set") - } - setFileTree((fileTree) => removeFile(deleteFileOpen, fileTree)) - setDeleteFileOpen(undefined) - if (activePath === deleteFileOpen) { - setActivePath(undefined) - } - setDirty(true) - }} - open={Boolean(deleteFileOpen)} - onClose={() => setDeleteFileOpen(undefined)} - filename={deleteFileOpen || ""} - /> - { - setRenameFileOpen(undefined) - }} - filename={renameFileOpen || ""} - checkExists={(path) => existsFile(path, fileTree)} - onConfirm={(newPath) => { - if (!renameFileOpen) { - return - } - setFileTree((fileTree) => - moveFile(renameFileOpen, newPath, fileTree), + <> +
+
+
+ ) - setActivePath(newPath) - setRenameFileOpen(undefined) - setDirty(true) - }} + } />
- setDeleteFileOpen(file)} - onSelect={(filePath) => { - if (!isFolder(filePath, fileTree)) { - setActivePath(filePath) + +
+ {/* Only start to show the build when a new template version is building */} + {templateVersion.id !== firstTemplateVersionOnEditor.current.id && ( +
+ +
+ )} + + + + +
-
-
- {activePath ? ( - { - if (!activePath) { - return - } - setFileTree((fileTree) => - updateFile(activePath, value, fileTree), - ) +
+
+
+ Template files +
+ + { + setCreateFileOpen(true) + event.currentTarget.blur() + }} + > + + + +
+ { + setCreateFileOpen(false) + }} + checkExists={(path) => existsFile(path, fileTree)} + onConfirm={(path) => { + setFileTree((fileTree) => createFile(path, fileTree, "")) + setActivePath(path) + setCreateFileOpen(false) setDirty(true) }} /> - ) : ( -
No file opened
- )} + { + if (!deleteFileOpen) { + throw new Error("delete file must be set") + } + setFileTree((fileTree) => + removeFile(deleteFileOpen, fileTree), + ) + setDeleteFileOpen(undefined) + if (activePath === deleteFileOpen) { + setActivePath(undefined) + } + setDirty(true) + }} + open={Boolean(deleteFileOpen)} + onClose={() => setDeleteFileOpen(undefined)} + filename={deleteFileOpen || ""} + /> + { + setRenameFileOpen(undefined) + }} + filename={renameFileOpen || ""} + checkExists={(path) => existsFile(path, fileTree)} + onConfirm={(newPath) => { + if (!renameFileOpen) { + return + } + setFileTree((fileTree) => + moveFile(renameFileOpen, newPath, fileTree), + ) + setActivePath(newPath) + setRenameFileOpen(undefined) + setDirty(true) + }} + /> +
+ setDeleteFileOpen(file)} + onSelect={(filePath) => { + if (!isFolder(filePath, fileTree)) { + setActivePath(filePath) + } + }} + onRename={(file) => setRenameFileOpen(file)} + activePath={activePath} + />
-
-
- +
+
+ {activePath ? ( + { + if (!activePath) { + return + } + setFileTree((fileTree) => + updateFile(activePath, value, fileTree), + ) + setDirty(true) + }} + /> + ) : ( +
No file opened
+ )} +
- {!disableUpdate && ( +
+
- )} -
- -
- {buildLogs && ( - - )} - {templateVersion.job.error && ( -
- {templateVersion.job.error} -
- )} -
- -
- {resources && ( - r.workspace_transition === "start", + {templateVersion.job.status !== "succeeded" ? ( + getStatus(templateVersion).icon + ) : ( + )} - /> - )} -
-
+ Build Log + - {templateVersionSucceeded && ( - <> -
- - )} + {!disableUpdate && ( + + )} +
+ +
+ {buildLogs && ( + + )} + {templateVersion.job.error && ( +
+ {templateVersion.job.error} +
+ )} +
+ +
+ {resources && ( + r.workspace_transition === "start", + )} + /> + )} +
+
+ + {templateVersionSucceeded && ( + <> +
+ + )} +
-
+ + + ) } diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx new file mode 100644 index 0000000000..07886ec58f --- /dev/null +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -0,0 +1,183 @@ +import { + MockTemplateVersion, + MockWorkspaceBuildLogs, + renderWithAuth, +} from "testHelpers/renderHelpers" +import TemplateVersionEditorPage from "./TemplateVersionEditorPage" +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import * as api from "api/api" + +// 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 +jest.mock("components/TemplateResourcesTable/TemplateResourcesTable", () => { + return { + TemplateResourcesTable: () =>
, + } +}) + +test("Use custom name and set it as active when publishing", async () => { + const user = userEvent.setup() + renderWithAuth(, { + extraRoutes: [ + { + path: "/templates/:templateId", + element:
, + }, + ], + }) + const topbar = await screen.findByTestId("topbar") + + // Build Template + jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" }) + jest + .spyOn(api, "createTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion) + jest + .spyOn(api, "getTemplateVersion") + .mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" }) + jest.spyOn(api, "watchBuildLogs").mockImplementation((_, onMessage) => { + onMessage(MockWorkspaceBuildLogs[0]) + return Promise.resolve() + }) + const buildButton = within(topbar).getByRole("button", { + name: "Build template", + }) + await user.click(buildButton) + + // Publish + const patchTemplateVersion = jest + .spyOn(api, "patchTemplateVersion") + .mockResolvedValue(MockTemplateVersion) + const updateActiveTemplateVersion = jest + .spyOn(api, "updateActiveTemplateVersion") + .mockResolvedValue({ message: "" }) + await within(topbar).findByText("Success") + const publishButton = within(topbar).getByRole("button", { + name: "Publish version", + }) + await user.click(publishButton) + const publishDialog = await screen.findByTestId("dialog") + const nameField = within(publishDialog).getByLabelText("Version name") + await user.clear(nameField) + await user.type(nameField, "v1.0") + await user.click( + within(publishDialog).getByLabelText("Promote to default version"), + ) + await user.click( + within(publishDialog).getByRole("button", { name: "Publish" }), + ) + await waitFor(() => { + expect(patchTemplateVersion).toBeCalledWith("new-version-id", { + name: "v1.0", + }) + expect(updateActiveTemplateVersion).toBeCalledWith("test-template", { + id: "new-version-id", + }) + }) +}) + +test("Do not mark as active if promote is not checked", async () => { + const user = userEvent.setup() + renderWithAuth(, { + extraRoutes: [ + { + path: "/templates/:templateId", + element:
, + }, + ], + }) + const topbar = await screen.findByTestId("topbar") + + // Build Template + jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" }) + jest + .spyOn(api, "createTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion) + jest + .spyOn(api, "getTemplateVersion") + .mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" }) + jest.spyOn(api, "watchBuildLogs").mockImplementation((_, onMessage) => { + onMessage(MockWorkspaceBuildLogs[0]) + return Promise.resolve() + }) + const buildButton = within(topbar).getByRole("button", { + name: "Build template", + }) + await user.click(buildButton) + + // Publish + const patchTemplateVersion = jest + .spyOn(api, "patchTemplateVersion") + .mockResolvedValue(MockTemplateVersion) + const updateActiveTemplateVersion = jest + .spyOn(api, "updateActiveTemplateVersion") + .mockResolvedValue({ message: "" }) + await within(topbar).findByText("Success") + const publishButton = within(topbar).getByRole("button", { + name: "Publish version", + }) + await user.click(publishButton) + const publishDialog = await screen.findByTestId("dialog") + const nameField = within(publishDialog).getByLabelText("Version name") + await user.clear(nameField) + await user.type(nameField, "v1.0") + await user.click( + within(publishDialog).getByRole("button", { name: "Publish" }), + ) + await waitFor(() => { + expect(patchTemplateVersion).toBeCalledWith("new-version-id", { + name: "v1.0", + }) + }) + expect(updateActiveTemplateVersion).toBeCalledTimes(0) +}) + +test("The default version name is used when a new one is not used", async () => { + const user = userEvent.setup() + renderWithAuth(, { + extraRoutes: [ + { + path: "/templates/:templateId", + element:
, + }, + ], + }) + const topbar = await screen.findByTestId("topbar") + + // Build Template + jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" }) + jest + .spyOn(api, "createTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion) + jest + .spyOn(api, "getTemplateVersion") + .mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" }) + jest.spyOn(api, "watchBuildLogs").mockImplementation((_, onMessage) => { + onMessage(MockWorkspaceBuildLogs[0]) + return Promise.resolve() + }) + const buildButton = within(topbar).getByRole("button", { + name: "Build template", + }) + await user.click(buildButton) + + // Publish + const patchTemplateVersion = jest + .spyOn(api, "patchTemplateVersion") + .mockResolvedValue(MockTemplateVersion) + await within(topbar).findByText("Success") + const publishButton = within(topbar).getByRole("button", { + name: "Publish version", + }) + await user.click(publishButton) + const publishDialog = await screen.findByTestId("dialog") + await user.click( + within(publishDialog).getByRole("button", { name: "Publish" }), + ) + await waitFor(() => { + expect(patchTemplateVersion).toBeCalledWith("new-version-id", { + name: MockTemplateVersion.name, + }) + }) +}) diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index d5dcfd71ab..dfd2967367 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -4,7 +4,7 @@ import { useOrganizationId } from "hooks/useOrganizationId" import { usePermissions } from "hooks/usePermissions" import { FC } from "react" import { Helmet } from "react-helmet-async" -import { useParams } from "react-router-dom" +import { useNavigate, useParams } from "react-router-dom" import { pageTitle } from "util/page" import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService" import { useTemplateVersionData } from "./data" @@ -16,9 +16,15 @@ type Params = { export const TemplateVersionEditorPage: FC = () => { const { version: versionName, template: templateName } = useParams() as Params + const navigate = useNavigate() const orgId = useOrganizationId() const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, { context: { orgId }, + actions: { + onPublish: () => { + navigate(`/templates/${templateName}`) + }, + }, }) const permissions = usePermissions() const { isSuccess, data } = useTemplateVersionData( @@ -53,11 +59,27 @@ export const TemplateVersionEditorPage: FC = () => { templateId: data.template.id, }) }} - onUpdate={() => { + onCancelPublish={() => { sendEvent({ - type: "UPDATE_ACTIVE_VERSION", + type: "CANCEL_PUBLISH", }) }} + onPublish={() => { + sendEvent({ + type: "PUBLISH", + }) + }} + onConfirmPublish={(data) => { + sendEvent({ + type: "CONFIRM_PUBLISH", + ...data, + }) + }} + isAskingPublishParameters={editorState.matches( + "askPublishParameters", + )} + publishingError={editorState.context.publishingError} + isPublishing={editorState.matches("publishingVersion")} disablePreview={editorState.hasTag("loading")} disableUpdate={ editorState.hasTag("loading") || diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts new file mode 100644 index 0000000000..ca8dc50c48 --- /dev/null +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts @@ -0,0 +1,4 @@ +export type PublishVersionData = { + name: string + isActiveVersion: boolean +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f504abea3f..0d866762e4 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -669,8 +669,7 @@ export const MockWorkspace: TypesGen.Workspace = { autostart_schedule: MockWorkspaceAutostartEnabled.schedule, ttl_ms: 2 * 60 * 60 * 1000, latest_build: MockWorkspaceBuild, - last_used_at: "", - organization_id: MockOrganization.id, + last_used_at: "2022-05-16T15:29:10.302441433Z", } export const MockStoppedWorkspace: TypesGen.Workspace = { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index ff18cbf6a6..9a0bc8f309 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -4,6 +4,8 @@ import { CreateWorkspaceBuildRequest } from "../api/typesGenerated" import { permissionsToCheck } from "../xServices/auth/authXService" import * as M from "./entities" import { MockGroup, MockWorkspaceQuota } from "./entities" +import fs from "fs" +import path from "path" export const handlers = [ rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => { @@ -318,4 +320,17 @@ export const handlers = [ return res(ctx.status(200), ctx.json([M.MockWorkspaceBuildParameter1])) }, ), + + rest.get("api/v2/files/:fileId", (_, res, ctx) => { + const fileBuffer = fs.readFileSync( + path.resolve(__dirname, "./templateFiles.tar"), + ) + + return res( + ctx.set("Content-Length", fileBuffer.byteLength.toString()), + ctx.set("Content-Type", "application/octet-stream"), + // Respond with the "ArrayBuffer". + ctx.body(fileBuffer), + ) + }), ] diff --git a/site/src/testHelpers/templateFiles.tar b/site/src/testHelpers/templateFiles.tar new file mode 100644 index 0000000000000000000000000000000000000000..b0bbdff89fcb7d9b8c1990e2af1086a1a746506a GIT binary patch literal 27648 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 tarReader, }), + assignPublishingError: assign({ + publishingError: (_, event) => event.data, + }), + clearPublishingError: assign({ publishingError: (_) => undefined }), }, services: { uploadTar: async ({ fileTree, tarReader }) => { @@ -285,28 +305,17 @@ export const templateVersionEditorMachine = createMachine( } return API.getTemplateVersion(ctx.version.id) }, - watchBuildLogs: (ctx) => async (callback) => { - return new Promise((resolve, reject) => { - if (!ctx.version) { - return reject("version must be set") + watchBuildLogs: + ({ version }) => + async (callback) => { + if (!version) { + throw new Error("version must be set") } - const proto = location.protocol === "https:" ? "wss:" : "ws:" - const socket = new WebSocket( - `${proto}//${location.host}/api/v2/templateversions/${ctx.version?.id}/logs?follow=true`, - ) - socket.binaryType = "blob" - socket.addEventListener("message", (event) => { - callback({ type: "ADD_BUILD_LOG", log: JSON.parse(event.data) }) + + return API.watchBuildLogs(version.id, (log) => { + callback({ type: "ADD_BUILD_LOG", log }) }) - socket.addEventListener("error", () => { - reject(new Error("socket errored")) - }) - socket.addEventListener("close", () => { - // When the socket closes, logs have finished streaming! - resolve() - }) - }) - }, + }, getResources: (ctx) => { if (!ctx.version) { throw new Error("template version must be set") @@ -321,16 +330,24 @@ export const templateVersionEditorMachine = createMachine( await API.cancelTemplateVersionBuild(ctx.version.id) } }, - updateActiveVersion: async (ctx) => { - if (!ctx.templateId) { - throw new Error("template must be set") + publishingVersion: async ( + { version, templateId }, + { name, isActiveVersion }, + ) => { + if (!version) { + throw new Error("Version is not set") } - if (!ctx.version) { - throw new Error("template version must be set") + if (!templateId) { + throw new Error("Template is not set") } - await API.updateActiveTemplateVersion(ctx.templateId, { - id: ctx.version.id, - }) + await Promise.all([ + API.patchTemplateVersion(version.id, { name }), + isActiveVersion + ? API.updateActiveTemplateVersion(templateId, { + id: version.id, + }) + : Promise.resolve(), + ]) }, }, },