feat(site): use template/module config variable's default value as its field placeholder if present (#26880)

closes DEVEX-543

Previously, templates'/modules' config variables' input fields would
just display Required/Optional as placeholder text. Now we show the
field's default value as a placeholder if defined
([example](https://github.com/coder/coder/blob/496c0576ca6bfbf3ba4b8d470cd320549f7fc6d1/coderd/templatebuilder/modules/aider/module.json#L30)),
falling back to Required/Optional if there's no default defined.

599b607194 co-authored with Claude Code
This commit is contained in:
Andrew Aquino
2026-06-30 16:01:16 -07:00
committed by GitHub
parent a48ace5017
commit 15d4eb9db8
5 changed files with 102 additions and 11 deletions
@@ -11,7 +11,11 @@ import {
TemplateBuilderTitle,
} from "#/pages/TemplateBuilder/TemplateBuilderHeader";
import { cn } from "#/utils/cn";
import type { ConfigurationFieldDefinition } from "./ConfigurationField";
import {
type ConfigurationFieldDefinition,
ConfigurationFieldLabel,
} from "./ConfigurationField";
import { defaultPlaceholder } from "./defaultPlaceholder";
import { TemplateConfiguration } from "./TemplateConfiguration";
interface BaseTemplateParametersStepProps {
@@ -34,12 +38,13 @@ function variableToField(
onChange: (name: string, value: string) => void,
): ConfigurationFieldDefinition {
const id = `base-var-${variable.name}`;
const label = <ConfigurationFieldLabel variable={variable} />;
if (variable.type === "bool") {
return {
type: "switch",
id,
label: variable.name,
label,
description: variable.description || undefined,
required: variable.required,
checked: value === "true",
@@ -51,10 +56,12 @@ function variableToField(
return {
type: "text",
id,
label: variable.name,
label,
description: variable.description || undefined,
required: variable.required,
placeholder: variable.required ? "Required" : "Optional",
placeholder:
defaultPlaceholder(variable.default) ??
(variable.required ? "Required" : "Optional"),
field: {
name: variable.name,
id,
@@ -1,4 +1,5 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import type { TemplateBuilderModuleVariable } from "#/api/typesGenerated";
import { FormField } from "#/components/FormField/FormField";
import { Label } from "#/components/Label/Label";
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
@@ -122,13 +123,15 @@ const SelectField: FC<SelectFieldDefinition> = ({
<div className="!col-end-1 flex flex-col gap-2">
<Label htmlFor={id}>
{label}
{required && (
{required ? (
<>
{" "}
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
) : (
<OptionalIndicator />
)}
</Label>
{description && (
@@ -170,13 +173,15 @@ const RadioField: FC<RadioFieldDefinition> = ({
<div className="flex flex-col gap-2">
<Label id={labelId}>
{label}
{required && (
{required ? (
<>
{" "}
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
) : (
<OptionalIndicator />
)}
</Label>
{description && (
@@ -293,13 +298,15 @@ const SwitchGroupField: FC<SwitchGroupFieldDefinition> = ({
<div className="flex flex-col gap-2">
<Label id={labelId}>
{label}
{required && (
{required ? (
<>
{" "}
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
) : (
<OptionalIndicator />
)}
</Label>
{description && (
@@ -335,3 +342,23 @@ export const ConfigurationFieldContainer: FC<PropsWithChildren> = ({
</div>
);
};
const OptionalIndicator: FC = () => {
return (
<>
{" "}
<span className="text-content-secondary">(optional)</span>
</>
);
};
export const ConfigurationFieldLabel: FC<{
variable: TemplateBuilderModuleVariable;
}> = ({ variable }) => {
return (
<>
{variable.name}
{!variable.required && <OptionalIndicator />}
</>
);
};
@@ -11,7 +11,11 @@ import {
TemplateBuilderSubtitle,
TemplateBuilderTitle,
} from "#/pages/TemplateBuilder/TemplateBuilderHeader";
import type { ConfigurationFieldDefinition } from "./ConfigurationField";
import {
type ConfigurationFieldDefinition,
ConfigurationFieldLabel,
} from "./ConfigurationField";
import { defaultPlaceholder } from "./defaultPlaceholder";
import { ModuleConfiguration } from "./ModuleConfiguration";
interface ModuleSettingsStepProps {
@@ -31,12 +35,13 @@ function variableToField(
onChange: (name: string, value: string) => void,
): ConfigurationFieldDefinition {
const id = `mod-${moduleId}-${variable.name}`;
const label = <ConfigurationFieldLabel variable={variable} />;
if (variable.type === "bool") {
return {
type: "switch",
id,
label: variable.name,
label,
description: variable.description || undefined,
required: variable.required,
checked: value === "true",
@@ -48,10 +53,12 @@ function variableToField(
return {
type: "text",
id,
label: variable.name,
label,
description: variable.description || undefined,
required: variable.required,
placeholder: variable.required ? "Required" : "Optional",
placeholder:
defaultPlaceholder(variable.default) ??
(variable.required ? "Required" : "Optional"),
field: {
name: variable.name,
id,
@@ -0,0 +1,28 @@
import { defaultPlaceholder } from "./defaultPlaceholder";
describe("defaultPlaceholder", () => {
it("formats numeric defaults, including zero", () => {
expect(defaultPlaceholder(13337)).toBe("13337");
expect(defaultPlaceholder(0)).toBe("0");
});
it("formats boolean defaults", () => {
expect(defaultPlaceholder(false)).toBe("false");
expect(defaultPlaceholder(true)).toBe("true");
});
it("returns non-empty string defaults unchanged", () => {
expect(defaultPlaceholder("v0.10.0")).toBe("v0.10.0");
});
it("returns undefined for empty or absent defaults", () => {
expect(defaultPlaceholder("")).toBeUndefined();
expect(defaultPlaceholder(undefined)).toBeUndefined();
expect(defaultPlaceholder(null)).toBeUndefined();
});
it("returns undefined for object and array defaults", () => {
expect(defaultPlaceholder({ key: "value" })).toBeUndefined();
expect(defaultPlaceholder(["a", "b"])).toBeUndefined();
});
});
@@ -0,0 +1,22 @@
/**
* Coerces a module variable's `default` into placeholder text.
*
* `TemplateBuilderModuleVariable.default` is generated as
* `Record<string, string>` because every `json.RawMessage` field maps to that
* type, but the actual wire value is a JSON scalar (string, number, or
* boolean). The parameter is typed `unknown` so the generated value is accepted
* without a cast.
*
* An empty-string default returns `undefined` so callers fall back to their
* existing Required/Optional hint. Object or array defaults also return
* `undefined` since they have no meaningful placeholder representation.
*/
export function defaultPlaceholder(value: unknown): string | undefined {
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (typeof value === "string" && value !== "") {
return value;
}
return undefined;
}