~jan0sch/smederee

Showing details for patch f31fdcd29191906e80b6192849af9df27435df8b.
2023-05-15 (Mon), 7:21 PM - Jens Grassel - f31fdcd29191906e80b6192849af9df27435df8b

Tickets: Add route for ticket creation.

- add simple form and route for creating tickets
- add `incrementNextTicketNumber` to project repository
- some code cleanup
- tests
Summary of changes
3 files added
  • modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala
  • modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html
9 files modified with 283 lines added and 36 lines removed
  • modules/hub/src/main/resources/messages.properties with 14 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 7 added and 7 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 123 added and 2 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html with 31 added and 23 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala with 30 added and 4 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala with 30 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala with 26 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala with 10 added and 0 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala with 12 added and 0 removed lines
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-30 15:43:55.172754038 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-30 15:43:55.176754042 +0000
@@ -285,6 +285,18 @@
 form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project.
 form.milestone.title.placeholder=milestone title
 form.milestone.title=Title
+form.ticket.content.help=The description can be as detailed as needed.
+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.labels=Labels
+form.ticket.labels.help=Select labels to apply to this ticket.
+form.ticket.resolution=Resolution
+form.ticket.resolution.help=This can be set to further describe the resolution of a ticket.
+form.ticket.status=Status
+form.ticket.status.help=The current status of the ticket describing its life cycle.
+form.ticket.title.placeholder=Short concise summary what the ticket is about.
+form.ticket.title=Title
 
 project.label.edit.link=Edit
 project.label.edit.title=Edit label ''{0}''.
@@ -304,6 +316,8 @@
 project.milestones.list.empty=There are no milestones defined for this project.
 project.milestones.list.title={0} milestones found.
 project.milestones.view.title=Milestones
+project.tickets.add.link=New ticket
+project.tickets.add.title=Create a new ticket for {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/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-30 15:43:55.176754042 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-30 15:43:55.176754042 +0000
@@ -155,13 +155,13 @@
         ticketsConfiguration.database.user,
         ticketsConfiguration.database.pass
       )
-      ticketServiceApi      = new DoobieTicketServiceApi[IO](ticketsTransactor)
-      ticketLabelsRepo      = new DoobieLabelRepository[IO](ticketsTransactor)
-      ticketMilestonesRepo  = new DoobieMilestoneRepository[IO](ticketsTransactor)
-      ticketProjectsRepo    = new DoobieProjectRepository[IO](ticketsTransactor)
-      ticketsRepo           = new DoobieTicketRepository[IO](ticketsTransactor)
-      ticketRoutes          = new TicketRoutes[IO](ticketsConfiguration, ticketProjectsRepo, ticketsRepo)
-      ticketLabelRoutes     = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo)
+      ticketServiceApi     = new DoobieTicketServiceApi[IO](ticketsTransactor)
+      ticketLabelsRepo     = new DoobieLabelRepository[IO](ticketsTransactor)
+      ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor)
+      ticketProjectsRepo   = new DoobieProjectRepository[IO](ticketsTransactor)
+      ticketsRepo          = new DoobieTicketRepository[IO](ticketsTransactor)
+      ticketRoutes      = new TicketRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo, ticketsRepo)
+      ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo)
       ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo)
       cryptoClock           = java.time.Clock.systemUTC
       csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile)
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala	2025-01-30 15:43:55.176754042 +0000
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.tickets
+
+import cats._
+import cats.data._
+import cats.syntax.all._
+import de.smederee.tickets.forms.FormValidator
+import de.smederee.tickets.forms.types._
+
+import scala.util.Try
+
+/** Data container to edit a ticket.
+  *
+  * @param number
+  *   The unique identifier of a ticket within the project scope is its number.
+  * @param title
+  *   A concise and short description of the ticket which should not exceed 72 characters.
+  * @param content
+  *   An optional field to describe the ticket in great detail if needed.
+  * @param status
+  *   The current status of the ticket describing its life cycle.
+  * @param resolution
+  *   An optional resolution state of the ticket that should be set if it is closed.
+  * @param submitter
+  *   The unique id of the person who submitted (created) this ticket which is optional because of possible account
+  *   deletion or other reasons.
+  */
+final case class TicketForm(
+    number: Option[TicketNumber],
+    title: TicketTitle,
+    content: Option[TicketContent],
+    status: TicketStatus,
+    resolution: Option[TicketResolution],
+    submitter: Option[SubmitterId]
+)
+
+object TicketForm extends FormValidator[TicketForm] {
+  val fieldNumber: FormField     = FormField("number")
+  val fieldTitle: FormField      = FormField("title")
+  val fieldContent: FormField    = FormField("content")
+  val fieldStatus: FormField     = FormField("status")
+  val fieldResolution: FormField = FormField("resolution")
+  val fieldSubmitter: FormField  = FormField("submitter")
+
+  /** Create a form for editing a ticket from the given ticket data.
+    *
+    * @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(ticket: Ticket): TicketForm =
+    TicketForm(
+      number = ticket.number.some,
+      title = ticket.title,
+      content = ticket.content,
+      status = ticket.status,
+      resolution = ticket.resolution,
+      submitter = ticket.submitter.map(_.id)
+    )
+
+  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, TicketForm] = {
+    val number = data
+      .get(fieldNumber)
+      .fold(Option.empty[TicketNumber].validNec)(s =>
+        TicketNumber
+          .fromString(s)
+          .fold(FormFieldError("Invalid ticket number!").invalidNec)(number => Option(number).validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldNumber -> es.toList)))
+    val title = data
+      .get(fieldTitle)
+      .map(_.trim)
+      .fold(FormFieldError("No ticket title given!").invalidNec)(s =>
+        TicketTitle.from(s).fold(FormFieldError("Invalid ticket title!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList)))
+    val content = data
+      .get(fieldContent)
+      .filter(_.nonEmpty)
+      .fold(none[TicketContent].validNec)(s =>
+        TicketContent.from(s).fold(FormFieldError("Invalid ticket content!").invalidNec)(_.some.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldContent -> es.toList)))
+    val status = data
+      .get(fieldStatus)
+      .fold(FormFieldError("No ticket status given!").invalidNec)(s =>
+        Try(TicketStatus.valueOf(s)).toOption.fold(FormFieldError("Invalid ticket status!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldStatus -> es.toList)))
+    val resolution = data
+      .get(fieldResolution)
+      .fold(none[TicketResolution].validNec)(s =>
+        Try(TicketResolution.valueOf(s)).toOption
+          .fold(FormFieldError("Invalid ticket resolution!").invalidNec)(_.some.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldResolution -> es.toList)))
+    val submitterId = data
+      .get(fieldSubmitter)
+      .fold(none[SubmitterId].validNec)(s =>
+        SubmitterId.fromString(s).toOption.fold(FormFieldError("Invalid submitter id!").invalidNec)(_.some.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldSubmitter -> es.toList)))
+    (number, title, content, status, resolution, submitterId).mapN {
+      case (number, title, content, status, resolution, submitterId) =>
+        TicketForm(number, title, content, status, resolution, submitterId)
+    }
+  }
+
+  extension (form: TicketForm) {
+
+    /** Convert the form class into a stringified map which is used as underlying data type for form handling in the
+      * twirl templating library.
+      *
+      * @return
+      *   A stringified map containing the data of the form.
+      */
+    def toMap: Map[String, String] =
+      Map(
+        TicketForm.fieldNumber.toString     -> form.number.map(_.toString).getOrElse(""),
+        TicketForm.fieldTitle.toString      -> form.title.toString,
+        TicketForm.fieldContent.toString    -> form.content.map(_.toString).getOrElse(""),
+        TicketForm.fieldStatus.toString     -> form.status.toString,
+        TicketForm.fieldResolution.toString -> form.resolution.map(_.toString).getOrElse(""),
+        TicketForm.fieldSubmitter.toString  -> form.submitter.map(_.toString).getOrElse("")
+      )
+  }
+}
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-30 15:43:55.176754042 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-30 15:43:55.176754042 +0000
@@ -17,18 +17,23 @@
 
 package de.smederee.tickets
 
+import java.time.{ OffsetDateTime, ZoneOffset }
+
 import cats._
+import cats.data._
 import cats.effect._
 import cats.syntax.all._
 import de.smederee.html.LinkTools._
 import de.smederee.html._
-import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.Account
+import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.i18n.LanguageCode
 import de.smederee.security.{ CsrfToken, Username }
 import de.smederee.tickets.config._
+import de.smederee.tickets.forms.types._
 import org.http4s._
 import org.http4s.dsl.Http4sDsl
+import org.http4s.headers.Location
 import org.http4s.twirl.TwirlInstances._
 import org.slf4j.LoggerFactory
 
@@ -36,6 +41,8 @@
   *
   * @param configuration
   *   The ticket service configuration.
+  * @param labelRepo
+  *   A repository for handling database operations for labels.
   * @param projectRepo
   *   A repository for handling database operations regarding our projects and their metadata.
   * @param ticketRepo
@@ -45,6 +52,7 @@
   */
 final class TicketRoutes[F[_]: Async](
     configuration: SmedereeTicketsConfiguration,
+    labelRepo: LabelRepository[F],
     projectRepo: ProjectRepository[F],
     ticketRepo: TicketRepository[F]
 ) extends Http4sDsl[F] {
@@ -144,6 +152,119 @@
       }
     } yield resp
 
+  private val addTicket: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "tickets" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+          language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+          projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+          resp <- projectAndId match {
+            case Some((project, projectId)) =>
+              for {
+                _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))(
+                  new Error("Only maintainers may add milestones!")
+                )
+                labels <- labelRepo.allLabels(projectId).compile.toList
+                formData <- Sync[F].delay {
+                  urlForm.values.map { t =>
+                    val (key, values) = t
+                    (
+                      key,
+                      values.headOption.getOrElse("")
+                    ) // Pick the first value (a field might get submitted multiple times)!
+                  }
+                }
+                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,
+                        projectBaseUri,
+                        "Create a new ticket.".some,
+                        user.some,
+                        project
+                      )(formData, FormErrors.fromNec(errors))
+                    )
+                  case Validated.Valid(ticketData) =>
+                    for {
+                      timestamp <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC))
+                      number    <- projectRepo.incrementNextTicketNumber(projectId)
+                      ticket <- Sync[F].delay(
+                        Ticket(
+                          number = number,
+                          title = ticketData.title,
+                          content = ticketData.content,
+                          status = ticketData.status,
+                          resolution = ticketData.resolution,
+                          submitter = None, // FIXME: Enable storing a submitter or default to current user.
+                          createdAt = timestamp,
+                          updatedAt = timestamp
+                        )
+                      )
+                      _    <- ticketRepo.createTicket(projectId)(ticket)
+                      resp <- SeeOther(Location(projectBaseUri.addSegment("tickets")))
+                    } yield resp
+                }
+              } yield resp
+            case _ => NotFound()
+          }
+        } yield resp
+      }
+  }
+
+  private val showCreateTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "newTicket" as user =>
+      for {
+        csrf         <- Sync[F].delay(ar.req.getCsrfToken)
+        language     <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+        projectAndId <- loadProject(user.some)(projectOwnerName, projectName)
+        resp <- projectAndId match {
+          case Some((project, projectId)) =>
+            for {
+              labels <- labelRepo.allLabels(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.createTicket(lang = language)(
+                  projectBaseUri.addSegment("tickets"),
+                  csrf,
+                  labels,
+                  projectBaseUri,
+                  "Create a new ticket.".some,
+                  user.some,
+                  project
+                )()
+              )
+            } yield resp
+          case _ => NotFound()
+        }
+      } yield resp
+  }
+
   private val showTicketsPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
@@ -164,7 +285,7 @@
       } yield resp
   }
 
-  val protectedRoutes = showTicketsPage
+  val protectedRoutes = addTicket <+> showCreateTicketPage <+> showTicketsPage
 
   val routes = 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html	2025-01-30 15:43:55.176754042 +0000
@@ -0,0 +1,127 @@
+@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,
+  projectBaseUri: Uri,
+  title: Option[String] = None,
+  user: Option[Account],
+  project: Project
+)(formData: Map[String, String] = Map.empty,
+  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.view.title")
+        </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.add.title", s"~${project.owner.name}/${project.name}")</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")" 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.get(fieldTitle)}">
+                    @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="8" value="@{formData.get(fieldContent)}"></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="ticket_labels">@Messages("form.ticket.labels")</label>
+                    <select class="pure-input-1" id="ticket_labels" name="ticket_labels" multiple>
+                      @for(label <- labels) {
+                        <option value="@label.id">@label.name</option>
+                      }
+                    </select>
+                    <span class="pure-form-message" id="ticket_labels.help">@Messages("form.ticket.labels.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.get(fieldStatus).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.get(fieldStatus).exists(_ === TicketStatus.Resolved.toString)){}else{disabled="disabled"}>
+                      <option value=""></option>
+                      @for(resolution <- TicketResolution.values) {
+                        <option value="@resolution" @if(formData.get(fieldResolution).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.create.button.submit")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html	2025-01-30 15:43:55.176754042 +0000
@@ -0,0 +1,6 @@
+@import java.time._
+@import java.time.format._
+@import java.util.Locale
+
+@(timestamp: OffsetDateTime, style: FormatStyle = FormatStyle.MEDIUM)(implicit locale: Locale)
+(@DateTimeFormatter.ofLocalizedDateTime(style).withLocale(locale).format(timestamp))
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html	2025-01-30 15:43:55.176754042 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html	2025-01-30 15:43:55.176754042 +0000
@@ -1,7 +1,7 @@
 @import de.smederee.hub.Account
 @import de.smederee.hub.views.html.main
 @import de.smederee.tickets._
-@import de.smederee.tickets.views.html.showProjectMenu
+@import de.smederee.tickets.views.html.format.formatDateTime
 
 @(baseUri: Uri = Uri(path = Uri.Path.Root),
   lang: LanguageCode = LanguageCode("en")
@@ -30,33 +30,41 @@
   <div class="pure-g">
     <div class="pure-u-1-1 pure-u-md-1-1">
       <div class="l-box">
+        @if(user.exists(account => ProjectOwnerId.fromUserId(account.uid) === project.owner.uid)) {
+          <a class="pure-button pure-button-success" href="@projectBaseUri.addSegment("newTicket")" title="@Messages("project.tickets.add.title", s"~${project.owner.name}/${project.name}")">@Messages("project.tickets.add.link")</a>
+        } else {
+        }
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
         <div class="ticket-list">
           <h4>@Messages("project.tickets.list.title", tickets.size)</h4>
           @if(tickets.size === 0) {
             <div class="alert alert-info">@Messages("project.tickets.list.empty")</div>
           } else {
-            @defining(32) { lineHeight =>
-              @for(ticket <- tickets) {
-                <div class="pure-g ticket">
-                  <div class="pure-u-1-24 ticket-icon">@icon(baseUri)("crosshair", lineHeight.some)</div>
-                  <div class="pure-u-3-24">@ticket.number</div>
-                  <div class="pure-u-18-24">@ticket.title</div>
-                  <div class="pure-u-4-24"></div>
-                </div>
-                <div class="pure-g ticket-content">
-                  <div class="pure-u-20-24">@ticket.content.take(176) ...</div>
-                  <div class="pure-u-4-24"></div>
-                </div>
-                <div class="pure-g ticket-details">
-                  <div class="pure-u-3-24"></div>
-                  <div class="pure-u-3-24"></div>
-                  <div class="pure-u-3-24"></div>
-                  <div class="pure-u-3-24">@ticket.status</div>
-                  <div class="pure-u-3-24">@ticket.resolution</div>
-                  <div class="pure-u-3-24">@ticket.submitter</div>
-                  <div class="pure-u-3-24">@ticket.updatedAt</div>
-                </div>
-              }
+            @for(ticket <- tickets) {
+              <div class="pure-g ticket">
+                <div class="pure-u-1-24"><input id="ticket-@ticket.number" type="checkbox"/></div>
+                <div class="pure-u-1-24"><span @if(ticket.status === TicketStatus.Resolved){style="text-decoration: line-through;"}else{}>@ticket.number</span></div>
+                <div class="pure-u-20-24"><a href="#">@ticket.title</a></div>
+                <div class="pure-u-4-24"></div>
+              </div>
+              <div class="pure-g ticket-content">
+                <div class="pure-u-1-24"></div>
+                <div class="pure-u-20-24">@if(ticket.content.exists(_.toString.length < 180)) { @ticket.content } else { @ticket.content.map(_.toString.take(176)) ...} </div>
+                <div class="pure-u-3-24"></div>
+              </div>
+              <div class="pure-g ticket-details" style="font-size: 14px;">
+                <div class="pure-u-1-24"></div>
+                <div class="pure-u-8-24"></div>
+                <div class="pure-u-3-24">@ticket.status</div>
+                <div class="pure-u-3-24">@ticket.resolution</div>
+                <div class="pure-u-3-24">@ticket.submitter</div>
+                <div class="pure-u-6-24">@formatDateTime(ticket.updatedAt)</div>
+              </div>
             }
           }
         </div>
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala	2025-01-30 15:43:55.176754042 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/BaseSpec.scala	2025-01-30 15:43:55.176754042 +0000
@@ -243,6 +243,32 @@
       } yield r
     }
 
+  /** Return the next ticket number for the given project.
+    *
+    * @param projectId
+    *   The internal database ID of the project.
+    * @return
+    *   The next ticket number.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def loadNextTicketNumber(projectId: ProjectId): IO[Int] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT next_ticket_number FROM "tickets"."projects" WHERE id = ?"""
+          )
+        )
+        _      <- IO.delay(statement.setLong(1, projectId.toLong))
+        result <- IO.delay(statement.executeQuery)
+        number <- IO.delay {
+          result.next()
+          result.getInt("next_ticket_number")
+        }
+        _ <- IO(statement.close())
+      } yield number
+    }
+
   /** Find the project ID for the given owner and project name.
     *
     * @param owner
@@ -264,7 +290,7 @@
         _      <- IO.delay(statement.setObject(1, owner))
         _      <- IO.delay(statement.setString(2, name.toString))
         result <- IO.delay(statement.executeQuery)
-        account <- IO.delay {
+        projectId <- IO.delay {
           if (result.next()) {
             ProjectId.from(result.getLong("id"))
           } else {
@@ -272,7 +298,7 @@
           }
         }
         _ <- IO(statement.close())
-      } yield account
+      } yield projectId
     }
 
   /** Find the ticket ID for the given project ID and ticket number.
@@ -296,7 +322,7 @@
         _      <- IO.delay(statement.setLong(1, project.toLong))
         _      <- IO.delay(statement.setInt(2, number.toInt))
         result <- IO.delay(statement.executeQuery)
-        account <- IO.delay {
+        ticketId <- IO.delay {
           if (result.next()) {
             TicketId.from(result.getLong("id"))
           } else {
@@ -304,7 +330,7 @@
           }
         }
         _ <- IO(statement.close())
-      } yield account
+      } yield ticketId
     }
 
   /** Find the ticket service user with the given user id.
diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala
--- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	2025-01-30 15:43:55.176754042 +0000
+++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieProjectRepositoryTest.scala	2025-01-30 15:43:55.176754042 +0000
@@ -124,6 +124,36 @@
     }
   }
 
+  test("incrementNextTicketNumber must return and increment the old value") {
+    (genProjectOwner.sample, genProject.sample) match {
+      case (Some(owner), Some(firstProject)) =>
+        val project     = firstProject.copy(owner = owner)
+        val dbConfig    = configuration.database
+        val tx          = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val projectRepo = new DoobieProjectRepository[IO](tx)
+        val test = for {
+          _         <- createProjectOwner(owner)
+          _         <- projectRepo.createProject(project)
+          projectId <- loadProjectId(owner.uid, project.name)
+          result <- projectId match {
+            case None => fail("Project was not created!")
+            case Some(projectId) =>
+              for {
+                before <- loadNextTicketNumber(projectId)
+                number <- projectRepo.incrementNextTicketNumber(projectId)
+                after  <- loadNextTicketNumber(projectId)
+              } yield (TicketNumber(before), number, TicketNumber(after))
+          }
+        } yield result
+        test.map { result =>
+          val (before, number, after) = result
+          assertEquals(before, number)
+          assertEquals(after, TicketNumber(number.toInt + 1))
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
   test("updateProject must update a project") {
     (genProjectOwner.sample, genProject.sample, genProject.sample) match {
       case (Some(owner), Some(firstProject), Some(secondProject)) =>
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala	2025-01-30 15:43:55.176754042 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieProjectRepository.scala	2025-01-30 15:43:55.176754042 +0000
@@ -37,6 +37,7 @@
   given Meta[ProjectName]        = Meta[String].timap(ProjectName.apply)(_.toString)
   given Meta[ProjectOwnerId]     = Meta[UUID].timap(ProjectOwnerId.apply)(_.toUUID)
   given Meta[ProjectOwnerName]   = Meta[String].timap(ProjectOwnerName.apply)(_.toString)
+  given Meta[TicketNumber]       = Meta[Int].timap(TicketNumber.apply)(_.toInt)
 
   override def createProject(project: Project): F[Int] =
     sql"""INSERT INTO "tickets"."projects" (name, owner, is_private, description, created_at, updated_at)
@@ -88,6 +89,31 @@
           FROM "tickets"."users"
           WHERE name = $name""".query[ProjectOwner].option.transact(tx)
 
+  override def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber] = {
+    // TODO: Find out which of the queries is more reliable and more performant.
+    val sqlQuery1 = sql"""UPDATE "tickets"."projects" AS alias1
+                            SET next_ticket_number = alias2.next_ticket_number + 1
+                          FROM (
+                            SELECT
+                              id,
+                              next_ticket_number
+                            FROM "tickets"."projects"
+                            WHERE id = $projectId
+                          ) AS alias2
+                          WHERE alias1.id = alias2.id
+                          RETURNING alias2.next_ticket_number AS next_ticket_number"""
+    val sqlQuery2 = sql"""WITH old_number AS (
+                            SELECT next_ticket_number FROM "tickets"."projects" WHERE id = $projectId
+                          )
+                          UPDATE "tickets"."projects"
+                            SET next_ticket_number = next_ticket_number + 1
+                          WHERE id = $projectId
+                          RETURNING (
+                            SELECT next_ticket_number FROM old_number
+                          )"""
+    sqlQuery2.query[TicketNumber].unique.transact(tx)
+  }
+
   override def updateProject(project: Project): F[Int] =
     sql"""UPDATE "tickets"."projects" SET
             is_private = ${project.isPrivate},
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala	2025-01-30 15:43:55.176754042 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/ProjectRepository.scala	2025-01-30 15:43:55.180754046 +0000
@@ -74,6 +74,16 @@
     */
   def findProjectOwner(name: ProjectOwnerName): F[Option[ProjectOwner]]
 
+  /** Increment the counter column for the next ticket number and return the old value (i.e. the value _before_ it was
+    * incremented).
+    *
+    * @param projectId
+    *   The internal database id of the project.
+    * @return
+    *   The ticket number _before_ it was incremented.
+    */
+  def incrementNextTicketNumber(projectId: ProjectId): F[TicketNumber]
+
   /** Update the database entry for the given project.
     *
     * @param project
diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala
--- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-30 15:43:55.176754042 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-30 15:43:55.180754046 +0000
@@ -95,6 +95,8 @@
   given Ordering[TicketNumber] = (x: TicketNumber, y: TicketNumber) => x.compareTo(y)
   given Order[TicketNumber]    = Order.fromOrdering
 
+  val Format: Regex = "^-?\\d+$".r
+
   /** Create an instance of TicketNumber from the given Int type.
     *
     * @param source
@@ -113,6 +115,16 @@
     */
   def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0)
 
+  /** Try to create an instance of TicketNumber from the given String.
+    *
+    * @param source
+    *   A string that should fulfil the requirements to be converted into a TicketNumber.
+    * @return
+    *   An option to the successfully converted TicketNumber.
+    */
+  def fromString(source: String): Option[TicketNumber] =
+    Option(source).filter(Format.matches).map(_.toInt).flatMap(from)
+
   extension (number: TicketNumber) {
     def toInt: Int = number.toInt
   }