Skip to content
Merged
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
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ A **Card** placed in the `COMPANION` **Zone** whose deckbuilding restriction the
A user-defined free-text grouping within the `MAINBOARD` **Zone** (e.g. "Ramp", "Removal").
A **DeckCard** may belong to several **Categories** at once: memberships are ordered, the first is the **primary**, and the rest are secondary.
The card renders in full under its primary and ghosted under each secondary; section header counts and commander-template targets count every membership (ghosts included), while the stats distribution bar tallies primaries only so it sums to deck size.
Removing a ghost (secondary) row strips only that one membership — the **DeckCard** stays in the deck under its remaining **Categories** — whereas removing the primary row deletes the **DeckCard** from the deck entirely.
Zero memberships means uncategorized; leaving `MAINBOARD` clears all memberships.
Distinct from **CardType** (Creature/Instant/...) and from **Format**.

Expand Down
170 changes: 170 additions & 0 deletions app/_components/builder/card-row-sortable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DndContext } from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable";
import type { DeckCard } from "@/lib/deck/zone-view";

vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(""),
}));

vi.mock("@/lib/deck/editor-actions", () => ({
updateCardQuantity: vi.fn().mockResolvedValue(undefined),
removeCardFromDeck: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/app/_actions/deck/categories", () => ({
moveCardTo: vi.fn().mockResolvedValue(undefined),
setCardCategories: vi.fn().mockResolvedValue(undefined),
}));

import { removeCardFromDeck } from "@/lib/deck/editor-actions";
import { setCardCategories } from "@/app/_actions/deck/categories";
import { CardRowSortable } from "./card-row-sortable";

const mockRemoveCard = vi.mocked(removeCardFromDeck);
const mockSetCategories = vi.mocked(setCardCategories);

const DECK_ID = "deck-1";
const PRINTING_ID = 42;

function makeDc(overrides: Partial<DeckCard> = {}): DeckCard {
return {
id: "dc-1",
deckId: DECK_ID,
cardId: 1,
quantity: 1,
zone: "MAINBOARD",
categories: [],
printingId: PRINTING_ID,
isFoil: false,
createdAt: new Date(),
updatedAt: new Date(),
card: {
id: 1,
name: "Sol Ring",
mainType: "Artifact",
typeLine: "Artifact",
manaCost: null,
oracleText: null,
colorIdentity: [],
gameChanger: false,
legalities: null,
printings: [{ id: PRINTING_ID, imageUri: null, backImageUri: null }],
},
printing: {
id: PRINTING_ID,
setCode: "lea",
collectorNumber: "270",
imageUri: null,
backImageUri: null,
},
...overrides,
} as unknown as DeckCard;
}

function renderRow(dc: DeckCard) {
return render(
<DndContext>
<SortableContext items={[dc.id]}>
<ul>
<CardRowSortable
dc={dc}
deckId={DECK_ID}
format="COMMANDER"
subcategories={["Ramp", "Removal"]}
commanderSet={false}
dispatch={vi.fn()}
viewerId="user-1"
viewOptions={{ manaValues: true, price: false, ownership: false }}
/>
</ul>
</SortableContext>
</DndContext>,
);
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("CardRowSortable ghost rows", () => {
it("labels a ghost row with its primary category so it is distinguishable", () => {
const dc = makeDc({
isSecondary: true,
categories: ["Ramp", "Removal"],
sectionCategory: "Removal",
});
renderRow(dc);

expect(screen.getByText(/\(also in Ramp\)/)).toBeInTheDocument();
});

it("primary row carries no ghost qualifier", () => {
renderRow(makeDc({ categories: ["Ramp"] }));

expect(screen.queryByText(/also in/i)).toBeNull();
});

it("ghost Trash strips only the section membership, keeping the card in the deck", async () => {
const user = userEvent.setup();
const dc = makeDc({
isSecondary: true,
categories: ["Ramp", "Removal"],
sectionCategory: "Removal",
});
renderRow(dc);

await user.click(
screen.getByRole("button", { name: /remove sol ring from removal/i }),
);

expect(mockSetCategories).toHaveBeenCalledWith(DECK_ID, "dc-1", ["Ramp"]);
expect(mockRemoveCard).not.toHaveBeenCalled();
});

it("primary row Trash deletes the whole card", async () => {
const user = userEvent.setup();
renderRow(makeDc({ categories: ["Ramp"] }));

await user.click(
screen.getByRole("button", { name: /remove sol ring from deck/i }),
);

expect(mockRemoveCard).toHaveBeenCalledWith(DECK_ID, "dc-1");
expect(mockSetCategories).not.toHaveBeenCalled();
});

it.each(["Backspace", "Delete"])(
"keyboard %s on a ghost row strips only the section membership",
(key) => {
const dc = makeDc({
isSecondary: true,
categories: ["Ramp", "Removal"],
sectionCategory: "Removal",
});
const { container } = renderRow(dc);

const row = container.querySelector("[data-deck-row]") as HTMLElement;
fireEvent.keyDown(row, { key });

expect(mockSetCategories).toHaveBeenCalledWith(DECK_ID, "dc-1", ["Ramp"]);
expect(mockRemoveCard).not.toHaveBeenCalled();
},
);

it.each(["Backspace", "Delete"])(
"keyboard %s on a primary row deletes the whole card",
(key) => {
const { container } = renderRow(makeDc({ categories: ["Ramp"] }));

const row = container.querySelector("[data-deck-row]") as HTMLElement;
fireEvent.keyDown(row, { key });

expect(mockRemoveCard).toHaveBeenCalledWith(DECK_ID, "dc-1");
expect(mockSetCategories).not.toHaveBeenCalled();
},
);
});
44 changes: 40 additions & 4 deletions app/_components/builder/card-row-sortable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
updateCardQuantity,
removeCardFromDeck,
} from "@/lib/deck/editor-actions";
import { moveCardTo } from "@/app/_actions/deck/categories";
import { moveCardTo, setCardCategories } from "@/app/_actions/deck/categories";
import type { Zone } from "@/lib/generated/prisma/enums";

const ROW_ZONE_BY_KEY: Record<string, Zone> = {
Expand Down Expand Up @@ -110,7 +110,7 @@
}
}

export function CardRowSortable({

Check warning on line 113 in app/_components/builder/card-row-sortable.tsx

View workflow job for this annotation

GitHub Actions / lint

Function 'CardRowSortable' has a complexity of 26. Maximum allowed is 20

Check warning on line 113 in app/_components/builder/card-row-sortable.tsx

View workflow job for this annotation

GitHub Actions / lint

Function 'CardRowSortable' has too many lines (232). Maximum allowed is 200
dc,
deckId,
format,
Expand Down Expand Up @@ -180,6 +180,22 @@
});
}

// Ghost rows list the card under a secondary category. Their Trash strips
// only that membership; the card stays in the deck under its other
// categories. Full deletion remains the primary row's job.
function removeMembership() {
const next = dc.categories.filter((c) => c !== dc.sectionCategory);
startTransition(async () => {
dispatch({
type: "move",
deckCardId: dc.id,
zone: "MAINBOARD",
categories: next,
});
await setCardCategories(deckId, dc.id, next);
});
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
function moveToZone(nextZone: Zone) {
if (nextZone === dc.zone) return;
startTransition(async () => {
Expand All @@ -199,7 +215,18 @@
}

function onRowKeyDown(e: React.KeyboardEvent<HTMLLIElement>) {
handleRowKeyDown(e, { dc, preview, previewPayload, rowRef, changeQty, moveToZone, remove, setPrintingPickerOpen });
// Keyboard delete mirrors the Trash button: a ghost (secondary) row strips
// only its membership, a primary row deletes the whole DeckCard.
handleRowKeyDown(e, {
dc,
preview,
previewPayload,
rowRef,
changeQty,
moveToZone,
remove: dc.isSecondary ? removeMembership : remove,
setPrintingPickerOpen,
});
}

const li = (
Expand Down Expand Up @@ -257,6 +284,11 @@
>
{dc.card.name}
</button>
{dc.isSecondary && (
<span className="sr-only">
(also in {dc.categories[0]})
</span>
)}
{illegalBadge}
<GameChangerChip format={format} gameChanger={dc.card.gameChanger} />
</div>
Expand Down Expand Up @@ -320,9 +352,13 @@
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove ${dc.card.name} from deck`}
aria-label={
dc.isSecondary
? `Remove ${dc.card.name} from ${dc.sectionCategory}`
: `Remove ${dc.card.name} from deck`
}
disabled={isPending}
onClick={remove}
onClick={dc.isSecondary ? removeMembership : remove}
className="size-11 shrink-0 md:size-7 text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden />
Expand Down
42 changes: 42 additions & 0 deletions app/_components/builder/card-row.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,48 @@ describe("CardRow ownership badge", () => {
});
});

describe("CardRow ghost rows", () => {
it("labels a secondary fan-out row with its primary category", () => {
render(
<ul>
<CardRow
dc={makeDc({
isSecondary: true,
categories: ["Ramp", "Removal"],
sectionCategory: "Removal",
})}
deckId={DECK_ID}
format="COMMANDER"
subcategories={["Ramp", "Removal"]}
isOwner={true}
dispatch={vi.fn()}
viewerId="user-1"
viewOptions={{ manaValues: true, price: false, ownership: false }}
/>
</ul>,
);
expect(screen.getByText(/\(also in Ramp\)/)).toBeInTheDocument();
});

it("primary row carries no ghost qualifier", () => {
render(
<ul>
<CardRow
dc={makeDc({ categories: ["Ramp"] })}
deckId={DECK_ID}
format="COMMANDER"
subcategories={["Ramp"]}
isOwner={true}
dispatch={vi.fn()}
viewerId="user-1"
viewOptions={{ manaValues: true, price: false, ownership: false }}
/>
</ul>,
);
expect(screen.queryByText(/also in/i)).toBeNull();
});
});

describe("CardRow inventory menu", () => {
it("opens via right-click and fires setHolding when 'Mark as owned' is chosen", async () => {
const user = userEvent.setup();
Expand Down
5 changes: 5 additions & 0 deletions app/_components/builder/card-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@
);
}

export function CardRow({

Check warning on line 190 in app/_components/builder/card-row.tsx

View workflow job for this annotation

GitHub Actions / lint

Function 'CardRow' has a complexity of 22. Maximum allowed is 20
dc,
deckId,
format,
Expand Down Expand Up @@ -272,6 +272,11 @@
>
{dc.card.name}
</button>
{dc.isSecondary && (
<span className="sr-only">
(also in {dc.categories[0]})
</span>
)}
{illegalBadge}
<GameChangerChip format={format} gameChanger={dc.card.gameChanger} />
</div>
Expand Down
30 changes: 27 additions & 3 deletions app/_components/builder/card-stack-sortable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
updateCardQuantity,
removeCardFromDeck,
} from "@/lib/deck/editor-actions";
import { setCardCategories } from "@/app/_actions/deck/categories";
import { cn } from "@/lib/utils";
import type { DeckCard, ZoneAction } from "@/lib/deck/zone-view";
import type { Format } from "@/lib/generated/prisma/enums";
Expand Down Expand Up @@ -119,6 +120,21 @@ function CardStackItemSortable({
});
}

// Ghost tiles show the card under a secondary category; their Trash strips
// only that membership rather than deleting the card from the deck.
function removeMembership() {
const next = dc.categories.filter((c) => c !== dc.sectionCategory);
startTransition(async () => {
dispatch({
type: "move",
deckCardId: dc.id,
zone: "MAINBOARD",
categories: next,
});
await setCardCategories(deckId, dc.id, next);
});
}

function onTileClick(e: React.MouseEvent<HTMLDivElement>) {
if (isInteractiveTargetStack(e.target)) return;
preview?.openDetail(previewPayload, tileRef.current);
Expand Down Expand Up @@ -165,7 +181,11 @@ function CardStackItemSortable({
onFocus={() => preview?.preview(previewPayload)}
onClick={onTileClick}
onKeyDown={onTileKeyDown}
aria-label={`${dc.card.name} ×${dc.quantity}`}
aria-label={
dc.isSecondary
? `${dc.card.name} ×${dc.quantity} (also in ${dc.categories[0]})`
: `${dc.card.name} ×${dc.quantity}`
}
>
{imageUri ? (
<Image
Expand Down Expand Up @@ -240,9 +260,13 @@ function CardStackItemSortable({
variant="ghost"
size="icon-sm"
type="button"
aria-label={`Remove ${dc.card.name} from deck`}
aria-label={
dc.isSecondary
? `Remove ${dc.card.name} from ${dc.sectionCategory}`
: `Remove ${dc.card.name} from deck`
}
disabled={isPending}
onClick={remove}
onClick={dc.isSecondary ? removeMembership : remove}
className="size-7 text-white hover:bg-white/15 hover:text-destructive"
>
<Trash2 aria-hidden />
Expand Down
6 changes: 5 additions & 1 deletion app/_components/builder/card-stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,11 @@ function CardStackItem({
onFocus={() => preview?.preview(previewPayload)}
onClick={onTileClick}
onKeyDown={onTileKeyDown}
aria-label={`${dc.card.name} ×${dc.quantity}`}
aria-label={
dc.isSecondary
? `${dc.card.name} ×${dc.quantity} (also in ${dc.categories[0]})`
: `${dc.card.name} ×${dc.quantity}`
}
>
{imageUri ? (
<Image
Expand Down
Loading
Loading