diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a1763ab75fcb6..209c4f322ccd7 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -335,7 +335,7 @@ export const getTemplateVersionGitAuth = async ( export const getTemplateVersionParameters = async ( versionId: string, -): Promise => { +): Promise => { const response = await axios.get( `/api/v2/templateversions/${versionId}/parameters`, ) diff --git a/site/src/components/TemplateLayout/TemplatePageHeader.tsx b/site/src/components/TemplateLayout/TemplatePageHeader.tsx index 11fc83223a980..636d3dcb9db77 100644 --- a/site/src/components/TemplateLayout/TemplatePageHeader.tsx +++ b/site/src/components/TemplateLayout/TemplatePageHeader.tsx @@ -28,6 +28,7 @@ const Language = { createButton: "Create workspace", deleteButton: "Delete", editFilesButton: "Edit files", + duplicateButton: "Duplicate", } const TemplateMenu: FC<{ @@ -67,6 +68,15 @@ const TemplateMenu: FC<{ > {Language.settingsButton} + {canEditFiles && ( + + {Language.duplicateButton} + + )} {canEditFiles && ( { +type GetInitialValuesParams = { + fromExample?: TemplateExample + fromCopy?: Template + parameters?: ParameterSchema[] + variables?: TemplateVersionVariable[] + canSetMaxTTL: boolean +} + +const getInitialValues = ({ + fromExample, + fromCopy, + canSetMaxTTL, + variables, + parameters, +}: GetInitialValuesParams) => { let initialValues = defaultInitialValues + if (!canSetMaxTTL) { initialValues = { ...initialValues, max_ttl_hours: 0, } } - if (!starterTemplate) { - return initialValues + + if (fromExample) { + initialValues = { + ...initialValues, + name: fromExample.id, + display_name: fromExample.name, + icon: fromExample.icon, + description: fromExample.description, + } + } + + if (fromCopy) { + initialValues = { + ...initialValues, + ...fromCopy, + name: `${fromCopy.name}-copy`, + display_name: fromCopy.display_name + ? `Copy of ${fromCopy.display_name}` + : "", + } + } + + if (variables) { + variables.forEach((variable) => { + if (!initialValues.user_variable_values) { + initialValues.user_variable_values = [] + } + initialValues.user_variable_values.push({ + name: variable.name, + value: variable.sensitive ? "" : variable.value, + }) + }) } - return { - ...initialValues, - name: starterTemplate.id, - display_name: starterTemplate.name, - icon: starterTemplate.icon, - description: starterTemplate.description, + if (parameters) { + parameters.forEach((parameter) => { + if (!initialValues.parameter_values_by_name) { + initialValues.parameter_values_by_name = {} + } + initialValues.parameter_values_by_name[parameter.name] = + parameter.default_source_value + }) } + + return initialValues } export interface CreateTemplateFormProps { @@ -142,12 +189,14 @@ export interface CreateTemplateFormProps { jobError?: string logs?: ProvisionerJobLog[] canSetMaxTTL: boolean + copiedTemplate?: Template } export const CreateTemplateForm: FC = ({ onCancel, onSubmit, starterTemplate, + copiedTemplate, parameters, variables, isSubmitting, @@ -159,7 +208,13 @@ export const CreateTemplateForm: FC = ({ }) => { const styles = useStyles() const form = useFormik({ - initialValues: getInitialValues(canSetMaxTTL, starterTemplate), + initialValues: getInitialValues({ + canSetMaxTTL, + fromExample: starterTemplate, + fromCopy: copiedTemplate, + variables, + parameters, + }), validationSchema, onSubmit, }) @@ -177,6 +232,8 @@ export const CreateTemplateForm: FC = ({ {starterTemplate ? ( + ) : copiedTemplate ? ( + ) : ( = ({ {/* Parameters */} - {parameters && ( + {parameters && parameters.length > 0 && ( = ({ )} {/* Variables */} - {variables && ( + {variables && variables.length > 0 && ( = ({ {variables.map((variable, index) => ( { await form.setFieldValue("user_variable_values." + index, { name: variable.name, - value: value, + value, }) }} /> diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx index c338bc18ced1e..5d0c7d9abe52c 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx @@ -14,10 +14,10 @@ import { MockProvisionerJob, } from "testHelpers/entities" -const renderPage = async () => { +const renderPage = async (searchParams: URLSearchParams) => { // Render with the example ID so we don't need to upload a file const view = renderWithAuth(, { - route: `/templates/new?exampleId=${MockTemplateExample.id}`, + route: `/templates/new?${searchParams.toString()}`, path: "/templates/new", // We need this because after creation, the user will be redirected to here extraRoutes: [{ path: "templates/:template", element: <> }], @@ -56,7 +56,10 @@ test("Create template with variables", async () => { ]) // Render page, fill the name and submit - const { router, container } = await renderPage() + 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( @@ -103,3 +106,32 @@ test("Create template with variables", async () => { ], }) }) + +test("Create template from another template", async () => { + const searchParams = new URLSearchParams({ + fromTemplate: MockTemplate.name, + }) + const { router } = await renderPage(searchParams) + // Name and display name are using copy prefixes + expect(screen.getByLabelText(/Name/)).toHaveValue(`${MockTemplate.name}-copy`) + expect(screen.getByLabelText(/Display name/)).toHaveValue( + `Copy of ${MockTemplate.display_name}`, + ) + // Variables are using the same values + expect( + screen.getByLabelText(MockTemplateVersionVariable1.description, { + exact: false, + }), + ).toHaveValue(MockTemplateVersionVariable1.value) + // Create template + jest + .spyOn(API, "createTemplateVersion") + .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}`, + ) +}) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 4692bbd346b02..e6443f48f3bf2 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -24,6 +24,7 @@ const CreateTemplatePage: FC = () => { context: { organizationId, exampleId: searchParams.get("exampleId"), + templateNameToCopy: searchParams.get("fromTemplate"), }, actions: { onCreate: (_, { data }) => { @@ -31,6 +32,7 @@ const CreateTemplatePage: FC = () => { }, }, }) + const { starterTemplate, parameters, @@ -67,6 +69,7 @@ const CreateTemplatePage: FC = () => { {shouldDisplayForm && ( { }) it("succeeds with default owner", async () => { + jest.spyOn(API, "getTemplateVersionSchema").mockResolvedValueOnce([]) jest .spyOn(API, "getUsers") .mockResolvedValueOnce({ users: [MockUser], count: 1 }) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 0d866762e48d9..39a1335ef1a4a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1492,27 +1492,31 @@ export const MockWorkspaceBuildParameter5: TypesGen.WorkspaceBuildParameter = { value: "5", } +export const MockParameterSchema: TypesGen.ParameterSchema = { + id: "000000", + job_id: "000000", + allow_override_destination: false, + allow_override_source: true, + created_at: "", + default_destination_scheme: "none", + default_refresh: "", + default_source_scheme: "data", + default_source_value: "default-value", + name: "parameter name", + description: "Some description!", + redisplay_value: false, + validation_condition: "", + validation_contains: [], + validation_error: "", + validation_type_system: "", + validation_value_type: "", +} + export const mockParameterSchema = ( partial: Partial, ): TypesGen.ParameterSchema => { return { - id: "000000", - job_id: "000000", - allow_override_destination: false, - allow_override_source: true, - created_at: "", - default_destination_scheme: "none", - default_refresh: "", - default_source_scheme: "data", - default_source_value: "default-value", - name: "parameter name", - description: "Some description!", - redisplay_value: false, - validation_condition: "", - validation_contains: [], - validation_error: "", - validation_type_system: "", - validation_value_type: "", + ...MockParameterSchema, ...partial, } } diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 9a0bc8f309cfb..787c291ff78ca 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -3,7 +3,7 @@ import { WorkspaceBuildTransition } from "../api/types" import { CreateWorkspaceBuildRequest } from "../api/typesGenerated" import { permissionsToCheck } from "../xServices/auth/authXService" import * as M from "./entities" -import { MockGroup, MockWorkspaceQuota } from "./entities" +import { MockGroup, mockParameterSchema, MockWorkspaceQuota } from "./entities" import fs from "fs" import path from "path" @@ -79,7 +79,23 @@ export const handlers = [ rest.get( "/api/v2/templateversions/:templateVersionId/schema", async (req, res, ctx) => { - return res(ctx.status(200), ctx.json([])) + return res( + ctx.status(200), + ctx.json([ + mockParameterSchema({ + id: "1", + name: M.MockTemplateVersionParameter1.name, + }), + mockParameterSchema({ + id: "2", + name: M.MockTemplateVersionParameter2.name, + }), + mockParameterSchema({ + id: "3", + name: M.MockTemplateVersionParameter3.name, + }), + ]), + ) }, ), rest.get( @@ -321,7 +337,7 @@ export const handlers = [ }, ), - rest.get("api/v2/files/:fileId", (_, res, ctx) => { + rest.get("/api/v2/files/:fileId", (_, res, ctx) => { const fileBuffer = fs.readFileSync( path.resolve(__dirname, "./templateFiles.tar"), ) @@ -333,4 +349,32 @@ export const handlers = [ ctx.body(fileBuffer), ) }), + + rest.get( + "/api/v2/templateversions/:templateVersionId/parameters", + (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json([ + M.MockTemplateVersionParameter1, + M.MockTemplateVersionParameter2, + M.MockTemplateVersionParameter3, + ]), + ) + }, + ), + + rest.get( + "/api/v2/templateversions/:templateVersionId/variables", + (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json([ + M.MockTemplateVersionVariable1, + M.MockTemplateVersionVariable2, + M.MockTemplateVersionVariable3, + ]), + ) + }, + ), ] diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 96a41575ba261..9548e483c8334 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -7,6 +7,8 @@ import { uploadTemplateFile, getTemplateVersionLogs, getTemplateVersionVariables, + getTemplateByName, + getTemplateVersionParameters, } from "api/api" import { CreateTemplateVersionRequest, @@ -61,6 +63,9 @@ interface CreateTemplateContext { // 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 = @@ -106,6 +111,14 @@ export const createTemplateMachine = loadVersionLogs: { data: ProvisionerJobLog[] } + copyTemplateData: { + data: { + template: Template + version: TemplateVersion + parameters: ParameterSchema[] + variables: TemplateVersionVariable[] + } + } }, }, tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, @@ -114,6 +127,10 @@ export const createTemplateMachine = starting: { always: [ { target: "loadingStarterTemplate", cond: "isExampleProvided" }, + { + target: "copyingTemplateData", + cond: "isTemplateIdToCopyProvided", + }, { target: "idle" }, ], tags: ["loading"], @@ -132,6 +149,27 @@ export const createTemplateMachine = }, 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: { @@ -292,10 +330,56 @@ export const createTemplateMachine = } 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, schemaParameters, computedParameters, variables] = + await Promise.all([ + getTemplateVersion(template.active_version_id), + getTemplateVersionSchema(template.active_version_id), + getTemplateVersionParameters(template.active_version_id), + getTemplateVersionVariables(template.active_version_id), + ]) + + // Recreate parameters with default_source_value from the already + // computed version parameters + const parameters: ParameterSchema[] = [] + computedParameters.forEach((computedParameter) => { + const schema = schemaParameters.find( + (schema) => schema.name === computedParameter.name, + ) + if (!schema) { + throw new Error( + `Parameter ${computedParameter.name} not found in schema`, + ) + } + parameters.push({ + ...schema, + default_source_value: computedParameter.source_value, + }) + }) + + return { + template, + version, + parameters, + variables, + } + }, createFirstVersion: async ({ organizationId, + templateNameToCopy, exampleId, uploadResponse, + version, }) => { if (exampleId) { return createTemplateVersion(organizationId, { @@ -306,6 +390,21 @@ export const createTemplateMachine = }) } + 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: "terraform", + tags: {}, + }) + } + if (uploadResponse) { return createTemplateVersion(organizationId, { storage_method: "file", @@ -456,9 +555,17 @@ export const createTemplateMachine = uploadResponse: (_) => undefined, }), assignJobLogs: assign({ jobLogs: (_, { data }) => data }), + assignCopiedTemplateData: assign({ + copiedTemplate: (_, { data }) => data.template, + version: (_, { data }) => data.version, + parameters: (_, { data }) => data.parameters, + variables: (_, { data }) => data.variables, + }), }, guards: { isExampleProvided: ({ exampleId }) => Boolean(exampleId), + isTemplateIdToCopyProvided: ({ templateNameToCopy }) => + Boolean(templateNameToCopy), isNotUsingExample: ({ exampleId }) => !exampleId, hasFile: ({ file }) => Boolean(file), hasFailed: (_, { data }) => @@ -469,6 +576,9 @@ export const createTemplateMachine = ), hasNoParametersOrVariables: (_, { data }) => data.parameters === undefined && data.variables === undefined, + hasParametersOrVariables: (_, { data }) => { + return data.parameters.length > 0 || data.variables.length > 0 + }, }, }, )