feat: Adjust forms to include Rich Parameters (#5856)

* XService: GetTemplateParameters

* Rich parameter input shows up

* Render option icons

* Icons

* WIP

* For testing purposes: template

* Fix: useState

* WIP: dynamic validation

* Yup validation

* Translations

* Remove temporary template

* make fmt

* WIP

* Fix: tests

* Fix: fmt

* URL param

* Refactor

* Test: rich param value

* Storybook

* Fix

* Refactor for testing purposes

* Typo

* test: string validation

* Button: build parameters

* Full screen page

* Fix: navigate

* XState done

* refactor: postWorkspaceBuild

* RichParameterInput rendered

* Fix: bad initial value

* Validation works

* Maybe

* Fix

* Go back button

* GoBack button

* Form

* Fix

* Storybook

* Fix: CreateWorkspacePage

* fmt

* Test

* ns

* fmt

* All tests

* feat: WorkspaceActions depend on template parameters

* Fix
This commit is contained in:
Marcin Tojek 2023-02-01 18:13:11 +01:00 committed by GitHub
parent d5e2454b1b
commit f9ae105a26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1785 additions and 45 deletions

View File

@ -7,6 +7,7 @@ import GroupsPage from "pages/GroupsPage/GroupsPage"
import LoginPage from "pages/LoginPage/LoginPage"
import { SetupPage } from "pages/SetupPage/SetupPage"
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage"
import { WorkspaceBuildParametersPage } from "pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPage"
import TemplatesPage from "pages/TemplatesPage/TemplatesPage"
import UsersPage from "pages/UsersPage/UsersPage"
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
@ -213,6 +214,10 @@ export const AppRouter: FC = () => {
path="change-version"
element={<WorkspaceChangeVersionPage />}
/>
<Route
path="build-parameters"
element={<WorkspaceBuildParametersPage />}
/>
</Route>
</Route>
</Route>

View File

@ -1,7 +1,6 @@
import axios, { AxiosRequestHeaders } from "axios"
import dayjs from "dayjs"
import * as Types from "./types"
import { WorkspaceBuildTransition } from "./types"
import * as TypesGen from "./typesGenerated"
export const hardCodedCSRFCookie = (): string => {
@ -288,6 +287,15 @@ export const getTemplateVersionParameters = async (
return response.data
}
export const getTemplateVersionRichParameters = async (
versionId: string,
): Promise<TypesGen.TemplateVersionParameter[]> => {
const response = await axios.get(
`/api/v2/templateversions/${versionId}/rich-parameters`,
)
return response.data
}
export const createTemplate = async (
organizationId: string,
data: TypesGen.CreateTemplateRequest,
@ -390,26 +398,29 @@ export const getWorkspaceByOwnerAndName = async (
return response.data
}
const postWorkspaceBuild =
(transition: WorkspaceBuildTransition) =>
async (
workspaceId: string,
template_version_id?: string,
): Promise<TypesGen.WorkspaceBuild> => {
const payload = {
transition,
template_version_id,
}
const response = await axios.post(
`/api/v2/workspaces/${workspaceId}/builds`,
payload,
)
return response.data
}
export const postWorkspaceBuild = async (
workspaceId: string,
data: TypesGen.CreateWorkspaceBuildRequest,
): Promise<TypesGen.WorkspaceBuild> => {
const response = await axios.post(
`/api/v2/workspaces/${workspaceId}/builds`,
data,
)
return response.data
}
export const startWorkspace = postWorkspaceBuild("start")
export const stopWorkspace = postWorkspaceBuild("stop")
export const deleteWorkspace = postWorkspaceBuild("delete")
export const startWorkspace = (
workspaceId: string,
templateVersionID: string,
) =>
postWorkspaceBuild(workspaceId, {
transition: "start",
template_version_id: templateVersionID,
})
export const stopWorkspace = (workspaceId: string) =>
postWorkspaceBuild(workspaceId, { transition: "stop" })
export const deleteWorkspace = (workspaceId: string) =>
postWorkspaceBuild(workspaceId, { transition: "delete" })
export const cancelWorkspaceBuild = async (
workspaceBuildId: TypesGen.WorkspaceBuild["id"],
@ -790,3 +801,12 @@ export const updateWorkspaceVersion = async (
const template = await getTemplate(workspace.template_id)
return startWorkspace(workspace.id, template.active_version_id)
}
export const getWorkspaceBuildParameters = async (
workspaceBuildId: TypesGen.WorkspaceBuild["id"],
): Promise<TypesGen.WorkspaceBuildParameter[]> => {
const response = await axios.get<TypesGen.WorkspaceBuildParameter[]>(
`/api/v2/workspacebuilds/${workspaceBuildId}/parameters`,
)
return response.data
}

View File

@ -4,6 +4,7 @@ import { makeStyles } from "@material-ui/core/styles"
import BlockIcon from "@material-ui/icons/Block"
import CloudQueueIcon from "@material-ui/icons/CloudQueue"
import UpdateOutlined from "@material-ui/icons/UpdateOutlined"
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
import CropSquareIcon from "@material-ui/icons/CropSquare"
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
@ -51,6 +52,23 @@ export const ChangeVersionButton: FC<
)
}
export const BuildParametersButton: FC<
React.PropsWithChildren<WorkspaceAction>
> = ({ handleAction }) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<Button
className={styles.actionButton}
startIcon={<SettingsOutlined />}
onClick={handleAction}
>
{t("actionButton.buildParameters")}
</Button>
)
}
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {

View File

@ -0,0 +1,19 @@
import Button from "@material-ui/core/Button"
interface GoBackButtonProps {
onClick: () => void
}
export const Language = {
ariaLabel: "Go back",
}
export const GoBackButton: React.FC<
React.PropsWithChildren<GoBackButtonProps>
> = ({ onClick }) => {
return (
<Button onClick={onClick} size="small" aria-label={Language.ariaLabel}>
Go back
</Button>
)
}

View File

@ -0,0 +1,95 @@
import { Story } from "@storybook/react"
import { TemplateVersionParameter } from "api/typesGenerated"
import {
RichParameterInput,
RichParameterInputProps,
} from "./RichParameterInput"
export default {
title: "components/RichParameterInput",
component: RichParameterInput,
}
const Template: Story<RichParameterInputProps> = (
args: RichParameterInputProps,
) => <RichParameterInput {...args} />
const createTemplateVersionParameter = (
partial: Partial<TemplateVersionParameter>,
): TemplateVersionParameter => {
return {
name: "first_parameter",
description: "This is first parameter.",
type: "string",
mutable: false,
default_value: "default string",
icon: "/icon/folder.svg",
options: [],
validation_error: "",
validation_regex: "",
validation_min: 0,
validation_max: 0,
...partial,
}
}
export const Basic = Template.bind({})
Basic.args = {
initialValue: "initial-value",
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",
parameter: createTemplateVersionParameter({
name: "number_parameter",
type: "number",
description: "Numeric parameter",
}),
}
export const BooleanType = Template.bind({})
BooleanType.args = {
initialValue: "false",
parameter: createTemplateVersionParameter({
name: "bool_parameter",
type: "bool",
description: "Boolean parameter",
}),
}
export const OptionsType = Template.bind({})
OptionsType.args = {
initialValue: "first_option",
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",
},
],
}),
}

View File

@ -0,0 +1,224 @@
import FormControlLabel from "@material-ui/core/FormControlLabel"
import Radio from "@material-ui/core/Radio"
import RadioGroup from "@material-ui/core/RadioGroup"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import { Stack } from "components/Stack/Stack"
import { FC, useState } from "react"
import { TemplateVersionParameter } from "../../api/typesGenerated"
import { colors } from "theme/colors"
const isBoolean = (parameter: TemplateVersionParameter) => {
return parameter.type === "bool"
}
export interface ParameterLabelProps {
index: number
parameter: TemplateVersionParameter
}
const ParameterLabel: FC<ParameterLabelProps> = ({ index, parameter }) => {
const styles = useStyles()
return (
<span>
<span className={styles.labelNameWithIcon}>
{parameter.icon && (
<span className={styles.iconWrapper}>
<img
className={styles.icon}
alt="Parameter icon"
src={parameter.icon}
style={{
pointerEvents: "none",
}}
/>
</span>
)}
<span className={styles.labelName}>
<label htmlFor={`rich_parameter_values[${index}].value`}>
{parameter.name}
</label>
</span>
</span>
<span className={styles.labelDescription}>{parameter.description}</span>
{!parameter.mutable && (
<div className={styles.labelImmutable}>
This parameter cannot be changed after creating workspace.
</div>
)}
</span>
)
}
export interface RichParameterInputProps {
index: number
disabled?: boolean
parameter: TemplateVersionParameter
onChange: (value: string) => void
initialValue?: string
}
export const RichParameterInput: FC<RichParameterInputProps> = ({
index,
disabled,
onChange,
parameter,
initialValue,
...props
}) => {
const styles = useStyles()
return (
<Stack direction="column" spacing={0.75}>
<ParameterLabel index={index} parameter={parameter} />
<div className={styles.input}>
<RichParameterField
{...props}
index={index}
disabled={disabled}
onChange={onChange}
parameter={parameter}
initialValue={initialValue}
/>
</div>
</Stack>
)
}
const RichParameterField: React.FC<RichParameterInputProps> = ({
disabled,
onChange,
parameter,
initialValue,
...props
}) => {
const [parameterValue, setParameterValue] = useState(initialValue)
const styles = useStyles()
if (isBoolean(parameter)) {
return (
<RadioGroup
defaultValue={parameterValue}
onChange={(event) => {
onChange(event.target.value)
}}
>
<FormControlLabel
disabled={disabled}
value="true"
control={<Radio color="primary" size="small" disableRipple />}
label="True"
/>
<FormControlLabel
disabled={disabled}
value="false"
control={<Radio color="primary" size="small" disableRipple />}
label="False"
/>
</RadioGroup>
)
}
if (parameter.options.length > 0) {
return (
<RadioGroup
defaultValue={parameterValue}
onChange={(event) => {
onChange(event.target.value)
}}
>
{parameter.options.map((option) => (
<FormControlLabel
disabled={disabled}
key={option.name}
value={option.value}
control={<Radio color="primary" size="small" disableRipple />}
label={
<span>
{option.icon && (
<img
className={styles.optionIcon}
alt="Parameter icon"
src={option.icon}
style={{
pointerEvents: "none",
}}
/>
)}
{option.name}
</span>
}
/>
))}
</RadioGroup>
)
}
// A text field can technically handle all cases!
// As other cases become more prominent (like filtering for numbers),
// we should break this out into more finely scoped input fields.
return (
<TextField
{...props}
type={parameter.type}
size="small"
disabled={disabled}
placeholder={parameter.default_value}
value={parameterValue}
onChange={(event) => {
setParameterValue(event.target.value)
onChange(event.target.value)
}}
/>
)
}
const iconSize = 20
const optionIconSize = 24
const useStyles = makeStyles((theme) => ({
labelName: {
fontSize: 14,
color: theme.palette.text.secondary,
display: "block",
marginBottom: theme.spacing(1.0),
},
labelNameWithIcon: {
marginBottom: theme.spacing(0.5),
},
labelDescription: {
fontSize: 16,
color: theme.palette.text.primary,
display: "block",
fontWeight: 600,
},
labelImmutable: {
marginTop: theme.spacing(0.5),
marginBottom: theme.spacing(0.5),
color: colors.yellow[7],
},
input: {
display: "flex",
flexDirection: "column",
},
checkbox: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
},
iconWrapper: {
float: "left",
},
icon: {
maxHeight: iconSize,
width: iconSize,
marginRight: theme.spacing(1.0),
},
optionIcon: {
maxHeight: optionIconSize,
width: optionIconSize,
marginRight: theme.spacing(1.0),
float: "left",
},
}))

View File

@ -45,6 +45,7 @@ export interface WorkspaceProps {
handleUpdate: () => void
handleCancel: () => void
handleChangeVersion: () => void
handleBuildParameters: () => void
isUpdating: boolean
workspace: TypesGen.Workspace
resources?: TypesGen.WorkspaceResource[]
@ -56,6 +57,7 @@ export interface WorkspaceProps {
buildInfo?: TypesGen.BuildInfoResponse
applicationsHost?: string
template?: TypesGen.Template
templateParameters?: TypesGen.TemplateVersionParameter[]
quota_budget?: number
}
@ -70,6 +72,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleUpdate,
handleCancel,
handleChangeVersion,
handleBuildParameters,
workspace,
isUpdating,
resources,
@ -81,6 +84,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
buildInfo,
applicationsHost,
template,
templateParameters,
quota_budget,
}) => {
const { t } = useTranslation("workspacePage")
@ -122,7 +126,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
if (template !== undefined) {
transitionStats = ActiveTransition(template, workspace)
}
return (
<Margins>
<PageHeader
@ -138,6 +141,9 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
/>
<WorkspaceActions
workspaceStatus={workspace.latest_build.status}
hasTemplateParameters={
templateParameters ? templateParameters.length > 0 : false
}
isOutdated={workspace.outdated}
handleStart={handleStart}
handleStop={handleStop}
@ -145,6 +151,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleChangeVersion={handleChangeVersion}
handleBuildParameters={handleBuildParameters}
isUpdating={isUpdating}
/>
</Stack>

View File

@ -12,6 +12,7 @@ const renderComponent = async (props: Partial<WorkspaceActionsProps> = {}) => {
workspaceStatus={
props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status
}
hasTemplateParameters={props.hasTemplateParameters ?? false}
isOutdated={props.isOutdated ?? false}
handleStart={jest.fn()}
handleStop={jest.fn()}
@ -19,6 +20,7 @@ const renderComponent = async (props: Partial<WorkspaceActionsProps> = {}) => {
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
handleChangeVersion={jest.fn()}
handleBuildParameters={jest.fn()}
isUpdating={false}
/>,
)
@ -30,6 +32,7 @@ const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
workspaceStatus={
props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status
}
hasTemplateParameters={props.hasTemplateParameters ?? false}
isOutdated={props.isOutdated ?? false}
handleStart={jest.fn()}
handleStop={jest.fn()}
@ -37,6 +40,7 @@ const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
handleChangeVersion={jest.fn()}
handleBuildParameters={jest.fn()}
isUpdating={false}
/>,
)
@ -74,6 +78,33 @@ describe("WorkspaceActions", () => {
)
})
})
describe("when the workspace is started", () => {
it("primary is stop; secondary is delete", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.stop", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.delete", { ns: "workspacePage" }),
)
})
})
describe("when the workspace with rich parameters is started", () => {
it("primary is stop; secondary is build parameters", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockWorkspace.latest_build.status,
hasTemplateParameters: true,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.stop", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.buildParameters", { ns: "workspacePage" }),
)
})
})
describe("when the workspace is stopping", () => {
it("primary is stopping; cancel is available; no secondary", async () => {
await renderComponent({

View File

@ -7,14 +7,16 @@ import {
ChangeVersionButton,
DeleteButton,
DisabledButton,
BuildParametersButton,
StartButton,
StopButton,
UpdateButton,
} from "../DropdownButton/ActionCtas"
import { ButtonMapping, ButtonTypesEnum, statusToAbilities } from "./constants"
import { ButtonMapping, ButtonTypesEnum, buttonAbilities } from "./constants"
export interface WorkspaceActionsProps {
workspaceStatus: WorkspaceStatus
hasTemplateParameters: boolean
isOutdated: boolean
handleStart: () => void
handleStop: () => void
@ -22,12 +24,14 @@ export interface WorkspaceActionsProps {
handleUpdate: () => void
handleCancel: () => void
handleChangeVersion: () => void
handleBuildParameters: () => void
isUpdating: boolean
children?: ReactNode
}
export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
workspaceStatus,
hasTemplateParameters,
isOutdated,
handleStart,
handleStop,
@ -35,11 +39,14 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleUpdate,
handleCancel,
handleChangeVersion,
handleBuildParameters,
isUpdating,
}) => {
const { t } = useTranslation("workspacePage")
const { canCancel, canAcceptJobs, actions } =
statusToAbilities[workspaceStatus]
const { canCancel, canAcceptJobs, actions } = buttonAbilities(
workspaceStatus,
hasTemplateParameters,
)
const canBeUpdated = isOutdated && canAcceptJobs
// A mapping of button type to the corresponding React component
@ -51,6 +58,9 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
[ButtonTypesEnum.changeVersion]: (
<ChangeVersionButton handleAction={handleChangeVersion} />
),
[ButtonTypesEnum.buildParameters]: (
<BuildParametersButton handleAction={handleBuildParameters} />
),
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.starting]: (
<ActionLoadingButton label={t("actionButton.starting")} />

View File

@ -12,6 +12,7 @@ export enum ButtonTypesEnum {
update = "update",
updating = "updating",
changeVersion = "changeVersion",
buildParameters = "buildParameters",
// disabled buttons
canceling = "canceling",
deleted = "deleted",
@ -28,7 +29,24 @@ interface WorkspaceAbilities {
canAcceptJobs: boolean
}
export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
export const buttonAbilities = (
status: WorkspaceStatus,
hasTemplateParameters: boolean,
): WorkspaceAbilities => {
if (hasTemplateParameters) {
return statusToAbilities[status]
}
const all = statusToAbilities[status]
return {
...all,
actions: all.actions.filter(
(action) => action !== ButtonTypesEnum.buildParameters,
),
}
}
const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
starting: {
actions: [ButtonTypesEnum.starting],
canCancel: true,
@ -37,6 +55,7 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
running: {
actions: [
ButtonTypesEnum.stop,
ButtonTypesEnum.buildParameters,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
@ -51,6 +70,7 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
stopped: {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.buildParameters,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
@ -61,6 +81,7 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.stop,
ButtonTypesEnum.buildParameters,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
@ -71,6 +92,7 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
failed: {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.buildParameters,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],

View File

@ -2,5 +2,8 @@
"templateLabel": "Template",
"nameLabel": "Workspace Name",
"ownerLabel": "Owner",
"createWorkspace": "Create workspace"
"createWorkspace": "Create workspace",
"validationRequiredParameter": "Parameter is required.",
"validationNumberNotInRange": "Value must be between {{min}} and {{max}}.",
"validationPatternNotMatched": "{{error}} (value does not match the pattern {{pattern}})."
}

View File

@ -11,6 +11,7 @@ import usersPage from "./usersPage.json"
import templateSettingsPage from "./templateSettingsPage.json"
import templateVersionPage from "./templateVersionPage.json"
import loginPage from "./loginPage.json"
import workspaceBuildParametersPage from "./workspaceBuildParametersPage.json"
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
import workspaceSchedulePage from "./workspaceSchedulePage.json"
import appearanceSettings from "./appearanceSettings.json"
@ -33,6 +34,7 @@ export const en = {
templateSettingsPage,
templateVersionPage,
loginPage,
workspaceBuildParametersPage,
workspaceChangeVersionPage,
workspaceSchedulePage,
appearanceSettings,

View File

@ -0,0 +1,9 @@
{
"title": "Workspace build parameters",
"detail": "Those values were provided by the workspace owner.",
"noParametersDefined": "This template does not use any rich parameters.",
"validationRequiredParameter": "Parameter is required.",
"validationNumberNotInRange": "Value must be between {{min}} and {{max}}.",
"validationPatternNotMatched": "{{error}} (value does not match the pattern {{pattern}}).",
"updateWorkspace": "Update workspace"
}

View File

@ -28,7 +28,8 @@
"starting": "Starting...",
"stopping": "Stopping...",
"deleting": "Deleting...",
"changeVersion": "Change version"
"changeVersion": "Change version",
"buildParameters": "Build parameters"
},
"disabledButton": {
"canceling": "Canceling",

View File

@ -9,6 +9,9 @@ import {
MockWorkspace,
MockWorkspaceQuota,
MockWorkspaceRequest,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
} from "testHelpers/entities"
import { renderWithAuth } from "testHelpers/renderHelpers"
import CreateWorkspacePage from "./CreateWorkspacePage"
@ -17,6 +20,16 @@ const { t } = i18next
const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" })
const createWorkspaceText = t("createWorkspace", { ns: "createWorkspacePage" })
const validationNumberNotInRangeText = t("validationNumberNotInRange", {
ns: "createWorkspacePage",
min: "1",
max: "3",
})
const validationPatternNotMatched = t("validationPatternNotMatched", {
ns: "createWorkspacePage",
error: MockTemplateVersionParameter3.validation_error,
pattern: "^[a-z]{3}$",
})
const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, {
@ -27,11 +40,29 @@ const renderCreateWorkspacePage = () => {
describe("CreateWorkspacePage", () => {
it("renders", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1])
renderCreateWorkspacePage()
const element = await screen.findByText("Create workspace")
const element = await screen.findByText(createWorkspaceText)
expect(element).toBeDefined()
})
it("renders with rich parameter", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1])
renderCreateWorkspacePage()
const element = await screen.findByText(createWorkspaceText)
expect(element).toBeDefined()
const firstParameter = await screen.findByText(
MockTemplateVersionParameter1.description,
)
expect(firstParameter).toBeDefined()
})
it("succeeds with default owner", async () => {
jest
.spyOn(API, "getUsers")
@ -40,6 +71,9 @@ describe("CreateWorkspacePage", () => {
.spyOn(API, "getWorkspaceQuota")
.mockResolvedValueOnce(MockWorkspaceQuota)
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1])
renderCreateWorkspacePage()
@ -73,13 +107,106 @@ describe("CreateWorkspacePage", () => {
default_source_value: "",
}),
])
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1])
renderWithAuth(<CreateWorkspacePage />, {
route:
"/templates/" +
MockTemplate.name +
`/workspace?param.${param}=${paramValue}`,
path: "/templates/:template/workspace",
})
}),
await screen.findByDisplayValue(paramValue)
})
it("uses default rich param values passed from the URL", async () => {
const param = "first_parameter"
const paramValue = "It works!"
jest.spyOn(API, "getTemplateVersionSchema").mockResolvedValueOnce([
mockParameterSchema({
name: param,
default_source_value: "",
}),
])
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1])
await waitFor(() =>
renderWithAuth(<CreateWorkspacePage />, {
route:
"/templates/" +
MockTemplate.name +
`/workspace?param.${param}=${paramValue}`,
path: "/templates/:template/workspace",
}),
)
await screen.findByDisplayValue(paramValue)
})
it("rich parameter: number validation fails", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
])
await waitFor(() => renderCreateWorkspacePage())
const element = await screen.findByText("Create workspace")
expect(element).toBeDefined()
const secondParameter = await screen.findByText(
MockTemplateVersionParameter2.description,
)
expect(secondParameter).toBeDefined()
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
)
expect(secondParameterField).toBeDefined()
fireEvent.change(secondParameterField, {
target: { value: "4" },
})
fireEvent.submit(secondParameter)
const validationError = await screen.findByText(
validationNumberNotInRangeText,
)
expect(validationError).toBeDefined()
})
it("rich parameter: string validation fails", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter3,
])
await waitFor(() => renderCreateWorkspacePage())
const element = await screen.findByText(createWorkspaceText)
expect(element).toBeDefined()
const thirdParameter = await screen.findByText(
MockTemplateVersionParameter3.description,
)
expect(thirdParameter).toBeDefined()
const thirdParameterField = await screen.findByLabelText(
MockTemplateVersionParameter3.name,
)
expect(thirdParameterField).toBeDefined()
fireEvent.change(thirdParameterField, {
target: { value: "1234" },
})
fireEvent.submit(thirdParameterField)
const validationError = await screen.findByText(validationPatternNotMatched)
expect(validationError).toBeInTheDocument()
})
})

View File

@ -30,6 +30,7 @@ const CreateWorkspacePage: FC = () => {
})
const {
templates,
templateParameters,
templateSchema,
selectedTemplate,
getTemplateSchemaError,
@ -57,6 +58,7 @@ const CreateWorkspacePage: FC = () => {
templateName={templateName}
templates={templates}
selectedTemplate={selectedTemplate}
templateParameters={templateParameters}
templateSchema={templateSchema}
createWorkspaceErrors={{
[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError,

View File

@ -3,6 +3,9 @@ import {
makeMockApiError,
mockParameterSchema,
MockTemplate,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
} from "../../testHelpers/entities"
import {
CreateWorkspaceErrors,
@ -108,3 +111,15 @@ CreateWorkspaceError.args = {
name: true,
},
}
export const RichParameters = Template.bind({})
RichParameters.args = {
templates: [MockTemplate],
selectedTemplate: MockTemplate,
templateParameters: [
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
],
createWorkspaceErrors: {},
}

View File

@ -2,10 +2,10 @@ import TextField from "@material-ui/core/TextField"
import * as TypesGen from "api/typesGenerated"
import { FormFooter } from "components/FormFooter/FormFooter"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { i18n } from "i18n"
import { FC, useState } from "react"
import { useTranslation } from "react-i18next"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
@ -30,6 +30,8 @@ export interface CreateWorkspacePageViewProps {
templateName: string
templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template
templateParameters?: TypesGen.TemplateVersionParameter[]
templateSchema?: TypesGen.ParameterSchema[]
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
canCreateForUser?: boolean
@ -42,30 +44,36 @@ export interface CreateWorkspacePageViewProps {
defaultParameterValues?: Record<string, string>
}
const { t } = i18n
export const validationSchema = Yup.object({
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
})
export const CreateWorkspacePageView: FC<
React.PropsWithChildren<CreateWorkspacePageViewProps>
> = (props) => {
const { t } = useTranslation("createWorkspacePage")
const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const [parameterValues, setParameterValues] = useState<
Record<string, string>
>(props.defaultParameterValues ?? {})
const initialRichParameterValues = selectInitialRichParametersValues(
props.templateParameters,
props.defaultParameterValues,
)
const { t } = useTranslation("createWorkspacePage")
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
initialValues: {
name: "",
template_id: props.selectedTemplate ? props.selectedTemplate.id : "",
rich_parameter_values: initialRichParameterValues,
},
validationSchema: Yup.object({
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
rich_parameter_values: ValidationSchemaForRichParameters(
"createWorkspacePage",
props.templateParameters,
),
}),
enableReinitialize: true,
validationSchema,
initialTouched: props.initialTouched,
onSubmit: (request) => {
if (!props.templateSchema) {
@ -249,6 +257,48 @@ export const CreateWorkspacePageView: FC<
</div>
)}
{/* Rich parameters */}
{props.templateParameters && props.templateParameters.length > 0 && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
Rich template params
</h2>
<p className={styles.formSectionInfoDescription}>
Those values are provided by your template&lsquo;s Terraform
configuration.
</p>
</div>
<Stack
direction="column"
spacing={4} // Spacing here is diff because the fields here don't have the MUI floating label spacing
className={styles.formSectionFields}
>
{props.templateParameters.map((parameter, index) => (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={form.isSubmitting}
index={index}
key={parameter.name}
onChange={(value) => {
form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
}}
parameter={parameter}
initialValue={workspaceBuildParameterValue(
initialRichParameterValues,
parameter,
)}
/>
))}
</Stack>
</div>
)}
<FormFooter
styles={formFooterStyles}
onCancel={props.onCancel}
@ -332,3 +382,122 @@ const useFormFooterStyles = makeStyles((theme) => ({
},
},
}))
const selectInitialRichParametersValues = (
templateParameters?: TypesGen.TemplateVersionParameter[],
defaultValuesFromQuery?: Record<string, string>,
): TypesGen.WorkspaceBuildParameter[] => {
const defaults: TypesGen.WorkspaceBuildParameter[] = []
if (!templateParameters) {
return defaults
}
templateParameters.forEach((parameter) => {
if (parameter.options.length > 0) {
let parameterValue = parameter.options[0].value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue,
}
defaults.push(buildParameter)
return
}
let parameterValue = parameter.default_value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue || "",
}
defaults.push(buildParameter)
})
return defaults
}
export const workspaceBuildParameterValue = (
workspaceBuildParameters: TypesGen.WorkspaceBuildParameter[],
parameter: TypesGen.TemplateVersionParameter,
): string => {
const buildParameter = workspaceBuildParameters.find((buildParameter) => {
return buildParameter.name === parameter.name
})
return (buildParameter && buildParameter.value) || ""
}
export const ValidationSchemaForRichParameters = (
ns: string,
templateParameters?: TypesGen.TemplateVersionParameter[],
): Yup.AnySchema => {
const { t } = useTranslation(ns)
if (!templateParameters) {
return Yup.object()
}
return Yup.array()
.of(
Yup.object().shape({
name: Yup.string().required(),
value: Yup.string()
.required(t("validationRequiredParameter"))
.test("verify with template", (val, ctx) => {
const name = ctx.parent.name
const templateParameter = templateParameters.find(
(parameter) => parameter.name === name,
)
if (templateParameter) {
switch (templateParameter.type) {
case "number":
if (
templateParameter.validation_min === 0 &&
templateParameter.validation_max === 0
) {
return true
}
if (
Number(val) < templateParameter.validation_min ||
templateParameter.validation_max < Number(val)
) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotInRange", {
min: templateParameter.validation_min,
max: templateParameter.validation_max,
}),
})
}
break
case "string":
{
if (templateParameter.validation_regex.length === 0) {
return true
}
const regex = new RegExp(templateParameter.validation_regex)
if (val && !regex.test(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationPatternNotMatched", {
error: templateParameter.validation_error,
pattern: templateParameter.validation_regex,
}),
})
}
}
break
}
}
return true
}),
}),
)
.required()
}

View File

@ -0,0 +1,119 @@
import { fireEvent, screen } from "@testing-library/react"
import {
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockWorkspace,
MockWorkspaceBuildParameter1,
MockWorkspaceBuildParameter2,
renderWithAuth,
} from "testHelpers/renderHelpers"
import * as API from "api/api"
import i18next from "i18next"
import { WorkspaceBuildParametersPage } from "./WorkspaceBuildParametersPage"
const { t } = i18next
const pageTitleText = t("title", { ns: "workspaceBuildParametersPage" })
const validationNumberNotInRangeText = t("validationNumberNotInRange", {
ns: "workspaceBuildParametersPage",
min: "1",
max: "3",
})
const renderWorkspaceBuildParametersPage = () => {
return renderWithAuth(<WorkspaceBuildParametersPage />, {
route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}/build-parameters`,
path: `/@:ownerName/:workspaceName/build-parameters`,
})
}
describe("WorkspaceBuildParametersPage", () => {
it("renders without rich parameters", async () => {
jest.spyOn(API, "getWorkspace").mockResolvedValueOnce(MockWorkspace)
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([])
jest
.spyOn(API, "getWorkspaceBuildParameters")
.mockResolvedValueOnce([
MockWorkspaceBuildParameter1,
MockWorkspaceBuildParameter2,
])
renderWorkspaceBuildParametersPage()
const element = await screen.findByText(pageTitleText)
expect(element).toBeDefined()
const goBackButton = await screen.findByText("Go back")
expect(goBackButton).toBeDefined()
})
it("renders with rich parameter", async () => {
jest.spyOn(API, "getWorkspace").mockResolvedValueOnce(MockWorkspace)
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
])
jest
.spyOn(API, "getWorkspaceBuildParameters")
.mockResolvedValueOnce([
MockWorkspaceBuildParameter1,
MockWorkspaceBuildParameter2,
])
renderWorkspaceBuildParametersPage()
const element = await screen.findByText(pageTitleText)
expect(element).toBeDefined()
const firstParameter = await screen.findByLabelText(
MockTemplateVersionParameter1.name,
)
expect(firstParameter).toBeDefined()
const secondParameter = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
)
expect(secondParameter).toBeDefined()
})
it("rich parameter: number validation fails", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
])
jest
.spyOn(API, "getWorkspaceBuildParameters")
.mockResolvedValueOnce([
MockWorkspaceBuildParameter1,
MockWorkspaceBuildParameter2,
])
renderWorkspaceBuildParametersPage()
const element = await screen.findByText(pageTitleText)
expect(element).toBeDefined()
const secondParameter = await screen.findByText(
MockTemplateVersionParameter2.description,
)
expect(secondParameter).toBeDefined()
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
)
expect(secondParameterField).toBeDefined()
fireEvent.change(secondParameterField, {
target: { value: "4" },
})
fireEvent.submit(secondParameter)
const validationError = await screen.findByText(
validationNumberNotInRangeText,
)
expect(validationError).toBeDefined()
})
})

View File

@ -0,0 +1,76 @@
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { pageTitle } from "util/page"
import { useMachine } from "@xstate/react"
import { useNavigate, useParams } from "react-router-dom"
import { workspaceBuildParametersMachine } from "xServices/workspace/workspaceBuildParametersXService"
import {
UpdateWorkspaceErrors,
WorkspaceBuildParametersPageView,
} from "./WorkspaceBuildParametersPageView"
export const WorkspaceBuildParametersPage: FC = () => {
const { t } = useTranslation("workspaceBuildParametersPage")
const navigate = useNavigate()
const { owner: workspaceOwner, workspace: workspaceName } = useParams() as {
owner: string
workspace: string
}
const [state, send] = useMachine(workspaceBuildParametersMachine, {
context: {
workspaceOwner,
workspaceName,
},
actions: {
onUpdateWorkspace: (_, event) => {
navigate(
`/@${event.data.workspace_owner_name}/${event.data.workspace_name}`,
)
},
},
})
const {
selectedWorkspace,
templateParameters,
workspaceBuildParameters,
getWorkspaceError,
getTemplateParametersError,
getWorkspaceBuildParametersError,
updateWorkspaceError,
} = state.context
return (
<>
<Helmet>
<title>{pageTitle(t("title"))}</title>
</Helmet>
<WorkspaceBuildParametersPageView
workspace={selectedWorkspace}
templateParameters={templateParameters}
workspaceBuildParameters={workspaceBuildParameters}
updatingWorkspace={state.matches("updatingWorkspace")}
hasErrors={state.matches("error")}
updateWorkspaceErrors={{
[UpdateWorkspaceErrors.GET_WORKSPACE_ERROR]: getWorkspaceError,
[UpdateWorkspaceErrors.GET_TEMPLATE_PARAMETERS_ERROR]:
getTemplateParametersError,
[UpdateWorkspaceErrors.GET_WORKSPACE_BUILD_PARAMETERS_ERROR]:
getWorkspaceBuildParametersError,
[UpdateWorkspaceErrors.UPDATE_WORKSPACE_ERROR]: updateWorkspaceError,
}}
onCancel={() => {
// Go back
navigate(-1)
}}
onSubmit={(request) => {
send({
type: "UPDATE_WORKSPACE",
request,
})
}}
/>
</>
)
}

View File

@ -0,0 +1,48 @@
import { ComponentMeta, Story } from "@storybook/react"
import {
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockTemplateVersionParameter4,
MockWorkspace,
} from "testHelpers/entities"
import {
WorkspaceBuildParametersPageView,
WorkspaceBuildParametersPageViewProps,
} from "./WorkspaceBuildParametersPageView"
export default {
title: "pages/WorkspaceBuildParametersPageView",
component: WorkspaceBuildParametersPageView,
} as ComponentMeta<typeof WorkspaceBuildParametersPageView>
const Template: Story<WorkspaceBuildParametersPageViewProps> = (args) => (
<WorkspaceBuildParametersPageView {...args} />
)
export const NoRichParametersDefined = Template.bind({})
NoRichParametersDefined.args = {
workspace: MockWorkspace,
templateParameters: [],
workspaceBuildParameters: [],
updateWorkspaceErrors: {},
initialTouched: {
name: true,
},
}
export const RichParametersDefined = Template.bind({})
RichParametersDefined.args = {
workspace: MockWorkspace,
templateParameters: [
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockTemplateVersionParameter4,
],
workspaceBuildParameters: [],
updateWorkspaceErrors: {},
initialTouched: {
name: true,
},
}

View File

@ -0,0 +1,317 @@
import { FC } from "react"
import { FullPageForm } from "components/FullPageForm/FullPageForm"
import { useTranslation } from "react-i18next"
import * as TypesGen from "api/typesGenerated"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Stack } from "components/Stack/Stack"
import { makeStyles } from "@material-ui/core/styles"
import { getFormHelpers } from "util/formUtils"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import {
ValidationSchemaForRichParameters,
workspaceBuildParameterValue,
} from "pages/CreateWorkspacePage/CreateWorkspacePageView"
import { FormFooter } from "components/FormFooter/FormFooter"
import * as Yup from "yup"
import { Maybe } from "components/Conditionals/Maybe"
import { GoBackButton } from "components/GoBackButton/GoBackButton"
export enum UpdateWorkspaceErrors {
GET_WORKSPACE_ERROR = "getWorkspaceError",
GET_TEMPLATE_PARAMETERS_ERROR = "getTemplateParametersError",
GET_WORKSPACE_BUILD_PARAMETERS_ERROR = "getWorkspaceBuildParametersError",
UPDATE_WORKSPACE_ERROR = "updateWorkspaceError",
}
export interface WorkspaceBuildParametersPageViewProps {
workspace?: TypesGen.Workspace
templateParameters?: TypesGen.TemplateVersionParameter[]
workspaceBuildParameters?: TypesGen.WorkspaceBuildParameter[]
initialTouched?: FormikTouched<TypesGen.CreateWorkspaceRequest>
updatingWorkspace: boolean
onCancel: () => void
onSubmit: (req: TypesGen.CreateWorkspaceBuildRequest) => void
hasErrors: boolean
updateWorkspaceErrors: Partial<Record<UpdateWorkspaceErrors, Error | unknown>>
}
export const WorkspaceBuildParametersPageView: FC<
React.PropsWithChildren<WorkspaceBuildParametersPageViewProps>
> = (props) => {
const { t } = useTranslation("workspaceBuildParametersPage")
const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const initialRichParameterValues = selectInitialRichParametersValues(
props.templateParameters,
props.workspaceBuildParameters,
)
const form: FormikContextType<TypesGen.CreateWorkspaceBuildRequest> =
useFormik<TypesGen.CreateWorkspaceBuildRequest>({
initialValues: {
template_version_id: props.workspace
? props.workspace.latest_build.template_version_id
: "",
transition: "start",
rich_parameter_values: initialRichParameterValues,
},
validationSchema: Yup.object({
rich_parameter_values: ValidationSchemaForRichParameters(
"workspaceBuildParametersPage",
props.templateParameters,
),
}),
enableReinitialize: true,
initialTouched: props.initialTouched,
onSubmit: (request) => {
props.onSubmit(
stripImmutableParameters(request, props.templateParameters),
)
form.setSubmitting(false)
},
})
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceBuildRequest>(
form,
props.updateWorkspaceErrors[UpdateWorkspaceErrors.UPDATE_WORKSPACE_ERROR],
)
{
props.hasErrors && (
<Stack>
{Boolean(
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.GET_WORKSPACE_ERROR
],
) && (
<AlertBanner
severity="error"
error={
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.GET_WORKSPACE_ERROR
]
}
/>
)}
{Boolean(
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.GET_TEMPLATE_PARAMETERS_ERROR
],
) && (
<AlertBanner
severity="error"
error={
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.GET_TEMPLATE_PARAMETERS_ERROR
]
}
/>
)}
{Boolean(
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.GET_WORKSPACE_BUILD_PARAMETERS_ERROR
],
) && (
<AlertBanner
severity="error"
error={
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.GET_WORKSPACE_BUILD_PARAMETERS_ERROR
]
}
/>
)}
</Stack>
)
}
return (
<FullPageForm title={t("title")} detail={t("detail")}>
<Maybe
condition={Boolean(
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.UPDATE_WORKSPACE_ERROR
],
)}
>
<AlertBanner
severity="error"
error={
props.updateWorkspaceErrors[
UpdateWorkspaceErrors.UPDATE_WORKSPACE_ERROR
]
}
/>
</Maybe>
<Maybe
condition={Boolean(
props.templateParameters && props.templateParameters.length === 0,
)}
>
<div className={styles.formSection}>
<AlertBanner severity="info" text={t("noParametersDefined")} />
<div className={styles.goBackSection}>
<GoBackButton onClick={props.onCancel} />
</div>
</div>
</Maybe>
{props.templateParameters &&
props.templateParameters.length > 0 &&
props.workspaceBuildParameters && (
<div className={styles.formSection}>
<form onSubmit={form.handleSubmit}>
<Stack
direction="column"
spacing={4} // Spacing here is diff because the fields here don't have the MUI floating label spacing
className={styles.formSectionFields}
>
{props.templateParameters.map((parameter, index) => (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={!parameter.mutable || form.isSubmitting}
index={index}
key={parameter.name}
onChange={(value) => {
form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
}}
parameter={parameter}
initialValue={workspaceBuildParameterValue(
initialRichParameterValues,
parameter,
)}
/>
))}
<FormFooter
styles={formFooterStyles}
onCancel={props.onCancel}
isLoading={props.updatingWorkspace}
submitLabel={t("updateWorkspace")}
/>
</Stack>
</form>
</div>
)}
</FullPageForm>
)
}
const selectInitialRichParametersValues = (
templateParameters?: TypesGen.TemplateVersionParameter[],
workspaceBuildParameters?: TypesGen.WorkspaceBuildParameter[],
): TypesGen.WorkspaceBuildParameter[] => {
const defaults: TypesGen.WorkspaceBuildParameter[] = []
if (!templateParameters) {
return defaults
}
templateParameters.forEach((parameter) => {
if (parameter.options.length > 0) {
let parameterValue = parameter.options[0].value
if (workspaceBuildParameters) {
const foundBuildParameter = workspaceBuildParameters.find(
(buildParameter) => {
return buildParameter.name === parameter.name
},
)
if (foundBuildParameter) {
parameterValue = foundBuildParameter.value
}
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue,
}
defaults.push(buildParameter)
return
}
let parameterValue = parameter.default_value
if (workspaceBuildParameters) {
const foundBuildParameter = workspaceBuildParameters.find(
(buildParameter) => {
return buildParameter.name === parameter.name
},
)
if (foundBuildParameter) {
parameterValue = foundBuildParameter.value
}
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue || "",
}
defaults.push(buildParameter)
})
return defaults
}
const stripImmutableParameters = (
request: TypesGen.CreateWorkspaceBuildRequest,
templateParameters?: TypesGen.TemplateVersionParameter[],
): TypesGen.CreateWorkspaceBuildRequest => {
if (!templateParameters || !request.rich_parameter_values) {
return request
}
const mutableBuildParameters = request.rich_parameter_values.filter(
(buildParameter) =>
templateParameters.find(
(templateParameter) => templateParameter.name === buildParameter.name,
)?.mutable,
)
return {
...request,
rich_parameter_values: mutableBuildParameters,
}
}
const useStyles = makeStyles(() => ({
goBackSection: {
display: "flex",
width: "100%",
marginTop: 32,
},
formSection: {
marginTop: 20,
},
formSectionFields: {
width: "100%",
},
}))
const useFormFooterStyles = makeStyles((theme) => ({
button: {
minWidth: theme.spacing(23),
[theme.breakpoints.down("sm")]: {
width: "100%",
},
},
footer: {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
flexDirection: "row-reverse",
gap: theme.spacing(2),
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
gap: theme.spacing(1),
},
},
}))

View File

@ -30,6 +30,7 @@ const { t } = i18next
// It renders the workspace page and waits for it be loaded
const renderWorkspacePage = async () => {
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
jest.spyOn(api, "getTemplateVersionRichParameters").mockResolvedValueOnce([])
renderWithAuth(<WorkspacePage />, {
route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`,
path: "/@:username/:workspace",

View File

@ -20,6 +20,7 @@ export const WorkspacePage: FC = () => {
workspace,
getWorkspaceError,
getTemplateWarning,
getTemplateParametersWarning,
checkPermissionsError,
} = workspaceState.context
const [quotaState, quotaSend] = useMachine(quotaMachine)
@ -50,6 +51,12 @@ export const WorkspacePage: FC = () => {
{Boolean(getTemplateWarning) && (
<AlertBanner severity="error" error={getTemplateWarning} />
)}
{Boolean(getTemplateParametersWarning) && (
<AlertBanner
severity="error"
error={getTemplateParametersWarning}
/>
)}
{Boolean(checkPermissionsError) && (
<AlertBanner severity="error" error={checkPermissionsError} />
)}

View File

@ -45,6 +45,7 @@ export const WorkspaceReadyPage = ({
const {
workspace,
template,
templateParameters,
refreshWorkspaceWarning,
builds,
getBuildsError,
@ -111,6 +112,7 @@ export const WorkspaceReadyPage = ({
handleUpdate={() => workspaceSend({ type: "UPDATE" })}
handleCancel={() => workspaceSend({ type: "CANCEL" })}
handleChangeVersion={() => navigate("change-version")}
handleBuildParameters={() => navigate("build-parameters")}
resources={workspace.latest_build.resources}
builds={builds}
canUpdateWorkspace={canUpdateWorkspace}
@ -125,6 +127,7 @@ export const WorkspaceReadyPage = ({
buildInfo={buildInfo}
applicationsHost={applicationsHost}
template={template}
templateParameters={templateParameters}
quota_budget={quotaState.context.quota?.budget}
/>
<DeleteDialog

View File

@ -631,11 +631,77 @@ export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = {
count: 26,
}
export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter =
{
name: "first_parameter",
type: "string",
description: "This is first parameter",
default_value: "abc",
mutable: true,
icon: "/icon/folder.svg",
options: [],
validation_error: "",
validation_regex: "",
validation_min: 0,
validation_max: 0,
}
export const MockTemplateVersionParameter2: TypesGen.TemplateVersionParameter =
{
name: "second_parameter",
type: "number",
description: "This is second parameter",
default_value: "2",
mutable: true,
icon: "/icon/folder.svg",
options: [],
validation_error: "",
validation_regex: "",
validation_min: 1,
validation_max: 3,
}
export const MockTemplateVersionParameter3: TypesGen.TemplateVersionParameter =
{
name: "third_parameter",
type: "string",
description: "This is third parameter",
default_value: "aaa",
mutable: true,
icon: "/icon/database.svg",
options: [],
validation_error: "No way!",
validation_regex: "^[a-z]{3}$",
validation_min: 0,
validation_max: 0,
}
export const MockTemplateVersionParameter4: TypesGen.TemplateVersionParameter =
{
name: "fourth_parameter",
type: "string",
description: "This is fourth parameter",
default_value: "def",
mutable: false,
icon: "/icon/database.svg",
options: [],
validation_error: "",
validation_regex: "",
validation_min: 0,
validation_max: 0,
}
// requests the MockWorkspace
export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = {
name: "test",
parameter_values: [],
template_id: "test-template",
rich_parameter_values: [
{
name: MockTemplateVersionParameter1.name,
value: MockTemplateVersionParameter1.default_value,
},
],
}
export const MockUserAgent: Types.UserAgent = {
@ -1185,6 +1251,16 @@ export const MockAppearance: TypesGen.AppearanceConfig = {
},
}
export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = {
name: MockTemplateVersionParameter1.name,
value: "mock-abc",
}
export const MockWorkspaceBuildParameter2: TypesGen.WorkspaceBuildParameter = {
name: MockTemplateVersionParameter2.name,
value: "3",
}
export const mockParameterSchema = (
partial: Partial<TypesGen.ParameterSchema>,
): TypesGen.ParameterSchema => {

View File

@ -36,7 +36,7 @@ interface FormHelpers {
export const getFormHelpers =
<T>(form: FormikContextType<T>, error?: Error | unknown) =>
(
name: keyof T,
name: string,
HelperText: ReactNode = "",
backendErrorName?: string,
): FormHelpers => {

View File

@ -2,12 +2,14 @@ import {
checkAuthorization,
createWorkspace,
getTemplates,
getTemplateVersionRichParameters,
getTemplateVersionSchema,
} from "api/api"
import {
CreateWorkspaceRequest,
ParameterSchema,
Template,
TemplateVersionParameter,
User,
Workspace,
} from "api/typesGenerated"
@ -19,11 +21,13 @@ type CreateWorkspaceContext = {
templateName: string
templates?: Template[]
selectedTemplate?: Template
templateParameters?: TemplateVersionParameter[]
templateSchema?: ParameterSchema[]
createWorkspaceRequest?: CreateWorkspaceRequest
createdWorkspace?: Workspace
createWorkspaceError?: Error | unknown
getTemplatesError?: Error | unknown
getTemplateParametersError?: Error | unknown
getTemplateSchemaError?: Error | unknown
permissions?: Record<string, boolean>
checkPermissionsError?: Error | unknown
@ -52,6 +56,9 @@ export const createWorkspaceMachine = createMachine(
getTemplates: {
data: Template[]
}
getTemplateParameters: {
data: TemplateVersionParameter[]
}
getTemplateSchema: {
data: ParameterSchema[]
}
@ -88,7 +95,7 @@ export const createWorkspaceMachine = createMachine(
src: "getTemplateSchema",
onDone: {
actions: ["assignTemplateSchema"],
target: "checkingPermissions",
target: "gettingTemplateParameters",
},
onError: {
actions: ["assignGetTemplateSchemaError"],
@ -96,6 +103,20 @@ export const createWorkspaceMachine = createMachine(
},
},
},
gettingTemplateParameters: {
entry: "clearGetTemplateParametersError",
invoke: {
src: "getTemplateParameters",
onDone: {
actions: ["assignTemplateParameters"],
target: "checkingPermissions",
},
onError: {
actions: ["assignGetTemplateParametersError"],
target: "error",
},
},
},
checkingPermissions: {
entry: "clearCheckPermissionsError",
invoke: {
@ -145,6 +166,17 @@ export const createWorkspaceMachine = createMachine(
{
services: {
getTemplates: (context) => getTemplates(context.organizationId),
getTemplateParameters: (context) => {
const { selectedTemplate } = context
if (!selectedTemplate) {
throw new Error("No selected template")
}
return getTemplateVersionRichParameters(
selectedTemplate.active_version_id,
)
},
getTemplateSchema: (context) => {
const { selectedTemplate } = context
@ -206,11 +238,13 @@ export const createWorkspaceMachine = createMachine(
return templates.length > 0 ? templates[0] : undefined
},
}),
assignTemplateParameters: assign({
templateParameters: (_, event) => event.data,
}),
assignTemplateSchema: assign({
// Only show parameters that are allowed to be overridden.
// CLI code: https://github.com/coder/coder/blob/main/cli/create.go#L152-L155
templateSchema: (_, event) =>
event.data.filter((param) => param.allow_override_source),
templateSchema: (_, event) => event.data,
}),
assignPermissions: assign({
permissions: (_, event) => event.data as Record<string, boolean>,
@ -239,6 +273,12 @@ export const createWorkspaceMachine = createMachine(
clearGetTemplatesError: assign({
getTemplatesError: (_) => undefined,
}),
assignGetTemplateParametersError: assign({
getTemplateParametersError: (_, event) => event.data,
}),
clearGetTemplateParametersError: assign({
getTemplateParametersError: (_) => undefined,
}),
assignGetTemplateSchemaError: assign({
getTemplateSchemaError: (_, event) => event.data,
}),

View File

@ -0,0 +1,223 @@
import {
getTemplateVersionRichParameters,
getWorkspaceByOwnerAndName,
getWorkspaceBuildParameters,
postWorkspaceBuild,
} from "api/api"
import {
CreateWorkspaceBuildRequest,
Template,
TemplateVersionParameter,
Workspace,
WorkspaceBuild,
WorkspaceBuildParameter,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
type WorkspaceBuildParametersContext = {
workspaceOwner: string
workspaceName: string
selectedWorkspace?: Workspace
selectedTemplate?: Template
templateParameters?: TemplateVersionParameter[]
workspaceBuildParameters?: WorkspaceBuildParameter[]
createWorkspaceBuildRequest?: CreateWorkspaceBuildRequest
getWorkspaceError?: Error | unknown
getTemplateParametersError?: Error | unknown
getWorkspaceBuildParametersError?: Error | unknown
updateWorkspaceError?: Error | unknown
}
type UpdateWorkspaceEvent = {
type: "UPDATE_WORKSPACE"
request: CreateWorkspaceBuildRequest
}
export const workspaceBuildParametersMachine = createMachine(
{
id: "workspaceBuildParametersState",
predictableActionArguments: true,
tsTypes:
{} as import("./workspaceBuildParametersXService.typegen").Typegen0,
schema: {
context: {} as WorkspaceBuildParametersContext,
events: {} as UpdateWorkspaceEvent,
services: {} as {
getWorkspace: {
data: Workspace
}
getTemplateParameters: {
data: TemplateVersionParameter[]
}
getWorkspaceBuildParameters: {
data: WorkspaceBuildParameter[]
}
updateWorkspace: {
data: WorkspaceBuild
}
},
},
initial: "gettingWorkspace",
states: {
gettingWorkspace: {
entry: "clearGetWorkspaceError",
invoke: {
src: "getWorkspace",
onDone: [
{
actions: ["assignWorkspace"],
target: "gettingTemplateParameters",
},
],
onError: {
actions: ["assignGetWorkspaceError"],
target: "error",
},
},
},
gettingTemplateParameters: {
entry: "clearGetTemplateParametersError",
invoke: {
src: "getTemplateParameters",
onDone: [
{
actions: ["assignTemplateParameters"],
target: "gettingWorkspaceBuildParameters",
},
],
onError: {
actions: ["assignGetTemplateParametersError"],
target: "error",
},
},
},
gettingWorkspaceBuildParameters: {
entry: "clearGetWorkspaceBuildParametersError",
invoke: {
src: "getWorkspaceBuildParameters",
onDone: {
actions: ["assignWorkspaceBuildParameters"],
target: "fillingParams",
},
onError: {
actions: ["assignGetWorkspaceBuildParametersError"],
target: "error",
},
},
},
fillingParams: {
on: {
UPDATE_WORKSPACE: {
actions: ["assignCreateWorkspaceBuildRequest"],
target: "updatingWorkspace",
},
},
},
updatingWorkspace: {
entry: "clearUpdateWorkspaceError",
invoke: {
src: "updateWorkspace",
onDone: {
actions: ["onUpdateWorkspace"],
target: "updated",
},
onError: {
actions: ["assignUpdateWorkspaceError"],
target: "fillingParams",
},
},
},
updated: {
entry: "onUpdateWorkspace",
type: "final",
},
error: {},
},
},
{
services: {
getWorkspace: (context) => {
const { workspaceOwner, workspaceName } = context
return getWorkspaceByOwnerAndName(workspaceOwner, workspaceName)
},
getTemplateParameters: (context) => {
const { selectedWorkspace } = context
if (!selectedWorkspace) {
throw new Error("No workspace selected")
}
return getTemplateVersionRichParameters(
selectedWorkspace.latest_build.template_version_id,
)
},
getWorkspaceBuildParameters: (context) => {
const { selectedWorkspace } = context
if (!selectedWorkspace) {
throw new Error("No workspace selected")
}
return getWorkspaceBuildParameters(selectedWorkspace.latest_build.id)
},
updateWorkspace: (context) => {
const { selectedWorkspace, createWorkspaceBuildRequest } = context
if (!selectedWorkspace) {
throw new Error("No workspace selected")
}
if (!createWorkspaceBuildRequest) {
throw new Error("No workspace build request")
}
return postWorkspaceBuild(
selectedWorkspace.id,
createWorkspaceBuildRequest,
)
},
},
actions: {
assignWorkspace: assign({
selectedWorkspace: (_, event) => event.data,
}),
assignTemplateParameters: assign({
templateParameters: (_, event) => event.data,
}),
assignWorkspaceBuildParameters: assign({
workspaceBuildParameters: (_, event) => event.data,
}),
assignCreateWorkspaceBuildRequest: assign({
createWorkspaceBuildRequest: (_, event) => event.request,
}),
assignGetWorkspaceError: assign({
getWorkspaceError: (_, event) => event.data,
}),
clearGetWorkspaceError: assign({
getWorkspaceError: (_) => undefined,
}),
assignGetTemplateParametersError: assign({
getTemplateParametersError: (_, event) => event.data,
}),
clearGetTemplateParametersError: assign({
getTemplateParametersError: (_) => undefined,
}),
clearGetWorkspaceBuildParametersError: assign({
getWorkspaceBuildParametersError: (_) => undefined,
}),
assignGetWorkspaceBuildParametersError: assign({
getWorkspaceBuildParametersError: (_, event) => event.data,
}),
clearUpdateWorkspaceError: assign({
updateWorkspaceError: (_) => undefined,
}),
assignUpdateWorkspaceError: assign({
updateWorkspaceError: (_, event) => event.data,
}),
},
},
)

View File

@ -43,6 +43,8 @@ const moreBuildsAvailable = (
const Language = {
getTemplateWarning:
"Error updating workspace: latest template could not be fetched.",
getTemplateParametersWarning:
"Error updating workspace: template parameters could not be fetched.",
buildError: "Workspace action failed.",
}
@ -53,11 +55,13 @@ export interface WorkspaceContext {
eventSource?: EventSource
workspace?: TypesGen.Workspace
template?: TypesGen.Template
templateParameters?: TypesGen.TemplateVersionParameter[]
build?: TypesGen.WorkspaceBuild
getWorkspaceError?: Error | unknown
// these are labeled as warnings because they don't make the page unusable
refreshWorkspaceWarning?: Error | unknown
getTemplateWarning: Error | unknown
getTemplateParametersWarning: Error | unknown
// Builds
builds?: TypesGen.WorkspaceBuild[]
getBuildsError?: Error | unknown
@ -130,6 +134,9 @@ export const workspaceMachine = createMachine(
getTemplate: {
data: TypesGen.Template
}
getTemplateParameters: {
data: TypesGen.TemplateVersionParameter[]
}
startWorkspaceWithLatestTemplate: {
data: TypesGen.WorkspaceBuild
}
@ -191,14 +198,14 @@ export const workspaceMachine = createMachine(
tags: "loading",
},
gettingTemplate: {
entry: "clearGettingTemplateWarning",
entry: "clearGetTemplateWarning",
invoke: {
src: "getTemplate",
id: "getTemplate",
onDone: [
{
actions: "assignTemplate",
target: "gettingPermissions",
target: "gettingTemplateParameters",
},
],
onError: [
@ -213,6 +220,29 @@ export const workspaceMachine = createMachine(
},
tags: "loading",
},
gettingTemplateParameters: {
entry: "clearGetTemplateParametersWarning",
invoke: {
src: "getTemplateParameters",
id: "getTemplateParameters",
onDone: [
{
actions: "assignTemplateParameters",
target: "gettingPermissions",
},
],
onError: [
{
actions: [
"assignGetTemplateParametersWarning",
"displayGetTemplateParametersWarning",
],
target: "error",
},
],
},
tags: "loading",
},
gettingPermissions: {
entry: "clearGetPermissionsError",
invoke: {
@ -506,6 +536,9 @@ export const workspaceMachine = createMachine(
assignTemplate: assign({
template: (_, event) => event.data,
}),
assignTemplateParameters: assign({
templateParameters: (_, event) => event.data,
}),
assignPermissions: assign({
// Setting event.data as Permissions to be more stricted. So we know
// what permissions we asked for.
@ -566,9 +599,18 @@ export const workspaceMachine = createMachine(
displayGetTemplateWarning: () => {
displayError(Language.getTemplateWarning)
},
clearGettingTemplateWarning: assign({
clearGetTemplateWarning: assign({
getTemplateWarning: (_) => undefined,
}),
assignGetTemplateParametersWarning: assign({
getTemplateParametersWarning: (_, event) => event.data,
}),
displayGetTemplateParametersWarning: () => {
displayError(Language.getTemplateParametersWarning)
},
clearGetTemplateParametersWarning: assign({
getTemplateParametersWarning: (_) => undefined,
}),
// Timeline
assignBuilds: assign({
builds: (_, event) => event.data,
@ -629,6 +671,15 @@ export const workspaceMachine = createMachine(
throw Error("Cannot get template without workspace")
}
},
getTemplateParameters: async (context) => {
if (context.workspace) {
return await API.getTemplateVersionRichParameters(
context.workspace.latest_build.template_version_id,
)
} else {
throw Error("Cannot get template parameters without workspace")
}
},
startWorkspaceWithLatestTemplate: (context) => async (send) => {
if (context.workspace && context.template) {
const startWorkspacePromise = await API.startWorkspace(