feat(site): add build parameters option when starting or restarting a workspace (#8524)

This commit is contained in:
Bruno Quaresma
2023-07-18 14:53:26 -03:00
committed by GitHub
parent 2fae9b0a69
commit d12221c782
19 changed files with 726 additions and 298 deletions

View File

@ -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,
}
}

View File

@ -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<LoadingButtonProps> = ({
children,
loadingIndicator,
...buttonProps
}) => {
export const LoadingButton = forwardRef<
HTMLButtonElement,
MuiLoadingButtonProps
>(({ children, loadingIndicator, ...buttonProps }, ref) => {
return (
<MuiLoadingButton variant="outlined" color="neutral" {...buttonProps}>
<MuiLoadingButton
variant="outlined"
color="neutral"
ref={ref}
{...buttonProps}
>
{/* known issue: https://github.com/mui/material-ui/issues/27853 */}
<span>
{buttonProps.loading && loadingIndicator ? loadingIndicator : children}
</span>
</MuiLoadingButton>
)
}
})

View File

@ -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 (
<ReactMarkdown
className={styles.markdown}
className={combineClasses([styles.markdown, className])}
remarkPlugins={[gfm]}
components={{
a: ({ href, target, children }) => (

View File

@ -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<typeof RichParameterInput> = {
title: "components/RichParameterInput",
component: RichParameterInput,
}
const Template: Story<RichParameterInputProps> = (
args: RichParameterInputProps,
) => <RichParameterInput {...args} />
export default meta
type Story = StoryObj<typeof RichParameterInput>
const createTemplateVersionParameter = (
partial: Partial<TemplateVersionParameter>,
@ -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",
},
}

View File

@ -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<ParameterLabelProps> = ({ id, parameter }) => {
{hasDescription ? (
<Stack spacing={0}>
<span className={styles.labelCaption}>{displayName}</span>
<span className={styles.labelPrimary}>
<MemoizedMarkdown>{parameter.description}</MemoizedMarkdown>
</span>
<MemoizedMarkdown className={styles.labelPrimary}>
{parameter.description}
</MemoizedMarkdown>
</Stack>
) : (
<span className={styles.labelPrimary}>{displayName}</span>
@ -54,12 +56,18 @@ const ParameterLabel: FC<ParameterLabelProps> = ({ id, parameter }) => {
)
}
export type RichParameterInputProps = Omit<TextFieldProps, "onChange"> & {
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<RichParameterInputProps> = ({
@ -68,14 +76,17 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
onChange,
parameter,
initialValue,
size = "medium",
...fieldProps
}) => {
const styles = useStyles()
return (
<Stack direction="column" spacing={2}>
<Stack
direction="column"
spacing={size === "small" ? 1.25 : 2}
className={size}
>
<ParameterLabel id={fieldProps.id} parameter={parameter} />
<div className={styles.input}>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<RichParameterField
{...fieldProps}
index={index}
@ -84,7 +95,7 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
parameter={parameter}
initialValue={initialValue}
/>
</div>
</Box>
</Stack>
)
}
@ -94,6 +105,7 @@ const RichParameterField: React.FC<RichParameterInputProps> = ({
onChange,
parameter,
initialValue,
size,
...props
}) => {
const [parameterValue, setParameterValue] = useState(initialValue)
@ -102,6 +114,7 @@ const RichParameterField: React.FC<RichParameterInputProps> = ({
if (isBoolean(parameter)) {
return (
<RadioGroup
className={styles.radioGroup}
defaultValue={parameterValue}
onChange={(event) => {
onChange(event.target.value)
@ -126,6 +139,7 @@ const RichParameterField: React.FC<RichParameterInputProps> = ({
if (parameter.options.length > 0) {
return (
<RadioGroup
className={styles.radioGroup}
defaultValue={parameterValue}
onChange={(event) => {
onChange(event.target.value)
@ -192,6 +206,7 @@ const RichParameterField: React.FC<RichParameterInputProps> = ({
return (
<TextField
{...props}
className={styles.textField}
type={parameter.type}
disabled={disabled}
required={parameter.required}
@ -205,15 +220,18 @@ const RichParameterField: React.FC<RichParameterInputProps> = ({
)
}
const optionIconSize = 20
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles<Theme>((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,
},
},
}))

View File

@ -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<TemplateVersionEditorProps> = ({
}`}
>
{templateVersion.job.error && (
<Alert severity="error">{templateVersion.job.error}</Alert>
<div>
<Alert severity="error">
<AlertTitle>Error during the build</AlertTitle>
<AlertDetail>{templateVersion.job.error}</AlertDetail>
</Alert>
</div>
)}
{buildLogs && buildLogs.length > 0 && (

View File

@ -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<React.PropsWithChildren<WorkspaceProps>> = ({
<PageHeaderActions>
<WorkspaceActions
workspaceStatus={workspace.latest_build.status}
isOutdated={workspace.outdated}
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}

View File

@ -0,0 +1,192 @@
import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined"
import Box from "@mui/material/Box"
import Button from "@mui/material/Button"
import Popover from "@mui/material/Popover"
import { useQuery } from "@tanstack/react-query"
import { getWorkspaceParameters } from "api/api"
import {
TemplateVersionParameter,
Workspace,
WorkspaceBuildParameter,
} from "api/typesGenerated"
import { FormFields } from "components/Form/Form"
import { Loader } from "components/Loader/Loader"
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import {
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
} from "components/Tooltips/HelpTooltip/HelpTooltip"
import { useFormik } from "formik"
import { useRef, useState } from "react"
import { getFormHelpers } from "utils/formUtils"
import { getInitialParameterValues } from "utils/richParameters"
export const BuildParametersPopover = ({
workspace,
disabled,
onSubmit,
}: {
workspace: Workspace
disabled?: boolean
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void
}) => {
const anchorRef = useRef<HTMLButtonElement>(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 (
<>
<Button
disabled={disabled}
color="neutral"
sx={{ px: 0 }}
ref={anchorRef}
onClick={() => {
setIsOpen(true)
}}
>
<ExpandMoreOutlined sx={{ fontSize: 16 }} />
</Button>
<Popover
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => {
setIsOpen(false)
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
sx={{
".MuiPaper-root": {
width: (theme) => theme.spacing(38),
marginTop: 1,
},
}}
>
<Box>
{parameters && parameters.buildParameters && ephemeralParameters ? (
ephemeralParameters.length > 0 ? (
<>
<Box
sx={{
color: (theme) => theme.palette.text.secondary,
p: 2.5,
borderBottom: (theme) =>
`1px solid ${theme.palette.divider}`,
}}
>
<HelpTooltipTitle>Build Options</HelpTooltipTitle>
<HelpTooltipText>
These parameters only apply for a single workspace start.
</HelpTooltipText>
</Box>
<Box sx={{ p: 2.5 }}>
<Form
onSubmit={(buildParameters) => {
onSubmit(buildParameters)
setIsOpen(false)
}}
ephemeralParameters={ephemeralParameters}
buildParameters={parameters.buildParameters}
/>
</Box>
</>
) : (
<Box
sx={{
color: (theme) => theme.palette.text.secondary,
p: 2.5,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<HelpTooltipTitle>Build Options</HelpTooltipTitle>
<HelpTooltipText>
This template has no ephemeral build options.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://coder.com/docs/v2/latest/templates/parameters#ephemeral-parameters">
Read the docs
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Box>
)
) : (
<Loader />
)}
</Box>
</Popover>
</>
)
}
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 (
<form onSubmit={form.handleSubmit}>
<FormFields>
{ephemeralParameters.map((parameter, index) => {
return (
<RichParameterInput
{...getFieldHelpers("rich_parameter_values[" + index + "].value")}
key={parameter.name}
parameter={parameter}
initialValue={form.values.rich_parameter_values[index]?.value}
index={index}
size="small"
onChange={async (value) => {
await form.setFieldValue(`rich_parameter_values[${index}]`, {
name: parameter.name,
value: value,
})
}}
/>
)
})}
</FormFields>
<Box sx={{ py: 3, pb: 1 }}>
<Button
type="submit"
variant="contained"
color="primary"
sx={{ width: "100%" }}
>
Build workspace
</Button>
</Box>
</form>
)
}

View File

@ -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<WorkspaceAction> = ({
)
}
export const StartButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
export const StartButton: FC<
Omit<WorkspaceAction, "handleAction"> & {
workspace: Workspace
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void
}
> = ({ handleAction, workspace, loading }) => {
return (
<LoadingButton
loading={loading}
loadingIndicator="Starting..."
loadingPosition="start"
startIcon={<PlayCircleOutlineIcon />}
onClick={handleAction}
<ButtonGroup
variant="outlined"
sx={{
// Workaround to make the border transitions smmothly on button groups
"& > button:hover + button": {
borderLeft: "1px solid #FFF",
},
}}
>
Start
</LoadingButton>
<LoadingButton
loading={loading}
loadingIndicator="Starting..."
loadingPosition="start"
startIcon={<PlayCircleOutlineIcon />}
onClick={() => handleAction()}
>
Start
</LoadingButton>
<BuildParametersPopover
workspace={workspace}
disabled={loading}
onSubmit={handleAction}
/>
</ButtonGroup>
)
}
@ -59,21 +82,38 @@ export const StopButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
)
}
export const RestartButton: FC<WorkspaceAction> = ({
handleAction,
loading,
}) => {
export const RestartButton: FC<
Omit<WorkspaceAction, "handleAction"> & {
workspace: Workspace
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void
}
> = ({ handleAction, loading, workspace }) => {
return (
<LoadingButton
loading={loading}
loadingIndicator="Restarting..."
loadingPosition="start"
startIcon={<ReplayIcon />}
onClick={handleAction}
data-testid="workspace-restart-button"
<ButtonGroup
variant="outlined"
sx={{
// Workaround to make the border transitions smmothly on button groups
"& > button:hover + button": {
borderLeft: "1px solid #FFF",
},
}}
>
Restart
</LoadingButton>
<LoadingButton
loading={loading}
loadingIndicator="Restarting..."
loadingPosition="start"
startIcon={<ReplayIcon />}
onClick={() => handleAction()}
data-testid="workspace-restart-button"
>
Restart
</LoadingButton>
<BuildParametersPopover
workspace={workspace}
disabled={loading}
onSubmit={handleAction}
/>
</ButtonGroup>
)
}

View File

@ -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,
}

View File

@ -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<WorkspaceActionsProps> = ({
workspaceStatus,
isOutdated,
workspace,
handleStart,
handleStop,
handleRestart,
@ -60,8 +58,8 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
canCancel,
canAcceptJobs,
actions: actionsByStatus,
} = actionsByWorkspaceStatus(workspaceStatus)
const canBeUpdated = isOutdated && canAcceptJobs
} = actionsByWorkspaceStatus(workspace.latest_build.status)
const canBeUpdated = workspace.outdated && canAcceptJobs
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
@ -71,17 +69,25 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
[ButtonTypesEnum.updating]: (
<UpdateButton loading handleAction={handleUpdate} />
),
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.start]: (
<StartButton workspace={workspace} handleAction={handleStart} />
),
[ButtonTypesEnum.starting]: (
<StartButton loading handleAction={handleStart} />
<StartButton loading workspace={workspace} handleAction={handleStart} />
),
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
[ButtonTypesEnum.stopping]: (
<StopButton loading handleAction={handleStop} />
),
[ButtonTypesEnum.restart]: <RestartButton handleAction={handleRestart} />,
[ButtonTypesEnum.restart]: (
<RestartButton workspace={workspace} handleAction={handleRestart} />
),
[ButtonTypesEnum.restarting]: (
<RestartButton loading handleAction={handleRestart} />
<RestartButton
loading
workspace={workspace}
handleAction={handleRestart}
/>
),
[ButtonTypesEnum.deleting]: <ActionLoadingButton label="Deleting" />,
[ButtonTypesEnum.canceling]: <DisabledButton label="Canceling..." />,

View File

@ -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<CreateWorkspacePageViewProps>
> = (props) => {
const templateParameters = props.templateParameters?.filter(
paramUsedToCreateWorkspace,
)
const initialRichParameterValues = selectInitialRichParametersValues(
props.templateParameters,
templateParameters,
props.defaultParameterValues,
)
const [gitAuthErrors, setGitAuthErrors] = useState<Record<string, string>>({})
@ -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<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
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<
</FormSection>
)}
{props.templateParameters && (
{templateParameters && (
<>
<MutableTemplateParametersSection
templateParameters={props.templateParameters}
templateParameters={templateParameters}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
@ -264,7 +264,7 @@ export const CreateWorkspacePageView: FC<
}}
/>
<ImmutableTemplateParametersSection
templateParameters={props.templateParameters}
templateParameters={templateParameters}
classes={{ root: styles.warningSection }}
getInputProps={(parameter, index) => {
return {

View File

@ -21,6 +21,7 @@ import {
selectInitialRichParametersValues,
workspaceBuildParameterValue,
} from "utils/richParameters"
import { paramUsedToCreateWorkspace } from "utils/workspace"
type ButtonValues = Record<string, string>
@ -38,7 +39,9 @@ const TemplateEmbedPage = () => {
</Helmet>
<TemplateEmbedPageView
template={template}
templateParameters={templateParameters}
templateParameters={templateParameters?.filter(
paramUsedToCreateWorkspace,
)}
/>
</>
)

View File

@ -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)}

View File

@ -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<WorkspaceParametersFormValues>({
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 (
<HorizontalForm onSubmit={form.handleSubmit} data-testid="form">
{mutableParameters.length > 0 && (
{hasNonEphemeralParameters && (
<FormSection
title={t("parameters").toString()}
description={t("parametersDescription").toString()}
>
<FormFields>
{mutableParameters.map((parameter, index) => (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={isSubmitting}
index={index}
key={parameter.name}
onChange={async (value) => {
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 : (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={isSubmitting}
index={index}
key={parameter.name}
onChange={async (value) => {
await form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
}}
parameter={parameter}
initialValue={workspaceBuildParameterValue(
buildParameters,
parameter,
)}
/>
),
)}
</FormFields>
</FormSection>
)}
{hasEphemeralParameters && (
<FormSection
title="Ephemeral Parameters"
description="These parameters only apply for a single workspace start."
>
<FormFields>
{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 ? (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={isSubmitting}
index={index}
key={parameter.name}
onChange={async (value) => {
await form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
}}
parameter={parameter}
initialValue={workspaceBuildParameterValue(
buildParameters,
parameter,
)}
/>
) : null,
)}
</FormFields>
</FormSection>
)}

View File

@ -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()

View File

@ -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
})
}

View File

@ -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

View File

@ -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<ReturnType<typeof loadInitialWorkspaceData>>
}
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