diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cb97226c5d..4e444e3867 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2495,6 +2495,16 @@ class ApiMethods { return response.data; }; + createTemplateFromBuilder = async ( + req: TypesGen.TemplateBuilderCreateTemplateRequest, + ): Promise => { + const response = await this.axios.post( + "/api/v2/templatebuilder/compose/template", + req, + ); + return response.data; + }; + uploadFile = async (file: File): Promise => { const response = await this.axios.post("/api/v2/files", file, { headers: { "Content-Type": file.type }, diff --git a/site/src/api/queries/templateBuilder.ts b/site/src/api/queries/templateBuilder.ts index 52cd90e8a0..210d36106d 100644 --- a/site/src/api/queries/templateBuilder.ts +++ b/site/src/api/queries/templateBuilder.ts @@ -10,3 +10,7 @@ export const templateBuilderModules = (base?: string) => ({ queryFn: () => API.getTemplateBuilderModules(base), staleTime: Number.POSITIVE_INFINITY, }); + +export const createTemplateFromBuilder = () => ({ + mutationFn: API.createTemplateFromBuilder, +}); diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index 46a57bab40..5561bbe9c5 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -1,29 +1,59 @@ import type { FC } from "react"; -import { useQuery } from "react-query"; -import { Navigate } from "react-router"; +import { useMutation, useQuery } from "react-query"; +import { Navigate, useNavigate } from "react-router"; import { deploymentConfig } from "#/api/queries/deployment"; +import { + createTemplateFromBuilder, + templateBuilderBases, +} from "#/api/queries/templateBuilder"; import { Loader } from "#/components/Loader/Loader"; +import { linkToTemplate, useLinks } from "#/modules/navigation"; import { pageTitle } from "#/utils/page"; import { TemplateBuilderPageView } from "./TemplateBuilderPageView"; +import type { TemplateBuilderWizardState } from "./wizardState"; +import { toCreateTemplateRequest } from "./wizardState"; const TemplateBuilderPage: FC = () => { + const navigate = useNavigate(); + const getLink = useLinks(); const { data, error, isLoading } = useQuery(deploymentConfig()); + const basesQuery = useQuery(templateBuilderBases()); + const createMutation = useMutation(createTemplateFromBuilder()); if (isLoading) { return ; } - // if the template builder is disabled in the deployment config, - // redirect to the new template page + // If the template builder is disabled in the deployment config, + // redirect to the new template page. const builderDisabled = data?.config?.template_builder?.disabled ?? false; if (builderDisabled) { return ; } + const handleCreate = (state: TemplateBuilderWizardState) => { + const req = toCreateTemplateRequest(state); + createMutation.mutate(req, { + onSuccess: (resp) => { + const t = resp.template; + navigate( + `${getLink(linkToTemplate(t.organization_name, t.name))}/files`, + { state: { justCreated: true } }, + ); + }, + }); + }; + return ( <> {pageTitle("Create Template")} - + ); }; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 01149ffba1..5050018096 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -1,9 +1,10 @@ -import { type FC, useReducer, useState } from "react"; +import { type FC, type ReactNode, useReducer, useState } from "react"; import { useQuery } from "react-query"; -import { - templateBuilderBases, - templateBuilderModules, -} from "#/api/queries/templateBuilder"; +import { templateBuilderModules } from "#/api/queries/templateBuilder"; +import type { + TemplateBuilderBasesResponse, + TemplateBuilderModulesResponse, +} from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { Link } from "#/components/Link/Link"; @@ -29,20 +30,34 @@ import { findNextVisibleIndex, findPrevVisibleIndex, nearestVisible, + type StepId, WIZARD_STEPS, } from "./steps"; -import { initialWizardState, wizardReducer } from "./wizardState"; +import { TemplateCustomizationsStep } from "./TemplateCustomizationsStep"; +import { + initialWizardState, + type TemplateBuilderWizardState, + type WizardAction, + wizardReducer, +} from "./wizardState"; interface TemplateBuilderPageViewProps { error: unknown; + basesData: TemplateBuilderBasesResponse | undefined; + onCreateTemplate: (state: TemplateBuilderWizardState) => void; + createError: Error | null; + isCreating: boolean; } export const TemplateBuilderPageView: FC = ({ error, + basesData, + onCreateTemplate, + createError, + isCreating, }) => { const [state, dispatch] = useReducer(wizardReducer, initialWizardState); const [stepIndex, setStepIndex] = useState(0); - const basesQuery = useQuery(templateBuilderBases()); const modulesQuery = useQuery(templateBuilderModules(state.selectedBase?.id)); const moduleVarMap = Object.fromEntries( @@ -56,20 +71,13 @@ export const TemplateBuilderPageView: FC = ({ const isFirstStep = prevIndex === -1; const isLastStep = nextIndex === -1; - const canContinue = - currentStep.id === "base-parameters" - ? baseParametersComplete( - basesQuery.data, - state.selectedBase?.id ?? null, - state.baseVariableValues, - ) - : currentStep.id === "module-settings" - ? moduleSettingsComplete( - modulesQuery.data, - state.modules.map((m) => m.id), - moduleVarMap, - ) - : true; + const canContinue = computeCanContinue( + currentStep.id, + state, + basesData, + modulesQuery.data, + moduleVarMap, + ); const handleBack = () => { setStepIndex(prevIndex); @@ -77,7 +85,7 @@ export const TemplateBuilderPageView: FC = ({ const handleNext = () => { if (isLastStep) { - // Compose will be wired in a follow-up issue. + onCreateTemplate(state); return; } setStepIndex(nextIndex); @@ -112,42 +120,12 @@ export const TemplateBuilderPageView: FC = ({
{/* Main content area */}
- {currentStep.id === "base-infra" ? ( - dispatch({ type: "SET_BASE", base })} - /> - ) : currentStep.id === "base-parameters" && state.selectedBase ? ( - - dispatch({ type: "SET_BASE_VARIABLES", values }) - } - /> - ) : currentStep.id === "module-select" && state.selectedBase ? ( - m.id)} - onChangeModules={(modules, meta) => - dispatch({ type: "SET_MODULES", modules, meta }) - } - /> - ) : currentStep.id === "module-settings" && state.selectedBase ? ( - m.id)} - moduleVariables={moduleVarMap} - onChangeModuleVariables={(moduleId, variables) => - dispatch({ type: "SET_MODULE_VARIABLES", moduleId, variables }) - } - /> - ) : ( -
-

- Step: {currentStep.id} -

-
+ {renderStepContent( + currentStep.id, + state, + dispatch, + moduleVarMap, + createError, )} {/* Navigation controls */} @@ -159,8 +137,12 @@ export const TemplateBuilderPageView: FC = ({ Back )} -
@@ -189,3 +171,104 @@ export const TemplateBuilderPageView: FC = ({ ); }; + +function renderStepContent( + stepId: StepId, + state: TemplateBuilderWizardState, + dispatch: (action: WizardAction) => void, + moduleVarMap: Record>, + createError: Error | null, +): ReactNode { + switch (stepId) { + case "base-infra": + return ( + dispatch({ type: "SET_BASE", base })} + /> + ); + case "base-parameters": + if (!state.selectedBase) return null; + return ( + + dispatch({ type: "SET_BASE_VARIABLES", values }) + } + /> + ); + case "module-select": + if (!state.selectedBase) return null; + return ( + m.id)} + onChangeModules={(modules, meta) => + dispatch({ type: "SET_MODULES", modules, meta }) + } + /> + ); + case "module-settings": + if (!state.selectedBase) return null; + return ( + m.id)} + moduleVariables={moduleVarMap} + onChangeModuleVariables={(moduleId, variables) => + dispatch({ + type: "SET_MODULE_VARIABLES", + moduleId, + variables, + }) + } + /> + ); + case "customizations": + return ( + <> + {createError != null && } + + dispatch({ + type: "SET_CUSTOMIZATION", + field, + value, + }) + } + /> + + ); + default: + return null; + } +} + +function computeCanContinue( + stepId: StepId, + state: TemplateBuilderWizardState, + basesData: TemplateBuilderBasesResponse | undefined, + modulesData: TemplateBuilderModulesResponse | undefined, + moduleVarMap: Record>, +): boolean { + switch (stepId) { + case "base-parameters": + return baseParametersComplete( + basesData, + state.selectedBase?.id ?? null, + state.baseVariableValues, + ); + case "module-settings": + return moduleSettingsComplete( + modulesData, + state.modules.map((m) => m.id), + moduleVarMap, + ); + case "customizations": + return state.name.trim() !== ""; + default: + return true; + } +} diff --git a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx new file mode 100644 index 0000000000..09cd4ec2a2 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx @@ -0,0 +1,155 @@ +import { type FC, useEffect, useState } from "react"; +import { useQuery } from "react-query"; +import { permittedOrganizations } from "#/api/queries/organizations"; +import type { Organization } from "#/api/typesGenerated"; +import { IconField } from "#/components/IconField/IconField"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { Textarea } from "#/components/Textarea/Textarea"; +import type { + SelectedBaseMeta, + TemplateBuilderWizardState, +} from "./wizardState"; + +interface TemplateCustomizationsStepProps { + state: TemplateBuilderWizardState; + onChangeField: ( + field: "organizationId" | "name" | "displayName" | "description" | "icon", + value: string, + ) => void; +} + +export const TemplateCustomizationsStep: FC< + TemplateCustomizationsStepProps +> = ({ state, onChangeField }) => { + const permittedOrgsQuery = useQuery( + permittedOrganizations({ + object: { resource_type: "template" }, + action: "create", + }), + ); + const orgOptions = permittedOrgsQuery.data ?? []; + + const [selectedOrg, setSelectedOrg] = useState(null); + + // Auto-select when exactly one org is available. + useEffect(() => { + if (orgOptions.length === 1 && !selectedOrg) { + setSelectedOrg(orgOptions[0]); + onChangeField("organizationId", orgOptions[0].id); + } + }, [orgOptions, selectedOrg, onChangeField]); + + const handleOrgChange = (org: Organization | null) => { + setSelectedOrg(org); + onChangeField("organizationId", org?.id ?? ""); + }; + + return ( +
+

Customizations

+

+ Add additional configurations. +

+ +
+ {/* Base template card */} + {state.selectedBase && } + + {/* Two-column form grid */} +
+ {/* Left column */} +
+ + onChangeField("displayName", e.target.value)} + placeholder="My Template" + /> +
+ + {/* Right column */} + {orgOptions.length > 0 && ( +
+ + +
+ )} + + {/* Left column */} +
+ +