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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 10 additions & 0 deletions
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

Lines changed: 74 additions & 16 deletions
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

Lines changed: 35 additions & 3 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 21 additions & 17 deletions
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)
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