feat(10-02): build SkillBrowser page with Browse tab, dialogs, and 5 mutations
- Replace stub with full three-tab page (Browse, Installed, Trending placeholders) - Five separate mutations: fetch, install, update, rollback, remove with distinct toasts - Agent selector dialog with skills directory input for install/update flow - Uninstall confirmation dialog with destructive button - Browse tab: search, source filter, category filter, sort, FilterBar, card grid, EmptyState - Breadcrumbs wired via useBreadcrumbs, data via TanStack Query
This commit is contained in:
parent
e3e4450113
commit
f492ec49f0
1 changed files with 561 additions and 1 deletions
|
|
@ -1,3 +1,563 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Download,
|
||||
Search,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { skillRegistryApi } from "@/api/skillRegistry";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SkillCard } from "@/components/SkillCard";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { FilterBar } from "@/components/FilterBar";
|
||||
import type { FilterValue } from "@/components/FilterBar";
|
||||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SortBy = "rating" | "name" | "recent";
|
||||
|
||||
export function SkillBrowser() {
|
||||
return <div>SkillBrowser placeholder</div>;
|
||||
const { selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
// Tab state
|
||||
const [tab, setTab] = useState<"browse" | "installed" | "trending">("browse");
|
||||
|
||||
// Browse tab filter state
|
||||
const [search, setSearch] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState<string | null>(null);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<SortBy>("rating");
|
||||
|
||||
// Dialog state
|
||||
const [installDialog, setInstallDialog] = useState<{ skillId: string; isUpdate?: boolean } | null>(null);
|
||||
const [agentSkillsDir, setAgentSkillsDir] = useState("");
|
||||
const [uninstallDialog, setUninstallDialog] = useState<{ skillId: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Workspace", href: "/dashboard" },
|
||||
{ label: "Skills" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
// Data fetching
|
||||
const { data: skills = [], isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.skillRegistry.list,
|
||||
queryFn: () => skillRegistryApi.list(),
|
||||
});
|
||||
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompany?.id ?? ""),
|
||||
queryFn: () => agentsApi.list(selectedCompany?.id ?? ""),
|
||||
enabled: !!selectedCompany?.id,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const fetchMutation = useMutation({
|
||||
mutationFn: () => skillRegistryApi.fetch(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
|
||||
pushToast({ title: "Registry refreshed", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Registry refresh failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (params: { skillId: string; agentSkillsDir: string }) =>
|
||||
skillRegistryApi.install(params.skillId, params.agentSkillsDir),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
|
||||
pushToast({ title: "Skill installed", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Install failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (params: { skillId: string; agentSkillsDir: string }) =>
|
||||
skillRegistryApi.install(params.skillId, params.agentSkillsDir),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
|
||||
pushToast({ title: "Skill updated", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Update failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (params: { skillId: string; versionId: string; agentSkillsDir: string }) =>
|
||||
skillRegistryApi.rollback(params.skillId, params.versionId, params.agentSkillsDir),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
|
||||
pushToast({ title: "Rolled back to previous version", tone: "success" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
pushToast({ title: "Rollback failed", body: err.message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (skillId: string) => skillRegistryApi.remove(skillId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.skillRegistry.list });
|
||||
pushToast({ title: "Skill uninstalled", tone: "success" });
|
||||
},
|
||||
});
|
||||
|
||||
// Derived data for filters
|
||||
const sources = useMemo(
|
||||
() => [...new Set(skills.map((s) => s.sourceId))].sort(),
|
||||
[skills],
|
||||
);
|
||||
const categories = useMemo(
|
||||
() => [...new Set(skills.map((s) => s.category).filter(Boolean) as string[])].sort(),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Browse tab filtering
|
||||
const filteredSkills = useMemo(() => {
|
||||
let result = skills.filter((s) => !s.removedAt);
|
||||
if (search) {
|
||||
result = result.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.description?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}
|
||||
if (sourceFilter) result = result.filter((s) => s.sourceId === sourceFilter);
|
||||
if (categoryFilter) result = result.filter((s) => s.category === categoryFilter);
|
||||
result = [...result];
|
||||
if (sortBy === "rating") result.sort((a, b) => (b.averageRating ?? 0) - (a.averageRating ?? 0));
|
||||
else if (sortBy === "name") result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return result;
|
||||
}, [skills, search, sourceFilter, categoryFilter, sortBy]);
|
||||
|
||||
// Active filter chips
|
||||
const activeFilters = useMemo(() => {
|
||||
const filters: FilterValue[] = [];
|
||||
if (sourceFilter) filters.push({ key: "source", label: "Source", value: sourceFilter });
|
||||
if (categoryFilter) filters.push({ key: "category", label: "Category", value: categoryFilter });
|
||||
return filters;
|
||||
}, [sourceFilter, categoryFilter]);
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSourceFilter(null);
|
||||
setCategoryFilter(null);
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (key: string) => {
|
||||
if (key === "source") setSourceFilter(null);
|
||||
if (key === "category") setCategoryFilter(null);
|
||||
};
|
||||
|
||||
// Installed tab grouping
|
||||
const installedGroups = useMemo(() => {
|
||||
const installed = skills.filter((s) => s.activeVersionId && !s.removedAt);
|
||||
if (installed.length === 0) return [];
|
||||
return [{ agentId: "all", agentName: "All Agents", skills: installed }];
|
||||
}, [skills]);
|
||||
|
||||
// Trending tab sections
|
||||
const activeSkills = useMemo(() => skills.filter((s) => !s.removedAt), [skills]);
|
||||
|
||||
const gainingTraction = useMemo(
|
||||
() => [...activeSkills].sort((a, b) => (b.ratingCount ?? 0) - (a.ratingCount ?? 0)).slice(0, 6),
|
||||
[activeSkills],
|
||||
);
|
||||
|
||||
const recentlyUpdated = useMemo(
|
||||
() => [...activeSkills].sort((a, b) => b.id.localeCompare(a.id)).slice(0, 6),
|
||||
[activeSkills],
|
||||
);
|
||||
|
||||
const youMightLike = useMemo(() => {
|
||||
const installedCategories = new Set(
|
||||
activeSkills
|
||||
.filter((s) => s.activeVersionId)
|
||||
.map((s) => s.category)
|
||||
.filter(Boolean),
|
||||
);
|
||||
if (installedCategories.size === 0)
|
||||
return activeSkills.filter((s) => !s.activeVersionId).slice(0, 6);
|
||||
return activeSkills
|
||||
.filter((s) => !s.activeVersionId && s.category && installedCategories.has(s.category))
|
||||
.slice(0, 6);
|
||||
}, [activeSkills]);
|
||||
|
||||
const handleRollback = (skillId: string) => {
|
||||
// Rollback requires a versionId — without version selection UI, use a no-op for now.
|
||||
// Full rollback flow is in Plan 03 (SkillDetail page).
|
||||
pushToast({ title: "Select a version from the skill detail page to roll back.", tone: "info" });
|
||||
void skillId;
|
||||
};
|
||||
|
||||
const handleInstallForAgent = (agentId: string) => {
|
||||
if (!installDialog) return;
|
||||
const dir = agentSkillsDir.trim() || `/agents/${agentId}/.claude/skills`;
|
||||
if (installDialog.isUpdate) {
|
||||
updateMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir });
|
||||
} else {
|
||||
installMutation.mutate({ skillId: installDialog.skillId, agentSkillsDir: dir });
|
||||
}
|
||||
setInstallDialog(null);
|
||||
setAgentSkillsDir("");
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{ value: "browse", label: "Browse" },
|
||||
{ value: "installed", label: "Installed" },
|
||||
{ value: "trending", label: "Trending" },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Skills</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchMutation.mutate()}
|
||||
disabled={fetchMutation.isPending}
|
||||
>
|
||||
{fetchMutation.isPending ? "Refreshing..." : "Refresh registry"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load skills. Check that the skill registry backend is running and try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && <PageSkeleton variant="list" />}
|
||||
|
||||
{/* Tabs */}
|
||||
{!isLoading && (
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||
<PageTabBar items={tabItems} value={tab} onValueChange={(v) => setTab(v as typeof tab)} align="start" />
|
||||
|
||||
{/* Browse tab */}
|
||||
<TabsContent value="browse" className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
placeholder="Search skills\u2026"
|
||||
aria-label="Search skills"
|
||||
className="max-w-xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={sourceFilter ?? ""}
|
||||
onValueChange={(v) => setSourceFilter(v || null)}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="All sources" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={categoryFilter ?? ""}
|
||||
onValueChange={(v) => setCategoryFilter(v || null)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(v) => setSortBy(v as SortBy)}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rating">Sort: Rating</SelectItem>
|
||||
<SelectItem value="name">Sort: Name</SelectItem>
|
||||
<SelectItem value="recent">Sort: Recent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Active filter chips */}
|
||||
<FilterBar
|
||||
filters={activeFilters}
|
||||
onRemove={handleRemoveFilter}
|
||||
onClear={handleClearFilters}
|
||||
/>
|
||||
|
||||
{/* Skill grid */}
|
||||
{filteredSkills.length > 0 && (
|
||||
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
isInstalled={!!skill.activeVersionId}
|
||||
hasUpdate={false}
|
||||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Browse empty state */}
|
||||
{filteredSkills.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
message="No skills found"
|
||||
action="Refresh registry"
|
||||
onAction={() => fetchMutation.mutate()}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Installed tab */}
|
||||
<TabsContent value="installed" className="space-y-6">
|
||||
{installedGroups.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Download}
|
||||
message="No skills installed"
|
||||
action="Browse skills"
|
||||
onAction={() => setTab("browse")}
|
||||
/>
|
||||
)}
|
||||
{installedGroups.map((group) => (
|
||||
<div key={group.agentId}>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
|
||||
<Identity name={group.agentName} size="sm" />
|
||||
<span className="text-xs text-muted-foreground ml-1">{group.skills.length}</span>
|
||||
</div>
|
||||
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-3")}>
|
||||
{group.skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
isInstalled
|
||||
hasUpdate={false}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Trending tab */}
|
||||
<TabsContent value="trending" className="space-y-8">
|
||||
{skills.length === 0 && (
|
||||
<EmptyState
|
||||
icon={TrendingUp}
|
||||
message="No trending data yet"
|
||||
/>
|
||||
)}
|
||||
{skills.length > 0 && (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Gaining Traction
|
||||
</h2>
|
||||
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
|
||||
{gainingTraction.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
isInstalled={!!skill.activeVersionId}
|
||||
hasUpdate={false}
|
||||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Recently Updated
|
||||
</h2>
|
||||
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
|
||||
{recentlyUpdated.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
isInstalled={!!skill.activeVersionId}
|
||||
hasUpdate={false}
|
||||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
You Might Like
|
||||
</h2>
|
||||
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4")}>
|
||||
{youMightLike.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
isInstalled={!!skill.activeVersionId}
|
||||
hasUpdate={false}
|
||||
onInstall={() => setInstallDialog({ skillId: skill.id })}
|
||||
onUpdate={() => setInstallDialog({ skillId: skill.id, isUpdate: true })}
|
||||
onRollback={() => handleRollback(skill.id)}
|
||||
onUninstall={() => setUninstallDialog({ skillId: skill.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* Agent selector dialog (install / update) */}
|
||||
<Dialog
|
||||
open={!!installDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setInstallDialog(null);
|
||||
setAgentSkillsDir("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which agent should receive this skill.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="skills-dir-input">
|
||||
Agent skills directory (leave blank to use default)
|
||||
</label>
|
||||
<Input
|
||||
id="skills-dir-input"
|
||||
placeholder="/path/to/agent/.claude/skills"
|
||||
value={agentSkillsDir}
|
||||
onChange={(e) => setAgentSkillsDir(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{agents.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No agents found in this workspace.</p>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<Button
|
||||
key={agent.id}
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
disabled={installMutation.isPending || updateMutation.isPending}
|
||||
onClick={() => handleInstallForAgent(agent.id)}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setInstallDialog(null);
|
||||
setAgentSkillsDir("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Uninstall confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!uninstallDialog}
|
||||
onOpenChange={(open) => !open && setUninstallDialog(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Uninstall skill?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove the skill files from the agent's directory. You can reinstall it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUninstallDialog(null)}>
|
||||
Keep skill
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() => {
|
||||
if (uninstallDialog) {
|
||||
removeMutation.mutate(uninstallDialog.skillId);
|
||||
setUninstallDialog(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Yes, uninstall
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue