feat: allow masking workspace parameter inputs (#18595)

This commit is contained in:
ケイラ
2025-07-01 16:27:43 -06:00
committed by GitHub
parent d22ac1cf65
commit 0b82f41a24
11 changed files with 139 additions and 48 deletions
+1
View File
@@ -1836,6 +1836,7 @@ export interface PreviewParameterStyling {
readonly placeholder?: string;
readonly disabled?: boolean;
readonly label?: string;
readonly mask_input?: boolean;
}
// From codersdk/parameters.go
@@ -8,18 +8,16 @@ const meta: Meta<typeof DynamicParameter> = {
parameters: {
layout: "centered",
},
args: {
parameter: MockPreviewParameter,
onChange: () => {},
},
};
export default meta;
type Story = StoryObj<typeof DynamicParameter>;
export const TextInput: Story = {
args: {
parameter: {
...MockPreviewParameter,
},
},
};
export const TextInput: Story = {};
export const TextArea: Story = {
args: {
@@ -230,3 +228,30 @@ export const AllBadges: Story = {
isPreset: true,
},
};
export const MaskedInput: Story = {
args: {
parameter: {
...MockPreviewParameter,
form_type: "input",
styling: {
...MockPreviewParameter.styling,
placeholder: "Tell me a secret",
mask_input: true,
},
},
},
};
export const MaskedTextArea: Story = {
args: {
parameter: {
...MockPreviewParameter,
form_type: "textarea",
styling: {
...MockPreviewParameter.styling,
mask_input: true,
},
},
},
};
@@ -5,6 +5,7 @@ import type {
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Checkbox } from "components/Checkbox/Checkbox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Input } from "components/Input/Input";
@@ -23,6 +24,7 @@ import {
SelectValue,
} from "components/Select/Select";
import { Slider } from "components/Slider/Slider";
import { Stack } from "components/Stack/Stack";
import { Switch } from "components/Switch/Switch";
import { TagInput } from "components/TagInput/TagInput";
import { Textarea } from "components/Textarea/Textarea";
@@ -36,6 +38,8 @@ import { useDebouncedValue } from "hooks/debounce";
import { useEffectEvent } from "hooks/hookPolyfills";
import {
CircleAlert,
Eye,
EyeOff,
Hourglass,
Info,
LinkIcon,
@@ -43,6 +47,7 @@ import {
TriangleAlert,
} from "lucide-react";
import { type FC, useEffect, useId, useRef, useState } from "react";
import { cn } from "utils/cn";
import type { AutofillBuildParameter } from "utils/richParameters";
import * as Yup from "yup";
@@ -265,6 +270,7 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
const [localValue, setLocalValue] = useState(
value !== undefined ? value : validValue(parameter.value),
);
const [showMaskedInput, setShowMaskedInput] = useState(false);
const debouncedLocalValue = useDebouncedValue(localValue, 500);
const onChangeEvent = useEffectEvent(onChange);
// prevDebouncedValueRef is to prevent calling the onChangeEvent on the initial render
@@ -309,27 +315,56 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
switch (parameter.form_type) {
case "textarea": {
return (
<Textarea
ref={textareaRef}
id={id}
className="overflow-y-auto max-h-[500px]"
value={localValue}
onChange={(e) => {
const target = e.currentTarget;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
<Stack direction="row" spacing={0} alignItems="center">
<Textarea
ref={textareaRef}
id={id}
className={cn(
"overflow-y-auto max-h-[500px]",
parameter.styling?.mask_input &&
!showMaskedInput &&
"[-webkit-text-security:disc]",
)}
value={localValue}
onChange={(e) => {
const target = e.currentTarget;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
setLocalValue(e.target.value);
}}
disabled={disabled}
placeholder={parameter.styling?.placeholder}
required={parameter.required}
/>
setLocalValue(e.target.value);
}}
disabled={disabled}
placeholder={parameter.styling?.placeholder}
required={parameter.required}
/>
{parameter.styling?.mask_input && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
}
case "input": {
const inputType = parameter.type === "number" ? "number" : "text";
const inputType =
parameter.type === "number"
? "number"
: parameter.styling?.mask_input && !showMaskedInput
? "password"
: "text";
const inputProps: Record<string, unknown> = {};
if (parameter.type === "number") {
@@ -346,18 +381,37 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
}
return (
<Input
id={id}
type={inputType}
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
}}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
<Stack direction="row" spacing={0} alignItems="center">
<Input
id={id}
type={inputType}
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
}}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
{parameter.styling?.mask_input && parameter.type !== "number" && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
}
}
@@ -451,9 +505,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
return (
<MultiSelectCombobox
inputProps={{
id: id,
}}
inputProps={{ id }}
options={options}
defaultOptions={selectedOptions}
onChange={(newValues) => {
@@ -698,7 +750,7 @@ const isValidParameterOption = (
if (Array.isArray(parsed)) {
values = parsed;
}
} catch (e) {
} catch {
return false;
}
@@ -899,12 +951,12 @@ export const Diagnostics: FC<DiagnosticsProps> = ({ diagnostics }) => {
{diagnostics.map((diagnostic, index) => (
<div
key={`diagnostic-${diagnostic.summary}-${index}`}
className={`text-xs font-semibold flex flex-col rounded-md border px-3.5 py-3.5 border-solid
${
diagnostic.severity === "error"
? "text-content-primary border-border-destructive bg-content-destructive/15"
: "text-content-primary border-border-warning bg-content-warning/15"
}`}
className={cn(
"text-xs font-semibold flex flex-col rounded-md border px-3.5 py-3.5 border-solid",
diagnostic.severity === "error"
? "text-content-primary border-border-destructive bg-content-destructive/15"
: "text-content-primary border-border-warning bg-content-warning/15",
)}
>
<div className="flex flex-row items-start">
{diagnostic.severity === "error" && (