Improve shared-tournament/event management UX
Deleting an event from a season only affects that season's scoring event; the shared canonical tournament and the other linked seasons survive. The old flow never communicated this and offered no way to manage the relationship, so it felt like deleting an event might wipe the whole shared tournament. - deleteScoringEvent is now shared-tournament-aware: it auto-heals the primary window (promotes the earliest remaining window when the primary is removed) and optionally deletes the canonical tournament when the last linked window is removed. Returns a result describing what happened. - Add deleteTournament model helper. - Events page: delete confirmation now explains exactly what a delete does (removed from this season only vs. last window), with an opt-in checkbox to also remove the orphaned shared tournament. - Tournament page: add a per-window "Remove" control on the Linked Sports Seasons card to unlink a season, reusing the auto-heal path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
This commit is contained in:
parent
7cbcc6fb14
commit
7f7ea4e29d
7 changed files with 380 additions and 21 deletions
|
|
@ -63,4 +63,101 @@ describe("deleteScoringEvent", () => {
|
|||
])
|
||||
);
|
||||
});
|
||||
|
||||
describe("shared tournament handling", () => {
|
||||
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
|
||||
// so the delete skips the QP/league branches and exercises only the
|
||||
// shared-tournament bookkeeping.
|
||||
function makeSharedDb({
|
||||
deletedEvent,
|
||||
remaining,
|
||||
siblingTournamentId = "tournament-1",
|
||||
}: {
|
||||
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
|
||||
remaining: Array<{ id: string; isPrimary: boolean }>;
|
||||
siblingTournamentId?: string;
|
||||
}) {
|
||||
const findFirst = vi
|
||||
.fn()
|
||||
// 1st call: the event being deleted
|
||||
.mockResolvedValueOnce({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
eventType: "schedule_event",
|
||||
isQualifyingEvent: false,
|
||||
...deletedEvent,
|
||||
})
|
||||
// subsequent calls: setPrimaryEvent looking up the promoted event
|
||||
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
|
||||
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst,
|
||||
findMany: vi.fn().mockResolvedValue(remaining),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
} as any;
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
||||
remaining: [{ id: "event-2", isPrimary: true }],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(1);
|
||||
expect(result.promotedPrimaryId).toBeNull();
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("promotes the earliest remaining window when the primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
// findMany is ordered asc(createdAt); first is the earliest.
|
||||
remaining: [
|
||||
{ id: "event-2", isPrimary: false },
|
||||
{ id: "event-3", isPrimary: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.promotedPrimaryId).toBe("event-2");
|
||||
expect(result.remainingWindows).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(0);
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db, {
|
||||
deleteOrphanTournament: true,
|
||||
});
|
||||
|
||||
expect(result.deletedTournament).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
findTournamentBySportNameYear,
|
||||
upsertTournament,
|
||||
updateTournamentStatus,
|
||||
deleteTournament,
|
||||
} from "../tournament";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
|
|
@ -172,3 +173,26 @@ describe("updateTournamentStatus", () => {
|
|||
expect(result.status).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTournament", () => {
|
||||
it("deletes the tournament row by id", async () => {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const db = { delete: vi.fn().mockReturnValue({ where }) };
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await deleteTournament(TOURNAMENT_ID);
|
||||
|
||||
expect(db.delete).toHaveBeenCalledTimes(1);
|
||||
expect(where).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses a provided db when passed (transaction)", async () => {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const providedDb = { delete: vi.fn().mockReturnValue({ where }) };
|
||||
|
||||
await deleteTournament(TOURNAMENT_ID, providedDb as never);
|
||||
|
||||
expect(providedDb.delete).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(database)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { BracketRegion } from "~/lib/bracket-templates";
|
|||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||
import { findParticipantNamesByIds } from "./season-participant";
|
||||
import { deleteTournament } from "./tournament";
|
||||
import type { EventType } from "./scoring-event-types";
|
||||
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
|
||||
|
||||
|
|
@ -201,17 +202,46 @@ export async function completeScoringEvent(
|
|||
* sports season (placements are stored there with no FK back to the event) and
|
||||
* recalculates league standings.
|
||||
*/
|
||||
export interface DeleteScoringEventOptions {
|
||||
/**
|
||||
* When this was the last window linked to its shared tournament, also delete
|
||||
* the now-orphaned canonical tournament (and its results). No-op when other
|
||||
* windows remain. Off by default so deleting one season's event never touches
|
||||
* the shared tournament unless explicitly requested.
|
||||
*/
|
||||
deleteOrphanTournament?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteScoringEventResult {
|
||||
/** The shared tournament this event was linked to, if any. */
|
||||
tournamentId: string | null;
|
||||
/** How many other windows still link to that tournament after the delete. */
|
||||
remainingWindows: number;
|
||||
/** Event promoted to primary because the deleted event was the primary window. */
|
||||
promotedPrimaryId: string | null;
|
||||
/** Whether the orphaned canonical tournament was deleted. */
|
||||
deletedTournament: boolean;
|
||||
}
|
||||
|
||||
export async function deleteScoringEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
opts?: DeleteScoringEventOptions
|
||||
): Promise<DeleteScoringEventResult> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
|
||||
if (!event) return;
|
||||
if (!event) {
|
||||
return {
|
||||
tournamentId: null,
|
||||
remainingWindows: 0,
|
||||
promotedPrimaryId: null,
|
||||
deletedTournament: false,
|
||||
};
|
||||
}
|
||||
|
||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||
// eventResults (which is how we know who had QP awarded from this event).
|
||||
|
|
@ -260,6 +290,41 @@ export async function deleteScoringEvent(
|
|||
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// Shared-tournament bookkeeping: keep the primary window valid and optionally
|
||||
// clean up a canonical tournament left with no windows.
|
||||
let remainingWindows = 0;
|
||||
let promotedPrimaryId: string | null = null;
|
||||
let deletedTournament = false;
|
||||
|
||||
if (event.tournamentId) {
|
||||
const remaining = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
|
||||
orderBy: asc(schema.scoringEvents.createdAt),
|
||||
});
|
||||
remainingWindows = remaining.length;
|
||||
|
||||
if (remaining.length > 0) {
|
||||
// Auto-heal: if the deleted event was the primary window (or no sibling is
|
||||
// flagged primary anymore), promote the earliest remaining window so the
|
||||
// shared major stays scorable and fan-out stays deterministic.
|
||||
const hasPrimary = remaining.some((e) => e.isPrimary);
|
||||
if (event.isPrimary || !hasPrimary) {
|
||||
await setPrimaryEvent(remaining[0].id, db);
|
||||
promotedPrimaryId = remaining[0].id;
|
||||
}
|
||||
} else if (opts?.deleteOrphanTournament) {
|
||||
await deleteTournament(event.tournamentId, db);
|
||||
deletedTournament = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tournamentId: event.tournamentId,
|
||||
remainingWindows,
|
||||
promotedPrimaryId,
|
||||
deletedTournament,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -136,3 +136,17 @@ export async function updateTournamentStatus(
|
|||
.returning();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a canonical tournament. The DB cascades remove its tournament_results;
|
||||
* any surviving scoring_events have their tournamentId set to NULL (onDelete:
|
||||
* "set null"). Callers should only use this once the last linked window has been
|
||||
* removed, so no scoring events are left pointing at it.
|
||||
*/
|
||||
export async function deleteTournament(
|
||||
id: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
deleteScoringEvent,
|
||||
bulkCreateScoringEvents,
|
||||
ensurePrimaryEvent,
|
||||
getSportsSeasonsByTournament,
|
||||
type CreateScoringEventData,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
|
|
@ -59,11 +60,39 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
);
|
||||
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
|
||||
|
||||
// Shared-tournament context for the delete confirmation dialog: for each event
|
||||
// linked to a canonical tournament, how many OTHER seasons ("windows") also use
|
||||
// it, and the tournament's name. Lets the UI explain exactly what a delete does.
|
||||
const sharedInfo = new Map<string, { name: string; windowCount: number }>();
|
||||
await Promise.all(
|
||||
[...linkedTournamentIds].map(async (tid) => {
|
||||
const [tournament, windows] = await Promise.all([
|
||||
allTournamentsForSport.find((t) => t.id === tid)
|
||||
? Promise.resolve(allTournamentsForSport.find((t) => t.id === tid)!)
|
||||
: getTournamentById(tid),
|
||||
getSportsSeasonsByTournament(tid),
|
||||
]);
|
||||
sharedInfo.set(tid, {
|
||||
name: tournament?.name ?? "shared tournament",
|
||||
windowCount: windows.length,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const eventsWithSharing = events.map((e) => {
|
||||
const info = e.tournamentId ? sharedInfo.get(e.tournamentId) : null;
|
||||
return {
|
||||
...e,
|
||||
tournamentName: info?.name ?? null,
|
||||
otherWindowCount: info ? Math.max(0, info.windowCount - 1) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
},
|
||||
events,
|
||||
events: eventsWithSharing,
|
||||
qpStandings,
|
||||
scoringRules,
|
||||
availableTournaments,
|
||||
|
|
@ -215,8 +244,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
await deleteScoringEvent(eventId);
|
||||
return { success: "Event deleted successfully" };
|
||||
const result = await deleteScoringEvent(eventId, undefined, {
|
||||
deleteOrphanTournament: formData.get("deleteTournament") === "1",
|
||||
});
|
||||
let success = "Event deleted";
|
||||
if (result.deletedTournament) {
|
||||
success = "Event deleted and its shared tournament removed";
|
||||
} else if (result.tournamentId && result.remainingWindows > 0) {
|
||||
success = `Event removed from this season (shared tournament kept — still used by ${result.remainingWindows} season${result.remainingWindows !== 1 ? "s" : ""})`;
|
||||
if (result.promotedPrimaryId) {
|
||||
success += "; another season was promoted to primary";
|
||||
}
|
||||
}
|
||||
return { success };
|
||||
} catch (error) {
|
||||
logger.error("Error deleting event:", error);
|
||||
return { error: "Failed to delete event" };
|
||||
|
|
|
|||
|
|
@ -305,7 +305,11 @@ export default function SportsSeasonEvents({
|
|||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
|
||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
|
||||
const isLinked = !!event.tournamentId;
|
||||
const hasOtherWindows = event.otherWindowCount > 0;
|
||||
const isLastWindow = isLinked && !hasOtherWindows;
|
||||
return (
|
||||
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -352,28 +356,65 @@ export default function SportsSeasonEvents({
|
|||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will also delete all results for this event. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete-event" />
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete-event" />
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
{!isLinked ? (
|
||||
<span>
|
||||
This will also delete all results for this event. This action cannot be undone.
|
||||
</span>
|
||||
) : hasOtherWindows ? (
|
||||
<span>
|
||||
This event is part of the shared tournament{" "}
|
||||
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
|
||||
<span className="font-medium">
|
||||
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
. Deleting removes it from <span className="font-medium">this season only</span> — the
|
||||
tournament and the other seasons keep their data.
|
||||
{event.isPrimary && (
|
||||
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
This is the only season linked to the shared tournament{" "}
|
||||
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
|
||||
event and its results. This action cannot be undone.
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{isLastWindow && (
|
||||
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="deleteTournament"
|
||||
value="1"
|
||||
className="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<span>
|
||||
Also delete the shared tournament and its recorded results
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</Form>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogFooter>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
getSportsSeasonsByTournament,
|
||||
getPrimaryEventForTournament,
|
||||
setPrimaryEvent,
|
||||
deleteScoringEvent,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import {
|
||||
|
|
@ -30,6 +31,17 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
|
|
@ -214,6 +226,26 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
if (intent === "unlink-window") {
|
||||
const eventId = formData.get("eventId");
|
||||
if (typeof eventId !== "string" || !eventId) {
|
||||
return { success: false as const, error: "Event ID is required", syncReport: null };
|
||||
}
|
||||
try {
|
||||
// Remove that season's scoring event. The tournament stays (we're on its
|
||||
// page); if this was the primary window, another is auto-promoted.
|
||||
await deleteScoringEvent(eventId);
|
||||
return { success: true as const, error: null, syncReport: null };
|
||||
} catch (error) {
|
||||
logger.error("unlink-window failed:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Failed to remove season",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Invalid intent",
|
||||
|
|
@ -236,6 +268,7 @@ export default function AdminTournamentDetail({
|
|||
} = loaderData;
|
||||
const retryFetcher = useFetcher<typeof action>();
|
||||
const primaryFetcher = useFetcher<typeof action>();
|
||||
const unlinkFetcher = useFetcher<typeof action>();
|
||||
|
||||
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
|
||||
const primaryScoringHref =
|
||||
|
|
@ -419,6 +452,51 @@ export default function AdminTournamentDetail({
|
|||
>
|
||||
{link.sportsSeason.status}
|
||||
</Badge>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={unlinkFetcher.state !== "idle"}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<span>
|
||||
This deletes that season's scoring event (and its results) but keeps{" "}
|
||||
<span className="font-medium">{tournament.name}</span> and the other linked seasons.
|
||||
{link.isPrimary && linkedSportsSeasons.length > 1 && (
|
||||
<> Because this is the primary scoring window, another season will be promoted to primary automatically.</>
|
||||
)}
|
||||
{linkedSportsSeasons.length === 1 && (
|
||||
<> This is the only linked season — the tournament will remain but have no windows until you link one again.</>
|
||||
)}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<unlinkFetcher.Form method="post">
|
||||
<input type="hidden" name="intent" value="unlink-window" />
|
||||
<input type="hidden" name="eventId" value={link.id} />
|
||||
<AlertDialogAction
|
||||
type="submit"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</unlinkFetcher.Form>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue