mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): improve bulk delete flow (#11093)
This commit is contained in:
@@ -10,19 +10,19 @@ const FALLBACK_ICON = "/icon/widgets.svg";
|
||||
const BUILT_IN_ICON_PATHS: {
|
||||
[resourceType: WorkspaceResource["type"]]: string;
|
||||
} = {
|
||||
docker_volume: "/icon/folder.svg",
|
||||
docker_volume: "/icon/database.svg",
|
||||
docker_container: "/icon/memory.svg",
|
||||
docker_image: "/icon/image.svg",
|
||||
kubernetes_persistent_volume_claim: "/icon/folder.svg",
|
||||
docker_image: "/icon/container.svg",
|
||||
kubernetes_persistent_volume_claim: "/icon/database.svg",
|
||||
kubernetes_pod: "/icon/memory.svg",
|
||||
google_compute_disk: "/icon/folder.svg",
|
||||
google_compute_disk: "/icon/database.svg",
|
||||
google_compute_instance: "/icon/memory.svg",
|
||||
aws_instance: "/icon/memory.svg",
|
||||
kubernetes_deployment: "/icon/memory.svg",
|
||||
null_resource: FALLBACK_ICON,
|
||||
};
|
||||
|
||||
const getIconPathResource = (resourceType: string): string => {
|
||||
export const getIconPathResource = (resourceType: string): string => {
|
||||
if (resourceType in BUILT_IN_ICON_PATHS) {
|
||||
return BUILT_IN_ICON_PATHS[resourceType];
|
||||
}
|
||||
|
||||
@@ -66,10 +66,15 @@ export const ScheduleDialog: FC<PropsWithChildren<ScheduleDialogProps>> = ({
|
||||
<>
|
||||
{showDormancyWarning && (
|
||||
<>
|
||||
<h4>{"Dormancy Threshold"}</h4>
|
||||
<h4>Dormancy Threshold</h4>
|
||||
<Stack direction="row" spacing={5}>
|
||||
<div css={styles.dialogDescription}>{`
|
||||
This change will result in ${inactiveWorkspacesToGoDormant} workspaces being immediately transitioned to the dormant state and ${inactiveWorkspacesToGoDormantInWeek} over the next seven days. To prevent this, do you want to reset the inactivity period for all template workspaces?`}</div>
|
||||
<div css={styles.dialogDescription}>
|
||||
This change will result in {inactiveWorkspacesToGoDormant}{" "}
|
||||
workspaces being immediately transitioned to the dormant state
|
||||
and {inactiveWorkspacesToGoDormantInWeek} over the next seven
|
||||
days. To prevent this, do you want to reset the inactivity
|
||||
period for all template workspaces?
|
||||
</div>
|
||||
<FormControlLabel
|
||||
css={{ marginTop: 16 }}
|
||||
control={
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined";
|
||||
import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/plugin/relativeTime";
|
||||
import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { type FC, useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
import { getIconPathResource } from "components/Resources/ResourceAvatar";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
|
||||
interface UseBatchActionsProps {
|
||||
onSuccess: () => Promise<void>;
|
||||
@@ -68,77 +73,257 @@ type BatchDeleteConfirmationProps = {
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { checkedWorkspaces, open, onClose, onConfirm, isLoading } = props;
|
||||
const theme = useTheme();
|
||||
const [confirmation, setConfirmation] = useState({ value: "", error: false });
|
||||
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
|
||||
checkedWorkspaces,
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [stage, setStage] = useState<
|
||||
"consequences" | "workspaces" | "resources"
|
||||
>("consequences");
|
||||
|
||||
const confirmDeletion = () => {
|
||||
setConfirmation((c) => ({ ...c, error: false }));
|
||||
|
||||
if (confirmation.value !== "DELETE") {
|
||||
setConfirmation((c) => ({ ...c, error: true }));
|
||||
return;
|
||||
const onProceed = () => {
|
||||
switch (stage) {
|
||||
case "resources":
|
||||
onConfirm();
|
||||
break;
|
||||
case "workspaces":
|
||||
setStage("resources");
|
||||
break;
|
||||
case "consequences":
|
||||
setStage("workspaces");
|
||||
break;
|
||||
}
|
||||
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const workspaceCount = `${checkedWorkspaces.length} ${
|
||||
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
|
||||
}`;
|
||||
|
||||
let confirmText: ReactNode = <>Review selected workspaces…</>;
|
||||
if (stage === "workspaces") {
|
||||
confirmText = <>Confirm {workspaceCount}…</>;
|
||||
}
|
||||
if (stage === "resources") {
|
||||
const resources = checkedWorkspaces
|
||||
.map((workspace) => workspace.latest_build.resources.length)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const resourceCount = `${resources} ${
|
||||
resources === 1 ? "resource" : "resources"
|
||||
}`;
|
||||
confirmText = (
|
||||
<>
|
||||
Delete {workspaceCount} and {resourceCount}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The flicker of these icons is quit noticeable if they aren't loaded in advance,
|
||||
// so we insert them into the document without actually displaying them yet.
|
||||
const resourceIconPreloads = [
|
||||
...new Set(
|
||||
checkedWorkspaces.flatMap((workspace) =>
|
||||
workspace.latest_build.resources.map(
|
||||
(resource) => resource.icon || getIconPathResource(resource.type),
|
||||
),
|
||||
),
|
||||
),
|
||||
].map((url) => (
|
||||
<img key={url} alt="" aria-hidden css={{ ...visuallyHidden }} src={url} />
|
||||
));
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
type="delete"
|
||||
open={open}
|
||||
confirmLoading={isLoading}
|
||||
onConfirm={confirmDeletion}
|
||||
onClose={() => {
|
||||
setStage("consequences");
|
||||
onClose();
|
||||
setConfirmation({ value: "", error: false });
|
||||
}}
|
||||
title={`Delete ${checkedWorkspaces?.length} ${
|
||||
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
|
||||
}`}
|
||||
title={`Delete ${workspaceCount}`}
|
||||
hideCancel
|
||||
confirmLoading={isLoading}
|
||||
confirmText={confirmText}
|
||||
onConfirm={onProceed}
|
||||
type="delete"
|
||||
description={
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
confirmDeletion();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
Deleting these workspaces is irreversible! Are you sure you want to
|
||||
proceed? Type{" "}
|
||||
<code
|
||||
css={{
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
`DELETE`
|
||||
</code>{" "}
|
||||
to confirm.
|
||||
</div>
|
||||
<TextField
|
||||
value={confirmation.value}
|
||||
required
|
||||
autoFocus
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": "Type DELETE to confirm",
|
||||
}}
|
||||
placeholder="Type DELETE to confirm"
|
||||
css={{ marginTop: 16 }}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget?.value;
|
||||
setConfirmation((c) => ({ ...c, value }));
|
||||
}}
|
||||
error={confirmation.error}
|
||||
helperText={confirmation.error && "Please type DELETE to confirm"}
|
||||
/>
|
||||
</form>
|
||||
<>
|
||||
{stage === "consequences" && <Consequences />}
|
||||
{stage === "workspaces" && (
|
||||
<Workspaces workspaces={checkedWorkspaces} />
|
||||
)}
|
||||
{stage === "resources" && (
|
||||
<Resources workspaces={checkedWorkspaces} />
|
||||
)}
|
||||
{resourceIconPreloads}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface StageProps {
|
||||
workspaces: Workspace[];
|
||||
}
|
||||
|
||||
const Consequences: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<p>Deleting workspaces is irreversible!</p>
|
||||
<ul css={styles.consequences}>
|
||||
<li>
|
||||
Terraform resources belonging to deleted workspaces will be destroyed.
|
||||
</li>
|
||||
<li>Any data stored in the workspace will be permanently deleted.</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Workspaces: FC<StageProps> = ({ workspaces }) => {
|
||||
const mostRecent = workspaces.reduce(
|
||||
(latestSoFar, against) => {
|
||||
if (!latestSoFar) {
|
||||
return against;
|
||||
}
|
||||
|
||||
return new Date(against.last_used_at).getTime() >
|
||||
new Date(latestSoFar.last_used_at).getTime()
|
||||
? against
|
||||
: latestSoFar;
|
||||
},
|
||||
undefined as Workspace | undefined,
|
||||
);
|
||||
|
||||
const owners = new Set(workspaces.map((it) => it.owner_id)).size;
|
||||
const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul css={styles.workspacesList}>
|
||||
{workspaces.map((workspace) => (
|
||||
<li key={workspace.id} css={styles.workspace}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<span css={{ fontWeight: 500, color: "#fff" }}>
|
||||
{workspace.name}
|
||||
</span>
|
||||
<Stack css={{ gap: 0, fontSize: 14, width: 128 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<PersonIcon />
|
||||
<span
|
||||
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
|
||||
>
|
||||
{workspace.owner_name}
|
||||
</span>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<ScheduleIcon css={styles.summaryIcon} />
|
||||
<span
|
||||
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
|
||||
>
|
||||
{dayjs(workspace.last_used_at).fromNow()}
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Stack justifyContent="center" direction="row" css={{ fontSize: 14 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<PersonIcon />
|
||||
<span>{ownersCount}</span>
|
||||
</Stack>
|
||||
{mostRecent && (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<ScheduleIcon css={styles.summaryIcon} />
|
||||
<span>Last used {dayjs(mostRecent.last_used_at).fromNow()}</span>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Resources: FC<StageProps> = ({ workspaces }) => {
|
||||
const resources: Record<string, { count: number; icon: string }> = {};
|
||||
workspaces.forEach((workspace) =>
|
||||
workspace.latest_build.resources.forEach((resource) => {
|
||||
if (!resources[resource.type]) {
|
||||
resources[resource.type] = {
|
||||
count: 0,
|
||||
icon: resource.icon || getIconPathResource(resource.type),
|
||||
};
|
||||
}
|
||||
|
||||
resources[resource.type].count++;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<p>
|
||||
Deleting{" "}
|
||||
{workspaces.length === 1 ? "this workspace" : "these workspaces"} will
|
||||
also permanently destroy…
|
||||
</p>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
wrap="wrap"
|
||||
css={{ gap: "6px 20px", fontSize: 14 }}
|
||||
>
|
||||
{Object.entries(resources).map(([type, summary]) => (
|
||||
<Stack key={type} direction="row" alignItems="center" spacing={1}>
|
||||
<img alt="" src={summary.icon} css={styles.summaryIcon} />
|
||||
<span>
|
||||
{summary.count} <code>{type}</code>
|
||||
</span>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const PersonIcon: FC = () => {
|
||||
// This size doesn't match the rest of the icons because MUI is just really
|
||||
// inconsistent. We have to make it bigger than the rest, and pull things in
|
||||
// on the sides to compensate.
|
||||
return <PersonOutlinedIcon css={{ width: 18, height: 18, margin: -1 }} />;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
summaryIcon: { width: 16, height: 16 },
|
||||
|
||||
consequences: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
paddingLeft: 16,
|
||||
marginBottom: 0,
|
||||
},
|
||||
|
||||
workspacesList: (theme) => ({
|
||||
listStyleType: "none",
|
||||
padding: 0,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden auto",
|
||||
maxHeight: 184,
|
||||
}),
|
||||
|
||||
workspace: (theme) => ({
|
||||
padding: "8px 16px",
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
"&:last-child": {
|
||||
border: "none",
|
||||
},
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockWorkspace, MockUser2 } from "testHelpers/entities";
|
||||
import { BatchDeleteConfirmation } from "./BatchActions";
|
||||
|
||||
const meta: Meta<typeof BatchDeleteConfirmation> = {
|
||||
title: "pages/WorkspacesPage/BatchDelete",
|
||||
component: BatchDeleteConfirmation,
|
||||
args: {
|
||||
onClose: action("onClose"),
|
||||
onConfirm: action("onConfirm"),
|
||||
open: true,
|
||||
checkedWorkspaces: [
|
||||
MockWorkspace,
|
||||
{
|
||||
...MockWorkspace,
|
||||
name: "Test-Workspace-2",
|
||||
last_used_at: "2023-08-16T15:29:10.302441433Z",
|
||||
owner_id: MockUser2.id,
|
||||
owner_name: MockUser2.username,
|
||||
},
|
||||
{
|
||||
...MockWorkspace,
|
||||
name: "Test-Workspace-3",
|
||||
last_used_at: "2023-11-16T15:29:10.302441433Z",
|
||||
owner_id: MockUser2.id,
|
||||
owner_name: MockUser2.username,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BatchDeleteConfirmation>;
|
||||
|
||||
const Example: Story = {};
|
||||
|
||||
export { Example as BatchDelete };
|
||||
@@ -63,11 +63,17 @@ describe("WorkspacesPage", () => {
|
||||
|
||||
await user.click(getWorkspaceCheckbox(workspaces[0]));
|
||||
await user.click(getWorkspaceCheckbox(workspaces[1]));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /actions/i }));
|
||||
const deleteButton = await screen.findByText(/delete/i);
|
||||
await user.click(deleteButton);
|
||||
await user.type(screen.getByLabelText(/type delete to confirm/i), "DELETE");
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// The button changes its text, and advances the content of the modal,
|
||||
// but it is technically the same button being clicked 3 times.
|
||||
const confirmButton = await screen.findByTestId("confirm-button");
|
||||
await user.click(confirmButton);
|
||||
await user.click(confirmButton);
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteWorkspace).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -127,7 +127,7 @@ const mockTemplates = [
|
||||
];
|
||||
|
||||
const meta: Meta<typeof WorkspacesPageView> = {
|
||||
title: "pages/WorkspacesPageView",
|
||||
title: "pages/WorkspacesPage",
|
||||
component: WorkspacesPageView,
|
||||
args: {
|
||||
limit: DEFAULT_RECORDS_PER_PAGE,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"code.svg",
|
||||
"coder.svg",
|
||||
"conda.svg",
|
||||
"container.svg",
|
||||
"database.svg",
|
||||
"datagrip.svg",
|
||||
"dataspell.svg",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-container"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></svg>
|
||||
|
After Width: | Height: | Size: 490 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#FFF"><path d="M24 22q-8.05 0-13.025-2.45T6 14q0-3.15 4.975-5.575Q15.95 6 24 6t13.025 2.425Q42 10.85 42 14q0 3.1-4.975 5.55Q32.05 22 24 22Zm0 10q-7.3 0-12.65-2.2Q6 27.6 6 24.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 27 24 27q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 21.5 42 19.5v5q0 3.1-5.35 5.3Q31.3 32 24 32Zm0 10q-7.3 0-12.65-2.2Q6 37.6 6 34.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 37 24 37q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 31.5 42 29.5v5q0 3.1-5.35 5.3Q31.3 42 24 42Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 630 B After Width: | Height: | Size: 320 B |
@@ -1 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#FFF"><path d="M18.85 29.15V18.9H29.1v10.25Zm3-3h4.25V21.9h-4.25ZM18 42v-4h-5q-1.2 0-2.1-.9-.9-.9-.9-2.1v-5H6v-3h4v-6.2H6v-3h4v-5q0-1.2.9-2.1.9-.9 2.1-.9h5V6h3v3.8h6.2V6h3v3.8h5q1.2 0 2.1.9.9.9.9 2.1v5H42v3h-3.8V27H42v3h-3.8v5q0 1.2-.9 2.1-.9.9-2.1.9h-5v4h-3v-4H21v4Zm17.2-7V12.8H13V35ZM24 24Z"/></svg>
|
||||
<svg width="24" height="24" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_197_2)">
|
||||
<path d="M9 2H3C2.44772 2 2 2.44772 2 3V9C2 9.55228 2.44772 10 3 10H9C9.55228 10 10 9.55228 10 9V3C10 2.44772 9.55228 2 9 2Z" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 4.5H4.5V7.5H7.5V4.5Z" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 0.5V2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 0.5V2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 10V11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 10V11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 4.5H11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 7H11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M0.5 4.5H2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M0.5 7H2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_197_2">
|
||||
<rect width="12" height="12" fill="#fff"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 1.2 KiB |
Reference in New Issue
Block a user