~jan0sch/smederee

Showing details for patch 333ff13b69c781391ed2be7791ea82c96fdd9368.
2023-05-17 (Wed), 3:28 PM - Jens Grassel - 333ff13b69c781391ed2be7791ea82c96fdd9368

Tickets: Editing of tickets.

- add edit page and logic for tickets
- add saving of selected labels and milestone
- add information to show ticket page
- small css fixes
- add updating of updated_at field upon updating a ticket in the database
Summary of changes
1 files added
  • modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html
7 files modified with 222 lines added and 37 lines removed
  • modules/hub/src/main/resources/assets/css/main.css with 10 added and 1 removed lines
  • modules/hub/src/main/resources/messages.properties with 5 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala with 18 added and 16 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 160 added and 5 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html with 2 added and 6 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html with 25 added and 7 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala with 2 added and 1 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-16 13:13:30.106339755 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-16 13:13:30.110339758 +0000
@@ -204,7 +204,7 @@
 .label-name {
   margin: auto;
   overflow: overlay;
-  padding: 0 0.25em;
+  padding: 0 0.5em;
   vertical-align: middle;
 }
 
@@ -331,6 +331,10 @@
   border-bottom: 1px solid var(--background2);
 }
 
+.ticket-buttons {
+  margin-bottom: 0.5em;
+}
+
 .ticket-resolved .ticket-number {
   color: var(--nord3);
   text-decoration: line-through;
@@ -340,6 +344,11 @@
   color: var(--nord3);
 }
 
+.ticket-sidebar {
+  background: var(--background2);
+  line-height: 1.5em;
+}
+
 .todo-default {
   background-color: var(--nord15);
 }
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	2025-01-16 13:13:30.106339755 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-16 13:13:30.110339758 +0000
@@ -289,8 +289,10 @@
 form.ticket.content.placeholder=A detailed description about the issue at hand.
 form.ticket.content=Describe the ticket in detail.
 form.ticket.create.button.submit=Create ticket
+form.ticket.edit.button.submit=Save changes
+form.ticket.edit.button.cancel=Cancel
 form.ticket.labels=Labels
-form.ticket.labels.help=Select labels to apply to this ticket.
+form.ticket.labels.help=Select labels to apply to this ticket. Hold down the control (or command) key to select multiple values.
 form.ticket.milestones=Milestones
 form.ticket.milestones.help=Select the milestones that this ticket is part of.
 form.ticket.resolution=Resolution
@@ -320,6 +322,8 @@
 project.milestones.view.title=Milestones
 project.tickets.add.link=New ticket
 project.tickets.add.title=Create a new ticket for {0}.
+project.tickets.edit.link=Edit ticket
+project.tickets.edit.title=Edit the ticket {0}.
 project.tickets.view.title=Tickets
 project.tickets.list.empty=There are no tickets defined for this project.
 project.tickets.list.title={0} tickets found.
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala	2025-01-16 13:13:30.110339758 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala	2025-01-16 13:13:30.110339758 +0000
@@ -41,9 +41,9 @@
   *   The unique id of the person who submitted (created) this ticket which is optional because of possible account
   *   deletion or other reasons.
   * @param labels
-  *   A list of label ids which are associated with the ticket.
+  *   A list of labels which are associated with the ticket.
   * @param milestones
-  *   A list of milestone ids which are associated with the ticket.
+  *   A list of milestones which are associated with the ticket.
   */
 final case class TicketForm(
     number: Option[TicketNumber],
@@ -52,8 +52,8 @@
     status: TicketStatus,
     resolution: Option[TicketResolution],
     submitter: Option[SubmitterId],
-    labels: List[LabelId],
-    milestones: List[MilestoneId]
+    labels: List[LabelName],
+    milestones: List[MilestoneTitle]
 )
 
 object TicketForm extends FormValidator[TicketForm] {
@@ -69,15 +69,15 @@
   /** Create a form for editing a ticket from the given ticket data.
     *
     * @param labels
-    *   A list of label ids which are associated with the ticket.
+    *   A list of labels which are associated with the ticket.
     * @param milestones
-    *   A list of milestone ids which are associated with the ticket.
+    *   A list of milestones which are associated with the ticket.
     * @param ticket
     *   The ticket which provides the data for the edit form.
     * @return
     *   A ticket form filled with the data from the given ticket.
     */
-  def fromTicket(labels: List[LabelId])(milestones: List[MilestoneId])(ticket: Ticket): TicketForm =
+  def fromTicket(labels: List[LabelName])(milestones: List[MilestoneTitle])(ticket: Ticket): TicketForm =
     TicketForm(
       number = ticket.number.some,
       title = ticket.title,
@@ -141,19 +141,19 @@
           .fold(FormFieldError("Invalid submitter id!").invalidNec)(_.some.validNec)
       )
       .leftMap(es => NonEmptyChain.of(Map(fieldSubmitter -> es.toList)))
-    val labelIds =
+    val labels =
       data
         .get(fieldLabels)
-        .fold(List.empty[LabelId].validNec[FormErrors])(_.toList.flatMap(LabelId.fromString).validNec[FormErrors])
-    val milestoneIds =
+        .fold(List.empty[LabelName].validNec[FormErrors])(_.toList.flatMap(LabelName.from).validNec[FormErrors])
+    val milestones =
       data
         .get(fieldMilestones)
-        .fold(List.empty[MilestoneId].validNec[FormErrors])(
-          _.toList.flatMap(MilestoneId.fromString).validNec[FormErrors]
+        .fold(List.empty[MilestoneTitle].validNec[FormErrors])(
+          _.toList.flatMap(MilestoneTitle.from).validNec[FormErrors]
         )
-    (number, title, content, status, resolution, submitterId, labelIds, milestoneIds).mapN {
-      case (number, title, content, status, resolution, submitterId, labelIds, milestoneIds) =>
-        TicketForm(number, title, content, status, resolution, submitterId, labelIds, milestoneIds)
+    (number, title, content, status, resolution, submitterId, labels, milestones).mapN {
+      case (number, title, content, status, resolution, submitterId, labels, milestones) =>
+        TicketForm(number, title, content, status, resolution, submitterId, labels, milestones)
     }
   }
 
@@ -172,7 +172,9 @@
         TicketForm.fieldContent.toString    -> form.content.map(_.toString).fold(Chain.empty)(c => Chain(c)),
         TicketForm.fieldStatus.toString     -> Chain(form.status.toString),
         TicketForm.fieldResolution.toString -> form.resolution.map(_.toString).fold(Chain.empty)(r => Chain(r)),
-        TicketForm.fieldSubmitter.toString  -> form.submitter.map(_.toString).fold(Chain.empty)(s => Chain(s))
+        TicketForm.fieldSubmitter.toString  -> form.submitter.map(_.toString).fold(Chain.empty)(s => Chain(s)),
+        TicketForm.fieldLabels.toString     -> Chain.fromSeq(form.labels.map(_.toString)),
+        TicketForm.fieldMilestones.toString -> Chain.fromSeq(form.milestones.map(_.toString))
       )
   }
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 13:13:30.110339758 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 13:13:30.110339758 +0000
@@ -129,8 +129,10 @@
       projectAndId <- loadProject(user)(projectOwnerName, projectName)
       ticket       <- projectAndId.traverse(tuple => ticketRepo.findTicket(tuple._2)(ticketNumber))
       resp <- (projectAndId, ticket.getOrElse(None)) match {
-        case (Some((project, _)), Some(ticket)) =>
+        case (Some((project, projectId)), Some(ticket)) =>
           for {
+            labels     <- ticketRepo.loadLabels(projectId)(ticket.number).compile.toList
+            milestones <- ticketRepo.loadMilestones(projectId)(ticket.number).compile.toList
             projectBaseUri <- Sync[F].delay(
               linkConfig.createFullUri(
                 Uri(path =
@@ -145,6 +147,8 @@
               views.html.showTicket(lang = language)(
                 projectBaseUri.addSegment("tickets"),
                 csrf,
+                labels,
+                milestones,
                 ticket,
                 renderedTicketContent,
                 projectBaseUri,
@@ -223,7 +227,7 @@
               case Some((project, projectId)) =>
                 for {
                   _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
-                    new Error("Only maintainers may add milestones!")
+                    new Error("Only maintainers may add tickets!")
                   )
                   labels     <- labelRepo.allLabels(projectId).compile.toList
                   milestones <- milestoneRepo.allMilestones(projectId).compile.toList
@@ -254,8 +258,12 @@
                       )
                     case Validated.Valid(ticketData) =>
                       for {
-                        timestamp <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC))
-                        number    <- projectRepo.incrementNextTicketNumber(projectId)
+                        timestamp    <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC))
+                        ticketLabels <- ticketData.labels.traverse(name => labelRepo.findLabel(projectId)(name))
+                        ticketMilestones <- ticketData.milestones.traverse(title =>
+                          milestoneRepo.findMilestone(projectId)(title)
+                        )
+                        number <- projectRepo.incrementNextTicketNumber(projectId)
                         ticket <- Sync[F].delay(
                           Ticket(
                             number = number,
@@ -269,6 +277,97 @@
                           )
                         )
                         _    <- ticketRepo.createTicket(projectId)(ticket)
+                        _    <- ticketLabels.flatten.traverse(ticketRepo.addLabel(projectId)(number))
+                        _    <- ticketMilestones.flatten.traverse(ticketRepo.addMilestone(projectId)(number))
+                        resp <- SeeOther(Location(projectBaseUri.addSegment("tickets")))
+                      } yield resp
+                  }
+                } yield resp
+              case _ => NotFound()
+            }
+          } yield resp
+      response.recoverWith { error =>
+        log.error("Internal Server Error", error)
+        for {
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+        } yield resp
+      }
+      }
+  }
+
+  private val editTicket: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "tickets" / TicketNumberPathParameter(ticketNumber) as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        val response =
+          for {
+            csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+            language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+            projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+            ticket       <- projectAndId.traverse(tuple => ticketRepo.findTicket(tuple._2)(ticketNumber))
+            resp <- (projectAndId, ticket.getOrElse(None)) match {
+              case (Some((project, projectId)), Some(ticket)) =>
+                for {
+                  _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                    new Error("Only maintainers may edit tickets!")
+                  )
+                  labels     <- labelRepo.allLabels(projectId).compile.toList
+                  milestones <- milestoneRepo.allMilestones(projectId).compile.toList
+                  formData   <- Sync[F].delay(urlForm.values)
+                  form       <- Sync[F].delay(TicketForm.validate(formData))
+                  projectBaseUri <- Sync[F].delay(
+                    linkConfig.createFullUri(
+                      Uri(path =
+                        Uri.Path(
+                          Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString))
+                        )
+                      )
+                    )
+                  )
+                  resp <- form match {
+                    case Validated.Invalid(errors) =>
+                      BadRequest(
+                        views.html.createTicket(lang = language)(
+                          projectBaseUri.addSegment("tickets"),
+                          csrf,
+                          labels,
+                          milestones,
+                          projectBaseUri,
+                          "Create a new ticket.".some,
+                          user.some,
+                          project
+                        )(formData.withDefaultValue(Chain.empty), FormErrors.fromNec(errors))
+                      )
+                    case Validated.Valid(ticketData) =>
+                      for {
+                        timestamp     <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC))
+                        oldLabels     <- ticketRepo.loadLabels(projectId)(ticket.number).compile.toList
+                        oldMilestones <- ticketRepo.loadMilestones(projectId)(ticket.number).compile.toList
+                        ticketLabels  <- ticketData.labels.traverse(name => labelRepo.findLabel(projectId)(name))
+                        ticketMilestones <- ticketData.milestones.traverse(title =>
+                          milestoneRepo.findMilestone(projectId)(title)
+                        )
+                        labelsToCreate     = ticketLabels.flatten.diff(oldLabels)
+                        labelsToRemove     = oldLabels.diff(ticketLabels.flatten)
+                        milestonesToCreate = ticketMilestones.flatten.diff(oldMilestones)
+                        milestonesToRemove = oldMilestones.diff(ticketMilestones.flatten)
+                        updatedTicket =
+                          ticket.copy(
+                            title = ticketData.title,
+                            content = ticketData.content,
+                            status = ticketData.status,
+                            resolution = ticketData.resolution,
+                            createdAt = ticket.createdAt,
+                            updatedAt = timestamp
+                          )
+                        _    <- ticketRepo.updateTicket(projectId)(updatedTicket)
+                        _    <- labelsToRemove.traverse(ticketRepo.removeLabel(projectId)(updatedTicket))
+                        _    <- milestonesToRemove.traverse(ticketRepo.removeMilestone(projectId)(updatedTicket))
+                        _    <- labelsToCreate.traverse(ticketRepo.addLabel(projectId)(ticket.number))
+                        _    <- milestonesToCreate.traverse(ticketRepo.addMilestone(projectId)(ticket.number))
                         resp <- SeeOther(Location(projectBaseUri.addSegment("tickets")))
                       } yield resp
                   }
@@ -336,6 +435,61 @@
       }
   }
 
+  private val showEditTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "tickets" / TicketNumberPathParameter(ticketNumber) / "edit" as user =>
+      val response =
+        for {
+          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+          ticket       <- projectAndId.traverse(tuple => ticketRepo.findTicket(tuple._2)(ticketNumber))
+          resp <- (projectAndId, ticket.getOrElse(None)) match {
+            case (Some((project, projectId)), Some(ticket)) =>
+              for {
+                ticketLabels     <- ticketRepo.loadLabels(projectId)(ticket.number).map(_.name).compile.toList
+                ticketMilestones <- ticketRepo.loadMilestones(projectId)(ticket.number).map(_.title).compile.toList
+                form             <- Sync[F].delay(TicketForm.fromTicket(ticketLabels)(ticketMilestones)(ticket))
+                formData         <- Sync[F].delay(form.toMap)
+                labels           <- labelRepo.allLabels(projectId).compile.toList
+                milestones       <- milestoneRepo.allMilestones(projectId).compile.toList
+                projectBaseUri <- Sync[F].delay(
+                  linkConfig.createFullUri(
+                    Uri(path =
+                      Uri.Path(
+                        Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString))
+                      )
+                    )
+                  )
+                )
+                resp <- Ok(
+                  views.html.editTicket(lang = language)(
+                    projectBaseUri.addSegment("tickets"),
+                    csrf,
+                    labels,
+                    milestones,
+                    projectBaseUri,
+                    ticket.number,
+                    s"Edit ticket ${ticket.number}".some,
+                    user.some,
+                    project
+                  )(formData.withDefaultValue(Chain.empty), FormErrors.empty)
+                )
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      response.recoverWith { error =>
+        log.error("Internal Server Error", error)
+        for {
+          csrf     <- Sync[F].delay(ar.req.getCsrfToken)
+          language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          resp     <- InternalServerError(views.html.errors.internalServerError(lang = language)(csrf, user.some))
+        } yield resp
+      }
+  }
+
   private val showTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
@@ -394,7 +548,8 @@
       } yield resp
   }
 
-  val protectedRoutes = addTicket <+> showCreateTicketPage <+> showTicketPage <+> showTicketsPage
+  val protectedRoutes =
+    addTicket <+> editTicket <+> showCreateTicketPage <+> showEditTicketPage <+> showTicketPage <+> showTicketsPage
 
   val routes = showTicketPageForGuests <+> showTicketsForGuests
 
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html	2025-01-16 13:13:30.110339758 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html	2025-01-16 13:13:30.110339758 +0000
@@ -87,9 +87,7 @@
                     <label for="@fieldLabels">@Messages("form.ticket.labels")</label>
                     <select class="pure-input-1" id="@fieldLabels" name="@fieldLabels" multiple>
                       @for(label <- labels) {
-                        @for(labelId <- label.id) {
-                          <option value="@labelId" @if(formData(fieldLabels).exists(_ === labelId.toString)){selected}else{}>@label.name</option>
-                        }
+                        <option value="@label.name" @if(formData(fieldLabels).exists(_ === label.name.toString)){selected}else{}>@label.name</option>
                       }
                     </select>
                     <span class="pure-form-message" id="@{fieldLabels}.help">@Messages("form.ticket.labels.help")</span>
@@ -98,9 +96,7 @@
                     <milestone for="@fieldMilestones">@Messages("form.ticket.milestones")</milestone>
                     <select class="pure-input-1" id="@fieldMilestones" name="@fieldMilestones" multiple>
                       @for(milestone <- milestones) {
-                        @for(milestoneId <- milestone.id) {
-                          <option value="@milestone.id" @if(formData(fieldMilestones).exists(_ === milestoneId.toString)){selected}else{}>@milestone.title</option>
-                        }
+                        <option value="@milestone.title" @if(formData(fieldMilestones).exists(_ === milestone.title.toString)){selected}else{}>@milestone.title</option>
                       }
                     </select>
                     <span class="pure-form-message" id="@{fieldMilestones}.help">@Messages("form.ticket.milestones.help")</span>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editTicket.scala.html	2025-01-16 13:13:30.110339758 +0000
@@ -0,0 +1,139 @@
+@import de.smederee.hub.Account
+@import de.smederee.hub.views.html.main
+@import de.smederee.tickets.TicketForm._
+@import de.smederee.tickets._
+@import de.smederee.tickets.forms._
+@import de.smederee.tickets.forms.types._
+@import de.smederee.tickets.views.html.forms.renderFormErrors
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  labels: List[Label] = Nil,
+  milestones: List[Milestone] = Nil,
+  projectBaseUri: Uri,
+  ticketNumber: TicketNumber,
+  title: Option[String] = None,
+  user: Option[Account],
+  project: Project
+)(formData: Map[String, Chain[String]],
+  formErrors: FormErrors = FormErrors.empty
+)
+@footer = {
+  <script type="application/javascript">
+    document.addEventListener("DOMContentLoaded", () => {
+      const ticketStatus = document.getElementById("@fieldStatus");
+      const ticketResolution = document.getElementById("@fieldResolution");
+
+      ticketStatus.addEventListener("change", (event) => {
+        if (event.target.value == "@{TicketStatus.Resolved.toString}") {
+          ticketResolution.disabled = false
+        } else {
+          ticketResolution.disabled = true
+          ticketResolution.value = ""
+        }
+      });
+    });
+  </script>
+}
+@main(baseUri, lang)(customFooters = footer)(csrf, title, user) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1">
+      <div class="l-box-left-right">
+        <h2><a href="@{baseUri.addSegment(s"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2>
+        @showProjectMenu(baseUri)(action.some, projectBaseUri, user, project)
+        <div class="project-summary-description">
+          @Messages("project.tickets.edit.title", ticketNumber)
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h4>@Messages("project.tickets.edit.title", s"~${project.owner.name}/${project.name}/$ticketNumber")</h4>
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+              <p class="alert alert-error">
+                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                <span class="sr-only">Fehler:</span>
+                @error
+              </p>
+            }
+          }
+        </div>
+        <div class="edit-tickets-form">
+          <form action="@projectBaseUri.addSegment("tickets").addSegment(ticketNumber.toString)" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
+            <fieldset class="pure-group">
+              <div class="pure-g">
+                <div class="pure-u-18-24 pure-u-md-18-24">
+                  <div class="pure-control-group pure-u-23-24">
+                    <label for="@{fieldTitle}">@Messages("form.ticket.title")</label>
+                    <input class="pure-input-1" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="72" placeholder="@Messages("form.ticket.title.placeholder")" required="required" type="text" value="@{formData(fieldTitle).headOption}">
+                    @renderFormErrors(fieldTitle, formErrors)
+                  </div>
+                  <div class="pure-control-group pure-u-23-24">
+                    <label for="@{fieldContent}">@Messages("form.ticket.content")</label>
+                    <textarea class="pure-input-1" id="@{fieldContent}" name="@{fieldContent}" placeholder="@Messages("form.ticket.content.placeholder")" rows="18">@{formData(fieldContent).headOption}</textarea>
+                    <span class="pure-form-message" id="@{fieldContent}.help">@Messages("form.ticket.content.help")</span>
+                    @renderFormErrors(fieldContent, formErrors)
+                  </div>
+                </div>
+                <div class="pure-6-24 pure-u-md-6-24">
+                  <div class="pure-control-group">
+                    <label for="@fieldLabels">@Messages("form.ticket.labels")</label>
+                    <select class="pure-input-1" id="@fieldLabels" name="@fieldLabels" multiple>
+                      @for(label <- labels) {
+                        <option value="@label.name" @if(formData(fieldLabels).exists(_ === label.name.toString)){selected}else{}>@label.name</option>
+                      }
+                    </select>
+                    <span class="pure-form-message" id="@{fieldLabels}.help">@Messages("form.ticket.labels.help")</span>
+                  </div>
+                  <div class="pure-control-group">
+                    <milestone for="@fieldMilestones">@Messages("form.ticket.milestones")</milestone>
+                    <select class="pure-input-1" id="@fieldMilestones" name="@fieldMilestones" multiple>
+                      @for(milestone <- milestones) {
+                        <option value="@milestone.title" @if(formData(fieldMilestones).exists(_ === milestone.title.toString)){selected}else{}>@milestone.title</option>
+                      }
+                    </select>
+                    <span class="pure-form-message" id="@{fieldMilestones}.help">@Messages("form.ticket.milestones.help")</span>
+                  </div>
+                  <div class="pure-control-group">
+                    <label for="@{fieldStatus}">@Messages("form.ticket.status")</label>
+                    <select class="pure-input-1" id="@{fieldStatus}" name="@{fieldStatus}">
+                      @for(status <- TicketStatus.Submitted :: TicketStatus.values.toList.filterNot(_ === TicketStatus.Submitted)) {
+                        <option value="@status" @if(formData(fieldStatus).headOption.exists(_ === status.toString)){selected="selected"}else{}>@status</option>
+                      }
+                    </select>
+                    <span class="pure-form-message" id="@{fieldStatus}.help">@Messages("form.ticket.status.help")</span>
+                    @renderFormErrors(fieldStatus, formErrors)
+                  </div>
+                  <div class="pure-control-group">
+                    <label for="@{fieldResolution}">@Messages("form.ticket.resolution")</label>
+                    <select class="pure-input-1" id="@{fieldResolution}" name="@{fieldResolution}" @if(formData(fieldStatus).headOption.exists(_ === TicketStatus.Resolved.toString)){}else{disabled="disabled"}>
+                      <option value=""></option>
+                      @for(resolution <- TicketResolution.values) {
+                        <option value="@resolution" @if(formData(fieldResolution).headOption.exists(_ === resolution.toString)){selected="selected"}else{}>@resolution</option>
+                      }
+                    </select>
+                    <span class="pure-form-message" id="@{fieldResolution}.help">@Messages("form.ticket.resolution.help")</span>
+                    @renderFormErrors(fieldResolution, formErrors)
+                  </div>
+                </div>
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-success">@Messages("form.ticket.edit.button.submit")</button>
+              <a class="pure-button" href="@projectBaseUri.addSegment("tickets").addSegment(ticketNumber.toString)">@Messages("form.ticket.edit.button.cancel")</a>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html	2025-01-16 13:13:30.110339758 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html	2025-01-16 13:13:30.110339758 +0000
@@ -7,6 +7,8 @@
   lang: LanguageCode = LanguageCode("en")
 )(action: Uri,
   csrf: Option[CsrfToken] = None,
+  labels: List[Label],
+  milestones: List[Milestone],
   ticket: Ticket,
   renderedTicketContent: Option[String],
   projectBaseUri: Uri,
@@ -27,19 +29,35 @@
     </div>
   </div>
   <div class="pure-g @if(ticket.status === TicketStatus.Resolved){ticket-resolved}else{ticket}">
-    <div class="pure-u-18-24 pure-u-md-18-24">
+    <div class="pure-u-16-24 pure-u-md-16-24">
       <div class="l-box">
+        @if(user.nonEmpty) {
+          <div class="ticket-buttons">
+            <a class="pure-button" href="@projectBaseUri.addSegment("tickets").addSegment(ticket.number.toString).addSegment("edit")" title="@Messages("project.tickets.edit.title", ticket.number)">@Messages("project.tickets.edit.link")</a>
+          </div>
+        } else {}
+        @for(label <- labels) {
+          <span class="label-name" style="background: @label.colour;">@label.name</span>
+        }
         <h1>@ticket.title</h1>
         <div class="ticket-content">@Html(renderedTicketContent)</div>
       </div>
     </div>
-    <div class="pure-u-5-24 pure-u-md-5-24">
+    <div class="pure-u-8-24 pure-u-md-8-24">
       <div class="l-box">
-        <div class="pure-g">
-          <div class="pure-u-2-5">Status</div><div class="pure-u-3-5">@formatTicketStatus(ticket)</div>
-          <div class="pure-u-2-5">Assigned</div><div class="pure-u-3-5">...</div>
-          <div class="pure-u-2-5">Reported</div><div class="pure-u-3-5">@formatTicketSubmitter(baseUri)(ticket) at @formatDateTime(ticket.createdAt)</div>
-          <div class="pure-u-2-5">Updated</div><div class="pure-u-3-5">@formatDateTime(ticket.updatedAt)</div>
+        <div class="pure-g ticket-sidebar">
+          <div class="pure-u-1-5">Status</div><div class="pure-u-4-5">@formatTicketStatus(ticket)</div>
+          <div class="pure-u-1-5">Assigned</div><div class="pure-u-4-5"></div>
+          <div class="pure-u-1-5">Reported</div><div class="pure-u-4-5">@formatTicketSubmitter(baseUri)(ticket) at @formatDateTime(ticket.createdAt)</div>
+          @if(milestones.nonEmpty) {
+            <div class="pure-u-1-5">Milestones</div>
+            <div class="pure-u-4-5">
+              @for(milestone <- milestones) {
+                <a href="@projectBaseUri.addSegment("milestones").addSegment(milestone.title.toString)">@milestone.title</a><br/>
+              }
+            </div>
+          } else {}
+          <div class="pure-u-1-5">Updated</div><div class="pure-u-4-5">@formatDateTime(ticket.updatedAt)</div>
         </div>
       </div>
     </div>
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2025-01-16 13:13:30.110339758 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala	2025-01-16 13:13:30.110339758 +0000
@@ -267,7 +267,8 @@
             content = ${ticket.content},
             status = ${ticket.status},
             resolution = ${ticket.resolution},
-            submitter = ${ticket.submitter.map(_.id)}
+            submitter = ${ticket.submitter.map(_.id)},
+            updated_at = NOW()
           WHERE project = $projectId
           AND number = ${ticket.number}""".update.run.transact(tx)