Skip to content

Commit 0efb84a

Browse files
atrakhConvex, Inc.
authored andcommitted
dashboard: add ability to delete multiple projects and add cmd+k menu (#36329)
GitOrigin-RevId: ac940f3347d61fd1e66ef769b6e93d333656d27e
1 parent 96d5f6f commit 0efb84a

File tree

9 files changed

+708
-2
lines changed

9 files changed

+708
-2
lines changed

npm-packages/common/config/rush/pnpm-lock.yaml

Lines changed: 342 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

npm-packages/dashboard/dashboard-openapi.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,26 @@
208208
}
209209
}
210210
},
211+
"/delete_projects": {
212+
"post": {
213+
"operationId": "dashboard_delete_projects",
214+
"requestBody": {
215+
"content": {
216+
"application/json": {
217+
"schema": {
218+
"$ref": "#/components/schemas/DeleteProjectsArgs"
219+
}
220+
}
221+
},
222+
"required": true
223+
},
224+
"responses": {
225+
"200": {
226+
"description": ""
227+
}
228+
}
229+
}
230+
},
211231
"/deployments/{deployment_id}/configure_periodic_backup": {
212232
"post": {
213233
"operationId": "configure_periodic_backup",
@@ -3062,6 +3082,20 @@
30623082
},
30633083
"additionalProperties": false
30643084
},
3085+
"DeleteProjectsArgs": {
3086+
"type": "object",
3087+
"required": [
3088+
"projectIds"
3089+
],
3090+
"properties": {
3091+
"projectIds": {
3092+
"type": "array",
3093+
"items": {
3094+
"$ref": "#/components/schemas/ProjectId"
3095+
}
3096+
}
3097+
}
3098+
},
30653099
"DeploymentId": {
30663100
"type": "integer",
30673101
"format": "int64",

npm-packages/dashboard/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@
5757
"openapi-fetch": "~0.13.0",
5858
"react-sparklines": "~1.7.0",
5959
"posthog-js": "~1.225.0",
60-
"shx": "~0.3.4"
60+
"shx": "~0.3.4",
61+
"cmdk": "~1.1.1",
62+
"react-hotkeys-hook": "~4.4.0"
6163
},
6264
"devDependencies": {
6365
"@babel/core": "^7.20.5",

npm-packages/dashboard/src/api/projects.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function useProjectById(teamId?: number, projectId?: number) {
1818
}
1919

2020
export function useProjects(
21-
teamId?: number,
21+
teamId: number | undefined,
2222
refreshInterval?: SWRConfiguration["refreshInterval"],
2323
) {
2424
const [initialData] = useInitialData();
@@ -74,3 +74,15 @@ export function useDeleteProject(
7474
redirectTo: "/",
7575
});
7676
}
77+
78+
export function useDeleteProjects(teamId: number | undefined) {
79+
return useBBMutation({
80+
path: "/delete_projects",
81+
pathParams: undefined,
82+
mutateKey: "/teams/{team_id}/projects",
83+
mutatePathParams: {
84+
team_id: teamId?.toString() || "",
85+
},
86+
successToast: "Projects deleted.",
87+
});
88+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { Command } from "cmdk";
2+
import { TrashIcon } from "@radix-ui/react-icons";
3+
import {
4+
useCurrentProject,
5+
useDeleteProjects,
6+
useProjects,
7+
} from "api/projects";
8+
import { useCurrentTeam } from "api/teams";
9+
import React from "react";
10+
import { Checkbox } from "dashboard-common/elements/Checkbox";
11+
import { useHotkeys } from "react-hotkeys-hook";
12+
import { buttonClasses } from "dashboard-common/elements/Button";
13+
import { Spinner } from "dashboard-common/elements/Spinner";
14+
import { TimestampDistance } from "dashboard-common/elements/TimestampDistance";
15+
import { useLaunchDarkly } from "hooks/useLaunchDarkly";
16+
import { Tooltip } from "dashboard-common/elements/Tooltip";
17+
import { useClickAway } from "react-use";
18+
import { cn } from "dashboard-common/lib/cn";
19+
import { useRouter } from "next/router";
20+
21+
export function CommandPalette() {
22+
const [open, setOpen] = React.useState(false);
23+
const [search, setSearch] = React.useState("");
24+
const [pages, setPages] = React.useState<string[]>([]);
25+
const page = pages[pages.length - 1];
26+
27+
useHotkeys(["meta+k", "ctrl+k"], (event) => {
28+
event.preventDefault();
29+
setOpen((isOpen) => !isOpen);
30+
});
31+
32+
useHotkeys(["escape", "backspace"], (event) => {
33+
if (
34+
pages.length > 0 &&
35+
(event.key === "Escape" || (event.key === "Backspace" && !search))
36+
) {
37+
setPages((currentPages) => currentPages.slice(0, -1));
38+
} else if (event.key === "Escape") {
39+
setOpen(false);
40+
}
41+
});
42+
43+
const ref = React.useRef<HTMLDivElement>(null);
44+
45+
useClickAway(ref, () => {
46+
setOpen(false);
47+
});
48+
49+
const isTeamAdmin = true;
50+
const { commandPalette, commandPaletteDeleteProjects } = useLaunchDarkly();
51+
52+
if (!commandPalette) {
53+
return null;
54+
}
55+
56+
return (
57+
<Command.Dialog
58+
open={open}
59+
ref={ref}
60+
label="Convex Command Palette"
61+
title="Convex Command Palette"
62+
>
63+
<Command.Input
64+
placeholder={
65+
page === "delete-projects"
66+
? "Search projects..."
67+
: "What do you want to do?"
68+
}
69+
value={search}
70+
onValueChange={setSearch}
71+
/>
72+
<Command.List>
73+
{!page && (
74+
<Command.Group heading="Projects">
75+
{commandPaletteDeleteProjects && (
76+
<Tooltip
77+
side="right"
78+
tip={
79+
!isTeamAdmin
80+
? "You must be a team admin to delete projects in bulk."
81+
: undefined
82+
}
83+
>
84+
<Command.Item
85+
onSelect={() =>
86+
setPages((currentPages) => [
87+
...currentPages,
88+
"delete-projects",
89+
])
90+
}
91+
disabled={!isTeamAdmin}
92+
>
93+
<TrashIcon className="size-4" />
94+
Delete Projects
95+
</Command.Item>
96+
</Tooltip>
97+
)}
98+
</Command.Group>
99+
)}
100+
101+
{page === "delete-projects" && (
102+
<DeleteProjectsPage onClose={() => setOpen(false)} />
103+
)}
104+
<Command.Empty>No results found.</Command.Empty>
105+
</Command.List>
106+
</Command.Dialog>
107+
);
108+
}
109+
110+
function DeleteProjectsPage({ onClose }: { onClose: () => void }) {
111+
const router = useRouter();
112+
const [projectIds, setProjectIds] = React.useState<number[]>([]);
113+
114+
const currentTeam = useCurrentTeam();
115+
const currentProject = useCurrentProject();
116+
const projects = useProjects(currentTeam?.id);
117+
const deleteProjects = useDeleteProjects(currentTeam?.id);
118+
const [isSubmitting, setIsSubmitting] = React.useState(false);
119+
120+
const handleDeleteProjects = async () => {
121+
if (projectIds.length === 0) {
122+
return;
123+
}
124+
125+
setIsSubmitting(true);
126+
setProjectIds([]);
127+
try {
128+
if (currentProject && projectIds.includes(currentProject.id)) {
129+
await router.push(`/t/${currentTeam?.slug}`);
130+
}
131+
await deleteProjects({ projectIds });
132+
onClose();
133+
} finally {
134+
setTimeout(() => {
135+
setIsSubmitting(false);
136+
}, 0);
137+
}
138+
};
139+
140+
return (
141+
<Command.Group heading="Select projects to delete">
142+
{isSubmitting && (
143+
<Command.Loading>
144+
<div className="flex items-center gap-1 text-sm text-content-secondary">
145+
<Spinner className="size-4" />
146+
Submitting...
147+
</div>
148+
</Command.Loading>
149+
)}
150+
{!isSubmitting &&
151+
projects?.map((project) => (
152+
<Command.Item
153+
key={project.id}
154+
className="flex justify-between"
155+
keywords={[project.name, project.slug]}
156+
onSelect={() =>
157+
setProjectIds(
158+
projectIds.includes(project.id)
159+
? projectIds.filter((id) => id !== project.id)
160+
: [...projectIds, project.id],
161+
)
162+
}
163+
>
164+
<div className="flex items-center gap-1">
165+
<Checkbox
166+
className="mr-1"
167+
checked={projectIds.includes(project.id)}
168+
onChange={() =>
169+
setProjectIds(
170+
projectIds.includes(project.id)
171+
? projectIds.filter((id) => id !== project.id)
172+
: [...projectIds, project.id],
173+
)
174+
}
175+
/>
176+
<p>
177+
{project.name}{" "}
178+
<span className="text-content-tertiary">({project.slug})</span>
179+
</p>
180+
</div>
181+
<TimestampDistance
182+
date={new Date(project.createTime)}
183+
prefix="Created"
184+
/>
185+
</Command.Item>
186+
))}
187+
{projectIds.length > 0 && (
188+
<Command.Item
189+
className={cn(
190+
buttonClasses({ size: "xs", variant: "neutral" }),
191+
"absolute bottom-4 right-4 z-20 flex items-center gap-4 text-xs",
192+
)}
193+
data-button
194+
onSelect={handleDeleteProjects}
195+
>
196+
<TrashIcon className="size-4" />
197+
Delete {projectIds.length}{" "}
198+
{projectIds.length === 1 ? "project" : "projects"}
199+
</Command.Item>
200+
)}
201+
</Command.Group>
202+
);
203+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
[cmdk-root] {
2+
@apply p-2 bg-background-secondary/95 dark:border backdrop-blur-sm fixed left-1/2 top-1/3 z-50 -translate-x-1/2 -translate-y-1/2 shadow-md max-w-[640px] w-full overflow-hidden rounded-lg;
3+
}
4+
5+
[cmdk-input] {
6+
@apply bg-transparent border-b mb-2 rounded-b-none px-3 py-2 text-sm block rounded placeholder:text-content-tertiary border-0 w-full focus:outline-none;
7+
}
8+
9+
[cmdk-overlay] {
10+
@apply fixed inset-0 z-40 bg-black/25;
11+
}
12+
13+
[cmdk-item] {
14+
content-visibility: auto;
15+
16+
@apply rounded text-sm flex items-center gap-1 p-2 cursor-pointer;
17+
18+
&[data-selected="true"]:not([data-button]) {
19+
@apply bg-background-tertiary;
20+
}
21+
22+
&[data-disabled="true"] {
23+
@apply text-content-tertiary cursor-not-allowed;
24+
}
25+
26+
&[data-button] {
27+
@apply text-xs;
28+
&[data-selected="true"] {
29+
@apply bg-background-primary;
30+
}
31+
}
32+
33+
&:active {
34+
@apply bg-background-tertiary;
35+
}
36+
37+
& + [cmdk-item] {
38+
margin-top: 4px;
39+
}
40+
41+
svg {
42+
width: 18px;
43+
height: 18px;
44+
}
45+
}
46+
47+
[cmdk-list] {
48+
@apply h-[330px] overflow-auto overscroll-contain transition-[height] duration-100 ease-[ease] focus:outline-none;
49+
}
50+
51+
[cmdk-separator] {
52+
@apply h-px w-full my-1 bg-border-transparent;
53+
}
54+
55+
*:not([hidden]) + [cmdk-group] {
56+
@apply mt-2;
57+
}
58+
59+
[cmdk-group-heading] {
60+
@apply select-none text-xs text-content-tertiary p-2 py-1 flex items-center sticky top-0;
61+
}
62+
63+
[cmdk-empty] {
64+
@apply text-sm flex items-center justify-center h-12 whitespace-pre-wrap text-content-tertiary;
65+
}

0 commit comments

Comments
 (0)