Skip to content

Commit b26f306

Browse files
feat(site): Duplicate template (coder#6853)
1 parent 6378294 commit b26f306

File tree

9 files changed

+302
-40
lines changed

9 files changed

+302
-40
lines changed

site/src/api/api.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ export const getTemplateVersionGitAuth = async (
335335

336336
export const getTemplateVersionParameters = async (
337337
versionId: string,
338-
): Promise<TypesGen.Parameter[]> => {
338+
): Promise<TypesGen.ComputedParameter[]> => {
339339
const response = await axios.get(
340340
`/api/v2/templateversions/${versionId}/parameters`,
341341
)

site/src/components/TemplateLayout/TemplatePageHeader.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const Language = {
2828
createButton: "Create workspace",
2929
deleteButton: "Delete",
3030
editFilesButton: "Edit files",
31+
duplicateButton: "Duplicate",
3132
}
3233

3334
const TemplateMenu: FC<{
@@ -67,6 +68,15 @@ const TemplateMenu: FC<{
6768
>
6869
{Language.settingsButton}
6970
</MenuItem>
71+
{canEditFiles && (
72+
<MenuItem
73+
onClick={handleClose}
74+
component={RouterLink}
75+
to={`/templates/new?fromTemplate=${templateName}`}
76+
>
77+
{Language.duplicateButton}
78+
</MenuItem>
79+
)}
7080
{canEditFiles && (
7181
<MenuItem
7282
component={RouterLink}

site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx

+74-16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import TextField from "@material-ui/core/TextField"
44
import {
55
ParameterSchema,
66
ProvisionerJobLog,
7+
Template,
78
TemplateExample,
89
TemplateVersionVariable,
910
} from "api/typesGenerated"
@@ -106,28 +107,74 @@ const defaultInitialValues: CreateTemplateData = {
106107
allow_user_cancel_workspace_jobs: false,
107108
}
108109

109-
const getInitialValues = (
110-
canSetMaxTTL: boolean,
111-
starterTemplate?: TemplateExample,
112-
) => {
110+
type GetInitialValuesParams = {
111+
fromExample?: TemplateExample
112+
fromCopy?: Template
113+
parameters?: ParameterSchema[]
114+
variables?: TemplateVersionVariable[]
115+
canSetMaxTTL: boolean
116+
}
117+
118+
const getInitialValues = ({
119+
fromExample,
120+
fromCopy,
121+
canSetMaxTTL,
122+
variables,
123+
parameters,
124+
}: GetInitialValuesParams) => {
113125
let initialValues = defaultInitialValues
126+
114127
if (!canSetMaxTTL) {
115128
initialValues = {
116129
...initialValues,
117130
max_ttl_hours: 0,
118131
}
119132
}
120-
if (!starterTemplate) {
121-
return initialValues
133+
134+
if (fromExample) {
135+
initialValues = {
136+
...initialValues,
137+
name: fromExample.id,
138+
display_name: fromExample.name,
139+
icon: fromExample.icon,
140+
description: fromExample.description,
141+
}
142+
}
143+
144+
if (fromCopy) {
145+
initialValues = {
146+
...initialValues,
147+
...fromCopy,
148+
name: `${fromCopy.name}-copy`,
149+
display_name: fromCopy.display_name
150+
? `Copy of ${fromCopy.display_name}`
151+
: "",
152+
}
153+
}
154+
155+
if (variables) {
156+
variables.forEach((variable) => {
157+
if (!initialValues.user_variable_values) {
158+
initialValues.user_variable_values = []
159+
}
160+
initialValues.user_variable_values.push({
161+
name: variable.name,
162+
value: variable.sensitive ? "" : variable.value,
163+
})
164+
})
122165
}
123166

124-
return {
125-
...initialValues,
126-
name: starterTemplate.id,
127-
display_name: starterTemplate.name,
128-
icon: starterTemplate.icon,
129-
description: starterTemplate.description,
167+
if (parameters) {
168+
parameters.forEach((parameter) => {
169+
if (!initialValues.parameter_values_by_name) {
170+
initialValues.parameter_values_by_name = {}
171+
}
172+
initialValues.parameter_values_by_name[parameter.name] =
173+
parameter.default_source_value
174+
})
130175
}
176+
177+
return initialValues
131178
}
132179

133180
export interface CreateTemplateFormProps {
@@ -142,12 +189,14 @@ export interface CreateTemplateFormProps {
142189
jobError?: string
143190
logs?: ProvisionerJobLog[]
144191
canSetMaxTTL: boolean
192+
copiedTemplate?: Template
145193
}
146194

147195
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
148196
onCancel,
149197
onSubmit,
150198
starterTemplate,
199+
copiedTemplate,
151200
parameters,
152201
variables,
153202
isSubmitting,
@@ -159,7 +208,13 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
159208
}) => {
160209
const styles = useStyles()
161210
const form = useFormik<CreateTemplateData>({
162-
initialValues: getInitialValues(canSetMaxTTL, starterTemplate),
211+
initialValues: getInitialValues({
212+
canSetMaxTTL,
213+
fromExample: starterTemplate,
214+
fromCopy: copiedTemplate,
215+
variables,
216+
parameters,
217+
}),
163218
validationSchema,
164219
onSubmit,
165220
})
@@ -177,6 +232,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
177232
<FormFields>
178233
{starterTemplate ? (
179234
<SelectedTemplate template={starterTemplate} />
235+
) : copiedTemplate ? (
236+
<SelectedTemplate template={copiedTemplate} />
180237
) : (
181238
<TemplateUpload
182239
{...upload}
@@ -329,7 +386,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
329386
</FormSection>
330387

331388
{/* Parameters */}
332-
{parameters && (
389+
{parameters && parameters.length > 0 && (
333390
<FormSection
334391
title={t("form.parameters.title")}
335392
description={t("form.parameters.description")}
@@ -353,21 +410,22 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
353410
)}
354411

355412
{/* Variables */}
356-
{variables && (
413+
{variables && variables.length > 0 && (
357414
<FormSection
358415
title="Variables"
359416
description="Input variables allow you to customize templates without altering their source code."
360417
>
361418
<FormFields>
362419
{variables.map((variable, index) => (
363420
<VariableInput
421+
defaultValue={variable.value}
364422
variable={variable}
365423
disabled={isSubmitting}
366424
key={variable.name}
367425
onChange={async (value) => {
368426
await form.setFieldValue("user_variable_values." + index, {
369427
name: variable.name,
370-
value: value,
428+
value,
371429
})
372430
}}
373431
/>

site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx

+35-3
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ import {
1414
MockProvisionerJob,
1515
} from "testHelpers/entities"
1616

17-
const renderPage = async () => {
17+
const renderPage = async (searchParams: URLSearchParams) => {
1818
// Render with the example ID so we don't need to upload a file
1919
const view = renderWithAuth(<CreateTemplatePage />, {
20-
route: `/templates/new?exampleId=${MockTemplateExample.id}`,
20+
route: `/templates/new?${searchParams.toString()}`,
2121
path: "/templates/new",
2222
// We need this because after creation, the user will be redirected to here
2323
extraRoutes: [{ path: "templates/:template", element: <></> }],
@@ -56,7 +56,10 @@ test("Create template with variables", async () => {
5656
])
5757

5858
// Render page, fill the name and submit
59-
const { router, container } = await renderPage()
59+
const searchParams = new URLSearchParams({
60+
exampleId: MockTemplateExample.id,
61+
})
62+
const { router, container } = await renderPage(searchParams)
6063
const form = container.querySelector("form") as HTMLFormElement
6164
await userEvent.type(screen.getByLabelText(/Name/), "my-template")
6265
await userEvent.click(
@@ -103,3 +106,32 @@ test("Create template with variables", async () => {
103106
],
104107
})
105108
})
109+
110+
test("Create template from another template", async () => {
111+
const searchParams = new URLSearchParams({
112+
fromTemplate: MockTemplate.name,
113+
})
114+
const { router } = await renderPage(searchParams)
115+
// Name and display name are using copy prefixes
116+
expect(screen.getByLabelText(/Name/)).toHaveValue(`${MockTemplate.name}-copy`)
117+
expect(screen.getByLabelText(/Display name/)).toHaveValue(
118+
`Copy of ${MockTemplate.display_name}`,
119+
)
120+
// Variables are using the same values
121+
expect(
122+
screen.getByLabelText(MockTemplateVersionVariable1.description, {
123+
exact: false,
124+
}),
125+
).toHaveValue(MockTemplateVersionVariable1.value)
126+
// Create template
127+
jest
128+
.spyOn(API, "createTemplateVersion")
129+
.mockResolvedValue(MockTemplateVersion)
130+
jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate)
131+
await userEvent.click(
132+
screen.getByRole("button", { name: /create template/i }),
133+
)
134+
expect(router.state.location.pathname).toEqual(
135+
`/templates/${MockTemplate.name}`,
136+
)
137+
})

site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ const CreateTemplatePage: FC = () => {
2424
context: {
2525
organizationId,
2626
exampleId: searchParams.get("exampleId"),
27+
templateNameToCopy: searchParams.get("fromTemplate"),
2728
},
2829
actions: {
2930
onCreate: (_, { data }) => {
3031
navigate(`/templates/${data.name}`)
3132
},
3233
},
3334
})
35+
3436
const {
3537
starterTemplate,
3638
parameters,
@@ -67,6 +69,7 @@ const CreateTemplatePage: FC = () => {
6769

6870
{shouldDisplayForm && (
6971
<CreateTemplateForm
72+
copiedTemplate={state.context.copiedTemplate}
7073
canSetMaxTTL={canSetMaxTTL}
7174
error={error}
7275
starterTemplate={starterTemplate}

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ describe("CreateWorkspacePage", () => {
7979
})
8080

8181
it("succeeds with default owner", async () => {
82+
jest.spyOn(API, "getTemplateVersionSchema").mockResolvedValueOnce([])
8283
jest
8384
.spyOn(API, "getUsers")
8485
.mockResolvedValueOnce({ users: [MockUser], count: 1 })

site/src/testHelpers/entities.ts

+21-17
Original file line numberDiff line numberDiff line change
@@ -1492,27 +1492,31 @@ export const MockWorkspaceBuildParameter5: TypesGen.WorkspaceBuildParameter = {
14921492
value: "5",
14931493
}
14941494

1495+
export const MockParameterSchema: TypesGen.ParameterSchema = {
1496+
id: "000000",
1497+
job_id: "000000",
1498+
allow_override_destination: false,
1499+
allow_override_source: true,
1500+
created_at: "",
1501+
default_destination_scheme: "none",
1502+
default_refresh: "",
1503+
default_source_scheme: "data",
1504+
default_source_value: "default-value",
1505+
name: "parameter name",
1506+
description: "Some description!",
1507+
redisplay_value: false,
1508+
validation_condition: "",
1509+
validation_contains: [],
1510+
validation_error: "",
1511+
validation_type_system: "",
1512+
validation_value_type: "",
1513+
}
1514+
14951515
export const mockParameterSchema = (
14961516
partial: Partial<TypesGen.ParameterSchema>,
14971517
): TypesGen.ParameterSchema => {
14981518
return {
1499-
id: "000000",
1500-
job_id: "000000",
1501-
allow_override_destination: false,
1502-
allow_override_source: true,
1503-
created_at: "",
1504-
default_destination_scheme: "none",
1505-
default_refresh: "",
1506-
default_source_scheme: "data",
1507-
default_source_value: "default-value",
1508-
name: "parameter name",
1509-
description: "Some description!",
1510-
redisplay_value: false,
1511-
validation_condition: "",
1512-
validation_contains: [],
1513-
validation_error: "",
1514-
validation_type_system: "",
1515-
validation_value_type: "",
1519+
...MockParameterSchema,
15161520
...partial,
15171521
}
15181522
}

0 commit comments

Comments
 (0)