Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions app/controllers/YourDirectDebitInstructionsController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import config.FrontendAppConfig
import controllers.actions.*
import models.UserAnswers
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc.{Action, AnyContent, MessagesControllerComponents}
import play.api.mvc.{Action, AnyContent, Call, MessagesControllerComponents}
import queries.{DirectDebitReferenceQuery, PaymentPlanReferenceQuery}
import repositories.SessionRepository
import services.NationalDirectDebitService
import services.{NationalDirectDebitService, PaginationService}
import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendBaseController
import views.html.YourDirectDebitInstructionsView

Expand All @@ -38,17 +38,33 @@ class YourDirectDebitInstructionsController @Inject() (
view: YourDirectDebitInstructionsView,
appConfig: FrontendAppConfig,
nddService: NationalDirectDebitService,
paginationService: PaginationService,
sessionRepository: SessionRepository
)(implicit ec: ExecutionContext)
extends FrontendBaseController
with I18nSupport {

def onPageLoad: Action[AnyContent] = (identify andThen getData).async { implicit request =>
val userAnswers = request.userAnswers.getOrElse(UserAnswers(request.userId))
val currentPage = request.getQueryString("page").flatMap(_.toIntOption).getOrElse(1)

cleanseDirectDebitReference(userAnswers).flatMap { _ =>
nddService.retrieveAllDirectDebits(request.userId) map { directDebitDetailsData =>
val maxLimitReached = directDebitDetailsData.directDebitCount > appConfig.maxNumberDDIsAllowed
Ok(view(directDebitDetailsData.directDebitList.map(_.toDirectDebitDetails), maxLimitReached))

val paginationResult = paginationService.paginateDirectDebits(
allDirectDebits = directDebitDetailsData.directDebitList,
currentPage = currentPage,
baseUrl = routes.YourDirectDebitInstructionsController.onPageLoad().url
)

Ok(
view(
directDebitDetails = paginationResult.paginatedData,
maxLimitReached = maxLimitReached,
paginationViewModel = paginationResult.paginationViewModel
)
)
}
}
}
Expand Down
164 changes: 164 additions & 0 deletions app/services/PaginationService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2025 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package services

import models.{DirectDebitDetails, NddDetails}
import viewmodels.govuk.PaginationFluency.*

import java.time.LocalDateTime
import javax.inject.{Inject, Singleton}

case class PaginationConfig(
recordsPerPage: Int = 3,
maxRecords: Int = 99,
maxVisiblePages: Int = 5
)

case class PaginationResult(
paginatedData: Seq[DirectDebitDetails],
paginationViewModel: PaginationViewModel,
totalRecords: Int,
currentPage: Int,
totalPages: Int
)

@Singleton
class PaginationService @Inject() {

private val config = PaginationConfig()

def paginateDirectDebits(
allDirectDebits: Seq[NddDetails],
currentPage: Int = 1,
baseUrl: String
): PaginationResult = {

val sortedDirectDebits = allDirectDebits
.sortBy(_.submissionDateTime)(Ordering[LocalDateTime].reverse)
.take(config.maxRecords)

val totalRecords = sortedDirectDebits.length
val totalPages = calculateTotalPages(totalRecords)
val validCurrentPage = validateCurrentPage(currentPage, totalPages)

val (startIndex, endIndex) = calculatePageIndices(validCurrentPage, totalRecords)

val paginatedData = sortedDirectDebits
.slice(startIndex, endIndex)
.map(_.toDirectDebitDetails)

val paginationViewModel = createPaginationViewModel(
currentPage = validCurrentPage,
totalPages = totalPages,
baseUrl = baseUrl
)

PaginationResult(
paginatedData = paginatedData,
paginationViewModel = paginationViewModel,
totalRecords = totalRecords,
currentPage = validCurrentPage,
totalPages = totalPages
)
}

private def calculateTotalPages(totalRecords: Int): Int =
Math.ceil(totalRecords.toDouble / config.recordsPerPage).toInt

private def validateCurrentPage(currentPage: Int, totalPages: Int): Int =
Math.max(1, Math.min(currentPage, totalPages))

private def calculatePageIndices(currentPage: Int, totalRecords: Int): (Int, Int) = {
val startIndex = (currentPage - 1) * config.recordsPerPage
val endIndex = Math.min(startIndex + config.recordsPerPage, totalRecords)
(startIndex, endIndex)
}

private def createPaginationViewModel(
currentPage: Int,
totalPages: Int,
baseUrl: String
): PaginationViewModel = {
if (totalPages <= 1) {
PaginationViewModel()
} else {
val items = generatePageItems(currentPage, totalPages, baseUrl)
val previous = createPreviousLink(currentPage, baseUrl)
val next = createNextLink(currentPage, totalPages, baseUrl)

PaginationViewModel(
items = items,
previous = previous,
next = next
)
}
}

private def createPreviousLink(currentPage: Int, baseUrl: String): Option[PaginationLinkViewModel] =
if (currentPage > 1) {
Some(PaginationLinkViewModel(s"$baseUrl?page=${currentPage - 1}").withText("site.pagination.previous"))
} else None

private def createNextLink(currentPage: Int, totalPages: Int, baseUrl: String): Option[PaginationLinkViewModel] =
if (currentPage < totalPages) {
Some(PaginationLinkViewModel(s"$baseUrl?page=${currentPage + 1}").withText("site.pagination.next"))
} else None

private def generatePageItems(
currentPage: Int,
totalPages: Int,
baseUrl: String
): Seq[PaginationItemViewModel] = {
val pageRange = calculatePageRange(currentPage, totalPages)
val items = scala.collection.mutable.ListBuffer[PaginationItemViewModel]()

if (pageRange.head > 1) {
items += PaginationItemViewModel("1", s"$baseUrl?page=1").withCurrent(1 == currentPage)
if (pageRange.head > 2) {
items += PaginationItemViewModel.ellipsis()
}
}

pageRange.foreach { page =>
items += PaginationItemViewModel(
number = page.toString,
href = s"$baseUrl?page=$page"
).withCurrent(page == currentPage)
}

if (pageRange.last < totalPages) {
if (pageRange.last < totalPages - 1) {
items += PaginationItemViewModel.ellipsis()
}
items += PaginationItemViewModel(totalPages.toString, s"$baseUrl?page=$totalPages").withCurrent(totalPages == currentPage)
}

items.toSeq
}

private def calculatePageRange(currentPage: Int, totalPages: Int): Range = {
val halfVisible = config.maxVisiblePages / 2
val startPage = Math.max(1, currentPage - halfVisible)
val endPage = Math.min(totalPages, startPage + config.maxVisiblePages - 1)

val adjustedStartPage = if (endPage - startPage < config.maxVisiblePages - 1) {
Math.max(1, endPage - config.maxVisiblePages + 1)
} else startPage

adjustedStartPage to endPage
}
}
183 changes: 183 additions & 0 deletions app/viewmodels/govuk/PaginationFluency.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Copyright 2025 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package viewmodels.govuk

import play.api.i18n.Messages
import uk.gov.hmrc.govukfrontend.views.viewmodels.pagination.{Pagination, PaginationItem, PaginationLink}
import utils.Utils.emptyString

object PaginationFluency {

object PaginationViewModel {

def apply(): PaginationViewModel =
PaginationViewModel(
items = Nil,
previous = None,
next = None,
landmarkLabel = "site.pagination.landmark",
classes = emptyString,
attributes = Map.empty
)

def apply(items: Seq[PaginationItemViewModel]): PaginationViewModel =
PaginationViewModel(
items = items,
previous = None,
next = None,
landmarkLabel = "site.pagination.landmark",
classes = emptyString,
attributes = Map.empty
)
}

case class PaginationViewModel(
items: Seq[PaginationItemViewModel] = Nil,
previous: Option[PaginationLinkViewModel] = None,
next: Option[PaginationLinkViewModel] = None,
landmarkLabel: String = "site.pagination.landmark",
classes: String = emptyString,
attributes: Map[String, String] = Map.empty
) {

def withItems(items: Seq[PaginationItemViewModel]): PaginationViewModel =
copy(items = items)

def withPrevious(previous: PaginationLinkViewModel): PaginationViewModel =
copy(previous = Some(previous))

def withNext(next: PaginationLinkViewModel): PaginationViewModel =
copy(next = Some(next))

def withLandmarkLabel(landmarkLabel: String): PaginationViewModel =
copy(landmarkLabel = landmarkLabel)

def withClasses(classes: String): PaginationViewModel =
copy(classes = classes)

def withAttributes(attributes: Map[String, String]): PaginationViewModel =
copy(attributes = attributes)

def asPagination(implicit messages: Messages): Pagination = {
Pagination(
items = Some(items.map(_.asPaginationItem)),
previous = previous.map(_.asPaginationLink),
next = next.map(_.asPaginationLink),
landmarkLabel = Some(messages(landmarkLabel)),
classes = classes,
attributes = attributes
)
}
}

object PaginationItemViewModel {

def apply(number: String, href: String): PaginationItemViewModel =
PaginationItemViewModel(
number = number,
href = href,
visuallyHiddenText = None,
current = false,
ellipsis = false,
attributes = Map.empty
)

def ellipsis(): PaginationItemViewModel =
PaginationItemViewModel(
number = emptyString,
href = emptyString,
visuallyHiddenText = None,
current = false,
ellipsis = true,
attributes = Map.empty
)
}

case class PaginationItemViewModel(
number: String,
href: String,
visuallyHiddenText: Option[String],
current: Boolean,
ellipsis: Boolean,
attributes: Map[String, String]
) {

def withVisuallyHiddenText(visuallyHiddenText: String): PaginationItemViewModel =
copy(visuallyHiddenText = Some(visuallyHiddenText))

def withCurrent(current: Boolean): PaginationItemViewModel =
copy(current = current)

def withAttributes(attributes: Map[String, String]): PaginationItemViewModel =
copy(attributes = attributes)

def asPaginationItem(implicit messages: Messages): PaginationItem = {
PaginationItem(
number = if (ellipsis) None else Some(number),
href = if (ellipsis) emptyString else href,
visuallyHiddenText = visuallyHiddenText.map(messages(_)),
current = if (current) Some(true) else None,
ellipsis = if (ellipsis) Some(true) else None,
attributes = attributes
)
}
}

object PaginationLinkViewModel {

def apply(href: String): PaginationLinkViewModel =
PaginationLinkViewModel(
href = href,
text = None,
html = None,
labelText = None,
attributes = Map.empty
)
}

case class PaginationLinkViewModel(
href: String,
text: Option[String],
html: Option[String],
labelText: Option[String],
attributes: Map[String, String]
) {

def withText(text: String): PaginationLinkViewModel =
copy(text = Some(text))

def withHtml(html: String): PaginationLinkViewModel =
copy(html = Some(html))

def withLabelText(labelText: String): PaginationLinkViewModel =
copy(labelText = Some(labelText))

def withAttributes(attributes: Map[String, String]): PaginationLinkViewModel =
copy(attributes = attributes)

def asPaginationLink(implicit messages: Messages): PaginationLink = {
PaginationLink(
href = href,
text = text.map(messages(_)),
labelText = labelText.map(messages(_)),
attributes = attributes
)
}
}
}

trait PaginationFluency
Loading