feat: implement inline editing for custom fields in ticket sidebar

- Show CF values as read-only text with edit affordance (pencil icon on hover)
- Click to enter edit mode: inline input (free-text) or select (choice fields)
- Save on blur or Enter, cancel on Escape — reverts to original value
- Auto-save for select fields on change
- Loading spinner while saving
- Remove now-unused customFieldValue helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 10:55:45 +02:00
parent 2501bcbad1
commit 000e97e1bd

View File

@@ -233,6 +233,7 @@ export default function TicketDetailPage({
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null); const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null);
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({}); const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null); const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -401,11 +402,14 @@ export default function TicketDetailPage({
} }
}; };
const handleCustomFieldSave = async (fieldId: string) => { const handleCustomFieldSave = async (fieldId: string, valueOverride?: string) => {
if (!ticket || customFieldSaving) return; if (!ticket || customFieldSaving) return;
const value = customFieldDrafts[fieldId]?.trim() ?? ""; const value = (valueOverride ?? customFieldDrafts[fieldId] ?? "").trim();
const currentValue = ticket.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? ""; const currentValue = ticket.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
if (value === currentValue) return; if (value === currentValue) {
setEditingFieldId(null);
return;
}
const field = queueFields.find((assignment) => assignment.custom_field_id === fieldId)?.custom_field; const field = queueFields.find((assignment) => assignment.custom_field_id === fieldId)?.custom_field;
if (value && field?.pattern) { if (value && field?.pattern) {
const regex = new RegExp(field.pattern); const regex = new RegExp(field.pattern);
@@ -440,11 +444,9 @@ export default function TicketDetailPage({
); );
} }
if (txRes.data) setTransactions(txRes.data); if (txRes.data) setTransactions(txRes.data);
setEditingFieldId(null);
}; };
const customFieldValue = (fieldId: string) =>
ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
const handleSendComment = async () => { const handleSendComment = async () => {
if (!replyText.trim() || sending) return; if (!replyText.trim() || sending) return;
setSending(true); setSending(true);
@@ -950,28 +952,34 @@ export default function TicketDetailPage({
const options = Array.isArray(field?.values) const options = Array.isArray(field?.values)
? field.values.map((value) => String(value)) ? field.values.map((value) => String(value))
: []; : [];
const fieldType = field?.field_type.toLowerCase() ?? ""; const currentValue =
const currentDraft = customFieldDrafts[fieldId] ?? customFieldValue(fieldId); ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
const dirty = currentDraft !== customFieldValue(fieldId); const isEditing = editingFieldId === fieldId;
const draftValue = customFieldDrafts[fieldId] ?? currentValue;
const isSaving = customFieldSaving === fieldId; const isSaving = customFieldSaving === fieldId;
return ( return (
<div <div
key={assignment.id} key={assignment.id}
className="grid gap-2 border-b border-border px-3 py-3 last:border-b-0" className="grid gap-1.5 border-b border-border px-3 py-2.5 last:border-b-0"
> >
<label className="text-[11px] font-semibold uppercase text-muted-foreground"> <label className="text-[11px] font-semibold uppercase text-muted-foreground">
{field?.name ?? fieldId} {field?.name ?? fieldId}
</label> </label>
<div className="flex items-center gap-2">
{(fieldType.includes("select") || options.length > 0) && options.length > 0 ? ( {isEditing ? (
<div className="flex items-center gap-1.5">
{options.length > 0 ? (
<select <select
value={currentDraft} value={draftValue}
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: nextValue })); setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
void handleCustomFieldSave(fieldId, nextValue);
}} }}
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors focus:border-ring" onBlur={() => setEditingFieldId(null)}
autoFocus
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
> >
<option value="">Not set</option> <option value="">Not set</option>
{options.map((option) => ( {options.map((option) => (
@@ -982,34 +990,66 @@ export default function TicketDetailPage({
</select> </select>
) : ( ) : (
<input <input
value={currentDraft} value={draftValue}
onChange={(event) => onChange={(event) =>
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: event.target.value })) setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }))
} }
onKeyDown={(event) => { onBlur={() => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { if (draftValue.trim() !== currentValue) {
void handleCustomFieldSave(fieldId); void handleCustomFieldSave(fieldId);
} else {
setEditingFieldId(null);
} }
}} }}
placeholder={field?.pattern ? field.pattern : "Not set"} onKeyDown={(event) => {
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" if (event.key === "Enter") {
void handleCustomFieldSave(fieldId);
} else if (event.key === "Escape") {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(null);
}
}}
autoFocus
placeholder="Not set"
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/> />
)} )}
<button {isSaving && (
onClick={() => void handleCustomFieldSave(fieldId)} <div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
disabled={!dirty || isSaving}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
dirty && !isSaving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "border border-border bg-card text-muted-foreground"
)} )}
title="Save custom field" {!isSaving && (
<button
type="button" type="button"
onClick={() => {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(null);
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<SaveIcon className="h-4 w-4" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
)}
</div> </div>
) : (
<button
type="button"
onClick={() => {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(fieldId);
}}
className="group flex items-center gap-1.5 text-sm min-w-0 -mx-1 rounded px-1 py-0.5 hover:bg-accent/60 transition-colors"
>
<span
className={cn(
"truncate",
currentValue ? "text-foreground" : "text-muted-foreground"
)}
>
{currentValue || "Not set"}
</span>
<PencilIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
</div> </div>
); );
})} })}