Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import type {
SelectedBaseMeta,
TemplateBuilderWizardState,
} from "./wizardState";
import { toCreateTemplateRequest, toSelectedBaseMeta } from "./wizardState";
import {
type CustomizationsFormValues,
toCreateTemplateRequest,
toSelectedBaseMeta,
} from "./wizardState";

const TemplateBuilderPage: FC = () => {
const navigate = useNavigate();
Expand Down Expand Up @@ -109,8 +113,11 @@ const TemplateBuilderPage: FC = () => {
return <Navigate to="/templates/new" replace />;
}

const handleCreate = (state: TemplateBuilderWizardState) => {
const req = toCreateTemplateRequest(state);
const handleCreate = (
state: TemplateBuilderWizardState,
customizations: CustomizationsFormValues,
) => {
const req = toCreateTemplateRequest(state, customizations);
const durationSeconds = (Date.now() - state.enteredAt) / 1000;

createMutation.mutate(req, {
Expand Down
50 changes: 33 additions & 17 deletions site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ import {
WIZARD_STEPS,
} from "./steps";
import { TemplateAlternatives } from "./TemplateAlternatives";
import { TemplateCustomizationsStep } from "./TemplateCustomizationsStep";
import {
TEMPLATE_CUSTOMIZATIONS_FORM_ID,
TemplateCustomizationsStep,
} from "./TemplateCustomizationsStep";
import {
type CustomizationsFormValues,
initWizardState,
type SelectedBaseMeta,
type TemplateBuilderWizardState,
Expand All @@ -59,7 +63,10 @@ interface TemplateBuilderPageViewProps {
error: unknown;
basesData: TemplateBuilderBasesResponse | undefined;
preselectedBase?: SelectedBaseMeta;
onCreateTemplate: (state: TemplateBuilderWizardState) => void;
onCreateTemplate: (
state: TemplateBuilderWizardState,
customizations: CustomizationsFormValues,
) => void;
createError: Error | null;
isCreating: boolean;
onClearCreateError?: () => void;
Expand Down Expand Up @@ -154,13 +161,16 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
};

const handleNext = () => {
if (isLastStep) {
onCreateTemplate(state);
return;
}
navigateToStep(nextIndex);
};

const handleCreate = useCallback(
(values: CustomizationsFormValues) => {
onCreateTemplate(state, values);
},
[onCreateTemplate, state],
);

const handleProvisionerStatusChange = useCallback(
(value: boolean | undefined) => {
dispatch({ type: "SET_HAS_PROVISIONERS", value });
Expand Down Expand Up @@ -278,6 +288,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
handleProvisionerStatusChange,
handleDeselectModule,
registerModuleRef,
handleCreate,
)}
</div>

Expand All @@ -290,9 +301,19 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
Back
</Button>
)}
<Button onClick={handleNext} disabled={!canContinue}>
{isLastStep ? "Create Template" : "Continue"}
</Button>
{isLastStep ? (
<Button
type="submit"
form={TEMPLATE_CUSTOMIZATIONS_FORM_ID}
disabled={state.hasProvisioners === false}
>
Create Template
</Button>
) : (
<Button onClick={handleNext} disabled={!canContinue}>
Continue
</Button>
)}
</div>

{currentStep.id === "base-infra" && <TemplateAlternatives />}
Expand Down Expand Up @@ -332,6 +353,7 @@ function renderStepContent(
onProvisionerStatusChange: (value: boolean | undefined) => void,
onRemoveModule: (moduleId: string) => void,
registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void,
onCreate: (values: CustomizationsFormValues) => void,
): ReactNode {
switch (stepId) {
case "base-infra":
Expand Down Expand Up @@ -387,13 +409,7 @@ function renderStepContent(
{createError != null && <ErrorAlert error={createError} />}
<TemplateCustomizationsStep
state={state}
onChangeField={(field, value) =>
dispatch({
type: "SET_CUSTOMIZATION",
field,
value,
})
}
onCreate={onCreate}
onProvisionerStatusChange={onProvisionerStatusChange}
/>
</>
Expand Down Expand Up @@ -426,7 +442,7 @@ function computeCanContinue(
moduleVarMap,
);
case "customizations":
return state.name.trim() !== "" && state.hasProvisioners !== false;
return state.hasProvisioners !== false;
default:
return true;
}
Expand Down
147 changes: 147 additions & 0 deletions site/src/pages/TemplateBuilder/TemplateCustomizationsStep.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import {
getProvisionerDaemonsKey,
permittedOrganizations,
} from "#/api/queries/organizations";
import { Button } from "#/components/Button/Button";
import {
MockDefaultOrganization,
MockOrganization,
MockOrganization2,
MockProvisioner,
} from "#/testHelpers/entities";
import {
TEMPLATE_CUSTOMIZATIONS_FORM_ID,
TemplateCustomizationsStep,
} from "./TemplateCustomizationsStep";
import {
initialWizardState,
type TemplateBuilderWizardState,
} from "./wizardState";

const baseState: TemplateBuilderWizardState = {
...initialWizardState,
baseTemplateId: "docker",
selectedBase: {
id: "docker",
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
hasParameters: false,
hasPrerequisites: false,
},
name: "docker",
displayName: "Docker Containers",
description: "Run workspaces as Docker containers",
icon: "/icon/docker.svg",
};

const permittedOrgsKey = permittedOrganizations({
object: { resource_type: "template" },
action: "create",
}).queryKey;

const provisionersKey = (organizationId: string) =>
getProvisionerDaemonsKey(organizationId);

const meta: Meta<typeof TemplateCustomizationsStep> = {
title: "pages/TemplateBuilder/TemplateCustomizationsStep",
component: TemplateCustomizationsStep,
args: {
state: baseState,
onCreate: fn(),
onProvisionerStatusChange: fn(),
},
parameters: {
queries: [
{ key: permittedOrgsKey, data: [MockOrganization, MockOrganization2] },
],
},
// The "Create Template" submit button lives in the wizard's shared nav bar,
// outside this component. It is associated with the form via the `form`
// attribute, so the stories render an equivalent button to exercise submit.
decorators: [
(Story) => (
<div className="flex flex-col gap-6">
<Story />
<Button type="submit" form={TEMPLATE_CUSTOMIZATIONS_FORM_ID}>
Create Template
</Button>
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof TemplateCustomizationsStep>;

export const MultipleOrganizations: Story = {};

// Submitting without choosing an organization surfaces an aggregated error at
// the top of the step instead of an inline field error, and does not call
// onCreate.
export const MissingOrganizationError: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByTestId("organization-autocomplete");
await userEvent.click(
canvas.getByRole("button", { name: "Create Template" }),
);
await canvas.findByText("Select an organization to continue.");
await expect(args.onCreate).not.toHaveBeenCalled();
},
};

// A single permitted organization is auto-selected, so a valid form submits and
// forwards the selected organization id to onCreate.
export const SingleOrganizationSubmits: Story = {
parameters: {
queries: [
{ key: permittedOrgsKey, data: [MockDefaultOrganization] },
{
key: provisionersKey(MockDefaultOrganization.id),
data: [MockProvisioner],
},
],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Wait for the auto-selected org to render in the autocomplete.
await canvas.findByText(MockDefaultOrganization.display_name);
await userEvent.click(
canvas.getByRole("button", { name: "Create Template" }),
);
await expect(args.onCreate).toHaveBeenCalledWith(
expect.objectContaining({
organization_id: MockDefaultOrganization.id,
name: "docker",
}),
);
},
};

// A missing template ID surfaces the name validation message at the top of the
// step and blocks submission.
export const MissingNameError: Story = {
args: {
state: { ...baseState, name: "" },
},
parameters: {
queries: [
{ key: permittedOrgsKey, data: [MockDefaultOrganization] },
{
key: provisionersKey(MockDefaultOrganization.id),
data: [MockProvisioner],
},
],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(MockDefaultOrganization.display_name);
await userEvent.click(
canvas.getByRole("button", { name: "Create Template" }),
);
await canvas.findByText("Please enter a template id.");
await expect(args.onCreate).not.toHaveBeenCalled();
},
};
Loading
Loading