Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 38ad8d1

Browse files
feat: add provisioner tags field on template creation (#16656)
Close #15426 Demo: https://github.com/user-attachments/assets/a7901908-8714-4a55-8d4f-c27bf7743111
1 parent 6498464 commit 38ad8d1

14 files changed

+393
-260
lines changed

site/src/components/Input/Input.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const Input = forwardRef<
1818
file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-content-primary
1919
placeholder:text-content-secondary
2020
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
21-
disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`,
21+
disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-inherit`,
2222
className,
2323
)}
2424
ref={ref}

site/src/modules/provisioners/ProvisionerAlert.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
5252
<AlertTitle>{title}</AlertTitle>
5353
<AlertDetail>
5454
<div>{detail}</div>
55-
<Stack direction="row" spacing={1} wrap="wrap">
55+
<div className="flex items-center gap-2 flex-wrap mt-2">
5656
{Object.entries(tags ?? {})
5757
.filter(([key]) => key !== "owner")
5858
.map(([key, value]) => (
5959
<ProvisionerTag key={key} tagName={key} tagValue={value} />
6060
))}
61-
</Stack>
61+
</div>
6262
</AlertDetail>
6363
</Alert>
6464
);

site/src/modules/provisioners/ProvisionerTag.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,14 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
4545
<>
4646
{kv}
4747
<IconButton
48-
aria-label={`delete-${tagName}`}
4948
size="small"
5049
color="secondary"
5150
onClick={() => {
5251
onDelete(tagName);
5352
}}
5453
>
5554
<CloseIcon fontSize="inherit" css={{ width: 14, height: 14 }} />
55+
<span className="sr-only">Delete {tagName}</span>
5656
</IconButton>
5757
</>
5858
) : (
@@ -62,7 +62,7 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
6262
return <BooleanPill value={boolValue}>{content}</BooleanPill>;
6363
}
6464
return (
65-
<Pill size="lg" icon={<Sell />}>
65+
<Pill size="lg" icon={<Sell />} data-testid={`tag-${tagName}`}>
6666
{content}
6767
</Pill>
6868
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { expect, userEvent, within } from "@storybook/test";
3+
import type { ProvisionerDaemon } from "api/typesGenerated";
4+
import { type FC, useState } from "react";
5+
import { ProvisionerTagsField } from "./ProvisionerTagsField";
6+
7+
const meta: Meta<typeof ProvisionerTagsField> = {
8+
title: "modules/provisioners/ProvisionerTagsField",
9+
component: ProvisionerTagsField,
10+
args: {
11+
value: {},
12+
},
13+
};
14+
15+
export default meta;
16+
type Story = StoryObj<typeof ProvisionerTagsField>;
17+
18+
export const Empty: Story = {
19+
args: {
20+
value: {},
21+
},
22+
};
23+
24+
export const WithInitialValue: Story = {
25+
args: {
26+
value: {
27+
cluster: "dogfood-2",
28+
env: "gke",
29+
scope: "organization",
30+
},
31+
},
32+
};
33+
34+
type StatefulProvisionerTagsFieldProps = {
35+
initialValue?: ProvisionerDaemon["tags"];
36+
};
37+
38+
const StatefulProvisionerTagsField: FC<StatefulProvisionerTagsFieldProps> = ({
39+
initialValue = {},
40+
}) => {
41+
const [value, setValue] = useState<ProvisionerDaemon["tags"]>(initialValue);
42+
return <ProvisionerTagsField value={value} onChange={setValue} />;
43+
};
44+
45+
export const OnOverwriteOwner: Story = {
46+
play: async ({ canvasElement }) => {
47+
const user = userEvent.setup();
48+
const canvas = within(canvasElement);
49+
const keyInput = canvas.getByLabelText("Tag key");
50+
const valueInput = canvas.getByLabelText("Tag value");
51+
const addButton = canvas.getByRole("button", { name: "Add tag" });
52+
53+
await user.type(keyInput, "owner");
54+
await user.type(valueInput, "dogfood-2");
55+
await user.click(addButton);
56+
57+
await canvas.findByText("Cannot override owner tag");
58+
},
59+
};
60+
61+
export const OnInvalidScope: Story = {
62+
play: async ({ canvasElement }) => {
63+
const user = userEvent.setup();
64+
const canvas = within(canvasElement);
65+
const keyInput = canvas.getByLabelText("Tag key");
66+
const valueInput = canvas.getByLabelText("Tag value");
67+
const addButton = canvas.getByRole("button", { name: "Add tag" });
68+
69+
await user.type(keyInput, "scope");
70+
await user.type(valueInput, "invalid");
71+
await user.click(addButton);
72+
73+
await canvas.findByText("Scope value must be 'organization' or 'user'");
74+
},
75+
};
76+
77+
export const OnAddTag: Story = {
78+
render: () => <StatefulProvisionerTagsField />,
79+
play: async ({ canvasElement }) => {
80+
const user = userEvent.setup();
81+
const canvas = within(canvasElement);
82+
const keyInput = canvas.getByLabelText("Tag key");
83+
const valueInput = canvas.getByLabelText("Tag value");
84+
const addButton = canvas.getByRole("button", { name: "Add tag" });
85+
86+
await user.type(keyInput, "cluster");
87+
await user.type(valueInput, "dogfood-2");
88+
await user.click(addButton);
89+
90+
const addedTag = await canvas.findByTestId("tag-cluster");
91+
await expect(addedTag).toHaveTextContent("cluster dogfood-2");
92+
},
93+
};
94+
95+
export const OnRemoveTag: Story = {
96+
render: () => (
97+
<StatefulProvisionerTagsField initialValue={{ cluster: "dogfood-2" }} />
98+
),
99+
play: async ({ canvasElement }) => {
100+
const user = userEvent.setup();
101+
const canvas = within(canvasElement);
102+
const removeButton = canvas.getByRole("button", { name: "Delete cluster" });
103+
104+
await user.click(removeButton);
105+
106+
await expect(canvas.queryByTestId("tag-cluster")).toBeNull();
107+
},
108+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import TextField from "@mui/material/TextField";
2+
import type { ProvisionerDaemon } from "api/typesGenerated";
3+
import { Button } from "components/Button/Button";
4+
import { Input } from "components/Input/Input";
5+
import { PlusIcon } from "lucide-react";
6+
import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
7+
import { type FC, useRef, useState } from "react";
8+
import * as Yup from "yup";
9+
10+
// Users can't delete these tags
11+
const REQUIRED_TAGS = ["scope", "organization", "user"];
12+
13+
// Users can't override these tags
14+
const IMMUTABLE_TAGS = ["owner"];
15+
16+
type ProvisionerTagsFieldProps = {
17+
value: ProvisionerDaemon["tags"];
18+
onChange: (value: ProvisionerDaemon["tags"]) => void;
19+
};
20+
21+
export const ProvisionerTagsField: FC<ProvisionerTagsFieldProps> = ({
22+
value: fieldValue,
23+
onChange,
24+
}) => {
25+
return (
26+
<div className="flex flex-col gap-3">
27+
<div className="flex items-center gap-2 flex-wrap">
28+
{Object.entries(fieldValue)
29+
// Filter out since users cannot override it
30+
.filter(([key]) => !IMMUTABLE_TAGS.includes(key))
31+
.map(([key, value]) => {
32+
const onDelete = (key: string) => {
33+
const { [key]: _, ...newFieldValue } = fieldValue;
34+
onChange(newFieldValue);
35+
};
36+
37+
return (
38+
<ProvisionerTag
39+
key={key}
40+
tagName={key}
41+
tagValue={value}
42+
// Required tags can't be deleted
43+
onDelete={REQUIRED_TAGS.includes(key) ? undefined : onDelete}
44+
/>
45+
);
46+
})}
47+
</div>
48+
49+
<NewTagControl
50+
onAdd={(tag) => {
51+
onChange({ ...fieldValue, [tag.key]: tag.value });
52+
}}
53+
/>
54+
</div>
55+
);
56+
};
57+
58+
const newTagSchema = Yup.object({
59+
key: Yup.string()
60+
.required("Key is required")
61+
.notOneOf(["owner"], "Cannot override owner tag"),
62+
value: Yup.string()
63+
.required("Value is required")
64+
.when("key", ([key], schema) => {
65+
if (key === "scope") {
66+
return schema.oneOf(
67+
["organization", "scope"],
68+
"Scope value must be 'organization' or 'user'",
69+
);
70+
}
71+
72+
return schema;
73+
}),
74+
});
75+
76+
type Tag = { key: string; value: string };
77+
78+
type NewTagControlProps = {
79+
onAdd: (tag: Tag) => void;
80+
};
81+
82+
const NewTagControl: FC<NewTagControlProps> = ({ onAdd }) => {
83+
const keyInputRef = useRef<HTMLInputElement>(null);
84+
const [error, setError] = useState<string>();
85+
const [newTag, setNewTag] = useState<Tag>({
86+
key: "",
87+
value: "",
88+
});
89+
90+
const addNewTag = async () => {
91+
try {
92+
await newTagSchema.validate(newTag);
93+
onAdd(newTag);
94+
setNewTag({ key: "", value: "" });
95+
keyInputRef.current?.focus();
96+
} catch (e) {
97+
const isValidationError = e instanceof Yup.ValidationError;
98+
99+
if (!isValidationError) {
100+
throw e;
101+
}
102+
103+
if (e instanceof Yup.ValidationError) {
104+
setError(e.errors[0]);
105+
}
106+
}
107+
};
108+
109+
const addNewTagOnEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
110+
if (e.key === "Enter") {
111+
e.preventDefault();
112+
e.stopPropagation();
113+
addNewTag();
114+
}
115+
};
116+
117+
return (
118+
<div className="flex flex-col gap-1 max-w-72">
119+
<div className="flex items-center gap-2">
120+
<label className="sr-only" htmlFor="tag-key-input">
121+
Tag key
122+
</label>
123+
<TextField
124+
inputRef={keyInputRef}
125+
size="small"
126+
id="tag-key-input"
127+
name="key"
128+
placeholder="Key"
129+
value={newTag.key}
130+
onChange={(e) => setNewTag({ ...newTag, key: e.target.value.trim() })}
131+
onKeyDown={addNewTagOnEnter}
132+
/>
133+
134+
<label className="sr-only" htmlFor="tag-value-input">
135+
Tag value
136+
</label>
137+
<TextField
138+
size="small"
139+
id="tag-value-input"
140+
name="value"
141+
placeholder="Value"
142+
value={newTag.value}
143+
onChange={(e) =>
144+
setNewTag({ ...newTag, value: e.target.value.trim() })
145+
}
146+
onKeyDown={addNewTagOnEnter}
147+
/>
148+
149+
<Button
150+
className="flex-shrink-0"
151+
size="icon"
152+
type="button"
153+
onClick={addNewTag}
154+
>
155+
<PlusIcon />
156+
<span className="sr-only">Add tag</span>
157+
</Button>
158+
</div>
159+
{error && (
160+
<span className="text-xs text-content-destructive">{error}</span>
161+
)}
162+
</div>
163+
);
164+
};

0 commit comments

Comments
 (0)