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 + }, }, }, ) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy