diff --git a/backend/testjam/routers/bugs.py b/backend/testjam/routers/bugs.py index 26eb954..33ae04f 100644 --- a/backend/testjam/routers/bugs.py +++ b/backend/testjam/routers/bugs.py @@ -19,7 +19,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status -from sqlalchemy import func, select +from sqlalchemy import case, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, selectinload @@ -176,6 +176,17 @@ def _latest_active_version_id(db: Session, project_id: int) -> int | None: return row[0] if row else None +SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3} + + +def _bug_sort_clause(sort_by: str | None): + if sort_by == "severity": + return case(SEVERITY_ORDER, value=Bug.severity, else_=99) + if sort_by == "created_at": + return Bug.created_at.desc() + return Bug.number.desc() + + @projects_router.get("/{id}/bugs", response_model=list[BugOut]) def list_bugs( id: int, @@ -188,6 +199,7 @@ def list_bugs( search: str | None = None, cf_key: str | None = None, cf_value: str | None = None, + sort_by: str | None = None, skip: int = 0, limit: int = 50, db: Session = Depends(get_db), @@ -218,7 +230,8 @@ def list_bugs( if search: like = f"%{search.lower()}%" query = query.filter(func.lower(Bug.title).like(like)) - rows = query.order_by(Bug.number.desc()).all() + order = _bug_sort_clause(sort_by) + rows = query.order_by(order).all() if tag: rows = [bug for bug in rows if bug.tags and tag in bug.tags] if cf_key: diff --git a/frontend/src/components/bug/NewBugDialog.jsx b/frontend/src/components/bug/NewBugDialog.jsx index 510e7a0..fa17922 100644 --- a/frontend/src/components/bug/NewBugDialog.jsx +++ b/frontend/src/components/bug/NewBugDialog.jsx @@ -11,13 +11,13 @@ import { Input } from "../ui/input" import { Label } from "../ui/label" import { Textarea } from "../ui/textarea" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" +import { Badge } from "../ui/badge" import { EnvironmentPicker } from "../environment/EnvironmentPicker" import { CustomFieldsForm } from "../customField/CustomFieldInput" +import { SEVERITIES, SEVERITY_VARIANT } from "../../lib/bugConfig" const VERSION_NONE = "__none__" -const SEVERITIES = ["critical", "high", "medium", "low"] - export function NewBugDialog({ projectId, trigger, prefill = {}, onCreated }) { const { t } = useTranslation("bugs") const [open, setOpen] = useState(false) @@ -91,10 +91,16 @@ export function NewBugDialog({ projectId, trigger, prefill = {}, onCreated }) {
diff --git a/frontend/src/components/share/PublicReportDialog.jsx b/frontend/src/components/share/PublicReportDialog.jsx index a5a7e8b..30d615f 100644 --- a/frontend/src/components/share/PublicReportDialog.jsx +++ b/frontend/src/components/share/PublicReportDialog.jsx @@ -49,6 +49,10 @@ export function PublicReportDialog({ const handleCreate = async (event) => { event.preventDefault() + if (expiresAt && new Date(expiresAt) <= new Date()) { + toast.error(t("expiredDateError")) + return + } try { const payload = { kind: defaultKind, @@ -133,6 +137,7 @@ export function PublicReportDialog({ setExpiresAt(event.target.value)} />

{t("expiresHelp")}

diff --git a/frontend/src/components/ui/context-panel.jsx b/frontend/src/components/ui/context-panel.jsx index a2183c2..3252345 100644 --- a/frontend/src/components/ui/context-panel.jsx +++ b/frontend/src/components/ui/context-panel.jsx @@ -77,10 +77,12 @@ function ContextSection({ section }) { function ContextRow({ row }) { const Icon = row.icon return ( -
- {Icon && } - {row.label} - {row.value} +
+
+ {Icon && } +
+
{row.label}
+
{row.value}
) } diff --git a/frontend/src/i18n/locales/ca/bugs.json b/frontend/src/i18n/locales/ca/bugs.json index 1e41ef1..57d0ba8 100644 --- a/frontend/src/i18n/locales/ca/bugs.json +++ b/frontend/src/i18n/locales/ca/bugs.json @@ -24,7 +24,11 @@ "version": "Versió", "assignee": "Assignat", "tag": "Etiqueta", - "any": "Qualsevol" + "any": "Qualsevol", + "sortLabel": "Sort by", + "sortNewest": "Newest first", + "sortSeverity": "Severity", + "sortCreated": "Creation date" }, "severity": { "critical": "Crítica", @@ -48,7 +52,8 @@ "version": "Versió", "environment": "Entorn", "assignee": "Assignat", - "externalUrl": "URL del tiquet extern" + "externalUrl": "URL del tiquet extern", + "notSet": "Not set" }, "actions": { "save": "Desa", @@ -57,7 +62,8 @@ "changeStatus": "Canvia estat", "addComment": "Afegeix comentari", "edit": "Edita", - "cancel": "Cancel·la" + "cancel": "Cancel·la", + "editTitle": "Click to edit title" }, "tabs": { "description": "Descripció", @@ -166,7 +172,8 @@ "version": "Versió", "externalTicket": "Tiquet extern", "tags": "Etiquetes", - "noTags": "Sense etiquetes" + "noTags": "Sense etiquetes", + "affectedVersion": "Affected version" }, "links": { "add": "Afegeix vincle", @@ -205,11 +212,16 @@ "updateFailed": "No s'han pogut desar els canvis", "commentFailed": "No s'ha pogut afegir el comentari", "commentEditFailed": "No s'ha pogut desar el comentari", - "commentDeleteFailed": "No s'ha pogut eliminar el comentari" + "commentDeleteFailed": "No s'ha pogut eliminar el comentari", + "severityChanged": "Severity updated", + "severityFailed": "Could not change severity" }, "report": { "dialogTitle": "Reporta bug", "fromResult": "Des del resultat {{result}}", "logHint": "L'extracte del log s'adjunta automàticament a la descripció." + }, + "list": { + "updated": "updated" } } diff --git a/frontend/src/i18n/locales/ca/publicReports.json b/frontend/src/i18n/locales/ca/publicReports.json index 9667345..4668a03 100644 --- a/frontend/src/i18n/locales/ca/publicReports.json +++ b/frontend/src/i18n/locales/ca/publicReports.json @@ -30,5 +30,6 @@ "revoked": "Enllaç revocat", "revokeFailed": "No s'ha pogut revocar l'enllaç", "created": "Enllaç públic generat", - "createFailed": "No s'ha pogut crear l'enllaç" + "createFailed": "No s'ha pogut crear l'enllaç", + "expiredDateError": "Expiration date must be in the future" } diff --git a/frontend/src/i18n/locales/en/bugs.json b/frontend/src/i18n/locales/en/bugs.json index e0ae1db..31df574 100644 --- a/frontend/src/i18n/locales/en/bugs.json +++ b/frontend/src/i18n/locales/en/bugs.json @@ -36,7 +36,11 @@ "versionLabel": "Filter by version", "anyStatus": "Any status", "anySeverity": "Any severity", - "anyVersion": "Any version" + "anyVersion": "Any version", + "sortLabel": "Sort by", + "sortNewest": "Newest first", + "sortSeverity": "Severity", + "sortCreated": "Creation date" }, "severity": { "critical": "Critical", @@ -66,7 +70,8 @@ "fixedInVersionNone": "Not set", "affectedVersion": "Affected version", "affectedVersionPlaceholder": "Pick a version…", - "affectedVersionNone": "Not set" + "affectedVersionNone": "Not set", + "notSet": "Not set" }, "actions": { "save": "Save", @@ -75,7 +80,8 @@ "changeStatus": "Change status", "addComment": "Add comment", "edit": "Edit", - "cancel": "Cancel" + "cancel": "Cancel", + "editTitle": "Click to edit title" }, "tabs": { "description": "Description", @@ -188,7 +194,8 @@ "version": "Version", "externalTicket": "External ticket", "tags": "Tags", - "noTags": "No tags" + "noTags": "No tags", + "affectedVersion": "Affected version" }, "links": { "add": "Add link", @@ -227,11 +234,16 @@ "updateFailed": "Could not save changes", "commentFailed": "Could not add comment", "commentEditFailed": "Could not save comment", - "commentDeleteFailed": "Could not delete comment" + "commentDeleteFailed": "Could not delete comment", + "severityChanged": "Severity updated", + "severityFailed": "Could not change severity" }, "report": { "dialogTitle": "Report bug", "fromResult": "From result {{result}}", "logHint": "Log excerpt attached to the description automatically." + }, + "list": { + "updated": "updated" } } diff --git a/frontend/src/i18n/locales/en/publicReports.json b/frontend/src/i18n/locales/en/publicReports.json index 00a4cb6..1c49eb7 100644 --- a/frontend/src/i18n/locales/en/publicReports.json +++ b/frontend/src/i18n/locales/en/publicReports.json @@ -30,5 +30,6 @@ "revoked": "Link revoked", "revokeFailed": "Could not revoke link", "created": "Public link generated", - "createFailed": "Could not create link" + "createFailed": "Could not create link", + "expiredDateError": "Expiration date must be in the future" } diff --git a/frontend/src/i18n/locales/es/bugs.json b/frontend/src/i18n/locales/es/bugs.json index 001472c..05d617e 100644 --- a/frontend/src/i18n/locales/es/bugs.json +++ b/frontend/src/i18n/locales/es/bugs.json @@ -36,7 +36,11 @@ "versionLabel": "Filtrar por versión", "anyStatus": "Cualquier estado", "anySeverity": "Cualquier severidad", - "anyVersion": "Cualquier versión" + "anyVersion": "Cualquier versión", + "sortLabel": "Ordenar por", + "sortNewest": "Más recientes", + "sortSeverity": "Severidad", + "sortCreated": "Fecha de creación" }, "severity": { "critical": "Crítica", @@ -66,7 +70,8 @@ "fixedInVersionNone": "Sin establecer", "affectedVersion": "Versión afectada", "affectedVersionPlaceholder": "Elige una versión…", - "affectedVersionNone": "Sin establecer" + "affectedVersionNone": "Sin establecer", + "notSet": "Sin asignar" }, "actions": { "save": "Guardar", @@ -75,7 +80,8 @@ "changeStatus": "Cambiar estado", "addComment": "Añadir comentario", "edit": "Editar", - "cancel": "Cancelar" + "cancel": "Cancelar", + "editTitle": "Clic para editar el título" }, "tabs": { "description": "Descripción", @@ -188,7 +194,8 @@ "version": "Versión", "externalTicket": "Ticket externo", "tags": "Etiquetas", - "noTags": "Sin etiquetas" + "noTags": "Sin etiquetas", + "affectedVersion": "Versión afectada" }, "links": { "add": "Añadir vínculo", @@ -227,11 +234,16 @@ "updateFailed": "No se pudieron guardar los cambios", "commentFailed": "No se pudo añadir el comentario", "commentEditFailed": "No se pudo guardar el comentario", - "commentDeleteFailed": "No se pudo eliminar el comentario" + "commentDeleteFailed": "No se pudo eliminar el comentario", + "severityChanged": "Severidad actualizada", + "severityFailed": "No se pudo cambiar la severidad" }, "report": { "dialogTitle": "Reportar bug", "fromResult": "Desde el resultado {{result}}", "logHint": "El extracto de log se adjunta automáticamente a la descripción." + }, + "list": { + "updated": "actualizado" } } diff --git a/frontend/src/i18n/locales/es/publicReports.json b/frontend/src/i18n/locales/es/publicReports.json index c62a7a8..34f8a08 100644 --- a/frontend/src/i18n/locales/es/publicReports.json +++ b/frontend/src/i18n/locales/es/publicReports.json @@ -30,5 +30,6 @@ "revoked": "Enlace revocado", "revokeFailed": "No se pudo revocar el enlace", "created": "Enlace público generado", - "createFailed": "No se pudo crear el enlace" + "createFailed": "No se pudo crear el enlace", + "expiredDateError": "La fecha de expiración debe ser futura" } diff --git a/frontend/src/i18n/locales/eu/bugs.json b/frontend/src/i18n/locales/eu/bugs.json index 901e227..b385c6e 100644 --- a/frontend/src/i18n/locales/eu/bugs.json +++ b/frontend/src/i18n/locales/eu/bugs.json @@ -24,7 +24,11 @@ "version": "Bertsioa", "assignee": "Esleitua", "tag": "Etiketa", - "any": "Edozein" + "any": "Edozein", + "sortLabel": "Sort by", + "sortNewest": "Newest first", + "sortSeverity": "Severity", + "sortCreated": "Creation date" }, "severity": { "critical": "Kritikoa", @@ -48,7 +52,8 @@ "version": "Bertsioa", "environment": "Ingurunea", "assignee": "Esleitua", - "externalUrl": "Kanpoko txartelaren URLa" + "externalUrl": "Kanpoko txartelaren URLa", + "notSet": "Not set" }, "actions": { "save": "Gorde", @@ -57,7 +62,8 @@ "changeStatus": "Aldatu egoera", "addComment": "Gehitu iruzkina", "edit": "Editatu", - "cancel": "Utzi" + "cancel": "Utzi", + "editTitle": "Click to edit title" }, "tabs": { "description": "Deskribapena", @@ -166,7 +172,8 @@ "version": "Bertsioa", "externalTicket": "Kanpoko txartela", "tags": "Etiketak", - "noTags": "Etiketarik ez" + "noTags": "Etiketarik ez", + "affectedVersion": "Affected version" }, "links": { "add": "Gehitu lotura", @@ -205,11 +212,16 @@ "updateFailed": "Ezin izan dira aldaketak gorde", "commentFailed": "Ezin izan da iruzkina gehitu", "commentEditFailed": "Ezin izan da iruzkina gorde", - "commentDeleteFailed": "Ezin izan da iruzkina ezabatu" + "commentDeleteFailed": "Ezin izan da iruzkina ezabatu", + "severityChanged": "Severity updated", + "severityFailed": "Could not change severity" }, "report": { "dialogTitle": "Eman akatsaren berri", "fromResult": "Emaitzatik {{result}}", "logHint": "Logaren laburpena automatikoki gehitzen zaio deskribapenari." + }, + "list": { + "updated": "updated" } } diff --git a/frontend/src/i18n/locales/eu/publicReports.json b/frontend/src/i18n/locales/eu/publicReports.json index 3baff39..3f10cff 100644 --- a/frontend/src/i18n/locales/eu/publicReports.json +++ b/frontend/src/i18n/locales/eu/publicReports.json @@ -30,5 +30,6 @@ "revoked": "Esteka baliogabetuta", "revokeFailed": "Ezin izan da esteka baliogabetu", "created": "Esteka publikoa sortu da", - "createFailed": "Ezin izan da esteka sortu" + "createFailed": "Ezin izan da esteka sortu", + "expiredDateError": "Expiration date must be in the future" } diff --git a/frontend/src/i18n/locales/gl/bugs.json b/frontend/src/i18n/locales/gl/bugs.json index 130df2d..9e39784 100644 --- a/frontend/src/i18n/locales/gl/bugs.json +++ b/frontend/src/i18n/locales/gl/bugs.json @@ -24,7 +24,11 @@ "version": "Versión", "assignee": "Asignado", "tag": "Etiqueta", - "any": "Calquera" + "any": "Calquera", + "sortLabel": "Sort by", + "sortNewest": "Newest first", + "sortSeverity": "Severity", + "sortCreated": "Creation date" }, "severity": { "critical": "Crítica", @@ -48,7 +52,8 @@ "version": "Versión", "environment": "Contorno", "assignee": "Asignado", - "externalUrl": "URL do tícket externo" + "externalUrl": "URL do tícket externo", + "notSet": "Not set" }, "actions": { "save": "Gardar", @@ -57,7 +62,8 @@ "changeStatus": "Cambiar estado", "addComment": "Engadir comentario", "edit": "Editar", - "cancel": "Cancelar" + "cancel": "Cancelar", + "editTitle": "Click to edit title" }, "tabs": { "description": "Descrición", @@ -166,7 +172,8 @@ "version": "Versión", "externalTicket": "Tícket externo", "tags": "Etiquetas", - "noTags": "Sen etiquetas" + "noTags": "Sen etiquetas", + "affectedVersion": "Affected version" }, "links": { "add": "Engadir vínculo", @@ -205,11 +212,16 @@ "updateFailed": "Non se puideron gardar os cambios", "commentFailed": "Non se puido engadir o comentario", "commentEditFailed": "Non se puido gardar o comentario", - "commentDeleteFailed": "Non se puido eliminar o comentario" + "commentDeleteFailed": "Non se puido eliminar o comentario", + "severityChanged": "Severity updated", + "severityFailed": "Could not change severity" }, "report": { "dialogTitle": "Reportar bug", "fromResult": "Dende o resultado {{result}}", "logHint": "O extracto do log adxúntase automaticamente á descrición." + }, + "list": { + "updated": "updated" } } diff --git a/frontend/src/i18n/locales/gl/publicReports.json b/frontend/src/i18n/locales/gl/publicReports.json index c8ec1e8..6b796e1 100644 --- a/frontend/src/i18n/locales/gl/publicReports.json +++ b/frontend/src/i18n/locales/gl/publicReports.json @@ -30,5 +30,6 @@ "revoked": "Ligazón revogada", "revokeFailed": "Non se puido revogar a ligazón", "created": "Ligazón pública xerada", - "createFailed": "Non se puido crear a ligazón" + "createFailed": "Non se puido crear a ligazón", + "expiredDateError": "Expiration date must be in the future" } diff --git a/frontend/src/lib/bugConfig.js b/frontend/src/lib/bugConfig.js index b0aaa24..ef8ca65 100644 --- a/frontend/src/lib/bugConfig.js +++ b/frontend/src/lib/bugConfig.js @@ -1,3 +1,5 @@ +export const SEVERITIES = ["critical", "high", "medium", "low"] + export const SEVERITY_VARIANT = { critical: "destructive", high: "warning", diff --git a/frontend/src/pages/BugDetailPage.jsx b/frontend/src/pages/BugDetailPage.jsx index 2148bb6..aa5d7c7 100644 --- a/frontend/src/pages/BugDetailPage.jsx +++ b/frontend/src/pages/BugDetailPage.jsx @@ -59,7 +59,7 @@ import { BugTimeline } from "../components/bug/BugTimeline" import { BugExternalLinksPanel } from "../components/integration/BugExternalLinksPanel" import { CustomFieldsEditableBody } from "../components/customField/CustomFieldInput" import { MdEditor, MdViewer } from "../components/MdEditor" -import { SEVERITY_VARIANT, STATUS_VARIANT, STATUSES } from "../lib/bugConfig" +import { SEVERITIES, SEVERITY_VARIANT, STATUS_VARIANT, STATUSES } from "../lib/bugConfig" export function BugDetailPage() { const { t } = useTranslation(["bugs", "nav", "customFields"]) @@ -124,6 +124,8 @@ export function BugDetailPage() { const [editingBody, setEditingBody] = useState("") const [editingDescription, setEditingDescription] = useState(false) const [descriptionDraft, setDescriptionDraft] = useState("") + const [editingTitle, setEditingTitle] = useState(false) + const [titleDraft, setTitleDraft] = useState("") const [deleteBugOpen, setDeleteBugOpen] = useState(false) const [commentPendingDelete, setCommentPendingDelete] = useState(null) const statusLabels = useMemo( @@ -132,10 +134,23 @@ export function BugDetailPage() { ) const historyCount = activity.length + comments.length + const affectedVersionPicker = bug ? ( + + updateBug.mutateAsync({ id: bug.id, data: { version_id: versionId } }) + } + saving={updateBug.isPending} + /> + ) : null + const fixedInVersionPicker = bug ? ( - updateBug.mutateAsync({ id: bug.id, data: { fixed_in_version_id: versionId } }) } @@ -158,14 +173,37 @@ export function BugDetailPage() { ), } : null + const handleStatusChange = async (status) => { + if (!bug || status === bug.status) return + try { + await changeStatus.mutateAsync({ id: bug.id, status }) + toast.success(t("toast.statusChanged")) + } catch (error) { + toast.error(error?.response?.data?.detail ?? t("toast.statusFailed")) + } + } + + const handleSeverityChange = async (severity) => { + if (!bug || severity === bug.severity) return + try { + await updateBug.mutateAsync({ id: bug.id, data: { severity } }) + toast.success(t("toast.severityChanged")) + } catch (error) { + toast.error(error?.response?.data?.detail ?? t("toast.severityFailed")) + } + } + const contextSections = useMemo(() => { if (!bug) return [] return buildContextSections({ bug, links, linkContext, + affectedVersionPicker, fixedInVersionPicker, customFieldsSection, + onSeverityChange: handleSeverityChange, + onStatusChange: handleStatusChange, onDeleteLink: canWrite ? (linkId) => removeLink.mutateAsync({ bugId: bug.id, linkId }) : null, @@ -185,7 +223,7 @@ export function BugDetailPage() { ) : null, t, }) - }, [bug, links, linkContext, removeLink, fixedInVersionPicker, customFieldsSection, canWrite, t]) + }, [bug, links, linkContext, removeLink, affectedVersionPicker, fixedInVersionPicker, customFieldsSection, canWrite, t]) if (isLoading || !bug) { return ( @@ -198,14 +236,19 @@ export function BugDetailPage() { ) } - const handleStatusChange = async (status) => { - if (status === bug.status) return + const handleTitleSave = async () => { + const trimmed = titleDraft.trim() + if (!trimmed || trimmed === bug.title) { + setEditingTitle(false) + return + } try { - await changeStatus.mutateAsync({ id: bug.id, status }) - toast.success(t("toast.statusChanged")) + await updateBug.mutateAsync({ id: bug.id, data: { title: trimmed } }) + toast.success(t("toast.updated")) } catch (error) { - toast.error(error?.response?.data?.detail ?? t("toast.statusFailed")) + toast.error(error?.response?.data?.detail ?? t("toast.updateFailed")) } + setEditingTitle(false) } const handleDelete = async () => { @@ -296,16 +339,30 @@ export function BugDetailPage() {

#{bug.number} - {bug.title} + {editingTitle ? ( + setTitleDraft(event.target.value)} + onBlur={handleTitleSave} + onKeyDown={event => { + if (event.key === "Enter") handleTitleSave() + if (event.key === "Escape") setEditingTitle(false) + }} + /> + ) : ( + { setTitleDraft(bug.title); setEditingTitle(true) }} + title={t("actions.editTitle")} + > + {bug.title} + + )}

- - {t(`severity.${bug.severity}`)} - - - {t(`statuses.${bug.status}`)} - {bug.environment && ( )} @@ -346,18 +403,6 @@ export function BugDetailPage() { {isWatching ? t("watch.watching") : t("watch.watch")} {bug.watcher_count ?? watchers.length} - {canDelete && (