Add inline edit for participant names on admin sports season page (#257)
Fixes #224 * Add inline edit for participant names on admin sports season page Adds a pencil icon button next to each participant's delete button. Clicking it switches the row into edit mode with an input field, a save (checkmark) and cancel (X) button. Escape also cancels. Submits via intent="update-name" and validates uniqueness within the season before persisting. https://claude.ai/code/session_01Q94DWQ1HZEB9PJhGMArU8D * Simplify edit state and surface edit errors to user - Combine editingId + editingName into single editing: { id, name } | null state object - Extract cancelEdit() helper called in 3 places instead of repeating setEditing(null) - Tag all update-name action responses with intent: "update-name" so errors can be identified and routed correctly - Show update-name errors inline below the edit input - Guard single-add and bulk-add error displays against showing update-name errors https://claude.ai/code/session_01Q94DWQ1HZEB9PJhGMArU8D --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c5ccee2225
commit
1d3eb4aac2
1 changed files with 111 additions and 18 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { Form, Link } from "react-router";
|
import { Form, Link } from "react-router";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.participants";
|
import type { Route } from "./+types/admin.sports-seasons.$id.participants";
|
||||||
|
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
findParticipantByName,
|
findParticipantByName,
|
||||||
createParticipant,
|
createParticipant,
|
||||||
deleteParticipant,
|
deleteParticipant,
|
||||||
|
updateParticipant,
|
||||||
} from "~/models/participant";
|
} from "~/models/participant";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
@ -28,7 +30,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Plus, Trash2, ArrowLeft } from "lucide-react";
|
import { Plus, Trash2, ArrowLeft, Pencil, Check, X } from "lucide-react";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Participants — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
return [{ title: `Participants — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||||
|
|
@ -62,6 +64,33 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "update-name") {
|
||||||
|
const participantId = formData.get("participantId");
|
||||||
|
const newName = formData.get("newName");
|
||||||
|
|
||||||
|
if (typeof participantId !== "string") {
|
||||||
|
return { error: "Invalid participant", intent: "update-name" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof newName !== "string" || !newName.trim()) {
|
||||||
|
return { error: "Name is required", intent: "update-name" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = newName.trim();
|
||||||
|
const existing = await findParticipantByName(params.id, trimmedName);
|
||||||
|
if (existing && existing.id !== participantId) {
|
||||||
|
return { error: `"${trimmedName}" already exists in this season.`, intent: "update-name" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateParticipant(participantId, { name: trimmedName });
|
||||||
|
return { success: true, intent: "update-name" };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating participant:", error);
|
||||||
|
return { error: "Failed to update participant. Please try again.", intent: "update-name" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bulk add participants
|
// Bulk add participants
|
||||||
if (intent === "bulk") {
|
if (intent === "bulk") {
|
||||||
const bulkNames = formData.get("bulkNames");
|
const bulkNames = formData.get("bulkNames");
|
||||||
|
|
@ -142,10 +171,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
||||||
export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) {
|
export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { sportsSeason, participants } = loaderData;
|
const { sportsSeason, participants } = loaderData;
|
||||||
|
const [editing, setEditing] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
const cancelEdit = () => setEditing(null);
|
||||||
|
|
||||||
// Use success state to reset forms by changing the key
|
// Use success state to reset forms by changing the key
|
||||||
const formKey = actionData?.success ? Date.now() : 'static';
|
const formKey = actionData?.success ? Date.now() : 'static';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData?.success && actionData?.intent === "update-name") {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
}, [actionData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
|
|
@ -183,7 +221,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionData?.error && !actionData?.count && (
|
{actionData?.error && !actionData?.count && actionData?.intent !== "update-name" && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
{actionData.error}
|
{actionData.error}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,7 +267,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionData?.error && actionData?.count === undefined && (
|
{actionData?.error && actionData?.count === undefined && actionData?.intent !== "update-name" && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
{actionData.error}
|
{actionData.error}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -273,26 +311,81 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead className="w-[50px]" />
|
<TableHead className="w-[100px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{participants.map((participant) => (
|
{participants.map((participant) => (
|
||||||
<TableRow key={participant.id}>
|
<TableRow key={participant.id}>
|
||||||
<TableCell className="font-medium">{participant.name}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
{editing?.id === participant.id ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
value={editing.name}
|
||||||
|
onChange={(e) => setEditing({ id: participant.id, name: e.target.value })}
|
||||||
|
className="h-8"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||||
|
/>
|
||||||
|
{actionData?.error && actionData?.intent === "update-name" && (
|
||||||
|
<p className="text-xs text-destructive">{actionData.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
participant.name
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Form method="post">
|
{editing?.id === participant.id ? (
|
||||||
<input type="hidden" name="intent" value="delete" />
|
<div className="flex gap-1">
|
||||||
<input type="hidden" name="participantId" value={participant.id} />
|
<Form method="post">
|
||||||
<Button
|
<input type="hidden" name="intent" value="update-name" />
|
||||||
type="submit"
|
<input type="hidden" name="participantId" value={participant.id} />
|
||||||
variant="ghost"
|
<input type="hidden" name="newName" value={editing.name} />
|
||||||
size="sm"
|
<Button
|
||||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
type="submit"
|
||||||
>
|
variant="ghost"
|
||||||
<Trash2 className="h-4 w-4" />
|
size="sm"
|
||||||
</Button>
|
className="h-8 w-8 p-0 text-emerald-500 hover:text-emerald-400"
|
||||||
</Form>
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => setEditing({ id: participant.id, name: participant.name })}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<input type="hidden" name="participantId" value={participant.id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue