feat(site): Duplicate template (#6853)

This commit is contained in:
Bruno Quaresma
2023-03-30 11:42:29 -03:00
committed by GitHub
parent 6378294071
commit b26f30688f
9 changed files with 302 additions and 40 deletions
+1 -1
View File
@@ -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 })
+21 -17
View File
@@ -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,
}
}
+47 -3
View File
@@ -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
},
},
},
)