mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add TagInput component for dynamic parameters (#17719)
resolves coder/preview#50 This uses the existing MultiTextField component as the tag-select component for Dynamic parameters. The intention is not to completely re-write the MultiTextField but to make some design improvements to match the updated design patterns. This should still work with the existing non-experimental CreateWorkspacePage. Before <img width="556" alt="Screenshot 2025-05-08 at 12 58 31" src="https://github.com/user-attachments/assets/9bf5bbf8-e26d-4523-8b5f-e4234e83d192" /> After <img width="548" alt="Screenshot 2025-05-08 at 12 43 28" src="https://github.com/user-attachments/assets/9fa90795-b2a9-4c07-b90e-938219202799" />
This commit is contained in:
@@ -19,7 +19,7 @@ import type {
|
||||
AutofillBuildParameter,
|
||||
AutofillSource,
|
||||
} from "utils/richParameters";
|
||||
import { MultiTextField } from "./MultiTextField";
|
||||
import { TagInput } from "../TagInput/TagInput";
|
||||
|
||||
const isBoolean = (parameter: TemplateVersionParameter) => {
|
||||
return parameter.type === "bool";
|
||||
@@ -372,7 +372,7 @@ const RichParameterField: FC<RichParameterInputProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiTextField
|
||||
<TagInput
|
||||
id={parameter.name}
|
||||
data-testid="parameter-field-list-of-string"
|
||||
label={props.label as string}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { TagInput } from "./TagInput";
|
||||
|
||||
const meta: Meta<typeof TagInput> = {
|
||||
title: "components/TagInput",
|
||||
component: TagInput,
|
||||
decorators: [(Story) => <div style={{ maxWidth: "500px" }}>{Story()}</div>],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TagInput>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
values: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithEmptyTags: Story = {
|
||||
args: {
|
||||
values: ["", "", ""],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLongTags: Story = {
|
||||
args: {
|
||||
values: [
|
||||
"this-is-a-very-long-long-long-tag-that-might-wrap",
|
||||
"another-long-tag-example",
|
||||
"short",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithManyTags: Story = {
|
||||
args: {
|
||||
values: [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3",
|
||||
"tag4",
|
||||
"tag5",
|
||||
"tag6",
|
||||
"tag7",
|
||||
"tag8",
|
||||
"tag9",
|
||||
"tag10",
|
||||
"tag11",
|
||||
"tag12",
|
||||
"tag13",
|
||||
"tag14",
|
||||
"tag15",
|
||||
"tag16",
|
||||
"tag17",
|
||||
"tag18",
|
||||
"tag19",
|
||||
"tag20",
|
||||
],
|
||||
},
|
||||
};
|
||||
+22
-43
@@ -1,27 +1,39 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import FormHelperText from "@mui/material/FormHelperText";
|
||||
import type { FC } from "react";
|
||||
import { type FC, useId, useMemo } from "react";
|
||||
|
||||
export type MultiTextFieldProps = {
|
||||
export type TagInputProps = {
|
||||
label: string;
|
||||
id?: string;
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
};
|
||||
|
||||
export const MultiTextField: FC<MultiTextFieldProps> = ({
|
||||
export const TagInput: FC<TagInputProps> = ({
|
||||
label,
|
||||
id,
|
||||
values,
|
||||
onChange,
|
||||
}) => {
|
||||
const baseId = useId();
|
||||
|
||||
const itemIds = useMemo(() => {
|
||||
return Array.from(
|
||||
{ length: values.length },
|
||||
(_, index) => `${baseId}-item-${index}`,
|
||||
);
|
||||
}, [baseId, values.length]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label css={styles.root}>
|
||||
<label
|
||||
className="flex flex-wrap min-h-10 px-1.5 py-1.5 gap-2 border border-border border-solid relative rounded-md
|
||||
focus-within:border-content-link focus-within:border-2 focus-within:-top-px focus-within:-left-px"
|
||||
>
|
||||
{values.map((value, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
key={itemIds[index]}
|
||||
className="rounded-md bg-surface-secondary text-content-secondary h-7"
|
||||
label={value}
|
||||
size="small"
|
||||
onDelete={() => {
|
||||
@@ -32,7 +44,7 @@ export const MultiTextField: FC<MultiTextFieldProps> = ({
|
||||
<input
|
||||
id={id}
|
||||
aria-label={label}
|
||||
css={styles.input}
|
||||
className="flex-grow text-inherit p-0 border-none bg-transparent focus:outline-none"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ",") {
|
||||
event.preventDefault();
|
||||
@@ -64,42 +76,9 @@ export const MultiTextField: FC<MultiTextFieldProps> = ({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<FormHelperText>{'Type "," to separate the values'}</FormHelperText>
|
||||
<FormHelperText className="text-content-secondary text-xs">
|
||||
{'Type "," to separate the values'}
|
||||
</FormHelperText>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: (theme) => ({
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 8,
|
||||
minHeight: 48, // Chip height + paddings
|
||||
padding: "10px 14px",
|
||||
fontSize: 16,
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
position: "relative",
|
||||
margin: "8px 0 4px", // Have same margin than TextField
|
||||
|
||||
"&:has(input:focus)": {
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: 2,
|
||||
// Compensate for the border width
|
||||
top: -1,
|
||||
left: -1,
|
||||
},
|
||||
}),
|
||||
|
||||
input: {
|
||||
flexGrow: 1,
|
||||
fontSize: "inherit",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "none",
|
||||
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "components/Select/Select";
|
||||
import { Slider } from "components/Slider/Slider";
|
||||
import { Switch } from "components/Switch/Switch";
|
||||
import { TagInput } from "components/TagInput/TagInput";
|
||||
import { Textarea } from "components/Textarea/Textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -195,21 +196,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
|
||||
);
|
||||
|
||||
case "multi-select": {
|
||||
let values: string[] = [];
|
||||
|
||||
if (value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
values = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Error parsing parameter value with form_type multi-select",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
const values = parseStringArrayValue(value);
|
||||
|
||||
// Map parameter options to MultiSelectCombobox options format
|
||||
const options: Option[] = parameter.options.map((opt) => ({
|
||||
@@ -253,6 +240,21 @@ const ParameterField: FC<ParameterFieldProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
case "tag-select": {
|
||||
const values = parseStringArrayValue(value);
|
||||
|
||||
return (
|
||||
<TagInput
|
||||
id={parameter.name}
|
||||
label={parameter.display_name || parameter.name}
|
||||
values={values}
|
||||
onChange={(values) => {
|
||||
onChange(JSON.stringify(values));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "switch":
|
||||
return (
|
||||
<Switch
|
||||
@@ -375,6 +377,23 @@ const ParameterField: FC<ParameterFieldProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const parseStringArrayValue = (value: string): string[] => {
|
||||
let values: string[] = [];
|
||||
|
||||
if (value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
values = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing parameter of type list(string)", e);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
interface OptionDisplayProps {
|
||||
option: PreviewParameterOption;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user