Skip to content

Commit ae6a28e

Browse files
committed
Create ProvisionerTagsField
1 parent 4449931 commit ae6a28e

File tree

8 files changed

+326
-251
lines changed

8 files changed

+326
-251
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/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,141 @@
1+
import type { ProvisionerDaemon } from "api/typesGenerated";
2+
import { Button } from "components/Button/Button";
3+
import { Input } from "components/Input/Input";
4+
import { PlusIcon } from "lucide-react";
5+
import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
6+
import { type FC, useState } from "react";
7+
import * as Yup from "yup";
8+
9+
// Users can't delete these tags
10+
const REQUIRED_TAGS = ["scope", "organization", "user"];
11+
12+
// Users can't override these tags
13+
const IMMUTABLE_TAGS = ["owner"];
14+
15+
type ProvisionerTagsFieldProps = {
16+
value: ProvisionerDaemon["tags"];
17+
onChange: (value: ProvisionerDaemon["tags"]) => void;
18+
};
19+
20+
export const ProvisionerTagsField: FC<ProvisionerTagsFieldProps> = ({
21+
value: fieldValue,
22+
onChange,
23+
}) => {
24+
return (
25+
<div className="flex flex-col gap-3">
26+
<div className="flex items-center gap-2 flex-wrap">
27+
{Object.entries(fieldValue)
28+
// Filter out since users cannot override it
29+
.filter(([key]) => !IMMUTABLE_TAGS.includes(key))
30+
.map(([key, value]) => {
31+
const onDelete = (key: string) => {
32+
const { [key]: _, ...newFieldValue } = fieldValue;
33+
onChange(newFieldValue);
34+
};
35+
36+
return (
37+
<ProvisionerTag
38+
key={key}
39+
tagName={key}
40+
tagValue={value}
41+
// Required tags can't be deleted
42+
onDelete={REQUIRED_TAGS.includes(key) ? undefined : onDelete}
43+
/>
44+
);
45+
})}
46+
</div>
47+
48+
<NewTagForm
49+
onSubmit={(tag) => {
50+
onChange({ ...fieldValue, [tag.key]: tag.value });
51+
}}
52+
/>
53+
</div>
54+
);
55+
};
56+
57+
const newTagSchema = Yup.object({
58+
key: Yup.string()
59+
.required("Key is required")
60+
.notOneOf(["owner"], "Cannot override owner tag"),
61+
value: Yup.string()
62+
.required("Value is required")
63+
.when("key", ([key], schema) => {
64+
if (key === "scope") {
65+
return schema.oneOf(
66+
["organization", "scope"],
67+
"Scope value must be 'organization' or 'user'",
68+
);
69+
}
70+
71+
return schema;
72+
}),
73+
});
74+
75+
type NewTagFormProps = {
76+
onSubmit: (values: { key: string; value: string }) => void;
77+
};
78+
79+
const NewTagForm: FC<NewTagFormProps> = ({ onSubmit }) => {
80+
const [error, setError] = useState<string>();
81+
82+
return (
83+
<form
84+
className="flex flex-col gap-1"
85+
onSubmit={async (e) => {
86+
e.preventDefault();
87+
const form = e.currentTarget;
88+
const key = form.key.value.trim();
89+
const value = form.value.value.trim();
90+
91+
try {
92+
await newTagSchema.validate({ key, value });
93+
onSubmit({ key, value });
94+
form.reset();
95+
} catch (e) {
96+
const isValidationError = e instanceof Yup.ValidationError;
97+
98+
if (!isValidationError) {
99+
throw e;
100+
}
101+
102+
if (e instanceof Yup.ValidationError) {
103+
setError(e.errors[0]);
104+
}
105+
}
106+
}}
107+
>
108+
<div className="flex items-center gap-2">
109+
<label className="sr-only" htmlFor="tag-key-input">
110+
Tag key
111+
</label>
112+
<Input
113+
id="tag-key-input"
114+
name="key"
115+
placeholder="Key"
116+
className="h-8 md:text-xs px-2"
117+
required
118+
/>
119+
120+
<label className="sr-only" htmlFor="tag-value-input">
121+
Tag value
122+
</label>
123+
<Input
124+
id="tag-value-input"
125+
name="value"
126+
placeholder="Value"
127+
className="h-8 md:text-xs px-2"
128+
required
129+
/>
130+
131+
<Button size="icon" type="submit">
132+
<PlusIcon />
133+
<span className="sr-only">Add tag</span>
134+
</Button>
135+
</div>
136+
{error && (
137+
<span className="text-xs text-content-destructive">{error}</span>
138+
)}
139+
</form>
140+
);
141+
};

site/src/pages/TemplateVersionEditorPage/ProvisionerTagsPopover.stories.tsx

+47-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Meta, StoryObj } from "@storybook/react";
2-
import { userEvent, within } from "@storybook/test";
2+
import { expect, fn, userEvent, within } from "@storybook/test";
3+
import { useState } from "react";
34
import { chromatic } from "testHelpers/chromatic";
45
import { MockTemplateVersion } from "testHelpers/entities";
56
import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover";
@@ -19,14 +20,53 @@ const meta: Meta<typeof ProvisionerTagsPopover> = {
1920
export default meta;
2021
type Story = StoryObj<typeof ProvisionerTagsPopover>;
2122

22-
const Example: Story = {
23-
play: async ({ canvasElement, step }) => {
23+
export const Closed: Story = {};
24+
25+
export const Open: Story = {
26+
play: async ({ canvasElement }) => {
27+
const canvas = within(canvasElement);
28+
await userEvent.click(canvas.getByRole("button"));
29+
},
30+
};
31+
32+
export const OnTagsChange: Story = {
33+
parameters: {
34+
chromatic: { disableSnapshot: true },
35+
},
36+
args: {
37+
tags: {},
38+
},
39+
render: (args) => {
40+
const [tags, setTags] = useState(args.tags);
41+
return <ProvisionerTagsPopover tags={tags} onTagsChange={fn(setTags)} />;
42+
},
43+
play: async ({ canvasElement }) => {
44+
const user = userEvent.setup();
2445
const canvas = within(canvasElement);
2546

26-
await step("Open popover", async () => {
27-
await userEvent.click(canvas.getByRole("button"));
47+
const expandButton = canvas.getByRole("button", {
48+
name: "Expand provisioner tags",
49+
});
50+
await userEvent.click(expandButton);
51+
52+
const keyInput = await canvas.findByLabelText("Tag key");
53+
const valueInput = await canvas.findByLabelText("Tag value");
54+
const addButton = await canvas.findByRole("button", {
55+
name: "Add tag",
56+
hidden: true,
2857
});
58+
59+
await user.type(keyInput, "cluster");
60+
await user.type(valueInput, "dogfood-2");
61+
await user.click(addButton);
62+
const addedTag = await canvas.findByTestId("tag-cluster");
63+
await expect(addedTag).toHaveTextContent("cluster dogfood-2");
64+
65+
const removeButton = canvas.getByRole("button", {
66+
name: "Delete cluster",
67+
hidden: true,
68+
});
69+
await user.click(removeButton);
70+
await expect(canvas.queryByTestId("tag-cluster")).toBeNull();
2971
},
3072
};
31-
32-
export { Example as ProvisionerTagsPopover };

0 commit comments

Comments
 (0)