~jan0sch/smederee
Showing details for patch 97c550e848eb1f6ccfb9e2643bfe40ee290e3826.
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-15 23:43:43.406836721 +0000 +++ new-smederee/modules/hub/src/main/resources/messages.properties 2025-01-15 23:43:43.410836725 +0000 @@ -330,9 +330,16 @@ 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.empty=No tickets were found. project.tickets.list.title={0} tickets found. +project.tickets.query=Search tickets +project.tickets.query.examples=Example queries: +project.tickets.query.help=Enter a query to search for tickets e.g. "status: Resolved resolution: Duplicate" +project.tickets.query.open=Open tickets +project.tickets.query.open.help=Use a query that will show only open tickets. +project.tickets.query.resolved=Resolved tickets +project.tickets.query.resolved.help=Use a query that will show only resolved tickets. +project.tickets.view.title=Tickets ticket.assigned=Assigned to ticket.reported=Reported by 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-15 23:43:43.410836725 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 2025-01-15 23:43:43.410836725 +0000 @@ -204,6 +204,7 @@ csrf, linkToHubService, tickets, + filter, projectBaseUri, "Manage your project tickets.".some, user, @@ -528,11 +529,11 @@ private val showTicketsPage: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( projectName - ) / "tickets" as user => + ) / "tickets" :? TicketFilter.OptionalUrlParameter(maybeFilter) as user => val response = for { csrf <- Sync[F].delay(ar.req.getCsrfToken) - resp <- doShowTickets(None)(csrf)(user.some)(projectOwnerName)(projectName) + resp <- doShowTickets(maybeFilter)(csrf)(user.some)(projectOwnerName)(projectName) } yield resp response.recoverWith { error => log.error("Internal Server Error", error) @@ -547,10 +548,10 @@ private val showTicketsForGuests: HttpRoutes[F] = HttpRoutes.of { case req @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( projectName - ) / "tickets" => + ) / "tickets" :? TicketFilter.OptionalUrlParameter(maybeFilter) => for { csrf <- Sync[F].delay(req.getCsrfToken) - resp <- doShowTickets(None)(csrf)(None)(projectOwnerName)(projectName) + resp <- doShowTickets(maybeFilter)(csrf)(None)(projectOwnerName)(projectName) } yield resp } 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-15 23:43:43.410836725 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html 2025-01-15 23:43:43.410836725 +0000 @@ -8,6 +8,7 @@ csrf: Option[CsrfToken] = None, linkToHubService: Uri, tickets: List[Ticket], + ticketFilter: Option[TicketFilter], projectBaseUri: Uri, title: Option[String] = None, user: Option[Account], @@ -37,6 +38,25 @@ </div> </div> </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <form action="@{projectBaseUri.addSegment("tickets")}" class="pure-form" method="GET" accept-charset="UTF-8"> + <fieldset> + <div class="pure-control-group"> + <input aria-label="@Messages("project.tickets.query.help")" class="pure-input-2-3" id="q" name="q" maxlength="128" placeholder="@Messages("project.tickets.query.help")" type="text" value="@{ticketFilter.map(_.toQueryParameter)}"> + <button type="submit" class="pure-button">@Messages("project.tickets.query")</button> + </div> + </fieldset> + </form> + <div class="pure-form-message"> + <span>@Messages("project.tickets.query.examples")</span> + <a href="@{projectBaseUri.addSegment("tickets").withQueryParam("q", TicketFilter.OpenTicketsOnly)}" title="@Messages("project.tickets.query.open.help")">@Messages("project.tickets.query.open")</a> + <a href="@{projectBaseUri.addSegment("tickets").withQueryParam("q", TicketFilter.ResolvedTicketsOnly)}" title="@Messages("project.tickets.query.resolved.help")">@Messages("project.tickets.query.resolved")</a> + </div> + </div> + </div> + </div> <div class="pure-g"> <div class="pure-u-1-1 pure-u-md-1-1"> <div class="l-box"> 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-15 23:43:43.410836725 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 2025-01-15 23:43:43.410836725 +0000 @@ -137,7 +137,7 @@ val resolutionFilter = filter.resolution.toNel.map(resolutions => Fragments.in(fr""""tickets".resolution""", resolutions)) val submitterFilter = - filter.submitter.toNel.map(submitters => Fragments.in(fr""""tickets".submitter""", submitters.map(_.id))) + filter.submitter.toNel.map(submitters => Fragments.in(fr""""submitters".name""", submitters)) selectTicketColumns ++ whereAndOpt( projectFilter.some, numberFilter, 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-15 23:43:43.410836725 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-15 23:43:43.410836725 +0000 @@ -21,6 +21,8 @@ import cats._ import cats.syntax.all._ +import org.http4s.{ QueryParamDecoder, QueryParamEncoder } +import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher import scala.util.matching.Regex @@ -166,6 +168,16 @@ object TicketStatus { given Eq[TicketStatus] = Eq.fromUniversalEquals + + /** Try to parse a ticket status instance from the given string without throwin an exception like `valueOf`. + * + * @param source + * A string that should contain the name of a ticket status. + * @return + * An option to the successfully deserialised instance. + */ + def fromString(source: String): Option[TicketStatus] = + TicketStatus.values.map(_.toString).find(_ === source).map(TicketStatus.valueOf) } /** Possible types of "resolved states" of a ticket. @@ -207,6 +219,16 @@ object TicketResolution { given Eq[TicketResolution] = Eq.fromUniversalEquals + + /** Try to parse a ticket resolution instance from the given string without throwin an exception like `valueOf`. + * + * @param source + * A string that should contain the name of a ticket resolution. + * @return + * An option to the successfully deserialised instance. + */ + def fromString(source: String): Option[TicketResolution] = + TicketResolution.values.map(_.toString).find(_ === source).map(TicketResolution.valueOf) } /** A concise and short description of the ticket which should not exceed 80 characters. @@ -276,16 +298,25 @@ * @param resolution * A list of ticket resolution kinds that must be matched. * @param submitter - * A list of submitters from whom the ticket must have been submitted. + * A list of usernames from whom the ticket must have been submitted. */ final case class TicketFilter( number: List[TicketNumber], status: List[TicketStatus], resolution: List[TicketResolution], - submitter: List[Submitter] + submitter: List[SubmitterName] ) object TicketFilter { + given QueryParamDecoder[TicketFilter] = QueryParamDecoder[String].map(TicketFilter.fromQueryParameter) + given QueryParamEncoder[TicketFilter] = QueryParamEncoder[String].contramap(_.toQueryParameter) + + /** Decode an optional possibly existing query parameter into a `TicketFilter`. + * + * Usage: `case GET -> Root / "..." :? OptionalUrlParamter(maybeFilter) => ...` + */ + object OptionalUrlParameter extends OptionalQueryParamDecoderMatcher[TicketFilter]("q") + // Only "open" tickets. val OpenTicketsOnly = TicketFilter( number = Nil, @@ -296,4 +327,98 @@ // Only resolved (closed) tickets. val ResolvedTicketsOnly = TicketFilter(number = Nil, status = List(TicketStatus.Resolved), resolution = Nil, submitter = Nil) + + /** Parse the given query string which must contain a serialised ticket filter instance and return a ticket filter + * with the successfully parsed filters. + * + * @param queryString + * A query string parameter passed via an URL. + * @return + * A ticket filter instance which may be empty. + */ + def fromQueryParameter(queryString: String): TicketFilter = { + val number = + if (queryString.contains("numbers: ")) + queryString + .drop(queryString.indexOf("numbers: ") + 9) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(TicketNumber.fromString) + .flatten + .toList + else + Nil + val status = + if (queryString.contains("status: ")) + queryString + .drop(queryString.indexOf("status: ") + 8) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(TicketStatus.fromString) + .flatten + .toList + else + Nil + val resolution = + if (queryString.contains("resolution: ")) + queryString + .drop(queryString.indexOf("resolution: ") + 12) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(TicketResolution.fromString) + .flatten + .toList + else + Nil + val submitter = + if (queryString.contains("by: ")) + queryString + .drop(queryString.indexOf("by: ") + 4) + .takeWhile(char => !char.isWhitespace) + .split(",") + .map(SubmitterName.from) + .flatten + .toList + else + Nil + TicketFilter(number, status, resolution, submitter) + } + + extension (filter: TicketFilter) { + + /** Convert this ticket filter instance into a query string representation that can be passed as query parameter in + * a URL and parsed back again. + * + * @return + * A string containing a serialised form of the ticket filter that can be used as a URL query parameter. + */ + def toQueryParameter: String = { + val numbers = + if (filter.number.isEmpty) + None + else + filter.number.map(_.toString).mkString(",").some + val status = + if (filter.status.isEmpty) + None + else + filter.status.map(_.toString).mkString(",").some + val resolution = + if (filter.resolution.isEmpty) + None + else + filter.resolution.map(_.toString).mkString(",").some + val submitter = + if (filter.submitter.isEmpty) + None + else + filter.submitter.map(_.toString).mkString(",").some + List( + numbers.map(string => s"numbers: $string"), + status.map(string => s"status: $string"), + resolution.map(string => s"resolution: $string"), + submitter.map(string => s"by: $string") + ).flatten.mkString(" ") + } + } } diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-15 23:43:43.410836725 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-15 23:43:43.410836725 +0000 @@ -406,7 +406,7 @@ val submitters = tickets.map(_.submitter).flatten val wantedSubmitters = submitters.take(submitters.size / 2) val expectedTickets = tickets.filter(t => t.submitter.exists(s => wantedSubmitters.exists(_ === s))) - val filter = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters) + val filter = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters.map(_.name)) val project = generatedProject.copy(owner = owner) val dbConfig = configuration.database val tx = Transactor.fromDriverManager[IO]( diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-15 23:43:43.410836725 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-15 23:43:43.410836725 +0000 @@ -106,11 +106,14 @@ val genTicketContent: Gen[Option[TicketContent]] = Gen.alphaStr.map(TicketContent.from) - val genTicketStatus: Gen[TicketStatus] = Gen.oneOf(TicketStatus.values.toList) + val genTicketStatus: Gen[TicketStatus] = Gen.oneOf(TicketStatus.values.toList) + val genTicketStatusList: Gen[List[TicketStatus]] = Gen.nonEmptyListOf(genTicketStatus).map(_.distinct) - val genTicketResolution: Gen[TicketResolution] = Gen.oneOf(TicketResolution.values.toList) + val genTicketResolution: Gen[TicketResolution] = Gen.oneOf(TicketResolution.values.toList) + val genTicketResolutions: Gen[List[TicketResolution]] = Gen.nonEmptyListOf(genTicketResolution).map(_.distinct) - val genTicketNumber: Gen[TicketNumber] = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) + val genTicketNumber: Gen[TicketNumber] = Gen.choose(0, Int.MaxValue).map(TicketNumber.apply) + val genTicketNumbers: Gen[List[TicketNumber]] = Gen.nonEmptyListOf(genTicketNumber).map(_.distinct) val genTicketTitle: Gen[TicketTitle] = Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(TicketTitle.MaxLength).mkString).map(TicketTitle.apply) @@ -147,6 +150,14 @@ val genTickets: Gen[List[Ticket]] = Gen.nonEmptyListOf(genTicket).map(_.zipWithIndex.map(tuple => tuple._1.copy(number = TicketNumber(tuple._2 + 1)))) + val genTicketFilter: Gen[TicketFilter] = + for { + number <- Gen.listOf(genTicketNumber) + status <- Gen.listOf(genTicketStatus) + resolution <- Gen.listOf(genTicketResolution) + submitter <- Gen.listOf(genSubmitter) + } yield TicketFilter(number, status, resolution, submitter.map(_.name).distinct) + val genProjectOwnerName: Gen[ProjectOwnerName] = for { length <- Gen.choose(2, 30) prefix <- Gen.alphaChar diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketFilterTest.scala 2025-01-15 23:43:43.410836725 +0000 @@ -0,0 +1,122 @@ +/* + * 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 de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class TicketFilterTest extends ScalaCheckSuite { + given Arbitrary[Submitter] = Arbitrary(genSubmitter) + given Arbitrary[TicketFilter] = Arbitrary(genTicketFilter) + given Arbitrary[TicketNumber] = Arbitrary(genTicketNumber) + given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution) + given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus) + + property("fromQueryParameter must produce empty filters for invalid input") { + forAll { (randomInput: String) => + assertEquals( + TicketFilter.fromQueryParameter(randomInput), + TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = Nil) + ) + } + } + + property("fromQueryParameter must work for numbers only") { + forAll { (numbers: List[TicketNumber]) => + val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil) + assertEquals(TicketFilter.fromQueryParameter(s"numbers: ${numbers.map(_.toString).mkString(",")}"), filter) + } + } + + property("fromQueryParameter must work for status only") { + forAll { (status: List[TicketStatus]) => + val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil) + assertEquals(TicketFilter.fromQueryParameter(s"status: ${status.map(_.toString).mkString(",")}"), filter) + } + } + + property("fromQueryParameter must work for resolution only") { + forAll { (resolution: List[TicketResolution]) => + val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil) + assertEquals(TicketFilter.fromQueryParameter(s"resolution: ${resolution.map(_.toString).mkString(",")}"), filter) + } + } + + property("fromQueryParameter must work for submitter only") { + forAll { (submitters: List[Submitter]) => + if (submitters.nonEmpty) { + val filter = TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name)) + assertEquals( + TicketFilter.fromQueryParameter(s"by: ${submitters.map(_.name.toString).mkString(",")}"), + filter + ) + } + } + } + + property("toQueryParameter must include numbers") { + forAll { (numbers: List[TicketNumber]) => + if (numbers.nonEmpty) { + val filter = TicketFilter(number = numbers, status = Nil, resolution = Nil, submitter = Nil) + assert(TicketFilter.toQueryParameter(filter).contains(s"numbers: ${numbers.map(_.toString).mkString(",")}")) + } + } + } + + property("toQueryParameter must include status") { + forAll { (status: List[TicketStatus]) => + if (status.nonEmpty) { + val filter = TicketFilter(number = Nil, status = status, resolution = Nil, submitter = Nil) + assert(TicketFilter.toQueryParameter(filter).contains(s"status: ${status.map(_.toString).mkString(",")}")) + } + } + } + + property("toQueryParameter must include resolution") { + forAll { (resolution: List[TicketResolution]) => + if (resolution.nonEmpty) { + val filter = TicketFilter(number = Nil, status = Nil, resolution = resolution, submitter = Nil) + assert( + TicketFilter.toQueryParameter(filter).contains(s"resolution: ${resolution.map(_.toString).mkString(",")}"), + TicketFilter.toQueryParameter(filter) + ) + } + } + } + + property("toQueryParameter must include submitter") { + forAll { (submitters: List[Submitter]) => + if (submitters.nonEmpty) { + val filter = TicketFilter(number = Nil, status = Nil, resolution = Nil, submitter = submitters.map(_.name)) + assert( + TicketFilter.toQueryParameter(filter).contains(s"by: ${submitters.map(_.name.toString).mkString(",")}"), + TicketFilter.toQueryParameter(filter) + ) + } + } + } + + property("toQueryParameter must be the dual of fromQueryParameter") { + forAll { (filter: TicketFilter) => + assertEquals(TicketFilter.fromQueryParameter(filter.toQueryParameter), filter) + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketResolutionTest.scala 2025-01-15 23:43:43.410836725 +0000 @@ -0,0 +1,40 @@ +/* + * 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 de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class TicketResolutionTest extends ScalaCheckSuite { + given Arbitrary[TicketResolution] = Arbitrary(genTicketResolution) + + property("valueOf must work for all known instances") { + forAll { (status: TicketResolution) => + assertEquals(TicketResolution.valueOf(status.toString), status) + } + } + + property("fromString must work for all known instances") { + forAll { (status: TicketResolution) => + assertEquals(TicketResolution.fromString(status.toString), Option(status)) + } + } +} diff -rN -u old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala --- old-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/tickets/src/test/scala/de/smederee/tickets/TicketStatusTest.scala 2025-01-15 23:43:43.410836725 +0000 @@ -0,0 +1,40 @@ +/* + * 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 de.smederee.tickets.Generators._ + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +final class TicketStatusTest extends ScalaCheckSuite { + given Arbitrary[TicketStatus] = Arbitrary(genTicketStatus) + + property("valueOf must work for all known instances") { + forAll { (status: TicketStatus) => + assertEquals(TicketStatus.valueOf(status.toString), status) + } + } + + property("fromString must work for all known instances") { + forAll { (status: TicketStatus) => + assertEquals(TicketStatus.fromString(status.toString), Option(status)) + } + } +}