Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions src/app/helpers/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"

/**
* Admin "Remove helper" flow on the Helpers page:
* 1. Kebab menu (three vertical dots) on a helper row shows "Remove helper".
* 2. Selecting it opens a confirmation dialog ("Yes" / "Cancel").
* 3. "Yes" removes the helper and shows a success dialog with "Close".
* 4. "Cancel" closes the dialog without removing the helper.
*/

const h = vi.hoisted(() => ({
removeHelperMutateAsync: vi.fn(() => Promise.resolve({ helperId: "helper-1", projectId: "proj-1" })),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))

// --- jsdom polyfills required by Radix dropdown/popper ---
beforeAll(() => {
;(global as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {})
Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture || (() => false)
Element.prototype.setPointerCapture = Element.prototype.setPointerCapture || (() => {})
Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture || (() => {})
})

vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(""),
}))

vi.mock("next/link", () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))

vi.mock("sonner", () => ({
toast: { error: h.toastError, success: h.toastSuccess },
}))

vi.mock("@/components/layout/sidebar", () => ({ Sidebar: () => null }))
vi.mock("@/components/layout/header", () => ({ Header: () => null }))
vi.mock("@/components/drawers/add-helper-drawer", () => ({
AddHelperDrawer: () => null,
}))
vi.mock("@/components/drawers/accept-request-drawer", () => ({
AcceptRequestDrawer: () => null,
}))

vi.mock("@/contexts/project-context", () => ({
useProjectSelection: () => ({ selectedProjectId: "proj-1" }),
}))

vi.mock("@/contexts/user-context", () => ({
useUser: () => ({ user: { id: "admin-1", name: "Admin", role: "admin" } }),
}))

vi.mock("@/hooks/useHelpers", () => ({
useHelpers: () => ({
data: [
{
helper_id: "helper-1",
user_id: "user-2",
project_id: "proj-1",
category: "core",
user: { name: "Alice", username: "alice", avatar_url: null },
},
],
isLoading: false,
}),
useCreateHelper: () => ({ mutateAsync: vi.fn(), isPending: false }),
useAddSelfAsHelper: () => ({ mutateAsync: vi.fn(), isPending: false }),
useRemoveHelper: () => ({
mutateAsync: h.removeHelperMutateAsync,
isPending: false,
}),
}))

vi.mock("@/hooks/usePendingRequests", () => ({
usePendingRequests: () => ({ data: [], isLoading: false }),
useUpdatePendingRequest: () => ({ mutateAsync: vi.fn(), isPending: false }),
}))

vi.mock("@/hooks/useProject", () => ({
useCreateProjectInvite: () => ({ mutateAsync: vi.fn(), isPending: false }),
useListProjectInvites: () => ({ data: [], isLoading: false }),
useRevokeProjectInvite: () => ({ mutateAsync: vi.fn(), isPending: false }),
}))

import HelpersPage from "./page"

function openKebabMenu() {
const trigger = screen.getByRole("button", { name: /open menu for alice/i })
fireEvent.pointerDown(trigger)
fireEvent.click(trigger)
return trigger
}

describe("HelpersPage — remove helper flow", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("shows a 'Remove helper' option in the helper row kebab menu", async () => {
render(<HelpersPage />)
expect(screen.getByText("Alice")).toBeInTheDocument()

openKebabMenu()

expect(
await screen.findByRole("menuitem", { name: /remove helper/i }),
).toBeInTheDocument()
})

it("removes the helper after confirming, then shows the success dialog", async () => {
render(<HelpersPage />)

openKebabMenu()
fireEvent.click(await screen.findByRole("menuitem", { name: /remove helper/i }))

// Confirmation popup
expect(
await screen.findByText("Are you sure you want to remove this helper?"),
).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument()

fireEvent.click(screen.getByRole("button", { name: "Yes" }))

await waitFor(() => {
expect(h.removeHelperMutateAsync).toHaveBeenCalledWith({
helperId: "helper-1",
projectId: "proj-1",
})
})

// Success popup
expect(
await screen.findByText("The helper is now removed from the project"),
).toBeInTheDocument()

fireEvent.click(screen.getByRole("button", { name: "Close" }))
await waitFor(() => {
expect(
screen.queryByText("The helper is now removed from the project"),
).not.toBeInTheDocument()
})
})

it("does not remove the helper when cancelling the confirmation", async () => {
render(<HelpersPage />)

openKebabMenu()
fireEvent.click(await screen.findByRole("menuitem", { name: /remove helper/i }))

expect(
await screen.findByText("Are you sure you want to remove this helper?"),
).toBeInTheDocument()

fireEvent.click(screen.getByRole("button", { name: "Cancel" }))

await waitFor(() => {
expect(
screen.queryByText("Are you sure you want to remove this helper?"),
).not.toBeInTheDocument()
})
expect(h.removeHelperMutateAsync).not.toHaveBeenCalled()
})

it("shows an error toast and no success dialog when removal fails", async () => {
h.removeHelperMutateAsync.mockRejectedValueOnce(new Error("boom"))
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})

render(<HelpersPage />)

openKebabMenu()
fireEvent.click(await screen.findByRole("menuitem", { name: /remove helper/i }))
fireEvent.click(await screen.findByRole("button", { name: "Yes" }))

await waitFor(() => {
expect(h.toastError).toHaveBeenCalledWith(
"Failed to remove helper. Please try again.",
)
})
expect(
screen.queryByText("The helper is now removed from the project"),
).not.toBeInTheDocument()

consoleError.mockRestore()
})
})
104 changes: 100 additions & 4 deletions src/app/helpers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,26 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { ProfileAvatar } from "@/components/ui/profile-avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { MoreVertical, Plus, Search, ChevronDown, ChevronUp, ChevronsUpDown, Copy, X, UserPlus } from "lucide-react"
import { toast } from "sonner"
import { Sidebar } from "@/components/layout/sidebar"
import { Header } from "@/components/layout/header"
import { AddHelperDrawer } from "@/components/drawers/add-helper-drawer"
import { AcceptRequestDrawer } from "@/components/drawers/accept-request-drawer"
import { useHelpers, useCreateHelper, useAddSelfAsHelper } from "@/hooks/useHelpers"
import { useHelpers, useCreateHelper, useAddSelfAsHelper, useRemoveHelper } from "@/hooks/useHelpers"
import { usePendingRequests, useUpdatePendingRequest } from "@/hooks/usePendingRequests"
import { useCreateProjectInvite, useListProjectInvites, useRevokeProjectInvite } from "@/hooks/useProject"
import { useProjectSelection } from "@/contexts/project-context"
Expand Down Expand Up @@ -84,6 +97,9 @@ export default function HelpersPage() {
const [sortField, setSortField] = useState<SortField | null>(null)
const [sortDirection, setSortDirection] = useState<SortDirection>(null)

const [helperToRemove, setHelperToRemove] = useState<{ id: string; name: string } | null>(null)
const [isRemovedDialogOpen, setIsRemovedDialogOpen] = useState(false)

// Derive view from URL so we don't need setState in effect
const currentView = (searchParams.get("view") === "requests" ? "requests" : searchParams.get("view") === "invited" ? "invited" : "added") as "added" | "requests" | "invited"

Expand All @@ -100,6 +116,7 @@ export default function HelpersPage() {
const updatePendingRequest = useUpdatePendingRequest()
const createInvite = useCreateProjectInvite()
const revokeInvite = useRevokeProjectInvite()
const removeHelper = useRemoveHelper()

// Admin is a helper if their user_id appears in the project's helpers list
const isCurrentUserHelper = !!(
Expand Down Expand Up @@ -236,6 +253,21 @@ export default function HelpersPage() {
setSelectedRequest(null)
}

const handleConfirmRemoveHelper = async () => {
if (!helperToRemove || !projectId) return
try {
await removeHelper.mutateAsync({
helperId: helperToRemove.id,
projectId: projectId,
})
setHelperToRemove(null)
setIsRemovedDialogOpen(true)
} catch (error) {
console.error("Failed to remove helper:", error)
toast.error("Failed to remove helper. Please try again.")
}
}

const handleSort = (field: SortField) => {
if (sortField === field) {
// Cycle through: asc -> desc -> null
Expand Down Expand Up @@ -694,9 +726,26 @@ export default function HelpersPage() {
) : (
<span className="text-sm text-muted-foreground px-3 py-1">Not registered</span>
)}
<Button variant="ghost" size="sm" className="text-muted-foreground hover:bg-muted">
<MoreVertical className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:bg-muted"
aria-label={`Open menu for ${helper.name}`}
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onSelect={() => setHelperToRemove({ id: helper.id, name: helper.name })}
>
Remove helper
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
Expand Down Expand Up @@ -788,6 +837,53 @@ export default function HelpersPage() {
onSubmit={handleConfirmAcceptRequest}
requestData={selectedRequest}
/>

{/* Remove helper confirmation dialog */}
<Dialog
open={!!helperToRemove}
onOpenChange={(open) => {
if (!open) setHelperToRemove(null)
}}
>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Are you sure you want to remove this helper?</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setHelperToRemove(null)}
disabled={removeHelper.isPending}
>
Cancel
</Button>
<Button
className="bg-brand-primary hover:bg-brand-primary/90 text-white"
onClick={handleConfirmRemoveHelper}
disabled={removeHelper.isPending}
>
Yes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Helper removed confirmation dialog */}
<Dialog open={isRemovedDialogOpen} onOpenChange={setIsRemovedDialogOpen}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>The helper is now removed from the project</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button
className="bg-brand-primary hover:bg-brand-primary/90 text-white"
onClick={() => setIsRemovedDialogOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
Expand Down
Loading