mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: TemplateCustomizationsStep and compose POST (DEVEX-287) (#26433)
Part of the Template Builder wizard PR stack. ## Frontend changes 1. **TemplateCustomizationsStep**: Final wizard step with org picker, icon picker, name/display name/description/icon fields, and redirect on success. 2. **Refactor**: Moved queries/mutation from PageView to Page container, extracted `renderStepContent` switch and `computeCanContinue` switch into standalone functions. --- > [!NOTE] > Generated by Coder Agents on behalf of @jeremyruppel
This commit is contained in:
@@ -2495,6 +2495,16 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
createTemplateFromBuilder = async (
|
||||
req: TypesGen.TemplateBuilderCreateTemplateRequest,
|
||||
): Promise<TypesGen.TemplateBuilderCreateTemplateResponse> => {
|
||||
const response = await this.axios.post(
|
||||
"/api/v2/templatebuilder/compose/template",
|
||||
req,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
uploadFile = async (file: File): Promise<TypesGen.UploadResponse> => {
|
||||
const response = await this.axios.post("/api/v2/files", file, {
|
||||
headers: { "Content-Type": file.type },
|
||||
|
||||
@@ -10,3 +10,7 @@ export const templateBuilderModules = (base?: string) => ({
|
||||
queryFn: () => API.getTemplateBuilderModules(base),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
export const createTemplateFromBuilder = () => ({
|
||||
mutationFn: API.createTemplateFromBuilder,
|
||||
});
|
||||
|
||||
@@ -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 <Loader />;
|
||||
}
|
||||
|
||||
// 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 <Navigate to="/templates/new" replace />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<title>{pageTitle("Create Template")}</title>
|
||||
<TemplateBuilderPageView error={error} />
|
||||
<TemplateBuilderPageView
|
||||
error={error}
|
||||
basesData={basesQuery.data}
|
||||
onCreateTemplate={handleCreate}
|
||||
createError={createMutation.error}
|
||||
isCreating={createMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<TemplateBuilderPageViewProps> = ({
|
||||
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<TemplateBuilderPageViewProps> = ({
|
||||
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<TemplateBuilderPageViewProps> = ({
|
||||
|
||||
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<TemplateBuilderPageViewProps> = ({
|
||||
<div className="flex gap-8">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{currentStep.id === "base-infra" ? (
|
||||
<BaseInfraSelectStep
|
||||
selectedBaseId={state.selectedBase?.id ?? null}
|
||||
onSelectBase={(base) => dispatch({ type: "SET_BASE", base })}
|
||||
/>
|
||||
) : currentStep.id === "base-parameters" && state.selectedBase ? (
|
||||
<BaseTemplateParametersStep
|
||||
baseId={state.selectedBase.id}
|
||||
values={state.baseVariableValues}
|
||||
onChangeValues={(values) =>
|
||||
dispatch({ type: "SET_BASE_VARIABLES", values })
|
||||
}
|
||||
/>
|
||||
) : currentStep.id === "module-select" && state.selectedBase ? (
|
||||
<ModuleSelectStep
|
||||
baseId={state.selectedBase.id}
|
||||
selectedModuleIds={state.modules.map((m) => m.id)}
|
||||
onChangeModules={(modules, meta) =>
|
||||
dispatch({ type: "SET_MODULES", modules, meta })
|
||||
}
|
||||
/>
|
||||
) : currentStep.id === "module-settings" && state.selectedBase ? (
|
||||
<ModuleSettingsStep
|
||||
baseId={state.selectedBase.id}
|
||||
selectedModuleIds={state.modules.map((m) => m.id)}
|
||||
moduleVariables={moduleVarMap}
|
||||
onChangeModuleVariables={(moduleId, variables) =>
|
||||
dispatch({ type: "SET_MODULE_VARIABLES", moduleId, variables })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
|
||||
<p className="text-sm text-content-secondary">
|
||||
Step: {currentStep.id}
|
||||
</p>
|
||||
</div>
|
||||
{renderStepContent(
|
||||
currentStep.id,
|
||||
state,
|
||||
dispatch,
|
||||
moduleVarMap,
|
||||
createError,
|
||||
)}
|
||||
|
||||
{/* Navigation controls */}
|
||||
@@ -159,8 +137,12 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleNext} disabled={!canContinue}>
|
||||
{isLastStep ? "Create Template" : "Continue"}
|
||||
<Button onClick={handleNext} disabled={!canContinue || isCreating}>
|
||||
{isCreating
|
||||
? "Creating..."
|
||||
: isLastStep
|
||||
? "Create Template"
|
||||
: "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,3 +171,104 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
|
||||
</Margins>
|
||||
);
|
||||
};
|
||||
|
||||
function renderStepContent(
|
||||
stepId: StepId,
|
||||
state: TemplateBuilderWizardState,
|
||||
dispatch: (action: WizardAction) => void,
|
||||
moduleVarMap: Record<string, Record<string, string>>,
|
||||
createError: Error | null,
|
||||
): ReactNode {
|
||||
switch (stepId) {
|
||||
case "base-infra":
|
||||
return (
|
||||
<BaseInfraSelectStep
|
||||
selectedBaseId={state.selectedBase?.id ?? null}
|
||||
onSelectBase={(base) => dispatch({ type: "SET_BASE", base })}
|
||||
/>
|
||||
);
|
||||
case "base-parameters":
|
||||
if (!state.selectedBase) return null;
|
||||
return (
|
||||
<BaseTemplateParametersStep
|
||||
baseId={state.selectedBase.id}
|
||||
values={state.baseVariableValues}
|
||||
onChangeValues={(values) =>
|
||||
dispatch({ type: "SET_BASE_VARIABLES", values })
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "module-select":
|
||||
if (!state.selectedBase) return null;
|
||||
return (
|
||||
<ModuleSelectStep
|
||||
baseId={state.selectedBase.id}
|
||||
selectedModuleIds={state.modules.map((m) => m.id)}
|
||||
onChangeModules={(modules, meta) =>
|
||||
dispatch({ type: "SET_MODULES", modules, meta })
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "module-settings":
|
||||
if (!state.selectedBase) return null;
|
||||
return (
|
||||
<ModuleSettingsStep
|
||||
baseId={state.selectedBase.id}
|
||||
selectedModuleIds={state.modules.map((m) => m.id)}
|
||||
moduleVariables={moduleVarMap}
|
||||
onChangeModuleVariables={(moduleId, variables) =>
|
||||
dispatch({
|
||||
type: "SET_MODULE_VARIABLES",
|
||||
moduleId,
|
||||
variables,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "customizations":
|
||||
return (
|
||||
<>
|
||||
{createError != null && <ErrorAlert error={createError} />}
|
||||
<TemplateCustomizationsStep
|
||||
state={state}
|
||||
onChangeField={(field, value) =>
|
||||
dispatch({
|
||||
type: "SET_CUSTOMIZATION",
|
||||
field,
|
||||
value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function computeCanContinue(
|
||||
stepId: StepId,
|
||||
state: TemplateBuilderWizardState,
|
||||
basesData: TemplateBuilderBasesResponse | undefined,
|
||||
modulesData: TemplateBuilderModulesResponse | undefined,
|
||||
moduleVarMap: Record<string, Record<string, string>>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Organization | null>(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 (
|
||||
<div className="border border-border border-solid p-6 rounded-lg">
|
||||
<h2 className="text-lg font-semibold mb-1">Customizations</h2>
|
||||
<p className="text-sm text-content-secondary mb-6">
|
||||
Add additional configurations.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-8">
|
||||
{/* Base template card */}
|
||||
{state.selectedBase && <BaseTemplateCard base={state.selectedBase} />}
|
||||
|
||||
{/* Two-column form grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-6 content-start">
|
||||
{/* Left column */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="template-display-name">Display name</Label>
|
||||
<Input
|
||||
id="template-display-name"
|
||||
value={state.displayName}
|
||||
onChange={(e) => onChangeField("displayName", e.target.value)}
|
||||
placeholder="My Template"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
{orgOptions.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="organization">
|
||||
Organization
|
||||
<span className="text-xs font-bold text-content-destructive ml-1">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<OrganizationAutocomplete
|
||||
id="organization"
|
||||
required
|
||||
value={selectedOrg}
|
||||
onChange={handleOrgChange}
|
||||
options={orgOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left column */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="template-description">Description</Label>
|
||||
<Textarea
|
||||
id="template-description"
|
||||
value={state.description}
|
||||
onChange={(e) => onChangeField("description", e.target.value)}
|
||||
placeholder="Describe what this template is for"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-content-secondary">
|
||||
Used by both humans and Agents to identify templates.
|
||||
</p>
|
||||
|
||||
<IconField
|
||||
value={state.icon}
|
||||
onChange={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
onChangeField("icon", target.value);
|
||||
}}
|
||||
onPickEmoji={(value) => onChangeField("icon", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="template-name">
|
||||
ID
|
||||
<span className="text-xs font-bold text-content-destructive ml-1">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
value={state.name}
|
||||
onChange={(e) => onChangeField("name", e.target.value)}
|
||||
placeholder="my-template"
|
||||
aria-required
|
||||
/>
|
||||
<p className="text-xs text-content-secondary">
|
||||
Used to identify the template in URLs and the API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseTemplateCard: FC<{ base: SelectedBaseMeta }> = ({ base }) => {
|
||||
return (
|
||||
<div className="w-56 shrink-0 rounded-lg bg-surface-secondary p-4 self-start">
|
||||
{base.iconUrl && (
|
||||
<div className="w-10 h-10 rounded-md bg-surface-tertiary flex items-center justify-center mb-3">
|
||||
<img src={base.iconUrl} alt="" className="w-6 h-6" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-medium text-content-primary">{base.name}</p>
|
||||
<p className="text-xs text-content-secondary mt-1">
|
||||
Preset based on base template
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TemplateBuilderWizardState } from "./wizardState";
|
||||
|
||||
type StepId =
|
||||
export type StepId =
|
||||
| "base-infra"
|
||||
| "base-parameters"
|
||||
| "module-select"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
moduleHasConfigurableVars,
|
||||
type TemplateBuilderWizardState,
|
||||
toComposeRequest,
|
||||
toCreateTemplateRequest,
|
||||
type WizardAction,
|
||||
wizardReducer,
|
||||
} from "./wizardState";
|
||||
@@ -370,3 +371,38 @@ describe("toComposeRequest", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCreateTemplateRequest", () => {
|
||||
it("produces the correct API request shape", () => {
|
||||
const state: TemplateBuilderWizardState = {
|
||||
...initialWizardState,
|
||||
baseTemplateId: "docker",
|
||||
organizationId: "org-123",
|
||||
name: "my-template",
|
||||
displayName: "My Template",
|
||||
description: "A test template",
|
||||
icon: "/icon/docker.svg",
|
||||
modules: [{ id: "code-server" }],
|
||||
};
|
||||
const request = toCreateTemplateRequest(state);
|
||||
expect(request.base_template_id).toBe("docker");
|
||||
expect(request.organization_id).toBe("org-123");
|
||||
expect(request.name).toBe("my-template");
|
||||
expect(request.display_name).toBe("My Template");
|
||||
expect(request.description).toBe("A test template");
|
||||
expect(request.icon).toBe("/icon/docker.svg");
|
||||
expect(request.modules).toEqual([{ id: "code-server" }]);
|
||||
});
|
||||
|
||||
it("omits empty optional fields", () => {
|
||||
const state: TemplateBuilderWizardState = {
|
||||
...initialWizardState,
|
||||
baseTemplateId: "docker",
|
||||
name: "my-template",
|
||||
};
|
||||
const request = toCreateTemplateRequest(state);
|
||||
expect(request.display_name).toBeUndefined();
|
||||
expect(request.description).toBeUndefined();
|
||||
expect(request.icon).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
TemplateBuilderComposeModule,
|
||||
TemplateBuilderComposeRequest,
|
||||
TemplateBuilderCreateTemplateRequest,
|
||||
TemplateBuilderModule,
|
||||
} from "#/api/typesGenerated";
|
||||
|
||||
@@ -32,8 +33,10 @@ export type TemplateBuilderWizardState = {
|
||||
baseVariableValues: Record<string, string>;
|
||||
modules: TemplateBuilderComposeModule[];
|
||||
organizationId?: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
selectedBase: SelectedBaseMeta | null;
|
||||
selectedModules: SelectedModuleMeta[];
|
||||
};
|
||||
@@ -42,8 +45,10 @@ export const initialWizardState: TemplateBuilderWizardState = {
|
||||
baseTemplateId: null,
|
||||
baseVariableValues: {},
|
||||
modules: [],
|
||||
name: "",
|
||||
displayName: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
selectedBase: null,
|
||||
selectedModules: [],
|
||||
};
|
||||
@@ -63,7 +68,7 @@ export type WizardAction =
|
||||
}
|
||||
| {
|
||||
type: "SET_CUSTOMIZATION";
|
||||
field: "organizationId" | "displayName" | "description";
|
||||
field: "organizationId" | "name" | "displayName" | "description" | "icon";
|
||||
value: string;
|
||||
}
|
||||
| { type: "RESET" };
|
||||
@@ -149,3 +154,20 @@ export const toComposeRequest = (
|
||||
modules: state.modules,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Project wizard state into the API request shape for the
|
||||
* create-template endpoint.
|
||||
*/
|
||||
export const toCreateTemplateRequest = (
|
||||
state: TemplateBuilderWizardState,
|
||||
): TemplateBuilderCreateTemplateRequest => {
|
||||
return {
|
||||
...toComposeRequest(state),
|
||||
organization_id: state.organizationId ?? "",
|
||||
name: state.name,
|
||||
display_name: state.displayName || undefined,
|
||||
description: state.description || undefined,
|
||||
icon: state.icon || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user