chore(site): remove template version editor xservice (#10490)

Close https://github.com/coder/coder/issues/9942
This commit is contained in:
Bruno Quaresma
2023-11-02 21:42:33 -03:00
committed by GitHub
parent 03045bd47a
commit c9aeea6f64
8 changed files with 393 additions and 609 deletions
+7
View File
@@ -5,3 +5,10 @@ export const uploadFile = () => {
mutationFn: API.uploadFile,
};
};
export const file = (fileId: string) => {
return {
queryKey: ["files", fileId],
queryFn: () => API.getFile(fileId),
};
};
+16
View File
@@ -129,6 +129,15 @@ export const templateVersionVariables = (versionId: string) => {
};
};
export const createTemplateVersion = (orgId: string) => {
return {
mutationFn: async (request: CreateTemplateVersionRequest) => {
const newVersion = await API.createTemplateVersion(orgId, request);
return newVersion;
},
};
};
export const createAndBuildTemplateVersion = (orgId: string) => {
return {
mutationFn: async (request: CreateTemplateVersionRequest) => {
@@ -216,6 +225,13 @@ export const richParameters = (versionId: string) => {
};
};
export const resources = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId, "resources"],
queryFn: () => API.getTemplateVersionResources(versionId),
};
};
const waitBuildToBeFinished = async (version: TemplateVersion) => {
let data: TemplateVersion;
let jobStatus: ProvisionerJobStatus;
@@ -34,9 +34,9 @@ type Story = StoryObj<typeof TemplateVersionEditor>;
export const Example: Story = {};
export const Logs = {
export const Logs: Story = {
args: {
isBuildingNewVersion: true,
defaultTab: "logs",
buildLogs: MockWorkspaceBuildLogs,
templateVersion: {
...MockTemplateVersion,
@@ -47,7 +47,7 @@ export const Logs = {
export const Resources: Story = {
args: {
isBuildingNewVersion: true,
defaultTab: "resources",
buildLogs: MockWorkspaceBuildLogs,
resources: [
MockWorkspaceResource,
@@ -60,9 +60,9 @@ export const Resources: Story = {
},
};
export const ManyLogs = {
export const WithError = {
args: {
isBuildingNewVersion: true,
defaultTab: "logs",
templateVersion: {
...MockTemplateVersion,
job: {
@@ -49,10 +49,10 @@ import AlertTitle from "@mui/material/AlertTitle";
import { DashboardFullPage } from "components/Dashboard/DashboardLayout";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
export interface TemplateVersionEditorProps {
template: Template;
templateVersion: TemplateVersion;
isBuildingNewVersion: boolean;
defaultFileTree: FileTree;
buildLogs?: ProvisionerJobLog[];
resources?: WorkspaceResource[];
@@ -71,6 +71,7 @@ export interface TemplateVersionEditorProps {
missingVariables?: TemplateVersionVariable[];
onSubmitMissingVariableValues: (values: VariableValue[]) => void;
onCancelSubmitMissingVariableValues: () => void;
defaultTab?: Tab;
}
const topbarHeight = 80;
@@ -92,7 +93,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
disableUpdate,
template,
templateVersion,
isBuildingNewVersion,
defaultFileTree,
onPreview,
onPublish,
@@ -109,11 +109,10 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
missingVariables,
onSubmitMissingVariableValues,
onCancelSubmitMissingVariableValues,
defaultTab,
}) => {
const theme = useTheme();
// If resources are provided, show them by default!
// This is for Storybook!
const [selectedTab, setSelectedTab] = useState(() => (resources ? 1 : 0));
const [selectedTab, setSelectedTab] = useState<Tab>(defaultTab);
const [fileTree, setFileTree] = useState(defaultFileTree);
const [createFileOpen, setCreateFileOpen] = useState(false);
const [deleteFileOpen, setDeleteFileOpen] = useState<string>();
@@ -125,8 +124,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
const triggerPreview = useCallback(() => {
onPreview(fileTree);
// Switch to the build log!
setSelectedTab(0);
setSelectedTab("logs");
}, [fileTree, onPreview]);
// Stop ctrl+s from saving files and make ctrl+enter trigger a preview.
@@ -159,11 +157,12 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
previousVersion.current = templateVersion;
return;
}
if (
["running", "pending"].includes(previousVersion.current.job.status) &&
templateVersion.job.status === "succeeded"
) {
setSelectedTab(1);
setSelectedTab("resources");
setDirty(false);
}
previousVersion.current = templateVersion;
@@ -218,7 +217,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
)}
<div css={styles.topbarSides}>
{isBuildingNewVersion && (
{buildLogs && (
<TemplateVersionStatusBadge version={templateVersion} />
)}
@@ -358,9 +357,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
<div css={styles.tabs}>
<button
css={styles.tab}
className={selectedTab === 0 ? "active" : ""}
className={selectedTab === "logs" ? "active" : ""}
onClick={() => {
setSelectedTab(0);
setSelectedTab("logs");
}}
>
{templateVersion.job.status !== "succeeded" ? (
@@ -374,9 +373,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
{!disableUpdate && (
<button
css={styles.tab}
className={selectedTab === 1 ? "active" : ""}
className={selectedTab === "resources" ? "active" : ""}
onClick={() => {
setSelectedTab(1);
setSelectedTab("resources");
}}
>
<PreviewIcon />
@@ -389,7 +388,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
css={[
styles.panel,
{
display: selectedTab !== 0 ? "none" : "flex",
display: selectedTab !== "logs" ? "none" : "flex",
flexDirection: "column",
},
]}
@@ -427,7 +426,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
styles.panel,
{
paddingBottom: theme.spacing(2),
display: selectedTab !== 1 ? "none" : undefined,
display: selectedTab !== "resources" ? "none" : undefined,
},
]}
>
@@ -4,6 +4,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as api from "api/api";
import {
MockTemplate,
MockTemplateVersion,
MockWorkspaceBuildLogs,
} from "testHelpers/entities";
@@ -17,9 +18,10 @@ jest.mock("components/TemplateResourcesTable/TemplateResourcesTable", () => {
};
});
test("Use custom name, message and set it as active when publishing", async () => {
const user = userEvent.setup();
const renderTemplateEditorPage = () => {
renderWithAuth(<TemplateVersionEditorPage />, {
route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`,
path: "/templates/:template/versions/:version/edit",
extraRoutes: [
{
path: "/templates/:templateId",
@@ -27,16 +29,26 @@ test("Use custom name, message and set it as active when publishing", async () =
},
],
});
};
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
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
const newTemplateVersion = {
...MockTemplateVersion,
id: "new-version-id",
name: "new-version",
};
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "getTemplateVersion")
.mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" });
.spyOn(api, "getTemplateVersionByName")
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "watchBuildLogsByTemplateVersionId")
.mockImplementation((_, options) => {
@@ -52,7 +64,7 @@ test("Use custom name, message and set it as active when publishing", async () =
// Publish
const patchTemplateVersion = jest
.spyOn(api, "patchTemplateVersion")
.mockResolvedValue(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
const updateActiveTemplateVersion = jest
.spyOn(api, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" });
@@ -84,24 +96,22 @@ test("Use custom name, message and set it as active when publishing", async () =
test("Do not mark as active if promote is not checked", async () => {
const user = userEvent.setup();
renderWithAuth(<TemplateVersionEditorPage />, {
extraRoutes: [
{
path: "/templates/:templateId",
element: <div />,
},
],
});
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")
.mockResolvedValueOnce(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "getTemplateVersion")
.mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" });
.spyOn(api, "getTemplateVersionByName")
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "watchBuildLogsByTemplateVersionId")
.mockImplementation((_, options) => {
@@ -117,7 +127,7 @@ test("Do not mark as active if promote is not checked", async () => {
// Publish
const patchTemplateVersion = jest
.spyOn(api, "patchTemplateVersion")
.mockResolvedValue(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
const updateActiveTemplateVersion = jest
.spyOn(api, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" });
@@ -146,30 +156,27 @@ 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 MockTemplateVersionWithEmptyMessage = {
const newTemplateVersion = {
...MockTemplateVersion,
id: "new-version-id",
name: "new-version",
};
const MockTemplateVersionWithEmptyMessage = {
...newTemplateVersion,
message: "",
};
const user = userEvent.setup();
renderWithAuth(<TemplateVersionEditorPage />, {
extraRoutes: [
{
path: "/templates/:templateId",
element: <div />,
},
],
});
renderTemplateEditorPage();
const topbar = await screen.findByTestId("topbar");
// Build Template
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersionWithEmptyMessage);
jest.spyOn(api, "getTemplateVersion").mockResolvedValue({
...MockTemplateVersionWithEmptyMessage,
id: "new-version-id",
});
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
jest
.spyOn(api, "getTemplateVersionByName")
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
jest
.spyOn(api, "watchBuildLogsByTemplateVersionId")
.mockImplementation((_, options) => {
@@ -1,12 +1,35 @@
import { useMachine } from "@xstate/react";
import { TemplateVersionEditor } from "./TemplateVersionEditor";
import { useOrganizationId } from "hooks/useOrganizationId";
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useParams } from "react-router-dom";
import { pageTitle } from "utils/page";
import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService";
import { useTemplateVersionData } from "./data";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
createTemplateVersion,
resources,
templateByName,
templateVersionByName,
templateVersionVariables,
} from "api/queries/templates";
import { file, uploadFile } from "api/queries/files";
import { TarFileTypeCodes, TarReader, TarWriter } from "utils/tar";
import { FileTree, traverse } from "utils/filetree";
import {
createTemplateVersionFileTree,
isAllowedFile,
} from "utils/templateVersion";
import {
patchTemplateVersion,
updateActiveTemplateVersion,
watchBuildLogsByTemplateVersionId,
} from "api/api";
import {
PatchTemplateVersionRequest,
ProvisionerJobLog,
TemplateVersion,
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
type Params = {
version: string;
@@ -14,25 +37,69 @@ type Params = {
};
export const TemplateVersionEditorPage: FC = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { version: versionName, template: templateName } =
const { version: initialVersionName, template: templateName } =
useParams() as Params;
const orgId = useOrganizationId();
const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, {
context: { orgId },
});
const { isSuccess, data } = useTemplateVersionData(
{
orgId,
templateName,
versionName,
},
{
onSuccess(data) {
sendEvent({ type: "INITIALIZE", tarReader: data.tarReader });
},
},
const [currentVersionName, setCurrentVersionName] =
useState(initialVersionName);
const templateQuery = useQuery(templateByName(orgId, templateName));
const templateVersionOptions = templateVersionByName(
orgId,
templateName,
currentVersionName,
);
const templateVersionQuery = useQuery({
...templateVersionOptions,
keepPreviousData: true,
});
const uploadFileMutation = useMutation(uploadFile());
const createTemplateVersionMutation = useMutation(
createTemplateVersion(orgId),
);
const resourcesQuery = useQuery({
...resources(templateVersionQuery.data?.id ?? ""),
enabled: templateVersionQuery.data?.job.status === "succeeded",
});
const { logs, setLogs } = useVersionLogs(templateVersionQuery.data, {
onDone: templateVersionQuery.refetch,
});
const { fileTree, tarFile } = useFileTree(templateVersionQuery.data);
const {
missingVariables,
setIsMissingVariablesDialogOpen,
isMissingVariablesDialogOpen,
} = useMissingVariables(templateVersionQuery.data);
// Handle template publishing
const [isPublishingDialogOpen, setIsPublishingDialogOpen] = useState(false);
const publishVersionMutation = useMutation({
mutationFn: publishVersion,
});
const [lastSuccessfulPublishedVersion, setLastSuccessfulPublishedVersion] =
useState<TemplateVersion>();
// 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) => {
setCurrentVersionName(newVersion.name);
queryClient.setQueryData(templateVersionOptions.queryKey, newVersion);
};
return (
<>
@@ -40,45 +107,54 @@ export const TemplateVersionEditorPage: FC = () => {
<title>{pageTitle(`${templateName} · Template Editor`)}</title>
</Helmet>
{isSuccess && (
{templateQuery.data && templateVersionQuery.data && fileTree && (
<TemplateVersionEditor
template={data.template}
templateVersion={editorState.context.version || data.version}
isBuildingNewVersion={Boolean(editorState.context.version)}
defaultFileTree={data.fileTree}
onPreview={(fileTree) => {
sendEvent({
type: "CREATE_VERSION",
fileTree,
templateId: data.template.id,
template={templateQuery.data}
templateVersion={templateVersionQuery.data}
defaultFileTree={fileTree}
onPreview={async (newFileTree) => {
if (!tarFile) {
return;
}
onBuildStart();
const newVersionFile = await generateVersionFiles(
tarFile,
newFileTree,
);
const serverFile = await uploadFileMutation.mutateAsync(
newVersionFile,
);
const newVersion = await createTemplateVersionMutation.mutateAsync({
provisioner: "terraform",
storage_method: "file",
tags: {},
template_id: templateQuery.data.id,
file_id: serverFile.hash,
});
onBuildEnds(newVersion);
}}
onPublish={() => {
sendEvent({
type: "PUBLISH",
});
setIsPublishingDialogOpen(true);
}}
onCancelPublish={() => {
sendEvent({
type: "CANCEL_PUBLISH",
});
setIsPublishingDialogOpen(false);
}}
onConfirmPublish={(data) => {
sendEvent({
type: "CONFIRM_PUBLISH",
...data,
onConfirmPublish={async ({ isActiveVersion, ...data }) => {
await publishVersionMutation.mutateAsync({
isActiveVersion,
data,
version: templateVersionQuery.data,
});
setIsPublishingDialogOpen(false);
setLastSuccessfulPublishedVersion(templateVersionQuery.data);
}}
isAskingPublishParameters={editorState.matches(
"askPublishParameters",
)}
isPublishing={editorState.matches("publishingVersion")}
publishingError={editorState.context.publishingError}
publishedVersion={editorState.context.lastSuccessfulPublishedVersion}
isAskingPublishParameters={isPublishingDialogOpen}
isPublishing={publishVersionMutation.isLoading}
publishingError={publishVersionMutation.error}
publishedVersion={lastSuccessfulPublishedVersion}
onCreateWorkspace={() => {
const params = new URLSearchParams();
const publishedVersion =
editorState.context.lastSuccessfulPublishedVersion;
const publishedVersion = lastSuccessfulPublishedVersion;
if (publishedVersion) {
params.set("version", publishedVersion.id);
}
@@ -86,25 +162,40 @@ export const TemplateVersionEditorPage: FC = () => {
`/templates/${templateName}/workspace?${params.toString()}`,
);
}}
disablePreview={editorState.hasTag("loading")}
disableUpdate={
editorState.hasTag("loading") ||
editorState.context.version?.job.status !== "succeeded"
disablePreview={
templateVersionQuery.data.job.status === "running" ||
templateVersionQuery.data.job.status === "pending" ||
createTemplateVersionMutation.isLoading ||
uploadFileMutation.isLoading
}
resources={editorState.context.resources}
buildLogs={editorState.context.buildLogs}
isPromptingMissingVariables={editorState.matches("promptVariables")}
missingVariables={editorState.context.missingVariables}
onSubmitMissingVariableValues={(values) => {
sendEvent({
type: "SET_MISSING_VARIABLE_VALUES",
values,
disableUpdate={
templateVersionQuery.data.job.status !== "succeeded" ||
templateVersionQuery.data.name === initialVersionName ||
templateVersionQuery.data.name ===
lastSuccessfulPublishedVersion?.name
}
resources={resourcesQuery.data}
buildLogs={logs}
isPromptingMissingVariables={isMissingVariablesDialogOpen}
missingVariables={missingVariables}
onSubmitMissingVariableValues={async (values) => {
if (!uploadFileMutation.data) {
return;
}
onBuildStart();
const newVersion = await createTemplateVersionMutation.mutateAsync({
provisioner: "terraform",
storage_method: "file",
tags: {},
template_id: templateQuery.data.id,
file_id: uploadFileMutation.data.hash,
user_variable_values: values,
});
onBuildEnds(newVersion);
setIsMissingVariablesDialogOpen(false);
}}
onCancelSubmitMissingVariableValues={() => {
sendEvent({
type: "CANCEL_MISSING_VARIABLE_VALUES",
});
setIsMissingVariablesDialogOpen(false);
}}
/>
)}
@@ -112,4 +203,163 @@ export const TemplateVersionEditorPage: FC = () => {
);
};
const useFileTree = (templateVersion: TemplateVersion | undefined) => {
const fileQuery = useQuery({
...file(templateVersion?.job.file_id ?? ""),
enabled: templateVersion !== undefined,
});
const [state, setState] = useState<{
fileTree?: FileTree;
tarFile?: TarReader;
}>({
fileTree: undefined,
tarFile: undefined,
});
useEffect(() => {
const initializeFileTree = async (file: ArrayBuffer) => {
const tarFile = new TarReader();
await tarFile.readFile(file);
const fileTree = await createTemplateVersionFileTree(tarFile);
setState({ fileTree, tarFile });
};
if (fileQuery.data) {
initializeFileTree(fileQuery.data).catch(() => {
displayError("Error on initializing the editor");
});
}
}, [fileQuery.data]);
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 { data: missingVariables } = useQuery({
...templateVersionVariables(templateVersion?.id ?? ""),
enabled: templateVersion?.job.error_code === "REQUIRED_TEMPLATE_VARIABLES",
});
const [isMissingVariablesDialogOpen, setIsMissingVariablesDialogOpen] =
useState(false);
useEffect(() => {
if (missingVariables) {
setIsMissingVariablesDialogOpen(true);
}
}, [missingVariables]);
return {
missingVariables,
isMissingVariablesDialogOpen,
setIsMissingVariablesDialogOpen,
};
};
const generateVersionFiles = async (
tarReader: TarReader,
fileTree: FileTree,
) => {
const tar = new TarWriter();
// Add previous non editable files
for (const file of tarReader.fileInfo) {
if (!isAllowedFile(file.name)) {
if (file.type === TarFileTypeCodes.Dir) {
tar.addFolder(file.name, {
mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42
mtime: file.mtime,
user: file.user,
group: file.group,
});
} else {
tar.addFile(file.name, tarReader.getTextFile(file.name) as string, {
mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42
mtime: file.mtime,
user: file.user,
group: file.group,
});
}
}
}
// Add the editable files
traverse(fileTree, (content, _filename, fullPath) => {
// When a file is deleted. Don't add it to the tar.
if (content === undefined) {
return;
}
if (typeof content === "string") {
tar.addFile(fullPath, content);
return;
}
tar.addFolder(fullPath);
});
const blob = (await tar.write()) as Blob;
return new File([blob], "template.tar");
};
const publishVersion = async (options: {
version: TemplateVersion;
data: PatchTemplateVersionRequest;
isActiveVersion: boolean;
}) => {
const { version, data, isActiveVersion } = options;
const haveChanges =
data.name !== version.name || data.message !== version.message;
const publishActions: Promise<unknown>[] = [];
if (haveChanges) {
publishActions.push(patchTemplateVersion(version.id, data));
}
if (isActiveVersion) {
publishActions.push(
updateActiveTemplateVersion(version.template_id!, {
id: version.id,
}),
);
}
return Promise.all(publishActions);
};
export default TemplateVersionEditorPage;
@@ -1,47 +0,0 @@
import { useQuery, UseQueryOptions } from "react-query";
import { getFile, getTemplateByName, getTemplateVersionByName } from "api/api";
import { TarReader } from "utils/tar";
import { createTemplateVersionFileTree } from "utils/templateVersion";
const getTemplateVersionData = async (
orgId: string,
templateName: string,
versionName: string,
) => {
const [template, version] = await Promise.all([
getTemplateByName(orgId, templateName),
getTemplateVersionByName(orgId, templateName, versionName),
]);
const tarFile = await getFile(version.job.file_id);
const tarReader = new TarReader();
await tarReader.readFile(tarFile);
const fileTree = await createTemplateVersionFileTree(tarReader);
return {
template,
version,
fileTree,
tarReader,
};
};
type GetTemplateVersionResponse = Awaited<
ReturnType<typeof getTemplateVersionData>
>;
type UseTemplateVersionDataParams = {
orgId: string;
templateName: string;
versionName: string;
};
export const useTemplateVersionData = (
{ templateName, versionName, orgId }: UseTemplateVersionDataParams,
options?: UseQueryOptions<GetTemplateVersionResponse>,
) => {
return useQuery({
queryKey: ["templateVersion", templateName, versionName],
queryFn: () => getTemplateVersionData(orgId, templateName, versionName),
...options,
});
};
@@ -1,448 +0,0 @@
import {
ProvisionerJobLog,
ProvisionerJobStatus,
TemplateVersion,
TemplateVersionVariable,
UploadResponse,
VariableValue,
WorkspaceResource,
} from "api/typesGenerated";
import { assign, createMachine } from "xstate";
import * as API from "api/api";
import { FileTree, traverse } from "utils/filetree";
import { isAllowedFile } from "utils/templateVersion";
import { TarReader, TarWriter } from "utils/tar";
import { PublishVersionData } from "pages/TemplateVersionEditorPage/types";
export interface TemplateVersionEditorMachineContext {
orgId: string;
templateId?: string;
fileTree?: FileTree;
uploadResponse?: UploadResponse;
version?: TemplateVersion;
resources?: WorkspaceResource[];
buildLogs?: ProvisionerJobLog[];
tarReader?: TarReader;
publishingError?: unknown;
lastSuccessfulPublishedVersion?: TemplateVersion;
missingVariables?: TemplateVersionVariable[];
missingVariableValues?: VariableValue[];
}
export const templateVersionEditorMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QBcwFsAOAbAhqgamAE6wCWA9gHYCiEpy5RAdKZfaTlqQF6tQDEASQByggCqCAggBlBALWoBtAAwBdRKAzkyyCpQ0gAHogC0AFgDsypgDYAnAFZlFpzefKbADgA0IAJ6IAIzKngDMTIF2FmYONoFW9s4Avkm+qJi4BMRkVLT0jCwQWGD8AMIAStSSYtQA+vjU5QDKggDywirqSCBaOnoGxggmoTYOTKEATIEOThNxDmajvgEIgTFjDhaeNpPTZpbRKWno2HhghCR6eQzMpEUlAAoAqgBCsk0AEp0GvexUA6Y5mNlKEzBNPJ5Ap4JjCbEt-EEJsozEw7MoHKEQhYJqFPBYbBYjiB0qcspdcnQboVivwACKCJoAWQZTVqzzeDI+tRekmEwka326v10-26gxMcM8TGcoRGc0CNjMyg8yyCkzsqOUkWcnjMEJhRJJmXO2SulIKOFgAGsHgBXABGXFgAAsHjgiDg0GBUCQyrzStRpGzXu8vmofto-voxaZAvFxnZ9mFonY7NswarVuCLEwLPjQlCPGsJodUsSTsaLjkaObmJabQ6na73Z7vdkyu0AGKCcqM4Mcz6CzSRkXR0CDOM5ta6ya4jHbZR2TNx1O5iHTHEWQLgzyGitnKtm-LMDCN0guviHqj8CBUMAsSgAN3IVvvp8d5+dl9NVCHPRH-QxggSrWOioSOHCdhzCEMzLuCGoFqMkJmGsgRynuGQHj+NbHkw75Nt+5KUPwxBEAUpIAGaMGgeFnhelBQFelB-sKgHjkEcbStESKbGYaZFoEy6LlKcJePE4IzDYEwYaSJpEdcBQAMY4JQilgFwDGCJQDxkVARBwLALy2qQWAQDed4Ps+r5MMpqnqUZJkQCxAGiuxQyhA4UpmLiLhePYaEjMuFgFqibjOPmqbKNJZZGlh8m1kwtrYOQOAQGI7rmZQ96sFZ95JVgKVpe6zl9K5RimFETAljioI4mmdgjBYy7QhsepWE4DVQShMmVthCnMIp+l4HwDmmZl2VPi+96DWAZyjU54ZCi5Y7lcBDgTNKeITGm+LYutNjNRMrV4uii7gRM+w9XF1b9UwADueCKV+DHzdI5BQLA-CSLStLck8gjSL90itAA4iVUYAggjg5o4UxJutkzbEFDgakionbImMKeVdZI3QlD3IE9I3GaZb0ffwLz-YDtS0u0SiLcOpUrYMvlMJJowwgqiZpsumPSoWnjIrEMTBTjcl47hBNEy9JMQGTn2lP6gb1I0LTtODo6QyYeKoidaZxBd0JxMuUnWCjFipiMITeeBYtMbdUvPVAr3vQrlTVHUDTNG0HQM-+TNa3ENi5vEnjQ6m20o4dgS2GC6L1W4upmHbfUJRR3rS4x2HjZZU1MOnhPOkxGtsatUkorqmxWLKFuC4JCIIHDwcuAj60uKCiwp-FuEF5nTE5zlee90X2GKIEXSMxDQHBDsuZah5cbBHYAWZk3uYzJEDVxlJdhdxLVIYGRmDIPg7ocI6cBMAVqV8Iy55kAxp9EOfxSfbeWW59ZsW40eB9HxgJ8z44AvrAK+hVb730vEAkBCBB7KVHJ0EuZVBhhVRAFTwvFI5TFXrVJg3l9Qll1CEGwe9f7kX-oA5+wDX7UhKE0agYhajMiaC0YQIN6iSHKFIN4nsZBPGoE0JBzNEAEgiDuWUippgW28qvdaG0rBrB3iEKKhIYr7h-hSXCh9yDHyfi-S+dwaSK2EAGIMzDWHsPwJw7h0heHSH4YIv2rFkFBEklVUI0ipgNSsPsVeJZg5RCxmhWYkRQikM0VSYe5Q4DkFtEQNSb8LKD2sjAZA0TYCxPiXAIRkNixjDQpCDwgTtz4hNk4WwDgFTKgxHxLcYSiSUHIBAOABhv7izIUQCMAcgImC3MHGUcog4gQOg3VMYx7DREWDEaEkJorHEwhonCVJWDsE4DwPgXSp5uQlCFPpUIkTInsBmBucYPARGiFFSYJYPAFnCUsgohiwCbM1j0gs8jqlgjRKmbcy4PIbTxEnI6BYYIODubdesdoPwujdB6L0Pp4BLW6ds7cGow6eVlOta2YIRkrG3MiTUGIsQ4jxASMFCV8KfkItWZ5pdBgeSlM4GYwtrZOF8ScuM4QkTgX2ASZUaZ6nzNkvbBKtk1IaSgFpHS719KwEMrLGlLihhKgZSUuuQIwg4tcZVYSWp4hSW2GEMluF8qFXSp0xFWzVrDEcNKSY6JDYzxxA4Q64R1ptxBEcw5RqqQzWGjLRyCrhGrEWGzDxVgwjeXWpCHwJzoSuqOkCKEnlwQkLUQs9pESCiO2Jo5eWgbIZwg2nHeeSIrAesOv0o5BIwR6lxLvNNQrU49wzk7Ji+agJeE5QSfyTgUYwjMKvLUGwbl2FGAU7qDberdz-jogBejqEtItS8ty20Y6JkWDCFw0zjkrCxuEFCCxLBxDzF6yd10Ol4QofOkBYCb4MTvrKqBVCQHtrcrKDU66pIlgWJ5HdiAcRKjQQsD1wRtoCvLOm4VWir3QJoY819q0wioo6mjLY2JFg4MVHg6Y4FZzbn7d6goUSYlxISQhicHiEJxCVCMewiotSrwKbYXYwQPAoTxCkFIQA */
predictableActionArguments: true,
id: "templateVersionEditor",
schema: {
context: {} as TemplateVersionEditorMachineContext,
events: {} as
| { type: "INITIALIZE"; tarReader: TarReader }
| {
type: "CREATE_VERSION";
fileTree: FileTree;
templateId: string;
}
| { type: "CANCEL_VERSION" }
| { type: "SET_MISSING_VARIABLE_VALUES"; values: VariableValue[] }
| { type: "CANCEL_MISSING_VARIABLE_VALUES" }
| { type: "ADD_BUILD_LOG"; log: ProvisionerJobLog }
| { type: "BUILD_DONE" }
| { type: "PUBLISH" }
| ({ type: "CONFIRM_PUBLISH" } & PublishVersionData)
| { type: "CANCEL_PUBLISH" },
services: {} as {
uploadTar: {
data: UploadResponse;
};
createBuild: {
data: TemplateVersion;
};
cancelBuild: {
data: void;
};
fetchVersion: {
data: TemplateVersion;
};
getResources: {
data: WorkspaceResource[];
};
publishingVersion: {
data: void;
};
loadMissingVariables: {
data: TemplateVersionVariable[];
};
},
},
tsTypes: {} as import("./templateVersionEditorXService.typegen").Typegen0,
initial: "initializing",
states: {
initializing: {
on: {
INITIALIZE: {
actions: ["assignTarReader"],
target: "idle",
},
},
},
idle: {
on: {
CREATE_VERSION: {
actions: ["assignCreateBuild"],
target: "cancelingInProgressBuild",
},
PUBLISH: {
target: "askPublishParameters",
},
},
},
askPublishParameters: {
on: {
CANCEL_PUBLISH: "idle",
CONFIRM_PUBLISH: "publishingVersion",
},
},
publishingVersion: {
tags: "loading",
entry: ["clearPublishingError", "clearLastSuccessfulPublishedVersion"],
invoke: {
id: "publishingVersion",
src: "publishingVersion",
onError: {
actions: ["assignPublishingError"],
target: "askPublishParameters",
},
onDone: {
actions: ["assignLastSuccessfulPublishedVersion"],
target: ["idle"],
},
},
},
cancelingInProgressBuild: {
tags: "loading",
invoke: {
id: "cancelBuild",
src: "cancelBuild",
onDone: {
target: "uploadTar",
},
},
},
uploadTar: {
tags: "loading",
invoke: {
id: "uploadTar",
src: "uploadTar",
onDone: {
target: "creatingBuild",
actions: "assignUploadResponse",
},
},
},
creatingBuild: {
tags: "loading",
invoke: {
id: "createBuild",
src: "createBuild",
onDone: {
actions: "assignBuild",
target: "watchingBuildLogs",
},
},
},
watchingBuildLogs: {
tags: "loading",
invoke: {
id: "watchBuildLogs",
src: "watchBuildLogs",
},
on: {
ADD_BUILD_LOG: {
actions: "addBuildLog",
},
BUILD_DONE: "fetchingVersion",
CANCEL_VERSION: {
target: "cancelingInProgressBuild",
},
CREATE_VERSION: {
actions: ["assignCreateBuild"],
target: "uploadTar",
},
},
},
fetchingVersion: {
tags: "loading",
invoke: {
id: "fetchVersion",
src: "fetchVersion",
onDone: [
{
actions: ["assignBuild"],
target: "promptVariables",
cond: "jobFailedWithMissingVariables",
},
{
actions: ["assignBuild"],
target: "fetchResources",
},
],
},
},
promptVariables: {
initial: "loadingMissingVariables",
states: {
loadingMissingVariables: {
invoke: {
src: "loadMissingVariables",
onDone: {
actions: "assignMissingVariables",
target: "idle",
},
},
},
idle: {
on: {
SET_MISSING_VARIABLE_VALUES: {
actions: "assignMissingVariableValues",
target: "#templateVersionEditor.creatingBuild",
},
CANCEL_MISSING_VARIABLE_VALUES: {
target: "#templateVersionEditor.idle",
},
},
},
},
},
fetchResources: {
tags: "loading",
invoke: {
id: "getResources",
src: "getResources",
onDone: {
actions: ["assignResources"],
target: "idle",
},
},
},
},
},
{
actions: {
assignCreateBuild: assign({
fileTree: (_, event) => event.fileTree,
templateId: (_, event) => event.templateId,
buildLogs: (_, _1) => [],
resources: (_, _1) => [],
}),
assignResources: assign({
resources: (_, event) => event.data,
}),
assignUploadResponse: assign({
uploadResponse: (_, event) => event.data,
}),
assignBuild: assign({
version: (_, event) => event.data,
}),
assignLastSuccessfulPublishedVersion: assign({
lastSuccessfulPublishedVersion: (ctx) => ctx.version,
version: () => undefined,
}),
addBuildLog: assign({
buildLogs: (context, event) => {
const previousLogs = context.buildLogs ?? [];
return [...previousLogs, event.log];
},
// Instead of periodically fetching the version,
// we just assume the state is running after the first log.
//
// The machine fetches the version after the log stream ends anyways!
version: (context) => {
if (!context.version || context.buildLogs?.length !== 0) {
return context.version;
}
return {
...context.version,
job: {
...context.version.job,
status: "running" as ProvisionerJobStatus,
},
};
},
}),
assignTarReader: assign({
tarReader: (_, { tarReader }) => tarReader,
}),
assignPublishingError: assign({
publishingError: (_, event) => event.data,
}),
clearPublishingError: assign({ publishingError: (_) => undefined }),
clearLastSuccessfulPublishedVersion: assign({
lastSuccessfulPublishedVersion: (_) => undefined,
}),
assignMissingVariables: assign({
missingVariables: (_, event) => event.data,
}),
assignMissingVariableValues: assign({
missingVariableValues: (_, event) => event.values,
}),
},
services: {
uploadTar: async ({ fileTree, tarReader }) => {
if (!fileTree) {
throw new Error("file tree must to be set");
}
if (!tarReader) {
throw new Error("tar reader must to be set");
}
const tar = new TarWriter();
// Add previous non editable files
for (const file of tarReader.fileInfo) {
if (!isAllowedFile(file.name)) {
if (file.type === "5") {
tar.addFolder(file.name, {
mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42
mtime: file.mtime,
user: file.user,
group: file.group,
});
} else {
tar.addFile(
file.name,
tarReader.getTextFile(file.name) as string,
{
mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42
mtime: file.mtime,
user: file.user,
group: file.group,
},
);
}
}
}
// Add the editable files
traverse(fileTree, (content, _filename, fullPath) => {
// When a file is deleted. Don't add it to the tar.
if (content === undefined) {
return;
}
if (typeof content === "string") {
tar.addFile(fullPath, content);
return;
}
tar.addFolder(fullPath);
});
const blob = (await tar.write()) as Blob;
return API.uploadFile(new File([blob], "template.tar"));
},
createBuild: (ctx) => {
if (!ctx.uploadResponse) {
throw new Error("no upload response");
}
return API.createTemplateVersion(ctx.orgId, {
provisioner: "terraform",
storage_method: "file",
tags: {},
template_id: ctx.templateId,
file_id: ctx.uploadResponse.hash,
user_variable_values: ctx.missingVariableValues,
});
},
fetchVersion: (ctx) => {
if (!ctx.version) {
throw new Error("template version must be set");
}
return API.getTemplateVersion(ctx.version.id);
},
watchBuildLogs:
({ version }) =>
async (callback) => {
if (!version) {
throw new Error("version must be set");
}
const socket = API.watchBuildLogsByTemplateVersionId(version.id, {
onMessage: (log) => {
callback({ type: "ADD_BUILD_LOG", log });
},
onDone: () => {
callback({ type: "BUILD_DONE" });
},
onError: (error) => {
console.error(error);
},
});
return () => {
socket.close();
};
},
getResources: (ctx) => {
if (!ctx.version) {
throw new Error("template version must be set");
}
return API.getTemplateVersionResources(ctx.version.id);
},
cancelBuild: async (ctx) => {
if (!ctx.version) {
return;
}
if (ctx.version.job.status === "running") {
await API.cancelTemplateVersionBuild(ctx.version.id);
}
},
publishingVersion: async (
{ version, templateId },
{ name, message, isActiveVersion },
) => {
if (!version) {
throw new Error("Version is not set");
}
if (!templateId) {
throw new Error("Template is not set");
}
const haveChanges =
name !== version.name || message !== version.message;
await Promise.all([
haveChanges
? API.patchTemplateVersion(version.id, { name, message })
: Promise.resolve(),
isActiveVersion
? API.updateActiveTemplateVersion(templateId, {
id: version.id,
})
: Promise.resolve(),
]);
},
loadMissingVariables: ({ version }) => {
if (!version) {
throw new Error("Version is not set");
}
const variables = API.getTemplateVersionVariables(version.id);
return variables;
},
},
guards: {
jobFailedWithMissingVariables: (_, { data }) => {
return data.job.error_code === "REQUIRED_TEMPLATE_VARIABLES";
},
},
},
);