mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: manage provisioner tags in template editor (#11600)
This commit is contained in:
@@ -106,6 +106,7 @@
|
||||
"@storybook/addon-links": "7.5.2",
|
||||
"@storybook/addon-mdx-gfm": "7.5.2",
|
||||
"@storybook/addon-themes": "7.6.4",
|
||||
"@storybook/preview-api": "7.6.9",
|
||||
"@storybook/react": "7.5.2",
|
||||
"@storybook/react-vite": "7.5.2",
|
||||
"@swc/core": "1.3.38",
|
||||
|
||||
Generated
+71
-17
@@ -239,6 +239,9 @@ devDependencies:
|
||||
'@storybook/addon-themes':
|
||||
specifier: 7.6.4
|
||||
version: 7.6.4
|
||||
'@storybook/preview-api':
|
||||
specifier: 7.6.9
|
||||
version: 7.6.9
|
||||
'@storybook/react':
|
||||
specifier: 7.5.2
|
||||
version: 7.5.2(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)
|
||||
@@ -406,7 +409,7 @@ devDependencies:
|
||||
version: 7.5.2
|
||||
storybook-addon-react-router-v6:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0(@storybook/blocks@7.5.3)(@storybook/channels@7.5.3)(@storybook/components@7.5.3)(@storybook/core-events@7.5.3)(@storybook/manager-api@7.5.3)(@storybook/preview-api@7.5.3)(@storybook/theming@7.5.3)(react-dom@18.2.0)(react-router-dom@6.20.0)(react-router@6.20.0)(react@18.2.0)
|
||||
version: 2.0.0(@storybook/blocks@7.5.3)(@storybook/channels@7.5.3)(@storybook/components@7.5.3)(@storybook/core-events@7.5.3)(@storybook/manager-api@7.5.3)(@storybook/preview-api@7.6.9)(@storybook/theming@7.5.3)(react-dom@18.2.0)(react-router-dom@6.20.0)(react-router@6.20.0)(react@18.2.0)
|
||||
storybook-react-context:
|
||||
specifier: 0.6.0
|
||||
version: 0.6.0(react-dom@18.2.0)
|
||||
@@ -523,14 +526,14 @@ packages:
|
||||
resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
'@babel/types': 7.23.0
|
||||
'@babel/types': 7.23.4
|
||||
dev: true
|
||||
|
||||
/@babel/helper-builder-binary-assignment-operator-visitor@7.22.15:
|
||||
resolution: {integrity: sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
'@babel/types': 7.23.0
|
||||
'@babel/types': 7.23.4
|
||||
dev: true
|
||||
|
||||
/@babel/helper-compilation-targets@7.22.15:
|
||||
@@ -627,7 +630,7 @@ packages:
|
||||
resolution: {integrity: sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
'@babel/types': 7.23.0
|
||||
'@babel/types': 7.23.4
|
||||
dev: true
|
||||
|
||||
/@babel/helper-module-imports@7.22.15:
|
||||
@@ -667,7 +670,7 @@ packages:
|
||||
resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
'@babel/types': 7.23.0
|
||||
'@babel/types': 7.23.4
|
||||
dev: true
|
||||
|
||||
/@babel/helper-plugin-utils@7.22.5:
|
||||
@@ -708,7 +711,7 @@ packages:
|
||||
resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
'@babel/types': 7.23.0
|
||||
'@babel/types': 7.23.4
|
||||
dev: true
|
||||
|
||||
/@babel/helper-split-export-declaration@7.22.6:
|
||||
@@ -740,7 +743,7 @@ packages:
|
||||
dependencies:
|
||||
'@babel/helper-function-name': 7.23.0
|
||||
'@babel/template': 7.22.15
|
||||
'@babel/types': 7.23.0
|
||||
'@babel/types': 7.23.4
|
||||
dev: true
|
||||
|
||||
/@babel/helpers@7.23.2:
|
||||
@@ -4207,7 +4210,7 @@ packages:
|
||||
'@storybook/client-logger': 7.5.2
|
||||
'@storybook/components': 7.5.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@storybook/core-events': 7.5.2
|
||||
'@storybook/csf': 0.1.1
|
||||
'@storybook/csf': 0.1.2
|
||||
'@storybook/docs-tools': 7.5.2
|
||||
'@storybook/global': 5.0.0
|
||||
'@storybook/manager-api': 7.5.2(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -4365,6 +4368,17 @@ packages:
|
||||
tiny-invariant: 1.3.1
|
||||
dev: true
|
||||
|
||||
/@storybook/channels@7.6.9:
|
||||
resolution: {integrity: sha512-goGGZPT294CS1QDF65Fs+PCauvM/nTMseU913ZVSZbFTk4uvqIXOaOraqhQze8A/C8a0yls4qu2Wp00tCnyaTA==}
|
||||
dependencies:
|
||||
'@storybook/client-logger': 7.6.9
|
||||
'@storybook/core-events': 7.6.9
|
||||
'@storybook/global': 5.0.0
|
||||
qs: 6.11.2
|
||||
telejson: 7.2.0
|
||||
tiny-invariant: 1.3.1
|
||||
dev: true
|
||||
|
||||
/@storybook/cli@7.5.2:
|
||||
resolution: {integrity: sha512-8JPvA/K66zBmRFpRRwsD0JLqZUODRrGmNuAWx+Bj1K8wqbg68MYnOflbkSIxIVxrfhd39OrffV0h8CwKNL9gAg==}
|
||||
hasBin: true
|
||||
@@ -4436,13 +4450,19 @@ packages:
|
||||
'@storybook/global': 5.0.0
|
||||
dev: true
|
||||
|
||||
/@storybook/client-logger@7.6.9:
|
||||
resolution: {integrity: sha512-Xm6fa6AR3cjxabauMldBv/66OOp5IhDiUEpp4D/a7hXfvCWqwmjVJ6EPz9WzkMhcPbMJr8vWJBaS3glkFqsRng==}
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
dev: true
|
||||
|
||||
/@storybook/codemod@7.5.2:
|
||||
resolution: {integrity: sha512-PxZg0w4OlmFB4dBzB+sCgwmHNke0n1N8vNooxtcuusrLKlbUfmssYRnQn6yRSJw0WfkUYgI10CWxGaamaOFekA==}
|
||||
dependencies:
|
||||
'@babel/core': 7.23.2
|
||||
'@babel/preset-env': 7.23.2(@babel/core@7.23.2)
|
||||
'@babel/types': 7.23.0
|
||||
'@storybook/csf': 0.1.1
|
||||
'@storybook/csf': 0.1.2
|
||||
'@storybook/csf-tools': 7.5.2
|
||||
'@storybook/node-logger': 7.5.2
|
||||
'@storybook/types': 7.5.2
|
||||
@@ -4590,6 +4610,12 @@ packages:
|
||||
ts-dedent: 2.2.0
|
||||
dev: true
|
||||
|
||||
/@storybook/core-events@7.6.9:
|
||||
resolution: {integrity: sha512-YCds7AA6sbnnZ2qq5l+AIxhQqYlXB8eVTkjj6phgczsLjkqKapYFxAFc3ppRnE0FcsL2iji17ikHzZ8+eHYznA==}
|
||||
dependencies:
|
||||
ts-dedent: 2.2.0
|
||||
dev: true
|
||||
|
||||
/@storybook/core-server@7.5.2:
|
||||
resolution: {integrity: sha512-4oXpy1L/NyHiz/OXNUFnSeMLA/+lTgQAlVx86pRbEBDj6snt1/NSx2+yZyFtZ/XTnJ22BPpM8IIrgm95ZlQKmA==}
|
||||
dependencies:
|
||||
@@ -4599,7 +4625,7 @@ packages:
|
||||
'@storybook/channels': 7.5.2
|
||||
'@storybook/core-common': 7.5.2
|
||||
'@storybook/core-events': 7.5.2
|
||||
'@storybook/csf': 0.1.1
|
||||
'@storybook/csf': 0.1.2
|
||||
'@storybook/csf-tools': 7.5.2
|
||||
'@storybook/docs-mdx': 0.1.0
|
||||
'@storybook/global': 5.0.0
|
||||
@@ -4657,7 +4683,7 @@ packages:
|
||||
'@babel/parser': 7.23.0
|
||||
'@babel/traverse': 7.23.2
|
||||
'@babel/types': 7.23.0
|
||||
'@storybook/csf': 0.1.1
|
||||
'@storybook/csf': 0.1.2
|
||||
'@storybook/types': 7.5.2
|
||||
fs-extra: 11.1.1
|
||||
recast: 0.23.4
|
||||
@@ -4834,6 +4860,25 @@ packages:
|
||||
util-deprecate: 1.0.2
|
||||
dev: true
|
||||
|
||||
/@storybook/preview-api@7.6.9:
|
||||
resolution: {integrity: sha512-qVRylkOc70Ivz/oRE3cXaQA9r60qXSCXhY8xFjnBvZFjoYr0ImGx+tt0818YzSkhTf6LsNbx9HxwW4+x7JD6dw==}
|
||||
dependencies:
|
||||
'@storybook/channels': 7.6.9
|
||||
'@storybook/client-logger': 7.6.9
|
||||
'@storybook/core-events': 7.6.9
|
||||
'@storybook/csf': 0.1.2
|
||||
'@storybook/global': 5.0.0
|
||||
'@storybook/types': 7.6.9
|
||||
'@types/qs': 6.9.10
|
||||
dequal: 2.0.3
|
||||
lodash: 4.17.21
|
||||
memoizerific: 1.11.3
|
||||
qs: 6.11.2
|
||||
synchronous-promise: 2.0.17
|
||||
ts-dedent: 2.2.0
|
||||
util-deprecate: 1.0.2
|
||||
dev: true
|
||||
|
||||
/@storybook/preview@7.5.2:
|
||||
resolution: {integrity: sha512-dA5VpHp0D9nh9/wOzWP8At1wtz/SiaMBbwaiEOFTFUGcPerrkroEWadIlSSB7vgQJ9yWiD4l3KDaS8ANzHWtPQ==}
|
||||
dev: true
|
||||
@@ -5041,6 +5086,15 @@ packages:
|
||||
file-system-cache: 2.3.0
|
||||
dev: true
|
||||
|
||||
/@storybook/types@7.6.9:
|
||||
resolution: {integrity: sha512-Qnx7exS6bO1MrqasHl12h8/HeBuxrwg2oMXROO7t0qmprV6+DGb6OxztsVIgbKR+m6uqFFM1q+f/Q5soI1qJ6g==}
|
||||
dependencies:
|
||||
'@storybook/channels': 7.6.9
|
||||
'@types/babel__core': 7.20.5
|
||||
'@types/express': 4.17.17
|
||||
file-system-cache: 2.3.0
|
||||
dev: true
|
||||
|
||||
/@swc/core-darwin-arm64@1.3.38:
|
||||
resolution: {integrity: sha512-4ZTJJ/cR0EsXW5UxFCifZoGfzQ07a8s4ayt1nLvLQ5QoB1GTAf9zsACpvWG8e7cmCR0L76R5xt8uJuyr+noIXA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6474,7 +6528,7 @@ packages:
|
||||
dependencies:
|
||||
'@babel/core': 7.23.2
|
||||
'@jest/transform': 29.7.0
|
||||
'@types/babel__core': 7.20.3
|
||||
'@types/babel__core': 7.20.5
|
||||
babel-plugin-istanbul: 6.1.1
|
||||
babel-preset-jest: 29.5.0(@babel/core@7.23.2)
|
||||
chalk: 4.1.2
|
||||
@@ -6502,9 +6556,9 @@ packages:
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
dependencies:
|
||||
'@babel/template': 7.22.15
|
||||
'@babel/types': 7.23.0
|
||||
'@types/babel__core': 7.20.3
|
||||
'@types/babel__traverse': 7.20.3
|
||||
'@babel/types': 7.23.4
|
||||
'@types/babel__core': 7.20.5
|
||||
'@types/babel__traverse': 7.20.4
|
||||
dev: true
|
||||
|
||||
/babel-plugin-macros@3.1.0:
|
||||
@@ -13162,7 +13216,7 @@ packages:
|
||||
resolution: {integrity: sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==}
|
||||
dev: true
|
||||
|
||||
/storybook-addon-react-router-v6@2.0.0(@storybook/blocks@7.5.3)(@storybook/channels@7.5.3)(@storybook/components@7.5.3)(@storybook/core-events@7.5.3)(@storybook/manager-api@7.5.3)(@storybook/preview-api@7.5.3)(@storybook/theming@7.5.3)(react-dom@18.2.0)(react-router-dom@6.20.0)(react-router@6.20.0)(react@18.2.0):
|
||||
/storybook-addon-react-router-v6@2.0.0(@storybook/blocks@7.5.3)(@storybook/channels@7.5.3)(@storybook/components@7.5.3)(@storybook/core-events@7.5.3)(@storybook/manager-api@7.5.3)(@storybook/preview-api@7.6.9)(@storybook/theming@7.5.3)(react-dom@18.2.0)(react-router-dom@6.20.0)(react-router@6.20.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-M+PR7rdacFDwUCQZRBJVnzyEOqHrDVrTqN8ufqo+TuXxk33QZvb3QeZuo0d2UTYctgA1GY74EX9RJCEXZpv6VQ==}
|
||||
peerDependencies:
|
||||
'@storybook/blocks': ^7.0.0
|
||||
@@ -13187,7 +13241,7 @@ packages:
|
||||
'@storybook/components': 7.5.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@storybook/core-events': 7.5.3
|
||||
'@storybook/manager-api': 7.5.3(react-dom@18.2.0)(react@18.2.0)
|
||||
'@storybook/preview-api': 7.5.3
|
||||
'@storybook/preview-api': 7.6.9
|
||||
'@storybook/theming': 7.5.3(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
@@ -170,7 +170,7 @@ export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
padding: "8px 16px 8px 8px",
|
||||
padding: 8,
|
||||
gap: 8,
|
||||
cursor: "default",
|
||||
}}
|
||||
@@ -182,11 +182,7 @@ export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
type BooleanPillProps = Omit<
|
||||
ComponentProps<typeof Pill>,
|
||||
"children" | "icon" | "value"
|
||||
> & {
|
||||
children: string;
|
||||
type BooleanPillProps = Omit<ComponentProps<typeof Pill>, "icon" | "value"> & {
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ import Person from "@mui/icons-material/Person";
|
||||
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Sell from "@mui/icons-material/Sell";
|
||||
import { FC } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
|
||||
export const ProvisionerDaemonsPage = () => {
|
||||
const healthStatus = useOutletContext<HealthcheckReport>();
|
||||
@@ -129,9 +132,9 @@ export const ProvisionerDaemonsPage = () => {
|
||||
</span>
|
||||
</Pill>
|
||||
</Tooltip>
|
||||
{Object.keys(extraTags).map((k) =>
|
||||
renderTag(k, extraTags[k]),
|
||||
)}
|
||||
{Object.keys(extraTags).map((k) => (
|
||||
<ProvisionerTag key={k} k={k} v={extraTags[k]} />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -188,13 +191,42 @@ const parseBool = (s: string): { valid: boolean; value: boolean } => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderTag = (k: string, v: string) => {
|
||||
interface ProvisionerTagProps {
|
||||
k: string;
|
||||
v: string;
|
||||
onDelete?: (key: string) => void;
|
||||
}
|
||||
|
||||
export const ProvisionerTag: FC<ProvisionerTagProps> = ({ k, v, onDelete }) => {
|
||||
const { valid, value: boolValue } = parseBool(v);
|
||||
const kv = `${k}: ${v}`;
|
||||
const content = onDelete ? (
|
||||
<>
|
||||
{kv}
|
||||
<IconButton
|
||||
aria-label={"delete-" + k}
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
onDelete(k);
|
||||
}}
|
||||
>
|
||||
<CloseIcon
|
||||
fontSize="inherit"
|
||||
css={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
kv
|
||||
);
|
||||
if (valid) {
|
||||
return <BooleanPill value={boolValue}>{kv}</BooleanPill>;
|
||||
return <BooleanPill value={boolValue}>{content}</BooleanPill>;
|
||||
}
|
||||
return <Pill icon={<Sell />}>{kv}</Pill>;
|
||||
return <Pill icon={<Sell />}>{content}</Pill>;
|
||||
};
|
||||
|
||||
export default ProvisionerDaemonsPage;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { MockTemplateVersion } from "testHelpers/entities";
|
||||
import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover";
|
||||
import { useArgs } from "@storybook/preview-api";
|
||||
|
||||
const meta: Meta<typeof ProvisionerTagsPopover> = {
|
||||
title: "component/ProvisionerTagsPopover",
|
||||
parameters: {
|
||||
chromatic,
|
||||
layout: "centered",
|
||||
},
|
||||
component: ProvisionerTagsPopover,
|
||||
args: {
|
||||
tags: MockTemplateVersion.job.tags,
|
||||
},
|
||||
render: function Render(args) {
|
||||
const [{ tags }, updateArgs] = useArgs();
|
||||
|
||||
return (
|
||||
<ProvisionerTagsPopover
|
||||
{...args}
|
||||
tags={tags}
|
||||
onSubmit={({ key, value }) => {
|
||||
updateArgs({ tags: { ...tags, [key]: value } });
|
||||
}}
|
||||
onDelete={(key) => {
|
||||
const newTags = { ...tags };
|
||||
delete newTags[key];
|
||||
updateArgs({ tags: newTags });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProvisionerTagsPopover>;
|
||||
|
||||
export const Example: Story = {};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { renderComponent } from "testHelpers/renderHelpers";
|
||||
import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { MockTemplateVersion } from "testHelpers/entities";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
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: organization/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 el4 = await screen.findByText(/foo: bar/i);
|
||||
expect(el4).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: whatatag/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: whatatag/i);
|
||||
expect(el2).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { TopbarButton } from "components/FullPageLayout/Topbar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { ProvisionerTag } from "pages/HealthPage/ProvisionerDaemonsPage";
|
||||
import { type FC } from "react";
|
||||
import useTheme from "@mui/system/useTheme";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { getFormHelpers, onChangeTrimmed } from "utils/formUtils";
|
||||
import { FormFields, FormSection, VerticalForm } from "components/Form/Form";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import Link from "@mui/material/Link";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
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;
|
||||
}),
|
||||
});
|
||||
|
||||
interface ProvisionerTagsPopoverProps {
|
||||
tags: Record<string, string>;
|
||||
onSubmit: (values: typeof initialValues) => void;
|
||||
onDelete: (key: string) => void;
|
||||
}
|
||||
|
||||
export const ProvisionerTagsPopover: FC<ProvisionerTagsPopoverProps> = ({
|
||||
tags,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const form = useFormik({
|
||||
initialValues,
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
onSubmit(values);
|
||||
form.resetForm();
|
||||
},
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<TopbarButton
|
||||
color="neutral"
|
||||
css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }}
|
||||
>
|
||||
<ExpandMoreOutlined css={{ fontSize: 14 }} />
|
||||
</TopbarButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
horizontal="right"
|
||||
css={{ ".MuiPaper-root": { width: 300 } }}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
color: theme.palette.text.secondary,
|
||||
padding: 20,
|
||||
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.keys(tags)
|
||||
.filter((key) => {
|
||||
// filter out owner since you cannot override it
|
||||
return key !== "owner";
|
||||
})
|
||||
.map((k) => (
|
||||
<>
|
||||
{k === "scope" ? (
|
||||
<ProvisionerTag key={k} k={k} v={tags[k]} />
|
||||
) : (
|
||||
<ProvisionerTag
|
||||
key={k}
|
||||
k={k}
|
||||
v={tags[k]}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
TopbarIconButton,
|
||||
} from "components/FullPageLayout/Topbar";
|
||||
import { Sidebar } from "components/FullPageLayout/Sidebar";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import { ProvisionerTagsPopover } from "./ProvisionerTagsPopover";
|
||||
|
||||
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
|
||||
|
||||
@@ -78,6 +80,8 @@ export interface TemplateVersionEditorProps {
|
||||
onSubmitMissingVariableValues: (values: VariableValue[]) => void;
|
||||
onCancelSubmitMissingVariableValues: () => void;
|
||||
defaultTab?: Tab;
|
||||
provisionerTags: Record<string, string>;
|
||||
onUpdateProvisionerTags: (tags: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const findInitialFile = (fileTree: FileTree): string | undefined => {
|
||||
@@ -114,6 +118,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
onSubmitMissingVariableValues,
|
||||
onCancelSubmitMissingVariableValues,
|
||||
defaultTab,
|
||||
provisionerTags,
|
||||
onUpdateProvisionerTags,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [selectedTab, setSelectedTab] = useState<Tab>(defaultTab);
|
||||
@@ -236,20 +242,45 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
<TemplateVersionStatusBadge version={templateVersion} />
|
||||
)}
|
||||
|
||||
<TopbarButton
|
||||
startIcon={
|
||||
<PlayArrowOutlined
|
||||
css={{ color: theme.palette.success.light }}
|
||||
/>
|
||||
}
|
||||
title="Build template (Ctrl + Enter)"
|
||||
disabled={disablePreview}
|
||||
onClick={() => {
|
||||
triggerPreview();
|
||||
<ButtonGroup
|
||||
variant="outlined"
|
||||
css={{
|
||||
// Workaround to make the border transitions smoothly on button groups
|
||||
"& > button:hover + button": {
|
||||
borderLeft: "1px solid #FFF",
|
||||
},
|
||||
}}
|
||||
disabled={disablePreview}
|
||||
>
|
||||
Build
|
||||
</TopbarButton>
|
||||
<TopbarButton
|
||||
startIcon={
|
||||
<PlayArrowOutlined
|
||||
css={{ color: theme.palette.success.light }}
|
||||
/>
|
||||
}
|
||||
title="Build template (Ctrl + Enter)"
|
||||
disabled={disablePreview}
|
||||
onClick={() => {
|
||||
triggerPreview();
|
||||
}}
|
||||
>
|
||||
Build
|
||||
</TopbarButton>
|
||||
<ProvisionerTagsPopover
|
||||
tags={provisionerTags}
|
||||
onSubmit={({ key, value }) => {
|
||||
onUpdateProvisionerTags({
|
||||
...provisionerTags,
|
||||
[key]: value,
|
||||
});
|
||||
}}
|
||||
onDelete={(key) => {
|
||||
const newTags = { ...provisionerTags };
|
||||
delete newTags[key];
|
||||
onUpdateProvisionerTags(newTags);
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
|
||||
<TopbarButton
|
||||
variant="contained"
|
||||
|
||||
@@ -102,6 +102,16 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
queryClient.setQueryData(templateVersionOptions.queryKey, newVersion);
|
||||
};
|
||||
|
||||
// Provisioner Tags
|
||||
const [provisionerTags, setProvisionerTags] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
if (templateVersionQuery.data?.job.tags) {
|
||||
setProvisionerTags(templateVersionQuery.data.job.tags);
|
||||
}
|
||||
}, [templateVersionQuery.data?.job.tags]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -127,7 +137,7 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
const newVersion = await createTemplateVersionMutation.mutateAsync({
|
||||
provisioner: "terraform",
|
||||
storage_method: "file",
|
||||
tags: templateVersionQuery.data.job.tags,
|
||||
tags: provisionerTags,
|
||||
template_id: templateQuery.data.id,
|
||||
file_id: serverFile.hash,
|
||||
});
|
||||
@@ -210,6 +220,10 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
onCancelSubmitMissingVariableValues={() => {
|
||||
setIsMissingVariablesDialogOpen(false);
|
||||
}}
|
||||
provisionerTags={provisionerTags}
|
||||
onUpdateProvisionerTags={(tags) => {
|
||||
setProvisionerTags(tags);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FullScreenLoader />
|
||||
|
||||
@@ -359,7 +359,14 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = {
|
||||
status: "succeeded",
|
||||
file_id: MockOrganization.id,
|
||||
completed_at: "2022-05-17T17:39:01.382927298Z",
|
||||
tags: {},
|
||||
tags: {
|
||||
scope: "organization",
|
||||
owner: "",
|
||||
wowzers: "whatatag",
|
||||
isCapable: "false",
|
||||
department: "engineering",
|
||||
dreaming: "true",
|
||||
},
|
||||
queue_position: 0,
|
||||
queue_size: 0,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user