mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: create dynamic parameter component (#17351)
- Create DynamicParameter component and test with locally run preview websocket. - Adapt CreateWorkspacePageExperimental to work with PreviewParameter instead of TemplateVersionParameter - Small changes to checkbox, multi-select combobox and radiogroup The websocket implementation is temporary for testing purpose with a locally run preview websocket
This commit is contained in:
@ -8,6 +8,9 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To allow for an indeterminate state the checkbox must be controlled, otherwise the checked prop would remain undefined
|
||||||
|
*/
|
||||||
export const Checkbox = React.forwardRef<
|
export const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
@ -16,7 +16,7 @@ const meta: Meta<typeof MultiSelectCombobox> = {
|
|||||||
All organizations selected
|
All organizations selected
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
defaultOptions: organizations.map((org) => ({
|
options: organizations.map((org) => ({
|
||||||
label: org.display_name,
|
label: org.display_name,
|
||||||
value: org.id,
|
value: org.id,
|
||||||
})),
|
})),
|
||||||
|
@ -203,9 +203,11 @@ export const MultiSelectCombobox = forwardRef<
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [onScrollbar, setOnScrollbar] = useState(false);
|
const [onScrollbar, setOnScrollbar] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null); // Added this
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Option[]>(value || []);
|
const [selected, setSelected] = useState<Option[]>(
|
||||||
|
arrayDefaultOptions ?? [],
|
||||||
|
);
|
||||||
const [options, setOptions] = useState<GroupOption>(
|
const [options, setOptions] = useState<GroupOption>(
|
||||||
transitionToGroupOption(arrayDefaultOptions, groupBy),
|
transitionToGroupOption(arrayDefaultOptions, groupBy),
|
||||||
);
|
);
|
||||||
|
@ -34,7 +34,7 @@ export const RadioGroupItem = React.forwardRef<
|
|||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
|
||||||
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
|
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
|
||||||
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
|
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
|
||||||
hover:border-border-hover`,
|
hover:border-border-hover data-[state=checked]:border-border-hover`,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -0,0 +1,579 @@
|
|||||||
|
import type {
|
||||||
|
PreviewParameter,
|
||||||
|
PreviewParameterOption,
|
||||||
|
WorkspaceBuildParameter,
|
||||||
|
} from "api/typesGenerated";
|
||||||
|
import { Badge } from "components/Badge/Badge";
|
||||||
|
import { Checkbox } from "components/Checkbox/Checkbox";
|
||||||
|
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||||
|
import { Input } from "components/Input/Input";
|
||||||
|
import { Label } from "components/Label/Label";
|
||||||
|
import { MemoizedMarkdown } from "components/Markdown/Markdown";
|
||||||
|
import {
|
||||||
|
MultiSelectCombobox,
|
||||||
|
type Option,
|
||||||
|
} from "components/MultiSelectCombobox/MultiSelectCombobox";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "components/Select/Select";
|
||||||
|
import { Switch } from "components/Switch/Switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "components/Tooltip/Tooltip";
|
||||||
|
import { Info, Settings, TriangleAlert } from "lucide-react";
|
||||||
|
import { type FC, useId } from "react";
|
||||||
|
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
|
||||||
|
export interface DynamicParameterProps {
|
||||||
|
parameter: PreviewParameter;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
isPreset?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicParameter: FC<DynamicParameterProps> = ({
|
||||||
|
parameter,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
isPreset,
|
||||||
|
}) => {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
data-testid={`parameter-field-${parameter.name}`}
|
||||||
|
>
|
||||||
|
<ParameterLabel parameter={parameter} isPreset={isPreset} />
|
||||||
|
<ParameterField
|
||||||
|
parameter={parameter}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
{parameter.diagnostics.length > 0 && (
|
||||||
|
<ParameterDiagnostics diagnostics={parameter.diagnostics} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ParameterLabelProps {
|
||||||
|
parameter: PreviewParameter;
|
||||||
|
isPreset?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ParameterLabel: FC<ParameterLabelProps> = ({ parameter, isPreset }) => {
|
||||||
|
const hasDescription = parameter.description && parameter.description !== "";
|
||||||
|
const displayName = parameter.display_name
|
||||||
|
? parameter.display_name
|
||||||
|
: parameter.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{parameter.icon && (
|
||||||
|
<span className="w-5 h-5">
|
||||||
|
<ExternalImage
|
||||||
|
className="w-full h-full mt-0.5 object-contain"
|
||||||
|
alt="Parameter icon"
|
||||||
|
src={parameter.icon}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="flex gap-2 flex-wrap text-sm font-medium">
|
||||||
|
{displayName}
|
||||||
|
|
||||||
|
{parameter.mutable && (
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Badge size="sm" variant="warning">
|
||||||
|
<TriangleAlert />
|
||||||
|
Immutable
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
This value cannot be modified after the workspace has been
|
||||||
|
created.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{isPreset && (
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Badge size="sm">
|
||||||
|
<Settings />
|
||||||
|
Preset
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
Preset parameters cannot be modified.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{hasDescription && (
|
||||||
|
<div className="text-content-secondary">
|
||||||
|
<MemoizedMarkdown className="text-xs">
|
||||||
|
{parameter.description}
|
||||||
|
</MemoizedMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ParameterFieldProps {
|
||||||
|
parameter: PreviewParameter;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ParameterField: FC<ParameterFieldProps> = ({
|
||||||
|
parameter,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
id,
|
||||||
|
}) => {
|
||||||
|
const value = parameter.value.valid ? parameter.value.value : "";
|
||||||
|
const defaultValue = parameter.default_value.valid
|
||||||
|
? parameter.default_value.value
|
||||||
|
: "";
|
||||||
|
|
||||||
|
switch (parameter.form_type) {
|
||||||
|
case "dropdown":
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
onValueChange={onChange}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select option" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{parameter.options.map((option) => (
|
||||||
|
<SelectItem key={option.value.value} value={option.value.value}>
|
||||||
|
<OptionDisplay option={option} />
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "multi-select": {
|
||||||
|
// Map parameter options to MultiSelectCombobox options format
|
||||||
|
const comboboxOptions: Option[] = parameter.options.map((opt) => ({
|
||||||
|
value: opt.value.value,
|
||||||
|
label: opt.name,
|
||||||
|
disable: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultOptions: Option[] = JSON.parse(defaultValue).map(
|
||||||
|
(val: string) => {
|
||||||
|
const option = parameter.options.find((o) => o.value.value === val);
|
||||||
|
return {
|
||||||
|
value: val,
|
||||||
|
label: option?.name || val,
|
||||||
|
disable: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectCombobox
|
||||||
|
inputProps={{
|
||||||
|
id: `${id}-${parameter.name}`,
|
||||||
|
}}
|
||||||
|
options={comboboxOptions}
|
||||||
|
defaultOptions={defaultOptions}
|
||||||
|
onChange={(newValues) => {
|
||||||
|
const values = newValues.map((option) => option.value);
|
||||||
|
onChange(JSON.stringify(values));
|
||||||
|
}}
|
||||||
|
hidePlaceholderWhenSelected
|
||||||
|
placeholder="Select option"
|
||||||
|
emptyIndicator={
|
||||||
|
<p className="text-center text-md text-content-primary">
|
||||||
|
No results found
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "switch":
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={value === "true"}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onChange(checked ? "true" : "false");
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "radio":
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
>
|
||||||
|
{parameter.options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value.value}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
id={option.value.value}
|
||||||
|
value={option.value.value}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={option.value.value} className="cursor-pointer">
|
||||||
|
<OptionDisplay option={option} />
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={parameter.name}
|
||||||
|
checked={value === "true"}
|
||||||
|
defaultChecked={defaultValue === "true"} // TODO: defaultChecked is always overridden by checked
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onChange(checked ? "true" : "false");
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={parameter.name}>
|
||||||
|
{parameter.display_name || parameter.name}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "input": {
|
||||||
|
const inputType = parameter.type === "number" ? "number" : "text";
|
||||||
|
const inputProps: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (parameter.type === "number") {
|
||||||
|
const validations = parameter.validations[0] || {};
|
||||||
|
const { validation_min, validation_max } = validations;
|
||||||
|
|
||||||
|
if (validation_min !== null) {
|
||||||
|
inputProps.min = validation_min;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation_max !== null) {
|
||||||
|
inputProps.max = validation_max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type={inputType}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={
|
||||||
|
(parameter.styling as { placehholder?: string })?.placehholder
|
||||||
|
}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OptionDisplayProps {
|
||||||
|
option: PreviewParameterOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OptionDisplay: FC<OptionDisplayProps> = ({ option }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{option.icon && (
|
||||||
|
<ExternalImage
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
src={option.icon}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{option.name}</span>
|
||||||
|
{option.description && (
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-3.5 h-3.5 text-content-secondary" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={10}>
|
||||||
|
{option.description}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ParameterDiagnosticsProps {
|
||||||
|
diagnostics: PreviewParameter["diagnostics"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ParameterDiagnostics: FC<ParameterDiagnosticsProps> = ({
|
||||||
|
diagnostics,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{diagnostics.map((diagnostic, index) => (
|
||||||
|
<div
|
||||||
|
key={`diagnostic-${diagnostic.summary}-${index}`}
|
||||||
|
className={`text-xs px-1 ${
|
||||||
|
diagnostic.severity === "error"
|
||||||
|
? "text-content-destructive"
|
||||||
|
: "text-content-warning"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{diagnostic.summary}</div>
|
||||||
|
{diagnostic.detail && <div>{diagnostic.detail}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInitialParameterValues = (
|
||||||
|
params: PreviewParameter[],
|
||||||
|
autofillParams?: AutofillBuildParameter[],
|
||||||
|
): WorkspaceBuildParameter[] => {
|
||||||
|
return params.map((parameter) => {
|
||||||
|
// Short-circuit for ephemeral parameters, which are always reset to
|
||||||
|
// the template-defined default.
|
||||||
|
if (parameter.ephemeral) {
|
||||||
|
return {
|
||||||
|
name: parameter.name,
|
||||||
|
value: parameter.default_value.valid
|
||||||
|
? parameter.default_value.value
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const autofillParam = autofillParams?.find(
|
||||||
|
({ name }) => name === parameter.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: parameter.name,
|
||||||
|
value:
|
||||||
|
autofillParam &&
|
||||||
|
isValidValue(parameter, autofillParam) &&
|
||||||
|
autofillParam.value
|
||||||
|
? autofillParam.value
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidValue = (
|
||||||
|
previewParam: PreviewParameter,
|
||||||
|
buildParam: WorkspaceBuildParameter,
|
||||||
|
) => {
|
||||||
|
if (previewParam.options.length > 0) {
|
||||||
|
const validValues = previewParam.options.map(
|
||||||
|
(option) => option.value.value,
|
||||||
|
);
|
||||||
|
return validValues.includes(buildParam.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useValidationSchemaForDynamicParameters = (
|
||||||
|
parameters?: PreviewParameter[],
|
||||||
|
lastBuildParameters?: WorkspaceBuildParameter[],
|
||||||
|
): Yup.AnySchema => {
|
||||||
|
if (!parameters) {
|
||||||
|
return Yup.object();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object().shape({
|
||||||
|
name: Yup.string().required(),
|
||||||
|
value: Yup.string()
|
||||||
|
.test("verify with template", (val, ctx) => {
|
||||||
|
const name = ctx.parent.name;
|
||||||
|
const parameter = parameters.find(
|
||||||
|
(parameter) => parameter.name === name,
|
||||||
|
);
|
||||||
|
if (parameter) {
|
||||||
|
switch (parameter.type) {
|
||||||
|
case "number": {
|
||||||
|
const minValidation = parameter.validations.find(
|
||||||
|
(v) => v.validation_min !== null,
|
||||||
|
);
|
||||||
|
const maxValidation = parameter.validations.find(
|
||||||
|
(v) => v.validation_max !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
minValidation &&
|
||||||
|
minValidation.validation_min !== null &&
|
||||||
|
!maxValidation &&
|
||||||
|
Number(val) < minValidation.validation_min
|
||||||
|
) {
|
||||||
|
return ctx.createError({
|
||||||
|
path: ctx.path,
|
||||||
|
message:
|
||||||
|
parameterError(parameter, val) ??
|
||||||
|
`Value must be greater than ${minValidation.validation_min}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!minValidation &&
|
||||||
|
maxValidation &&
|
||||||
|
maxValidation.validation_max !== null &&
|
||||||
|
Number(val) > maxValidation.validation_max
|
||||||
|
) {
|
||||||
|
return ctx.createError({
|
||||||
|
path: ctx.path,
|
||||||
|
message:
|
||||||
|
parameterError(parameter, val) ??
|
||||||
|
`Value must be less than ${maxValidation.validation_max}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
minValidation &&
|
||||||
|
minValidation.validation_min !== null &&
|
||||||
|
maxValidation &&
|
||||||
|
maxValidation.validation_max !== null &&
|
||||||
|
(Number(val) < minValidation.validation_min ||
|
||||||
|
Number(val) > maxValidation.validation_max)
|
||||||
|
) {
|
||||||
|
return ctx.createError({
|
||||||
|
path: ctx.path,
|
||||||
|
message:
|
||||||
|
parameterError(parameter, val) ??
|
||||||
|
`Value must be between ${minValidation.validation_min} and ${maxValidation.validation_max}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const monotonic = parameter.validations.find(
|
||||||
|
(v) =>
|
||||||
|
v.validation_monotonic !== null &&
|
||||||
|
v.validation_monotonic !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (monotonic && lastBuildParameters) {
|
||||||
|
const lastBuildParameter = lastBuildParameters.find(
|
||||||
|
(last: { name: string }) => last.name === name,
|
||||||
|
);
|
||||||
|
if (lastBuildParameter) {
|
||||||
|
switch (monotonic.validation_monotonic) {
|
||||||
|
case "increasing":
|
||||||
|
if (Number(lastBuildParameter.value) > Number(val)) {
|
||||||
|
return ctx.createError({
|
||||||
|
path: ctx.path,
|
||||||
|
message: `Value must only ever increase (last value was ${lastBuildParameter.value})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "decreasing":
|
||||||
|
if (Number(lastBuildParameter.value) < Number(val)) {
|
||||||
|
return ctx.createError({
|
||||||
|
path: ctx.path,
|
||||||
|
message: `Value must only ever decrease (last value was ${lastBuildParameter.value})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "string": {
|
||||||
|
const regex = parameter.validations.find(
|
||||||
|
(v) =>
|
||||||
|
v.validation_regex !== null && v.validation_regex !== "",
|
||||||
|
);
|
||||||
|
if (!regex || !regex.validation_regex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val && !new RegExp(regex.validation_regex).test(val)) {
|
||||||
|
return ctx.createError({
|
||||||
|
path: ctx.path,
|
||||||
|
message: parameterError(parameter, val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.required();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parameterError = (
|
||||||
|
parameter: PreviewParameter,
|
||||||
|
value?: string,
|
||||||
|
): string | undefined => {
|
||||||
|
const validation_error = parameter.validations.find(
|
||||||
|
(v) => v.validation_error !== null,
|
||||||
|
);
|
||||||
|
const minValidation = parameter.validations.find(
|
||||||
|
(v) => v.validation_min !== null,
|
||||||
|
);
|
||||||
|
const maxValidation = parameter.validations.find(
|
||||||
|
(v) => v.validation_max !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validation_error || !value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = new Map<string, string>([
|
||||||
|
[
|
||||||
|
"{min}",
|
||||||
|
minValidation ? (minValidation.validation_min?.toString() ?? "") : "",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"{max}",
|
||||||
|
maxValidation ? (maxValidation.validation_max?.toString() ?? "") : "",
|
||||||
|
],
|
||||||
|
["{value}", value],
|
||||||
|
]);
|
||||||
|
return validation_error.validation_error.replace(
|
||||||
|
/{min}|{max}|{value}/g,
|
||||||
|
(match) => r.get(match) || "",
|
||||||
|
);
|
||||||
|
};
|
@ -1,30 +1,34 @@
|
|||||||
import { API } from "api/api";
|
|
||||||
import type { ApiErrorResponse } from "api/errors";
|
import type { ApiErrorResponse } from "api/errors";
|
||||||
import { checkAuthorization } from "api/queries/authCheck";
|
import { checkAuthorization } from "api/queries/authCheck";
|
||||||
import {
|
import {
|
||||||
richParameters,
|
|
||||||
templateByName,
|
templateByName,
|
||||||
templateVersionExternalAuth,
|
templateVersionExternalAuth,
|
||||||
templateVersionPresets,
|
templateVersionPresets,
|
||||||
} from "api/queries/templates";
|
} from "api/queries/templates";
|
||||||
import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
|
import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
|
||||||
import type {
|
import type {
|
||||||
TemplateVersionParameter,
|
DynamicParametersRequest,
|
||||||
UserParameter,
|
DynamicParametersResponse,
|
||||||
|
Template,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from "api/typesGenerated";
|
} from "api/typesGenerated";
|
||||||
import { Loader } from "components/Loader/Loader";
|
import { Loader } from "components/Loader/Loader";
|
||||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||||
import { useEffectEvent } from "hooks/hookPolyfills";
|
import { useEffectEvent } from "hooks/hookPolyfills";
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
|
||||||
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
||||||
import { type FC, useCallback, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||||
import { paramsUsedToCreateWorkspace } from "utils/workspace";
|
|
||||||
import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental";
|
import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental";
|
||||||
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
|
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
|
||||||
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
|
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
|
||||||
@ -32,7 +36,6 @@ import {
|
|||||||
type CreateWorkspacePermissions,
|
type CreateWorkspacePermissions,
|
||||||
createWorkspaceChecks,
|
createWorkspaceChecks,
|
||||||
} from "./permissions";
|
} from "./permissions";
|
||||||
|
|
||||||
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
|
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
|
||||||
|
|
||||||
const CreateWorkspacePageExperimental: FC = () => {
|
const CreateWorkspacePageExperimental: FC = () => {
|
||||||
@ -41,7 +44,11 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
const { user: me } = useAuthenticated();
|
const { user: me } = useAuthenticated();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { experiments } = useDashboard();
|
|
||||||
|
const [currentResponse, setCurrentResponse] =
|
||||||
|
useState<DynamicParametersResponse | null>(null);
|
||||||
|
const [wsResponseId, setWSResponseId] = useState<number>(0);
|
||||||
|
const sendMessage = (message: DynamicParametersRequest) => {};
|
||||||
|
|
||||||
const customVersionId = searchParams.get("version") ?? undefined;
|
const customVersionId = searchParams.get("version") ?? undefined;
|
||||||
const defaultName = searchParams.get("name");
|
const defaultName = searchParams.get("name");
|
||||||
@ -72,14 +79,8 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
);
|
);
|
||||||
const realizedVersionId =
|
const realizedVersionId =
|
||||||
customVersionId ?? templateQuery.data?.active_version_id;
|
customVersionId ?? templateQuery.data?.active_version_id;
|
||||||
|
|
||||||
const organizationId = templateQuery.data?.organization_id;
|
const organizationId = templateQuery.data?.organization_id;
|
||||||
const richParametersQuery = useQuery({
|
|
||||||
...richParameters(realizedVersionId ?? ""),
|
|
||||||
enabled: realizedVersionId !== undefined,
|
|
||||||
});
|
|
||||||
const realizedParameters = richParametersQuery.data
|
|
||||||
? richParametersQuery.data.filter(paramsUsedToCreateWorkspace)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
externalAuth,
|
externalAuth,
|
||||||
@ -89,11 +90,8 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
} = useExternalAuth(realizedVersionId);
|
} = useExternalAuth(realizedVersionId);
|
||||||
|
|
||||||
const isLoadingFormData =
|
const isLoadingFormData =
|
||||||
templateQuery.isLoading ||
|
templateQuery.isLoading || permissionsQuery.isLoading;
|
||||||
permissionsQuery.isLoading ||
|
const loadFormDataError = templateQuery.error ?? permissionsQuery.error;
|
||||||
richParametersQuery.isLoading;
|
|
||||||
const loadFormDataError =
|
|
||||||
templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error;
|
|
||||||
|
|
||||||
const title = autoCreateWorkspaceMutation.isLoading
|
const title = autoCreateWorkspaceMutation.isLoading
|
||||||
? "Creating workspace..."
|
? "Creating workspace..."
|
||||||
@ -107,16 +105,7 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Auto fill parameters
|
// Auto fill parameters
|
||||||
const autofillEnabled = experiments.includes("auto-fill-parameters");
|
const autofillParameters = getAutofillParameters(searchParams);
|
||||||
const userParametersQuery = useQuery({
|
|
||||||
queryKey: ["userParameters"],
|
|
||||||
queryFn: () => API.getUserParameters(templateQuery.data!.id),
|
|
||||||
enabled: autofillEnabled && templateQuery.isSuccess,
|
|
||||||
});
|
|
||||||
const autofillParameters = getAutofillParameters(
|
|
||||||
searchParams,
|
|
||||||
userParametersQuery.data ? userParametersQuery.data : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoCreationStartedRef = useRef(false);
|
const autoCreationStartedRef = useRef(false);
|
||||||
const automateWorkspaceCreation = useEffectEvent(async () => {
|
const automateWorkspaceCreation = useEffectEvent(async () => {
|
||||||
@ -146,10 +135,7 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
externalAuth?.every((auth) => auth.optional || auth.authenticated),
|
externalAuth?.every((auth) => auth.optional || auth.authenticated),
|
||||||
);
|
);
|
||||||
|
|
||||||
let autoCreateReady =
|
let autoCreateReady = mode === "auto" && hasAllRequiredExternalAuth;
|
||||||
mode === "auto" &&
|
|
||||||
(!autofillEnabled || userParametersQuery.isSuccess) &&
|
|
||||||
hasAllRequiredExternalAuth;
|
|
||||||
|
|
||||||
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
|
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
|
||||||
if (
|
if (
|
||||||
@ -181,17 +167,29 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [automateWorkspaceCreation, autoCreateReady]);
|
}, [automateWorkspaceCreation, autoCreateReady]);
|
||||||
|
|
||||||
|
const sortedParams = useMemo(() => {
|
||||||
|
if (!currentResponse?.parameters) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [...currentResponse.parameters].sort((a, b) => a.order - b.order);
|
||||||
|
}, [currentResponse?.parameters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle(title)}</title>
|
<title>{pageTitle(title)}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
|
{!currentResponse ||
|
||||||
|
!templateQuery.data ||
|
||||||
|
isLoadingFormData ||
|
||||||
|
isLoadingExternalAuth ||
|
||||||
|
autoCreateReady ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<CreateWorkspacePageViewExperimental
|
<CreateWorkspacePageViewExperimental
|
||||||
mode={mode}
|
mode={mode}
|
||||||
defaultName={defaultName}
|
defaultName={defaultName}
|
||||||
|
diagnostics={currentResponse.diagnostics}
|
||||||
disabledParams={disabledParams}
|
disabledParams={disabledParams}
|
||||||
defaultOwner={me}
|
defaultOwner={me}
|
||||||
autofillParameters={autofillParameters}
|
autofillParameters={autofillParameters}
|
||||||
@ -202,22 +200,25 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
autoCreateWorkspaceMutation.error
|
autoCreateWorkspaceMutation.error
|
||||||
}
|
}
|
||||||
resetMutation={createWorkspaceMutation.reset}
|
resetMutation={createWorkspaceMutation.reset}
|
||||||
template={templateQuery.data!}
|
template={templateQuery.data}
|
||||||
versionId={realizedVersionId}
|
versionId={realizedVersionId}
|
||||||
externalAuth={externalAuth ?? []}
|
externalAuth={externalAuth ?? []}
|
||||||
externalAuthPollingState={externalAuthPollingState}
|
externalAuthPollingState={externalAuthPollingState}
|
||||||
startPollingExternalAuth={startPollingExternalAuth}
|
startPollingExternalAuth={startPollingExternalAuth}
|
||||||
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
|
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
|
||||||
permissions={permissionsQuery.data as CreateWorkspacePermissions}
|
permissions={permissionsQuery.data as CreateWorkspacePermissions}
|
||||||
parameters={realizedParameters as TemplateVersionParameter[]}
|
parameters={sortedParams}
|
||||||
presets={templateVersionPresetsQuery.data ?? []}
|
presets={templateVersionPresetsQuery.data ?? []}
|
||||||
creatingWorkspace={createWorkspaceMutation.isLoading}
|
creatingWorkspace={createWorkspaceMutation.isLoading}
|
||||||
|
setWSResponseId={setWSResponseId}
|
||||||
|
sendMessage={sendMessage}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
}}
|
}}
|
||||||
onSubmit={async (request, owner) => {
|
onSubmit={async (request, owner) => {
|
||||||
|
let workspaceRequest = request;
|
||||||
if (realizedVersionId) {
|
if (realizedVersionId) {
|
||||||
request = {
|
workspaceRequest = {
|
||||||
...request,
|
...request,
|
||||||
template_id: undefined,
|
template_id: undefined,
|
||||||
template_version_id: realizedVersionId,
|
template_version_id: realizedVersionId,
|
||||||
@ -225,7 +226,7 @@ const CreateWorkspacePageExperimental: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await createWorkspaceMutation.mutateAsync({
|
const workspace = await createWorkspaceMutation.mutateAsync({
|
||||||
...request,
|
...workspaceRequest,
|
||||||
userId: owner.id,
|
userId: owner.id,
|
||||||
});
|
});
|
||||||
onCreateWorkspace(workspace);
|
onCreateWorkspace(workspace);
|
||||||
@ -286,13 +287,7 @@ const useExternalAuth = (versionId: string | undefined) => {
|
|||||||
|
|
||||||
const getAutofillParameters = (
|
const getAutofillParameters = (
|
||||||
urlSearchParams: URLSearchParams,
|
urlSearchParams: URLSearchParams,
|
||||||
userParameters: UserParameter[],
|
|
||||||
): AutofillBuildParameter[] => {
|
): AutofillBuildParameter[] => {
|
||||||
const userParamMap = userParameters.reduce((acc, param) => {
|
|
||||||
acc.set(param.name, param);
|
|
||||||
return acc;
|
|
||||||
}, new Map<string, UserParameter>());
|
|
||||||
|
|
||||||
const buildValues: AutofillBuildParameter[] = Array.from(
|
const buildValues: AutofillBuildParameter[] = Array.from(
|
||||||
urlSearchParams.keys(),
|
urlSearchParams.keys(),
|
||||||
)
|
)
|
||||||
@ -300,18 +295,8 @@ const getAutofillParameters = (
|
|||||||
.map((key) => {
|
.map((key) => {
|
||||||
const name = key.replace("param.", "");
|
const name = key.replace("param.", "");
|
||||||
const value = urlSearchParams.get(key) ?? "";
|
const value = urlSearchParams.get(key) ?? "";
|
||||||
// URL should take precedence over user parameters
|
|
||||||
userParamMap.delete(name);
|
|
||||||
return { name, value, source: "url" };
|
return { name, value, source: "url" };
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const param of userParamMap.values()) {
|
|
||||||
buildValues.push({
|
|
||||||
name: param.name,
|
|
||||||
value: param.value,
|
|
||||||
source: "user_history",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return buildValues;
|
return buildValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import type { Interpolation, Theme } from "@emotion/react";
|
|
||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
|
import type {
|
||||||
|
DynamicParametersRequest,
|
||||||
|
PreviewDiagnostics,
|
||||||
|
PreviewParameter,
|
||||||
|
} from "api/typesGenerated";
|
||||||
import { Alert } from "components/Alert/Alert";
|
import { Alert } from "components/Alert/Alert";
|
||||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||||
import { Avatar } from "components/Avatar/Avatar";
|
import { Avatar } from "components/Avatar/Avatar";
|
||||||
@ -9,12 +13,18 @@ import { SelectFilter } from "components/Filter/SelectFilter";
|
|||||||
import { Input } from "components/Input/Input";
|
import { Input } from "components/Input/Input";
|
||||||
import { Label } from "components/Label/Label";
|
import { Label } from "components/Label/Label";
|
||||||
import { Pill } from "components/Pill/Pill";
|
import { Pill } from "components/Pill/Pill";
|
||||||
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
|
|
||||||
import { Spinner } from "components/Spinner/Spinner";
|
import { Spinner } from "components/Spinner/Spinner";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
|
import { Switch } from "components/Switch/Switch";
|
||||||
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||||
import { type FormikContextType, useFormik } from "formik";
|
import { type FormikContextType, useFormik } from "formik";
|
||||||
|
import { useDebouncedFunction } from "hooks/debounce";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DynamicParameter,
|
||||||
|
getInitialParameterValues,
|
||||||
|
useValidationSchemaForDynamicParameters,
|
||||||
|
} from "modules/workspaces/DynamicParameter/DynamicParameter";
|
||||||
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
@ -25,11 +35,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getFormHelpers, nameValidator } from "utils/formUtils";
|
import { getFormHelpers, nameValidator } from "utils/formUtils";
|
||||||
import {
|
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||||
type AutofillBuildParameter,
|
|
||||||
getInitialRichParameterValues,
|
|
||||||
useValidationSchemaForRichParameters,
|
|
||||||
} from "utils/richParameters";
|
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import type {
|
import type {
|
||||||
CreateWorkspaceMode,
|
CreateWorkspaceMode,
|
||||||
@ -37,65 +43,67 @@ import type {
|
|||||||
} from "./CreateWorkspacePage";
|
} from "./CreateWorkspacePage";
|
||||||
import { ExternalAuthButton } from "./ExternalAuthButton";
|
import { ExternalAuthButton } from "./ExternalAuthButton";
|
||||||
import type { CreateWorkspacePermissions } from "./permissions";
|
import type { CreateWorkspacePermissions } from "./permissions";
|
||||||
export const Language = {
|
|
||||||
duplicationWarning:
|
|
||||||
"Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface CreateWorkspacePageViewExperimentalProps {
|
export interface CreateWorkspacePageViewExperimentalProps {
|
||||||
mode: CreateWorkspaceMode;
|
autofillParameters: AutofillBuildParameter[];
|
||||||
|
creatingWorkspace: boolean;
|
||||||
defaultName?: string | null;
|
defaultName?: string | null;
|
||||||
|
defaultOwner: TypesGen.User;
|
||||||
|
diagnostics: PreviewDiagnostics;
|
||||||
disabledParams?: string[];
|
disabledParams?: string[];
|
||||||
error: unknown;
|
error: unknown;
|
||||||
resetMutation: () => void;
|
|
||||||
defaultOwner: TypesGen.User;
|
|
||||||
template: TypesGen.Template;
|
|
||||||
versionId?: string;
|
|
||||||
externalAuth: TypesGen.TemplateVersionExternalAuth[];
|
externalAuth: TypesGen.TemplateVersionExternalAuth[];
|
||||||
externalAuthPollingState: ExternalAuthPollingState;
|
externalAuthPollingState: ExternalAuthPollingState;
|
||||||
startPollingExternalAuth: () => void;
|
|
||||||
hasAllRequiredExternalAuth: boolean;
|
hasAllRequiredExternalAuth: boolean;
|
||||||
parameters: TypesGen.TemplateVersionParameter[];
|
mode: CreateWorkspaceMode;
|
||||||
autofillParameters: AutofillBuildParameter[];
|
parameters: PreviewParameter[];
|
||||||
presets: TypesGen.Preset[];
|
|
||||||
permissions: CreateWorkspacePermissions;
|
permissions: CreateWorkspacePermissions;
|
||||||
creatingWorkspace: boolean;
|
presets: TypesGen.Preset[];
|
||||||
|
template: TypesGen.Template;
|
||||||
|
versionId?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
req: TypesGen.CreateWorkspaceRequest,
|
req: TypesGen.CreateWorkspaceRequest,
|
||||||
owner: TypesGen.User,
|
owner: TypesGen.User,
|
||||||
) => void;
|
) => void;
|
||||||
|
resetMutation: () => void;
|
||||||
|
sendMessage: (message: DynamicParametersRequest) => void;
|
||||||
|
setWSResponseId: (value: React.SetStateAction<number>) => void;
|
||||||
|
startPollingExternalAuth: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateWorkspacePageViewExperimental: FC<
|
export const CreateWorkspacePageViewExperimental: FC<
|
||||||
CreateWorkspacePageViewExperimentalProps
|
CreateWorkspacePageViewExperimentalProps
|
||||||
> = ({
|
> = ({
|
||||||
mode,
|
autofillParameters,
|
||||||
|
creatingWorkspace,
|
||||||
defaultName,
|
defaultName,
|
||||||
|
defaultOwner,
|
||||||
|
diagnostics,
|
||||||
disabledParams,
|
disabledParams,
|
||||||
error,
|
error,
|
||||||
resetMutation,
|
|
||||||
defaultOwner,
|
|
||||||
template,
|
|
||||||
versionId,
|
|
||||||
externalAuth,
|
externalAuth,
|
||||||
externalAuthPollingState,
|
externalAuthPollingState,
|
||||||
startPollingExternalAuth,
|
|
||||||
hasAllRequiredExternalAuth,
|
hasAllRequiredExternalAuth,
|
||||||
|
mode,
|
||||||
parameters,
|
parameters,
|
||||||
autofillParameters,
|
|
||||||
presets = [],
|
|
||||||
permissions,
|
permissions,
|
||||||
creatingWorkspace,
|
presets = [],
|
||||||
|
template,
|
||||||
|
versionId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
resetMutation,
|
||||||
|
sendMessage,
|
||||||
|
setWSResponseId,
|
||||||
|
startPollingExternalAuth,
|
||||||
}) => {
|
}) => {
|
||||||
const [owner, setOwner] = useState(defaultOwner);
|
const [owner, setOwner] = useState(defaultOwner);
|
||||||
const [suggestedName, setSuggestedName] = useState(() =>
|
const [suggestedName, setSuggestedName] = useState(() =>
|
||||||
generateWorkspaceName(),
|
generateWorkspaceName(),
|
||||||
);
|
);
|
||||||
|
const [showPresetParameters, setShowPresetParameters] = useState(false);
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
|
||||||
const rerollSuggestedName = useCallback(() => {
|
const rerollSuggestedName = useCallback(() => {
|
||||||
setSuggestedName(() => generateWorkspaceName());
|
setSuggestedName(() => generateWorkspaceName());
|
||||||
}, []);
|
}, []);
|
||||||
@ -105,16 +113,19 @@ export const CreateWorkspacePageViewExperimental: FC<
|
|||||||
initialValues: {
|
initialValues: {
|
||||||
name: defaultName ?? "",
|
name: defaultName ?? "",
|
||||||
template_id: template.id,
|
template_id: template.id,
|
||||||
rich_parameter_values: getInitialRichParameterValues(
|
rich_parameter_values: getInitialParameterValues(
|
||||||
parameters,
|
parameters,
|
||||||
autofillParameters,
|
autofillParameters,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
validationSchema: Yup.object({
|
validationSchema: Yup.object({
|
||||||
name: nameValidator("Workspace Name"),
|
name: nameValidator("Workspace Name"),
|
||||||
rich_parameter_values: useValidationSchemaForRichParameters(parameters),
|
rich_parameter_values:
|
||||||
|
useValidationSchemaForDynamicParameters(parameters),
|
||||||
}),
|
}),
|
||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
|
validateOnChange: false,
|
||||||
|
validateOnBlur: true,
|
||||||
onSubmit: (request) => {
|
onSubmit: (request) => {
|
||||||
if (!hasAllRequiredExternalAuth) {
|
if (!hasAllRequiredExternalAuth) {
|
||||||
return;
|
return;
|
||||||
@ -195,10 +206,64 @@ export const CreateWorkspacePageViewExperimental: FC<
|
|||||||
presetOptions,
|
presetOptions,
|
||||||
selectedPresetIndex,
|
selectedPresetIndex,
|
||||||
presets,
|
presets,
|
||||||
parameters,
|
|
||||||
form.setFieldValue,
|
form.setFieldValue,
|
||||||
|
parameters,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const sendDynamicParamsRequest = (
|
||||||
|
parameter: PreviewParameter,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const formInputs = Object.fromEntries(
|
||||||
|
form.values.rich_parameter_values?.map((value) => {
|
||||||
|
return [value.name, value.value];
|
||||||
|
}) ?? [],
|
||||||
|
);
|
||||||
|
// Update the input for the changed parameter
|
||||||
|
formInputs[parameter.name] = value;
|
||||||
|
|
||||||
|
setWSResponseId((prevId) => {
|
||||||
|
const newId = prevId + 1;
|
||||||
|
const request: DynamicParametersRequest = {
|
||||||
|
id: newId,
|
||||||
|
inputs: formInputs,
|
||||||
|
};
|
||||||
|
sendMessage(request);
|
||||||
|
return newId;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { debounced: handleChangeDebounced } = useDebouncedFunction(
|
||||||
|
async (
|
||||||
|
parameter: PreviewParameter,
|
||||||
|
parameterField: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
await form.setFieldValue(parameterField, {
|
||||||
|
name: parameter.form_type,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
sendDynamicParamsRequest(parameter, value);
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = async (
|
||||||
|
parameter: PreviewParameter,
|
||||||
|
parameterField: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
if (parameter.form_type === "input" || parameter.form_type === "textarea") {
|
||||||
|
handleChangeDebounced(parameter, parameterField, value);
|
||||||
|
} else {
|
||||||
|
await form.setFieldValue(parameterField, {
|
||||||
|
name: parameter.form_type,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
sendDynamicParamsRequest(parameter, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute sticky top-5 ml-10">
|
<div className="absolute sticky top-5 ml-10">
|
||||||
@ -244,7 +309,8 @@ export const CreateWorkspacePageViewExperimental: FC<
|
|||||||
dismissible
|
dismissible
|
||||||
data-testid="duplication-warning"
|
data-testid="duplication-warning"
|
||||||
>
|
>
|
||||||
{Language.duplicationWarning}
|
Duplicating a workspace only copies its parameters. No state from
|
||||||
|
the old workspace is copied over.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -353,9 +419,8 @@ export const CreateWorkspacePageViewExperimental: FC<
|
|||||||
<hgroup>
|
<hgroup>
|
||||||
<h2 className="text-xl font-semibold m-0">Parameters</h2>
|
<h2 className="text-xl font-semibold m-0">Parameters</h2>
|
||||||
<p className="text-sm text-content-secondary m-0">
|
<p className="text-sm text-content-secondary m-0">
|
||||||
These are the settings used by your template. Please note that
|
These are the settings used by your template. Immutable
|
||||||
immutable parameters cannot be modified once the workspace is
|
parameters cannot be modified once the workspace is created.
|
||||||
created.
|
|
||||||
</p>
|
</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
{presets.length > 0 && (
|
{presets.length > 0 && (
|
||||||
@ -382,6 +447,16 @@ export const CreateWorkspacePageViewExperimental: FC<
|
|||||||
selectedOption={presetOptions[selectedPresetIndex]}
|
selectedOption={presetOptions[selectedPresetIndex]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="show-preset-parameters"
|
||||||
|
checked={showPresetParameters}
|
||||||
|
onCheckedChange={setShowPresetParameters}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-preset-parameters">
|
||||||
|
Show preset parameters
|
||||||
|
</Label>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@ -390,26 +465,32 @@ export const CreateWorkspacePageViewExperimental: FC<
|
|||||||
{parameters.map((parameter, index) => {
|
{parameters.map((parameter, index) => {
|
||||||
const parameterField = `rich_parameter_values.${index}`;
|
const parameterField = `rich_parameter_values.${index}`;
|
||||||
const parameterInputName = `${parameterField}.value`;
|
const parameterInputName = `${parameterField}.value`;
|
||||||
|
const isPresetParameter = presetParameterNames.includes(
|
||||||
|
parameter.name,
|
||||||
|
);
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
disabledParams?.includes(
|
disabledParams?.includes(
|
||||||
parameter.name.toLowerCase().replace(/ /g, "_"),
|
parameter.name.toLowerCase().replace(/ /g, "_"),
|
||||||
) ||
|
) ||
|
||||||
|
(parameter.styling as { disabled?: boolean })?.disabled ||
|
||||||
creatingWorkspace ||
|
creatingWorkspace ||
|
||||||
presetParameterNames.includes(parameter.name);
|
isPresetParameter;
|
||||||
|
|
||||||
|
// Hide preset parameters if showPresetParameters is false
|
||||||
|
if (!showPresetParameters && isPresetParameter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RichParameterInput
|
<DynamicParameter
|
||||||
{...getFieldHelpers(parameterInputName)}
|
{...getFieldHelpers(parameterInputName)}
|
||||||
onChange={async (value) => {
|
|
||||||
await form.setFieldValue(parameterField, {
|
|
||||||
name: parameter.name,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
key={parameter.name}
|
key={parameter.name}
|
||||||
parameter={parameter}
|
parameter={parameter}
|
||||||
parameterAutofill={autofillByName[parameter.name]}
|
onChange={(value) =>
|
||||||
|
handleChange(parameter, parameterField, value)
|
||||||
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
isPreset={isPresetParameter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -431,10 +512,3 @@ export const CreateWorkspacePageViewExperimental: FC<
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = {
|
|
||||||
description: (theme) => ({
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
}),
|
|
||||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
||||||
|
@ -257,7 +257,7 @@ export const IdpOrgSyncPageView: FC<IdpSyncPageViewProps> = ({
|
|||||||
className="min-w-60 max-w-3xl"
|
className="min-w-60 max-w-3xl"
|
||||||
value={coderOrgs}
|
value={coderOrgs}
|
||||||
onChange={setCoderOrgs}
|
onChange={setCoderOrgs}
|
||||||
defaultOptions={organizations.map((org) => ({
|
options={organizations.map((org) => ({
|
||||||
label: org.display_name,
|
label: org.display_name,
|
||||||
value: org.id,
|
value: org.id,
|
||||||
}))}
|
}))}
|
||||||
|
@ -259,7 +259,7 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
|
|||||||
className="min-w-60 max-w-3xl"
|
className="min-w-60 max-w-3xl"
|
||||||
value={coderGroups}
|
value={coderGroups}
|
||||||
onChange={setCoderGroups}
|
onChange={setCoderGroups}
|
||||||
defaultOptions={groups.map((group) => ({
|
options={groups.map((group) => ({
|
||||||
label: group.display_name || group.name,
|
label: group.display_name || group.name,
|
||||||
value: group.id,
|
value: group.id,
|
||||||
}))}
|
}))}
|
||||||
|
@ -200,7 +200,7 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
|
|||||||
className="min-w-60 max-w-3xl"
|
className="min-w-60 max-w-3xl"
|
||||||
value={coderRoles}
|
value={coderRoles}
|
||||||
onChange={setCoderRoles}
|
onChange={setCoderRoles}
|
||||||
defaultOptions={roles.map((role) => ({
|
options={roles.map((role) => ({
|
||||||
label: role.display_name || role.name,
|
label: role.display_name || role.name,
|
||||||
value: role.name,
|
value: role.name,
|
||||||
}))}
|
}))}
|
||||||
|
Reference in New Issue
Block a user