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";
|
||||
|
||||
/**
|
||||
* To allow for an indeterminate state the checkbox must be controlled, otherwise the checked prop would remain undefined
|
||||
*/
|
||||
export const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
|
@ -16,7 +16,7 @@ const meta: Meta<typeof MultiSelectCombobox> = {
|
||||
All organizations selected
|
||||
</p>
|
||||
),
|
||||
defaultOptions: organizations.map((org) => ({
|
||||
options: organizations.map((org) => ({
|
||||
label: org.display_name,
|
||||
value: org.id,
|
||||
})),
|
||||
|
@ -203,9 +203,11 @@ export const MultiSelectCombobox = forwardRef<
|
||||
const [open, setOpen] = useState(false);
|
||||
const [onScrollbar, setOnScrollbar] = 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>(
|
||||
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-visible:ring-offset-4 focus-visible:ring-offset-surface-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,
|
||||
)}
|
||||
{...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 { checkAuthorization } from "api/queries/authCheck";
|
||||
import {
|
||||
richParameters,
|
||||
templateByName,
|
||||
templateVersionExternalAuth,
|
||||
templateVersionPresets,
|
||||
} from "api/queries/templates";
|
||||
import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
|
||||
import type {
|
||||
TemplateVersionParameter,
|
||||
UserParameter,
|
||||
DynamicParametersRequest,
|
||||
DynamicParametersResponse,
|
||||
Template,
|
||||
Workspace,
|
||||
} from "api/typesGenerated";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||
import { useEffectEvent } from "hooks/hookPolyfills";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
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 { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
import { paramsUsedToCreateWorkspace } from "utils/workspace";
|
||||
import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental";
|
||||
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
|
||||
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
|
||||
@ -32,7 +36,6 @@ import {
|
||||
type CreateWorkspacePermissions,
|
||||
createWorkspaceChecks,
|
||||
} from "./permissions";
|
||||
|
||||
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
|
||||
|
||||
const CreateWorkspacePageExperimental: FC = () => {
|
||||
@ -41,7 +44,11 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
const { user: me } = useAuthenticated();
|
||||
const navigate = useNavigate();
|
||||
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 defaultName = searchParams.get("name");
|
||||
@ -72,14 +79,8 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
);
|
||||
const realizedVersionId =
|
||||
customVersionId ?? templateQuery.data?.active_version_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 {
|
||||
externalAuth,
|
||||
@ -89,11 +90,8 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
} = useExternalAuth(realizedVersionId);
|
||||
|
||||
const isLoadingFormData =
|
||||
templateQuery.isLoading ||
|
||||
permissionsQuery.isLoading ||
|
||||
richParametersQuery.isLoading;
|
||||
const loadFormDataError =
|
||||
templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error;
|
||||
templateQuery.isLoading || permissionsQuery.isLoading;
|
||||
const loadFormDataError = templateQuery.error ?? permissionsQuery.error;
|
||||
|
||||
const title = autoCreateWorkspaceMutation.isLoading
|
||||
? "Creating workspace..."
|
||||
@ -107,16 +105,7 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
);
|
||||
|
||||
// Auto fill parameters
|
||||
const autofillEnabled = experiments.includes("auto-fill-parameters");
|
||||
const userParametersQuery = useQuery({
|
||||
queryKey: ["userParameters"],
|
||||
queryFn: () => API.getUserParameters(templateQuery.data!.id),
|
||||
enabled: autofillEnabled && templateQuery.isSuccess,
|
||||
});
|
||||
const autofillParameters = getAutofillParameters(
|
||||
searchParams,
|
||||
userParametersQuery.data ? userParametersQuery.data : [],
|
||||
);
|
||||
const autofillParameters = getAutofillParameters(searchParams);
|
||||
|
||||
const autoCreationStartedRef = useRef(false);
|
||||
const automateWorkspaceCreation = useEffectEvent(async () => {
|
||||
@ -146,10 +135,7 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
externalAuth?.every((auth) => auth.optional || auth.authenticated),
|
||||
);
|
||||
|
||||
let autoCreateReady =
|
||||
mode === "auto" &&
|
||||
(!autofillEnabled || userParametersQuery.isSuccess) &&
|
||||
hasAllRequiredExternalAuth;
|
||||
let autoCreateReady = mode === "auto" && hasAllRequiredExternalAuth;
|
||||
|
||||
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
|
||||
if (
|
||||
@ -181,17 +167,29 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
}
|
||||
}, [automateWorkspaceCreation, autoCreateReady]);
|
||||
|
||||
const sortedParams = useMemo(() => {
|
||||
if (!currentResponse?.parameters) {
|
||||
return [];
|
||||
}
|
||||
return [...currentResponse.parameters].sort((a, b) => a.order - b.order);
|
||||
}, [currentResponse?.parameters]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle(title)}</title>
|
||||
</Helmet>
|
||||
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
|
||||
{!currentResponse ||
|
||||
!templateQuery.data ||
|
||||
isLoadingFormData ||
|
||||
isLoadingExternalAuth ||
|
||||
autoCreateReady ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<CreateWorkspacePageViewExperimental
|
||||
mode={mode}
|
||||
defaultName={defaultName}
|
||||
diagnostics={currentResponse.diagnostics}
|
||||
disabledParams={disabledParams}
|
||||
defaultOwner={me}
|
||||
autofillParameters={autofillParameters}
|
||||
@ -202,22 +200,25 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
autoCreateWorkspaceMutation.error
|
||||
}
|
||||
resetMutation={createWorkspaceMutation.reset}
|
||||
template={templateQuery.data!}
|
||||
template={templateQuery.data}
|
||||
versionId={realizedVersionId}
|
||||
externalAuth={externalAuth ?? []}
|
||||
externalAuthPollingState={externalAuthPollingState}
|
||||
startPollingExternalAuth={startPollingExternalAuth}
|
||||
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
|
||||
permissions={permissionsQuery.data as CreateWorkspacePermissions}
|
||||
parameters={realizedParameters as TemplateVersionParameter[]}
|
||||
parameters={sortedParams}
|
||||
presets={templateVersionPresetsQuery.data ?? []}
|
||||
creatingWorkspace={createWorkspaceMutation.isLoading}
|
||||
setWSResponseId={setWSResponseId}
|
||||
sendMessage={sendMessage}
|
||||
onCancel={() => {
|
||||
navigate(-1);
|
||||
}}
|
||||
onSubmit={async (request, owner) => {
|
||||
let workspaceRequest = request;
|
||||
if (realizedVersionId) {
|
||||
request = {
|
||||
workspaceRequest = {
|
||||
...request,
|
||||
template_id: undefined,
|
||||
template_version_id: realizedVersionId,
|
||||
@ -225,7 +226,7 @@ const CreateWorkspacePageExperimental: FC = () => {
|
||||
}
|
||||
|
||||
const workspace = await createWorkspaceMutation.mutateAsync({
|
||||
...request,
|
||||
...workspaceRequest,
|
||||
userId: owner.id,
|
||||
});
|
||||
onCreateWorkspace(workspace);
|
||||
@ -286,13 +287,7 @@ const useExternalAuth = (versionId: string | undefined) => {
|
||||
|
||||
const getAutofillParameters = (
|
||||
urlSearchParams: URLSearchParams,
|
||||
userParameters: UserParameter[],
|
||||
): AutofillBuildParameter[] => {
|
||||
const userParamMap = userParameters.reduce((acc, param) => {
|
||||
acc.set(param.name, param);
|
||||
return acc;
|
||||
}, new Map<string, UserParameter>());
|
||||
|
||||
const buildValues: AutofillBuildParameter[] = Array.from(
|
||||
urlSearchParams.keys(),
|
||||
)
|
||||
@ -300,18 +295,8 @@ const getAutofillParameters = (
|
||||
.map((key) => {
|
||||
const name = key.replace("param.", "");
|
||||
const value = urlSearchParams.get(key) ?? "";
|
||||
// URL should take precedence over user parameters
|
||||
userParamMap.delete(name);
|
||||
return { name, value, source: "url" };
|
||||
});
|
||||
|
||||
for (const param of userParamMap.values()) {
|
||||
buildValues.push({
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
source: "user_history",
|
||||
});
|
||||
}
|
||||
return buildValues;
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,9 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type {
|
||||
DynamicParametersRequest,
|
||||
PreviewDiagnostics,
|
||||
PreviewParameter,
|
||||
} from "api/typesGenerated";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
@ -9,12 +13,18 @@ import { SelectFilter } from "components/Filter/SelectFilter";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Switch } from "components/Switch/Switch";
|
||||
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||
import { type FormikContextType, useFormik } from "formik";
|
||||
import { useDebouncedFunction } from "hooks/debounce";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
DynamicParameter,
|
||||
getInitialParameterValues,
|
||||
useValidationSchemaForDynamicParameters,
|
||||
} from "modules/workspaces/DynamicParameter/DynamicParameter";
|
||||
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
||||
import {
|
||||
type FC,
|
||||
@ -25,11 +35,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { getFormHelpers, nameValidator } from "utils/formUtils";
|
||||
import {
|
||||
type AutofillBuildParameter,
|
||||
getInitialRichParameterValues,
|
||||
useValidationSchemaForRichParameters,
|
||||
} from "utils/richParameters";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
import * as Yup from "yup";
|
||||
import type {
|
||||
CreateWorkspaceMode,
|
||||
@ -37,65 +43,67 @@ import type {
|
||||
} from "./CreateWorkspacePage";
|
||||
import { ExternalAuthButton } from "./ExternalAuthButton";
|
||||
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 {
|
||||
mode: CreateWorkspaceMode;
|
||||
autofillParameters: AutofillBuildParameter[];
|
||||
creatingWorkspace: boolean;
|
||||
defaultName?: string | null;
|
||||
defaultOwner: TypesGen.User;
|
||||
diagnostics: PreviewDiagnostics;
|
||||
disabledParams?: string[];
|
||||
error: unknown;
|
||||
resetMutation: () => void;
|
||||
defaultOwner: TypesGen.User;
|
||||
template: TypesGen.Template;
|
||||
versionId?: string;
|
||||
externalAuth: TypesGen.TemplateVersionExternalAuth[];
|
||||
externalAuthPollingState: ExternalAuthPollingState;
|
||||
startPollingExternalAuth: () => void;
|
||||
hasAllRequiredExternalAuth: boolean;
|
||||
parameters: TypesGen.TemplateVersionParameter[];
|
||||
autofillParameters: AutofillBuildParameter[];
|
||||
presets: TypesGen.Preset[];
|
||||
mode: CreateWorkspaceMode;
|
||||
parameters: PreviewParameter[];
|
||||
permissions: CreateWorkspacePermissions;
|
||||
creatingWorkspace: boolean;
|
||||
presets: TypesGen.Preset[];
|
||||
template: TypesGen.Template;
|
||||
versionId?: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (
|
||||
req: TypesGen.CreateWorkspaceRequest,
|
||||
owner: TypesGen.User,
|
||||
) => void;
|
||||
resetMutation: () => void;
|
||||
sendMessage: (message: DynamicParametersRequest) => void;
|
||||
setWSResponseId: (value: React.SetStateAction<number>) => void;
|
||||
startPollingExternalAuth: () => void;
|
||||
}
|
||||
|
||||
export const CreateWorkspacePageViewExperimental: FC<
|
||||
CreateWorkspacePageViewExperimentalProps
|
||||
> = ({
|
||||
mode,
|
||||
autofillParameters,
|
||||
creatingWorkspace,
|
||||
defaultName,
|
||||
defaultOwner,
|
||||
diagnostics,
|
||||
disabledParams,
|
||||
error,
|
||||
resetMutation,
|
||||
defaultOwner,
|
||||
template,
|
||||
versionId,
|
||||
externalAuth,
|
||||
externalAuthPollingState,
|
||||
startPollingExternalAuth,
|
||||
hasAllRequiredExternalAuth,
|
||||
mode,
|
||||
parameters,
|
||||
autofillParameters,
|
||||
presets = [],
|
||||
permissions,
|
||||
creatingWorkspace,
|
||||
presets = [],
|
||||
template,
|
||||
versionId,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
resetMutation,
|
||||
sendMessage,
|
||||
setWSResponseId,
|
||||
startPollingExternalAuth,
|
||||
}) => {
|
||||
const [owner, setOwner] = useState(defaultOwner);
|
||||
const [suggestedName, setSuggestedName] = useState(() =>
|
||||
generateWorkspaceName(),
|
||||
);
|
||||
const [showPresetParameters, setShowPresetParameters] = useState(false);
|
||||
const id = useId();
|
||||
|
||||
const rerollSuggestedName = useCallback(() => {
|
||||
setSuggestedName(() => generateWorkspaceName());
|
||||
}, []);
|
||||
@ -105,16 +113,19 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
initialValues: {
|
||||
name: defaultName ?? "",
|
||||
template_id: template.id,
|
||||
rich_parameter_values: getInitialRichParameterValues(
|
||||
rich_parameter_values: getInitialParameterValues(
|
||||
parameters,
|
||||
autofillParameters,
|
||||
),
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: nameValidator("Workspace Name"),
|
||||
rich_parameter_values: useValidationSchemaForRichParameters(parameters),
|
||||
rich_parameter_values:
|
||||
useValidationSchemaForDynamicParameters(parameters),
|
||||
}),
|
||||
enableReinitialize: true,
|
||||
validateOnChange: false,
|
||||
validateOnBlur: true,
|
||||
onSubmit: (request) => {
|
||||
if (!hasAllRequiredExternalAuth) {
|
||||
return;
|
||||
@ -195,10 +206,64 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
presetOptions,
|
||||
selectedPresetIndex,
|
||||
presets,
|
||||
parameters,
|
||||
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 (
|
||||
<>
|
||||
<div className="absolute sticky top-5 ml-10">
|
||||
@ -244,7 +309,8 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
dismissible
|
||||
data-testid="duplication-warning"
|
||||
>
|
||||
{Language.duplicationWarning}
|
||||
Duplicating a workspace only copies its parameters. No state from
|
||||
the old workspace is copied over.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@ -353,9 +419,8 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
<hgroup>
|
||||
<h2 className="text-xl font-semibold m-0">Parameters</h2>
|
||||
<p className="text-sm text-content-secondary m-0">
|
||||
These are the settings used by your template. Please note that
|
||||
immutable parameters cannot be modified once the workspace is
|
||||
created.
|
||||
These are the settings used by your template. Immutable
|
||||
parameters cannot be modified once the workspace is created.
|
||||
</p>
|
||||
</hgroup>
|
||||
{presets.length > 0 && (
|
||||
@ -382,6 +447,16 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
selectedOption={presetOptions[selectedPresetIndex]}
|
||||
/>
|
||||
</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>
|
||||
</Stack>
|
||||
)}
|
||||
@ -390,26 +465,32 @@ export const CreateWorkspacePageViewExperimental: FC<
|
||||
{parameters.map((parameter, index) => {
|
||||
const parameterField = `rich_parameter_values.${index}`;
|
||||
const parameterInputName = `${parameterField}.value`;
|
||||
const isPresetParameter = presetParameterNames.includes(
|
||||
parameter.name,
|
||||
);
|
||||
const isDisabled =
|
||||
disabledParams?.includes(
|
||||
parameter.name.toLowerCase().replace(/ /g, "_"),
|
||||
) ||
|
||||
(parameter.styling as { disabled?: boolean })?.disabled ||
|
||||
creatingWorkspace ||
|
||||
presetParameterNames.includes(parameter.name);
|
||||
isPresetParameter;
|
||||
|
||||
// Hide preset parameters if showPresetParameters is false
|
||||
if (!showPresetParameters && isPresetParameter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RichParameterInput
|
||||
<DynamicParameter
|
||||
{...getFieldHelpers(parameterInputName)}
|
||||
onChange={async (value) => {
|
||||
await form.setFieldValue(parameterField, {
|
||||
name: parameter.name,
|
||||
value,
|
||||
});
|
||||
}}
|
||||
key={parameter.name}
|
||||
parameter={parameter}
|
||||
parameterAutofill={autofillByName[parameter.name]}
|
||||
onChange={(value) =>
|
||||
handleChange(parameter, parameterField, value)
|
||||
}
|
||||
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"
|
||||
value={coderOrgs}
|
||||
onChange={setCoderOrgs}
|
||||
defaultOptions={organizations.map((org) => ({
|
||||
options={organizations.map((org) => ({
|
||||
label: org.display_name,
|
||||
value: org.id,
|
||||
}))}
|
||||
|
@ -259,7 +259,7 @@ export const IdpGroupSyncForm: FC<IdpGroupSyncFormProps> = ({
|
||||
className="min-w-60 max-w-3xl"
|
||||
value={coderGroups}
|
||||
onChange={setCoderGroups}
|
||||
defaultOptions={groups.map((group) => ({
|
||||
options={groups.map((group) => ({
|
||||
label: group.display_name || group.name,
|
||||
value: group.id,
|
||||
}))}
|
||||
|
@ -200,7 +200,7 @@ export const IdpRoleSyncForm: FC<IdpRoleSyncFormProps> = ({
|
||||
className="min-w-60 max-w-3xl"
|
||||
value={coderRoles}
|
||||
onChange={setCoderRoles}
|
||||
defaultOptions={roles.map((role) => ({
|
||||
options={roles.map((role) => ({
|
||||
label: role.display_name || role.name,
|
||||
value: role.name,
|
||||
}))}
|
||||
|
Reference in New Issue
Block a user