From b1a0b2561fbf7667defce62581c75e4372b655fa Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 13:01:42 +0000 Subject: [PATCH 01/16] Started to think on a few abstractions --- site/src/api/queries/templates.ts | 69 +++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 15fc52db1ec1e..de8cc48165824 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 "@tanstack/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,49 @@ export const templateVersionExternalAuth = (versionId: string) => { queryFn: () => API.getTemplateVersionExternalAuth(versionId), }; }; + +const createTemplate = async (options: { + organizationId: string; + version: CreateTemplateVersionRequest; + data: CreateTemplateRequest; +}) => { + const version = await API.createTemplateVersion( + options.organizationId, + options.version, + ); + await waitBuildToBeFinished(version); + return API.createTemplate(options.organizationId, { + ...options.data, + 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); +}; + +class JobError extends Error { + private job: ProvisionerJob; + + constructor(job: ProvisionerJob) { + super(job.error); + this.job = job; + } + + get code() { + return this.job.error_code; + } +} From fca65047ec0a7b5e8912e8d24a281eadf27c2888 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 13:52:01 +0000 Subject: [PATCH 02/16] Refactor loading views and duplicate template --- site/src/api/queries/templates.ts | 10 +- .../CreateTemplatePage/CreateTemplateForm.tsx | 64 +++--- .../CreateTemplatePage/CreateTemplatePage.tsx | 198 +++++++++++++++--- .../AutostopRequirementHelperText.tsx | 30 +-- .../TemplateScheduleForm.tsx | 2 +- .../TemplateSchedulePage/formHelpers.tsx | 2 +- .../TemplateVariablesPage.tsx | 4 +- site/src/utils/schedule.ts | 31 ++- .../createTemplate/createTemplateXService.ts | 4 +- 9 files changed, 247 insertions(+), 98 deletions(-) diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index de8cc48165824..5fcf7b5a1811b 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -121,10 +121,16 @@ export const templateVersionExternalAuth = (versionId: string) => { }; }; -const createTemplate = async (options: { +export const createTemplate = () => { + return { + mutationFn: createTemplateFn, + }; +}; + +const createTemplateFn = async (options: { organizationId: string; version: CreateTemplateVersionRequest; - data: CreateTemplateRequest; + data: Omit; }) => { const version = await API.createTemplateVersion( options.organizationId, diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 06a260ee65437..2633833cd1687 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -198,43 +198,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 +285,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); }} /> )} diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index b0b9f056daded..a8c215804c7a4 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -1,47 +1,57 @@ -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 { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"; import { CreateTemplateForm } from "./CreateTemplateForm"; import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + createTemplate, + templateByName, + templateVersion, + templateVersionVariables, +} from "api/queries/templates"; +import { ProvisionerType } from "api/typesGenerated"; +import { calculateAutostopRequirementDaysValue } from "utils/schedule"; + +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"; 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 organizationId = useOrganizationId(); + // 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 { 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,7 +64,14 @@ const CreateTemplatePage: FC = () => { - {state.hasTag("loading") && } + {searchParams.has("fromTemplate") ? ( + + ) : searchParams.has("exampleId") ? ( + + ) : ( + + )} + {/* {state.hasTag("loading") && } {Boolean(error) && !isApiValidationError(error) && ( @@ -92,10 +109,129 @@ const CreateTemplatePage: FC = () => { logs={jobLogs} /> )} - + */} ); }; +const DuplicateTemplateView = () => { + const navigate = useNavigate(); + const organizationId = useOrganizationId(); + const [searchParams] = useSearchParams(); + const templateByNameQuery = useQuery( + templateByName(organizationId, searchParams.get("fromTemplate")!), + ); + const templateVersionQuery = useQuery({ + ...templateVersion( + templateByNameQuery.data?.template.active_version_id ?? "", + ), + enabled: templateByNameQuery.data !== undefined, + }); + const templateVersionVariablesQuery = useQuery({ + ...templateVersionVariables( + templateByNameQuery.data?.template.active_version_id ?? "", + ), + enabled: templateByNameQuery.data !== undefined, + }); + const isLoading = + templateByNameQuery.isLoading || + templateVersionQuery.isLoading || + templateVersionVariablesQuery.isLoading; + const loadingError = + templateByNameQuery.error || + templateVersionQuery.error || + templateVersionVariablesQuery.error; + + const formEntitlements = useFormEntitlements(); + + const createTemplateMutation = useMutation(createTemplate()); + + if (isLoading) { + return ; + } + + if (loadingError) { + return ; + } + + return ( + navigate(-1)} + onSubmit={async (formData) => { + const template = await createTemplateMutation.mutateAsync({ + organizationId, + version: { + storage_method: "file", + file_id: templateVersionQuery.data!.job.file_id, + provisioner: provisioner, + tags: {}, + }, + data: prepareData(formData), + }); + navigate(`/templates/${template.name}`); + }} + + // jobError={jobError} + // logs={jobLogs} + /> + ); +}; + +const useFormEntitlements = () => { + 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; + + return { + allowAdvancedScheduling, + allowDisableEveryoneAccess, + allowAutostopRequirement, + }; +}; + +const ImportStaterTemplateView = () => { + return
Import
; +}; + +const UploadTemplateView = () => { + return
Upload
; +}; + +const prepareData = (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 default CreateTemplatePage; 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 db02b3f10b46e..996bdc5a2fd8a 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/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 index e30c1cca93799..a3f2b5b5b703c 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -20,11 +20,11 @@ import { VariableValue, } from "api/typesGenerated"; import { displayError } from "components/GlobalSnackbar/utils"; +import { delay } from "utils/delay"; import { TemplateAutostopRequirementDaysValue, calculateAutostopRequirementDaysValue, -} from "pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText"; -import { delay } from "utils/delay"; +} from "utils/schedule"; import { assign, createMachine } from "xstate"; // for creating a new template: From 1180e8be3ba793b783086f53bad07a594f8ce535 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 14:12:29 +0000 Subject: [PATCH 03/16] Show logs and build errors --- site/src/api/queries/templateVersions.ts | 8 ++++ site/src/api/queries/templates.ts | 14 +++--- .../CreateTemplatePage/CreateTemplatePage.tsx | 43 +++++++++++-------- 3 files changed, 38 insertions(+), 27 deletions(-) create mode 100644 site/src/api/queries/templateVersions.ts 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 5fcf7b5a1811b..75232493a0376 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -157,18 +157,16 @@ const waitBuildToBeFinished = async (version: TemplateVersion) => { } while (jobStatus === "pending" || jobStatus === "running"); // No longer pending/running, but didn't succeed - throw new JobError(data.job); + throw new JobError(data.job, version); }; -class JobError extends Error { - private job: ProvisionerJob; +export class JobError extends Error { + public job: ProvisionerJob; + public version: TemplateVersion; - constructor(job: ProvisionerJob) { + constructor(job: ProvisionerJob, version: TemplateVersion) { super(job.error); this.job = job; - } - - get code() { - return this.job.error_code; + this.version = version; } } diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index a8c215804c7a4..5f2a63feeb253 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -11,6 +11,7 @@ import { CreateTemplateForm } from "./CreateTemplateForm"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { useMutation, useQuery } from "@tanstack/react-query"; import { + JobError, createTemplate, templateByName, templateVersion, @@ -18,6 +19,7 @@ import { } from "api/queries/templates"; import { ProvisionerType } from "api/typesGenerated"; import { calculateAutostopRequirementDaysValue } from "utils/schedule"; +import { templateVersionLogs } from "api/queries/templateVersions"; const provisioner: ProvisionerType = // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Playwright needs to use a different provisioner type! @@ -122,17 +124,15 @@ const DuplicateTemplateView = () => { const templateByNameQuery = useQuery( templateByName(organizationId, searchParams.get("fromTemplate")!), ); + const activeVersionId = + templateByNameQuery.data?.template.active_version_id ?? ""; const templateVersionQuery = useQuery({ - ...templateVersion( - templateByNameQuery.data?.template.active_version_id ?? "", - ), - enabled: templateByNameQuery.data !== undefined, + ...templateVersion(activeVersionId), + enabled: templateByNameQuery.isSuccess, }); const templateVersionVariablesQuery = useQuery({ - ...templateVersionVariables( - templateByNameQuery.data?.template.active_version_id ?? "", - ), - enabled: templateByNameQuery.data !== undefined, + ...templateVersionVariables(activeVersionId), + enabled: templateByNameQuery.isSuccess, }); const isLoading = templateByNameQuery.isLoading || @@ -146,6 +146,12 @@ const DuplicateTemplateView = () => { const formEntitlements = useFormEntitlements(); 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 ; @@ -176,13 +182,20 @@ const DuplicateTemplateView = () => { }); navigate(`/templates/${template.name}`); }} - - // jobError={jobError} - // logs={jobLogs} + jobError={isJobError ? createError.job.error : undefined} + logs={templateVersionLogsQuery.data} /> ); }; +const ImportStaterTemplateView = () => { + return
Import
; +}; + +const UploadTemplateView = () => { + return
Upload
; +}; + const useFormEntitlements = () => { const { entitlements } = useDashboard(); const allowAdvancedScheduling = @@ -201,14 +214,6 @@ const useFormEntitlements = () => { }; }; -const ImportStaterTemplateView = () => { - return
Import
; -}; - -const UploadTemplateView = () => { - return
Upload
; -}; - const prepareData = (formData: CreateTemplateData) => { const { default_ttl_hours, From f6b3fd21b44c64ab25ab109a09ee4327ad48d054 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 14:16:13 +0000 Subject: [PATCH 04/16] Fix missing parameters --- site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 5f2a63feeb253..7fbc46be99d4e 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -176,6 +176,7 @@ const DuplicateTemplateView = () => { storage_method: "file", file_id: templateVersionQuery.data!.job.file_id, provisioner: provisioner, + user_variable_values: formData.user_variable_values, tags: {}, }, data: prepareData(formData), From 21ae383e798c45e5365aa49120a531681dd198a8 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 16:20:25 +0000 Subject: [PATCH 05/16] Refactor DuplicateTemplateView --- site/src/api/queries/templates.ts | 4 +- .../CreateTemplatePage/CreateTemplatePage.tsx | 137 +----------------- .../DuplicateTemplateView.tsx | 86 +++++++++++ site/src/pages/CreateTemplatePage/utils.ts | 79 ++++++++++ 4 files changed, 168 insertions(+), 138 deletions(-) create mode 100644 site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx create mode 100644 site/src/pages/CreateTemplatePage/utils.ts diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 75232493a0376..e2d70d7725ab7 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -130,7 +130,7 @@ export const createTemplate = () => { const createTemplateFn = async (options: { organizationId: string; version: CreateTemplateVersionRequest; - data: Omit; + template: Omit; }) => { const version = await API.createTemplateVersion( options.organizationId, @@ -138,7 +138,7 @@ const createTemplateFn = async (options: { ); await waitBuildToBeFinished(version); return API.createTemplate(options.organizationId, { - ...options.data, + ...options.template, template_version_id: version.id, }); }; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 7fbc46be99d4e..0b9758f63546f 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -1,29 +1,9 @@ -import { useDashboard } from "components/Dashboard/DashboardProvider"; import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"; -import { Loader } from "components/Loader/Loader"; -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 { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"; -import { CreateTemplateForm } from "./CreateTemplateForm"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - JobError, - createTemplate, - templateByName, - templateVersion, - templateVersionVariables, -} from "api/queries/templates"; -import { ProvisionerType } from "api/typesGenerated"; -import { calculateAutostopRequirementDaysValue } from "utils/schedule"; -import { templateVersionLogs } from "api/queries/templateVersions"; - -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"; +import { DuplicateTemplateView } from "./DuplicateTemplateView"; const CreateTemplatePage: FC = () => { const navigate = useNavigate(); @@ -117,78 +97,6 @@ const CreateTemplatePage: FC = () => { ); }; -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 formEntitlements = useFormEntitlements(); - - 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)} - onSubmit={async (formData) => { - const template = await createTemplateMutation.mutateAsync({ - organizationId, - version: { - storage_method: "file", - file_id: templateVersionQuery.data!.job.file_id, - provisioner: provisioner, - user_variable_values: formData.user_variable_values, - tags: {}, - }, - data: prepareData(formData), - }); - navigate(`/templates/${template.name}`); - }} - jobError={isJobError ? createError.job.error : undefined} - logs={templateVersionLogsQuery.data} - /> - ); -}; - const ImportStaterTemplateView = () => { return
Import
; }; @@ -197,47 +105,4 @@ const UploadTemplateView = () => { return
Upload
; }; -const useFormEntitlements = () => { - 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; - - return { - allowAdvancedScheduling, - allowDisableEveryoneAccess, - allowAutostopRequirement, - }; -}; - -const prepareData = (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 default CreateTemplatePage; diff --git a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx new file mode 100644 index 0000000000000..80eb755c6dade --- /dev/null +++ b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx @@ -0,0 +1,86 @@ +import { useQuery, useMutation } from "@tanstack/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 { firstVersion, 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: firstVersion( + templateVersionQuery.data!, + 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..920c7302b2950 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -0,0 +1,79 @@ +import { + Entitlements, + ProvisionerType, + TemplateExample, + TemplateVersion, + VariableValue, +} from "api/typesGenerated"; +import { calculateAutostopRequirementDaysValue } from "utils/schedule"; +import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"; + +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 firstVersion = ( + base: TemplateVersion | TemplateExample, + variables: VariableValue[] | undefined, +) => { + const baseReq = { + storage_method: "file" as const, + provisioner: provisioner, + user_variable_values: variables, + tags: {}, + }; + + if ("job" in base) { + return { + ...baseReq, + file_id: base.job.file_id, + }; + } + + return { + ...baseReq, + example_id: base.id, + }; +}; From db14220cc986ac4d2f73d918c7af55121cf1ab53 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 16:37:29 +0000 Subject: [PATCH 06/16] Refactor ImportStarterTemplateView --- .../CreateTemplatePage/CreateTemplatePage.tsx | 7 +- .../ImportStarterTemplateView.tsx | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 0b9758f63546f..532f8e933f342 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -4,6 +4,7 @@ import { Helmet } from "react-helmet-async"; import { useNavigate, useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { DuplicateTemplateView } from "./DuplicateTemplateView"; +import { ImportStarterTemplateView } from "./ImportStarterTemplateView"; const CreateTemplatePage: FC = () => { const navigate = useNavigate(); @@ -49,7 +50,7 @@ const CreateTemplatePage: FC = () => { {searchParams.has("fromTemplate") ? ( ) : searchParams.has("exampleId") ? ( - + ) : ( )} @@ -97,10 +98,6 @@ const CreateTemplatePage: FC = () => { ); }; -const ImportStaterTemplateView = () => { - return
Import
; -}; - const UploadTemplateView = () => { return
Upload
; }; diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx new file mode 100644 index 0000000000000..fe2ecdf68d184 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -0,0 +1,69 @@ +import { useQuery, useMutation } from "@tanstack/react-query"; +import { templateVersionLogs } from "api/queries/templateVersions"; +import { + JobError, + createTemplate, + templateExamples, +} 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 { firstVersion, 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, + }); + + 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: firstVersion( + templateExample!, + formData.user_variable_values, + ), + template: newTemplate(formData), + }); + navigate(`/templates/${template.name}`); + }} + /> + ); +}; From a95ed6d4a2c58a52d6723ebd4acfbbc5e83330f0 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 16:54:28 +0000 Subject: [PATCH 07/16] Refactor upload view --- site/src/api/api.ts | 2 +- site/src/api/queries/files.ts | 7 ++ .../CreateTemplatePage/CreateTemplatePage.tsx | 70 +------------------ .../DuplicateTemplateView.tsx | 6 +- .../ImportStarterTemplateView.tsx | 8 ++- .../CreateTemplatePage/UploadTemplateView.tsx | 56 +++++++++++++++ site/src/pages/CreateTemplatePage/utils.ts | 27 +++---- .../TemplateVersionEditorPage.test.tsx | 6 +- .../createTemplate/createTemplateXService.ts | 4 +- .../templateVersionEditorXService.ts | 2 +- 10 files changed, 94 insertions(+), 94 deletions(-) create mode 100644 site/src/api/queries/files.ts create mode 100644 site/src/pages/CreateTemplatePage/UploadTemplateView.tsx 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/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 532f8e933f342..5b98964d5c20b 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -5,36 +5,11 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { DuplicateTemplateView } from "./DuplicateTemplateView"; import { ImportStarterTemplateView } from "./ImportStarterTemplateView"; +import { UploadTemplateView } from "./UploadTemplateView"; const CreateTemplatePage: FC = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - // const organizationId = useOrganizationId(); - // 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,52 +29,9 @@ 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} - /> - )} - */} ); }; -const UploadTemplateView = () => { - return
Upload
; -}; - export default CreateTemplatePage; diff --git a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx index 80eb755c6dade..996b36e023137 100644 --- a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx @@ -13,7 +13,7 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { CreateTemplateForm } from "./CreateTemplateForm"; import { Loader } from "components/Loader/Loader"; import { useDashboard } from "components/Dashboard/DashboardProvider"; -import { firstVersion, getFormPermissions, newTemplate } from "./utils"; +import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; export const DuplicateTemplateView = () => { const navigate = useNavigate(); @@ -73,8 +73,8 @@ export const DuplicateTemplateView = () => { onSubmit={async (formData) => { const template = await createTemplateMutation.mutateAsync({ organizationId, - version: firstVersion( - templateVersionQuery.data!, + version: firstVersionFromFile( + templateVersionQuery.data!.job.file_id, formData.user_variable_values, ), template: newTemplate(formData), diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx index fe2ecdf68d184..84e3d82a137c7 100644 --- a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -11,7 +11,11 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { CreateTemplateForm } from "./CreateTemplateForm"; import { Loader } from "components/Loader/Loader"; import { useDashboard } from "components/Dashboard/DashboardProvider"; -import { firstVersion, getFormPermissions, newTemplate } from "./utils"; +import { + firstVersionFromExample, + getFormPermissions, + newTemplate, +} from "./utils"; export const ImportStarterTemplateView = () => { const navigate = useNavigate(); @@ -56,7 +60,7 @@ export const ImportStarterTemplateView = () => { onSubmit={async (formData) => { const template = await createTemplateMutation.mutateAsync({ organizationId, - version: firstVersion( + version: firstVersionFromExample( templateExample!, formData.user_variable_values, ), diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx new file mode 100644 index 0000000000000..1a41c51547177 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -0,0 +1,56 @@ +import { useQuery, useMutation } from "@tanstack/react-query"; +import { templateVersionLogs } from "api/queries/templateVersions"; +import { JobError, createTemplate } 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, + }); + + 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 index 920c7302b2950..37167f57bc80d 100644 --- a/site/src/pages/CreateTemplatePage/utils.ts +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -2,7 +2,6 @@ import { Entitlements, ProvisionerType, TemplateExample, - TemplateVersion, VariableValue, } from "api/typesGenerated"; import { calculateAutostopRequirementDaysValue } from "utils/schedule"; @@ -54,26 +53,28 @@ export const getFormPermissions = (entitlements: Entitlements) => { }; }; -export const firstVersion = ( - base: TemplateVersion | TemplateExample, +export const firstVersionFromFile = ( + fileId: string, variables: VariableValue[] | undefined, ) => { - const baseReq = { + return { storage_method: "file" as const, provisioner: provisioner, user_variable_values: variables, + file_id: fileId, tags: {}, }; +}; - if ("job" in base) { - return { - ...baseReq, - file_id: base.job.file_id, - }; - } - +export const firstVersionFromExample = ( + example: TemplateExample, + variables: VariableValue[] | undefined, +) => { return { - ...baseReq, - example_id: base.id, + storage_method: "file" as const, + provisioner: provisioner, + user_variable_values: variables, + example_id: example.id, + tags: {}, }; }; 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/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index a3f2b5b5b703c..506af70e1bd73 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -3,7 +3,7 @@ import { createTemplateVersion, getTemplateVersion, createTemplate, - uploadTemplateFile, + uploadFile, getTemplateVersionLogs, getTemplateVersionVariables, getTemplateByName, @@ -320,7 +320,7 @@ export const createTemplateMachine = }, { services: { - uploadFile: (_, { file }) => uploadTemplateFile(file), + uploadFile: (_, { file }) => uploadFile(file), loadStarterTemplate: async ({ organizationId, exampleId }) => { if (!exampleId) { throw new Error(`Example ID is not defined.`); 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) { From 80ac7bf84adceb568f7fda52ed81a87d22b1197b Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 6 Oct 2023 16:57:01 +0000 Subject: [PATCH 08/16] Remove create template xservice --- .../CreateTemplatePage/CreateTemplateForm.tsx | 102 ++-- site/src/pages/CreateTemplatePage/utils.ts | 2 +- .../createTemplate/createTemplateXService.ts | 554 ------------------ 3 files changed, 61 insertions(+), 597 deletions(-) delete mode 100644 site/src/xServices/createTemplate/createTemplateXService.ts diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 2633833cd1687..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"), @@ -643,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/utils.ts b/site/src/pages/CreateTemplatePage/utils.ts index 37167f57bc80d..6b8772541579d 100644 --- a/site/src/pages/CreateTemplatePage/utils.ts +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -5,7 +5,7 @@ import { VariableValue, } from "api/typesGenerated"; import { calculateAutostopRequirementDaysValue } from "utils/schedule"; -import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"; +import { CreateTemplateData } from "./CreateTemplateForm"; const provisioner: ProvisionerType = // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Playwright needs to use a different provisioner type! diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts deleted file mode 100644 index 506af70e1bd73..0000000000000 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { - getTemplateExamples, - createTemplateVersion, - getTemplateVersion, - createTemplate, - uploadFile, - 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 { delay } from "utils/delay"; -import { - TemplateAutostopRequirementDaysValue, - calculateAutostopRequirementDaysValue, -} from "utils/schedule"; -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 }) => uploadFile(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"; -}; From be8a70ed2ce6b368706d48fb80c8499c0d0ca16c Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 13:26:19 +0000 Subject: [PATCH 09/16] Fix imports --- site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx | 2 +- site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx | 2 +- site/src/pages/CreateTemplatePage/UploadTemplateView.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx index 996b36e023137..612085378fa23 100644 --- a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation } from "react-query"; import { templateVersionLogs } from "api/queries/templateVersions"; import { templateByName, diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx index 84e3d82a137c7..649419b4db2dc 100644 --- a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation } from "react-query"; import { templateVersionLogs } from "api/queries/templateVersions"; import { JobError, diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx index 1a41c51547177..39cd4699fac68 100644 --- a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery, useMutation } from "react-query"; import { templateVersionLogs } from "api/queries/templateVersions"; import { JobError, createTemplate } from "api/queries/templates"; import { useOrganizationId } from "hooks"; From d3ca396771a13d616ded12846712d88b7fb0380f Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 13:32:21 +0000 Subject: [PATCH 10/16] Show missed variables --- .../ImportStarterTemplateView.tsx | 9 +++++++++ .../CreateTemplatePage/UploadTemplateView.tsx | 14 +++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx index 649419b4db2dc..9854a62cae2d0 100644 --- a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -4,6 +4,7 @@ import { JobError, createTemplate, templateExamples, + templateVersionVariables, } from "api/queries/templates"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { useOrganizationId } from "hooks"; @@ -40,6 +41,13 @@ export const ImportStarterTemplateView = () => { enabled: isJobError, }); + const missedVariables = useQuery({ + ...templateVersionVariables(isJobError ? createError.version.id : ""), + enabled: + isJobError && + createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", + }); + if (isLoading) { return ; } @@ -52,6 +60,7 @@ export const ImportStarterTemplateView = () => { navigate(-1)} diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx index 39cd4699fac68..a337b82a12404 100644 --- a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -1,6 +1,10 @@ import { useQuery, useMutation } from "react-query"; import { templateVersionLogs } from "api/queries/templateVersions"; -import { JobError, createTemplate } from "api/queries/templates"; +import { + JobError, + createTemplate, + templateVersionVariables, +} from "api/queries/templates"; import { useOrganizationId } from "hooks"; import { useNavigate } from "react-router-dom"; import { CreateTemplateForm } from "./CreateTemplateForm"; @@ -26,9 +30,17 @@ export const UploadTemplateView = () => { enabled: isJobError, }); + const missedVariables = useQuery({ + ...templateVersionVariables(isJobError ? createError.version.id : ""), + enabled: + isJobError && + createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES", + }); + return ( navigate(-1)} From 3d13ca601e2c7a24719c2bbf6916b73bdb3725d0 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 13:43:03 +0000 Subject: [PATCH 11/16] Remove emotion warning --- site/jest.config.ts | 1 + 1 file changed, 1 insertion(+) 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: { From 4d4a629fbb7f8f7851bd8dc39554df47e528d08a Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 14:54:05 +0000 Subject: [PATCH 12/16] Mock scroll to function --- site/jest.setup.ts | 1 + 1 file changed, 1 insertion(+) 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", { From 5310b1b6672258e9e24ae08742ffa2f9142b63bc Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 14:54:31 +0000 Subject: [PATCH 13/16] Mock logs --- site/src/testHelpers/handlers.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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) => { From d1b307a093d57dfa77151113827932a2c049c282 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 15:26:35 +0000 Subject: [PATCH 14/16] Fix one test --- .../CreateTemplatePage.test.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx index 9400e864a2ffc..0c024d68e110c 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx @@ -107,6 +107,12 @@ test("Create template with variables", async () => { }); test("Create template from another 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 +134,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}`, + ); + }); }); From 4e2476fba2b6552af7639effc391579bc90be7a7 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 16:43:49 +0000 Subject: [PATCH 15/16] Fix tests --- .../CreateTemplatePage.test.tsx | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx index 0c024d68e110c..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,7 @@ 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 From 673c52436321de4c43b71878859cb243e8945344 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 9 Oct 2023 17:00:51 +0000 Subject: [PATCH 16/16] Fix storybook --- .../CreateTemplateForm.stories.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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: [