Skip to content

feat(site): Duplicate template #6853

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 30, 2023
2 changes: 1 addition & 1 deletion site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
)
Expand Down
10 changes: 10 additions & 0 deletions site/src/components/TemplateLayout/TemplatePageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const Language = {
createButton: "Create workspace",
deleteButton: "Delete",
editFilesButton: "Edit files",
duplicateButton: "Duplicate",
}

const TemplateMenu: FC<{
Expand Down Expand Up @@ -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}
Expand Down
90 changes: 74 additions & 16 deletions site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import TextField from "@material-ui/core/TextField"
import {
ParameterSchema,
ProvisionerJobLog,
Template,
TemplateExample,
TemplateVersionVariable,
} from "api/typesGenerated"
Expand Down Expand Up @@ -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,
}
}

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 {
Expand All @@ -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,
Expand All @@ -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,
})
Expand All @@ -177,6 +232,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
<FormFields>
{starterTemplate ? (
<SelectedTemplate template={starterTemplate} />
) : copiedTemplate ? (
<SelectedTemplate template={copiedTemplate} />
) : (
<TemplateUpload
{...upload}
Expand Down Expand Up @@ -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")}
Expand All @@ -353,21 +410,22 @@ 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."
>
<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,
})
}}
/>
Expand Down
38 changes: 35 additions & 3 deletions site/src/pages/CreateTemplatePage/CreateTemplatePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <></> }],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}`,
)
})
3 changes: 3 additions & 0 deletions site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ const CreateTemplatePage: FC = () => {
context: {
organizationId,
exampleId: searchParams.get("exampleId"),
templateNameToCopy: searchParams.get("fromTemplate"),
},
actions: {
onCreate: (_, { data }) => {
navigate(`/templates/${data.name}`)
},
},
})

const {
starterTemplate,
parameters,
Expand Down Expand Up @@ -67,6 +69,7 @@ const CreateTemplatePage: FC = () => {

{shouldDisplayForm && (
<CreateTemplateForm
copiedTemplate={state.context.copiedTemplate}
canSetMaxTTL={canSetMaxTTL}
error={error}
starterTemplate={starterTemplate}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
38 changes: 21 additions & 17 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
Loading