The status column was rendering both a StatusBadge and a separate EditableCell next to it, so 'done' showed twice in the same cell. Adds a renderView prop to EditableCell so callers can override the view-mode display; status now uses the badge as its visual, click- to-edit behavior intact. Mobile card header drops its standalone badge for the same reason — the body's Status row now shows the badge inline.
148 lines
4.2 KiB
TypeScript
148 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, useTransition } from "react";
|
|
|
|
type EditableCellProps = {
|
|
value: string;
|
|
onSave: (next: string) => Promise<{ ok: boolean; error?: string }>;
|
|
label?: string;
|
|
isCurrentlyEditing?: boolean;
|
|
onEditStart?: () => void;
|
|
onEditEnd?: () => void;
|
|
/**
|
|
* Override how the value is rendered in view mode. Use this to show
|
|
* something other than plain text (e.g., a status pill) — clicking
|
|
* the rendered element still starts edit mode.
|
|
*/
|
|
renderView?: (value: string) => React.ReactNode;
|
|
};
|
|
|
|
export default function EditableCell({
|
|
value,
|
|
onSave,
|
|
label,
|
|
isCurrentlyEditing,
|
|
onEditStart,
|
|
onEditEnd,
|
|
renderView,
|
|
}: EditableCellProps) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(value);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!editing) setDraft(value);
|
|
}, [value, editing]);
|
|
|
|
useEffect(() => {
|
|
if (!error) return;
|
|
const id = setTimeout(() => setError(null), 3000);
|
|
return () => clearTimeout(id);
|
|
}, [error]);
|
|
|
|
function begin() {
|
|
setDraft(value);
|
|
setEditing(true);
|
|
onEditStart?.();
|
|
requestAnimationFrame(() => {
|
|
inputRef.current?.focus();
|
|
inputRef.current?.select();
|
|
});
|
|
}
|
|
|
|
function cancel() {
|
|
setEditing(false);
|
|
setDraft(value);
|
|
setError(null);
|
|
onEditEnd?.();
|
|
}
|
|
|
|
function commit() {
|
|
if (draft === value) {
|
|
cancel();
|
|
return;
|
|
}
|
|
startTransition(async () => {
|
|
const result = await onSave(draft);
|
|
if (result.ok) {
|
|
setEditing(false);
|
|
setError(null);
|
|
onEditEnd?.();
|
|
} else {
|
|
setError(result.error ?? "Save failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!editing) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={begin}
|
|
aria-label={label ? `Edit ${label}` : undefined}
|
|
className="group flex w-full min-w-0 items-center gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
|
|
>
|
|
<span className="min-w-0 flex-1 break-all">
|
|
{renderView
|
|
? renderView(value)
|
|
: value || <em className="not-italic text-zinc-400">—</em>}
|
|
</span>
|
|
<span
|
|
aria-hidden="true"
|
|
className="hidden shrink-0 self-center text-[10px] font-medium uppercase tracking-wider text-zinc-400 group-hover:inline"
|
|
>
|
|
edit
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
ref={inputRef}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
commit();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
cancel();
|
|
}
|
|
}}
|
|
disabled={isPending}
|
|
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-2 py-1 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={commit}
|
|
disabled={isPending}
|
|
aria-label="Save"
|
|
className="shrink-0 rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
|
>
|
|
{isPending ? "…" : "Save"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={cancel}
|
|
disabled={isPending}
|
|
aria-label="Cancel"
|
|
className="shrink-0 rounded-md px-2.5 py-1 text-[11px] font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{error && (
|
|
<p className="font-mono text-[11px] text-red-600" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|