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:
@@ -233,6 +233,7 @@ export default function TicketDetailPage({
|
||||
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null);
|
||||
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
|
||||
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
||||
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
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;
|
||||
const value = customFieldDrafts[fieldId]?.trim() ?? "";
|
||||
const value = (valueOverride ?? customFieldDrafts[fieldId] ?? "").trim();
|
||||
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;
|
||||
if (value && field?.pattern) {
|
||||
const regex = new RegExp(field.pattern);
|
||||
@@ -440,11 +444,9 @@ export default function TicketDetailPage({
|
||||
);
|
||||
}
|
||||
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 () => {
|
||||
if (!replyText.trim() || sending) return;
|
||||
setSending(true);
|
||||
@@ -950,66 +952,104 @@ export default function TicketDetailPage({
|
||||
const options = Array.isArray(field?.values)
|
||||
? field.values.map((value) => String(value))
|
||||
: [];
|
||||
const fieldType = field?.field_type.toLowerCase() ?? "";
|
||||
const currentDraft = customFieldDrafts[fieldId] ?? customFieldValue(fieldId);
|
||||
const dirty = currentDraft !== customFieldValue(fieldId);
|
||||
const currentValue =
|
||||
ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
|
||||
const isEditing = editingFieldId === fieldId;
|
||||
const draftValue = customFieldDrafts[fieldId] ?? currentValue;
|
||||
const isSaving = customFieldSaving === fieldId;
|
||||
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
{field?.name ?? fieldId}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{(fieldType.includes("select") || options.length > 0) && options.length > 0 ? (
|
||||
<select
|
||||
value={currentDraft}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setCustomFieldDrafts((current) => ({ ...current, [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"
|
||||
>
|
||||
<option value="">Not set</option>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={currentDraft}
|
||||
onChange={(event) =>
|
||||
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: event.target.value }))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
void handleCustomFieldSave(fieldId);
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{options.length > 0 ? (
|
||||
<select
|
||||
value={draftValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
|
||||
void handleCustomFieldSave(fieldId, nextValue);
|
||||
}}
|
||||
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>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={draftValue}
|
||||
onChange={(event) =>
|
||||
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }))
|
||||
}
|
||||
}}
|
||||
placeholder={field?.pattern ? field.pattern : "Not set"}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void handleCustomFieldSave(fieldId)}
|
||||
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"
|
||||
onBlur={() => {
|
||||
if (draftValue.trim() !== currentValue) {
|
||||
void handleCustomFieldSave(fieldId);
|
||||
} else {
|
||||
setEditingFieldId(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
title="Save custom field"
|
||||
{isSaving && (
|
||||
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
{!isSaving && (
|
||||
<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"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user