common/app/navigation/Navigation.scala (236 lines of code) (raw):
package navigation
import _root_.model.{NavItem, Page, Tags}
import common.{Edition, editions}
import navigation.NavMenu.navRoot
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{Json, Writes, _}
import scala.annotation.tailrec
sealed trait Subnav
case class FlatSubnav(links: Seq[NavLink]) extends Subnav
case class ParentSubnav(parent: NavLink, links: Seq[NavLink]) extends Subnav
case class NavLink(
title: String,
url: String,
longTitle: Option[String] = None,
// TODO: Shouldn't need iconName. Remove, and just make the first NavLink on mobile
iconName: Option[String] = None,
children: Seq[NavLink] = Nil,
classList: Seq[String] = Nil,
)
object NavLink {
def id(link: NavLink): String = link.title
def nullableSeq[A](path: JsPath, writer: => Writes[A]): OWrites[Seq[A]] =
OWrites[Seq[A]] { a =>
a match {
case nonEmpty if nonEmpty.nonEmpty =>
JsPath.createObj(path -> Json.toJson(nonEmpty)(Writes.seq(writer)))
case _ => JsObject.empty
}
}
// Custom writer so that we can drop sequences altogether. It is really important to minimise the data sent to DCR and
// this really helps.
implicit lazy val navLinkWrites: Writes[NavLink] = {
((__ \ "title").write[String]
and (__ \ "url").write[String]
and (__ \ "longTitle").writeNullable[String]
and (__ \ "iconName").writeNullable[String]
and (nullableSeq[NavLink](__ \ "children", navLinkWrites))
and (nullableSeq[String](__ \ "classList", Writes.StringWrites)))(nl =>
(nl.title, nl.url, nl.longTitle, nl.iconName, nl.children, nl.classList),
)
}
}
case class NavMenu(
currentUrl: String,
pillars: Seq[NavLink],
otherLinks: Seq[NavLink],
brandExtensions: Seq[NavLink],
currentNavLink: Option[NavLink],
currentParent: Option[NavLink],
currentPillar: Option[NavLink],
subNavSections: Option[Subnav],
)
object NavMenu {
implicit val navlinkWrites: OWrites[NavLink] = Json.writes[NavLink]
implicit val flatSubnavWrites: OWrites[FlatSubnav] = Json.writes[FlatSubnav]
implicit val parentSubnavWrites: OWrites[ParentSubnav] = Json.writes[ParentSubnav]
implicit val subnavWrites: Writes[Subnav] = Writes[Subnav] {
case nav: FlatSubnav => flatSubnavWrites.writes(nav)
case nav: ParentSubnav => parentSubnavWrites.writes(nav)
}
implicit val writes: OWrites[NavMenu] = Json.writes[NavMenu]
private[navigation] case class NavRoot(
children: Seq[NavLink],
otherLinks: Seq[NavLink],
brandExtensions: Seq[NavLink],
)
def apply(page: Page, edition: Edition): NavMenu = {
val root = navRoot(edition)
val currentUrl = getSectionOrPageUrl(page, edition)
val currentNavLink = findDescendantByUrl(currentUrl, edition, root.children, root.otherLinks)
val currentParent = currentNavLink.flatMap(link => findParent(link, edition, root.children, root.otherLinks))
val currentPillar = getPillar(currentParent, edition, root.children, root.otherLinks)
NavMenu(
currentUrl = currentUrl,
pillars = root.children,
otherLinks = root.otherLinks,
brandExtensions = root.brandExtensions,
currentNavLink = currentNavLink,
currentParent = currentParent,
currentPillar = currentPillar,
subNavSections = getSubnav(page.metadata.customSignPosting, currentNavLink, currentParent, currentPillar),
)
}
/*
* Useful when looking for a link, which may not exist in current edition, but
* does in another.
*
* For example, if you are in the US edition, but go to `/cricket`, we still
* want the Sports Pillar to be highlighted, even though cricket isn't in the
* UsSportsPillar
*/
private[navigation] def getChildrenFromOtherEditions(edition: Edition): Seq[NavLink] = {
// This shouldn't be a problem as Europe won't have special NavLinks
Edition
.othersWithBetaEditions(edition)
.flatMap(edition => NavMenu.navRoot(edition).children ++ NavMenu.navRoot(edition).otherLinks)
}
@tailrec
private[navigation] def find(graph: Seq[NavLink], p: NavLink => Boolean): Option[NavLink] = {
graph match {
case Nil => None
case head :: tail if p(head) => Some(head)
case head :: tail => find(tail ++ head.children, p)
}
}
def findDescendantByUrl(
url: String,
edition: Edition,
pillars: Seq[NavLink],
otherLinks: Seq[NavLink],
): Option[NavLink] = {
def hasUrl(link: NavLink): Boolean = link.url == url
find(pillars ++ otherLinks, hasUrl)
.orElse(find(getChildrenFromOtherEditions(edition), hasUrl))
}
def findParent(
currentNavLink: NavLink,
edition: Edition,
pillars: Seq[NavLink],
otherLinks: Seq[NavLink],
): Option[NavLink] = {
// When nav items can appear in two pillars, we want to ignore the least relevant one
def shouldIgnoreParent(parentTitle: String): Boolean = {
// Football is currently in the News Pillar and the Sport pillar, however we don't want the parent to be News.
(currentNavLink.title == "Football" && parentTitle == "News") ||
// Wellness is in both the News Pillar and the Lifestyle pillar, however we don't want the parent to be News.
(currentNavLink.title == "Wellness" && parentTitle == "News")
}
def isParent(link: NavLink): Boolean = {
link == currentNavLink ||
link.children.contains(currentNavLink) && !shouldIgnoreParent(link.title)
}
find(pillars ++ otherLinks, isParent)
.orElse(find(getChildrenFromOtherEditions(edition), isParent))
}
def getPillar(
currentParent: Option[NavLink],
edition: Edition,
pillars: Seq[NavLink],
otherLinks: Seq[NavLink],
): Option[NavLink] = {
currentParent.flatMap(parent =>
if (otherLinks.contains(parent)) {
None
} else if (pillars.contains(parent)) {
currentParent
} else findParent(parent, edition, pillars, otherLinks).orElse(Some(editions.Uk.navigationLinks.newsPillar)),
)
}
def navRoot(edition: Edition): NavRoot = {
val editionLinks: EditionNavLinks = edition.navigationLinks
NavRoot(
Seq(
editionLinks.newsPillar,
editionLinks.opinionPillar,
editionLinks.sportPillar,
editionLinks.culturePillar,
editionLinks.lifestylePillar,
),
editionLinks.otherLinks,
editionLinks.brandExtensions,
)
}
private[navigation] def getTagsFromPage(page: Page): Tags = {
Page.getContent(page).map(_.tags).getOrElse(Tags(Nil))
}
private[navigation] def getSectionOrPageUrl(page: Page, edition: Edition): String = {
val frontLikePages = List(
"theguardian",
"observer",
"football/live",
"football/tables",
"football/competitions",
"football/results",
"football/fixtures",
"type/cartoon",
"cartoons/archive",
)
val tags = getTagsFromPage(page)
val commonKeywords = tags.keywordIds
.intersect(NavLinks.tagPages)
.sortWith(tags.keywordIds.indexOf(_) < tags.keywordIds.indexOf(_))
val isTagPage = (page.metadata.isFront || frontLikePages.contains(page.metadata.id)) && NavLinks.tagPages
.contains(page.metadata.id)
val isArticleInTagPageSection = commonKeywords.nonEmpty
val id = if (Edition.byNetworkFrontId(page.metadata.sectionId).isDefined) {
""
} else if (isTagPage) {
page.metadata.id
} else if (isArticleInTagPageSection) {
commonKeywords.head
} else if (edition.isEditionalised(page.metadata.sectionId) || page.metadata.isFront) {
page.metadata.sectionId
} else {
page.metadata.sectionId
}
// if id is a section tag, e.g. education/education, convert it to just /education, so it can be succesfully
// found up in the navigation (see findDescendantByUrl)
val idParts = id.split("/")
if (idParts.length == 2 && idParts(0) == idParts(1)) {
s"/${idParts(0)}"
} else s"/$id"
}
def getSubnav(
customSignPosting: Option[NavItem],
currentNavLink: Option[NavLink],
currentParent: Option[NavLink],
currentPillar: Option[NavLink],
): Option[Subnav] = {
customSignPosting match {
case Some(navItem) =>
val links = navItem.links.map(link => NavLink(link.breadcrumbTitle, link.href))
val parent = NavLink(navItem.name.breadcrumbTitle, navItem.name.href)
Some(ParentSubnav(parent, links))
case None =>
val currentNavIsPillar = currentNavLink.equals(currentPillar)
val currentNavHasChildren = currentNavLink.exists(_.children.nonEmpty)
val parentIsPillar = currentParent.equals(currentPillar)
val parent =
if (currentNavHasChildren & !currentNavIsPillar) {
currentNavLink
} else if (parentIsPillar) {
None
} else {
currentParent
}
val links =
if (currentNavHasChildren) {
currentNavLink.map(_.children).getOrElse(Nil)
} else {
currentParent.map(_.children).getOrElse(Nil)
}
parent match {
case Some(p) => Some(ParentSubnav(p, links))
case None if links.nonEmpty => Some(FlatSubnav(links))
case None => None
}
}
}
}
// Used by AMP and DCR
case class SimpleMenu(
pillars: Seq[NavLink],
otherLinks: Seq[NavLink],
brandExtensions: Seq[NavLink],
readerRevenueLinks: ReaderRevenueLinks,
)
object SimpleMenu {
def apply(edition: Edition): SimpleMenu = {
val root = navRoot(edition)
SimpleMenu(root.children, root.otherLinks, root.brandExtensions, ReaderRevenueLinks.all)
}
implicit val writes: OWrites[SimpleMenu] = Json.writes[SimpleMenu]
}