Skip to content

Commit 0b8c6ad

Browse files
authored
Merge pull request #22 from LeagueToolkit/workshop-project-testing-ux-improvements
feat(workshop): add bulk delete and pack dialogs, enhance actions menu
2 parents 5309d16 + 0a5d665 commit 0b8c6ad

File tree

12 files changed

+607
-111
lines changed

12 files changed

+607
-111
lines changed

src/components/Checkbox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
5252
ref={ref}
5353
disabled={disabled}
5454
className={twMerge(
55-
"group inline-flex shrink-0 items-center justify-center rounded border transition-colors",
55+
"group inline-flex shrink-0 cursor-pointer items-center justify-center rounded border transition-colors",
5656
sizeClasses[size],
5757
"border-surface-600 bg-surface-800",
5858
"hover:border-surface-500 hover:bg-surface-700",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { LuChevronDown, LuPackage, LuPlay, LuTrash2, LuX } from "react-icons/lu";
2+
3+
import { Button, Menu } from "@/components";
4+
import { useWorkshopDialogsStore, useWorkshopSelectionStore } from "@/stores";
5+
6+
import { useFilteredProjects } from "../api/useFilteredProjects";
7+
import { useTestProjects } from "../api/useTestProject";
8+
9+
export function ActionsMenu() {
10+
const selectedPaths = useWorkshopSelectionStore((s) => s.selectedPaths);
11+
const clear = useWorkshopSelectionStore((s) => s.clear);
12+
13+
const filteredProjects = useFilteredProjects();
14+
const openBulkDeleteDialog = useWorkshopDialogsStore((s) => s.openBulkDeleteDialog);
15+
const openBulkPackDialog = useWorkshopDialogsStore((s) => s.openBulkPackDialog);
16+
const testProjects = useTestProjects();
17+
18+
const selectedCount = selectedPaths.size;
19+
const hasSelection = selectedCount > 0;
20+
21+
function getSelectedProjects() {
22+
return filteredProjects.filter((p) => selectedPaths.has(p.path));
23+
}
24+
25+
function handleDelete() {
26+
const selected = getSelectedProjects();
27+
if (selected.length === 0) return;
28+
openBulkDeleteDialog(selected);
29+
}
30+
31+
function handlePack() {
32+
const selected = getSelectedProjects();
33+
if (selected.length === 0) return;
34+
openBulkPackDialog(selected);
35+
}
36+
37+
function handleTest() {
38+
const selected = getSelectedProjects();
39+
if (selected.length === 0) return;
40+
testProjects.mutate(
41+
{ projects: selected.map((p) => ({ path: p.path, displayName: p.displayName })) },
42+
{
43+
onSuccess: () => clear(),
44+
onError: (err) => console.error("Failed to test projects:", err.message),
45+
},
46+
);
47+
}
48+
49+
return (
50+
<Menu.Root>
51+
<Menu.Trigger
52+
disabled={!hasSelection}
53+
render={
54+
<Button
55+
variant="outline"
56+
size="sm"
57+
disabled={!hasSelection}
58+
right={<LuChevronDown className="h-3.5 w-3.5" />}
59+
>
60+
{hasSelection ? `Actions (${selectedCount})` : "Actions"}
61+
</Button>
62+
}
63+
/>
64+
<Menu.Portal>
65+
<Menu.Positioner>
66+
<Menu.Popup>
67+
<Menu.Item
68+
icon={<LuTrash2 className="h-4 w-4" />}
69+
variant="danger"
70+
onClick={handleDelete}
71+
disabled={!hasSelection}
72+
>
73+
{selectedCount > 1 ? `Delete ${selectedCount}` : "Delete"}
74+
</Menu.Item>
75+
<Menu.Item
76+
icon={<LuPackage className="h-4 w-4" />}
77+
onClick={handlePack}
78+
disabled={!hasSelection}
79+
>
80+
{selectedCount > 1 ? `Pack ${selectedCount}` : "Pack"}
81+
</Menu.Item>
82+
<Menu.Item
83+
icon={<LuPlay className="h-4 w-4" />}
84+
onClick={handleTest}
85+
disabled={!hasSelection}
86+
>
87+
{selectedCount > 1 ? `Test ${selectedCount}` : "Test"}
88+
</Menu.Item>
89+
{hasSelection && (
90+
<>
91+
<Menu.Separator />
92+
<Menu.Item icon={<LuX className="h-4 w-4" />} onClick={clear}>
93+
Clear selection
94+
</Menu.Item>
95+
</>
96+
)}
97+
</Menu.Popup>
98+
</Menu.Positioner>
99+
</Menu.Portal>
100+
</Menu.Root>
101+
);
102+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { useQueryClient } from "@tanstack/react-query";
2+
import { useCallback, useRef, useState } from "react";
3+
import { LuCheck, LuTriangleAlert, LuX } from "react-icons/lu";
4+
5+
import { Button, Dialog, Progress } from "@/components";
6+
import { api } from "@/lib/tauri";
7+
import { useWorkshopDialogsStore, useWorkshopSelectionStore } from "@/stores";
8+
9+
import { workshopKeys } from "../api/keys";
10+
11+
type Phase = "confirm" | "deleting" | "done";
12+
13+
interface DeleteItemResult {
14+
displayName: string;
15+
outcome: { ok: true } | { ok: false; error: string };
16+
}
17+
18+
export function BulkDeleteDialog() {
19+
const projects = useWorkshopDialogsStore((s) => s.bulkDeleteProjects);
20+
const closeDialog = useWorkshopDialogsStore((s) => s.closeBulkDeleteDialog);
21+
const queryClient = useQueryClient();
22+
23+
const open = projects.length > 0;
24+
25+
const [phase, setPhase] = useState<Phase>("confirm");
26+
const [currentIndex, setCurrentIndex] = useState(0);
27+
const [results, setResults] = useState<DeleteItemResult[]>([]);
28+
const cancelledRef = useRef(false);
29+
30+
const handleClose = useCallback(() => {
31+
if (phase !== "confirm") {
32+
queryClient.invalidateQueries({ queryKey: workshopKeys.projects() });
33+
}
34+
closeDialog();
35+
setPhase("confirm");
36+
setCurrentIndex(0);
37+
setResults([]);
38+
cancelledRef.current = false;
39+
useWorkshopSelectionStore.getState().clear();
40+
}, [closeDialog, queryClient, phase]);
41+
42+
async function handleDelete() {
43+
cancelledRef.current = false;
44+
setPhase("deleting");
45+
setResults([]);
46+
47+
const accumulated: DeleteItemResult[] = [];
48+
49+
for (let i = 0; i < projects.length; i++) {
50+
if (cancelledRef.current) break;
51+
setCurrentIndex(i);
52+
53+
const result = await api.deleteWorkshopProject(projects[i].path);
54+
55+
const item: DeleteItemResult = result.ok
56+
? { displayName: projects[i].displayName, outcome: { ok: true } }
57+
: {
58+
displayName: projects[i].displayName,
59+
outcome: { ok: false, error: result.error.message },
60+
};
61+
62+
accumulated.push(item);
63+
setResults([...accumulated]);
64+
}
65+
66+
setPhase("done");
67+
}
68+
69+
function handleCancel() {
70+
cancelledRef.current = true;
71+
}
72+
73+
if (!open) return null;
74+
75+
const successCount = results.filter((r) => r.outcome.ok).length;
76+
77+
return (
78+
<Dialog.Root
79+
open={open}
80+
onOpenChange={(isOpen) => {
81+
if (!isOpen && phase !== "deleting") handleClose();
82+
}}
83+
>
84+
<Dialog.Portal>
85+
<Dialog.Backdrop />
86+
<Dialog.Overlay size="lg">
87+
<Dialog.Header>
88+
<Dialog.Title>Delete {projects.length} Projects</Dialog.Title>
89+
{phase !== "deleting" && <Dialog.Close />}
90+
</Dialog.Header>
91+
92+
<Dialog.Body>
93+
{phase === "confirm" && (
94+
<div className="space-y-4">
95+
<div className="flex items-start gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
96+
<LuTriangleAlert className="mt-0.5 h-5 w-5 shrink-0 text-red-400" />
97+
<div>
98+
<h3 className="font-medium text-red-300">
99+
Are you sure you want to delete {projects.length} projects?
100+
</h3>
101+
<p className="mt-1 text-sm text-surface-400">
102+
This will permanently delete all project folders and their contents. This
103+
action cannot be undone.
104+
</p>
105+
</div>
106+
</div>
107+
108+
<div className="max-h-40 overflow-y-auto rounded-lg border border-surface-600 bg-surface-900 p-3">
109+
<ul className="space-y-1.5 text-sm">
110+
{projects.map((p) => (
111+
<li key={p.path}>
112+
<span className="text-surface-300">{p.displayName}</span>
113+
<span className="ml-2 text-xs break-all text-surface-500">{p.path}</span>
114+
</li>
115+
))}
116+
</ul>
117+
</div>
118+
</div>
119+
)}
120+
121+
{(phase === "deleting" || phase === "done") && (
122+
<div className="space-y-4">
123+
{phase === "deleting" && (
124+
<Progress.Root
125+
value={currentIndex + 1}
126+
max={projects.length}
127+
label={`Deleting: ${projects[currentIndex]?.displayName ?? ""}`}
128+
valueLabel={`${currentIndex + 1} / ${projects.length}`}
129+
>
130+
<Progress.Track>
131+
<Progress.Indicator />
132+
</Progress.Track>
133+
</Progress.Root>
134+
)}
135+
136+
{phase === "done" && (
137+
<p className="text-sm text-surface-300">
138+
{cancelledRef.current
139+
? `Cancelled after ${results.length} of ${projects.length} projects.`
140+
: `Deleted ${successCount} of ${projects.length} projects.`}
141+
{successCount < results.length && ` ${results.length - successCount} failed.`}
142+
</p>
143+
)}
144+
145+
<div className="max-h-48 overflow-y-auto rounded-lg border border-surface-600 bg-surface-900 p-3">
146+
<ul className="space-y-1.5 text-sm">
147+
{results.map((r, i) => (
148+
<li key={i} className="flex items-center gap-2">
149+
{r.outcome.ok ? (
150+
<LuCheck className="h-4 w-4 shrink-0 text-green-400" />
151+
) : (
152+
<LuX className="h-4 w-4 shrink-0 text-red-400" />
153+
)}
154+
<span className={r.outcome.ok ? "text-surface-300" : "text-red-300"}>
155+
{r.displayName}
156+
</span>
157+
{!r.outcome.ok && (
158+
<span className="truncate text-xs text-red-400/70">
159+
{r.outcome.error}
160+
</span>
161+
)}
162+
</li>
163+
))}
164+
</ul>
165+
</div>
166+
</div>
167+
)}
168+
</Dialog.Body>
169+
170+
<Dialog.Footer>
171+
{phase === "confirm" && (
172+
<>
173+
<Button variant="ghost" onClick={handleClose}>
174+
Cancel
175+
</Button>
176+
<Button
177+
variant="filled"
178+
onClick={handleDelete}
179+
className="bg-red-600 hover:bg-red-500"
180+
>
181+
Delete {projects.length} {projects.length === 1 ? "Project" : "Projects"}
182+
</Button>
183+
</>
184+
)}
185+
186+
{phase === "deleting" && (
187+
<Button variant="ghost" onClick={handleCancel}>
188+
Cancel
189+
</Button>
190+
)}
191+
192+
{phase === "done" && (
193+
<Button variant="ghost" onClick={handleClose}>
194+
Close
195+
</Button>
196+
)}
197+
</Dialog.Footer>
198+
</Dialog.Overlay>
199+
</Dialog.Portal>
200+
</Dialog.Root>
201+
);
202+
}

0 commit comments

Comments
 (0)