Skip to content

feat: add provisioner tags field on template creation #16656

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/src/components/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const Input = forwardRef<
file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-content-primary
placeholder:text-content-secondary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`,
disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-inherit`,
className,
)}
ref={ref}
Expand Down
4 changes: 2 additions & 2 deletions site/src/modules/provisioners/ProvisionerAlert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ export const ProvisionerAlert: FC<ProvisionerAlertProps> = ({
<AlertTitle>{title}</AlertTitle>
<AlertDetail>
<div>{detail}</div>
<Stack direction="row" spacing={1} wrap="wrap">
<div className="flex items-center gap-2 flex-wrap mt-2">
{Object.entries(tags ?? {})
.filter(([key]) => key !== "owner")
.map(([key, value]) => (
<ProvisionerTag key={key} tagName={key} tagValue={value} />
))}
</Stack>
</div>
</AlertDetail>
</Alert>
);
Expand Down
4 changes: 2 additions & 2 deletions site/src/modules/provisioners/ProvisionerTag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
<>
{kv}
<IconButton
aria-label={`delete-${tagName}`}
size="small"
color="secondary"
onClick={() => {
onDelete(tagName);
}}
>
<CloseIcon fontSize="inherit" css={{ width: 14, height: 14 }} />
<span className="sr-only">Delete {tagName}</span>
</IconButton>
</>
) : (
Expand All @@ -62,7 +62,7 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
return <BooleanPill value={boolValue}>{content}</BooleanPill>;
}
return (
<Pill size="lg" icon={<Sell />}>
<Pill size="lg" icon={<Sell />} data-testid={`tag-${tagName}`}>
{content}
</Pill>
);
Expand Down
108 changes: 108 additions & 0 deletions site/src/modules/provisioners/ProvisionerTagsField.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import type { ProvisionerDaemon } from "api/typesGenerated";
import { type FC, useState } from "react";
import { ProvisionerTagsField } from "./ProvisionerTagsField";

const meta: Meta<typeof ProvisionerTagsField> = {
title: "modules/provisioners/ProvisionerTagsField",
component: ProvisionerTagsField,
args: {
value: {},
},
};

export default meta;
type Story = StoryObj<typeof ProvisionerTagsField>;

export const Empty: Story = {
args: {
value: {},
},
};

export const WithInitialValue: Story = {
args: {
value: {
cluster: "dogfood-2",
env: "gke",
scope: "organization",
},
},
};

type StatefulProvisionerTagsFieldProps = {
initialValue?: ProvisionerDaemon["tags"];
};

const StatefulProvisionerTagsField: FC<StatefulProvisionerTagsFieldProps> = ({
initialValue = {},
}) => {
const [value, setValue] = useState<ProvisionerDaemon["tags"]>(initialValue);
return <ProvisionerTagsField value={value} onChange={setValue} />;
};

export const OnOverwriteOwner: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const keyInput = canvas.getByLabelText("Tag key");
const valueInput = canvas.getByLabelText("Tag value");
const addButton = canvas.getByRole("button", { name: "Add tag" });

await user.type(keyInput, "owner");
await user.type(valueInput, "dogfood-2");
await user.click(addButton);

await canvas.findByText("Cannot override owner tag");
},
};

export const OnInvalidScope: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const keyInput = canvas.getByLabelText("Tag key");
const valueInput = canvas.getByLabelText("Tag value");
const addButton = canvas.getByRole("button", { name: "Add tag" });

await user.type(keyInput, "scope");
await user.type(valueInput, "invalid");
await user.click(addButton);

await canvas.findByText("Scope value must be 'organization' or 'user'");
},
};

export const OnAddTag: Story = {
render: () => <StatefulProvisionerTagsField />,
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const keyInput = canvas.getByLabelText("Tag key");
const valueInput = canvas.getByLabelText("Tag value");
const addButton = canvas.getByRole("button", { name: "Add tag" });

await user.type(keyInput, "cluster");
await user.type(valueInput, "dogfood-2");
await user.click(addButton);

const addedTag = await canvas.findByTestId("tag-cluster");
await expect(addedTag).toHaveTextContent("cluster dogfood-2");
},
};

export const OnRemoveTag: Story = {
render: () => (
<StatefulProvisionerTagsField initialValue={{ cluster: "dogfood-2" }} />
),
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const removeButton = canvas.getByRole("button", { name: "Delete cluster" });

await user.click(removeButton);

await expect(canvas.queryByTestId("tag-cluster")).toBeNull();
},
};
164 changes: 164 additions & 0 deletions site/src/modules/provisioners/ProvisionerTagsField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import TextField from "@mui/material/TextField";
import type { ProvisionerDaemon } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Input } from "components/Input/Input";
import { PlusIcon } from "lucide-react";
import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
import { type FC, useRef, useState } from "react";
import * as Yup from "yup";

// Users can't delete these tags
const REQUIRED_TAGS = ["scope", "organization", "user"];

// Users can't override these tags
const IMMUTABLE_TAGS = ["owner"];

type ProvisionerTagsFieldProps = {
value: ProvisionerDaemon["tags"];
onChange: (value: ProvisionerDaemon["tags"]) => void;
};

export const ProvisionerTagsField: FC<ProvisionerTagsFieldProps> = ({
value: fieldValue,
onChange,
}) => {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(fieldValue)
// Filter out since users cannot override it
.filter(([key]) => !IMMUTABLE_TAGS.includes(key))
.map(([key, value]) => {
const onDelete = (key: string) => {
const { [key]: _, ...newFieldValue } = fieldValue;
onChange(newFieldValue);
};

return (
<ProvisionerTag
key={key}
tagName={key}
tagValue={value}
// Required tags can't be deleted
onDelete={REQUIRED_TAGS.includes(key) ? undefined : onDelete}
/>
);
})}
</div>

<NewTagControl
onAdd={(tag) => {
onChange({ ...fieldValue, [tag.key]: tag.value });
}}
/>
</div>
);
};

const newTagSchema = Yup.object({
key: Yup.string()
.required("Key is required")
.notOneOf(["owner"], "Cannot override owner tag"),
value: Yup.string()
.required("Value is required")
.when("key", ([key], schema) => {
if (key === "scope") {
return schema.oneOf(
["organization", "scope"],
"Scope value must be 'organization' or 'user'",
);
}

return schema;
}),
});

type Tag = { key: string; value: string };

type NewTagControlProps = {
onAdd: (tag: Tag) => void;
};

const NewTagControl: FC<NewTagControlProps> = ({ onAdd }) => {
const keyInputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string>();
const [newTag, setNewTag] = useState<Tag>({
key: "",
value: "",
});

const addNewTag = async () => {
try {
await newTagSchema.validate(newTag);
onAdd(newTag);
setNewTag({ key: "", value: "" });
keyInputRef.current?.focus();
} catch (e) {
const isValidationError = e instanceof Yup.ValidationError;

if (!isValidationError) {
throw e;
}

if (e instanceof Yup.ValidationError) {
setError(e.errors[0]);
}
}
};

const addNewTagOnEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addNewTag();
}
};

return (
<div className="flex flex-col gap-1 max-w-72">
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor="tag-key-input">
Tag key
</label>
<TextField
inputRef={keyInputRef}
size="small"
id="tag-key-input"
name="key"
placeholder="Key"
value={newTag.key}
onChange={(e) => setNewTag({ ...newTag, key: e.target.value.trim() })}
onKeyDown={addNewTagOnEnter}
/>

<label className="sr-only" htmlFor="tag-value-input">
Tag value
</label>
<TextField
size="small"
id="tag-value-input"
name="value"
placeholder="Value"
value={newTag.value}
onChange={(e) =>
setNewTag({ ...newTag, value: e.target.value.trim() })
}
onKeyDown={addNewTagOnEnter}
/>

<Button
className="flex-shrink-0"
size="icon"
type="button"
onClick={addNewTag}
>
<PlusIcon />
<span className="sr-only">Add tag</span>
</Button>
</div>
{error && (
<span className="text-xs text-content-destructive">{error}</span>
)}
</div>
);
};
Loading
Loading