From d12221c7828fe95bfba5885677044a57fbe6c7c1 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 18 Jul 2023 14:53:26 -0300 Subject: [PATCH] feat(site): add build parameters option when starting or restarting a workspace (#8524) --- site/src/api/api.ts | 24 +- .../LoadingButton/LoadingButton.tsx | 20 +- site/src/components/Markdown/Markdown.tsx | 8 +- .../RichParameterInput.stories.tsx | 349 +++++++++++------- .../RichParameterInput/RichParameterInput.tsx | 80 +++- .../TemplateVersionEditor.tsx | 10 +- site/src/components/Workspace/Workspace.tsx | 7 +- .../BuildParametersPopover.tsx | 192 ++++++++++ .../components/WorkspaceActions/Buttons.tsx | 84 +++-- .../WorkspaceActions.stories.tsx | 23 +- .../WorkspaceActions/WorkspaceActions.tsx | 32 +- .../CreateWorkspacePageView.tsx | 18 +- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 5 +- .../WorkspacePage/WorkspaceReadyPage.tsx | 12 +- .../WorkspaceParametersForm.tsx | 105 ++++-- .../WorkspaceParametersPage.tsx | 21 +- site/src/utils/richParameters.ts | 22 ++ site/src/utils/workspace.tsx | 4 + .../xServices/workspace/workspaceXService.ts | 8 +- 19 files changed, 726 insertions(+), 298 deletions(-) create mode 100644 site/src/components/WorkspaceActions/BuildParametersPopover.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2801c6b988..ba412e39e8 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -519,11 +519,13 @@ export const startWorkspace = ( workspaceId: string, templateVersionId: string, logLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"], + buildParameters?: TypesGen.WorkspaceBuildParameter[], ) => postWorkspaceBuild(workspaceId, { transition: "start", template_version_id: templateVersionId, log_level: logLevel, + rich_parameter_values: buildParameters, }) export const stopWorkspace = ( workspaceId: string, @@ -552,7 +554,13 @@ export const cancelWorkspaceBuild = async ( return response.data } -export const restartWorkspace = async (workspace: TypesGen.Workspace) => { +export const restartWorkspace = async ({ + workspace, + buildParameters, +}: { + workspace: TypesGen.Workspace + buildParameters?: TypesGen.WorkspaceBuildParameter[] +}) => { const stopBuild = await stopWorkspace(workspace.id) const awaitedStopBuild = await waitForBuild(stopBuild) @@ -564,6 +572,8 @@ export const restartWorkspace = async (workspace: TypesGen.Workspace) => { const startBuild = await startWorkspace( workspace.id, workspace.latest_build.template_version_id, + undefined, + buildParameters, ) await waitForBuild(startBuild) } @@ -1346,3 +1356,15 @@ export const issueReconnectingPTYSignedToken = async ( ) return response.data } + +export const getWorkspaceParameters = async (workspace: TypesGen.Workspace) => { + const latestBuild = workspace.latest_build + const [templateVersionRichParameters, buildParameters] = await Promise.all([ + getTemplateVersionRichParameters(latestBuild.template_version_id), + getWorkspaceBuildParameters(latestBuild.id), + ]) + return { + templateVersionRichParameters, + buildParameters, + } +} diff --git a/site/src/components/LoadingButton/LoadingButton.tsx b/site/src/components/LoadingButton/LoadingButton.tsx index 1f2a93cc6b..5270df37c4 100644 --- a/site/src/components/LoadingButton/LoadingButton.tsx +++ b/site/src/components/LoadingButton/LoadingButton.tsx @@ -1,21 +1,25 @@ -import { FC } from "react" +import { forwardRef } from "react" import MuiLoadingButton, { LoadingButtonProps as MuiLoadingButtonProps, } from "@mui/lab/LoadingButton" export type LoadingButtonProps = MuiLoadingButtonProps -export const LoadingButton: FC = ({ - children, - loadingIndicator, - ...buttonProps -}) => { +export const LoadingButton = forwardRef< + HTMLButtonElement, + MuiLoadingButtonProps +>(({ children, loadingIndicator, ...buttonProps }, ref) => { return ( - + {/* known issue: https://github.com/mui/material-ui/issues/27853 */} {buttonProps.loading && loadingIndicator ? loadingIndicator : children} ) -} +}) diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index 982a2b583e..85261e5993 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -12,17 +12,21 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import gfm from "remark-gfm" import { colors } from "theme/colors" import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism" +import { combineClasses } from "utils/combineClasses" export interface MarkdownProps { children: string } -export const Markdown: FC<{ children: string }> = ({ children }) => { +export const Markdown: FC<{ children: string; className?: string }> = ({ + children, + className, +}) => { const styles = useStyles() return ( ( diff --git a/site/src/components/RichParameterInput/RichParameterInput.stories.tsx b/site/src/components/RichParameterInput/RichParameterInput.stories.tsx index 11b7361d3c..2048a96f7e 100644 --- a/site/src/components/RichParameterInput/RichParameterInput.stories.tsx +++ b/site/src/components/RichParameterInput/RichParameterInput.stories.tsx @@ -1,18 +1,14 @@ -import { Story } from "@storybook/react" import { TemplateVersionParameter } from "api/typesGenerated" -import { - RichParameterInput, - RichParameterInputProps, -} from "./RichParameterInput" +import { RichParameterInput } from "./RichParameterInput" +import type { Meta, StoryObj } from "@storybook/react" -export default { +const meta: Meta = { title: "components/RichParameterInput", component: RichParameterInput, } -const Template: Story = ( - args: RichParameterInputProps, -) => +export default meta +type Story = StoryObj const createTemplateVersionParameter = ( partial: Partial, @@ -37,154 +33,221 @@ const createTemplateVersionParameter = ( } } -export const Basic = Template.bind({}) -Basic.args = { - initialValue: "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 Basic: Story = { + args: { + initialValue: "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 NumberType = Template.bind({}) -NumberType.args = { - initialValue: "4", - id: "number_parameter", - parameter: createTemplateVersionParameter({ - name: "number_parameter", - type: "number", - description: "Numeric parameter", - }), +export const NumberType: Story = { + args: { + initialValue: "4", + id: "number_parameter", + parameter: createTemplateVersionParameter({ + name: "number_parameter", + type: "number", + description: "Numeric parameter", + }), + }, } -export const BooleanType = Template.bind({}) -BooleanType.args = { - initialValue: "false", - id: "bool_parameter", - parameter: createTemplateVersionParameter({ - name: "bool_parameter", - type: "bool", - description: "Boolean parameter", - }), +export const BooleanType: Story = { + args: { + initialValue: "false", + id: "bool_parameter", + parameter: createTemplateVersionParameter({ + name: "bool_parameter", + type: "bool", + description: "Boolean parameter", + }), + }, } -export const OptionsType = Template.bind({}) -OptionsType.args = { - initialValue: "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 option 1", - icon: "", - }, - { - name: "Second option", - value: "second_option", - description: "This is option 2", - icon: "/icon/database.svg", - }, - { - name: "Third option", - value: "third_option", - description: "This is option 3", - icon: "/icon/aws.png", - }, - ], - }), +export const OptionsType: Story = { + args: { + initialValue: "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 option 1", + icon: "", + }, + { + name: "Second option", + value: "second_option", + description: "This is option 2", + icon: "/icon/database.svg", + }, + { + name: "Third option", + value: "third_option", + description: "This is option 3", + icon: "/icon/aws.png", + }, + ], + }), + }, } -export const ListStringType = Template.bind({}) -ListStringType.args = { - initialValue: JSON.stringify(["first", "second", "third"]), - id: "list_string_parameter", - parameter: createTemplateVersionParameter({ - name: "list_string_parameter", - type: "list(string)", - description: "List string parameter", - }), +export const ListStringType: Story = { + args: { + initialValue: 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 = Template.bind({}) -IconLabel.args = { - initialValue: "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 IconLabel: Story = { + args: { + initialValue: "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 = Template.bind({}) -NoDescription.args = { - initialValue: "", - 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 NoDescription: Story = { + args: { + initialValue: "", + 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 = Template.bind({}) -DescriptionWithLinks.args = { - initialValue: "", - 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 DescriptionWithLinks: Story = { + args: { + initialValue: "", + 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 = Template.bind({}) -BasicWithDisplayName.args = { - initialValue: "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!", - }), +export const BasicWithDisplayName: Story = { + args: { + initialValue: "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 SmallOptionsType: Story = { + args: { + ...OptionsType.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", + }, } diff --git a/site/src/components/RichParameterInput/RichParameterInput.tsx b/site/src/components/RichParameterInput/RichParameterInput.tsx index b1d0943bd5..3dd5f3f70c 100644 --- a/site/src/components/RichParameterInput/RichParameterInput.tsx +++ b/site/src/components/RichParameterInput/RichParameterInput.tsx @@ -9,6 +9,8 @@ import { TemplateVersionParameter } from "../../api/typesGenerated" import { colors } from "theme/colors" import { MemoizedMarkdown } from "components/Markdown/Markdown" import { MultiTextField } from "components/MultiTextField/MultiTextField" +import Box from "@mui/material/Box" +import { Theme } from "@mui/material/styles" const isBoolean = (parameter: TemplateVersionParameter) => { return parameter.type === "bool" @@ -42,9 +44,9 @@ const ParameterLabel: FC = ({ id, parameter }) => { {hasDescription ? ( {displayName} - - {parameter.description} - + + {parameter.description} + ) : ( {displayName} @@ -54,12 +56,18 @@ const ParameterLabel: FC = ({ id, parameter }) => { ) } -export type RichParameterInputProps = Omit & { +type Size = "medium" | "small" + +export type RichParameterInputProps = Omit< + TextFieldProps, + "onChange" | "size" +> & { index: number parameter: TemplateVersionParameter onChange: (value: string) => void initialValue?: string id: string + size?: Size } export const RichParameterInput: FC = ({ @@ -68,14 +76,17 @@ export const RichParameterInput: FC = ({ onChange, parameter, initialValue, + size = "medium", ...fieldProps }) => { - const styles = useStyles() - return ( - + -
+ = ({ parameter={parameter} initialValue={initialValue} /> -
+
) } @@ -94,6 +105,7 @@ const RichParameterField: React.FC = ({ onChange, parameter, initialValue, + size, ...props }) => { const [parameterValue, setParameterValue] = useState(initialValue) @@ -102,6 +114,7 @@ const RichParameterField: React.FC = ({ if (isBoolean(parameter)) { return ( { onChange(event.target.value) @@ -126,6 +139,7 @@ const RichParameterField: React.FC = ({ if (parameter.options.length > 0) { return ( { onChange(event.target.value) @@ -192,6 +206,7 @@ const RichParameterField: React.FC = ({ return ( = ({ ) } -const optionIconSize = 20 - -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles((theme) => ({ label: { marginBottom: theme.spacing(0.5), }, labelCaption: { fontSize: 14, color: theme.palette.text.secondary, + + ".small &": { + fontSize: 13, + lineHeight: "140%", + }, }, labelPrimary: { fontSize: 16, @@ -224,15 +242,34 @@ const useStyles = makeStyles((theme) => ({ margin: 0, lineHeight: "24px", // Keep the same as ParameterInput }, + + ".small &": { + fontSize: 14, + }, }, labelImmutable: { marginTop: theme.spacing(0.5), marginBottom: theme.spacing(0.5), color: colors.yellow[7], }, - input: { - display: "flex", - flexDirection: "column", + textField: { + ".small & .MuiInputBase-root": { + height: 36, + fontSize: 14, + borderRadius: 6, + }, + }, + radioGroup: { + ".small & .MuiFormControlLabel-label": { + fontSize: 14, + }, + ".small & .MuiRadio-root": { + padding: theme.spacing(0.75, "9px"), // 8px + 1px border + }, + ".small & .MuiRadio-root svg": { + width: 16, + height: 16, + }, }, checkbox: { display: "flex", @@ -243,6 +280,10 @@ const useStyles = makeStyles((theme) => ({ width: theme.spacing(2.5), height: theme.spacing(2.5), display: "block", + + ".small &": { + display: "none", + }, }, labelIcon: { width: "100%", @@ -255,7 +296,12 @@ const useStyles = makeStyles((theme) => ({ gap: theme.spacing(1.5), }, optionIcon: { - maxHeight: optionIconSize, - width: optionIconSize, + maxHeight: 20, + width: 20, + + ".small &": { + maxHeight: 16, + width: 16, + }, }, })) diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 0855c7dd57..d4a81a3363 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -13,7 +13,7 @@ import { VariableValue, WorkspaceResource, } from "api/typesGenerated" -import { Alert } from "components/Alert/Alert" +import { Alert, AlertDetail } from "components/Alert/Alert" import { Avatar } from "components/Avatar/Avatar" import { AvatarData } from "components/AvatarData/AvatarData" import { bannerHeight } from "components/DeploymentBanner/DeploymentBannerView" @@ -47,6 +47,7 @@ import { TemplateVersionStatusBadge, } from "./TemplateVersionStatusBadge" import { Theme } from "@mui/material/styles" +import AlertTitle from "@mui/material/AlertTitle" export interface TemplateVersionEditorProps { template: Template @@ -374,7 +375,12 @@ export const TemplateVersionEditor: FC = ({ }`} > {templateVersion.job.error && ( - {templateVersion.job.error} +
+ + Error during the build + {templateVersion.job.error} + +
)} {buildLogs && buildLogs.length > 0 && ( diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 63effb6817..bfa4d8bbb9 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -45,9 +45,9 @@ export interface WorkspaceProps { maxDeadlineIncrease: number maxDeadlineDecrease: number } - handleStart: () => void + handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void handleStop: () => void - handleRestart: () => void + handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void handleDelete: () => void handleUpdate: () => void handleCancel: () => void @@ -194,8 +194,7 @@ export const Workspace: FC> = ({ void +}) => { + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const { data: parameters } = useQuery({ + queryKey: ["workspace", workspace.id, "parameters"], + queryFn: () => getWorkspaceParameters(workspace), + enabled: isOpen, + }) + const ephemeralParameters = parameters + ? parameters.templateVersionRichParameters.filter((p) => p.ephemeral) + : undefined + + return ( + <> + + { + setIsOpen(false) + }} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + sx={{ + ".MuiPaper-root": { + width: (theme) => theme.spacing(38), + marginTop: 1, + }, + }} + > + + {parameters && parameters.buildParameters && ephemeralParameters ? ( + ephemeralParameters.length > 0 ? ( + <> + theme.palette.text.secondary, + p: 2.5, + borderBottom: (theme) => + `1px solid ${theme.palette.divider}`, + }} + > + Build Options + + These parameters only apply for a single workspace start. + + + +
{ + onSubmit(buildParameters) + setIsOpen(false) + }} + ephemeralParameters={ephemeralParameters} + buildParameters={parameters.buildParameters} + /> + + + ) : ( + theme.palette.text.secondary, + p: 2.5, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + Build Options + + This template has no ephemeral build options. + + + + Read the docs + + + + ) + ) : ( + + )} + + + + ) +} + +const Form = ({ + ephemeralParameters, + buildParameters, + onSubmit, +}: { + ephemeralParameters: TemplateVersionParameter[] + buildParameters: WorkspaceBuildParameter[] + onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void +}) => { + const form = useFormik({ + initialValues: { + rich_parameter_values: getInitialParameterValues( + 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, + }) + }} + /> + ) + })} + + + + + + ) +} diff --git a/site/src/components/WorkspaceActions/Buttons.tsx b/site/src/components/WorkspaceActions/Buttons.tsx index 9c508216ad..4e268c70a2 100644 --- a/site/src/components/WorkspaceActions/Buttons.tsx +++ b/site/src/components/WorkspaceActions/Buttons.tsx @@ -7,6 +7,9 @@ import ReplayIcon from "@mui/icons-material/Replay" import { LoadingButton } from "components/LoadingButton/LoadingButton" import { FC } from "react" import BlockOutlined from "@mui/icons-material/BlockOutlined" +import ButtonGroup from "@mui/material/ButtonGroup" +import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated" +import { BuildParametersPopover } from "./BuildParametersPopover" interface WorkspaceAction { loading?: boolean @@ -31,17 +34,37 @@ export const UpdateButton: FC = ({ ) } -export const StartButton: FC = ({ handleAction, loading }) => { +export const StartButton: FC< + Omit & { + workspace: Workspace + handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void + } +> = ({ handleAction, workspace, loading }) => { return ( - } - onClick={handleAction} + button:hover + button": { + borderLeft: "1px solid #FFF", + }, + }} > - Start - + } + onClick={() => handleAction()} + > + Start + + + ) } @@ -59,21 +82,38 @@ export const StopButton: FC = ({ handleAction, loading }) => { ) } -export const RestartButton: FC = ({ - handleAction, - loading, -}) => { +export const RestartButton: FC< + Omit & { + workspace: Workspace + handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void + } +> = ({ handleAction, loading, workspace }) => { return ( - } - onClick={handleAction} - data-testid="workspace-restart-button" + button:hover + button": { + borderLeft: "1px solid #FFF", + }, + }} > - Restart - + } + onClick={() => handleAction()} + data-testid="workspace-restart-button" + > + Restart + + + ) } diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx index b2b2526811..3697a20a4d 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx @@ -26,67 +26,66 @@ const defaultArgs = { export const Starting = Template.bind({}) Starting.args = { ...defaultArgs, - workspaceStatus: Mocks.MockStartingWorkspace.latest_build.status, + workspace: Mocks.MockStartingWorkspace, } export const Running = Template.bind({}) Running.args = { ...defaultArgs, - workspaceStatus: Mocks.MockWorkspace.latest_build.status, + workspace: Mocks.MockWorkspace, } export const Stopping = Template.bind({}) Stopping.args = { ...defaultArgs, - workspaceStatus: Mocks.MockStoppingWorkspace.latest_build.status, + workspace: Mocks.MockStoppingWorkspace, } export const Stopped = Template.bind({}) Stopped.args = { ...defaultArgs, - workspaceStatus: Mocks.MockStoppedWorkspace.latest_build.status, + workspace: Mocks.MockStoppedWorkspace, } export const Canceling = Template.bind({}) Canceling.args = { ...defaultArgs, - workspaceStatus: Mocks.MockCancelingWorkspace.latest_build.status, + workspace: Mocks.MockCancelingWorkspace, } export const Canceled = Template.bind({}) Canceled.args = { ...defaultArgs, - workspaceStatus: Mocks.MockCanceledWorkspace.latest_build.status, + workspace: Mocks.MockCanceledWorkspace, } export const Deleting = Template.bind({}) Deleting.args = { ...defaultArgs, - workspaceStatus: Mocks.MockDeletingWorkspace.latest_build.status, + workspace: Mocks.MockDeletingWorkspace, } export const Deleted = Template.bind({}) Deleted.args = { ...defaultArgs, - workspaceStatus: Mocks.MockDeletedWorkspace.latest_build.status, + workspace: Mocks.MockDeletedWorkspace, } export const Outdated = Template.bind({}) Outdated.args = { ...defaultArgs, - isOutdated: true, - workspaceStatus: Mocks.MockOutdatedWorkspace.latest_build.status, + workspace: Mocks.MockOutdatedWorkspace, } export const Failed = Template.bind({}) Failed.args = { ...defaultArgs, - workspaceStatus: Mocks.MockFailedWorkspace.latest_build.status, + workspace: Mocks.MockFailedWorkspace, } export const Updating = Template.bind({}) Updating.args = { ...defaultArgs, isUpdating: true, - workspaceStatus: Mocks.MockOutdatedWorkspace.latest_build.status, + workspace: Mocks.MockOutdatedWorkspace, } diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index f874b60665..8242b470c8 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -3,7 +3,7 @@ import Menu from "@mui/material/Menu" import { makeStyles } from "@mui/styles" import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined" import { FC, Fragment, ReactNode, useRef, useState } from "react" -import { WorkspaceStatus } from "api/typesGenerated" +import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated" import { ActionLoadingButton, CancelButton, @@ -24,11 +24,10 @@ import DeleteOutlined from "@mui/icons-material/DeleteOutlined" import IconButton from "@mui/material/IconButton" export interface WorkspaceActionsProps { - workspaceStatus: WorkspaceStatus - isOutdated: boolean - handleStart: () => void + workspace: Workspace + handleStart: (buildParameters?: WorkspaceBuildParameter[]) => void handleStop: () => void - handleRestart: () => void + handleRestart: (buildParameters?: WorkspaceBuildParameter[]) => void handleDelete: () => void handleUpdate: () => void handleCancel: () => void @@ -41,8 +40,7 @@ export interface WorkspaceActionsProps { } export const WorkspaceActions: FC = ({ - workspaceStatus, - isOutdated, + workspace, handleStart, handleStop, handleRestart, @@ -60,8 +58,8 @@ export const WorkspaceActions: FC = ({ canCancel, canAcceptJobs, actions: actionsByStatus, - } = actionsByWorkspaceStatus(workspaceStatus) - const canBeUpdated = isOutdated && canAcceptJobs + } = actionsByWorkspaceStatus(workspace.latest_build.status) + const canBeUpdated = workspace.outdated && canAcceptJobs const menuTriggerRef = useRef(null) const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -71,17 +69,25 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.updating]: ( ), - [ButtonTypesEnum.start]: , + [ButtonTypesEnum.start]: ( + + ), [ButtonTypesEnum.starting]: ( - + ), [ButtonTypesEnum.stop]: , [ButtonTypesEnum.stopping]: ( ), - [ButtonTypesEnum.restart]: , + [ButtonTypesEnum.restart]: ( + + ), [ButtonTypesEnum.restarting]: ( - + ), [ButtonTypesEnum.deleting]: , [ButtonTypesEnum.canceling]: , diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 7ee750b14c..eee8f48759 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -28,6 +28,7 @@ import { MutableTemplateParametersSection, } from "components/TemplateParameters/TemplateParameters" import { ErrorAlert } from "components/Alert/ErrorAlert" +import { paramUsedToCreateWorkspace } from "utils/workspace" export enum CreateWorkspaceErrors { GET_TEMPLATES_ERROR = "getTemplatesError", @@ -59,8 +60,11 @@ export interface CreateWorkspacePageViewProps { export const CreateWorkspacePageView: FC< React.PropsWithChildren > = (props) => { + const templateParameters = props.templateParameters?.filter( + paramUsedToCreateWorkspace, + ) const initialRichParameterValues = selectInitialRichParametersValues( - props.templateParameters, + templateParameters, props.defaultParameterValues, ) const [gitAuthErrors, setGitAuthErrors] = useState>({}) @@ -72,20 +76,16 @@ export const CreateWorkspacePageView: FC< // to disappear. setGitAuthErrors({}) }, [props.templateGitAuth]) - const workspaceErrors = props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR] - // Scroll to top of page if errors are present useEffect(() => { if (props.hasTemplateErrors || Boolean(workspaceErrors)) { window.scrollTo(0, 0) } }, [props.hasTemplateErrors, workspaceErrors]) - const { t } = useTranslation("createWorkspacePage") const styles = useStyles() - const form: FormikContextType = useFormik({ initialValues: { @@ -97,7 +97,7 @@ export const CreateWorkspacePageView: FC< name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })), rich_parameter_values: useValidationSchemaForRichParameters( "createWorkspacePage", - props.templateParameters, + templateParameters, ), }), enableReinitialize: true, @@ -240,10 +240,10 @@ export const CreateWorkspacePageView: FC< )} - {props.templateParameters && ( + {templateParameters && ( <> { return { ...getFieldHelpers( @@ -264,7 +264,7 @@ export const CreateWorkspacePageView: FC< }} /> { return { diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index cc48cdf6e1..8874bc8d2b 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -21,6 +21,7 @@ import { selectInitialRichParametersValues, workspaceBuildParameterValue, } from "utils/richParameters" +import { paramUsedToCreateWorkspace } from "utils/workspace" type ButtonValues = Record @@ -38,7 +39,9 @@ const TemplateEmbedPage = () => { ) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index abb87c4ffb..cca7ec98b0 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -100,13 +100,11 @@ export const WorkspaceReadyPage = ({ ["canceling", "deleting", "pending", "starting", "stopping"].includes( workspace.latest_build.status, )) - const { mutate: restartWorkspace, error: restartBuildError, isLoading: isRestarting, } = useRestartWorkspace() - // keep banner machine in sync with workspace useEffect(() => { bannerSend({ type: "REFRESH_WORKSPACE", workspace }) @@ -151,12 +149,14 @@ export const WorkspaceReadyPage = ({ isUpdating={workspaceState.matches("ready.build.requestingUpdate")} isRestarting={isRestarting} workspace={workspace} - handleStart={() => workspaceSend({ type: "START" })} + handleStart={(buildParameters) => + workspaceSend({ type: "START", buildParameters }) + } handleStop={() => workspaceSend({ type: "STOP" })} handleDelete={() => workspaceSend({ type: "ASK_DELETE" })} - handleRestart={() => { + handleRestart={(buildParameters) => { if (isWarningIgnored("restart")) { - restartWorkspace(workspace) + restartWorkspace({ workspace, buildParameters }) } else { setIsConfirmingRestart(true) } @@ -260,7 +260,7 @@ export const WorkspaceReadyPage = ({ if (shouldIgnore) { ignoreWarning("restart") } - restartWorkspace(workspace) + restartWorkspace({ workspace }) setIsConfirmingRestart(false) }} onClose={() => setIsConfirmingRestart(false)} diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx index 10c621cc14..15c38499b8 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx @@ -9,6 +9,7 @@ import { useFormik } from "formik" import { FC } from "react" import { useTranslation } from "react-i18next" import { + getInitialParameterValues, useValidationSchemaForRichParameters, workspaceBuildParameterValue, } from "utils/richParameters" @@ -48,18 +49,10 @@ export const WorkspaceParametersForm: FC<{ const form = useFormik({ onSubmit, initialValues: { - rich_parameter_values: mutableParameters.map((parameter) => { - const buildParameter = buildParameters.find( - (p) => p.name === parameter.name, - ) - if (!buildParameter) { - return { - name: parameter.name, - value: parameter.default_value, - } - } - return buildParameter - }), + rich_parameter_values: getInitialParameterValues( + mutableParameters, + buildParameters, + ), }, validationSchema: Yup.object({ rich_parameter_values: useValidationSchemaForRichParameters( @@ -72,36 +65,80 @@ export const WorkspaceParametersForm: FC<{ form, error, ) + const hasEphemeralParameters = mutableParameters.some( + (parameter) => parameter.ephemeral, + ) + const hasNonEphemeralParameters = mutableParameters.some( + (parameter) => !parameter.ephemeral, + ) return ( - {mutableParameters.length > 0 && ( + {hasNonEphemeralParameters && ( - {mutableParameters.map((parameter, index) => ( - { - await form.setFieldValue("rich_parameter_values." + index, { - name: parameter.name, - value: value, - }) - }} - parameter={parameter} - initialValue={workspaceBuildParameterValue( - buildParameters, - parameter, - )} - /> - ))} + {mutableParameters.map((parameter, index) => + // Since we are adding the values to the form based on the index + // we can't filter them to not loose the right index position + parameter.ephemeral ? null : ( + { + await form.setFieldValue("rich_parameter_values." + index, { + name: parameter.name, + value: value, + }) + }} + parameter={parameter} + initialValue={workspaceBuildParameterValue( + buildParameters, + parameter, + )} + /> + ), + )} + + + )} + {hasEphemeralParameters && ( + + + {mutableParameters.map((parameter, index) => + // Since we are adding the values to the form based on the index + // we can't filter them to not loose the right index position + parameter.ephemeral ? ( + { + await form.setFieldValue("rich_parameter_values." + index, { + name: parameter.name, + value: value, + }) + }} + parameter={parameter} + initialValue={workspaceBuildParameterValue( + buildParameters, + parameter, + )} + /> + ) : null, + )} )} diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index cbe74547d8..80a13b6225 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -1,9 +1,4 @@ -import { - getTemplateVersionRichParameters, - getWorkspaceBuildParameters, - postWorkspaceBuild, -} from "api/api" -import { Workspace } from "api/typesGenerated" +import { getWorkspaceParameters, postWorkspaceBuild } from "api/api" import { Helmet } from "react-helmet-async" import { pageTitle } from "utils/page" import { useWorkspaceSettingsContext } from "../WorkspaceSettingsLayout" @@ -20,22 +15,10 @@ import { FC } from "react" import { isApiValidationError } from "api/errors" import { ErrorAlert } from "components/Alert/ErrorAlert" -const getWorkspaceParameters = async (workspace: Workspace) => { - const latestBuild = workspace.latest_build - const [templateVersionRichParameters, buildParameters] = await Promise.all([ - getTemplateVersionRichParameters(latestBuild.template_version_id), - getWorkspaceBuildParameters(latestBuild.id), - ]) - return { - templateVersionRichParameters, - buildParameters, - } -} - const WorkspaceParametersPage = () => { const { workspace } = useWorkspaceSettingsContext() const query = useQuery({ - queryKey: ["workspaceSettings", workspace.id], + queryKey: ["workspace", workspace.id, "parameters"], queryFn: () => getWorkspaceParameters(workspace), }) const navigate = useNavigate() diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts index d542cc212e..f4c87eff48 100644 --- a/site/src/utils/richParameters.ts +++ b/site/src/utils/richParameters.ts @@ -32,6 +32,10 @@ export const selectInitialRichParametersValues = ( return } + if (parameter.ephemeral) { + parameterValue = parameter.default_value + } + if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) { parameterValue = defaultValuesFromQuery[parameter.name] } @@ -182,3 +186,21 @@ export const workspaceBuildParameterValue = ( }) return (buildParameter && buildParameter.value) || "" } + +export const getInitialParameterValues = ( + templateParameters: TemplateVersionParameter[], + buildParameters: WorkspaceBuildParameter[], +) => { + return templateParameters.map((parameter) => { + const buildParameter = buildParameters.find( + (p) => p.name === parameter.name, + ) + if (!buildParameter || parameter.ephemeral) { + return { + name: parameter.name, + value: parameter.default_value, + } + } + return buildParameter + }) +} diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 30e3409ccd..61213bf21b 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -285,3 +285,7 @@ const LoadingIcon = () => { export const hasJobError = (workspace: TypesGen.Workspace) => { return workspace.latest_build.job.error !== undefined } + +export const paramUsedToCreateWorkspace = ( + param: TypesGen.TemplateVersionParameter, +) => !param.ephemeral diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index e4baa7d9de..c3010526ed 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -75,7 +75,7 @@ export interface WorkspaceContext { export type WorkspaceEvent = | { type: "REFRESH_WORKSPACE"; data: TypesGen.ServerSentEvent["data"] } - | { type: "START" } + | { type: "START"; buildParameters?: TypesGen.WorkspaceBuildParameter[] } | { type: "STOP" } | { type: "ASK_DELETE" } | { type: "DELETE" } @@ -152,9 +152,6 @@ export const workspaceMachine = createMachine( loadInitialWorkspaceData: { data: Awaited> } - getTemplateParameters: { - data: TypesGen.TemplateVersionParameter[] - } updateWorkspace: { data: TypesGen.WorkspaceBuild } @@ -629,12 +626,13 @@ export const workspaceMachine = createMachine( send({ type: "REFRESH_TIMELINE" }) return build }, - startWorkspace: (context) => async (send) => { + startWorkspace: (context, data) => async (send) => { if (context.workspace) { const startWorkspacePromise = await API.startWorkspace( context.workspace.id, context.workspace.latest_build.template_version_id, context.createBuildLogLevel, + "buildParameters" in data ? data.buildParameters : undefined, ) send({ type: "REFRESH_TIMELINE" }) return startWorkspacePromise