mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
feat(site): Duplicate template (#6853)
This commit is contained in:
+1
-1
@@ -335,7 +335,7 @@ export const getTemplateVersionGitAuth = async (
|
||||
|
||||
export const getTemplateVersionParameters = async (
|
||||
versionId: string,
|
||||
): Promise<TypesGen.Parameter[]> => {
|
||||
): Promise<TypesGen.ComputedParameter[]> => {
|
||||
const response = await axios.get(
|
||||
`/api/v2/templateversions/${versionId}/parameters`,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
</MenuItem>
|
||||
{canEditFiles && (
|
||||
<MenuItem
|
||||
onClick={handleClose}
|
||||
component={RouterLink}
|
||||
to={`/templates/new?fromTemplate=${templateName}`}
|
||||
>
|
||||
{Language.duplicateButton}
|
||||
</MenuItem>
|
||||
)}
|
||||
{canEditFiles && (
|
||||
<MenuItem
|
||||
component={RouterLink}
|
||||
|
||||
@@ -4,6 +4,7 @@ import TextField from "@material-ui/core/TextField"
|
||||
import {
|
||||
ParameterSchema,
|
||||
ProvisionerJobLog,
|
||||
Template,
|
||||
TemplateExample,
|
||||
TemplateVersionVariable,
|
||||
} from "api/typesGenerated"
|
||||
@@ -106,28 +107,74 @@ const defaultInitialValues: CreateTemplateData = {
|
||||
allow_user_cancel_workspace_jobs: false,
|
||||
}
|
||||
|
||||
const getInitialValues = (
|
||||
canSetMaxTTL: boolean,
|
||||
starterTemplate?: TemplateExample,
|
||||
) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...initialValues,
|
||||
name: starterTemplate.id,
|
||||
display_name: starterTemplate.name,
|
||||
icon: starterTemplate.icon,
|
||||
description: starterTemplate.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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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<CreateTemplateFormProps> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
starterTemplate,
|
||||
copiedTemplate,
|
||||
parameters,
|
||||
variables,
|
||||
isSubmitting,
|
||||
@@ -159,7 +208,13 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const form = useFormik<CreateTemplateData>({
|
||||
initialValues: getInitialValues(canSetMaxTTL, starterTemplate),
|
||||
initialValues: getInitialValues({
|
||||
canSetMaxTTL,
|
||||
fromExample: starterTemplate,
|
||||
fromCopy: copiedTemplate,
|
||||
variables,
|
||||
parameters,
|
||||
}),
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
})
|
||||
@@ -177,6 +232,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
<FormFields>
|
||||
{starterTemplate ? (
|
||||
<SelectedTemplate template={starterTemplate} />
|
||||
) : copiedTemplate ? (
|
||||
<SelectedTemplate template={copiedTemplate} />
|
||||
) : (
|
||||
<TemplateUpload
|
||||
{...upload}
|
||||
@@ -329,7 +386,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
</FormSection>
|
||||
|
||||
{/* Parameters */}
|
||||
{parameters && (
|
||||
{parameters && parameters.length > 0 && (
|
||||
<FormSection
|
||||
title={t("form.parameters.title")}
|
||||
description={t("form.parameters.description")}
|
||||
@@ -353,7 +410,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
)}
|
||||
|
||||
{/* Variables */}
|
||||
{variables && (
|
||||
{variables && variables.length > 0 && (
|
||||
<FormSection
|
||||
title="Variables"
|
||||
description="Input variables allow you to customize templates without altering their source code."
|
||||
@@ -361,13 +418,14 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
<FormFields>
|
||||
{variables.map((variable, index) => (
|
||||
<VariableInput
|
||||
defaultValue={variable.value}
|
||||
variable={variable}
|
||||
disabled={isSubmitting}
|
||||
key={variable.name}
|
||||
onChange={async (value) => {
|
||||
await form.setFieldValue("user_variable_values." + index, {
|
||||
name: variable.name,
|
||||
value: value,
|
||||
value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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(<CreateTemplatePage />, {
|
||||
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}`,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 && (
|
||||
<CreateTemplateForm
|
||||
copiedTemplate={state.context.copiedTemplate}
|
||||
canSetMaxTTL={canSetMaxTTL}
|
||||
error={error}
|
||||
starterTemplate={starterTemplate}
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("CreateWorkspacePage", () => {
|
||||
})
|
||||
|
||||
it("succeeds with default owner", async () => {
|
||||
jest.spyOn(API, "getTemplateVersionSchema").mockResolvedValueOnce([])
|
||||
jest
|
||||
.spyOn(API, "getUsers")
|
||||
.mockResolvedValueOnce({ users: [MockUser], count: 1 })
|
||||
|
||||
@@ -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>,
|
||||
): 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
)
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user