feat: setup url autofill for dynamic parameters (#17739)

resolves coder/preview#80

Parameter autofill allows setting parameters from the url using the
format param.[param name]=["purple","green"]

Example:

http://localhost:8080/templates/coder/scratch/workspace?param.list=%5b%22purple%22%2c%22green%22%5d%0a

The goal is to maintain feature parity of for autofill with dynamic
parameters.

Note: user history autofill is no longer being used and is being
removed.
This commit is contained in:
Jaayden Halko
2025-05-16 18:05:33 -04:00
committed by GitHub
parent 87a1ebc460
commit d6cb9b49b7
4 changed files with 308 additions and 160 deletions
+5 -2
View File
@@ -15,10 +15,13 @@ export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
id?: string;
}
>(({ className, children, id, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
id={id}
className={cn(
`flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm
@@ -32,23 +32,29 @@ import {
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { Info, Settings, TriangleAlert } from "lucide-react";
import { type FC, useEffect, useId, useState } from "react";
import { useDebouncedValue } from "hooks/debounce";
import { useEffectEvent } from "hooks/hookPolyfills";
import { Info, LinkIcon, Settings, TriangleAlert } from "lucide-react";
import { type FC, useEffect, useId, useRef, useState } from "react";
import type { AutofillBuildParameter } from "utils/richParameters";
import * as Yup from "yup";
export interface DynamicParameterProps {
parameter: PreviewParameter;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
isPreset?: boolean;
autofill: boolean;
}
export const DynamicParameter: FC<DynamicParameterProps> = ({
parameter,
value,
onChange,
disabled,
isPreset,
autofill = false,
}) => {
const id = useId();
@@ -57,14 +63,31 @@ export const DynamicParameter: FC<DynamicParameterProps> = ({
className="flex flex-col gap-2"
data-testid={`parameter-field-${parameter.name}`}
>
<ParameterLabel parameter={parameter} isPreset={isPreset} />
<ParameterLabel
id={id}
parameter={parameter}
isPreset={isPreset}
autofill={autofill}
/>
<div className="max-w-lg">
<ParameterField
parameter={parameter}
onChange={onChange}
disabled={disabled}
id={id}
/>
{parameter.form_type === "input" ||
parameter.form_type === "textarea" ? (
<DebouncedParameterField
id={id}
parameter={parameter}
value={value}
onChange={onChange}
disabled={disabled}
/>
) : (
<ParameterField
id={id}
parameter={parameter}
value={value}
onChange={onChange}
disabled={disabled}
/>
)}
</div>
{parameter.diagnostics.length > 0 && (
<ParameterDiagnostics diagnostics={parameter.diagnostics} />
@@ -76,10 +99,16 @@ export const DynamicParameter: FC<DynamicParameterProps> = ({
interface ParameterLabelProps {
parameter: PreviewParameter;
isPreset?: boolean;
autofill: boolean;
id: string;
}
const ParameterLabel: FC<ParameterLabelProps> = ({ parameter, isPreset }) => {
const hasDescription = parameter.description && parameter.description !== "";
const ParameterLabel: FC<ParameterLabelProps> = ({
parameter,
isPreset,
autofill,
id,
}) => {
const displayName = parameter.display_name
? parameter.display_name
: parameter.name;
@@ -95,7 +124,10 @@ const ParameterLabel: FC<ParameterLabelProps> = ({ parameter, isPreset }) => {
)}
<div className="flex flex-col w-full gap-1">
<Label className="flex gap-2 flex-wrap text-sm font-medium">
<Label
htmlFor={id}
className="flex gap-2 flex-wrap text-sm font-medium"
>
<span className="flex">
{displayName}
{parameter.required && (
@@ -137,9 +169,26 @@ const ParameterLabel: FC<ParameterLabelProps> = ({ parameter, isPreset }) => {
</Tooltip>
</TooltipProvider>
)}
{autofill && (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center">
<Badge size="sm">
<LinkIcon />
URL Autofill
</Badge>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Autofilled from the URL
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Label>
{hasDescription && (
{Boolean(parameter.description) && (
<div className="text-content-secondary">
<MemoizedMarkdown className="text-xs">
{parameter.description}
@@ -151,8 +200,96 @@ const ParameterLabel: FC<ParameterLabelProps> = ({ parameter, isPreset }) => {
);
};
interface DebouncedParameterFieldProps {
parameter: PreviewParameter;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
id: string;
}
const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
parameter,
value,
onChange,
disabled,
id,
}) => {
const [localValue, setLocalValue] = useState(
value !== undefined ? value : validValue(parameter.value),
);
const debouncedLocalValue = useDebouncedValue(localValue, 500);
const onChangeEvent = useEffectEvent(onChange);
// prevDebouncedValueRef is to prevent calling the onChangeEvent on the initial render
const prevDebouncedValueRef = useRef<string | undefined>();
useEffect(() => {
if (prevDebouncedValueRef.current !== undefined) {
onChangeEvent(debouncedLocalValue);
}
prevDebouncedValueRef.current = debouncedLocalValue;
}, [debouncedLocalValue, onChangeEvent]);
switch (parameter.form_type) {
case "textarea":
return (
<Textarea
id={id}
className="max-w-2xl"
value={localValue}
onChange={(e) => {
const target = e.currentTarget;
target.style.height = "auto";
target.style.maxHeight = "700px";
target.style.height = `${target.scrollHeight}px`;
setLocalValue(e.target.value);
}}
disabled={disabled}
placeholder={parameter.styling?.placeholder}
required={parameter.required}
/>
);
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
id={id}
type={inputType}
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
}}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
);
}
}
};
interface ParameterFieldProps {
parameter: PreviewParameter;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
id: string;
@@ -160,17 +297,11 @@ interface ParameterFieldProps {
const ParameterField: FC<ParameterFieldProps> = ({
parameter,
value,
onChange,
disabled,
id,
}) => {
const value = validValue(parameter.value);
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
switch (parameter.form_type) {
case "dropdown":
return (
@@ -180,7 +311,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
disabled={disabled}
required={parameter.required}
>
<SelectTrigger>
<SelectTrigger id={id}>
<SelectValue
placeholder={parameter.styling?.placeholder || "Select option"}
/>
@@ -196,7 +327,15 @@ const ParameterField: FC<ParameterFieldProps> = ({
);
case "multi-select": {
const values = parseStringArrayValue(value);
const parsedValues = parseStringArrayValue(value ?? "");
if (parsedValues.error) {
return (
<p className="text-sm text-content-destructive">
{parsedValues.error}
</p>
);
}
// Map parameter options to MultiSelectCombobox options format
const options: Option[] = parameter.options.map((opt) => ({
@@ -209,7 +348,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
parameter.options.map((opt) => [opt.value.value, opt.name]),
);
const selectedOptions: Option[] = values.map((val) => {
const selectedOptions: Option[] = parsedValues.values.map((val) => {
return {
value: val,
label: optionMap.get(val) || val,
@@ -220,7 +359,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
return (
<MultiSelectCombobox
inputProps={{
id: `${id}-${parameter.name}`,
id: id,
}}
options={options}
defaultOptions={selectedOptions}
@@ -241,13 +380,21 @@ const ParameterField: FC<ParameterFieldProps> = ({
}
case "tag-select": {
const values = parseStringArrayValue(value);
const parsedValues = parseStringArrayValue(value ?? "");
if (parsedValues.error) {
return (
<p className="text-sm text-content-destructive">
{parsedValues.error}
</p>
);
}
return (
<TagInput
id={parameter.name}
id={id}
label={parameter.display_name || parameter.name}
values={values}
values={parsedValues.values}
onChange={(values) => {
onChange(JSON.stringify(values));
}}
@@ -258,6 +405,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
case "switch":
return (
<Switch
id={id}
checked={value === "true"}
onCheckedChange={(checked) => {
onChange(checked ? "true" : "false");
@@ -293,14 +441,14 @@ const ParameterField: FC<ParameterFieldProps> = ({
return (
<div className="flex items-center space-x-2">
<Checkbox
id={parameter.name}
id={id}
checked={value === "true"}
onCheckedChange={(checked) => {
onChange(checked ? "true" : "false");
}}
disabled={disabled}
/>
<Label htmlFor={parameter.name}>{parameter.styling?.label}</Label>
<Label htmlFor={id}>{parameter.styling?.label}</Label>
</div>
);
@@ -308,10 +456,10 @@ const ParameterField: FC<ParameterFieldProps> = ({
return (
<div className="flex flex-row items-baseline gap-3">
<Slider
id={id}
className="mt-2"
value={[Number(localValue ?? 0)]}
value={[Number(value)]}
onValueChange={([value]) => {
setLocalValue(value.toString());
onChange(value.toString());
}}
min={parameter.validations[0]?.validation_min ?? 0}
@@ -321,77 +469,32 @@ const ParameterField: FC<ParameterFieldProps> = ({
<span className="w-4 font-medium">{parameter.value.value}</span>
</div>
);
case "textarea":
return (
<Textarea
className="max-w-2xl"
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
onChange(e.target.value);
}}
onInput={(e) => {
const target = e.currentTarget;
target.style.maxHeight = "700px";
target.style.height = `${target.scrollHeight}px`;
}}
disabled={disabled}
placeholder={parameter.styling?.placeholder}
required={parameter.required}
/>
);
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}
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
onChange(e.target.value);
}}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
);
}
}
};
const parseStringArrayValue = (value: string): string[] => {
let values: string[] = [];
type ParsedValues = {
values: string[];
error: string;
};
const parseStringArrayValue = (value: string): ParsedValues => {
const parsedValues: ParsedValues = {
values: [],
error: "",
};
if (value) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
values = parsed;
parsedValues.values = parsed;
}
} catch (e) {
console.error("Error parsing parameter of type list(string)", e);
parsedValues.error = `Error parsing parameter of type list(string), ${e}`;
}
}
return values;
return parsedValues;
};
interface OptionDisplayProps {
@@ -469,14 +572,12 @@ export const getInitialParameterValues = (
({ name }) => name === parameter.name,
);
const useAutofill =
autofillParam?.value && isValidParameterOption(parameter, autofillParam);
return {
name: parameter.name,
value:
autofillParam &&
isValidParameterOption(parameter, autofillParam) &&
autofillParam.value
? autofillParam.value
: validValue(parameter.value),
value: useAutofill ? autofillParam.value : validValue(parameter.value),
};
});
};
@@ -489,6 +590,28 @@ const isValidParameterOption = (
previewParam: PreviewParameter,
buildParam: WorkspaceBuildParameter,
) => {
// multi-select is the only list(string) type with options
if (previewParam.form_type === "multi-select") {
let values: string[] = [];
try {
const parsed = JSON.parse(buildParam.value);
if (Array.isArray(parsed)) {
values = parsed;
}
} catch (e) {
return false;
}
if (previewParam.options.length > 0) {
const validValues = previewParam.options.map(
(option) => option.value.value,
);
return values.some((value) => validValues.includes(value));
}
return false;
}
// For parameters with options (dropdown, radio)
if (previewParam.options.length > 0) {
const validValues = previewParam.options.map(
(option) => option.value.value,
@@ -496,7 +619,8 @@ const isValidParameterOption = (
return validValues.includes(buildParam.value);
}
return false;
// For parameters without options (input,textarea,switch,checkbox,tag-select)
return true;
};
export const useValidationSchemaForDynamicParameters = (
@@ -1,3 +1,4 @@
import { API } from "api/api";
import { type ApiErrorResponse, DetailedError } from "api/errors";
import { checkAuthorization } from "api/queries/authCheck";
import {
@@ -9,11 +10,13 @@ import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
import type {
DynamicParametersRequest,
DynamicParametersResponse,
PreviewParameter,
Workspace,
} from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "hooks";
import { useEffectEvent } from "hooks/hookPolyfills";
import { getInitialParameterValues } from "modules/workspaces/DynamicParameter/DynamicParameter";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import {
type FC,
@@ -29,13 +32,13 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters";
import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental";
const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
import { API } from "api/api";
import {
type CreateWorkspacePermissions,
createWorkspaceChecks,
} from "./permissions";
const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
const CreateWorkspacePageExperimental: FC = () => {
@@ -45,11 +48,12 @@ const CreateWorkspacePageExperimental: FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [currentResponse, setCurrentResponse] =
const [latestResponse, setLatestResponse] =
useState<DynamicParametersResponse | null>(null);
const [wsResponseId, setWSResponseId] = useState<number>(-1);
const wsResponseId = useRef<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(null);
const initialParamsSentRef = useRef(false);
const customVersionId = searchParams.get("version") ?? undefined;
const defaultName = searchParams.get("name");
@@ -84,15 +88,61 @@ const CreateWorkspacePageExperimental: FC = () => {
const realizedVersionId =
customVersionId ?? templateQuery.data?.active_version_id;
const onMessage = useCallback((response: DynamicParametersResponse) => {
setCurrentResponse((prev) => {
if (prev?.id === response.id) {
return prev;
}
return response;
});
const autofillParameters = getAutofillParameters(searchParams);
const sendMessage = useCallback((formValues: Record<string, string>) => {
const request: DynamicParametersRequest = {
id: wsResponseId.current + 1,
inputs: formValues,
};
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(request));
wsResponseId.current = wsResponseId.current + 1;
}
}, []);
// On sends all initial parameter values to the websocket
// (including defaults and autofilled from the url)
// This ensures the backend has the complete initial state of the form,
// which is vital for correctly rendering dynamic UI elements where parameter visibility
// or options might depend on the initial values of other parameters.
const sendInitialParameters = useEffectEvent(
(parameters: PreviewParameter[]) => {
if (initialParamsSentRef.current) return;
if (parameters.length === 0) return;
const initialFormValues = getInitialParameterValues(
parameters,
autofillParameters,
);
if (initialFormValues.length === 0) return;
const initialParamsToSend: Record<string, string> = {};
for (const param of initialFormValues) {
if (param.name && param.value) {
initialParamsToSend[param.name] = param.value;
}
}
if (Object.keys(initialParamsToSend).length === 0) return;
sendMessage(initialParamsToSend);
initialParamsSentRef.current = true;
},
);
const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
if (latestResponse && latestResponse?.id >= response.id) {
return;
}
if (!initialParamsSentRef.current && response.parameters.length > 0) {
sendInitialParameters([...response.parameters]);
}
setLatestResponse(response);
});
// Initialize the WebSocket connection when there is a valid template version ID
useEffect(() => {
if (!realizedVersionId) return;
@@ -127,20 +177,6 @@ const CreateWorkspacePageExperimental: FC = () => {
};
}, [owner.id, realizedVersionId, onMessage]);
const sendMessage = useCallback((formValues: Record<string, string>) => {
setWSResponseId((prevId) => {
const request: DynamicParametersRequest = {
id: prevId + 1,
inputs: formValues,
};
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(request));
return prevId + 1;
}
return prevId;
});
}, []);
const organizationId = templateQuery.data?.organization_id;
const {
@@ -167,9 +203,6 @@ const CreateWorkspacePageExperimental: FC = () => {
[navigate],
);
// Auto fill parameters
const autofillParameters = getAutofillParameters(searchParams);
const autoCreationStartedRef = useRef(false);
const automateWorkspaceCreation = useEffectEvent(async () => {
if (autoCreationStartedRef.current || !organizationId) {
@@ -231,18 +264,18 @@ const CreateWorkspacePageExperimental: FC = () => {
}, [automateWorkspaceCreation, autoCreateReady]);
const sortedParams = useMemo(() => {
if (!currentResponse?.parameters) {
if (!latestResponse?.parameters) {
return [];
}
return [...currentResponse.parameters].sort((a, b) => a.order - b.order);
}, [currentResponse?.parameters]);
return [...latestResponse.parameters].sort((a, b) => a.order - b.order);
}, [latestResponse?.parameters]);
return (
<>
<Helmet>
<title>{pageTitle(title)}</title>
</Helmet>
{!currentResponse ||
{!latestResponse ||
!templateQuery.data ||
isLoadingFormData ||
isLoadingExternalAuth ||
@@ -252,7 +285,7 @@ const CreateWorkspacePageExperimental: FC = () => {
<CreateWorkspacePageViewExperimental
mode={mode}
defaultName={defaultName}
diagnostics={currentResponse?.diagnostics ?? []}
diagnostics={latestResponse?.diagnostics ?? []}
disabledParams={disabledParams}
defaultOwner={defaultOwner}
owner={owner}
@@ -19,7 +19,6 @@ import { Spinner } from "components/Spinner/Spinner";
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, CircleAlert, TriangleAlert } from "lucide-react";
import {
DynamicParameter,
@@ -141,6 +140,10 @@ export const CreateWorkspacePageViewExperimental: FC<
},
});
const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
);
useEffect(() => {
if (error) {
window.scrollTo(0, 0);
@@ -218,7 +221,7 @@ export const CreateWorkspacePageViewExperimental: FC<
parameter: PreviewParameter,
value: string,
) => {
const formInputs: { [k: string]: string } = {};
const formInputs: Record<string, string> = {};
formInputs[parameter.name] = value;
const parameters = form.values.rich_parameter_values ?? [];
@@ -234,37 +237,17 @@ export const CreateWorkspacePageViewExperimental: FC<
sendMessage(formInputs);
};
const { debounced: handleChangeDebounced } = useDebouncedFunction(
async (
parameter: PreviewParameter,
parameterField: string,
value: string,
) => {
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
form.setFieldTouched(parameter.name, true);
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.name,
value,
});
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest(parameter, value);
}
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest(parameter, value);
};
return (
@@ -509,6 +492,9 @@ export const CreateWorkspacePageViewExperimental: FC<
return null;
}
const formValue =
form.values?.rich_parameter_values?.[index]?.value || "";
return (
<DynamicParameter
key={parameter.name}
@@ -518,6 +504,8 @@ export const CreateWorkspacePageViewExperimental: FC<
}
disabled={isDisabled}
isPreset={isPresetParameter}
autofill={autofillByName[parameter.name] !== undefined}
value={formValue}
/>
);
})}