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:
Jaayden Halko
2025-04-16 10:00:25 +01:00
committed by GitHub
parent a7646d1524
commit 1db70bef5d
10 changed files with 760 additions and 117 deletions

View File

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

View File

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

View File

@ -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),
);

View File

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

View File

@ -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) || "",
);
};

View File

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

View File

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

View File

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

View File

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

View File

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