mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat(site): add ModuleSelectStep (#26427)
Implement the module selection wizard step with multi-select toggle and conflict warnings. - Add `getTemplateBuilderModules` API client method with optional `base` parameter for OS filtering - Add react-query wrapper with `staleTime: Infinity` to prevent re-fetches on step navigation - Render a flat grid of `ModuleCard` components with checkbox-style multi-select - Show non-blocking conflict warnings when selected modules declare `conflicts_with` each other - Map selections to `TemplateBuilderComposeModule[]` and `SelectedModuleMeta[]` for wizard state Relates to [DEVEX-285](https://linear.app/codercom/issue/DEVEX-285). > [!NOTE] > This PR was authored with Coder Agents. --------- Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
This commit is contained in:
co-authored by
Andrew Aquino
parent
a5a4c49a6f
commit
dca8178f8d
@@ -2452,6 +2452,16 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getTemplateBuilderModules = async (
|
||||
base?: string,
|
||||
): Promise<TypesGen.TemplateBuilderModulesResponse> => {
|
||||
const params = base ? `?base=${encodeURIComponent(base)}` : "";
|
||||
const response = await this.axios.get(
|
||||
`/api/v2/templatebuilder/modules${params}`,
|
||||
);
|
||||
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 },
|
||||
|
||||
@@ -4,3 +4,9 @@ export const templateBuilderBases = () => ({
|
||||
queryKey: ["templateBuilder", "bases"],
|
||||
queryFn: API.getTemplateBuilderBases,
|
||||
});
|
||||
|
||||
export const templateBuilderModules = (base?: string) => ({
|
||||
queryKey: ["templateBuilder", "modules", base ?? ""],
|
||||
queryFn: () => API.getTemplateBuilderModules(base),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { type FC, type PropsWithChildren, useMemo } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { templateBuilderModules } from "#/api/queries/templateBuilder";
|
||||
import type {
|
||||
TemplateBuilderComposeModule,
|
||||
TemplateBuilderModule,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { ModuleCard } from "./ModuleCard";
|
||||
import {
|
||||
moduleHasConfigurableVars,
|
||||
type SelectedModuleMeta,
|
||||
} from "./wizardState";
|
||||
|
||||
interface ModuleSelectStepProps {
|
||||
baseId: string;
|
||||
selectedModuleIds: string[];
|
||||
onChangeModules: (
|
||||
modules: TemplateBuilderComposeModule[],
|
||||
meta: SelectedModuleMeta[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function toMeta(m: TemplateBuilderModule): SelectedModuleMeta {
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.display_name,
|
||||
iconUrl: m.icon,
|
||||
hasConfigurableVars: moduleHasConfigurableVars(m),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO add this to the API response so we don't have to construct it manually here.
|
||||
function moduleDetailsUrl(moduleId: string): string {
|
||||
return `https://registry.coder.com/modules/${moduleId}`;
|
||||
}
|
||||
|
||||
interface ModuleConflict {
|
||||
moduleA: TemplateBuilderModule;
|
||||
moduleB: TemplateBuilderModule;
|
||||
}
|
||||
|
||||
const ModuleName: FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<code className="text-content-secondary bg-surface-tertiary mx-1 first:ml-0 px-1.5 py-1 rounded-sm">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
|
||||
const ConflictWarning: FC<ModuleConflict> = ({ moduleA, moduleB }) => {
|
||||
return (
|
||||
<div>
|
||||
<ModuleName>{moduleA.display_name}</ModuleName> and{" "}
|
||||
<ModuleName>{moduleB.display_name}</ModuleName> are conflicting modules.
|
||||
You can still continue, but you need to remove one of the conflicting
|
||||
modules before publishing the template.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModuleSelectStep: FC<ModuleSelectStepProps> = ({
|
||||
baseId,
|
||||
selectedModuleIds,
|
||||
onChangeModules,
|
||||
}) => {
|
||||
const { data, error, isLoading } = useQuery(templateBuilderModules(baseId));
|
||||
|
||||
const selectedSet = useMemo(
|
||||
() => new Set(selectedModuleIds),
|
||||
[selectedModuleIds],
|
||||
);
|
||||
|
||||
const modules = data?.modules ?? [];
|
||||
|
||||
const conflicts = useMemo<ModuleConflict[]>(() => {
|
||||
const warnings: string[] = [];
|
||||
// Loop through the selected modules and check for conflicts. We sort the
|
||||
// pair of conflicting module IDs alphabetically and join them with a "+"
|
||||
// to create a unique identifier for the pair.
|
||||
for (const id of selectedSet) {
|
||||
const m = modules.find((mod) => mod.id === id);
|
||||
if (!m) continue;
|
||||
for (const conflictId of m.conflicts_with) {
|
||||
if (selectedSet.has(conflictId)) {
|
||||
const pair = [id, conflictId].sort().join("+");
|
||||
if (!warnings.includes(pair)) {
|
||||
warnings.push(pair);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// take the computed warnings and return the actual modules that have
|
||||
// conflicts so we can display their names in the UI.
|
||||
return warnings.map((pair) => {
|
||||
const [a, b] = pair.split("+");
|
||||
const moduleA = modules.find((m) => m.id === a)!;
|
||||
const moduleB = modules.find((m) => m.id === b)!;
|
||||
return { moduleA, moduleB };
|
||||
});
|
||||
}, [selectedSet, modules]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorAlert error={error} />;
|
||||
}
|
||||
|
||||
const handleToggle = (target: TemplateBuilderModule) => {
|
||||
const isSelected = selectedSet.has(target.id);
|
||||
let nextIds: string[];
|
||||
if (isSelected) {
|
||||
nextIds = selectedModuleIds.filter((id) => id !== target.id);
|
||||
} else {
|
||||
nextIds = [...selectedModuleIds, target.id];
|
||||
}
|
||||
|
||||
const modulesById = new Map(modules.map((m) => [m.id, m]));
|
||||
const nextModules: TemplateBuilderComposeModule[] = nextIds.map((id) => ({
|
||||
id,
|
||||
}));
|
||||
const nextMeta: SelectedModuleMeta[] = nextIds
|
||||
.map((id) => modulesById.get(id))
|
||||
.filter((m): m is TemplateBuilderModule => m != null)
|
||||
.map(toMeta);
|
||||
|
||||
onChangeModules(nextModules, nextMeta);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-1">Select modules</h2>
|
||||
<p className="text-sm text-content-secondary mb-4">
|
||||
Add functionality to your template.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{modules.map((m) => (
|
||||
<ModuleCard
|
||||
key={m.id}
|
||||
name={m.display_name}
|
||||
description={m.description}
|
||||
iconUrl={m.icon}
|
||||
detailsUrl={moduleDetailsUrl(m.id)}
|
||||
selected={selectedSet.has(m.id)}
|
||||
onSelect={() => handleToggle(m)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<Alert severity="warning" prominent className="mt-6">
|
||||
<AlertTitle>Conflicting modules selected</AlertTitle>
|
||||
<AlertDescription>
|
||||
{conflicts.map(({ moduleA, moduleB }) => (
|
||||
<ConflictWarning
|
||||
key={`${moduleA.id}+${moduleB.id}`}
|
||||
moduleA={moduleA}
|
||||
moduleB={moduleB}
|
||||
/>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
BaseTemplateParametersStep,
|
||||
baseParametersComplete,
|
||||
} from "./BaseTemplateParametersStep";
|
||||
import { ModuleSelectStep } from "./ModuleSelectStep";
|
||||
import { SelectionSummary } from "./SelectionSummary";
|
||||
import {
|
||||
findNextVisibleIndex,
|
||||
@@ -101,6 +102,14 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
|
||||
<p className="text-sm text-content-secondary">
|
||||
|
||||
@@ -20,7 +20,7 @@ export type SelectedBaseMeta = {
|
||||
* UI-only metadata for a selected module.
|
||||
* Kept separate from the API request payload.
|
||||
*/
|
||||
type SelectedModuleMeta = {
|
||||
export type SelectedModuleMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
|
||||
Reference in New Issue
Block a user