~jan0sch/smederee
Showing details for patch 3ed1ceea17734d3ac7ac029cdcbd918f91878bf3.
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt --- old-smederee/build.sbt 2025-01-16 13:03:36.590032984 +0000 +++ new-smederee/build.sbt 2025-01-16 13:03:36.594032983 +0000 @@ -217,6 +217,8 @@ case module => module } ++ Seq("org.scala-lang.modules" %% "scala-xml" % "2.1.0"), TwirlKeys.templateImports ++= Seq( + "cats._", + "cats.data._", "cats.syntax.all._", "de.smederee.html._", "de.smederee.i18n._", 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:03:36.590032984 +0000 +++ new-smederee/modules/hub/src/main/resources/messages.properties 2025-01-16 13:03:36.594032983 +0000 @@ -291,6 +291,8 @@ form.ticket.create.button.submit=Create ticket form.ticket.labels=Labels form.ticket.labels.help=Select labels to apply to this ticket. +form.ticket.milestones=Milestones +form.ticket.milestones.help=Select the milestones that this ticket is part of. form.ticket.resolution=Resolution form.ticket.resolution.help=This can be set to further describe the resolution of a ticket. form.ticket.status=Status 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-16 13:03:36.590032984 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-16 13:03:36.594032983 +0000 @@ -160,8 +160,14 @@ 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) + ticketRoutes = new TicketRoutes[IO]( + ticketsConfiguration, + ticketLabelsRepo, + ticketMilestonesRepo, + 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/LabelForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala 2025-01-16 13:03:36.590032984 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala 2025-01-16 13:03:36.594032983 +0000 @@ -57,35 +57,41 @@ def fromLabel(label: Label): LabelForm = LabelForm(id = label.id, name = label.name, description = label.description, colour = label.colour) - override def validate(data: Map[String, String]): ValidatedNec[FormErrors, LabelForm] = { + override def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, LabelForm] = { val id = data .get(fieldId) - .fold(Option.empty[LabelId].validNec)(s => - LabelId.fromString(s).fold(FormFieldError("Invalid label id!").invalidNec)(id => Option(id).validNec) + .fold(Option.empty[LabelId].validNec)( + _.headOption + .flatMap(LabelId.fromString) + .fold(FormFieldError("Invalid label id!").invalidNec)(id => Option(id).validNec) ) .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList))) val name = data .get(fieldName) - .map(_.trim) // We strip leading and trailing whitespace! - .fold(FormFieldError("No label name given!").invalidNec)(s => - LabelName.from(s).fold(FormFieldError("Invalid label name!").invalidNec)(_.validNec) + .fold(FormFieldError("No label name given!").invalidNec)( + _.headOption + .map(_.trim) // We strip leading and trailing whitespace! + .flatMap(LabelName.from) + .fold(FormFieldError("Invalid label name!").invalidNec)(_.validNec) ) .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList))) val description = data .get(fieldDescription) - .fold(Option.empty[LabelDescription].validNec) { s => - if (s.trim.isEmpty) - Option.empty[LabelDescription].validNec // Sometimes "empty" strings are sent. - else - LabelDescription - .from(s) - .fold(FormFieldError("Invalid label description!").invalidNec)(descr => Option(descr).validNec) - } + .fold(Option.empty[LabelDescription].validNec)( + _.headOption + .map(_.trim) + .filter(_.nonEmpty) + .fold(none[LabelDescription].validNec)(s => + LabelDescription + .from(s) + .fold(FormFieldError("Invalid label description!").invalidNec)(descr => Option(descr).validNec) + ) + ) .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList))) val colour = data .get(fieldColour) - .fold(FormFieldError("No label colour given!").invalidNec)(s => - ColourCode.from(s).fold(FormFieldError("Invalid label colour!").invalidNec)(_.validNec) + .fold(FormFieldError("No label colour given!").invalidNec)( + _.headOption.flatMap(ColourCode.from).fold(FormFieldError("Invalid label colour!").invalidNec)(_.validNec) ) .leftMap(es => NonEmptyChain.of(Map(fieldColour -> es.toList))) (id, name, description, colour).mapN { case (id, name, description, colour) => @@ -101,12 +107,12 @@ * @return * A stringified map containing the data of the form. */ - def toMap: Map[String, String] = + def toMap: Map[String, Chain[String]] = Map( - LabelForm.fieldId.toString -> form.id.map(_.toString).getOrElse(""), - LabelForm.fieldName.toString -> form.name.toString, - LabelForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""), - LabelForm.fieldColour.toString -> form.colour.toString + LabelForm.fieldId.toString -> form.id.map(_.toString).fold(Chain.empty)(id => Chain(id)), + LabelForm.fieldName.toString -> Chain(form.name.toString), + LabelForm.fieldDescription.toString -> form.description.map(_.toString).fold(Chain.empty)(d => Chain(d)), + LabelForm.fieldColour.toString -> Chain(form.colour.toString) ) } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 2025-01-16 13:03:36.590032984 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala 2025-01-16 13:03:36.594032983 +0000 @@ -150,82 +150,83 @@ projectName ) / "labels" 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 labels!") - ) - 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(LabelForm.validate(formData)) - labels <- projectAndId.traverse(tuple => labelRepo.allLabels(tuple._2).compile.toList) - projectBaseUri <- Sync[F].delay( - linkConfig.createFullUri( - Uri(path = - Uri.Path( - Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + 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) + resp <- projectAndId match { + case Some(project, projectId) => + for { + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + new Error("Only maintainers may add labels!") + ) + formData <- Sync[F].delay(urlForm.values) + form <- Sync[F].delay(LabelForm.validate(formData)) + labels <- projectAndId.traverse(tuple => labelRepo.allLabels(tuple._2).compile.toList) + 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.editLabels(lang = language)( - projectBaseUri.addSegment("labels"), - csrf, - labels.getOrElse(List.empty), - projectBaseUri, - "Manage your project labels.".some, - user.some, - project - )(formData, FormErrors.fromNec(errors)) - ) - case Validated.Valid(labelData) => - val label = Label(None, labelData.name, labelData.description, labelData.colour) - for { - checkDuplicate <- labelRepo.findLabel(projectId)(labelData.name) - resp <- checkDuplicate match { - case None => - labelRepo.createLabel(projectId)(label) *> SeeOther( - Location(projectBaseUri.addSegment("labels")) - ) - case Some(_) => - BadRequest( - views.html.editLabels(lang = language)( - projectBaseUri.addSegment("labels"), - csrf, - labels.getOrElse(List.empty), - projectBaseUri, - "Manage your project labels.".some, - user.some, - project - )( - formData, - Map( - LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!")) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.editLabels(lang = language)( + projectBaseUri.addSegment("labels"), + csrf, + labels.getOrElse(List.empty), + projectBaseUri, + "Manage your project labels.".some, + user.some, + project + )(formData.withDefaultValue(Chain.empty), FormErrors.fromNec(errors)) + ) + case Validated.Valid(labelData) => + val label = Label(None, labelData.name, labelData.description, labelData.colour) + for { + checkDuplicate <- labelRepo.findLabel(projectId)(labelData.name) + resp <- checkDuplicate match { + case None => + labelRepo.createLabel(projectId)(label) *> SeeOther( + Location(projectBaseUri.addSegment("labels")) + ) + case Some(_) => + BadRequest( + views.html.editLabels(lang = language)( + projectBaseUri.addSegment("labels"), + csrf, + labels.getOrElse(List.empty), + projectBaseUri, + "Manage your project labels.".some, + user.some, + project + )( + formData.withDefaultValue(Chain.empty), + Map( + LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!")) + ) ) ) - ) - } - } yield resp - } - } yield resp - case _ => NotFound() - } - } yield resp + } + } 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 + } } } @@ -247,15 +248,7 @@ resp <- label match { case Some(label) => for { - 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)! - } - } + formData <- Sync[F].delay(urlForm.values) projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = @@ -268,15 +261,24 @@ ) ) ) - userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes")) + userIsSure <- Sync[F].delay( + formData.get("i-am-sure").map(_.headOption.exists(_ === "yes")).getOrElse(false) + ) labelIdMatches <- Sync[F].delay( formData .get(LabelForm.fieldId) - .flatMap(LabelId.fromString) - .exists(id => label.id.exists(_ === id)) + .map( + _.headOption + .flatMap(LabelId.fromString) + .exists(id => label.id.exists(_ === id)) + ) + .getOrElse(false) ) labelNameMatches <- Sync[F].delay( - formData.get(LabelForm.fieldName).flatMap(LabelName.from).exists(_ === labelName) + formData + .get(LabelForm.fieldName) + .map(_.headOption.flatMap(LabelName.from).exists(_ === labelName)) + .getOrElse(false) ) resp <- (labelIdMatches && labelNameMatches && userIsSure) match { case false => BadRequest("Invalid form data!") @@ -300,102 +302,107 @@ projectName ) / "label" / LabelNamePathParameter(labelName) 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) - label <- projectAndId match { - case Some((_, projectId)) => labelRepo.findLabel(projectId)(labelName) - case _ => Sync[F].delay(None) - } - resp <- (projectAndId, label) match { - case (Some(project, projectId), Some(label)) => - for { - _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( - new Error("Only maintainers may add labels!") - ) - projectBaseUri <- Sync[F].delay( - linkConfig.createFullUri( - Uri(path = - Uri.Path( - Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + 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) + label <- projectAndId match { + case Some((_, projectId)) => labelRepo.findLabel(projectId)(labelName) + case _ => Sync[F].delay(None) + } + resp <- (projectAndId, label) match { + case (Some(project, projectId), Some(label)) => + for { + _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( + new Error("Only maintainers may add labels!") + ) + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) ) ) ) - ) - actionUri <- Sync[F].delay(projectBaseUri.addSegment("label").addSegment(label.name.toString)) - 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)! - } - } - labelIdMatches <- Sync[F].delay( - formData - .get(LabelForm.fieldId) - .flatMap(LabelId.fromString) - .exists(id => label.id.exists(_ === id)) match { - case false => - NonEmptyChain - .of(Map(LabelForm.fieldGlobal -> List(FormFieldError("Label ID does not match!")))) - .invalidNec - case true => label.id.validNec - } - ) - form <- Sync[F].delay(LabelForm.validate(formData)) - resp <- form match { - case Validated.Invalid(errors) => - BadRequest( - views.html.editLabel(lang = language)( - actionUri, - csrf, - label, - projectBaseUri, - s"Edit label ${label.name}".some, - user, - project - )( - formData.toMap, - FormErrors.fromNec(errors) + actionUri <- Sync[F].delay(projectBaseUri.addSegment("label").addSegment(label.name.toString)) + formData <- Sync[F].delay(urlForm.values) +// labelIdMatches <- Sync[F].delay( +// formData +// .get(LabelForm.fieldId) +// .flatMap(LabelId.fromString) +// .exists(id => label.id.exists(_ === id)) match { +// case false => +// NonEmptyChain +// .of(Map(LabelForm.fieldGlobal -> List(FormFieldError("Label ID does not match!")))) +// .invalidNec +// case true => label.id.validNec +// } +// ) + form <- Sync[F].delay(LabelForm.validate(formData)) + resp <- form match { + case Validated.Invalid(errors) => + BadRequest( + views.html.editLabel(lang = language)( + actionUri, + csrf, + label, + projectBaseUri, + s"Edit label ${label.name}".some, + user, + project + )( + formData.toMap.withDefaultValue(Chain.empty), + FormErrors.fromNec(errors) + ) ) - ) - case Validated.Valid(labelData) => - val updatedLabel = - label.copy(name = labelData.name, description = labelData.description, colour = labelData.colour) - for { - checkDuplicate <- labelRepo.findLabel(projectId)(updatedLabel.name) - resp <- checkDuplicate.filterNot(_.id === updatedLabel.id) match { - case None => - labelRepo.updateLabel(updatedLabel) *> SeeOther( - Location(projectBaseUri.addSegment("labels")) - ) - case Some(_) => - BadRequest( - views.html.editLabel(lang = language)( - actionUri, - csrf, - label, - projectBaseUri, - s"Edit label ${label.name}".some, - user, - project - )( - formData, - Map( - LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!")) + case Validated.Valid(labelData) => + val updatedLabel = + label.copy( + name = labelData.name, + description = labelData.description, + colour = labelData.colour + ) + for { + checkDuplicate <- labelRepo.findLabel(projectId)(updatedLabel.name) + resp <- checkDuplicate.filterNot(_.id === updatedLabel.id) match { + case None => + labelRepo.updateLabel(updatedLabel) *> SeeOther( + Location(projectBaseUri.addSegment("labels")) + ) + case Some(_) => + BadRequest( + views.html.editLabel(lang = language)( + actionUri, + csrf, + label, + projectBaseUri, + s"Edit label ${label.name}".some, + user, + project + )( + formData.toMap.withDefaultValue(Chain.empty), + Map( + LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!")) + ) ) ) - ) - } - } yield resp - } - } yield resp - case _ => NotFound() - } - } yield resp + } + } 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 + } } } @@ -440,10 +447,19 @@ case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( projectName ) / "labels" as user => - for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - resp <- doShowLabels(csrf)(user.some)(projectOwnerName)(projectName) - } yield resp + val response = + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + resp <- doShowLabels(csrf)(user.some)(projectOwnerName)(projectName) + } 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 showLabelsForGuests: HttpRoutes[F] = HttpRoutes.of { diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala 2025-01-16 13:03:36.590032984 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala 2025-01-16 13:03:36.594032983 +0000 @@ -65,41 +65,45 @@ dueDate = milestone.dueDate ) - override def validate(data: Map[String, String]): ValidatedNec[FormErrors, MilestoneForm] = { + override def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, MilestoneForm] = { val id = data .get(fieldId) - .fold(Option.empty[MilestoneId].validNec)(s => - MilestoneId.fromString(s).fold(FormFieldError("Invalid milestone id!").invalidNec)(id => Option(id).validNec) + .fold(Option.empty[MilestoneId].validNec)( + _.headOption + .flatMap(MilestoneId.fromString) + .fold(FormFieldError("Invalid milestone id!").invalidNec)(id => Option(id).validNec) ) .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList))) val title = data .get(fieldTitle) - .map(_.trim) // We strip leading and trailing whitespace! - .fold(FormFieldError("No milestone title given!").invalidNec)(s => - MilestoneTitle.from(s).fold(FormFieldError("Invalid milestone title!").invalidNec)(_.validNec) + .fold(FormFieldError("No milestone title given!").invalidNec)( + _.headOption + .map(_.trim) // We strip leading and trailing whitespace! + .flatMap(MilestoneTitle.from) + .fold(FormFieldError("Invalid milestone title!").invalidNec)(_.validNec) ) .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList))) val description = data .get(fieldDescription) - .fold(Option.empty[MilestoneDescription].validNec) { s => - if (s.trim.isEmpty) - Option.empty[MilestoneDescription].validNec // Sometimes "empty" strings are sent. - else - MilestoneDescription - .from(s) - .fold(FormFieldError("Invalid milestone description!").invalidNec)(descr => Option(descr).validNec) - } + .fold(Option.empty[MilestoneDescription].validNec)( + _.headOption + .map(_.trim) + .filter(_.nonEmpty) + .fold(none[MilestoneDescription].validNec)(s => + MilestoneDescription + .from(s) + .fold(FormFieldError("Invalid milestone description!").invalidNec)(descr => Option(descr).validNec) + ) + ) .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList))) val dueDate = data .get(fieldDueDate) - .fold(Option.empty[LocalDate].validNec) { s => - if (s.trim.isEmpty) - Option.empty[LocalDate].validNec - else - Validated - .catchNonFatal(LocalDate.parse(s)) - .map(date => Option(date)) - } + .fold(Option.empty[LocalDate].validNec)( + _.headOption + .map(_.trim) + .filter(_.nonEmpty) + .fold(none[LocalDate].validNec)(s => Validated.catchNonFatal(LocalDate.parse(s)).map(date => date.some)) + ) .leftMap(_ => NonEmptyChain.of(Map(fieldDueDate -> List(FormFieldError("Invalid milestone due date!"))))) (id, title, description, dueDate).mapN { case (id, title, description, dueDate) => MilestoneForm(id, title, description, dueDate) @@ -114,12 +118,12 @@ * @return * A stringified map containing the data of the form. */ - def toMap: Map[String, String] = + def toMap: Map[String, Chain[String]] = Map( - MilestoneForm.fieldId.toString -> form.id.map(_.toString).getOrElse(""), - MilestoneForm.fieldTitle.toString -> form.title.toString, - MilestoneForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""), - MilestoneForm.fieldDueDate.toString -> form.dueDate.map(_.toString).getOrElse("") + MilestoneForm.fieldId.toString -> form.id.map(_.toString).fold(Chain.empty)(id => Chain(id)), + MilestoneForm.fieldTitle.toString -> Chain(form.title.toString), + MilestoneForm.fieldDescription.toString -> form.description.map(_.toString).fold(Chain.empty)(d => Chain(d)), + MilestoneForm.fieldDueDate.toString -> form.dueDate.map(_.toString).fold(Chain.empty)(date => Chain(date)) ) } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-16 13:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-16 13:03:36.594032983 +0000 @@ -157,15 +157,7 @@ _ <- Sync[F].raiseUnless(project.owner.uid === ProjectOwnerId.fromUserId(user.uid))( new Error("Only maintainers may add milestones!") ) - 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)! - } - } + formData <- Sync[F].delay(urlForm.values) form <- Sync[F].delay(MilestoneForm.validate(formData)) milestones <- projectAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList) projectBaseUri <- Sync[F].delay( @@ -326,26 +318,17 @@ actionUri <- Sync[F].delay( projectBaseUri.addSegment("milestone").addSegment(milestone.title.toString) ) - 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)! - } - } + formData <- Sync[F].delay(urlForm.values) milestoneIdMatches <- Sync[F].delay( formData .get(MilestoneForm.fieldId) - .flatMap(MilestoneId.fromString) - .exists(id => milestone.id.exists(_ === id)) match { - case false => + .flatMap(_.headOption.flatMap(MilestoneId.fromString)) + .find(id => milestone.id.exists(_ === id)) + .fold( NonEmptyChain .of(Map(MilestoneForm.fieldGlobal -> List(FormFieldError("Milestone ID does not match!")))) .invalidNec - case true => milestone.id.validNec - } + )(_ => milestone.id.validNec) ) form <- Sync[F].delay(MilestoneForm.validate(formData)) resp <- form match { @@ -360,7 +343,7 @@ user, project )( - formData.toMap, + formData, FormErrors.fromNec(errors) ) ) 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:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketForm.scala 2025-01-16 13:03:36.594032983 +0000 @@ -40,6 +40,10 @@ * @param submitter * 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. + * @param milestones + * A list of milestone ids which are associated with the ticket. */ final case class TicketForm( number: Option[TicketNumber], @@ -47,79 +51,109 @@ content: Option[TicketContent], status: TicketStatus, resolution: Option[TicketResolution], - submitter: Option[SubmitterId] + submitter: Option[SubmitterId], + labels: List[LabelId], + milestones: List[MilestoneId] ) 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 fieldLabels: FormField = FormField("labels") + val fieldMilestones: FormField = FormField("milestones") + val fieldNumber: FormField = FormField("number") val fieldResolution: FormField = FormField("resolution") + val fieldStatus: FormField = FormField("status") val fieldSubmitter: FormField = FormField("submitter") + val fieldTitle: FormField = FormField("title") /** 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. + * @param milestones + * A list of milestone ids 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(ticket: Ticket): TicketForm = + def fromTicket(labels: List[LabelId])(milestones: List[MilestoneId])(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) + submitter = ticket.submitter.map(_.id), + labels = labels, + milestones = milestones ) - override def validate(data: Map[String, String]): ValidatedNec[FormErrors, TicketForm] = { + override def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, TicketForm] = { val number = data .get(fieldNumber) - .fold(Option.empty[TicketNumber].validNec)(s => - TicketNumber - .fromString(s) + .fold(Option.empty[TicketNumber].validNec)( + _.headOption + .flatMap(TicketNumber.fromString) .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) + .fold(FormFieldError("No ticket title given!").invalidNec)( + _.headOption + .map(_.trim) + .flatMap(TicketTitle.from) + .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) + .fold(none[TicketContent].validNec)( + _.headOption + .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) + .fold(FormFieldError("No ticket status given!").invalidNec)( + _.headOption + .flatMap(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(none[TicketResolution].validNec)( + _.headOption + .flatMap(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) + .fold(none[SubmitterId].validNec)( + _.headOption + .flatMap(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) + val labelIds = + data + .get(fieldLabels) + .fold(List.empty[LabelId].validNec[FormErrors])(_.toList.flatMap(LabelId.fromString).validNec[FormErrors]) + val milestoneIds = + data + .get(fieldMilestones) + .fold(List.empty[MilestoneId].validNec[FormErrors])( + _.toList.flatMap(MilestoneId.fromString).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) } } @@ -131,14 +165,14 @@ * @return * A stringified map containing the data of the form. */ - def toMap: Map[String, String] = + def toMap: Map[String, Chain[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("") + TicketForm.fieldNumber.toString -> form.number.map(_.toString).fold(Chain.empty)(nr => Chain(nr)), + TicketForm.fieldTitle.toString -> Chain(form.title.toString), + 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)) ) } } 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:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 2025-01-16 13:03:36.594032983 +0000 @@ -44,6 +44,8 @@ * The ticket service configuration. * @param labelRepo * A repository for handling database operations for labels. + * @param milestoneRepo + * A repository for handling database operations for milestones. * @param projectRepo * A repository for handling database operations regarding our projects and their metadata. * @param ticketRepo @@ -54,6 +56,7 @@ final class TicketRoutes[F[_]: Async]( configuration: SmedereeTicketsConfiguration, labelRepo: LabelRepository[F], + milestoneRepo: MilestoneRepository[F], projectRepo: ProjectRepository[F], ticketRepo: TicketRepository[F] ) extends Http4sDsl[F] { @@ -211,6 +214,84 @@ projectName ) / "tickets" 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) + 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 + 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)) + number <- projectRepo.incrementNextTicketNumber(projectId) + ticket <- Sync[F].delay( + Ticket( + number = number, + title = ticketData.title, + content = ticketData.content, + status = ticketData.status, + resolution = ticketData.resolution, + submitter = Submitter(SubmitterId(user.uid.toUUID), SubmitterName(user.name.toString)).some, + createdAt = timestamp, + updatedAt = timestamp + ) + ) + _ <- ticketRepo.createTicket(projectId)(ticket) + 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 showCreateTicketPage: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "newTicket" as user => + val response = for { csrf <- Sync[F].delay(ar.req.getCsrfToken) language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en"))) @@ -218,20 +299,8 @@ 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)) + labels <- labelRepo.allLabels(projectId).compile.toList + milestones <- milestoneRepo.allMilestones(projectId).compile.toList projectBaseUri <- Sync[F].delay( linkConfig.createFullUri( Uri(path = @@ -241,92 +310,49 @@ ) ) ) - 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 = Submitter(SubmitterId(user.uid.toUUID), SubmitterName(user.name.toString)).some, - createdAt = timestamp, - updatedAt = timestamp - ) - ) - _ <- ticketRepo.createTicket(projectId)(ticket) - resp <- SeeOther(Location(projectBaseUri.addSegment("tickets"))) - } yield resp - } + resp <- Ok( + views.html.createTicket(lang = language)( + projectBaseUri.addSegment("tickets"), + csrf, + labels, + milestones, + projectBaseUri, + "Create a new ticket.".some, + user.some, + project + )() + ) } 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 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 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 + val response = + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + resp <- doShowTicket(csrf)(user.some)(projectOwnerName)(projectName)(ticketNumber) + } 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 showTicketPageForGuests: HttpRoutes[F] = HttpRoutes.of { @@ -343,10 +369,19 @@ case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( projectName ) / "tickets" as user => - for { - csrf <- Sync[F].delay(ar.req.getCsrfToken) - resp <- doShowTickets(None)(csrf)(user.some)(projectOwnerName)(projectName) - } yield resp + val response = + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + resp <- doShowTickets(None)(csrf)(user.some)(projectOwnerName)(projectName) + } 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 showTicketsForGuests: HttpRoutes[F] = HttpRoutes.of { 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:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/createTicket.scala.html 2025-01-16 13:03:36.594032983 +0000 @@ -11,11 +11,12 @@ )(action: Uri, csrf: Option[CsrfToken] = None, labels: List[Label] = Nil, + milestones: List[Milestone] = Nil, projectBaseUri: Uri, title: Option[String] = None, user: Option[Account], project: Project -)(formData: Map[String, String] = Map.empty, +)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty), formErrors: FormErrors = FormErrors.empty ) @footer = { @@ -71,31 +72,44 @@ <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)}"> + <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="8" value="@{formData.get(fieldContent)}"></textarea> + <textarea class="pure-input-1" id="@{fieldContent}" name="@{fieldContent}" placeholder="@Messages("form.ticket.content.placeholder")" rows="8" value="@{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="ticket_labels">@Messages("form.ticket.labels")</label> - <select class="pure-input-1" id="ticket_labels" name="ticket_labels" multiple> + <label for="@fieldLabels">@Messages("form.ticket.labels")</label> + <select class="pure-input-1" id="@fieldLabels" name="@fieldLabels" multiple> @for(label <- labels) { - <option value="@label.id">@label.name</option> + @for(labelId <- label.id) { + <option value="@labelId" @if(formData(fieldLabels).exists(_ === labelId.toString)){selected}else{}>@label.name</option> + } } </select> - <span class="pure-form-message" id="ticket_labels.help">@Messages("form.ticket.labels.help")</span> + <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) { + @for(milestoneId <- milestone.id) { + <option value="@milestone.id" @if(formData(fieldMilestones).exists(_ === milestoneId.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.get(fieldStatus).exists(_ === status.toString)){selected="selected"}else{}>@status</option> + <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> @@ -103,10 +117,10 @@ </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"}> + <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.get(fieldResolution).exists(_ === resolution.toString)){selected="selected"}else{}>@resolution</option> + <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> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html 2025-01-16 13:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabel.scala.html 2025-01-16 13:03:36.594032983 +0000 @@ -16,7 +16,7 @@ title: Option[String] = None, user: Account, project: Project -)(formData: Map[String, String] = Map.empty, +)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty), formErrors: FormErrors = FormErrors.empty ) @main(baseUri, lang)()(csrf, title, user.some) { @@ -55,19 +55,19 @@ <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@label.id"> <div class="pure-control-group"> <label for="@{fieldName}">@Messages("form.label.name")</label> - <input id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}"> + <input id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData(fieldName).headOption}"> <span class="pure-form-message" id="@{fieldName}.help" style="margin-left: 13em;">@Messages("form.label.name.help")</span> @renderFormErrors(fieldName, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldDescription}">@Messages("form.label.description")</label> - <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}"> + <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData(fieldDescription).headOption}"> <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.label.description.help")</span> @renderFormErrors(fieldDescription, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldColour}">@Messages("form.label.colour")</label> - <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}"> + <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData(fieldColour).headOption.getOrElse("#B48EAD")}"> <span class="pure-form-message" id="@{fieldColour}.help" style="margin-left: 13em;">@Messages("form.label.colour.help")</span> @renderFormErrors(fieldColour, formErrors) </div> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html 2025-01-16 13:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editLabels.scala.html 2025-01-16 13:03:36.598032982 +0000 @@ -16,7 +16,7 @@ title: Option[String] = None, user: Option[Account], project: Project -)(formData: Map[String, String] = Map.empty, +)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty), formErrors: FormErrors = FormErrors.empty ) @main(baseUri, lang)()(csrf, title, user) { @@ -58,19 +58,19 @@ <fieldset> <div class="pure-control-group"> <label for="@{fieldName}">@Messages("form.label.name")</label> - <input class="pure-input-3-4" id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}"> + <input class="pure-input-3-4" id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData(fieldName).headOption}"> <span class="pure-form-message" id="@{fieldName}.help">@Messages("form.label.name.help")</span> @renderFormErrors(fieldName, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldDescription}">@Messages("form.label.description")</label> - <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}"> + <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData(fieldDescription).headOption}"> <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.label.description.help")</span> @renderFormErrors(fieldDescription, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldColour}">@Messages("form.label.colour")</label> - <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}"> + <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData(fieldColour).headOption.getOrElse("#B48EAD")}"> <span class="pure-form-message" id="@{fieldColour}.help">@Messages("form.label.colour.help")</span> @renderFormErrors(fieldColour, formErrors) </div> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html 2025-01-16 13:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestone.scala.html 2025-01-16 13:03:36.598032982 +0000 @@ -16,7 +16,7 @@ title: Option[String] = None, user: Account, project: Project -)(formData: Map[String, String] = Map.empty, +)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty), formErrors: FormErrors = FormErrors.empty ) @main(baseUri, lang)()(csrf, title, user.some) { @@ -55,19 +55,19 @@ <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@milestone.id"> <div class="pure-control-group"> <milestone for="@{fieldTitle}">@Messages("form.milestone.title")</milestone> - <input id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}"> + <input id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData(fieldTitle).headOption}"> <span class="pure-form-message" id="@{fieldTitle}.help" style="margin-left: 13em;">@Messages("form.milestone.title.help")</span> @renderFormErrors(fieldTitle, formErrors) </div> <div class="pure-control-group"> <milestone for="@{fieldDescription}">@Messages("form.milestone.description")</milestone> - <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.milestone.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}"> + <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.milestone.description.placeholder")" type="text" value="@{formData(fieldDescription).headOption}"> <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.milestone.description.help")</span> @renderFormErrors(fieldDescription, formErrors) </div> <div class="pure-control-group"> <milestone for="@{fieldDueDate}">@Messages("form.milestone.due-date")</milestone> - <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}"> + <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData(fieldDueDate).headOption}"> <span class="pure-form-message" id="@{fieldDueDate}.help" style="margin-left: 13em;">@Messages("form.milestone.due-date.help")</span> @renderFormErrors(fieldDueDate, formErrors) </div> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html 2025-01-16 13:03:36.594032983 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/editMilestones.scala.html 2025-01-16 13:03:36.598032982 +0000 @@ -18,7 +18,7 @@ title: Option[String] = None, user: Option[Account], project: Project -)(formData: Map[String, String] = Map.empty, +)(formData: Map[String, Chain[String]] = Map.empty.withDefaultValue(Chain.empty), formErrors: FormErrors = FormErrors.empty ) @main(baseUri, lang)()(csrf, title, user) { @@ -60,19 +60,19 @@ <fieldset> <div class="pure-control-group"> <label for="@{fieldTitle}">@Messages("form.milestone.title")</label> - <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}"> + <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle).headOption}"> <span class="pure-form-message" id="@{fieldTitle}.help">@Messages("form.milestone.title.help")</span> @renderFormErrors(fieldTitle, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldDescription}">@Messages("form.milestone.description")</label> - <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription)}"></textarea> + <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription).headOption}"></textarea> <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.milestone.description.help")</span> @renderFormErrors(fieldDescription, formErrors) </div> <div class="pure-control-group"> <label for="@{fieldDueDate}">@Messages("form.milestone.due-date")</label> - <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}"> + <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate).headOption}"> <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span> @renderFormErrors(fieldDueDate, formErrors) </div> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/internalServerError.scala.html 2025-01-16 13:03:36.598032982 +0000 @@ -0,0 +1,21 @@ +@import de.smederee.hub._ +@import de.smederee.hub.views.html._ + +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, user: Option[Account]) +@defining(lang.toLocale) { implicit locale => +@main(baseUri, lang)()(csrf, Messages("errors.internal-server-error.title").some, user) { + <div class="content"> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">@Messages("errors.internal-server-error.title"):</span> + @Messages("errors.internal-server-error.message") + </p> + </div> + </div> + </div> + </div> +} +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/errors/unvalidatedAccount.scala.html 2025-01-16 13:03:36.598032982 +0000 @@ -0,0 +1,21 @@ +@import de.smederee.hub._ +@import de.smederee.hub.views.html._ + +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account) +@main(baseUri, lang)()(csrf, title, user.some) { +@defining(lang.toLocale) { implicit locale => + <div class="content"> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">@Messages("global.error"):</span> + @Messages("errors.account.not-validated") + </p> + </div> + </div> + </div> + </div> +} +} diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 2025-01-16 13:03:36.594032983 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/forms/FormValidator.scala 2025-01-16 13:03:36.598032982 +0000 @@ -44,7 +44,7 @@ * @return * Either the validated form as concrete type T or a list of form errors. */ - def validate(data: Map[String, String]): ValidatedNec[FormErrors, T] + def validate(data: Map[String, Chain[String]]): ValidatedNec[FormErrors, T] }