diff --git a/site/jest.config.ts b/site/jest.config.ts index 4dceb389ab318..de1f518e97563 100644 --- a/site/jest.config.ts +++ b/site/jest.config.ts @@ -16,6 +16,7 @@ module.exports = { transform: { react: { runtime: "automatic", + importSource: "@emotion/react", }, }, experimental: { diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 76e4a51b5d9e3..2a17c9dc9a62b 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -47,6 +47,7 @@ global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom global.Blob = Blob as any; +global.scrollTo = jest.fn(); // Polyfill the getRandomValues that is used on utils/random.ts Object.defineProperty(global.self, "crypto", { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 561000e39ef9a..8ff9169c162b0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1104,7 +1104,7 @@ export const getTemplateExamples = async ( return response.data; }; -export const uploadTemplateFile = async ( +export const uploadFile = async ( file: File, ): Promise => { const response = await axios.post("/api/v2/files", file, { diff --git a/site/src/api/queries/files.ts b/site/src/api/queries/files.ts new file mode 100644 index 0000000000000..5fd3250d50106 --- /dev/null +++ b/site/src/api/queries/files.ts @@ -0,0 +1,7 @@ +import * as API from "api/api"; + +export const uploadFile = () => { + return { + mutationFn: API.uploadFile, + }; +}; diff --git a/site/src/api/queries/templateVersions.ts b/site/src/api/queries/templateVersions.ts new file mode 100644 index 0000000000000..c73e75c0e3d34 --- /dev/null +++ b/site/src/api/queries/templateVersions.ts @@ -0,0 +1,8 @@ +import * as API from "api/api"; + +export const templateVersionLogs = (versionId: string) => { + return { + queryKey: ["templateVersion", versionId, "logs"], + queryFn: () => API.getTemplateVersionLogs(versionId), + }; +}; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 1c5dfb6bef9ca..a25eea3753bbc 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -5,6 +5,8 @@ import { type CreateTemplateVersionRequest, type ProvisionerJobStatus, type TemplateVersion, + CreateTemplateRequest, + ProvisionerJob, } from "api/typesGenerated"; import { type QueryClient, type QueryOptions } from "react-query"; import { delay } from "utils/delay"; @@ -80,25 +82,10 @@ export const templateVersionVariables = (versionId: string) => { export const createAndBuildTemplateVersion = (orgId: string) => { return { - mutationFn: async ( - request: CreateTemplateVersionRequest, - ): Promise => { + mutationFn: async (request: CreateTemplateVersionRequest) => { const newVersion = await API.createTemplateVersion(orgId, request); - - let data: TemplateVersion; - let jobStatus: ProvisionerJobStatus; - do { - await delay(1000); - data = await API.getTemplateVersion(newVersion.id); - jobStatus = data.job.status; - - if (jobStatus === "succeeded") { - return newVersion.id; - } - } while (jobStatus === "pending" || jobStatus === "running"); - - // No longer pending/running, but didn't succeed - throw data.job.error; + await waitBuildToBeFinished(newVersion); + return newVersion; }, }; }; @@ -133,3 +120,53 @@ export const templateVersionExternalAuth = (versionId: string) => { queryFn: () => API.getTemplateVersionExternalAuth(versionId), }; }; + +export const createTemplate = () => { + return { + mutationFn: createTemplateFn, + }; +}; + +const createTemplateFn = async (options: { + organizationId: string; + version: CreateTemplateVersionRequest; + template: Omit; +}) => { + const version = await API.createTemplateVersion( + options.organizationId, + options.version, + ); + await waitBuildToBeFinished(version); + return API.createTemplate(options.organizationId, { + ...options.template, + template_version_id: version.id, + }); +}; + +const waitBuildToBeFinished = async (version: TemplateVersion) => { + let data: TemplateVersion; + let jobStatus: ProvisionerJobStatus; + do { + await delay(1000); + data = await API.getTemplateVersion(version.id); + jobStatus = data.job.status; + + if (jobStatus === "succeeded") { + return version.id; + } + } while (jobStatus === "pending" || jobStatus === "running"); + + // No longer pending/running, but didn't succeed + throw new JobError(data.job, version); +}; + +export class JobError extends Error { + public job: ProvisionerJob; + public version: TemplateVersion; + + constructor(job: ProvisionerJob, version: TemplateVersion) { + super(job.error); + this.job = job; + this.version = version; + } +} diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx index d366c4a1e3206..9ffdcbfe0059e 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx @@ -1,4 +1,5 @@ import { + MockTemplate, MockTemplateExample, MockTemplateVersionVariable1, MockTemplateVersionVariable2, @@ -21,16 +22,26 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Initial: Story = {}; +export const Upload: Story = { + args: { + upload: { + isUploading: false, + onRemove: () => {}, + onUpload: () => {}, + file: undefined, + }, + }, +}; -export const WithStarterTemplate: Story = { +export const StarterTemplate: Story = { args: { starterTemplate: MockTemplateExample, }, }; -export const WithVariables: Story = { +export const DuplicateTemplateWithVariables: Story = { args: { + copiedTemplate: MockTemplate, variables: [ MockTemplateVersionVariable1, MockTemplateVersionVariable2, @@ -43,6 +54,7 @@ export const WithVariables: 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: [ diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 06a260ee65437..a7dbe7efb4448 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -6,6 +6,7 @@ import { Template, TemplateExample, TemplateVersionVariable, + VariableValue, } from "api/typesGenerated"; import { Stack } from "components/Stack/Stack"; import { TemplateUpload, TemplateUploadProps } from "./TemplateUpload"; @@ -18,7 +19,6 @@ import { onChangeTrimmed, templateDisplayNameValidator, } from "utils/formUtils"; -import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"; import * as Yup from "yup"; import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { @@ -42,51 +42,27 @@ import { AutostopRequirementWeeksHelperText, } from "pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText"; import MenuItem from "@mui/material/MenuItem"; +import { TemplateAutostopRequirementDaysValue } from "utils/schedule"; const MAX_DESCRIPTION_CHAR_LIMIT = 128; const MAX_TTL_DAYS = 30; -const hours = (h: number) => (h === 1 ? "hour" : "hours"); - -const DefaultTTLHelperText = (props: { ttl?: number }) => { - const { ttl = 0 } = props; - - // Error will show once field is considered touched - if (ttl < 0) { - return null; - } - - if (ttl === 0) { - return Workspaces will run until stopped manually.; - } - - return ( - - Workspaces will default to stopping after {ttl} {hours(ttl)} without - activity. - - ); -}; - -const MaxTTLHelperText = (props: { ttl?: number }) => { - const { ttl = 0 } = props; - - // Error will show once field is considered touched - if (ttl < 0) { - return null; - } - - if (ttl === 0) { - return Workspaces may run indefinitely.; - } - - return ( - - Workspaces must stop within {ttl} {hours(ttl)} of starting, regardless of - any active connections. - - ); -}; +export interface CreateTemplateData { + name: string; + display_name: string; + description: string; + icon: string; + default_ttl_hours: number; + max_ttl_hours: number; + autostop_requirement_days_of_week: TemplateAutostopRequirementDaysValue; + autostop_requirement_weeks: number; + allow_user_autostart: boolean; + allow_user_autostop: boolean; + allow_user_cancel_workspace_jobs: boolean; + parameter_values_by_name?: Record; + user_variable_values?: VariableValue[]; + allow_everyone_group_access: boolean; +} const validationSchema = Yup.object({ name: nameValidator("Name"), @@ -198,43 +174,47 @@ const getInitialValues = ({ return initialValues; }; -export interface CreateTemplateFormProps { +type CopiedTemplateForm = { copiedTemplate: Template }; +type StarterTemplateForm = { starterTemplate: TemplateExample }; +type UploadTemplateForm = { upload: TemplateUploadProps }; + +export type CreateTemplateFormProps = ( + | CopiedTemplateForm + | StarterTemplateForm + | UploadTemplateForm +) & { onCancel: () => void; onSubmit: (data: CreateTemplateData) => void; isSubmitting: boolean; - upload: TemplateUploadProps; - starterTemplate?: TemplateExample; variables?: TemplateVersionVariable[]; error?: unknown; jobError?: string; logs?: ProvisionerJobLog[]; allowAdvancedScheduling: boolean; - copiedTemplate?: Template; allowDisableEveryoneAccess: boolean; allowAutostopRequirement: boolean; -} +}; -export const CreateTemplateForm: FC = ({ - onCancel, - onSubmit, - starterTemplate, - copiedTemplate, - variables, - isSubmitting, - upload, - error, - jobError, - logs, - allowAdvancedScheduling, - allowDisableEveryoneAccess, - allowAutostopRequirement, -}) => { +export const CreateTemplateForm: FC = (props) => { + const { + onCancel, + onSubmit, + variables, + isSubmitting, + error, + jobError, + logs, + allowAdvancedScheduling, + allowDisableEveryoneAccess, + allowAutostopRequirement, + } = props; const styles = useStyles(); const form = useFormik({ initialValues: getInitialValues({ allowAdvancedScheduling, - fromExample: starterTemplate, - fromCopy: copiedTemplate, + fromExample: + "starterTemplate" in props ? props.starterTemplate : undefined, + fromCopy: "copiedTemplate" in props ? props.copiedTemplate : undefined, variables, }), validationSchema, @@ -281,16 +261,18 @@ export const CreateTemplateForm: FC = ({ description="The name is used to identify the template in URLs and the API." > - {starterTemplate ? ( - - ) : copiedTemplate ? ( - - ) : ( + {"starterTemplate" in props && ( + + )} + {"copiedTemplate" in props && ( + + )} + {"upload" in props && ( { await fillNameAndDisplayWithFilename(file.name, form); - upload.onUpload(file); + props.upload.onUpload(file); }} /> )} @@ -637,6 +619,48 @@ const fillNameAndDisplayWithFilename = async ( ]); }; +const hours = (h: number) => (h === 1 ? "hour" : "hours"); + +const DefaultTTLHelperText = (props: { ttl?: number }) => { + const { ttl = 0 } = props; + + // Error will show once field is considered touched + if (ttl < 0) { + return null; + } + + if (ttl === 0) { + return Workspaces will run until stopped manually.; + } + + return ( + + Workspaces will default to stopping after {ttl} {hours(ttl)} without + activity. + + ); +}; + +const MaxTTLHelperText = (props: { ttl?: number }) => { + const { ttl = 0 } = props; + + // Error will show once field is considered touched + if (ttl < 0) { + return null; + } + + if (ttl === 0) { + return Workspaces may run indefinitely.; + } + + return ( + + Workspaces must stop within {ttl} {hours(ttl)} of starting, regardless of + any active connections. + + ); +}; + const useStyles = makeStyles((theme) => ({ ttlFields: { width: "100%", diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx index 9400e864a2ffc..8891e376b2d19 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx @@ -11,7 +11,6 @@ import { MockTemplateVersionVariable3, MockTemplate, MockOrganization, - MockProvisionerJob, } from "testHelpers/entities"; const renderPage = async (searchParams: URLSearchParams) => { @@ -28,8 +27,14 @@ const renderPage = async (searchParams: URLSearchParams) => { return view; }; -test("Create template with variables", async () => { - // Return pending when creating the first template version +test("Create template from starter template", async () => { + // Render page, fill the name and submit + const searchParams = new URLSearchParams({ + exampleId: MockTemplateExample.id, + }); + const { router, container } = await renderPage(searchParams); + const form = container.querySelector("form") as HTMLFormElement; + jest.spyOn(API, "createTemplateVersion").mockResolvedValueOnce({ ...MockTemplateVersion, job: { @@ -37,7 +42,6 @@ test("Create template with variables", async () => { status: "pending", }, }); - // Return an error requesting for template variables jest.spyOn(API, "getTemplateVersion").mockResolvedValue({ ...MockTemplateVersion, job: { @@ -46,7 +50,6 @@ test("Create template with variables", async () => { error_code: "REQUIRED_TEMPLATE_VARIABLES", }, }); - // Return the template variables jest .spyOn(API, "getTemplateVersionVariables") .mockResolvedValue([ @@ -54,20 +57,13 @@ test("Create template with variables", async () => { MockTemplateVersionVariable2, MockTemplateVersionVariable3, ]); - - // Render page, fill the name and submit - const searchParams = new URLSearchParams({ - exampleId: MockTemplateExample.id, - }); - const { router, container } = await renderPage(searchParams); - const form = container.querySelector("form") as HTMLFormElement; await userEvent.type(screen.getByLabelText(/Name/), "my-template"); await userEvent.click( within(form).getByRole("button", { name: /create template/i }), ); // Wait for the variables form to be rendered and fill it - await screen.findByText(/Variables/); + await screen.findByText(/Variables/, undefined, { timeout: 5_000 }); // Type first variable await userEvent.clear(screen.getByLabelText(/var.first_variable/)); @@ -85,6 +81,7 @@ test("Create template with variables", async () => { jest .spyOn(API, "createTemplateVersion") .mockResolvedValue(MockTemplateVersion); + jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate); await userEvent.click( within(form).getByRole("button", { name: /create template/i }), @@ -94,7 +91,7 @@ test("Create template with variables", async () => { `/templates/${MockTemplate.name}`, ); expect(API.createTemplateVersion).toHaveBeenCalledWith(MockOrganization.id, { - file_id: MockProvisionerJob.file_id, + example_id: "aws-windows", provisioner: "terraform", storage_method: "file", tags: {}, @@ -106,7 +103,13 @@ test("Create template with variables", async () => { }); }); -test("Create template from another template", async () => { +test("Create template from duplicating a template", async () => { + jest.spyOn(API, "getTemplateByName").mockResolvedValue(MockTemplate); + jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); + jest + .spyOn(API, "getTemplateVersionVariables") + .mockResolvedValue([MockTemplateVersionVariable1]); + const searchParams = new URLSearchParams({ fromTemplate: MockTemplate.name, }); @@ -128,11 +131,14 @@ test("Create template from another template", async () => { jest .spyOn(API, "createTemplateVersion") .mockResolvedValue(MockTemplateVersion); + jest.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate); await userEvent.click( screen.getByRole("button", { name: /create template/i }), ); - expect(router.state.location.pathname).toEqual( - `/templates/${MockTemplate.name}`, - ); + await waitFor(() => { + expect(router.state.location.pathname).toEqual( + `/templates/${MockTemplate.name}`, + ); + }); }); diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index b0b9f056daded..5b98964d5c20b 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -1,47 +1,15 @@ -import { useMachine } from "@xstate/react"; -import { isApiValidationError } from "api/errors"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"; -import { Loader } from "components/Loader/Loader"; -import { Stack } from "components/Stack/Stack"; -import { useOrganizationId } from "hooks/useOrganizationId"; import { FC } from "react"; import { Helmet } from "react-helmet-async"; import { useNavigate, useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; -import { createTemplateMachine } from "xServices/createTemplate/createTemplateXService"; -import { CreateTemplateForm } from "./CreateTemplateForm"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { DuplicateTemplateView } from "./DuplicateTemplateView"; +import { ImportStarterTemplateView } from "./ImportStarterTemplateView"; +import { UploadTemplateView } from "./UploadTemplateView"; const CreateTemplatePage: FC = () => { const navigate = useNavigate(); - const organizationId = useOrganizationId(); const [searchParams] = useSearchParams(); - const [state, send] = useMachine(createTemplateMachine, { - context: { - organizationId, - exampleId: searchParams.get("exampleId"), - templateNameToCopy: searchParams.get("fromTemplate"), - }, - actions: { - onCreate: (_, { data }) => { - navigate(`/templates/${data.name}`); - }, - }, - }); - - const { starterTemplate, error, file, jobError, jobLogs, variables } = - state.context; - const shouldDisplayForm = !state.hasTag("loading"); - const { entitlements } = useDashboard(); - const allowAdvancedScheduling = - entitlements.features["advanced_template_scheduling"].enabled; - // Requires the template RBAC feature, otherwise disabling everyone access - // means no one can access. - const allowDisableEveryoneAccess = - entitlements.features["template_rbac"].enabled; - const allowAutostopRequirement = - entitlements.features["template_autostop_requirement"].enabled; const onCancel = () => { navigate(-1); @@ -54,45 +22,13 @@ const CreateTemplatePage: FC = () => { - {state.hasTag("loading") && } - - - {Boolean(error) && !isApiValidationError(error) && ( - - )} - - {shouldDisplayForm && ( - { - send({ - type: "CREATE", - data, - }); - }} - upload={{ - file, - isUploading: state.matches("uploading"), - onRemove: () => { - send("REMOVE_FILE"); - }, - onUpload: (file) => { - send({ type: "UPLOAD_FILE", file }); - }, - }} - jobError={jobError} - logs={jobLogs} - /> - )} - + {searchParams.has("fromTemplate") ? ( + + ) : searchParams.has("exampleId") ? ( + + ) : ( + + )} ); diff --git a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx new file mode 100644 index 0000000000000..612085378fa23 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx @@ -0,0 +1,86 @@ +import { useQuery, useMutation } from "react-query"; +import { templateVersionLogs } from "api/queries/templateVersions"; +import { + templateByName, + templateVersion, + templateVersionVariables, + JobError, + createTemplate, +} from "api/queries/templates"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { useOrganizationId } from "hooks"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { CreateTemplateForm } from "./CreateTemplateForm"; +import { Loader } from "components/Loader/Loader"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; + +export const DuplicateTemplateView = () => { + const navigate = useNavigate(); + const organizationId = useOrganizationId(); + const [searchParams] = useSearchParams(); + const templateByNameQuery = useQuery( + templateByName(organizationId, searchParams.get("fromTemplate")!), + ); + const activeVersionId = + templateByNameQuery.data?.template.active_version_id ?? ""; + const templateVersionQuery = useQuery({ + ...templateVersion(activeVersionId), + enabled: templateByNameQuery.isSuccess, + }); + const templateVersionVariablesQuery = useQuery({ + ...templateVersionVariables(activeVersionId), + enabled: templateByNameQuery.isSuccess, + }); + const isLoading = + templateByNameQuery.isLoading || + templateVersionQuery.isLoading || + templateVersionVariablesQuery.isLoading; + const loadingError = + templateByNameQuery.error || + templateVersionQuery.error || + templateVersionVariablesQuery.error; + + const dashboard = useDashboard(); + const formPermissions = getFormPermissions(dashboard.entitlements); + + const createTemplateMutation = useMutation(createTemplate()); + const createError = createTemplateMutation.error; + const isJobError = createError instanceof JobError; + const templateVersionLogsQuery = useQuery({ + ...templateVersionLogs(isJobError ? createError.version.id : ""), + enabled: isJobError, + }); + + if (isLoading) { + return ; + } + + if (loadingError) { + return ; + } + + return ( + navigate(-1)} + jobError={isJobError ? createError.job.error : undefined} + logs={templateVersionLogsQuery.data} + onSubmit={async (formData) => { + const template = await createTemplateMutation.mutateAsync({ + organizationId, + version: firstVersionFromFile( + templateVersionQuery.data!.job.file_id, + formData.user_variable_values, + ), + template: newTemplate(formData), + }); + navigate(`/templates/${template.name}`); + }} + /> + ); +}; diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx new file mode 100644 index 0000000000000..9854a62cae2d0 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -0,0 +1,82 @@ +import { useQuery, useMutation } from "react-query"; +import { templateVersionLogs } from "api/queries/templateVersions"; +import { + JobError, + createTemplate, + templateExamples, + templateVersionVariables, +} from "api/queries/templates"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { useOrganizationId } from "hooks"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { CreateTemplateForm } from "./CreateTemplateForm"; +import { Loader } from "components/Loader/Loader"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { + firstVersionFromExample, + getFormPermissions, + newTemplate, +} from "./utils"; + +export const ImportStarterTemplateView = () => { + const navigate = useNavigate(); + const organizationId = useOrganizationId(); + const [searchParams] = useSearchParams(); + const templateExamplesQuery = useQuery(templateExamples(organizationId)); + const templateExample = templateExamplesQuery.data?.find( + (e) => e.id === searchParams.get("exampleId")!, + ); + + const isLoading = templateExamplesQuery.isLoading; + const loadingError = templateExamplesQuery.error; + + const dashboard = useDashboard(); + const formPermissions = getFormPermissions(dashboard.entitlements); + + const createTemplateMutation = useMutation(createTemplate()); + const createError = createTemplateMutation.error; + const isJobError = createError instanceof JobError; + const templateVersionLogsQuery = useQuery({ + ...templateVersionLogs(isJobError ? createError.version.id : ""), + enabled: isJobError, + }); + + const missedVariables = useQuery({ + ...templateVersionVariables(isJobError ? createError.version.id : ""), + enabled: + isJobError && + createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", + }); + + if (isLoading) { + return ; + } + + if (loadingError) { + return ; + } + + return ( + navigate(-1)} + jobError={isJobError ? createError.job.error : undefined} + logs={templateVersionLogsQuery.data} + onSubmit={async (formData) => { + const template = await createTemplateMutation.mutateAsync({ + organizationId, + version: firstVersionFromExample( + templateExample!, + formData.user_variable_values, + ), + template: newTemplate(formData), + }); + navigate(`/templates/${template.name}`); + }} + /> + ); +}; diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx new file mode 100644 index 0000000000000..a337b82a12404 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -0,0 +1,68 @@ +import { useQuery, useMutation } from "react-query"; +import { templateVersionLogs } from "api/queries/templateVersions"; +import { + JobError, + createTemplate, + templateVersionVariables, +} from "api/queries/templates"; +import { useOrganizationId } from "hooks"; +import { useNavigate } from "react-router-dom"; +import { CreateTemplateForm } from "./CreateTemplateForm"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; +import { uploadFile } from "api/queries/files"; + +export const UploadTemplateView = () => { + const navigate = useNavigate(); + const organizationId = useOrganizationId(); + + const dashboard = useDashboard(); + const formPermissions = getFormPermissions(dashboard.entitlements); + + const uploadFileMutation = useMutation(uploadFile()); + const uploadedFile = uploadFileMutation.data; + + const createTemplateMutation = useMutation(createTemplate()); + const createError = createTemplateMutation.error; + const isJobError = createError instanceof JobError; + const templateVersionLogsQuery = useQuery({ + ...templateVersionLogs(isJobError ? createError.version.id : ""), + enabled: isJobError, + }); + + const missedVariables = useQuery({ + ...templateVersionVariables(isJobError ? createError.version.id : ""), + enabled: + isJobError && + createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", + }); + + return ( + navigate(-1)} + jobError={isJobError ? createError.job.error : undefined} + logs={templateVersionLogsQuery.data} + upload={{ + onUpload: uploadFileMutation.mutateAsync, + isUploading: uploadFileMutation.isLoading, + onRemove: uploadFileMutation.reset, + file: uploadFileMutation.variables, + }} + onSubmit={async (formData) => { + const template = await createTemplateMutation.mutateAsync({ + organizationId, + version: firstVersionFromFile( + uploadedFile!.hash, + formData.user_variable_values, + ), + template: newTemplate(formData), + }); + navigate(`/templates/${template.name}`); + }} + /> + ); +}; diff --git a/site/src/pages/CreateTemplatePage/utils.ts b/site/src/pages/CreateTemplatePage/utils.ts new file mode 100644 index 0000000000000..6b8772541579d --- /dev/null +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -0,0 +1,80 @@ +import { + Entitlements, + ProvisionerType, + TemplateExample, + VariableValue, +} from "api/typesGenerated"; +import { calculateAutostopRequirementDaysValue } from "utils/schedule"; +import { CreateTemplateData } from "./CreateTemplateForm"; + +const provisioner: ProvisionerType = + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Playwright needs to use a different provisioner type! + typeof (window as any).playwright !== "undefined" ? "echo" : "terraform"; + +export const newTemplate = (formData: CreateTemplateData) => { + const { + default_ttl_hours, + max_ttl_hours, + parameter_values_by_name, + allow_everyone_group_access, + autostop_requirement_days_of_week, + autostop_requirement_weeks, + ...safeTemplateData + } = formData; + + return { + ...safeTemplateData, + disable_everyone_group_access: !formData.allow_everyone_group_access, + default_ttl_ms: formData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms + max_ttl_ms: formData.max_ttl_hours * 60 * 60 * 1000, // Convert hours to ms + autostop_requirement: { + days_of_week: calculateAutostopRequirementDaysValue( + formData.autostop_requirement_days_of_week, + ), + weeks: formData.autostop_requirement_weeks, + }, + }; +}; + +export const getFormPermissions = (entitlements: Entitlements) => { + const allowAdvancedScheduling = + entitlements.features["advanced_template_scheduling"].enabled; + // Requires the template RBAC feature, otherwise disabling everyone access + // means no one can access. + const allowDisableEveryoneAccess = + entitlements.features["template_rbac"].enabled; + const allowAutostopRequirement = + entitlements.features["template_autostop_requirement"].enabled; + + return { + allowAdvancedScheduling, + allowDisableEveryoneAccess, + allowAutostopRequirement, + }; +}; + +export const firstVersionFromFile = ( + fileId: string, + variables: VariableValue[] | undefined, +) => { + return { + storage_method: "file" as const, + provisioner: provisioner, + user_variable_values: variables, + file_id: fileId, + tags: {}, + }; +}; + +export const firstVersionFromExample = ( + example: TemplateExample, + variables: VariableValue[] | undefined, +) => { + return { + storage_method: "file" as const, + provisioner: provisioner, + user_variable_values: variables, + example_id: example.id, + tags: {}, + }; +}; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText.tsx index 19cd6df71c1b7..fff5cf23c55fd 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText.tsx @@ -1,10 +1,5 @@ import { Template } from "api/typesGenerated"; - -export type TemplateAutostopRequirementDaysValue = - | "off" - | "daily" - | "saturday" - | "sunday"; +import { TemplateAutostopRequirementDaysValue } from "utils/schedule"; const autostopRequirementDescriptions = { off: "Workspaces are not required to stop periodically.", @@ -31,29 +26,6 @@ export const convertAutostopRequirementDaysValue = ( return "off"; }; -export const calculateAutostopRequirementDaysValue = ( - value: TemplateAutostopRequirementDaysValue, -): Template["autostop_requirement"]["days_of_week"] => { - switch (value) { - case "daily": - return [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - ]; - case "saturday": - return ["saturday"]; - case "sunday": - return ["sunday"]; - } - - return []; -}; - export const AutostopRequirementDaysHelperText = ({ days = "off", }: { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 88c272fe78ced..cf595ba19a7f9 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -33,9 +33,9 @@ import MenuItem from "@mui/material/MenuItem"; import { AutostopRequirementDaysHelperText, AutostopRequirementWeeksHelperText, - calculateAutostopRequirementDaysValue, convertAutostopRequirementDaysValue, } from "./AutostopRequirementHelperText"; +import { calculateAutostopRequirementDaysValue } from "utils/schedule"; const MS_HOUR_CONVERSION = 3600000; const MS_DAY_CONVERSION = 86400000; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx index f5b1c2bfe9c97..b579fc94d0a8d 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx @@ -1,6 +1,6 @@ import { UpdateTemplateMeta } from "api/typesGenerated"; +import { TemplateAutostopRequirementDaysValue } from "utils/schedule"; import * as Yup from "yup"; -import { TemplateAutostopRequirementDaysValue } from "./AutostopRequirementHelperText"; export interface TemplateScheduleFormValues extends Omit { diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx index b6abef5773d95..f45e9e7842bd4 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx @@ -67,8 +67,8 @@ export const TemplateVariablesPage: FC = () => { const buildVersion = useCallback( async (req: CreateTemplateVersionRequest) => { - const newVersionId = await sendCreateAndBuildTemplateVersion(req); - await publishVersion(newVersionId); + const newVersion = await sendCreateAndBuildTemplateVersion(req); + await publishVersion(newVersion.id); }, [sendCreateAndBuildTemplateVersion, publishVersion], ); diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index 5eccbcdc75dd4..9659c8f6c7098 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -30,7 +30,7 @@ test("Use custom name, message and set it as active when publishing", async () = const topbar = await screen.findByTestId("topbar"); // Build Template - jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" }); + jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" }); jest .spyOn(api, "createTemplateVersion") .mockResolvedValueOnce(MockTemplateVersion); @@ -95,7 +95,7 @@ test("Do not mark as active if promote is not checked", async () => { const topbar = await screen.findByTestId("topbar"); // Build Template - jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" }); + jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" }); jest .spyOn(api, "createTemplateVersion") .mockResolvedValueOnce(MockTemplateVersion); @@ -162,7 +162,7 @@ test("Patch request is not send when there are no changes", async () => { const topbar = await screen.findByTestId("topbar"); // Build Template - jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" }); + jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" }); jest .spyOn(api, "createTemplateVersion") .mockResolvedValueOnce(MockTemplateVersionWithEmptyMessage); diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index d08da8fa207db..f40ad06cc1df1 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -117,6 +117,12 @@ export const handlers = [ return res(ctx.status(200), ctx.json([])); }, ), + rest.get( + "/api/v2/templateversions/:templateversionId/logs", + async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs)); + }, + ), rest.get( "api/v2/organizations/:organizationId/templates/:templateName/versions/:templateVersionName", async (req, res, ctx) => { diff --git a/site/src/utils/schedule.ts b/site/src/utils/schedule.ts index add2f4b30c438..e60cecae6d042 100644 --- a/site/src/utils/schedule.ts +++ b/site/src/utils/schedule.ts @@ -4,7 +4,7 @@ import duration from "dayjs/plugin/duration"; import relativeTime from "dayjs/plugin/relativeTime"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import { Workspace } from "api/typesGenerated"; +import { Template, Workspace } from "api/typesGenerated"; import { isWorkspaceOn } from "./workspace"; import cronParser from "cron-parser"; @@ -199,3 +199,32 @@ export const quietHoursDisplay = ( return display; }; + +export type TemplateAutostopRequirementDaysValue = + | "off" + | "daily" + | "saturday" + | "sunday"; + +export const calculateAutostopRequirementDaysValue = ( + value: TemplateAutostopRequirementDaysValue, +): Template["autostop_requirement"]["days_of_week"] => { + switch (value) { + case "daily": + return [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ]; + case "saturday": + return ["saturday"]; + case "sunday": + return ["sunday"]; + } + + return []; +}; diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts deleted file mode 100644 index e30c1cca93799..0000000000000 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { - getTemplateExamples, - createTemplateVersion, - getTemplateVersion, - createTemplate, - uploadTemplateFile, - getTemplateVersionLogs, - getTemplateVersionVariables, - getTemplateByName, -} from "api/api"; -import { - ProvisionerJob, - ProvisionerJobLog, - ProvisionerType, - Template, - TemplateExample, - TemplateVersion, - TemplateVersionVariable, - UploadResponse, - VariableValue, -} from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; -import { - TemplateAutostopRequirementDaysValue, - calculateAutostopRequirementDaysValue, -} from "pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText"; -import { delay } from "utils/delay"; -import { assign, createMachine } from "xstate"; - -// for creating a new template: -// 1. upload template tar or use the example ID -// 2. create template version -// 3. wait for it to complete -// 4. verify if template has missing parameters or variables -// a. prompt for params -// b. create template version again with the same file hash -// c. wait for it to complete -// 5.create template with the successful template version ID -// https://github.com/coder/coder/blob/b6703b11c6578b2f91a310d28b6a7e57f0069be6/cli/templatecreate.go#L169-L170 - -const provisioner: ProvisionerType = - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Playwright needs to use a different provisioner type! - typeof (window as any).playwright !== "undefined" ? "echo" : "terraform"; - -export interface CreateTemplateData { - name: string; - display_name: string; - description: string; - icon: string; - default_ttl_hours: number; - max_ttl_hours: number; - autostop_requirement_days_of_week: TemplateAutostopRequirementDaysValue; - autostop_requirement_weeks: number; - allow_user_autostart: boolean; - allow_user_autostop: boolean; - allow_user_cancel_workspace_jobs: boolean; - parameter_values_by_name?: Record; - user_variable_values?: VariableValue[]; - allow_everyone_group_access: boolean; -} -interface CreateTemplateContext { - organizationId: string; - error?: unknown; - jobError?: string; - jobLogs?: ProvisionerJobLog[]; - starterTemplate?: TemplateExample; - exampleId?: string | null; // It can be null because it is being passed from query string - version?: TemplateVersion; - templateData?: CreateTemplateData; - variables?: TemplateVersionVariable[]; - // file is used in the FE to show the filename and some other visual stuff - // uploadedFile is the response from the server to use in the API - file?: File; - uploadResponse?: UploadResponse; - // When wanting to duplicate a Template - templateNameToCopy: string | null; // It can be null because it is passed from query string - copiedTemplate?: Template; -} - -export const createTemplateMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATgBM86dO3z5agDQgAnoi0B2CmosA2WbNsGOF5WpsBfD8bSYc+YjIcKho6JlY2aR4kEAEhETEYqQQ5RRV1LR09A2MzBGVpClkOEo5pABZlZVsNNWllLx90cgDScgoSfgwIcIBlUJxUVqCwFghRSiYAN34Aa0pfFsI24M7uvoGwIeWRhGn+ZGx6UU4uU-E44WPE0GSaxQsLeXLZaTVntQVc8xKlUo59LZbHUXhZGiBFv4du01j1mP1aINhuQWFtUPxUBQVgAzDEECiQvDQ1ZdOFQBF0LbInB7RgzQ4JU7nGKXBLiO5aCiPZ6vd7lT7yb4IWqKF7aN5ucrSYEcWTgwnUyYQEijADCACUAKIAQVwmuZfEEV1E7MQsg0QulHHKFBqxXcNhqWnk8uaUMC7XoytGAFUAAoAGQA8tqACIAfQAYgBJAP67gXI1spLmV5c57yCxyWzWmwOIXyarWDg1R4lDTKDQaaSuvxEj3BL0qlhagCyQYAapqo7H49FDfFrqaEPIOBobQK9C4LLVZELXjanAZ3Pz3LUwd4IW76ytKABXUik8JjCYUfbzAnbxUUA+w8K0+lHE7cA2xJNDlPCtNPcqZ7O5ix81MRBKkUeR1ClcoSlHKVbFrJYG33Q91mYVFUHRTEcTxS862vW8j2YB8DifRgmQTFl3xNT8q3kDQKG0DQs1eJxZHKec7AoAoNAUWVR1qODNwVYkFjdcIcKOZhI3oVBqA7LYhFEE9GEmOk5hE3DhPEhhmC08IpJkrA5Jk64iIZa4yP7N9Byo24QLkIo7Q4NRlABJ55CcS1rVFMdpVAysnI3JoNMQ3SdMhPTpNk+TrjQjCsSCXFUHxISQvCsLRMkyLDOi0RTJIizE2sm5JHMbi6IYpjpXAtjgIQYErGrZQLBsconm45QXUEq9NLSqAKAAdwwK5JIxAApfgACNcH4AAhMBVX4QIwBwCAlJUmYLxS3dQr6wbhqgSMxsm6a5oWpaVryxkX3IgdjWK5JpDHG0oJqNQnPKCtWMtCorA+t4HEqDqXE6oKEO23qBqG7SDqOqbZvmxbSGWyA1rPVTNu61KMt2qG9Nhk6EfOyBLvMl8okKu7hyrc16OkRi5Cqr7auajhbSzDqK0zJiBNB91wexyH9sO1Bxrh07EZVFbUfPdSwZGHbBeh4XRYJs6kYu-YzOfM4NEs1kP1slInooF7anez6aryao6LUWwmuUSpzXNOR4L5+WIb2pX8fhtXJZRtEMXi7BEuSzH+b8MTPbxkXjp9iXkYgEntdffWbJK4Vx3KunKpYy3ECqG06f5fl3P5QKt2C8OJL6u9mFbehYCEZg-VoDACGRmTpfR2W3faCHa6gevG-CFvUDbjvYCT0jrr1yj7pkQCrE0Zz1A66pbe+2xClsLM6hLB1bblLrK-dgWB6HpuoFH8fBlgWLA6wpKtJ3U+I508+G8v6-29vqeCoooqVMtBZ3psxaqnksxKAYixSsjEeYVzln3AWRB0TECwN-CeLANQ6j1CnOew56hOQoJnKCspXAKE0JaCsyg2ZvFok4R6VRXYvyQW-PqvUjIKUYAAdWEAACwwbfLuG0e4sOCBDDhOUeH8MEfJP+M8KbJkNlKWQDluJORcpmQEgpapZFsJxBwFZdD2HKLYD6zDrwSOxpw64vCsACNbj-eS99MIJWwltV+1cdo2NEHYhxY8nEyXkWcG6VlKafhUWo+0mi3IeV0b+RQbgHYWBcNbAo5cPGsK8b1RUwi1LP0sQLHJwlgl4MAZ+aQi9rC1FUM5QswJbBCiag1eozUpQzgsB9DQFiepFOxrkgOrjg7uLDp46GO1FSlNCaneeGdaK01AYzPOCAbBWFkK4H6spijViPpuRg-AIBwHEJknAiiDbpwALRGFqhc1RB97n3JBgg3uwRqCInCGctOyQPpNP0dKIEKTCw6H5DvHpIUB4UiRMJT5sz3JWEqbbR4NQgStSeEKasdEZz2CcBWFw7gmpgu2k2MAMKqauFZs8VwTUVAFDUC8IUW99EqCzO5YoZQS6EvlvhFCUBSURItLVKCVgSiuFgjvT4spOVZOhnyw2ORmZPGsC8KC7k3gzlkA0Y+iDxF9LYfpKKxk04zIIesig-1RxuBsBoMoQJPKMVtBWO2GrrWfHKOUKVOq2GK2jirOORMICyvTik4V7xOkHzFAKvIOhrXEMqKYt6FQmqsQ9T3MSH9h7N0cRPQND1Kj6JBPUYEtFKzOW+i8TiK8NBOCxSylNCsUGI3QVm2+OafglmsBUKtegTHKEtAxM1jtzSZmXgSrVLzU3pTYT46R9jZEyVbfkCwlooJqC5GyqojhxzZzrVYthioF0MxtHYDqjxXgA10L8wobqFAMwBtUTVvMxETvYduANADwmG2te2kEXbjGsV7bVNwS8xTPCMTYMcXgvBAA */ - createMachine( - { - id: "createTemplate", - predictableActionArguments: true, - schema: { - context: {} as CreateTemplateContext, - events: {} as - | { type: "CREATE"; data: CreateTemplateData } - | { type: "UPLOAD_FILE"; file: File } - | { type: "REMOVE_FILE" }, - services: {} as { - uploadFile: { - data: UploadResponse; - }; - loadStarterTemplate: { - data: TemplateExample; - }; - createFirstVersion: { - data: TemplateVersion; - }; - createVersionWithParametersAndVariables: { - data: TemplateVersion; - }; - waitForJobToBeCompleted: { - data: TemplateVersion; - }; - checkParametersAndVariables: { - data: { - variables?: TemplateVersionVariable[]; - }; - }; - createTemplate: { - data: Template; - }; - loadVersionLogs: { - data: ProvisionerJobLog[]; - }; - copyTemplateData: { - data: { - template: Template; - version: TemplateVersion; - variables: TemplateVersionVariable[]; - }; - }; - }, - }, - tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, - initial: "starting", - states: { - starting: { - always: [ - { target: "loadingStarterTemplate", cond: "isExampleProvided" }, - { - target: "copyingTemplateData", - cond: "isTemplateIdToCopyProvided", - }, - { target: "idle" }, - ], - tags: ["loading"], - }, - loadingStarterTemplate: { - invoke: { - src: "loadStarterTemplate", - onDone: { - target: "idle", - actions: ["assignStarterTemplate"], - }, - onError: { - target: "idle", - actions: ["assignError"], - }, - }, - tags: ["loading"], - }, - copyingTemplateData: { - invoke: { - src: "copyTemplateData", - onDone: [ - { - target: "creating.promptParametersAndVariables", - actions: ["assignCopiedTemplateData"], - cond: "hasParametersOrVariables", - }, - { - target: "idle", - actions: ["assignCopiedTemplateData"], - }, - ], - onError: { - target: "idle", - actions: ["assignError"], - }, - }, - tags: ["loading"], - }, - idle: { - on: { - CREATE: { - target: "creating", - actions: ["assignTemplateData"], - }, - UPLOAD_FILE: { - actions: ["assignFile"], - target: "uploading", - cond: "isNotUsingExample", - }, - REMOVE_FILE: { - actions: ["removeFile"], - cond: "hasFile", - }, - }, - }, - uploading: { - invoke: { - src: "uploadFile", - onDone: { - target: "idle", - actions: ["assignUploadResponse"], - }, - onError: { - target: "idle", - actions: ["displayUploadError", "removeFile"], - }, - }, - }, - creating: { - initial: "creatingFirstVersion", - states: { - creatingFirstVersion: { - invoke: { - src: "createFirstVersion", - onDone: { - target: "waitingForJobToBeCompleted", - actions: ["assignVersion"], - }, - onError: { - actions: ["assignError"], - target: "#createTemplate.idle", - }, - }, - tags: ["submitting"], - }, - waitingForJobToBeCompleted: { - invoke: { - src: "waitForJobToBeCompleted", - onDone: [ - { - target: "loadingVersionLogs", - actions: ["assignJobError", "assignVersion"], - cond: "hasFailed", - }, - { - target: "checkingParametersAndVariables", - actions: ["assignVersion"], - }, - ], - onError: { - target: "#createTemplate.idle", - actions: ["assignError"], - }, - }, - tags: ["submitting"], - }, - checkingParametersAndVariables: { - invoke: { - src: "checkParametersAndVariables", - onDone: [ - { - target: "creatingTemplate", - cond: "hasNoParametersOrVariables", - }, - { - target: "promptParametersAndVariables", - actions: ["assignParametersAndVariables"], - }, - ], - onError: { - target: "#createTemplate.idle", - actions: ["assignError"], - }, - }, - tags: ["submitting"], - }, - promptParametersAndVariables: { - on: { - CREATE: { - target: "creatingVersionWithParametersAndVariables", - actions: ["assignTemplateData"], - }, - }, - }, - creatingVersionWithParametersAndVariables: { - invoke: { - src: "createVersionWithParametersAndVariables", - onDone: { - target: "waitingForJobToBeCompleted", - actions: ["assignVersion"], - }, - onError: { - actions: ["assignError"], - target: "promptParametersAndVariables", - }, - }, - tags: ["submitting"], - }, - creatingTemplate: { - invoke: { - src: "createTemplate", - onDone: { - target: "created", - actions: ["onCreate"], - }, - onError: { - actions: ["assignError"], - target: "#createTemplate.idle", - }, - }, - tags: ["submitting"], - }, - created: { - type: "final", - }, - loadingVersionLogs: { - invoke: { - src: "loadVersionLogs", - onDone: { - target: "#createTemplate.idle", - actions: ["assignJobLogs"], - }, - onError: { - target: "#createTemplate.idle", - actions: ["assignError"], - }, - }, - }, - }, - }, - }, - }, - { - services: { - uploadFile: (_, { file }) => uploadTemplateFile(file), - loadStarterTemplate: async ({ organizationId, exampleId }) => { - if (!exampleId) { - throw new Error(`Example ID is not defined.`); - } - const examples = await getTemplateExamples(organizationId); - const starterTemplate = examples.find( - (example) => example.id === exampleId, - ); - if (!starterTemplate) { - throw new Error(`Example ${exampleId} not found.`); - } - return starterTemplate; - }, - copyTemplateData: async ({ organizationId, templateNameToCopy }) => { - if (!organizationId) { - throw new Error("No organization ID provided"); - } - if (!templateNameToCopy) { - throw new Error("No template name to copy provided"); - } - const template = await getTemplateByName( - organizationId, - templateNameToCopy, - ); - const [version, variables] = await Promise.all([ - getTemplateVersion(template.active_version_id), - getTemplateVersionVariables(template.active_version_id), - ]); - - return { - template, - version, - variables, - }; - }, - createFirstVersion: async ({ - organizationId, - templateNameToCopy, - exampleId, - uploadResponse, - version, - }) => { - if (exampleId) { - return createTemplateVersion(organizationId, { - storage_method: "file", - example_id: exampleId, - provisioner: provisioner, - tags: {}, - }); - } - - if (templateNameToCopy) { - if (!version) { - throw new Error( - "Can't copy template due to a missing template version", - ); - } - - return createTemplateVersion(organizationId, { - storage_method: "file", - file_id: version.job.file_id, - provisioner: provisioner, - tags: {}, - }); - } - - if (uploadResponse) { - return createTemplateVersion(organizationId, { - storage_method: "file", - file_id: uploadResponse.hash, - provisioner: provisioner, - tags: {}, - }); - } - - throw new Error("No file or example provided"); - }, - createVersionWithParametersAndVariables: async ({ - organizationId, - templateData, - version, - }) => { - if (!version) { - throw new Error("No previous version found"); - } - if (!templateData) { - throw new Error("No template data defined"); - } - - return createTemplateVersion(organizationId, { - storage_method: "file", - file_id: version.job.file_id, - provisioner: provisioner, - user_variable_values: templateData.user_variable_values, - tags: {}, - }); - }, - waitForJobToBeCompleted: async ({ version }) => { - if (!version) { - throw new Error("Version not defined"); - } - - let job = version.job; - while (isPendingOrRunning(job)) { - version = await getTemplateVersion(version.id); - job = version.job; - - // Delay the verification in two seconds to not overload the server - // with too many requests Maybe at some point we could have a - // websocket for template version Also, preferred doing this way to - // avoid a new state since we don't need to reflect it on the UI - if (isPendingOrRunning(job)) { - await delay(2_000); - } - } - return version; - }, - checkParametersAndVariables: async ({ version }) => { - if (!version) { - throw new Error("Version not defined"); - } - - let promiseVariables: Promise | undefined = - undefined; - - if (isMissingVariables(version)) { - promiseVariables = getTemplateVersionVariables(version.id); - } - - const [variables] = await Promise.all([promiseVariables]); - - return { - variables, - }; - }, - createTemplate: async ({ organizationId, version, templateData }) => { - if (!version) { - throw new Error("Version not defined"); - } - - if (!templateData) { - throw new Error("Template data not defined"); - } - - const { - default_ttl_hours, - max_ttl_hours, - parameter_values_by_name, - allow_everyone_group_access, - autostop_requirement_days_of_week, - autostop_requirement_weeks, - ...safeTemplateData - } = templateData; - - return createTemplate(organizationId, { - ...safeTemplateData, - disable_everyone_group_access: - !templateData.allow_everyone_group_access, - default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms - max_ttl_ms: templateData.max_ttl_hours * 60 * 60 * 1000, // Convert hours to ms - template_version_id: version.id, - autostop_requirement: { - days_of_week: calculateAutostopRequirementDaysValue( - templateData.autostop_requirement_days_of_week, - ), - weeks: templateData.autostop_requirement_weeks, - }, - }); - }, - loadVersionLogs: ({ version }) => { - if (!version) { - throw new Error("Version is not set"); - } - - return getTemplateVersionLogs(version.id); - }, - }, - actions: { - assignError: assign({ error: (_, { data }) => data }), - assignJobError: assign({ jobError: (_, { data }) => data.job.error }), - displayUploadError: () => { - displayError("Error on upload the file."); - }, - assignStarterTemplate: assign({ - starterTemplate: (_, { data }) => data, - }), - assignVersion: assign({ version: (_, { data }) => data }), - assignTemplateData: assign({ templateData: (_, { data }) => data }), - assignParametersAndVariables: assign({ - variables: (_, { data }) => data.variables, - }), - assignFile: assign({ file: (_, { file }) => file }), - assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }), - removeFile: assign({ - file: (_) => undefined, - uploadResponse: (_) => undefined, - }), - assignJobLogs: assign({ jobLogs: (_, { data }) => data }), - assignCopiedTemplateData: assign({ - copiedTemplate: (_, { data }) => data.template, - version: (_, { data }) => data.version, - variables: (_, { data }) => data.variables, - }), - }, - guards: { - isExampleProvided: ({ exampleId }) => Boolean(exampleId), - isTemplateIdToCopyProvided: ({ templateNameToCopy }) => - Boolean(templateNameToCopy), - isNotUsingExample: ({ exampleId }) => !exampleId, - hasFile: ({ file }) => Boolean(file), - hasFailed: (_, { data }) => - Boolean(data.job.status === "failed" && !isMissingVariables(data)), - hasNoParametersOrVariables: (_, { data }) => - data.variables === undefined, - hasParametersOrVariables: (_, { data }) => { - return data.variables.length > 0; - }, - }, - }, - ); - -const isMissingVariables = (version: TemplateVersion) => { - return Boolean( - version.job.error_code && - version.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", - ); -}; - -const isPendingOrRunning = (job: ProvisionerJob) => { - return job.status === "pending" || job.status === "running"; -}; diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts index 823bb374e8f37..7ef9fbd0b9379 100644 --- a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -350,7 +350,7 @@ export const templateVersionEditorMachine = createMachine( tar.addFolder(fullPath); }); const blob = (await tar.write()) as Blob; - return API.uploadTemplateFile(new File([blob], "template.tar")); + return API.uploadFile(new File([blob], "template.tar")); }, createBuild: (ctx) => { if (!ctx.uploadResponse) {