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 [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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user