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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 2 additions & 2 deletions
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
);
Lines changed: 108 additions & 0 deletions
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+
};
Lines changed: 164 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy