From 7c6b86d562a9a469c2d47943f49461fb0b910769 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 28 Apr 2026 15:05:37 +0200 Subject: [PATCH] allow edit ticket name --- csharp/App/Backend/Controller.cs | 33 ++++++- .../Backend/DataTypes/TicketTimelineEvent.cs | 2 +- .../dashboards/Tickets/TicketDetail.tsx | 93 ++++++++++++++++++- .../dashboards/Tickets/TimelinePanel.tsx | 6 +- .../src/interfaces/TicketTypes.tsx | 3 +- typescript/frontend-marios2/src/lang/de.json | 3 + typescript/frontend-marios2/src/lang/en.json | 3 + typescript/frontend-marios2/src/lang/fr.json | 3 + typescript/frontend-marios2/src/lang/it.json | 3 + 9 files changed, 139 insertions(+), 10 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 0ae6ccd97..a80045590 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2241,6 +2241,20 @@ public class Controller : ControllerBase var existing = Db.GetTicketById(ticket.Id); if (existing is null) return NotFound(); + // Subject is creator-only. Non-creators submitting a different Subject + // (typically a stale client-side view during a concurrent edit) are + // silently coerced to the existing value rather than 403'd, so an + // unrelated update (status, assignee, ...) never fails on a stale subject. + if (existing.CreatedByUserId != user.Id) + { + ticket.Subject = existing.Subject; + } + else if (String.IsNullOrWhiteSpace(ticket.Subject)) + { + return BadRequest("Subject is required."); + } + var subjectChanged = !String.Equals(ticket.Subject, existing.Subject); + // Enforce resolution when resolving if (ticket.Status == (Int32)TicketStatus.Resolved && (String.IsNullOrWhiteSpace(ticket.RootCause) || String.IsNullOrWhiteSpace(ticket.Solution))) @@ -2248,6 +2262,8 @@ public class Controller : ControllerBase return BadRequest("Root Cause and Solution are required to resolve a ticket."); } + var oldSubject = existing.Subject; + ticket.CreatedAt = existing.CreatedAt; ticket.CreatedByUserId = existing.CreatedByUserId; ticket.UpdatedAt = DateTime.UtcNow; @@ -2344,7 +2360,22 @@ public class Controller : ControllerBase }); } - return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed."); + if (!Db.Update(ticket)) return StatusCode(500, "Update failed."); + + if (subjectChanged) + { + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.SubjectChanged, + Description = $"Subject changed: \"{oldSubject}\" → \"{ticket.Subject}\".", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + } + + return ticket; } [HttpDelete(nameof(DeleteTicket))] diff --git a/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs index 66f2543b9..bf188f8e0 100644 --- a/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs +++ b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs @@ -6,7 +6,7 @@ public enum TimelineEventType { Created = 0, StatusChanged = 1, Assigned = 2, CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5, - ResolutionAdded = 6 + ResolutionAdded = 6, SubjectChanged = 7 } public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 } diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index 9422415cb..62d16128c 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { UserContext } from 'src/contexts/userContext'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, @@ -87,6 +88,12 @@ function TicketDetailPage() { const [editingDescription, setEditingDescription] = useState(false); const [savingDescription, setSavingDescription] = useState(false); const [descriptionSaved, setDescriptionSaved] = useState(false); + const [subject, setSubject] = useState(''); + const [editingSubject, setEditingSubject] = useState(false); + const [savingSubject, setSavingSubject] = useState(false); + const [subjectError, setSubjectError] = useState(''); + const userCtx = useContext(UserContext); + const currentUser = userCtx?.currentUser; const [docRefreshKey, setDocRefreshKey] = useState(0); const [solveGateOpen, setSolveGateOpen] = useState(false); const rootCauseRef = useRef(null); @@ -107,6 +114,7 @@ function TicketDetailPage() { setRootCause(res.data.ticket.rootCause ?? ''); setSolution(res.data.ticket.solution ?? ''); setDescription(res.data.ticket.description ?? ''); + setSubject(res.data.ticket.subject ?? ''); setEditCustomSub(res.data.ticket.customSubCategory ?? ''); setEditCustomCat(res.data.ticket.customCategory ?? ''); setError(''); @@ -205,6 +213,25 @@ function TicketDetailPage() { }); }; + const handleSaveSubject = () => { + if (!detail) return; + const trimmed = subject.trim(); + if (!trimmed) { + setSubjectError(intl.formatMessage({ id: 'subjectRequired', defaultMessage: 'Subject is required.' })); + return; + } + setSavingSubject(true); + setSubjectError(''); + axiosConfig + .put('/UpdateTicket', { ...detail.ticket, subject: trimmed }) + .then(() => { + fetchDetail(); + setEditingSubject(false); + }) + .catch(() => setSubjectError(intl.formatMessage({ id: 'failedToSaveSubject', defaultMessage: 'Failed to save subject.' }))) + .finally(() => setSavingSubject(false)); + }; + const handleSaveDescription = () => { if (!detail) return; setSavingDescription(true); @@ -285,10 +312,66 @@ function TicketDetailPage() { - - #{ticket.id} — {ticket.subject} - - + {editingSubject ? ( + + + + #{ticket.id} — + + { + setSubject(e.target.value); + setSubjectError(''); + }} + error={!!subjectError} + helperText={subjectError} + /> + + + + + + + ) : ( + + + #{ticket.id} — {ticket.subject} + + {currentUser?.id === ticket.createdByUserId && ( + + )} + + )} + {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx index 31701f660..c0569ea8b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx @@ -20,7 +20,8 @@ const eventTypeKeys: Record = { [TimelineEventType.CommentAdded]: { id: 'timelineCommentAdded', defaultMessage: 'Comment Added' }, [TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' }, [TimelineEventType.Escalated]: { id: 'timelineEscalated', defaultMessage: 'Escalated' }, - [TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' } + [TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' }, + [TimelineEventType.SubjectChanged]: { id: 'timelineSubjectChanged', defaultMessage: 'Subject Changed' } }; const eventTypeColors: Record = { @@ -30,7 +31,8 @@ const eventTypeColors: Record = { [TimelineEventType.CommentAdded]: '#2e7d32', [TimelineEventType.AiDiagnosisAttached]: '#0288d1', [TimelineEventType.Escalated]: '#d32f2f', - [TimelineEventType.ResolutionAdded]: '#4caf50' + [TimelineEventType.ResolutionAdded]: '#4caf50', + [TimelineEventType.SubjectChanged]: '#7b1fa2' }; interface TimelinePanelProps { diff --git a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx index 888dc58ee..ddeb15536 100644 --- a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx @@ -181,7 +181,8 @@ export enum TimelineEventType { CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5, - ResolutionAdded = 6 + ResolutionAdded = 6, + SubjectChanged = 7 } export type Ticket = { diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index f9ef3cfa4..c4345f916 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -623,6 +623,8 @@ "subCategory": "Unterkategorie", "edit": "Bearbeiten", "save": "Speichern", + "subjectRequired": "Betreff ist erforderlich.", + "failedToSaveSubject": "Betreff konnte nicht gespeichert werden.", "descriptionSaved": "Beschreibung gespeichert.", "subCatGeneral": "Allgemein", "subCatOther": "Sonstiges", @@ -653,6 +655,7 @@ "timelineAiDiagnosis": "KI-Diagnose", "timelineEscalated": "Eskaliert", "timelineResolutionAdded": "Lösung hinzugefügt", + "timelineSubjectChanged": "Betreff geändert", "timelineCreatedDesc": "Ticket erstellt von {name}.", "timelineStatusChangedDesc": "Status geändert auf {status}.", "timelineAssignedDesc": "Zugewiesen an {name}.", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 9f0911d59..4fbfac1d9 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -371,6 +371,8 @@ "subCategory": "Sub-Category", "edit": "Edit", "save": "Save", + "subjectRequired": "Subject is required.", + "failedToSaveSubject": "Failed to save subject.", "descriptionSaved": "Description saved.", "subCatGeneral": "General", "subCatOther": "Other", @@ -401,6 +403,7 @@ "timelineAiDiagnosis": "AI Diagnosis", "timelineEscalated": "Escalated", "timelineResolutionAdded": "Resolution Added", + "timelineSubjectChanged": "Subject Changed", "timelineCreatedDesc": "Ticket created by {name}.", "timelineStatusChangedDesc": "Status changed to {status}.", "timelineAssignedDesc": "Assigned to {name}.", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 1277120a7..114ac199a 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -623,6 +623,8 @@ "subCategory": "Sous-catégorie", "edit": "Modifier", "save": "Enregistrer", + "subjectRequired": "Le sujet est requis.", + "failedToSaveSubject": "Échec de l'enregistrement du sujet.", "descriptionSaved": "Description enregistrée.", "subCatGeneral": "Général", "subCatOther": "Autre", @@ -653,6 +655,7 @@ "timelineAiDiagnosis": "Diagnostic IA", "timelineEscalated": "Escaladé", "timelineResolutionAdded": "Résolution ajoutée", + "timelineSubjectChanged": "Sujet modifié", "timelineCreatedDesc": "Ticket créé par {name}.", "timelineStatusChangedDesc": "Statut modifié en {status}.", "timelineAssignedDesc": "Assigné à {name}.", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 2959bca2c..524bf4e7d 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -623,6 +623,8 @@ "subCategory": "Sottocategoria", "edit": "Modifica", "save": "Salva", + "subjectRequired": "L'oggetto è obbligatorio.", + "failedToSaveSubject": "Impossibile salvare l'oggetto.", "descriptionSaved": "Descrizione salvata.", "subCatGeneral": "Generale", "subCatOther": "Altro", @@ -653,6 +655,7 @@ "timelineAiDiagnosis": "Diagnosi IA", "timelineEscalated": "Escalato", "timelineResolutionAdded": "Risoluzione aggiunta", + "timelineSubjectChanged": "Oggetto modificato", "timelineCreatedDesc": "Ticket creato da {name}.", "timelineStatusChangedDesc": "Stato modificato in {status}.", "timelineAssignedDesc": "Assegnato a {name}.",