feat: add a template parameter settings page (#27559)

This commit is contained in:
McKayla はな
2026-08-25 15:06:30 -06:00
committed by GitHub
parent 2236710bad
commit 895c5c77a1
10 changed files with 530 additions and 44 deletions
@@ -66,6 +66,17 @@ To use the features described on this page in an existing template:
Dynamic Parameters features are backwards compatible, so all existing templates may be upgraded in-place.
## Data sources and cached template data
Coder reads Terraform `data` sources once, when it imports a template version, and stores the results.
Rendering the form evaluates your parameter expressions against those stored results, so a `data` source whose underlying value changes in your cloud or cluster keeps returning the imported value.
The `coder_workspace_owner` data source is the exception.
Coder substitutes the identity of the user filling in the form each time it renders, which is what makes [Identity-Aware Parameters](#identity-aware-parameters-premium) work.
To pick up a change to any other `data` source, [refresh the template data](../managing-templates/index.md#refresh-template-data).
This imports the active version's source files again and publishes the result as a new active version.
## Features and Capabilities
Dynamic Parameters introduces three primary enhancements to the standard parameter system:
@@ -797,13 +808,15 @@ Ensure that the following version requirements are met:
Enabling Dynamic Parameters on an existing template requires administrators to publish a new template version.
This will resolve the necessary template metadata to render the form.
To publish one without editing the template's Terraform, [refresh the template data](../managing-templates/index.md#refresh-template-data).
### Reverting to classic parameters
The classic parameter flow is deprecated and can no longer be enabled from the UI.
A template can still opt out of Dynamic Parameters by setting the `use_classic_parameter_flow`
field through the [templates API](../../../reference/api/templates.md#update-template-settings-by-id),
but this opt-out will be removed in a future release.
The classic parameter flow is deprecated and will be removed in a future release.
If a template does not work with Dynamic Parameters, you can opt that template out.
Select **Settings** > **Parameters** on the template, then select **Use parameter compatibility mode for workspace builds**.
You can also set the `use_classic_parameter_flow` field through the
[templates API](../../../reference/api/templates.md#update-template-settings-by-id).
If your template's parameters do not work with Dynamic Parameters, please
[file an issue](https://github.com/coder/coder/issues/new?labels=parameters) with the `parameters` label.
@@ -1,8 +1,9 @@
# Working with templates
You create and edit Coder templates as
[Terraform](https://developer.hashicorp.com/terraform/intro) configuration files (`.tf`) and
any supporting files, like a README or configuration files for other services.
[Terraform](https://developer.hashicorp.com/terraform/intro) configuration files
(`.tf`) and any supporting files, like a README or configuration files for other
services.
## Who creates templates?
@@ -16,8 +17,8 @@ You can give different users and groups access to templates with
## Creating templates
The [template builder](../creating-templates.md#template-builder) is
the recommended way to create templates. It guides you through selecting a base
The [template builder](../creating-templates.md#template-builder) is the
recommended way to create templates. It guides you through selecting a base
infrastructure template, adding modules (IDEs, tools, integrations), and
configuring template settings without writing Terraform.
@@ -36,13 +37,12 @@ Coder starter templates are also available on our
## Community Templates
As well as Coder's starter templates, you can see a list of community templates
by our users
[here](../../../../examples/templates/community-templates.md).
by our users [here](../../../../examples/templates/community-templates.md).
## Editing templates
Our templates are meant to be modified for your use cases. You can edit
any template's files directly in the Coder dashboard.
Our templates are meant to be modified for your use cases. You can edit any
template's files directly in the Coder dashboard.
![Editing a template](../../../images/templates/choosing-edit-template.gif)
@@ -77,18 +77,45 @@ automatically updated on the next startup.
![Template update policies](../../../images/templates/update-policies.png)
## Refresh template data
Coder reads Terraform `data` sources once, when it imports a template version.
Every workspace built from that version reuses those stored results.
Refreshing imports the active version's source files again and publishes the
result as the new active version. Use it to pick up changes to a `data` source,
or to give an older version the metadata that
[Dynamic Parameters](../extending-templates/dynamic-parameters.md) needs.
To refresh a template's data:
1. Navigate to the template, then select **Settings** > **Parameters**.
1. Select **Refresh template data**, then confirm.
The **Template data** section on that page shows which version is active and
when Coder last imported it.
New workspaces use the refreshed version as soon as the import finishes.
Workspaces that are already running stay on their current version until you
update them.
Refreshing a template's data requires permission to update the template, which
the [Template Admin](../../users/groups-roles.md#roles) role and above have.
## Workspace renaming
Workspace renaming is disabled by default on every template.
Enable it per template, once you have confirmed that a rename won't destroy anything.
Workspace renaming is disabled by default on every template. Enable it per
template, once you have confirmed that a rename won't destroy anything.
Terraform exposes the workspace name as `data.coder_workspace.me.name`.
If a template uses that value in an attribute Terraform can't change in place, renaming the workspace makes Terraform destroy the resource and create a new one on the next build.
When that resource is the home volume, the developer loses their data.
Terraform exposes the workspace name as `data.coder_workspace.me.name`. If a
template uses that value in an attribute Terraform can't change in place,
renaming the workspace makes Terraform destroy the resource and create a new one
on the next build. When that resource is the home volume, the developer loses
their data.
Other uses of the name are harmless.
A Kubernetes label or an environment variable changes without replacing anything.
Coder can't tell the two cases apart, so the decision stays with the template admin.
Other uses of the name are harmless. A Kubernetes label or an environment
variable changes without replacing anything. Coder can't tell the two cases
apart, so the decision stays with the template admin.
### Check whether your template is safe to rename
@@ -98,27 +125,34 @@ Search the template for uses of the workspace name:
grep -rn 'coder_workspace\.[a-z_]*\.name' .
```
For each result, ask whether Terraform would replace the resource if that value changed.
Attributes such as a volume name, a disk name, or an instance name usually force replacement.
Labels, tags, and environment variables usually don't.
For each result, ask whether Terraform would replace the resource if that value
changed. Attributes such as a volume name, a disk name, or an instance name
usually force replacement. Labels, tags, and environment variables usually
don't.
If a resource would be replaced, reference an immutable identifier instead, such as `data.coder_workspace.me.id`.
Refer to [Resource persistence](../extending-templates/resource-persistence.md) for the full set of practices.
If a resource would be replaced, reference an immutable identifier instead, such
as `data.coder_workspace.me.id`. Refer to
[Resource persistence](../extending-templates/resource-persistence.md) for the
full set of practices.
### Enable renaming for a template
1. Go to the template, then select **Settings**.
1. On the **General** page, under **Operations**, select **Allow users to rename their workspaces**.
1. On the **General** page, under **Operations**, select **Allow users to rename
their workspaces**.
1. Select **Save**.
Developers can then rename a workspace from the workspace's **Settings** page, or with `coder rename`.
Developers can then rename a workspace from the workspace's **Settings** page,
or with `coder rename`.
While the setting is off, the workspace name field is disabled and the API rejects renames.
While the setting is off, the workspace name field is disabled and the API
rejects renames.
> [!WARNING]
> The deployment-wide `CODER_ALLOW_WORKSPACE_RENAMES` option is deprecated.
> While it is set, renaming is enabled for every template in the deployment, and the per-template setting can't turn it off.
> Unset it, then enable renaming on the templates that need it.
> While it is set, renaming is enabled for every template in the deployment,
> and the per-template setting can't turn it off. Unset it, then enable
> renaming on the templates that need it.
## Delete templates
@@ -142,6 +176,8 @@ coder templates delete <template-name>
## Next steps
- [Image management](./image-management.md)
- [Dev Containers integration](../../integrations/devcontainers/integration.md) (recommended)
- [Envbuilder](../../integrations/devcontainers/envbuilder/index.md) (alternative for environments without Docker)
- [Dev Containers integration](../../integrations/devcontainers/integration.md)
(recommended)
- [Envbuilder](../../integrations/devcontainers/envbuilder/index.md)
(alternative for environments without Docker)
- [Change management](./change-management.md)
+28 -4
View File
@@ -227,14 +227,38 @@ export const updateActiveTemplateVersion = (
id: versionId,
}),
onSuccess: async () => {
// invalidated because of `active_version_id`
await queryClient.invalidateQueries({
queryKey: templateByNameKey(template.organization_id, template.name),
});
await Promise.all([
// invalidated because of `active_version_id`
queryClient.invalidateQueries({
queryKey: templateByNameKey(
template.organization_name,
template.name,
),
}),
queryClient.invalidateQueries({
queryKey: templateVersionsQueryKey(template.id),
}),
]);
},
};
};
export const enableTemplateParameterCompatibilityMode = (
template: Template,
queryClient: QueryClient,
) => {
return {
mutationFn: (compatibilityModeEnabled: boolean) =>
API.updateTemplateMeta(template.id, {
use_classic_parameter_flow: compatibilityModeEnabled,
}),
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: templateByNameKey(template.organization_name, template.name),
}),
};
};
export const templaceACLAvailable = (
templateId: string,
options: UsersRequest,
@@ -16,6 +16,9 @@ export const Sidebar: React.FC = () => {
<SettingsSidebarNavItem href="variables">
Variables
</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="parameters">
Parameters
</SettingsSidebarNavItem>
<SettingsSidebarNavItem href="schedule">
Schedule
</SettingsSidebarNavItem>
@@ -0,0 +1,109 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import { toast } from "sonner";
import {
createAndBuildTemplateVersion,
enableTemplateParameterCompatibilityMode,
templateVersion,
templateVersionsQueryKey,
updateActiveTemplateVersion,
} from "#/api/queries/templates";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import { pageTitle } from "#/utils/page";
import { useTemplateSettings } from "../TemplateSettingsLayout";
import { TemplateParametersPageView } from "./TemplateParametersPageView";
const TemplateParametersPage: React.FC = () => {
const { template, permissions } = useTemplateSettings();
const queryClient = useQueryClient();
const {
data: activeVersion,
error: activeVersionError,
isLoading: isActiveVersionLoading,
} = useQuery({
...templateVersion(template.active_version_id),
placeholderData: keepPreviousData,
});
const compatibilityModeMutation = useMutation(
enableTemplateParameterCompatibilityMode(template, queryClient),
);
const createAndBuildMutation = useMutation(
createAndBuildTemplateVersion(template.organization_name),
);
const promoteMutation = useMutation(
updateActiveTemplateVersion(template, queryClient),
);
if (activeVersionError) {
return <ErrorAlert error={activeVersionError} />;
}
if (isActiveVersionLoading || !activeVersion) {
return <Loader />;
}
return (
<>
<title>{pageTitle(template.name, "Parameters")}</title>
<TemplateParametersPageView
activeVersion={activeVersion}
useClassicParameterFlow={template.use_classic_parameter_flow}
canUpdate={permissions.canUpdateTemplate}
isSaving={compatibilityModeMutation.isPending}
isRefreshing={
createAndBuildMutation.isPending || promoteMutation.isPending
}
error={
compatibilityModeMutation.error ??
createAndBuildMutation.error ??
promoteMutation.error
}
onChangeClassicParameterFlow={async (useClassicParameterFlow) => {
compatibilityModeMutation.reset();
try {
await compatibilityModeMutation.mutateAsync(
useClassicParameterFlow,
);
toast.success(
useClassicParameterFlow
? `${template.display_name} will use parameter compatibility mode.`
: `${template.display_name} will use dynamic parameters.`,
);
} catch {}
}}
onRefresh={async () => {
createAndBuildMutation.reset();
promoteMutation.reset();
try {
const newVersion = await createAndBuildMutation.mutateAsync({
template_id: template.id,
provisioner: "terraform",
storage_method: "file",
file_id: activeVersion.job.file_id,
tags: activeVersion.job.tags,
message: "Refreshed template data",
});
await queryClient.invalidateQueries({
queryKey: templateVersionsQueryKey(template.id),
});
await promoteMutation.mutateAsync(newVersion.id);
toast.success(
`Template "${template.name}" data refreshed successfully.`,
);
} catch {}
}}
/>
</>
);
};
export default TemplateParametersPage;
@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { MockTemplateVersion, mockApiError } from "#/testHelpers/entities";
import { TemplateParametersPageView } from "./TemplateParametersPageView";
const meta: Meta<typeof TemplateParametersPageView> = {
title: "pages/TemplateSettingsPage/TemplateParametersPageView",
component: TemplateParametersPageView,
args: {
activeVersion: MockTemplateVersion,
useClassicParameterFlow: false,
canUpdate: true,
isSaving: false,
isRefreshing: false,
onChangeClassicParameterFlow: fn(),
onRefresh: fn(),
},
};
export default meta;
type Story = StoryObj<typeof TemplateParametersPageView>;
export const Example: Story = {};
export const Saving: Story = {
args: {
isSaving: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("checkbox", {
name: /use parameter compatibility mode for workspace builds/i,
}),
).not.toBeInTheDocument();
await expect(
canvas.getByRole("status", {
name: /saving parameter compatibility mode/i,
}),
).toBeVisible();
},
};
export const Refreshing: Story = {
args: {
isRefreshing: true,
},
};
export const NoPermission: Story = {
args: {
canUpdate: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: /refresh template data/i }),
).toBeDisabled();
await expect(
canvas.getByRole("checkbox", {
name: /use parameter compatibility mode for workspace builds/i,
}),
).toBeDisabled();
},
};
export const Failed: Story = {
args: {
error: mockApiError({
message: "Failed to import template version.",
}),
},
};
export const OptsIntoClassicParameters: Story = {
parameters: { pixel: { exclude: true } },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
const checkbox = canvas.getByRole("checkbox", {
name: /use parameter compatibility mode for workspace builds/i,
});
await expect(checkbox).not.toBeChecked();
await user.click(checkbox);
await expect(args.onChangeClassicParameterFlow).toHaveBeenCalledWith(true);
},
};
export const ConfirmsBeforeRefreshing: Story = {
parameters: { pixel: { exclude: true } },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
await user.click(
canvas.getByRole("button", { name: /refresh template data/i }),
);
const dialog = within(await within(document.body).findByRole("dialog"));
await expect(args.onRefresh).not.toHaveBeenCalled();
await user.click(dialog.getByRole("button", { name: "Refresh" }));
await expect(args.onRefresh).toHaveBeenCalledTimes(1);
},
};
@@ -0,0 +1,182 @@
import { useRef, useState } from "react";
import type { TemplateVersion } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { Checkbox } from "#/components/Checkbox/Checkbox";
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
import { Label } from "#/components/Label/Label";
import { Link } from "#/components/Link/Link";
import { Separator } from "#/components/Separator/Separator";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import {
StackLabel,
StackLabelHelperText,
} from "#/components/StackLabel/StackLabel";
import { docs } from "#/utils/docs";
import { formatDate } from "#/utils/time";
type TemplateParametersPageViewProps = {
activeVersion: TemplateVersion;
useClassicParameterFlow: boolean;
canUpdate: boolean;
isSaving: boolean;
isRefreshing: boolean;
error?: unknown;
onChangeClassicParameterFlow: (useClassicParameterFlow: boolean) => void;
onRefresh: () => void;
};
export const TemplateParametersPageView: React.FC<
TemplateParametersPageViewProps
> = ({
activeVersion,
useClassicParameterFlow,
canUpdate,
isSaving,
isRefreshing,
error,
onChangeClassicParameterFlow,
onRefresh,
}) => {
const [isConfirmingRefresh, setIsConfirmingRefresh] = useState(false);
const refreshButtonRef = useRef<HTMLButtonElement>(null);
const importedAt = activeVersion.job.completed_at;
return (
<div className="flex max-w-prose flex-col gap-8">
<SettingsHeader>
<SettingsHeaderTitle>Parameters</SettingsHeaderTitle>
<SettingsHeaderDescription>
Control how this template's parameters are resolved when a workspace
is created.
</SettingsHeaderDescription>
</SettingsHeader>
{error ? <ErrorAlert error={error} /> : null}
{!canUpdate && (
<p className="m-0 text-sm text-content-secondary">
You need permission to update this template to change these settings.
</p>
)}
<div className="flex items-start">
{/* sm + m-1 keeps the layout from shifting */}
<Spinner
size="sm"
className="m-1 shrink-0"
loading={isSaving}
label="Saving parameter compatibility mode"
>
<Checkbox
id="use_classic_parameter_flow"
name="use_classic_parameter_flow"
checked={useClassicParameterFlow}
onCheckedChange={(checked) => {
onChangeClassicParameterFlow(checked === true);
}}
disabled={!canUpdate}
/>
</Spinner>
<StackLabel>
<Label htmlFor="use_classic_parameter_flow">
<span className="flex flex-row items-center gap-2">
Use parameter compatibility mode for workspace builds
<Badge size="sm" variant="warning">
Deprecated
</Badge>
</span>
</Label>
<StackLabelHelperText>
Turn this on only if this template does not work with dynamic
parameters. Compatibility mode will force workspace builds to fall
back to the older "Rich parameters" strategy, which is deprecated
and will be removed in a future release.
</StackLabelHelperText>
<StackLabelHelperText>
Dynamic parameters are the default since Coder 2.25, and let you use
additional form types, identity-aware parameter values, conditional
parameters, and data sources. In some cases it may be necessary to
refresh template data for dynamic parameters to work properly.
</StackLabelHelperText>
<Link
className="self-start text-xs"
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
Learn more
</Link>
</StackLabel>
</div>
<Separator className="my-2" />
<section className="flex flex-col gap-4">
<SettingsHeaderTitle level="h2" hierarchy="secondary">
Template data
</SettingsHeaderTitle>
<p className="m-0 text-sm text-content-secondary">
Coder caches certain values (like Terraform <code>data</code> sources)
when a new template version is created, and every workspace built from
that version will reuse the same results. Refreshing will create a new
template version, allowing cached values to be updated.
</p>
<dl className="m-0 grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
<dt className="text-content-secondary">Active version</dt>
<dd className="m-0">{activeVersion.name}</dd>
<dt className="text-content-secondary">Last imported</dt>
<dd className="m-0">
{importedAt ? formatDate(new Date(importedAt)) : "Unknown"}
</dd>
</dl>
<div className="pt-2">
<Button
ref={refreshButtonRef}
disabled={!canUpdate || isRefreshing}
onClick={() => setIsConfirmingRefresh(true)}
>
<Spinner loading={isRefreshing} />
Refresh template data
</Button>
</div>
</section>
<ConfirmDialog
open={isConfirmingRefresh}
type="info"
hideCancel={false}
title="Refresh template data"
description={
<>
This creates a new template version from the same source as{" "}
<strong>{activeVersion.name}</strong> and makes it the active
version. New workspaces will use it immediately. Existing workspaces
keep running until they are updated.
</>
}
confirmText="Refresh"
onClose={() => setIsConfirmingRefresh(false)}
onConfirm={() => {
setIsConfirmingRefresh(false);
onRefresh();
}}
// Radix returns focus to its trigger on close, and this dialog has
// none, so focus would land on <body>.
onCloseAutoFocus={(event) => {
event.preventDefault();
refreshButtonRef.current?.focus();
}}
/>
</div>
);
};
@@ -60,7 +60,14 @@ export const TemplateSettingsLayout: FC = () => {
}
const error = templateQuery.isError || permissionsQuery.isError;
const template = templateQuery.data;
// We override `organization_name` here because we may have fallen back to
// `"default"` while fetching if the deployment does not have the
// organizations feature enabled, and so we need to make sure consumers do the
// same when invalidating queries.
const template = {
...templateQuery.data,
organization_name: organizationName,
};
return (
<>
@@ -5,7 +5,7 @@ import {
useQuery,
useQueryClient,
} from "react-query";
import { useNavigate, useParams } from "react-router";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
createAndBuildTemplateVersion,
@@ -27,10 +27,6 @@ import { TemplateVariablesPageView } from "./TemplateVariablesPageView";
const TemplateVariablesPage: FC = () => {
const getLink = useLinks();
const { organization = "default", template: templateName } = useParams() as {
organization?: string;
template: string;
};
const { template } = useTemplateSettings();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -57,7 +53,7 @@ const TemplateVariablesPage: FC = () => {
mutateAsync: sendCreateAndBuildTemplateVersion,
error: buildError,
isPending: isBuilding,
} = useMutation(createAndBuildTemplateVersion(organization));
} = useMutation(createAndBuildTemplateVersion(template.organization_id));
const {
mutateAsync: sendUpdateActiveTemplateVersion,
error: publishError,
@@ -106,7 +102,9 @@ const TemplateVariablesPage: FC = () => {
publishError,
}}
onCancel={() => {
navigate(getLink(linkToTemplate(organization, templateName)));
navigate(
getLink(linkToTemplate(template.organization_name, template.name)),
);
}}
onSubmit={async (formData) => {
const request = filterEmptySensitiveVariables(formData, variables);
+7
View File
@@ -234,6 +234,12 @@ const TemplateSchedulePage = lazy(
"./pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage"
),
);
const TemplateParametersPage = lazy(
() =>
import(
"./pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPage"
),
);
const TemplateSettingsPage = lazy(
() =>
import(
@@ -499,6 +505,7 @@ const templateRouter = () => {
<Route index element={<TemplateSettingsPage />} />
<Route path="permissions" element={<TemplatePermissionsPage />} />
<Route path="variables" element={<TemplateVariablesPage />} />
<Route path="parameters" element={<TemplateParametersPage />} />
<Route path="schedule" element={<TemplateSchedulePage />} />
</Route>