multica/apps/web/features/issues/components/batch-action-toolbar.tsx
Jiayuan bedf4a05c8 fix(agent): fix agent visibility defaults and permission model
- Change DB default for agent visibility from 'workspace' to 'private'
- Fix canManageAgent: workspace agents are now manageable by all members,
  private agents remain restricted to owner/admin
- Add private agent visibility check to BatchAssigneePicker (was missing)
2026-03-31 14:34:04 +08:00

342 lines
12 KiB
TypeScript

"use client";
import { useState } from "react";
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import type { Agent, UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { api } from "@/shared/api";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
export function BatchActionToolbar() {
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
const clear = useIssueSelectionStore((s) => s.clear);
const count = selectedIds.size;
const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [loading, setLoading] = useState(false);
if (count === 0) return null;
const ids = Array.from(selectedIds);
const handleBatchUpdate = async (updates: UpdateIssueRequest) => {
setLoading(true);
try {
await api.batchUpdateIssues(ids, updates);
for (const id of ids) {
useIssueStore.getState().updateIssue(id, updates);
}
toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`);
} catch {
toast.error("Failed to update issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
} finally {
setLoading(false);
}
};
const handleBatchDelete = async () => {
setLoading(true);
try {
await api.batchDeleteIssues(ids);
for (const id of ids) {
useIssueStore.getState().removeIssue(id);
}
clear();
toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`);
} catch {
toast.error("Failed to delete issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
} finally {
setLoading(false);
setDeleteOpen(false);
}
};
return (
<>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 rounded-lg border bg-background px-2 py-1.5 shadow-lg">
<div className="flex items-center gap-1.5 pl-1 pr-2 border-r mr-1">
<span className="text-sm font-medium">{count} selected</span>
<button
type="button"
onClick={clear}
className="rounded p-0.5 hover:bg-accent transition-colors"
>
<X className="size-3.5 text-muted-foreground" />
</button>
</div>
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
<StatusIcon status="todo" className="h-3.5 w-3.5 mr-1" />
Status
</PopoverTrigger>
<PopoverContent align="center" className="w-44 p-1">
{ALL_STATUSES.map((s) => {
const cfg = STATUS_CONFIG[s];
return (
<button
key={s}
type="button"
onClick={() => {
handleBatchUpdate({ status: s });
setStatusOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${cfg.hoverBg} transition-colors`}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
<span>{cfg.label}</span>
</button>
);
})}
</PopoverContent>
</Popover>
{/* Priority */}
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
<PriorityIcon priority="high" className="mr-1" />
Priority
</PopoverTrigger>
<PopoverContent align="center" className="w-44 p-1">
{PRIORITY_ORDER.map((p) => {
const cfg = PRIORITY_CONFIG[p];
return (
<button
key={p}
type="button"
onClick={() => {
handleBatchUpdate({ priority: p });
setPriorityOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${cfg.badgeBg} ${cfg.badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
</button>
);
})}
</PopoverContent>
</Popover>
{/* Assignee */}
<BatchAssigneePicker
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
onUpdate={handleBatchUpdate}
loading={loading}
/>
{/* Delete */}
<Button
variant="ghost"
size="sm"
disabled={loading}
onClick={() => setDeleteOpen(true)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5 mr-1" />
Delete
</Button>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {count} issue{count > 1 ? "s" : ""}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
selected issue{count > 1 ? "s" : ""} and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleBatchDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}
function BatchAssigneePicker({
open,
onOpenChange,
onUpdate,
loading,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onUpdate: (updates: UpdateIssueRequest) => void;
loading: boolean;
}) {
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorInitials } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
);
return (
<Popover
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) setFilter("");
}}
>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
Assignee
</PopoverTrigger>
<PopoverContent align="center" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Assign to..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</button>
{filteredMembers.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Members
</div>
{filteredMembers.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => {
onUpdate({ assignee_type: "member", assignee_id: m.user_id });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<span>{m.name}</span>
</button>
))}
</div>
)}
{filteredAgents.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<button
key={a.id}
type="button"
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({ assignee_type: "agent", assignee_id: a.id });
onOpenChange(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
)}
</button>
);
})}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}