Skip to content
107 changes: 106 additions & 1 deletion libraries/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,10 @@ def test_library_detail_404(library, old_version, tp):
tp.response_404(response)


@waffle.testutils.override_flag("v3", active=False)
def test_library_detail_missing_version(library, old_version, tp):
# custom error due to no existing version
# custom error due to no existing version; pinned to the legacy template now
# that v3 renders its own empty state
url = tp.reverse("library-detail", old_version.display_name, library.slug)
response = tp.get(url)
assert (
Expand All @@ -274,6 +276,109 @@ def test_library_detail_missing_version(library, old_version, tp):
)


@waffle.testutils.override_flag("v3", active=True)
def test_library_detail_missing_version_v3_empty_state(
library_version, old_version, tp
):
"""The v3 subpage swaps in the empty state, pointing at the current release."""
library = library_version.library
url = tp.reverse("library-detail", old_version.display_name, library.slug)
response = tp.get(url)
tp.response_200(response)
tp.assertContext("library_version_missing", True)
tp.assertContext(
"library_version_missing_description",
"There is no version of the Boost.MultiArray library for Boost 1.70.0. "
"The first release of Boost.MultiArray library was version 1.79.0.",
)
tp.assertContext("library_version_missing_cta_label", "Switch to latest (1.79.0)")
tp.assertContext(
"library_version_missing_cta_url",
tp.reverse("library-detail", "latest", library.slug),
)


@waffle.testutils.override_flag("v3", active=True)
def test_library_detail_missing_version_v3_cta_targets_newest_available(
library, version, old_version, tp
):
"""A library that left Boost points at its last release, not at latest."""
baker.make("libraries.LibraryVersion", library=library, version=old_version)
url = tp.reverse("library-detail", version.display_name, library.slug)
response = tp.get(url)
tp.response_200(response)
tp.assertContext(
"library_version_missing_description",
"There is no version of the Boost.MultiArray library for Boost 1.79.0. "
"The last release which included Boost.MultiArray was 1.70.0.",
)
tp.assertContext("library_version_missing_cta_label", "Switch to 1.70.0")
tp.assertContext(
"library_version_missing_cta_url",
tp.reverse("library-detail", old_version.slug, library.slug),
)


@waffle.testutils.override_flag("v3", active=True)
def test_library_detail_missing_version_v3_before_first_release_of_departed_library(
library, version, old_version, tp
):
"""A release older than the first still gets the "first release" sentence.

A library that has left Boost is still missing from the releases that predate
it, and there "the last release which included it" would answer a question the
visitor did not ask.
"""
older_version = baker.make(
"versions.Version",
name="boost-1.60.0",
release_date=datetime.date.today() - datetime.timedelta(days=730),
fully_imported=True,
)
baker.make("libraries.LibraryVersion", library=library, version=old_version)
url = tp.reverse("library-detail", older_version.display_name, library.slug)
response = tp.get(url)
tp.response_200(response)
tp.assertContext(
"library_version_missing_description",
"There is no version of the Boost.MultiArray library for Boost 1.60.0. "
"The first release of Boost.MultiArray library was version 1.70.0.",
)


@waffle.testutils.override_flag("v3", active=True)
def test_library_detail_missing_version_v3_develop_branch_of_departed_library(
library, version, old_version, tp
):
"""A branch head gets the "last release" sentence and the branch wording.

develop carries no version number, so it cannot be placed by the numeric
comparison, but it is by definition ahead of every release.
"""
develop = baker.make(
"versions.Version",
name="develop",
slug="develop",
release_date=datetime.date.today(),
full_release=False,
fully_imported=True,
)
baker.make("libraries.LibraryVersion", library=library, version=old_version)
url = tp.reverse("library-detail", develop.slug, library.slug)
response = tp.get(url)
tp.response_200(response)
tp.assertContext(
"library_version_missing_description",
"There is no version of the Boost.MultiArray library for the develop branch. "
"The last release which included Boost.MultiArray was 1.70.0.",
)
tp.assertContext("library_version_missing_cta_label", "Switch to 1.70.0")
tp.assertContext(
"library_version_missing_cta_url",
tp.reverse("library-detail", old_version.slug, library.slug),
)


def test_library_docs_redirect(tp, library, library_version):
"""
GET /libs/{library_slug}/
Expand Down
72 changes: 70 additions & 2 deletions libraries/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import structlog

from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
Expand Down Expand Up @@ -547,8 +548,7 @@ def get_context_data(self, **kwargs):
except LibraryVersion.DoesNotExist:
# No LibraryVersion for the selected release (e.g. viewing a version
# older than the library's first release). Flag it so the v3 template
# renders a placeholder instead of an empty subpage.
# TODO: replace with a designed empty-state (separate ticket).
# renders the empty state instead of an empty subpage.
context["library_version_missing"] = True
return context

Expand Down Expand Up @@ -586,6 +586,11 @@ def get_context_data(self, **kwargs):
def get_v3_context_data(self, **kwargs):
context = {**kwargs}

if context.get("library_version_missing"):
# The empty state renders a hero and nothing else, so none of the
# card context below applies.
return self.get_missing_version_context(context)

version_str = context.get("version_str") or LATEST_RELEASE_URL_PATH_STR

library_version = context.get("library_version")
Expand Down Expand Up @@ -657,6 +662,69 @@ def get_v3_context_data(self, **kwargs):

return context

def get_missing_version_context(self, context):
"""Add the copy and CTA for the "no records for this version" empty state.

Built here rather than in the template because both the sentence and the
CTA branch on data: a library with no releases at all has no second
sentence and nowhere to switch to, and a library that has left Boost
needs the last release that shipped it rather than the first.
"""
library = self.object
selected_version = context.get("selected_version")
selected_is_branch = selected_version.slug in settings.BOOST_BRANCHES
selected_label = (
f"the {selected_version.display_name} branch"
if selected_is_branch
else f"Boost {selected_version.display_name}"
)
description = (
f"There is no version of the {library.display_name} library for "
f"{selected_label}."
)

released_versions = Version.objects.active().filter(
library_version__library=library, beta=False, full_release=True
)
newest_version = released_versions.order_by("-name").first()

left_boost = newest_version and (
selected_is_branch
or selected_version.cleaned_version_parts_int
> newest_version.cleaned_version_parts_int
)
if left_boost:
description += (
f" The last release which included {library.display_name} was "
f"{newest_version.display_name}."
)
elif first_version := released_versions.order_by("name").first():
description += (
f" The first release of {library.display_name} library was version "
f"{first_version.display_name}."
)
context["library_version_missing_description"] = description

if newest_version:
is_latest = newest_version == Version.objects.most_recent()
context["library_version_missing_cta_url"] = reverse(
"library-detail",
kwargs={
"version_slug": (
LATEST_RELEASE_URL_PATH_STR
if is_latest
else newest_version.slug
),
"library_slug": library.slug,
},
)
context["library_version_missing_cta_label"] = (
f"Switch to latest ({newest_version.display_name})"
if is_latest
else f"Switch to {newest_version.display_name}"
)
return context

def get_dependency_diff(self, library_version):
diffs = library_version.version.get_dependency_diffs(
library=library_version.library
Expand Down
1 change: 0 additions & 1 deletion static/css/v3/auth-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
.auth-page {
display: flex;
flex-direction: column;
min-height: 100vh;
}

/* ── Error styling scoped to auth page ──────── */
Expand Down
35 changes: 35 additions & 0 deletions static/css/v3/foundations.css
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,38 @@ html.v3 code {
padding-left:var(--space-medium);
padding-right:var(--space-medium);
}

/* ==========================================================================
Full-height page: the footer sits at the bottom of the viewport on pages
whose content is too short to fill it, instead of riding up under the last
card.

Driven from the body because that is the only element every v3 page shares:
base.html renders [optional flag banner] + [page wrapper], and the wrapper
itself is a template block that homepage, community, release detail, auth
and library subpages each swap out for their own. Growing the body's own
children also means the banner's height is accounted for automatically,
rather than each page guessing at `min-height: 100vh`.
========================================================================== */

body.v3 {
display: flex;
flex-direction: column;
/* Overrides .h-screen, which would otherwise cap the page at exactly one
viewport and let longer content spill out of the flex column. */
height: auto;
min-height: 100vh;
}

/* The page wrapper, whatever class the template gave it: it is the body child
holding the header, the content and the footer. */
body.v3 > div:has(> .min-vh-110) {
display: flex;
flex-direction: column;
flex: 1 0 auto;
}

/* The content wrapper between the header and the footer takes up the slack. */
body.v3 > div > .min-vh-110 {
flex: 1 0 auto;
}
98 changes: 98 additions & 0 deletions static/css/v3/heros.css
Comment thread
julhoang marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
display: none;
}

/* Deliberately unscoped: any hero passing cta_label/cta_url renders this. */
.hero__actions {
display: flex;
flex-direction: row;
Expand Down Expand Up @@ -486,6 +487,57 @@
padding-top: var(--space-hero-padding-top);
}

/* Positioning context for the alert below. */
.hero:has(> .banner) {
/* Not var(--header-height): outside .header that resolves to an unrelated
legacy 2.5rem, which would tuck the alert under the header. 48px is
.header's own value; the rest of its footprint is its vertical padding. */
--hero-header-footprint: calc(48px + 2 * var(--space-default));
--hero-banner-inset: var(--space-large);
position: relative;
}

/* Lines up horizontally with the Nav. */
.hero > .banner {
position: absolute;
z-index: 2;
top: var(--hero-header-footprint);
left: var(--hero-banner-inset);
right: var(--hero-banner-inset);
width: auto;
max-width: calc(1440px - 2 * var(--hero-banner-inset));
margin-inline: auto;
}

/* Tracks the header's own padding switch in header.css at this same width. */
@media (max-width: 990px) {
.hero:has(> .banner) {
--hero-header-footprint: calc(48px + 2 * var(--space-medium));
--hero-banner-inset: var(--space-medium);
}
}

/* Wider heroes clear the header by more than the alert is tall, so overlaying
costs nothing. Down here there is no such slack: the block clears the header
by 32px, the release hero by 16px, and the alert wraps to three lines on a
narrow phone. Overlaying would cover the heading, and any fixed reserve would
only be a guess at how long the message runs, so the alert goes back in flow
and takes the dismiss shift that the wider layouts avoid. */
@media (max-width: 767px) {
.hero:has(> .banner) {
padding-top: var(--hero-header-footprint);
}

.hero:has(> .banner) .hero__block {
padding-top: 0;
}

.hero > .banner {
position: static;
width: calc(100% - 2 * var(--hero-banner-inset));
}
}

.hero--no-image .hero__block {
align-items: center;
}
Expand Down Expand Up @@ -777,6 +829,52 @@ html.dark .hero__links .btn-icon-library:hover {
background: var(--color-surface-weak-accent-yellow);
}

/* Library subpage empty state: no version of the library in this release. */
.hero--library-unavailable,
.dark .hero--library-unavailable {
background-color: var(--color-surface-page);
}

/* A ceiling, not the target width: it lifts the base hero's 662px cap so the
flex split against the illustration sets the width (~700px at 1440, Figma). */
.hero--library-unavailable .hero__content {
max-width: 830px;
gap: var(--space-xl);
}

/* Figma spaces headline, sentence and CTA evenly; the base hero packs the
headline and sentence tighter. */
.hero--library-unavailable .hero__text-block {
gap: var(--space-xl);
}

/* This copy runs longer than a library hero's, so the fixed height becomes a
floor: at a fixed height the CTA overflows onto the version alert once the
headline wraps to three lines in the tablet band. */
.hero--library-unavailable .hero__block {
padding-bottom: 0;
height: auto;
min-height: var(--hero-block-height);
}

/* The blend below composites against this box, not against .hero: .hero__block
opens a stacking context, which closes the blending group above the section's
background. Hence the surface color repeated here. */
.hero--library-unavailable .hero__image {
align-self: flex-end;
background-color: var(--color-surface-page);
}

/* The illustration ships on an opaque plate (white in the light export, black
in the dark one), so blend it away or its edge shows as a panel. */
.hero--library-unavailable .hero__img--light {
mix-blend-mode: multiply;
}

.hero--library-unavailable .hero__img--dark {
mix-blend-mode: screen;
}

/* ----- Hero variants: tablet ----- */
/* In the tablet band the community foreground is still full width but the mascot has
moved toward center, so cap the text column at 50vw to keep it clear of the art.
Expand Down
Loading
Loading