mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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.
|
||||
<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),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
<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.
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user