~jan0sch/smederee
Showing details for patch 333ff13b69c781391ed2be7791ea82c96fdd9368.
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)