~jan0sch/smederee

Showing details for patch bc961e8622946f8c0370cc0a41e7286a62398870.
2023-05-16 (Tue), 1:11 PM - Jens Grassel - bc961e8622946f8c0370cc0a41e7286a62398870

Tickets: Add route for showing single tickets.

- add route for authenticated users to show a ticket
- add translations for ticket status and resolution
- add template format helpers for ticket status and submitter
- add rendering function for ticket content to markdown renderer
Summary of changes
3 files added
  • modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketStatus.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketSubmitter.scala.html
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html
5 files modified with 105 lines added and 2 lines removed
  • modules/hub/src/main/resources/messages.properties with 13 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala with 20 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala with 65 added and 1 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html with 1 added and 1 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala with 6 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-16 12:55:19.025847706 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-16 12:55:19.025847706 +0000
@@ -321,3 +321,16 @@
 project.tickets.view.title=Tickets
 project.tickets.list.empty=There are no tickets defined for this project.
 project.tickets.list.title={0} tickets found.
+
+ticket.status.confirmed=Confirmed
+ticket.status.inprogress=In progress
+ticket.status.pending=Pending
+ticket.status.resolved=Resolved
+ticket.status.resolved.bydesign=By design
+ticket.status.resolved.closed=Closed
+ticket.status.resolved.duplicate=Duplicate
+ticket.status.resolved.fixed=Fixed
+ticket.status.resolved.implemented=Implemented
+ticket.status.resolved.invalid=Invalid
+ticket.status.resolved.wontfix=Won''t fix
+ticket.status.submitted=Submitted
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala	2025-01-16 12:55:19.025847706 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/MarkdownRenderer.scala	2025-01-16 12:55:19.025847706 +0000
@@ -20,6 +20,7 @@
 import java.util.Locale
 
 import cats.syntax.all._
+import de.smederee.tickets.TicketContent
 import org.commonmark.ext.gfm.tables.TablesExtension
 import org.commonmark.ext.heading.anchor.HeadingAnchorExtension
 import org.commonmark.ext.task.list.items.TaskListItemsExtension
@@ -40,6 +41,25 @@
   private val MarkdownExtensions =
     List(TablesExtension.create(), HeadingAnchorExtension.create(), TaskListItemsExtension.create())
 
+  /** Render the given ticket content using markdown.
+    *
+    * @param markdownSource
+    *   The content of a ticket description.
+    * @return
+    *   A string containing the rendered markdown (HTML).
+    */
+  def renderTicketContent(markdownSource: TicketContent): String = {
+    val parser   = Parser.builder().extensions(MarkdownExtensions.asJava).build()
+    val markdown = parser.parse(markdownSource.toString)
+    val renderer = HtmlRenderer
+      .builder()
+      .escapeHtml(true)
+      .extensions(MarkdownExtensions.asJava)
+      .sanitizeUrls(true)
+      .build()
+    renderer.render(markdown)
+  }
+
   /** Render the given markdown sources and adjust all relative links by prefixing them with the path for the repostiory
     * file browsing (`repo-name/files`).
     *
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 12:55:19.025847706 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala	2025-01-16 12:55:19.025847706 +0000
@@ -36,6 +36,7 @@
 import org.http4s.headers.Location
 import org.http4s.twirl.TwirlInstances._
 import org.slf4j.LoggerFactory
+import de.smederee.hub.MarkdownRenderer
 
 /** Routes for managing tickets.
   *
@@ -101,6 +102,59 @@
       }
     } yield projectAndId
 
+  /** Logic for rendering a detail page for a single ticket.
+    *
+    * @param csrf
+    *   An optional CSRF-Token that shall be used.
+    * @param user
+    *   An optional user account for whom the list of tickets shall be rendered.
+    * @param projectOwnerName
+    *   The username of the account who owns the project.
+    * @param projectName
+    *   The name of the project.
+    * @param ticketNumber
+    *   The number of the ticket that shall be rendered.
+    * @return
+    *   An HTTP response containing the rendered HTML.
+    */
+  private def doShowTicket(csrf: Option[CsrfToken])(user: Option[Account])(projectOwnerName: Username)(
+      projectName: ProjectName
+  )(ticketNumber: TicketNumber): F[Response[F]] =
+    for {
+      _ <- Sync[F].delay(log.debug(s"doShowTicket: $csrf, $user, $projectOwnerName, $projectName, $ticketNumber"))
+      language     <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en")))
+      projectAndId <- loadProject(user)(projectOwnerName, projectName)
+      ticket       <- projectAndId.traverse(tuple => ticketRepo.findTicket(tuple._2)(ticketNumber))
+      resp <- (projectAndId, ticket.getOrElse(None)) match {
+        case (Some((project, projectId)), Some(ticket)) =>
+          for {
+            projectBaseUri <- Sync[F].delay(
+              linkConfig.createFullUri(
+                Uri(path =
+                  Uri.Path(
+                    Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString))
+                  )
+                )
+              )
+            )
+            renderedTicketContent <- Sync[F].delay(ticket.content.map(MarkdownRenderer.renderTicketContent))
+            resp <- Ok(
+              views.html.showTicket(lang = language)(
+                projectBaseUri.addSegment("tickets"),
+                csrf,
+                ticket,
+                renderedTicketContent,
+                projectBaseUri,
+                ticket.title.toString.some,
+                user,
+                project
+              )
+            )
+          } yield resp
+        case _ => NotFound()
+      }
+    } yield resp
+
   /** Logic for rendering a list of all tickets for a project and optionally management functionality.
     *
     * @param filter
@@ -265,6 +319,16 @@
       } yield resp
   }
 
+  private val showTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
+          projectName
+        ) / "tickets" / TicketNumberPathParameter(ticketNumber) as user =>
+      for {
+        csrf <- Sync[F].delay(ar.req.getCsrfToken)
+        resp <- doShowTicket(csrf)(user.some)(projectOwnerName)(projectName)(ticketNumber)
+      } yield resp
+  }
+
   private val showTicketsPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter(
           projectName
@@ -285,7 +349,7 @@
       } yield resp
   }
 
-  val protectedRoutes = addTicket <+> showCreateTicketPage <+> showTicketsPage
+  val protectedRoutes = addTicket <+> showCreateTicketPage <+> showTicketPage <+> showTicketsPage
 
   val routes = showTicketsForGuests
 
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	2025-01-16 12:55:19.025847706 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatDateTime.scala.html	2025-01-16 12:55:19.029847715 +0000
@@ -3,4 +3,4 @@
 @import java.util.Locale
 
 @(timestamp: OffsetDateTime, style: FormatStyle = FormatStyle.MEDIUM)(implicit locale: Locale)
-(@DateTimeFormatter.ofLocalizedDateTime(style).withLocale(locale).format(timestamp))
+@DateTimeFormatter.ofLocalizedDateTime(style).withLocale(locale).format(timestamp)
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketStatus.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketStatus.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketStatus.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketStatus.scala.html	2025-01-16 12:55:19.029847715 +0000
@@ -0,0 +1,13 @@
+@import java.util.Locale
+@import de.smederee.tickets.Ticket
+
+@(ticket: Ticket)(implicit locale: Locale)
+@defining(ticket.status.toString.toLowerCase(Locale.ROOT)) { status =>
+  @if(ticket.resolution.nonEmpty) {
+    @for(resolution <- ticket.resolution) {
+      @Messages(s"ticket.status.${status}") (@Messages(s"ticket.status.${status}.${resolution.toString.toLowerCase(Locale.ROOT)}"))
+    }
+  } else {
+    @Messages(s"ticket.status.${status}")
+  }
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketSubmitter.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketSubmitter.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketSubmitter.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/format/formatTicketSubmitter.scala.html	2025-01-16 12:55:19.029847715 +0000
@@ -0,0 +1,7 @@
+@import java.util.Locale
+@import de.smederee.tickets.Ticket
+
+@(baseUri: Uri)(ticket: Ticket)(implicit locale: Locale)
+@for(submitter <- ticket.submitter) {
+  <a href="@baseUri.addSegment(s"~${submitter.name}")">@submitter.name</a>
+}
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTicket.scala.html	2025-01-16 12:55:19.029847715 +0000
@@ -0,0 +1,49 @@
+@import de.smederee.hub.Account
+@import de.smederee.hub.views.html.main
+@import de.smederee.tickets._
+@import de.smederee.tickets.views.html.format._
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root),
+  lang: LanguageCode = LanguageCode("en")
+)(action: Uri,
+  csrf: Option[CsrfToken] = None,
+  ticket: Ticket,
+  renderedTicketContent: Option[String],
+  projectBaseUri: Uri,
+  title: Option[String] = None,
+  user: Option[Account],
+  project: Project
+)
+@main(baseUri, lang)()(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">@ticket.number created by @formatTicketSubmitter(baseUri)(ticket) at @formatDateTime(ticket.createdAt)</div>
+      </div>
+    </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="l-box">
+        <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="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>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
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-16 12:55:19.025847706 +0000
+++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-16 12:55:19.029847715 +0000
@@ -130,6 +130,12 @@
   }
 }
 
+/** Extractor to retrieve a TicketNumber from a path parameter.
+  */
+object TicketNumberPathParameter {
+  def unapply(str: String): Option[TicketNumber] = Option(str).flatMap(TicketNumber.fromString)
+}
+
 /** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things
   * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket.
   */