From 895c5c77a1e83c7e66a8a602463a3acbcfaf5898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Tue, 25 Aug 2026 15:06:30 -0600 Subject: [PATCH] feat: add a template parameter settings page (#27559) --- .../extending-templates/dynamic-parameters.md | 21 +- .../templates/managing-templates/index.md | 92 ++++++--- site/src/api/queries/templates.ts | 32 ++- .../pages/TemplateSettingsPage/Sidebar.tsx | 3 + .../TemplateParametersPage.tsx | 109 +++++++++++ .../TemplateParametersPageView.stories.tsx | 107 ++++++++++ .../TemplateParametersPageView.tsx | 182 ++++++++++++++++++ .../TemplateSettingsLayout.tsx | 9 +- .../TemplateVariablesPage.tsx | 12 +- site/src/router.tsx | 7 + 10 files changed, 530 insertions(+), 44 deletions(-) create mode 100644 site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPage.tsx create mode 100644 site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.stories.tsx create mode 100644 site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.tsx diff --git a/docs/admin/templates/extending-templates/dynamic-parameters.md b/docs/admin/templates/extending-templates/dynamic-parameters.md index 5e415cec9b5..25bceb554fa 100644 --- a/docs/admin/templates/extending-templates/dynamic-parameters.md +++ b/docs/admin/templates/extending-templates/dynamic-parameters.md @@ -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. diff --git a/docs/admin/templates/managing-templates/index.md b/docs/admin/templates/managing-templates/index.md index 4c6f984abbe..edbbf5d8860 100644 --- a/docs/admin/templates/managing-templates/index.md +++ b/docs/admin/templates/managing-templates/index.md @@ -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 ## 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) diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index f242b8b85af..22cab6363c0 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -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, diff --git a/site/src/pages/TemplateSettingsPage/Sidebar.tsx b/site/src/pages/TemplateSettingsPage/Sidebar.tsx index edf59478a66..64a0900aac0 100644 --- a/site/src/pages/TemplateSettingsPage/Sidebar.tsx +++ b/site/src/pages/TemplateSettingsPage/Sidebar.tsx @@ -16,6 +16,9 @@ export const Sidebar: React.FC = () => { Variables + + Parameters + Schedule diff --git a/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPage.tsx new file mode 100644 index 00000000000..fb53aee5ee2 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPage.tsx @@ -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 ; + } + + if (isActiveVersionLoading || !activeVersion) { + return ; + } + + return ( + <> + {pageTitle(template.name, "Parameters")} + + { + 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; diff --git a/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.stories.tsx new file mode 100644 index 00000000000..0d6155b2cd2 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.stories.tsx @@ -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 = { + 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; + +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); + }, +}; diff --git a/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.tsx new file mode 100644 index 00000000000..c1bd9efada0 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateParametersPage/TemplateParametersPageView.tsx @@ -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(null); + const importedAt = activeVersion.job.completed_at; + + return ( +
+ + Parameters + + Control how this template's parameters are resolved when a workspace + is created. + + + + {error ? : null} + + {!canUpdate && ( +

+ You need permission to update this template to change these settings. +

+ )} + +
+ {/* sm + m-1 keeps the layout from shifting */} + + { + onChangeClassicParameterFlow(checked === true); + }} + disabled={!canUpdate} + /> + + + + + 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. + + + 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. + + + Learn more + + +
+ + + +
+ + Template data + + +

+ Coder caches certain values (like Terraform data 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. +

+ +
+
Active version
+
{activeVersion.name}
+
Last imported
+
+ {importedAt ? formatDate(new Date(importedAt)) : "Unknown"} +
+
+ +
+ +
+
+ + + This creates a new template version from the same source as{" "} + {activeVersion.name} 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 . + onCloseAutoFocus={(event) => { + event.preventDefault(); + refreshButtonRef.current?.focus(); + }} + /> +
+ ); +}; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx index 4dc5f042b77..c0d1944fbc8 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsLayout.tsx @@ -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 ( <> diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx index 2970c5bb838..8034860a91d 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx @@ -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); diff --git a/site/src/router.tsx b/site/src/router.tsx index 2630b693a33..ec9ddccad17 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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 = () => { } /> } /> } /> + } /> } />