From 0f53056648c9f1cfc279e5ae811db59437ed308a Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 14 Feb 2025 08:36:53 +0000 Subject: [PATCH 1/5] add support for presets to the coder frontend --- site/src/api/api.ts | 9 ++ site/src/api/queries/templates.ts | 8 ++ .../CreateWorkspacePage.tsx | 7 ++ .../CreateWorkspacePageView.stories.tsx | 32 +++++++ .../CreateWorkspacePageView.tsx | 94 ++++++++++++++++++- 5 files changed, 149 insertions(+), 1 deletion(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 43051961fa..13db8b841d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1145,6 +1145,15 @@ class ApiMethods { return response.data; }; + getTemplateVersionPresets = async ( + templateVersionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${templateVersionId}/presets`, + ); + return response.data; + }; + startWorkspace = ( workspaceId: string, templateVersionId: string, diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 8f6399cc4b..2cd2d7693c 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -2,6 +2,7 @@ import { API, type GetTemplatesOptions, type GetTemplatesQuery } from "api/api"; import type { CreateTemplateRequest, CreateTemplateVersionRequest, + Preset, ProvisionerJob, ProvisionerJobStatus, Template, @@ -305,6 +306,13 @@ export const previousTemplateVersion = ( }; }; +export const templateVersionPresets = (versionId: string) => { + return { + queryKey: ["templateVersion", versionId, "presets"], + queryFn: () => API.getTemplateVersionPresets(versionId), + }; +}; + const waitBuildToBeFinished = async ( version: TemplateVersion, onRequest?: (data: TemplateVersion) => void, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 56bd0da8a0..a04c46a609 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -5,6 +5,7 @@ import { richParameters, templateByName, templateVersionExternalAuth, + templateVersionPresets, } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; import type { @@ -56,6 +57,11 @@ const CreateWorkspacePage: FC = () => { const templateQuery = useQuery( templateByName(organizationName, templateName), ); + const templateVersionPresetsQuery = useQuery( + templateQuery.data + ? templateVersionPresets(templateQuery.data.active_version_id) + : { enabled: false }, + ); const permissionsQuery = useQuery( templateQuery.data ? checkAuthorization({ @@ -203,6 +209,7 @@ const CreateWorkspacePage: FC = () => { hasAllRequiredExternalAuth={hasAllRequiredExternalAuth} permissions={permissionsQuery.data as CreateWSPermissions} parameters={realizedParameters as TemplateVersionParameter[]} + presets={templateVersionPresetsQuery.data ?? []} creatingWorkspace={createWorkspaceMutation.isLoading} onCancel={() => { navigate(-1); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 46f1f87e8a..e25bd47d46 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -116,6 +116,38 @@ export const Parameters: Story = { }, }; +export const Presets: Story = { + args: { + presets: [ + { + ID: "preset-1", + Name: "Preset 1", + Parameters: [ + { + Name: MockTemplateVersionParameter1.name, + Value: "preset 1 override", + }, + ], + }, + { + ID: "preset-2", + Name: "Preset 2", + Parameters: [ + { + Name: MockTemplateVersionParameter2.name, + Value: "42", + }, + ], + }, + ], + parameters: [ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, + ], + }, +}; + export const ExternalAuth: Story = { args: { externalAuth: [ diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index cc912e1f6f..0d770b6cc0 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -6,6 +6,7 @@ import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; +import { SelectFilter } from "components/Filter/SelectFilter"; import { FormFields, FormFooter, @@ -64,6 +65,7 @@ export interface CreateWorkspacePageViewProps { hasAllRequiredExternalAuth: boolean; parameters: TypesGen.TemplateVersionParameter[]; autofillParameters: AutofillBuildParameter[]; + presets: TypesGen.Preset[]; permissions: CreateWSPermissions; creatingWorkspace: boolean; onCancel: () => void; @@ -88,6 +90,7 @@ export const CreateWorkspacePageView: FC = ({ hasAllRequiredExternalAuth, parameters, autofillParameters, + presets = [], permissions, creatingWorkspace, onSubmit, @@ -145,6 +148,68 @@ export const CreateWorkspacePageView: FC = ({ [autofillParameters], ); + const presetOptions = useMemo(() => { + return [ + { label: "None", value: "" }, + ...presets.map((preset) => ({ + label: preset.Name, + value: preset.ID, + })), + ]; + }, [presets]); + + const [selectedPresetIndex, setSelectedPresetIndex] = useState(0); + const [presetParameterNames, setPresetParameterNames] = useState( + [], + ); + + useEffect(() => { + // TODO (sasswart): test case: what if immutable parameters are used in the preset? + // TODO (sasswart): test case: what if presets are defined for a template version with no params? + // TODO (sasswart): test case: what if a non active version is selected? + // TODO (sasswart): test case: what if a preset is selected that has no parameters? + // TODO (sasswart): what if we have preset params and autofill params on the same param? + // TODO (sasswart): test case: if we move from preset to no preset, do we reset the params? + // If so, how should it behave? Reset to initial value? reset to last set value? + // TODO (sasswart): test case: rich parameters + + const selectedPresetOption = presetOptions[selectedPresetIndex]; + let selectedPreset: TypesGen.Preset | undefined; + for (const preset of presets) { + if (preset.ID === selectedPresetOption.value) { + selectedPreset = preset; + break; + } + } + + if (!selectedPreset || !selectedPreset.Parameters) { + setPresetParameterNames([]); + return; + } + + setPresetParameterNames(selectedPreset.Parameters.map((p) => p.Name)); + + for (const presetParameter of selectedPreset.Parameters) { + const parameterIndex = parameters.findIndex( + (p) => p.name === presetParameter.Name, + ); + if (parameterIndex === -1) continue; + + const parameterField = `rich_parameter_values.${parameterIndex}`; + + form.setFieldValue(parameterField, { + name: presetParameter.Name, + value: presetParameter.Value, + }); + } + }, [ + presetOptions, + selectedPresetIndex, + presets, + parameters, + form.setFieldValue, + ]); + return ( = ({ )} + {presets.length > 0 && ( + + + + { + setSelectedPresetIndex( + presetOptions.findIndex( + (preset) => preset.value === option?.value, + ), + ); + }} + placeholder="Select a preset" + selectedOption={presetOptions[selectedPresetIndex]} + /> + + + + )} + {/* General info */} = ({ const isDisabled = disabledParams?.includes( parameter.name.toLowerCase().replace(/ /g, "_"), - ) || creatingWorkspace; + ) || + creatingWorkspace || + presetParameterNames.includes(parameter.name); return ( Date: Fri, 14 Feb 2025 08:42:05 +0000 Subject: [PATCH 2/5] Collect all todo testcases in a single file --- coderd/presets_test.go | 8 ++++++++ .../CreateWorkspacePage/CreateWorkspacePageView.tsx | 9 --------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/coderd/presets_test.go b/coderd/presets_test.go index ffe51787d5..2d048abf69 100644 --- a/coderd/presets_test.go +++ b/coderd/presets_test.go @@ -15,6 +15,14 @@ import ( ) func TestTemplateVersionPresets(t *testing.T) { + // TODO (sasswart): test case: what if immutable parameters are used in the preset? + // TODO (sasswart): test case: what if presets are defined for a template version with no params? + // TODO (sasswart): test case: what if a non active version is selected? + // TODO (sasswart): test case: what if a preset is selected that has no parameters? + // TODO (sasswart): what if we have preset params and autofill params on the same param? + // TODO (sasswart): test case: if we move from preset to no preset, do we reset the params? + // If so, how should it behave? Reset to initial value? reset to last set value? + // TODO (sasswart): test case: rich parameters // TODO (sasswart): Test case: what if a user tries to read presets or preset parameters from a different org? t.Parallel() diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 0d770b6cc0..fccea627e4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -164,15 +164,6 @@ export const CreateWorkspacePageView: FC = ({ ); useEffect(() => { - // TODO (sasswart): test case: what if immutable parameters are used in the preset? - // TODO (sasswart): test case: what if presets are defined for a template version with no params? - // TODO (sasswart): test case: what if a non active version is selected? - // TODO (sasswart): test case: what if a preset is selected that has no parameters? - // TODO (sasswart): what if we have preset params and autofill params on the same param? - // TODO (sasswart): test case: if we move from preset to no preset, do we reset the params? - // If so, how should it behave? Reset to initial value? reset to last set value? - // TODO (sasswart): test case: rich parameters - const selectedPresetOption = presetOptions[selectedPresetIndex]; let selectedPreset: TypesGen.Preset | undefined; for (const preset of presets) { From 8d08a644fd0d37f6d3f3a41762b0a2eaa994ee99 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 14 Feb 2025 09:22:12 +0000 Subject: [PATCH 3/5] remove todos --- coderd/presets_test.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/coderd/presets_test.go b/coderd/presets_test.go index 2d048abf69..96d1a03e94 100644 --- a/coderd/presets_test.go +++ b/coderd/presets_test.go @@ -15,16 +15,6 @@ import ( ) func TestTemplateVersionPresets(t *testing.T) { - // TODO (sasswart): test case: what if immutable parameters are used in the preset? - // TODO (sasswart): test case: what if presets are defined for a template version with no params? - // TODO (sasswart): test case: what if a non active version is selected? - // TODO (sasswart): test case: what if a preset is selected that has no parameters? - // TODO (sasswart): what if we have preset params and autofill params on the same param? - // TODO (sasswart): test case: if we move from preset to no preset, do we reset the params? - // If so, how should it behave? Reset to initial value? reset to last set value? - // TODO (sasswart): test case: rich parameters - // TODO (sasswart): Test case: what if a user tries to read presets or preset parameters from a different org? - t.Parallel() givenPreset := codersdk.Preset{ From da9239e23578bbef12e49fd8e8c0b0e6768d7165 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 14 Feb 2025 09:33:33 +0000 Subject: [PATCH 4/5] add a story to test when a preset has been selected --- .../CreateWorkspacePageView.stories.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index e25bd47d46..e3d706afc7 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -10,6 +10,8 @@ import { mockApiError, } from "testHelpers/entities"; import { CreateWorkspacePageView } from "./CreateWorkspacePageView"; +import { within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; const meta: Meta = { title: "pages/CreateWorkspacePage", @@ -116,7 +118,7 @@ export const Parameters: Story = { }, }; -export const Presets: Story = { +export const PresetsButNoneSelected: Story = { args: { presets: [ { @@ -148,6 +150,15 @@ export const Presets: Story = { }, }; +export const PresetSelected: Story = { + args: PresetsButNoneSelected.args, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByLabelText("Preset")); + await userEvent.click(canvas.getByText("Preset 1")); + }, +}; + export const ExternalAuth: Story = { args: { externalAuth: [ From e47296c351e26a198440f37a105451b4639a747b Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 14 Feb 2025 09:43:33 +0000 Subject: [PATCH 5/5] make -B fmt --- .../CreateWorkspacePage/CreateWorkspacePageView.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index e3d706afc7..6f0647c9f2 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -1,5 +1,7 @@ import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; +import { within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { chromatic } from "testHelpers/chromatic"; import { MockTemplate, @@ -10,8 +12,6 @@ import { mockApiError, } from "testHelpers/entities"; import { CreateWorkspacePageView } from "./CreateWorkspacePageView"; -import { within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; const meta: Meta = { title: "pages/CreateWorkspacePage",