feat: add provisioner tags field on template creation (#16656)

Close https://github.com/coder/coder/issues/15426

Demo:


https://github.com/user-attachments/assets/a7901908-8714-4a55-8d4f-c27bf7743111
This commit is contained in:
Bruno Quaresma
2025-02-25 14:27:51 -03:00
committed by GitHub
parent 64984648d3
commit 38ad8d1f3a
14 changed files with 394 additions and 261 deletions
+1 -1
View File
@@ -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}
@@ -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>
);
@@ -45,7 +45,6 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
<>
{kv}
<IconButton
aria-label={`delete-${tagName}`}
size="small"
color="secondary"
onClick={() => {
@@ -53,6 +52,7 @@ export const ProvisionerTag: FC<ProvisionerTagProps> = ({
}}
>
<CloseIcon fontSize="inherit" css={{ width: 14, height: 14 }} />
<span className="sr-only">Delete {tagName}</span>
</IconButton>
</>
) : (
@@ -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>
);
@@ -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();
},
};
@@ -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>
);
};
@@ -2,6 +2,7 @@ import Link from "@mui/material/Link";
import TextField from "@mui/material/TextField";
import { provisionerDaemons } from "api/queries/organizations";
import type {
CreateTemplateVersionRequest,
Organization,
ProvisionerJobLog,
ProvisionerType,
@@ -24,6 +25,7 @@ import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import camelCase from "lodash/camelCase";
import capitalize from "lodash/capitalize";
import { ProvisionerTagsField } from "modules/provisioners/ProvisionerTagsField";
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
@@ -63,6 +65,7 @@ export interface CreateTemplateFormData {
allow_everyone_group_access: boolean;
provisioner_type: ProvisionerType;
organization: string;
tags: CreateTemplateVersionRequest["tags"];
}
const validationSchema = Yup.object({
@@ -96,6 +99,7 @@ const defaultInitialValues: CreateTemplateFormData = {
allow_everyone_group_access: true,
provisioner_type: "terraform",
organization: "default",
tags: {},
};
type GetInitialValuesParams = {
@@ -217,12 +221,11 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
});
const getFieldHelpers = getFormHelpers<CreateTemplateFormData>(form, error);
const provisionerDaemonsQuery = useQuery(
const { data: provisioners } = useQuery(
selectedOrg
? {
...provisionerDaemons(selectedOrg.id),
enabled: showOrganizationPicker,
select: (provisioners) => provisioners.length < 1,
}
: { enabled: false },
);
@@ -233,7 +236,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
// form submission**!! A user could easily see this warning, connect a
// provisioner, and then not refresh the page. Even if they submit without
// a provisioner, it'll just sit in the job queue until they connect one.
const showProvisionerWarning = provisionerDaemonsQuery.data;
const showProvisionerWarning = provisioners ? provisioners.length < 1 : false;
return (
<HorizontalForm onSubmit={form.handleSubmit}>
@@ -326,6 +329,32 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</FormFields>
</FormSection>
{provisioners && provisioners.length > 0 && (
<FormSection
title="Provisioner tags"
description={
<>
Tags are a way to control which provisioner daemons complete which
build jobs.&nbsp;
<Link
href={docs("/admin/provisioners")}
target="_blank"
rel="noreferrer"
>
Learn more...
</Link>
</>
}
>
<FormFields>
<ProvisionerTagsField
value={form.values.tags}
onChange={(tags) => form.setFieldValue("tags", tags)}
/>
</FormFields>
</FormSection>
)}
{/* Variables */}
{variables && variables.length > 0 && (
<FormSection
@@ -79,6 +79,7 @@ export const DuplicateTemplateView: FC<CreateTemplatePageViewProps> = ({
templateVersionQuery.data!.job.file_id,
formData.user_variable_values,
formData.provisioner_type,
formData.tags,
),
template: newTemplate(formData),
});
@@ -7,7 +7,6 @@ import {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import type { FC } from "react";
import { useQuery } from "react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
@@ -79,6 +78,7 @@ export const ImportStarterTemplateView: FC<CreateTemplatePageViewProps> = ({
version: firstVersionFromExample(
templateExample!,
formData.user_variable_values,
formData.tags,
),
template: newTemplate(formData),
});
@@ -7,7 +7,6 @@ import {
} from "api/queries/templates";
import { displayError } from "components/GlobalSnackbar/utils";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import type { FC } from "react";
import { useMutation, useQuery } from "react-query";
import { useNavigate } from "react-router-dom";
@@ -73,6 +72,7 @@ export const UploadTemplateView: FC<CreateTemplatePageViewProps> = ({
uploadedFile!.hash,
formData.user_variable_values,
formData.provisioner_type,
formData.tags,
),
template: newTemplate(formData),
});
+4 -2
View File
@@ -58,19 +58,21 @@ export const firstVersionFromFile = (
fileId: string,
variables: VariableValue[] | undefined,
provisionerType: ProvisionerType,
tags: CreateTemplateVersionRequest["tags"],
): CreateTemplateVersionRequest => {
return {
storage_method: "file" as const,
provisioner: provisionerType,
user_variable_values: variables,
file_id: fileId,
tags: {},
tags,
};
};
export const firstVersionFromExample = (
example: TemplateExample,
variables: VariableValue[] | undefined,
tags: CreateTemplateVersionRequest["tags"],
): CreateTemplateVersionRequest => {
return {
storage_method: "file" as const,
@@ -78,6 +80,6 @@ export const firstVersionFromExample = (
provisioner: "terraform",
user_variable_values: variables,
example_id: example.id,
tags: {},
tags,
};
};
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { expect, fn, userEvent, within } from "@storybook/test";
import { useState } from "react";
import { chromatic } from "testHelpers/chromatic";
import { MockTemplateVersion } from "testHelpers/entities";
import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover";
@@ -19,14 +20,53 @@ const meta: Meta<typeof ProvisionerTagsPopover> = {
export default meta;
type Story = StoryObj<typeof ProvisionerTagsPopover>;
const Example: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
export const Closed: Story = {};
await step("Open popover", async () => {
await userEvent.click(canvas.getByRole("button"));
});
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
},
};
export { Example as ProvisionerTagsPopover };
export const OnTagsChange: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
args: {
tags: {},
},
render: (args) => {
const [tags, setTags] = useState(args.tags);
return <ProvisionerTagsPopover tags={tags} onTagsChange={fn(setTags)} />;
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const expandButton = canvas.getByRole("button", {
name: "Expand provisioner tags",
});
await userEvent.click(expandButton);
const keyInput = await canvas.findByLabelText("Tag key");
const valueInput = await canvas.findByLabelText("Tag value");
const addButton = await canvas.findByRole("button", {
name: "Add tag",
hidden: true,
});
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");
const removeButton = canvas.getByRole("button", {
name: "Delete cluster",
hidden: true,
});
await user.click(removeButton);
await expect(canvas.queryByTestId("tag-cluster")).toBeNull();
},
};
@@ -1,119 +0,0 @@
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MockTemplateVersion } from "testHelpers/entities";
import { renderComponent } from "testHelpers/renderHelpers";
import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover";
let tags = MockTemplateVersion.job.tags;
describe("ProvisionerTagsPopover", () => {
describe("click the button", () => {
it("can add a tag", async () => {
const onSubmit = jest.fn().mockImplementation(({ key, value }) => {
tags = { ...tags, [key]: value };
});
const onDelete = jest.fn().mockImplementation((key) => {
const newTags = { ...tags };
delete newTags[key];
tags = newTags;
});
const { rerender } = renderComponent(
<ProvisionerTagsPopover
tags={tags}
onSubmit={onSubmit}
onDelete={onDelete}
/>,
);
// Open Popover
const btn = await screen.findByRole("button");
expect(btn).toBeEnabled();
await userEvent.click(btn);
// Check for existing tags
const el = await screen.findByText(/scope/i);
expect(el).toBeInTheDocument();
// Add key and value
const el2 = await screen.findByLabelText("Key");
expect(el2).toBeEnabled();
fireEvent.change(el2, { target: { value: "foo" } });
expect(el2).toHaveValue("foo");
const el3 = await screen.findByLabelText("Value");
expect(el3).toBeEnabled();
fireEvent.change(el3, { target: { value: "bar" } });
expect(el3).toHaveValue("bar");
// Submit
const btn2 = await screen.findByRole("button", {
name: /add/i,
hidden: true,
});
expect(btn2).toBeEnabled();
await userEvent.click(btn2);
expect(onSubmit).toHaveBeenCalledTimes(1);
rerender(
<ProvisionerTagsPopover
tags={tags}
onSubmit={onSubmit}
onDelete={onDelete}
/>,
);
// Check for new tag
const fooTag = await screen.findByText(/foo/i);
expect(fooTag).toBeInTheDocument();
const barValue = await screen.findByText(/bar/i);
expect(barValue).toBeInTheDocument();
});
it("can remove a tag", async () => {
const onSubmit = jest.fn().mockImplementation(({ key, value }) => {
tags = { ...tags, [key]: value };
});
const onDelete = jest.fn().mockImplementation((key) => {
delete tags[key];
tags = { ...tags };
});
const { rerender } = renderComponent(
<ProvisionerTagsPopover
tags={tags}
onSubmit={onSubmit}
onDelete={onDelete}
/>,
);
// Open Popover
const btn = await screen.findByRole("button");
expect(btn).toBeEnabled();
await userEvent.click(btn);
// Check for existing tags
const el = await screen.findByText(/wowzers/i);
expect(el).toBeInTheDocument();
// Find Delete button
const btn2 = await screen.findByRole("button", {
name: /delete-wowzers/i,
hidden: true,
});
expect(btn2).toBeEnabled();
// Delete tag
await userEvent.click(btn2);
expect(onDelete).toHaveBeenCalledTimes(1);
rerender(
<ProvisionerTagsPopover
tags={tags}
onSubmit={onSubmit}
onDelete={onDelete}
/>,
);
// Expect deleted tag to be gone
const el2 = screen.queryByText(/wowzers/i);
expect(el2).not.toBeInTheDocument();
});
});
});
@@ -1,68 +1,28 @@
import AddIcon from "@mui/icons-material/Add";
import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import TextField from "@mui/material/TextField";
import useTheme from "@mui/system/useTheme";
import { FormFields, FormSection, VerticalForm } from "components/Form/Form";
import type { ProvisionerDaemon } from "api/typesGenerated";
import { FormSection } from "components/Form/Form";
import { TopbarButton } from "components/FullPageLayout/Topbar";
import { Stack } from "components/Stack/Stack";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { useFormik } from "formik";
import { ProvisionerTag } from "modules/provisioners/ProvisionerTag";
import { type FC, Fragment } from "react";
import { ProvisionerTagsField } from "modules/provisioners/ProvisionerTagsField";
import type { FC } from "react";
import { docs } from "utils/docs";
import { getFormHelpers, onChangeTrimmed } from "utils/formUtils";
import * as Yup from "yup";
const initialValues = {
key: "",
value: "",
};
const validationSchema = Yup.object({
key: Yup.string()
.required("Required")
.notOneOf(["owner"], "Cannot override owner tag"),
value: Yup.string()
.required("Required")
.when("key", ([key], schema) => {
if (key === "scope") {
return schema.oneOf(
["organization", "scope"],
"Scope value must be 'organization' or 'user'",
);
}
return schema;
}),
});
export interface ProvisionerTagsPopoverProps {
tags: Record<string, string>;
onSubmit: (values: typeof initialValues) => void;
onDelete: (key: string) => void;
tags: ProvisionerDaemon["tags"];
onTagsChange: (values: ProvisionerDaemon["tags"]) => void;
}
export const ProvisionerTagsPopover: FC<ProvisionerTagsPopoverProps> = ({
tags,
onSubmit,
onDelete,
onTagsChange,
}) => {
const theme = useTheme();
const form = useFormik({
initialValues,
validationSchema,
onSubmit: (values) => {
onSubmit(values);
form.resetForm();
},
});
const getFieldHelpers = getFormHelpers(form);
return (
<Popover>
@@ -72,6 +32,7 @@ export const ProvisionerTagsPopover: FC<ProvisionerTagsPopoverProps> = ({
css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }}
>
<ExpandMoreOutlined css={{ fontSize: 14 }} />
<span className="sr-only">Expand provisioner tags</span>
</TopbarButton>
</PopoverTrigger>
<PopoverContent
@@ -85,70 +46,27 @@ export const ProvisionerTagsPopover: FC<ProvisionerTagsPopoverProps> = ({
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<VerticalForm onSubmit={form.handleSubmit}>
<Stack>
<FormSection
title="Provisioner Tags"
description={
<>
Tags are a way to control which provisioner daemons complete
which build jobs.&nbsp;
<Link
href={docs("/admin/provisioners")}
target="_blank"
rel="noreferrer"
>
Learn more...
</Link>
</>
}
/>
<Stack direction="row" spacing={1} wrap="wrap">
{Object.entries(tags)
// filter out owner since you cannot override it
.filter(([key]) => key !== "owner")
.map(([key, value]) => (
<Fragment key={key}>
{key === "scope" ? (
<ProvisionerTag tagName={key} tagValue={value} />
) : (
<ProvisionerTag
tagName={key}
tagValue={value}
onDelete={onDelete}
/>
)}
</Fragment>
))}
</Stack>
<FormFields>
<Stack direction="row">
<TextField
{...getFieldHelpers("key")}
size="small"
onChange={onChangeTrimmed(form)}
label="Key"
/>
<TextField
{...getFieldHelpers("value")}
size="small"
onChange={onChangeTrimmed(form)}
label="Value"
/>
<Button
variant="contained"
color="secondary"
type="submit"
aria-label="add"
disabled={!form.dirty || !form.isValid}
>
<AddIcon />
</Button>
</Stack>
</FormFields>
</Stack>
</VerticalForm>
<FormSection
classes={{
root: "flex flex-col gap-4",
}}
title="Provisioner Tags"
description={
<>
Tags are a way to control which provisioner daemons complete
which build jobs.&nbsp;
<Link
href={docs("/admin/provisioners")}
target="_blank"
rel="noreferrer"
>
Learn more...
</Link>
</>
}
>
<ProvisionerTagsField value={tags} onChange={onTagsChange} />
</FormSection>
</div>
</PopoverContent>
</Popover>
@@ -272,17 +272,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</TopbarButton>
<ProvisionerTagsPopover
tags={provisionerTags}
onSubmit={({ key, value }) => {
onUpdateProvisionerTags({
...provisionerTags,
[key]: value,
});
}}
onDelete={(key) => {
const newTags = { ...provisionerTags };
delete newTags[key];
onUpdateProvisionerTags(newTags);
}}
onTagsChange={onUpdateProvisionerTags}
/>
</div>