diff --git a/docs/admin/templates/extending-templates/dynamic-parameters.md b/docs/admin/templates/extending-templates/dynamic-parameters.md index be28d17dd5..6d0e47ed99 100644 --- a/docs/admin/templates/extending-templates/dynamic-parameters.md +++ b/docs/admin/templates/extending-templates/dynamic-parameters.md @@ -36,13 +36,12 @@ Dynamic Parameters help you reduce template duplication by setting the condition They reduce the potential complexity of user-facing configuration by allowing administrators to organize a long list of options into interactive, branching paths for workspace customization. They allow you to set resource guardrails by referencing Coder identity in the `coder_workspace_owner` data source. -## How to enable Dynamic Parameters +## How to use Dynamic Parameters -In Coder v2.25.0 and later, Dynamic Parameters are automatically enabled for new templates. For Coder v2.24 and below, you can opt-in to Dynamic Parameters for individual existing templates via template settings. +Dynamic Parameters is the standard workspace creation experience. +In Coder v2.25.0 and later it is enabled automatically, and the classic parameter flow is deprecated. -1. Go to your template's settings and enable the **Enable dynamic parameters for workspace creation** option. - - ![Enable dynamic parameters for workspace creation](../../../images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png) +To use the features described on this page in an existing template: 1. Update your template to use version >=2.4.0 of the Coder provider with the following Terraform block. @@ -57,8 +56,7 @@ In Coder v2.25.0 and later, Dynamic Parameters are automatically enabled for new } ``` -1. This enables Dynamic Parameters in the template. - Add some [conditional parameters](#available-form-input-types). +1. Add some [conditional parameters](#available-form-input-types). Note that these new features must be declared in your Terraform to start leveraging Dynamic Parameters. @@ -67,7 +65,6 @@ In Coder v2.25.0 and later, Dynamic Parameters are automatically enabled for new 1. Users should see the updated workspace creation form. Dynamic Parameters features are backwards compatible, so all existing templates may be upgraded in-place. -If you decide to revert to the legacy flow later, disable Dynamic Parameters in the template's settings. ## Features and Capabilities @@ -806,15 +803,13 @@ This will resolve the necessary template metadata to render the form. ### Reverting to classic parameters -To revert Dynamic Parameters on a template: +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. -1. Prepare your template by removing any conditional logic or user data references in parameters. -1. As a template administrator or owner, go to your template's settings: - - **Templates** > **Your template** > **Settings** - -1. Uncheck the **Enable dynamic parameters for workspace creation** option. -1. Create a new template version and publish to the active version. +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. ### Template variables not showing up diff --git a/docs/images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png b/docs/images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png deleted file mode 100644 index 14e84ccdef..0000000000 Binary files a/docs/images/admin/templates/extend-templates/dyn-params/dynamic-parameters-ga-settings.png and /dev/null differ diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 9d58d98aff..96e34d2194 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1250,9 +1250,7 @@ export const updateWorkspace = async ( await page.getByTestId("workspace-update-button").click(); await page.getByTestId("confirm-button").click(); - await page - .getByRole("button", { name: /go to workspace parameters/i }) - .click(); + await page.getByRole("link", { name: /go to workspace parameters/i }).click(); await fillParameters(page, richParameters, buildParameters); diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 68051e7b5c..98a9cb4eff 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -3,13 +3,10 @@ import { MockStoppedWorkspace, MockTemplate, MockTemplateVersion2, - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, MockWorkspace, MockWorkspaceBuild, - MockWorkspaceBuildParameter1, } from "#/testHelpers/entities"; -import { API, getURLWithSearchParams, MissingBuildParameters } from "./api"; +import { API, getURLWithSearchParams, ParameterValidationError } from "./api"; import type * as TypesGen from "./typesGenerated"; const axiosInstance = API.getAxiosInstance(); @@ -285,53 +282,61 @@ describe("api.ts", () => { }); }); - it("fails when having missing parameters", async () => { - vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue( - MockWorkspaceBuild, - ); - vi.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([ - MockTemplateVersionParameter1, - { ...MockTemplateVersionParameter2, mutable: false }, - ]); + it("fails when the server returns parameter validation errors", async () => { + const validationErrors = [ + { field: "first_parameter", detail: "First parameter is required" }, + { + field: "second_parameter", + detail: "Second parameter is not valid", + }, + ]; - let error = new Error(); + vi.spyOn(API, "postWorkspaceBuild") + // First postWorkspaceBuild call is for the stop, which should succeed + .mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }) + // Second postWorkspaceBuild call is for the start, which should fail + // with validation errors. + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + status: 400, + data: { + message: "Invalid build parameters", + validations: validationErrors, + }, + }, + }); + vi.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); + + let error: unknown; try { await API.updateWorkspace(MockWorkspace); } catch (e) { - error = e as Error; + error = e; } - expect(error).toBeInstanceOf(MissingBuildParameters); - // Verify if the correct missing parameters are being passed - expect((error as MissingBuildParameters).parameters).toEqual([ - MockTemplateVersionParameter1, - { ...MockTemplateVersionParameter2, mutable: false }, - ]); + expect(error).toBeInstanceOf(ParameterValidationError); + if (!(error instanceof ParameterValidationError)) { + throw new Error("expected a ParameterValidationError"); + } + expect(error.versionId).toBe(MockTemplate.active_version_id); + expect(error.validations).toEqual(validationErrors); }); - it("creates a build with no parameters if it is already filled", async () => { + it("succeeds when the server accepts the build parameters", async () => { vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, transition: "stop", }); vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, - template_version_id: MockTemplateVersion2.id, + template_version_id: MockTemplate.active_version_id, transition: "start", }); vi.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([ - MockWorkspaceBuildParameter1, - ]); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([ - { - ...MockTemplateVersionParameter1, - required: true, - mutable: false, - }, - ]); await API.updateWorkspace(MockWorkspace); expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { transition: "stop", @@ -376,10 +381,6 @@ describe("api.ts", () => { ...MockProvisionerJob, status: "succeeded", }); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( - [], - ); vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, template_version_id: MockTemplateVersion2.id, @@ -398,10 +399,6 @@ describe("api.ts", () => { it("does not stop workspace if already stopped", async () => { vi.spyOn(API, "stopWorkspace"); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( - [], - ); vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ ...MockWorkspaceBuild, template_version_id: MockTemplateVersion2.id, @@ -425,10 +422,6 @@ describe("api.ts", () => { ...MockProvisionerJob, status: "canceled", }); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce( - [], - ); vi.spyOn(API, "postWorkspaceBuild"); await expect( @@ -436,30 +429,6 @@ describe("api.ts", () => { ).rejects.toThrow("Workspace stop was canceled"); expect(API.postWorkspaceBuild).not.toHaveBeenCalled(); }); - - it("throws MissingBuildParameters for missing params", async () => { - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ - MockTemplateVersionParameter1, - { ...MockTemplateVersionParameter2, mutable: false }, - ]); - - let error = new Error(); - try { - await API.changeWorkspaceVersion( - MockStoppedWorkspace, - MockTemplateVersion2.id, - ); - } catch (e) { - error = e as Error; - } - - expect(error).toBeInstanceOf(MissingBuildParameters); - expect((error as MissingBuildParameters).parameters).toEqual([ - MockTemplateVersionParameter1, - { ...MockTemplateVersionParameter2, mutable: false }, - ]); - }); }); describe("chat configuration endpoints", () => { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ccc082368e..284edf485c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -38,82 +38,6 @@ import type { } from "./typesGenerated"; import * as TypesGen from "./typesGenerated"; -const getMissingParameters = ( - oldBuildParameters: TypesGen.WorkspaceBuildParameter[], - newBuildParameters: TypesGen.WorkspaceBuildParameter[], - templateParameters: TypesGen.TemplateVersionParameter[], -) => { - const missingParameters: TypesGen.TemplateVersionParameter[] = []; - const requiredParameters: TypesGen.TemplateVersionParameter[] = []; - - for (const p of templateParameters) { - // It is mutable and required. Mutable values can be changed after so we - // don't need to ask them if they are not required. - const isMutableAndRequired = p.mutable && p.required; - // Is immutable, so we can check if it is its first time on the build - const isImmutable = !p.mutable; - - if (isMutableAndRequired || isImmutable) { - requiredParameters.push(p); - } - } - - for (const parameter of requiredParameters) { - // Check if there is a new value - let buildParameter = newBuildParameters.find( - (p) => p.name === parameter.name, - ); - - // If not, get the old one - if (!buildParameter) { - buildParameter = oldBuildParameters.find( - (p) => p.name === parameter.name, - ); - } - - // If there is a value from the new or old one, it is not missed - if (buildParameter) { - continue; - } - - missingParameters.push(parameter); - } - - // Check if parameter "options" changed and we can't use old build parameters. - for (const templateParameter of templateParameters) { - if (templateParameter.options.length === 0) { - continue; - } - // For multi-select, extra steps are necessary to JSON parse the value. - if (templateParameter.form_type === "multi-select") { - continue; - } - let buildParameter = newBuildParameters.find( - (p) => p.name === templateParameter.name, - ); - - // If not, get the old one - if (!buildParameter) { - buildParameter = oldBuildParameters.find( - (p) => p.name === templateParameter.name, - ); - } - - if (!buildParameter) { - continue; - } - - const matchingOption = templateParameter.options.find( - (option) => option.value === buildParameter?.value, - ); - if (!matchingOption) { - missingParameters.push(templateParameter); - } - } - - return missingParameters; -}; - /** * Originally from codersdk/client.go. * The below declaration is required to stop Knip from complaining. @@ -479,20 +403,6 @@ export type InsightsTemplateParams = InsightsParams & { interval: "day" | "week"; }; -export class MissingBuildParameters extends Error { - parameters: TypesGen.TemplateVersionParameter[] = []; - versionId: string; - - constructor( - parameters: TypesGen.TemplateVersionParameter[], - versionId: string, - ) { - super("Missing build parameters."); - this.parameters = parameters; - this.versionId = versionId; - } -} - export class ParameterValidationError extends Error { constructor( public readonly versionId: string, @@ -2718,116 +2628,30 @@ class ApiMethods { } }; - /** Steps to change the workspace version - * - Get the latest template to access the latest active version - * - Get the current build parameters - * - Get the template parameters - * - Update the build parameters and check if there are missed parameters for - * the new version - * - If there are missing parameters raise an error - * - Stop the workspace if it is already running - * - Create a build with the version and updated build parameters - */ changeWorkspaceVersion = async ( workspace: TypesGen.Workspace, templateVersionId: string, newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], - isDynamicParametersEnabled = false, ): Promise => { - const currentBuildParameters = await this.getWorkspaceBuildParameters( - workspace.latest_build.id, - ); - - let templateParameters: TypesGen.TemplateVersionParameter[] = []; - if (isDynamicParametersEnabled) { - templateParameters = await this.getDynamicParameters( - templateVersionId, - workspace.owner_id, - currentBuildParameters, - ); - } else { - templateParameters = - await this.getTemplateVersionRichParameters(templateVersionId); - } - - const missingParameters = getMissingParameters( - currentBuildParameters, - newBuildParameters, - templateParameters, - ); - - if (missingParameters.length > 0) { - throw new MissingBuildParameters(missingParameters, templateVersionId); - } - - await this.stopWorkspaceIfRunning(workspace); - - return this.postWorkspaceBuild(workspace.id, { - transition: "start", - template_version_id: templateVersionId, - rich_parameter_values: newBuildParameters, - }); - }; - - /** Steps to update the workspace - * - Get the latest template to access the latest active version - * - Get the current build parameters - * - Get the template parameters - * - Update the build parameters and check if there are missed parameters for - * the newest version - * - If there are missing parameters raise an error - * - Stop the workspace if it is already running - * - Create a build with the latest version and updated build parameters - */ - updateWorkspace = async ( - workspace: TypesGen.Workspace, - newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], - isDynamicParametersEnabled = false, - ): Promise => { - const [template, oldBuildParameters] = await Promise.all([ - this.getTemplate(workspace.template_id), - this.getWorkspaceBuildParameters(workspace.latest_build.id), - ]); - - const activeVersionId = template.active_version_id; - - if (!isDynamicParametersEnabled) { - // Dynamic templates rely on the backend to fully validate parameters. - // Legacy templates do not, so do an additional check for any missing params. - const templateParameters = - await this.getTemplateVersionRichParameters(activeVersionId); - - const missingParameters = getMissingParameters( - oldBuildParameters, - newBuildParameters, - templateParameters, - ); - - if (missingParameters.length > 0) { - throw new MissingBuildParameters(missingParameters, activeVersionId); - } - } - await this.stopWorkspaceIfRunning(workspace); try { return await this.postWorkspaceBuild(workspace.id, { transition: "start", - template_version_id: activeVersionId, + template_version_id: templateVersionId, rich_parameter_values: newBuildParameters, }); } catch (error) { // If the build failed because of a parameter validation error, then we // throw a special sentinel error that can be caught by the caller. if ( - isDynamicParametersEnabled && isApiError(error) && error.response.status === 400 && error.response.data.validations && error.response.data.validations.length > 0 ) { throw new ParameterValidationError( - activeVersionId, + templateVersionId, error.response.data.validations, ); } @@ -2835,6 +2659,20 @@ class ApiMethods { } }; + updateWorkspace = async ( + workspace: TypesGen.Workspace, + newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + ): Promise => { + const template = await this.getTemplate(workspace.template_id); + const activeVersionId = template.active_version_id; + + return this.changeWorkspaceVersion( + workspace, + activeVersionId, + newBuildParameters, + ); + }; + getWorkspaceResolveAutostart = async ( workspaceId: string, ): Promise => { diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index d5d0fd5476..0b89fd5f1f 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -288,7 +288,6 @@ export const updateDeadline = ( export const changeVersion = ( workspace: Workspace, queryClient: QueryClient, - isDynamicParametersEnabled: boolean, ) => { return { mutationFn: ({ @@ -298,12 +297,7 @@ export const changeVersion = ( versionId: string; buildParameters?: WorkspaceBuildParameter[]; }) => { - return API.changeWorkspaceVersion( - workspace, - versionId, - buildParameters, - isDynamicParametersEnabled, - ); + return API.changeWorkspaceVersion(workspace, versionId, buildParameters); }, onSuccess: async (build: WorkspaceBuild) => { await updateWorkspaceBuild(build, queryClient); @@ -318,16 +312,10 @@ export const updateWorkspace = ( return { mutationFn: ({ buildParameters, - isDynamicParametersEnabled, }: { buildParameters?: WorkspaceBuildParameter[]; - isDynamicParametersEnabled: boolean; }) => { - return API.updateWorkspace( - workspace, - buildParameters, - isDynamicParametersEnabled, - ); + return API.updateWorkspace(workspace, buildParameters); }, onSuccess: async (build: WorkspaceBuild) => { await updateWorkspaceBuild(build, queryClient); diff --git a/site/src/components/RichParameterInput/RichParameterInput.stories.tsx b/site/src/components/RichParameterInput/RichParameterInput.stories.tsx deleted file mode 100644 index b4e27994f7..0000000000 --- a/site/src/components/RichParameterInput/RichParameterInput.stories.tsx +++ /dev/null @@ -1,418 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { TemplateVersionParameter } from "#/api/typesGenerated"; -import { chromatic } from "#/testHelpers/chromatic"; -import { RichParameterInput } from "./RichParameterInput"; - -const meta: Meta = { - title: "components/RichParameterInput", - parameters: { chromatic }, - component: RichParameterInput, -}; - -export default meta; -type Story = StoryObj; - -const createTemplateVersionParameter = ( - partial: Partial, -): TemplateVersionParameter => { - return { - name: "first_parameter", - description: "This is first parameter.", - type: "string", - form_type: "input", - mutable: true, - default_value: "default string", - icon: "/icon/folder.svg", - options: [], - validation_error: "", - validation_regex: "", - validation_min: 0, - validation_max: 0, - validation_monotonic: "increasing", - description_plaintext: "", - required: true, - ephemeral: false, - ...partial, - }; -}; - -export const Basic: Story = { - args: { - value: "initial-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - name: "project_name", - description: - "Customize the name of a Google Cloud project that will be created!", - }), - }, -}; - -export const Optional: Story = { - args: { - value: "initial-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - required: false, - name: "project_name", - description: - "Customize the name of a Google Cloud project that will be created!", - }), - }, -}; - -export const Immutable: Story = { - args: { - value: "initial-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - mutable: false, - name: "project_name", - description: - "Customize the name of a Google Cloud project that will be created!", - }), - }, -}; - -export const WithError: Story = { - args: { - id: "number_parameter", - parameter: createTemplateVersionParameter({ - name: "number_parameter", - type: "number", - description: "Numeric parameter", - default_value: "", - }), - error: true, - helperText: "Number must be greater than 5", - }, -}; - -export const NumberType: Story = { - args: { - value: "4", - id: "number_parameter", - parameter: createTemplateVersionParameter({ - name: "number_parameter", - type: "number", - description: "Numeric parameter", - }), - }, -}; - -export const NumberTypeWithIncreasingMonotonicity: Story = { - args: { - value: 4, - id: "number_parameter", - parameter: createTemplateVersionParameter({ - name: "number_parameter", - type: "number", - description: "Numeric parameter", - default_value: "", - validation_min: 0, - validation_max: 10, - validation_monotonic: "increasing", - }), - }, -}; - -export const NumberTypeWithDecreasingMonotonicity: Story = { - args: { - value: 4, - id: "number_parameter", - parameter: createTemplateVersionParameter({ - name: "number_parameter", - type: "number", - description: "Numeric parameter", - default_value: "", - validation_min: 0, - validation_max: 10, - validation_monotonic: "decreasing", - }), - }, -}; - -export const BooleanType: Story = { - args: { - value: "false", - id: "bool_parameter", - parameter: createTemplateVersionParameter({ - name: "bool_parameter", - type: "bool", - description: "Boolean parameter", - }), - }, -}; - -export const Options: Story = { - args: { - value: "first_option", - id: "options_parameter", - parameter: createTemplateVersionParameter({ - name: "options_parameter", - type: "string", - description: "Parameter with options", - options: [ - { - name: "First option", - value: "first_option", - description: "", - icon: "/icon/fedora.svg", - }, - { - name: "Second option", - value: "second_option", - description: "", - icon: "/icon/database.svg", - }, - { - name: "Third option", - value: "third_option", - description: "", - icon: "/icon/aws.svg", - }, - ], - }), - }, -}; - -export const OptionsWithDescriptions: Story = { - args: { - value: "first_option", - id: "options_parameter", - parameter: createTemplateVersionParameter({ - name: "options_parameter", - type: "string", - description: "Parameter with options", - options: [ - { - name: "First option", - value: "first_option", - description: "This is a short description.", - icon: "/icon/fedora.svg", - }, - { - name: "Second option", - value: "second_option", - description: - "This description is a little bit longer, but still not very long.", - icon: "/icon/database.svg", - }, - { - name: "Third option", - value: "third_option", - description: ` -In this description, we will explore the various ways in which this description -is a big long boy. We'll discuss such things as, lots of words wow it's long, and -boy howdy that's a number of sentences that this description contains. By the conclusion -of this essay, I hope to reveal to you, the reader, that this description is just an -absolute chonker. Just way longer than it actually needs to be. Absolutely massive. -Very big. - -> Wow, that description is straight up large. –Some guy, probably -`, - icon: "/icon/aws.svg", - }, - ], - }), - }, -}; - -export const ListStringType: Story = { - args: { - value: JSON.stringify(["first", "second", "third"]), - id: "list_string_parameter", - parameter: createTemplateVersionParameter({ - name: "list_string_parameter", - type: "list(string)", - description: "List string parameter", - }), - }, -}; - -export const IconLabel: Story = { - args: { - value: "initial-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - name: "project_name", - description: - "Customize the name of a Google Cloud project that will be created!", - icon: "/emojis/1f30e.png", - }), - }, -}; - -export const NoDescription: Story = { - args: { - value: "", - id: "region", - parameter: createTemplateVersionParameter({ - name: "Region", - description: "", - description_plaintext: "", - type: "string", - mutable: false, - default_value: "", - icon: "/emojis/1f30e.png", - options: [ - { - name: "Pittsburgh", - description: "", - value: "us-pittsburgh", - icon: "/emojis/1f1fa-1f1f8.png", - }, - { - name: "Helsinki", - description: "", - value: "eu-helsinki", - icon: "/emojis/1f1eb-1f1ee.png", - }, - { - name: "Sydney", - description: "", - value: "ap-sydney", - icon: "/emojis/1f1e6-1f1fa.png", - }, - ], - }), - }, -}; - -export const DescriptionWithLinks: Story = { - args: { - value: "", - id: "coder-repository-directory", - parameter: createTemplateVersionParameter({ - name: "Coder Repository Directory", - description: - "The directory specified will be created and [coder/coder](https://github.com/coder/coder) will be automatically cloned into it 🪄.", - description_plaintext: - "The directory specified will be created and coder/coder (https://github.com/coder/coder) will be automatically cloned into it 🪄.", - type: "string", - mutable: true, - default_value: "~/coder", - icon: "", - options: [], - }), - }, -}; - -export const BasicWithDisplayName: Story = { - args: { - value: "initial-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - name: "project_name", - display_name: "Project Name", - description: - "Customize the name of a Google Cloud project that will be created!", - }), - }, -}; - -// Smaller version of the components. Used in popovers. - -export const SmallBasic: Story = { - args: { - ...Basic.args, - size: "small", - }, -}; - -export const SmallNumberType: Story = { - args: { - ...NumberType.args, - size: "small", - }, -}; - -export const SmallBooleanType: Story = { - args: { - ...BooleanType.args, - size: "small", - }, -}; - -export const SmallOptions: Story = { - args: { - ...Options.args, - size: "small", - }, -}; - -export const SmallOptionsWithDescriptions: Story = { - args: { - ...OptionsWithDescriptions.args, - size: "small", - }, -}; - -export const SmallListStringType: Story = { - args: { - ...ListStringType.args, - size: "small", - }, -}; - -export const SmallIconLabel: Story = { - args: { - ...IconLabel.args, - size: "small", - }, -}; - -export const SmallNoDescription: Story = { - args: { - ...NoDescription.args, - size: "small", - }, -}; - -export const SmallBasicWithDisplayName: Story = { - args: { - ...BasicWithDisplayName.args, - size: "small", - }, -}; - -export const WithPreset: Story = { - args: { - value: "preset-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - name: "project_name", - description: - "Customize the name of a Google Cloud project that will be created!", - }), - isPreset: true, - }, -}; - -export const WithPresetAndImmutable: Story = { - args: { - value: "preset-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - name: "project_name", - description: - "Customize the name of a Google Cloud project that will be created!", - mutable: false, - }), - isPreset: true, - }, -}; - -export const WithPresetAndOptional: Story = { - args: { - value: "preset-value", - id: "project_name", - parameter: createTemplateVersionParameter({ - name: "project_name", - description: - "Customize the name of a Google Cloud project that will be created!", - required: false, - }), - isPreset: true, - }, -}; diff --git a/site/src/components/RichParameterInput/RichParameterInput.tsx b/site/src/components/RichParameterInput/RichParameterInput.tsx deleted file mode 100644 index e0d0d4bbe8..0000000000 --- a/site/src/components/RichParameterInput/RichParameterInput.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import type { Interpolation, Theme } from "@emotion/react"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import FormHelperText from "@mui/material/FormHelperText"; -import type { InputBaseComponentProps } from "@mui/material/InputBase"; -import Radio from "@mui/material/Radio"; -import RadioGroup from "@mui/material/RadioGroup"; -import TextField, { type TextFieldProps } from "@mui/material/TextField"; -import { CircleAlertIcon, SettingsIcon } from "lucide-react"; -import { type FC, type ReactNode, useState } from "react"; -import type { TemplateVersionParameter } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; -import { MemoizedMarkdown } from "#/components/Markdown/Markdown"; -import { Pill } from "#/components/Pill/Pill"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { cn } from "#/utils/cn"; -import type { - AutofillBuildParameter, - AutofillSource, -} from "#/utils/richParameters"; -import { TagInput } from "../TagInput/TagInput"; - -const isBoolean = (parameter: TemplateVersionParameter) => { - return parameter.type === "bool"; -}; - -const styles = { - label: { - marginBottom: 4, - }, - labelCaption: (theme) => ({ - fontSize: 14, - color: theme.palette.text.secondary, - - ".small &": { - fontSize: 13, - lineHeight: "140%", - }, - }), - labelPrimary: (theme) => ({ - fontSize: 16, - color: theme.palette.text.primary, - fontWeight: 500, - display: "flex", - alignItems: "center", - flexWrap: "wrap", - gap: 8, - - "& p": { - margin: 0, - lineHeight: "24px", // Keep the same as ParameterInput - }, - - ".small &": { - fontSize: 14, - }, - }), - optionalLabel: (theme) => ({ - fontSize: 14, - color: theme.palette.text.disabled, - fontWeight: 500, - }), - textField: { - ".small & .MuiInputBase-root": { - height: 36, - fontSize: 14, - borderRadius: 6, - }, - }, - radioGroup: { - ".small & .MuiFormControlLabel-label": { - fontSize: 14, - }, - ".small & .MuiRadio-root": { - padding: "6px 9px", // 8px + 1px border - }, - ".small & .MuiRadio-root svg": { - width: 16, - height: 16, - }, - }, - checkbox: { - display: "flex", - alignItems: "center", - gap: 8, - }, - labelIconWrapper: { - width: 20, - height: 20, - display: "block", - flexShrink: 0, - - ".small &": { - display: "none", - }, - }, - labelIcon: { - width: "100%", - height: "100%", - objectFit: "contain", - }, - optionIcon: { - pointerEvents: "none", - maxHeight: 20, - width: 20, - - ".small &": { - maxHeight: 16, - width: 16, - }, - }, - suggestion: (theme) => ({ - color: theme.roles.notice.fill.solid, - marginLeft: "-4px", - padding: "4px 6px", - lineHeight: "inherit", - fontSize: "inherit", - height: "unset", - minWidth: "unset", - }), -} satisfies Record>; - -interface ParameterLabelProps { - parameter: TemplateVersionParameter; - isPreset?: boolean; -} - -const ParameterLabel: FC = ({ parameter, isPreset }) => { - const hasDescription = parameter.description && parameter.description !== ""; - const displayName = parameter.display_name - ? parameter.display_name - : parameter.name; - - const labelPrimary = ( - - {displayName} - - {!parameter.required && ( - - - (optional) - - - If no value is specified, the system will default to the value set - by the administrator. - - - )} - {!parameter.mutable && ( - - - } - > - Immutable - - - - This value cannot be modified after the workspace has been created. - - - )} - {isPreset && ( - - - }> - Preset - - - - This value was set by a preset - - - )} - - ); - - return ( - - ); -}; - -type Size = "medium" | "small"; - -type RichParameterInputProps = Omit & { - parameter: TemplateVersionParameter; - parameterAutofill?: AutofillBuildParameter; - onChange: (value: string) => void; - size?: Size; - isPreset?: boolean; -}; - -const autofillDescription: Partial> = { - url: " from the URL.", -}; - -export const RichParameterInput: FC = ({ - size = "medium", - parameter, - parameterAutofill, - onChange, - isPreset, - ...fieldProps -}) => { - const autofillSource = parameterAutofill?.source; - const autofillValue = parameterAutofill?.value; - const [hideSuggestion, setHideSuggestion] = useState(false); - - return ( -
- -
- - {!parameter.ephemeral && - autofillSource === "user_history" && - autofillValue && - !hideSuggestion && ( - - {" "} - was recently used for this parameter. - - )} - {autofillSource && autofillDescription[autofillSource] && ( -
- 🪄 Autofilled {autofillDescription[autofillSource]} -
- )} -
-
- ); -}; - -const RichParameterField: FC = ({ - disabled, - onChange, - parameter, - parameterAutofill, - value, - size, - ...props -}) => { - const small = size === "small"; - - if (isBoolean(parameter)) { - return ( - onChange(value)} - > - } - label="True" - /> - } - label="False" - /> - - ); - } - - if (parameter.options.length > 0) { - return ( - onChange(value)} - > - {parameter.options.map((option) => ( - } - label={ -
- {option.icon && ( - - )} - {option.description ? ( -
- {small ? ( - - -
{option.name}
-
- - - {option.description} - - -
- ) : ( - <> - {option.name} - - {option.description} - - - )} -
- ) : ( - option.name - )} -
- } - /> - ))} -
- ); - } - - if (parameter.type === "list(string)") { - let values: string[] = []; - - if (typeof value !== "string") { - throw new Error("Expected value to be a string"); - } - - if (value) { - try { - values = JSON.parse(value) as string[]; - } catch (e) { - console.error("Error parsing list(string) parameter", e); - } - } - - return ( - { - try { - const value = JSON.stringify(values); - onChange(value); - } catch (e) { - console.error("Error on change of list(string) parameter", e); - } - }} - /> - ); - } - - let inputProps: InputBaseComponentProps = {}; - if (parameter.type === "number") { - switch (parameter.validation_monotonic) { - case "increasing": - inputProps = { - max: parameter.validation_max, - min: parameterAutofill?.value, - }; - break; - case "decreasing": - inputProps = { - max: parameterAutofill?.value, - min: parameter.validation_min, - }; - break; - default: - inputProps = { - max: parameter.validation_max, - min: parameter.validation_min, - }; - break; - } - } - - // A text field can technically handle all cases! - // As other cases become more prominent (like filtering for numbers), - // we should break this out into more finely scoped input fields. - return ( - { - onChange(event.target.value); - }} - /> - ); -}; diff --git a/site/src/modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning.test.tsx b/site/src/modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning.test.tsx deleted file mode 100644 index a5bcd6e494..0000000000 --- a/site/src/modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { ClassicParameterFlowDeprecationWarning } from "./ClassicParameterFlowDeprecationWarning"; - -vi.mock("#/modules/navigation", () => ({ - useLinks: () => () => "/mock-link", - linkToTemplate: () => "/mock-template-link", -})); - -describe("ClassicParameterFlowDeprecationWarning", () => { - const defaultProps = { - organizationName: "test-org", - templateName: "test-template", - }; - - it("renders warning when enabled and user has template update permissions", () => { - render( - , - ); - - expect(screen.getByText("deprecated")).toBeInTheDocument(); - expect(screen.getByText("Go to Template Settings")).toBeInTheDocument(); - }); - - it("does not render when enabled is false", () => { - const { container } = render( - , - ); - - expect(container.firstChild).toBeNull(); - }); -}); diff --git a/site/src/modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning.tsx b/site/src/modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning.tsx deleted file mode 100644 index c2c0a97bb5..0000000000 --- a/site/src/modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { FC } from "react"; -import { Alert } from "#/components/Alert/Alert"; -import { Link } from "#/components/Link/Link"; -import { docs } from "#/utils/docs"; - -interface ClassicParameterFlowDeprecationWarningProps { - templateSettingsLink: string; - isEnabled: boolean; -} - -export const ClassicParameterFlowDeprecationWarning: FC< - ClassicParameterFlowDeprecationWarningProps -> = ({ templateSettingsLink, isEnabled }) => { - if (!isEnabled) { - return null; - } - - return ( - -
- This template is using the classic parameter flow, which will be{" "} - deprecated and removed in a future release. Please - migrate to{" "} - - dynamic parameters - {" "} - on template settings for improved functionality. -
- - - Go to Template Settings - -
- ); -}; diff --git a/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx b/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx index 7f7ce6696d..2c5ce0b349 100644 --- a/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx +++ b/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx @@ -15,7 +15,7 @@ interface WorkspaceErrorDialogProps { open: boolean; error?: unknown; onClose: () => void; - showDetail: boolean; + showDetail?: boolean; workspaceOwner: string; workspaceName: string; templateVersionId: string; @@ -26,7 +26,7 @@ export const WorkspaceErrorDialog: FC = ({ open, error, onClose, - showDetail, + showDetail = false, workspaceOwner, workspaceName, templateVersionId, diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog.tsx deleted file mode 100644 index 1d4e16d22a..0000000000 --- a/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { css } from "@emotion/css"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogContentText from "@mui/material/DialogContentText"; -import DialogTitle from "@mui/material/DialogTitle"; -import { useFormik } from "formik"; -import type { FC } from "react"; -import * as Yup from "yup"; -import type { - TemplateVersionParameter, - WorkspaceBuildParameter, -} from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import type { DialogProps } from "#/components/Dialogs/Dialog"; -import { FormFields, VerticalForm } from "#/components/Form/Form"; -import { RichParameterInput } from "#/components/RichParameterInput/RichParameterInput"; -import { getFormHelpers } from "#/utils/formUtils"; -import { - getInitialRichParameterValues, - useValidationSchemaForRichParameters, -} from "#/utils/richParameters"; - -type UpdateBuildParametersDialogProps = DialogProps & { - onClose: () => void; - onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void; - missedParameters: TemplateVersionParameter[]; -}; - -export const UpdateBuildParametersDialog: FC< - UpdateBuildParametersDialogProps -> = ({ missedParameters, onUpdate, ...dialogProps }) => { - const form = useFormik({ - initialValues: { - rich_parameter_values: getInitialRichParameterValues(missedParameters), - }, - validationSchema: Yup.object({ - rich_parameter_values: - useValidationSchemaForRichParameters(missedParameters), - }), - onSubmit: (values) => { - onUpdate(values.rich_parameter_values); - }, - enableReinitialize: true, - }); - const getFieldHelpers = getFormHelpers(form); - - return ( - - - Workspace parameters - - - - This template has new parameters that must be configured to complete - the update - - - {missedParameters && ( - - {missedParameters.map((parameter, index) => { - return ( - { - await form.setFieldValue( - `rich_parameter_values.${index}`, - { - name: parameter.name, - value: value, - }, - ); - }} - /> - ); - })} - - )} - - - - - - - - ); -}; - -const classNames = { - root: css` - padding: 24px 40px; - - & h2 { - font-size: 20px; - font-weight: 400; - } - `, -}; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx deleted file mode 100644 index 72e4afc046..0000000000 --- a/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { FC } from "react"; -import { useNavigate } from "react-router"; -import type { FieldError } from "#/api/errors"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; - -type UpdateBuildParametersDialogExperimentalProps = { - open: boolean; - onClose: () => void; - validations: FieldError[]; - workspaceOwnerName: string; - workspaceName: string; - templateVersionId: string | undefined; -}; - -export const UpdateBuildParametersDialogExperimental: FC< - UpdateBuildParametersDialogExperimentalProps -> = ({ - validations, - open, - onClose, - workspaceOwnerName, - workspaceName, - templateVersionId, -}) => { - const navigate = useNavigate(); - - const handleGoToParameters = () => { - onClose(); - navigate( - `/@${workspaceOwnerName}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`, - ); - }; - - return ( - !isOpen && onClose()}> - - - Update workspace parameters - - This template has{" "} - - {validations.length} parameter - {validations.length === 1 ? "" : "s"} - {" "} - that must be configured to complete the update. - - - Would you like to go to the workspace parameters page to review and - update these parameters before continuing? - - - - - - - - - ); -}; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx index da4ac04ca9..6380bc2742 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx @@ -11,7 +11,7 @@ import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link as RouterLink } from "react-router"; import { toast } from "sonner"; -import { MissingBuildParameters, ParameterValidationError } from "#/api/api"; +import { ParameterValidationError } from "#/api/api"; import { type ApiError, getErrorDetail, @@ -33,10 +33,9 @@ import { DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; import { WorkspaceErrorDialog } from "../ErrorDialog/WorkspaceErrorDialog"; +import { UpdateBuildParametersDialog } from "../WorkspaceUpdateDialogs"; import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog"; import { DownloadLogsDialog } from "./DownloadLogsDialog"; -import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; -import { UpdateBuildParametersDialogExperimental } from "./UpdateBuildParametersDialogExperimental"; import { useWorkspaceDuplication } from "./useWorkspaceDuplication"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; @@ -71,11 +70,7 @@ export const WorkspaceMoreActions: FC = ({ // Change version const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false); const changeVersionMutation = useMutation( - changeVersion( - workspace, - queryClient, - !workspace.template_use_classic_parameter_flow, - ), + changeVersion(workspace, queryClient), ); const handleError = (error: unknown) => { @@ -201,44 +196,13 @@ export const WorkspaceMoreActions: FC = ({ onClose={() => setIsDownloadDialogOpen(false)} /> - {workspace.template_use_classic_parameter_flow ? ( + {changeVersionMutation.error instanceof ParameterValidationError && ( { changeVersionMutation.reset(); }} - onUpdate={(buildParameters) => { - if (changeVersionMutation.error instanceof MissingBuildParameters) { - changeVersionMutation.mutate({ - versionId: changeVersionMutation.error.versionId, - buildParameters, - }); - } - }} - /> - ) : ( - { - changeVersionMutation.reset(); - }} - workspaceOwnerName={workspace.owner_name} - workspaceName={workspace.name} - templateVersionId={ - changeVersionMutation.error instanceof ParameterValidationError - ? changeVersionMutation.error.versionId - : undefined - } /> )} @@ -271,7 +235,6 @@ export const WorkspaceMoreActions: FC = ({ open={workspaceErrorDialog.open} error={workspaceErrorDialog.error} onClose={() => setWorkspaceErrorDialog({ open: false })} - showDetail={workspace.template_use_classic_parameter_flow} workspaceOwner={workspace.owner_name} workspaceName={workspace.name} templateVersionId={workspace.latest_build.template_version_id} diff --git a/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx b/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx index add8a31610..f9ea8aa7d1 100644 --- a/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx +++ b/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx @@ -151,7 +151,7 @@ const WorkspaceOutdatedTooltipContent: FC = ({ - + ); }; diff --git a/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx b/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx index 15dd91c1f4..0ff03f6111 100644 --- a/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx +++ b/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx @@ -1,17 +1,24 @@ import { type FC, useState } from "react"; import { useMutation, useQueryClient } from "react-query"; -import { MissingBuildParameters, ParameterValidationError } from "#/api/api"; +import { Link } from "react-router"; +import { ParameterValidationError } from "#/api/api"; import { updateWorkspace } from "#/api/queries/workspaces"; import type { TemplateVersion, Workspace, WorkspaceBuild, - WorkspaceBuildParameter, } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { MemoizedInlineMarkdown } from "#/components/Markdown/InlineMarkdown"; -import { UpdateBuildParametersDialog } from "#/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog"; -import { UpdateBuildParametersDialogExperimental } from "#/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental"; type UseWorkspaceUpdateOptions = { workspace: Workspace; @@ -23,10 +30,7 @@ type UseWorkspaceUpdateOptions = { type UseWorkspaceUpdateResult = { update: () => void; isUpdating: boolean; - dialogs: { - updateConfirmation: UpdateConfirmationDialogProps; - missingBuildParameters: MissingBuildParametersDialogProps; - }; + dialogProps: WorkspaceUpdateDialogsProps; }; export const useWorkspaceUpdate = ({ @@ -52,11 +56,9 @@ export const useWorkspaceUpdate = ({ setIsConfirmingUpdate(true); }; - const confirmUpdate = (buildParameters: WorkspaceBuildParameter[] = []) => { + const confirmUpdate = () => { updateWorkspaceMutation.mutate({ - buildParameters, - isDynamicParametersEnabled: - !workspace.template_use_classic_parameter_flow, + buildParameters: [], }); setIsConfirmingUpdate(false); }; @@ -64,57 +66,54 @@ export const useWorkspaceUpdate = ({ return { update, isUpdating: updateWorkspaceMutation.isPending, - dialogs: { - updateConfirmation: { + dialogProps: { + confirmUpdateDialogProps: { open: isConfirmingUpdate, onClose: () => setIsConfirmingUpdate(false), onConfirm: () => confirmUpdate(), latestVersion, }, - missingBuildParameters: { - workspace, - error: updateWorkspaceMutation.error, - onClose: () => { - updateWorkspaceMutation.reset(); - }, - onUpdate: (buildParameters: WorkspaceBuildParameter[]) => { - if ( - updateWorkspaceMutation.error instanceof MissingBuildParameters || - updateWorkspaceMutation.error instanceof ParameterValidationError - ) { - confirmUpdate(buildParameters); - } - }, - }, + updateBuildParametersDialogProps: + updateWorkspaceMutation.error instanceof ParameterValidationError + ? { + workspace, + error: updateWorkspaceMutation.error, + onClose: () => { + updateWorkspaceMutation.reset(); + }, + } + : undefined, }, }; }; type WorkspaceUpdateDialogsProps = { - updateConfirmation: UpdateConfirmationDialogProps; - missingBuildParameters: MissingBuildParametersDialogProps; + confirmUpdateDialogProps: ConfirmUpdateDialogProps; + updateBuildParametersDialogProps?: UpdateBuildParametersDialogProps; }; export const WorkspaceUpdateDialogs: FC = ({ - updateConfirmation, - missingBuildParameters, + confirmUpdateDialogProps, + updateBuildParametersDialogProps, }) => { return ( <> - - + + {updateBuildParametersDialogProps && ( + + )} ); }; -type UpdateConfirmationDialogProps = { +type ConfirmUpdateDialogProps = { open: boolean; onClose: () => void; onConfirm: () => void; latestVersion?: TemplateVersion; }; -const UpdateConfirmationDialog: FC = ({ +const ConfirmUpdateDialog: FC = ({ latestVersion, ...dialogProps }) => { @@ -144,42 +143,49 @@ const UpdateConfirmationDialog: FC = ({ ); }; -type MissingBuildParametersDialogProps = { +type UpdateBuildParametersDialogProps = { workspace: Workspace; - error: unknown; + error: ParameterValidationError; onClose: () => void; - onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void; }; -const MissingBuildParametersDialog: FC = ({ - workspace, - error, - ...dialogProps -}) => { - const missedParameters = - error instanceof MissingBuildParameters ? error.parameters : []; - const versionId = - error instanceof ParameterValidationError ? error.versionId : undefined; - const isOpen = - error instanceof MissingBuildParameters || - error instanceof ParameterValidationError; +export const UpdateBuildParametersDialog: FC< + UpdateBuildParametersDialogProps +> = ({ workspace, error, onClose }) => { + const templateVersionId = error.versionId; + const validations = error.validations; - return workspace.template_use_classic_parameter_flow ? ( - - ) : ( - + return ( + onClose()}> + + + Update workspace parameters + + This workspace has{" "} + + {validations.length} parameter + {validations.length === 1 ? "" : "s"} + {" "} + that must be configured to complete the update. + + + Would you like to go to the workspace parameters page to review and + update these parameters before continuing? + + + + + + + + ); }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 0382d72666..626bb65596 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -21,13 +21,11 @@ import { HorizontalForm, } from "#/components/Form/Form"; import { IconField } from "#/components/IconField/IconField"; -import { Link } from "#/components/Link/Link"; import { Spinner } from "#/components/Spinner/Spinner"; import { StackLabel, StackLabelHelperText, } from "#/components/StackLabel/StackLabel"; -import { docs } from "#/utils/docs"; import { displayNameValidator, getFormHelpers, @@ -49,7 +47,6 @@ export const validationSchema = Yup.object({ allow_user_cancel_workspace_jobs: Yup.boolean(), icon: iconValidator, require_active_version: Yup.boolean(), - use_classic_parameter_flow: Yup.boolean(), disable_module_cache: Yup.boolean(), deprecation_message: Yup.string(), max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels), @@ -94,7 +91,6 @@ export const TemplateSettingsForm: FC = ({ deprecation_message: template.deprecation_message, disable_everyone_group_access: false, max_port_share_level: template.max_port_share_level, - use_classic_parameter_flow: template.use_classic_parameter_flow, cors_behavior: template.cors_behavior, disable_module_cache: template.disable_module_cache, }, @@ -225,47 +221,6 @@ export const TemplateSettingsForm: FC = ({ } /> - - form.setFieldValue( - "use_classic_parameter_flow", - !event.currentTarget.checked, - ) - } - disabled={false} - /> - } - label={ - - - Enable dynamic parameters for workspace creation (recommended) - - -
- The dynamic workspace form allows you to design your - template with additional form types and identity-aware - conditional parameters. This is the default option for new - templates. The classic workspace creation flow will be - deprecated in a future release. -
- - Learn more - -
-
- } - /> void; label: string; } @@ -44,19 +29,15 @@ export const BuildParametersPopover: FC = ({ workspace, disabled, label, - onSubmit, }) => { const [isOpen, setIsOpen] = useState(false); const build = workspace.latest_build; const { data: templateVersionParameters } = useQuery( richParameters(build.template_version_id), ); - const { data: buildParameters } = useQuery( - workspaceBuildParameters(build.id), + const ephemeralParameters = templateVersionParameters?.filter( + (p) => p.ephemeral, ); - const ephemeralParameters = templateVersionParameters - ? templateVersionParameters.filter((p) => p.ephemeral) - : undefined; return ( @@ -77,9 +58,6 @@ export const BuildParametersPopover: FC = ({ @@ -88,163 +66,68 @@ export const BuildParametersPopover: FC = ({ interface BuildParametersPopoverContentProps { workspace: Workspace; - ephemeralParameters?: TemplateVersionParameter[]; - buildParameters?: WorkspaceBuildParameter[]; - onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void; - setIsOpen: (newOpen: boolean) => void; + ephemeralParameters: TemplateVersionParameter[] | undefined; } const BuildParametersPopoverContent: FC = ({ workspace, ephemeralParameters, - buildParameters, - onSubmit, - setIsOpen, }) => { - if ( - !workspace.template_use_classic_parameter_flow && - ephemeralParameters && - ephemeralParameters.length > 0 - ) { + if (!ephemeralParameters) { + return ; + } + + if (ephemeralParameters.length === 0) { return ( -
-

- This workspace has ephemeral parameters which may use a temporary - value on workspace start. Configure the following parameters in - workspace settings. -

- -
-
    - {ephemeralParameters.map((param) => ( -
  • -

    - {param.display_name || param.name} -

    - {param.description && ( -

    - {param.description} -

    - )} -
  • - ))} -
-
- - - Go to workspace parameters - +
+ Build Options + + This template has no ephemeral build options. + + + + Read the docs + +
); } return ( - <> - {buildParameters && ephemeralParameters ? ( - ephemeralParameters.length > 0 ? ( -
-
- Build Options - - These parameters only apply for a single workspace start. - -
-
-
{ - onSubmit(buildParameters); - setIsOpen(false); - }} - ephemeralParameters={ephemeralParameters} - buildParameters={buildParameters.map( - (p): AutofillBuildParameter => ({ - ...p, - source: "active_build", - }), - )} - /> -
-
- ) : ( -
- Build Options - - This template has no ephemeral build options. - - - - Read the docs - - -
- ) - ) : ( - - )} - - ); -}; +
+

+ This workspace has ephemeral parameters which may use a temporary value + on workspace start. Configure the following parameters in workspace + settings. +

-interface FormProps { - ephemeralParameters: TemplateVersionParameter[]; - buildParameters: AutofillBuildParameter[]; - onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void; -} - -const Form: FC = ({ - ephemeralParameters, - buildParameters, - onSubmit, -}) => { - const form = useFormik({ - initialValues: { - rich_parameter_values: getInitialRichParameterValues( - ephemeralParameters, - buildParameters, - ), - }, - onSubmit: (values) => { - onSubmit(values.rich_parameter_values); - }, - }); - const getFieldHelpers = getFormHelpers(form); - - return ( - - - {ephemeralParameters.map((parameter, index) => { - return ( - { - await form.setFieldValue(`rich_parameter_values[${index}]`, { - name: parameter.name, - value: value, - }); - }} - /> - ); - })} - -
- +
+
    + {ephemeralParameters.map((param) => ( +
  • +

    + {param.display_name || param.name} +

    + {param.description && ( +

    + {param.description} +

    + )} +
  • + ))} +
- + + + Go to workspace parameters + +
); }; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index 5a1bcd5b8b..e2773d6082 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -114,7 +114,6 @@ export const StartButton: FC = ({ label="Start with build parameters" workspace={workspace} disabled={loading} - onSubmit={handleAction} />
); @@ -155,7 +154,6 @@ export const RestartButton: FC = ({ label="Restart with build parameters" workspace={workspace} disabled={loading} - onSubmit={handleAction} />
); diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.tsx index 04b89f722e..62d23acb1e 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/DebugButton.tsx @@ -32,7 +32,6 @@ export const DebugButton: FC = ({ ); diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.stories.tsx index 94b463f1c4..913190ede9 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.stories.tsx @@ -3,7 +3,6 @@ import { expect, screen, userEvent, waitFor } from "storybook/test"; import { richParametersKey } from "#/api/queries/templates"; import { workspaceBuildParametersKey } from "#/api/queries/workspaceBuilds"; import { - MockNonClassicParameterFlowWorkspace, MockTemplateVersionParameter1, MockTemplateVersionParameter6, MockWorkspace, @@ -83,35 +82,6 @@ export const WithOpenEphemeralBuildParameters: Story = { }, ], }, - play: async ({ step }) => { - await step("open popover", async () => { - await userEvent.click(screen.getByText("Retry with build parameters")); - expect( - await screen.findByText( - "These parameters only apply for a single workspace start.", - ), - ).toBeInTheDocument(); - }); - }, -}; - -export const WithOpenEphemeralBuildParametersNotClassic: Story = { - args: { - enableBuildParameters: true, - workspace: MockNonClassicParameterFlowWorkspace, - }, - parameters: { - queries: [ - { - key: richParametersKey(MockWorkspace.latest_build.template_version_id), - data: [MockTemplateVersionParameter6], - }, - { - key: workspaceBuildParametersKey(MockWorkspace.latest_build.id), - data: [], - }, - ], - }, play: async ({ step }) => { await step("open popover", async () => { await userEvent.click(screen.getByText("Retry with build parameters")); diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.tsx index 7308374c08..3b91521a2f 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/RetryButton.tsx @@ -32,7 +32,6 @@ export const RetryButton: FC = ({ ); diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index badc7ec08e..f6affdd87c 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -5,7 +5,7 @@ import { HttpResponse, http } from "msw"; import type { FC } from "react"; import type { MockInstance } from "vitest"; import * as apiModule from "#/api/api"; -import type { TemplateVersionParameter, Workspace } from "#/api/typesGenerated"; +import type { Workspace } from "#/api/typesGenerated"; import { DashboardContext, type DashboardProvider, @@ -23,9 +23,6 @@ import { MockStartingWorkspace, MockStoppedWorkspace, MockTemplate, - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, - MockUserOwner, MockWorkspace, MockWorkspaceBuild, MockWorkspaceBuildDelete, @@ -37,7 +34,7 @@ import { import { server } from "#/testHelpers/server"; import WorkspacePage from "./WorkspacePage"; -const { API, MissingBuildParameters } = apiModule; +const { API } = apiModule; type RenderWorkspacePageOptions = Omit; @@ -306,118 +303,6 @@ describe("WorkspacePage", () => { }); }); - // Started flaking after upgrading react-router. Tests the old parameters path - // and isn't worth spending more time to fix since this code will be removed - // in a few releases when dynamic parameters takes over the world. - it("updates the parameters when they are missing during update", async () => { - // Mocks - vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce( - MockOutdatedWorkspace, - ); - const updateWorkspaceSpy = vi - .spyOn(API, "updateWorkspace") - .mockRejectedValueOnce( - new MissingBuildParameters( - [MockTemplateVersionParameter1, MockTemplateVersionParameter2], - MockOutdatedWorkspace.template_active_version_id, - ), - ); - - // Render - await renderWorkspacePage(MockWorkspace); - - // Actions - const user = userEvent.setup(); - await user.click(screen.getByTestId("workspace-update-button")); - const confirmButton = await screen.findByTestId("confirm-button"); - await user.click(confirmButton); - - // The update was called - await waitFor(() => { - expect(API.updateWorkspace).toBeCalled(); - updateWorkspaceSpy.mockClear(); - }); - - // After trying to update, a new dialog asking for missed parameters should - // be displayed and filled - const dialog = await screen.findByRole("dialog", { - name: /workspace parameters/i, - }); - const firstParameterInput = within(dialog).getByLabelText( - MockTemplateVersionParameter1.name, - { exact: false }, - ); - await user.clear(firstParameterInput); - await user.type(firstParameterInput, "some-value"); - const secondParameterInput = within(dialog).getByLabelText( - MockTemplateVersionParameter2.name, - { exact: false }, - ); - await user.clear(secondParameterInput); - await user.type(secondParameterInput, "2"); - await user.click( - within(dialog).getByRole("button", { name: /update parameters/i }), - ); - - // Check if the update was called using the values from the form - await waitFor(() => { - expect(API.updateWorkspace).toHaveBeenCalledWith( - MockOutdatedWorkspace, - [ - { - name: MockTemplateVersionParameter1.name, - value: "some-value", - }, - { - name: MockTemplateVersionParameter2.name, - value: "2", - }, - ], - false, - ); - }); - }); - - it("restart the workspace with one time parameters when having the confirmation dialog", async () => { - localStorage.removeItem(`${MockUserOwner.id}_ignoredWarnings`); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ - { - ...MockTemplateVersionParameter1, - ephemeral: true, - name: "rebuild", - description: "Rebuild", - required: false, - }, - ]); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([ - { name: "rebuild", value: "false" }, - ]); - - const restartWorkspaceSpy = vi.spyOn(API, "restartWorkspace"); - const user = userEvent.setup(); - await renderWorkspacePage(MockWorkspace); - await user.click(screen.getByTestId("build-parameters-button")); - const buildParametersForm = await screen.findByTestId( - "build-parameters-form", - ); - const rebuildField = within(buildParametersForm).getByLabelText("Rebuild", { - exact: false, - }); - await user.clear(rebuildField); - await user.type(rebuildField, "true"); - await user.click(screen.getByTestId("build-parameters-submit")); - await user.click(screen.getByTestId("confirm-button")); - await waitFor(() => { - expect(restartWorkspaceSpy).toBeCalledWith({ - workspace: MockWorkspace, - buildParameters: [{ name: "rebuild", value: "true" }], - }); - }); - }); - - // Tried to get these wired up via describe.each to reduce repetition, but the - // syntax just got too convoluted because of the variance in what arguments - // each function gets called with describe("Retrying failed workspaces", () => { const retryButtonRe = /^Retry$/i; const retryDebugButtonRe = /^Debug$/i; @@ -506,110 +391,6 @@ describe("WorkspacePage", () => { }); }); - it("retry with build parameters", async () => { - const user = userEvent.setup(); - const workspace = { - ...MockFailedWorkspace, - latest_build: { - ...MockFailedWorkspace.latest_build, - transition: "start", - }, - } satisfies Workspace; - const parameter = { - ...MockTemplateVersionParameter1, - display_name: "Parameter 1", - ephemeral: true, - } satisfies TemplateVersionParameter; - - server.use( - http.get("/api/v2/templateversions/:versionId/rich-parameters", () => { - return HttpResponse.json([parameter]); - }), - ); - const retryWorkspaceSpy = vi - .spyOn(API, "retryWorkspace") - .mockResolvedValue(MockWorkspaceBuild); - - await renderWorkspacePage(workspace); - const retryWithBuildParametersButton = await screen.findByRole("button", { - name: "Retry with build parameters", - }); - await user.click(retryWithBuildParametersButton); - await screen.findByText("Build Options"); - const parameterField = await screen.findByLabelText( - parameter.display_name, - { - exact: false, - }, - ); - - await user.clear(parameterField); - await user.type(parameterField, "some-value"); - const submitButton = screen.getByText("Build workspace"); - await user.click(submitButton); - - await waitFor(() => { - expect(retryWorkspaceSpy).toBeCalledWith( - workspace, - workspace.latest_build.template_version_id, - undefined, - [{ name: parameter.name, value: "some-value" }], - ); - }); - }); - - it("debug with build parameters", async () => { - const user = userEvent.setup(); - const workspace = { - ...MockFailedWorkspace, - latest_build: { - ...MockFailedWorkspace.latest_build, - transition: "start", - }, - } satisfies Workspace; - const parameter = { - ...MockTemplateVersionParameter1, - display_name: "Parameter 1", - ephemeral: true, - } satisfies TemplateVersionParameter; - - server.use( - http.get("/api/v2/templateversions/:versionId/rich-parameters", () => { - return HttpResponse.json([parameter]); - }), - ); - const retryWorkspaceSpy = vi - .spyOn(API, "retryWorkspace") - .mockResolvedValue(MockWorkspaceBuild); - - await renderWorkspacePage(workspace); - const retryWithBuildParametersButton = await screen.findByRole("button", { - name: "Debug with build parameters", - }); - await user.click(retryWithBuildParametersButton); - await screen.findByText("Build Options"); - const parameterField = await screen.findByLabelText( - parameter.display_name, - { - exact: false, - }, - ); - - await user.clear(parameterField); - await user.type(parameterField, "some-value"); - const submitButton = screen.getByText("Build workspace"); - await user.click(submitButton); - - await waitFor(() => { - expect(retryWorkspaceSpy).toBeCalledWith( - workspace, - workspace.latest_build.template_version_id, - "debug", - [{ name: parameter.name, value: "some-value" }], - ); - }); - }); - describe("Navigation to other pages", () => { it("Shows a quota link when quota budget is greater than 0. Link navigates user to /workspaces route with the URL params populated with the corresponding organization", async () => { vi.spyOn(API, "getWorkspaceQuota").mockResolvedValueOnce({ diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 5906b1c514..0f1166fbed 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -226,10 +226,6 @@ export const WorkspaceReadyPage: FC = ({ const checkEphemeralParameters = async ( buildParameters?: TypesGen.WorkspaceBuildParameter[], ) => { - if (workspace.template_use_classic_parameter_flow) { - return { hasEphemeral: false, ephemeralParameters: [] }; - } - try { const dynamicParameters = await API.getDynamicParameters( workspace.latest_build.template_version_id, @@ -433,13 +429,12 @@ export const WorkspaceReadyPage: FC = ({ templateVersionId={workspace.latest_build.template_version_id} /> - + setWorkspaceErrorDialog({ open: false })} - showDetail={workspace.template_use_classic_parameter_flow} workspaceOwner={workspace.owner_name} workspaceName={workspace.name} templateVersionId={workspace.latest_build.template_version_id} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx index 210d8bf6d1..9d857e43a1 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx @@ -322,16 +322,8 @@ export const BatchUpdateSkipsUpToDateWorkspaces: Story = { await user.click(within(modal).getByRole("button", { name: /Update/ })); await waitFor(() => expect(API.updateWorkspace).toHaveBeenCalledTimes(2)); - expect(API.updateWorkspace).toHaveBeenCalledWith( - skipUpToDateWorkspaces[2], - [], - false, - ); - expect(API.updateWorkspace).toHaveBeenCalledWith( - skipUpToDateWorkspaces[3], - [], - false, - ); + expect(API.updateWorkspace).toHaveBeenCalledWith(skipUpToDateWorkspaces[2]); + expect(API.updateWorkspace).toHaveBeenCalledWith(skipUpToDateWorkspaces[3]); }, }; @@ -370,18 +362,12 @@ export const BatchUpdateRunningWorkspace: Story = { await waitFor(() => expect(API.updateWorkspace).toHaveBeenCalledTimes(3)); expect(API.updateWorkspace).toHaveBeenCalledWith( updateRunningWorkspaces[0], - [], - false, ); expect(API.updateWorkspace).toHaveBeenCalledWith( updateRunningWorkspaces[1], - [], - false, ); expect(API.updateWorkspace).toHaveBeenCalledWith( updateRunningWorkspaces[2], - [], - false, ); }, }; @@ -416,13 +402,9 @@ export const BatchUpdateIgnoresDormantWorkspaces: Story = { await waitFor(() => expect(API.updateWorkspace).toHaveBeenCalledTimes(2)); expect(API.updateWorkspace).toHaveBeenCalledWith( ignoreDormantWorkspaces[1], - [], - false, ); expect(API.updateWorkspace).toHaveBeenCalledWith( ignoreDormantWorkspaces[2], - [], - false, ); }, }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index e20e8eaa52..8baaa8f40f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -248,7 +248,6 @@ const WorkspacesPage: FC = () => { onSubmit={async () => { await batchActions.updateTemplateVersions({ workspaces: checkedWorkspaces, - isDynamicParametersEnabled: false, }); setActiveBatchAction(undefined); }} diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 540227daa4..3526c24d93 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -510,7 +510,7 @@ const WorkspaceActionsCell: FC = ({ > - + )} @@ -523,7 +523,7 @@ const WorkspaceActionsCell: FC = ({ > - + )} @@ -536,7 +536,7 @@ const WorkspaceActionsCell: FC = ({ > - + )} @@ -549,7 +549,7 @@ const WorkspaceActionsCell: FC = ({ > - + )} diff --git a/site/src/pages/WorkspacesPage/batchActions.ts b/site/src/pages/WorkspacesPage/batchActions.ts index b372415a24..2ac7011e1e 100644 --- a/site/src/pages/WorkspacesPage/batchActions.ts +++ b/site/src/pages/WorkspacesPage/batchActions.ts @@ -10,7 +10,6 @@ interface UseBatchActionsOptions { type UpdateAllPayload = Readonly<{ workspaces: readonly Workspace[]; - isDynamicParametersEnabled: boolean; }>; type UseBatchActionsResult = Readonly<{ @@ -78,11 +77,11 @@ export function useBatchActions( const updateAllMutation = useMutation({ mutationFn: (payload: UpdateAllPayload) => { - const { workspaces, isDynamicParametersEnabled } = payload; + const { workspaces } = payload; return Promise.all( workspaces .filter((w) => w.outdated && !w.dormant_at) - .map((w) => API.updateWorkspace(w, [], isDynamicParametersEnabled)), + .map((w) => API.updateWorkspace(w)), ); }, onSuccess, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 9515870033..001af980d0 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1832,12 +1832,6 @@ export const MockPendingWorkspace: TypesGen.Workspace = { }, }; -export const MockNonClassicParameterFlowWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: "test-non-classic-parameter-flow-workspace", - template_use_classic_parameter_flow: false, -}; - // just over one page of workspaces export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = { workspaces: range(1, 27).map((id: number) => ({ diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index c1c6c057f1..4b81b94afc 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -118,6 +118,16 @@ export const handlers = [ return HttpResponse.json([]); }, ), + http.post( + "/api/v2/templateversions/:templateVersionId/dynamic-parameters/evaluate", + () => { + return HttpResponse.json({ + id: 0, + diagnostics: [], + parameters: [], + }); + }, + ), http.get("/api/v2/templateversions/:templateVersionId/external-auth", () => { return HttpResponse.json([]); }), diff --git a/site/src/utils/richParameters.test.ts b/site/src/utils/richParameters.test.ts deleted file mode 100644 index e1cb956f9b..0000000000 --- a/site/src/utils/richParameters.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { TemplateVersionParameter } from "#/api/typesGenerated"; -import { getInitialRichParameterValues } from "./richParameters"; - -test("getInitialRichParameterValues return default value when default build parameter is not valid", () => { - const templateParameters: TemplateVersionParameter[] = [ - { - name: "cpu", - display_name: "CPU", - description: "The number of CPU cores", - description_plaintext: "The number of CPU cores", - type: "string", - form_type: "radio", - mutable: true, - default_value: "2", - icon: "/icon/memory.svg", - options: [ - { - name: "2 Cores", - description: "", - value: "2", - icon: "", - }, - { - name: "4 Cores", - description: "", - value: "4", - icon: "", - }, - { - name: "6 Cores", - description: "", - value: "6", - icon: "", - }, - { - name: "8 Cores", - description: "", - value: "8", - icon: "", - }, - ], - required: false, - ephemeral: false, - }, - ]; - - const cpuParameter = templateParameters[0]; - const [cpuParameterInitialValue] = getInitialRichParameterValues( - templateParameters, - [{ name: cpuParameter.name, value: "100", source: "url" }], - ); - - expect(cpuParameterInitialValue.value).toBe(cpuParameter.default_value); -}); diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts index e9af4bcb5e..0cac57d606 100644 --- a/site/src/utils/richParameters.ts +++ b/site/src/utils/richParameters.ts @@ -1,204 +1,9 @@ -import * as Yup from "yup"; -import type { - TemplateVersionParameter, - WorkspaceBuildParameter, -} from "#/api/typesGenerated"; +import type { WorkspaceBuildParameter } from "#/api/typesGenerated"; -export type AutofillSource = "user_history" | "url" | "active_build"; +type AutofillSource = "user_history" | "url" | "active_build"; // AutofillBuildParameter is a build parameter destined to a form, alongside // its source so that the form can explain where the value comes from. export type AutofillBuildParameter = { source: AutofillSource; } & WorkspaceBuildParameter; - -export const getInitialRichParameterValues = ( - templateParams: TemplateVersionParameter[], - autofillParams?: AutofillBuildParameter[], -): WorkspaceBuildParameter[] => { - return templateParams.map((parameter) => { - // Short-circuit for ephemeral parameters, which are always reset to - // the template-defined default. - if (parameter.ephemeral) { - return { - name: parameter.name, - value: parameter.default_value, - }; - } - - const autofillParam = autofillParams?.find( - ({ name }) => name === parameter.name, - ); - - return { - name: parameter.name, - value: - autofillParam && - isValidValue(parameter, autofillParam) && - autofillParam.source !== "user_history" - ? autofillParam.value - : parameter.default_value, - }; - }); -}; - -const isValidValue = ( - templateParam: TemplateVersionParameter, - buildParam: WorkspaceBuildParameter, -) => { - if (templateParam.options.length > 0) { - const validValues = templateParam.options.map((option) => option.value); - return validValues.includes(buildParam.value); - } - - return true; -}; - -export const useValidationSchemaForRichParameters = ( - templateParameters?: TemplateVersionParameter[], - lastBuildParameters?: WorkspaceBuildParameter[], -): Yup.AnySchema => { - if (!templateParameters) { - return Yup.object(); - } - - return Yup.array() - .of( - Yup.object().shape({ - name: Yup.string().required(), - value: Yup.string() - .test("verify with template", (val, ctx) => { - const name = ctx.parent.name; - const templateParameter = templateParameters.find( - (parameter) => parameter.name === name, - ); - if (templateParameter) { - switch (templateParameter.type) { - case "number": - if ( - templateParameter.validation_min && - !templateParameter.validation_max - ) { - if (Number(val) < templateParameter.validation_min) { - return ctx.createError({ - path: ctx.path, - message: - parameterError(templateParameter, val) ?? - `Value must be greater than ${templateParameter.validation_min}.`, - }); - } - } else if ( - !templateParameter.validation_min && - templateParameter.validation_max - ) { - if (templateParameter.validation_max < Number(val)) { - return ctx.createError({ - path: ctx.path, - message: - parameterError(templateParameter, val) ?? - `Value must be less than ${templateParameter.validation_max}.`, - }); - } - } else if ( - templateParameter.validation_min && - templateParameter.validation_max - ) { - if ( - Number(val) < templateParameter.validation_min || - templateParameter.validation_max < Number(val) - ) { - return ctx.createError({ - path: ctx.path, - message: - parameterError(templateParameter, val) ?? - `Value must be between ${templateParameter.validation_min} and ${templateParameter.validation_max}.`, - }); - } - } - - if ( - templateParameter.validation_monotonic && - lastBuildParameters - ) { - const lastBuildParameter = lastBuildParameters.find( - (last) => last.name === name, - ); - if (lastBuildParameter) { - switch (templateParameter.validation_monotonic) { - case "increasing": - if (Number(lastBuildParameter.value) > Number(val)) { - return ctx.createError({ - path: ctx.path, - message: `Value must only ever increase (last value was ${lastBuildParameter.value})`, - }); - } - break; - case "decreasing": - if (Number(lastBuildParameter.value) < Number(val)) { - return ctx.createError({ - path: ctx.path, - message: `Value must only ever decrease (last value was ${lastBuildParameter.value})`, - }); - } - break; - } - } - } - break; - case "string": - { - if ( - !templateParameter.validation_regex || - templateParameter.validation_regex.length === 0 - ) { - return true; - } - - const regex = new RegExp( - templateParameter.validation_regex, - ); - if (val && !regex.test(val)) { - return ctx.createError({ - path: ctx.path, - message: parameterError(templateParameter, val), - }); - } - } - break; - } - } - return true; - }), - }), - ) - .required(); -}; - -const parameterError = ( - parameter: TemplateVersionParameter, - value?: string, -): string | undefined => { - if (!parameter.validation_error || !value) { - return; - } - - const r = new Map([ - [ - "{min}", - parameter.validation_min !== undefined - ? parameter.validation_min.toString() - : "", - ], - [ - "{max}", - parameter.validation_max !== undefined - ? parameter.validation_max.toString() - : "", - ], - ["{value}", value], - ]); - return parameter.validation_error.replace( - /{min}|{max}|{value}/g, - (match) => r.get(match) || "", - ); -};