diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 34eafbf592..f2c0a747f0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2452,6 +2452,16 @@ class ApiMethods { return response.data; }; + getTemplateBuilderModules = async ( + base?: string, + ): Promise => { + 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 => { 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 8b0639398a..52cd90e8a0 100644 --- a/site/src/api/queries/templateBuilder.ts +++ b/site/src/api/queries/templateBuilder.ts @@ -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, +}); diff --git a/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx b/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx new file mode 100644 index 0000000000..b74feedd2f --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleSelectStep.tsx @@ -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 = ({ children }) => { + return ( + + {children} + + ); +}; + +const ConflictWarning: FC = ({ moduleA, moduleB }) => { + return ( +
+ {moduleA.display_name} and{" "} + {moduleB.display_name} are conflicting modules. + You can still continue, but you need to remove one of the conflicting + modules before publishing the template. +
+ ); +}; + +export const ModuleSelectStep: FC = ({ + baseId, + selectedModuleIds, + onChangeModules, +}) => { + const { data, error, isLoading } = useQuery(templateBuilderModules(baseId)); + + const selectedSet = useMemo( + () => new Set(selectedModuleIds), + [selectedModuleIds], + ); + + const modules = data?.modules ?? []; + + const conflicts = useMemo(() => { + 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 ; + } + + if (error) { + return ; + } + + 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 ( +
+

Select modules

+

+ Add functionality to your template. +

+ +
+ {modules.map((m) => ( + handleToggle(m)} + /> + ))} +
+ + {conflicts.length > 0 && ( + + Conflicting modules selected + + {conflicts.map(({ moduleA, moduleB }) => ( + + ))} + + + )} +
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 355e962109..dc464c76b6 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -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 = ({ dispatch({ type: "SET_BASE_VARIABLES", values }) } /> + ) : currentStep.id === "module-select" && state.selectedBase ? ( + m.id)} + onChangeModules={(modules, meta) => + dispatch({ type: "SET_MODULES", modules, meta }) + } + /> ) : (

diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 16ae0d0389..cac0521a4f 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -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;