From 092a118b465879019caaa03b30800c743aa9eee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Y=C3=A1=C3=B1ez?= Date: Tue, 4 Aug 2026 22:45:35 -0400 Subject: [PATCH 1/8] feat: implement the unavailable library page UI Replaces the placeholder on the v3 library subpage with the designed empty state: headline, a sentence naming the library and versions, a "Switch to ..." CTA and the bookshelf illustration, built on the shared library hero. - Add optional cta_label/cta_url/cta_icon_name to _hero_library.html, rendered through the existing hero button component - Add a hero--library-unavailable variant: page surface instead of the accent background, Figma's 32px content rhythm, bottom-flush illustration blended into the surface, and a min-height so the longer copy can't overflow the version alert in the tablet band - Build the sentence and CTA in the view, since both branch on data: a library with no releases has no "first release" clause and nowhere to switch to, and a library dropped from Boost points at its last release rather than latest - Skip the subpage card context entirely when the version is missing --- libraries/tests/test_views.py | 42 +++++++++++++- libraries/views.py | 57 ++++++++++++++++++- static/css/v3/heros.css | 54 ++++++++++++++++++ templates/v3/includes/_hero_library.html | 8 +++ .../_library_version_unavailable.html | 21 +++++++ templates/v3/libraries/library-subpage.html | 6 +- 6 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 templates/v3/includes/_library_version_unavailable.html diff --git a/libraries/tests/test_views.py b/libraries/tests/test_views.py index 90f0c71ce..5ad56b04b 100644 --- a/libraries/tests/test_views.py +++ b/libraries/tests/test_views.py @@ -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 ( @@ -274,6 +276,44 @@ 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 dropped from Boost sends the visitor to its last release, not 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_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}/ diff --git a/libraries/views.py b/libraries/views.py index bd91a2f58..f9514d78e 100644 --- a/libraries/views.py +++ b/libraries/views.py @@ -547,8 +547,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 @@ -586,6 +585,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") @@ -657,6 +661,55 @@ 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 "first + release" clause and nowhere to switch to, and the newest release that + actually ships the library is only the current one for libraries that + haven't been dropped from Boost. + """ + library = self.object + selected_version = context.get("selected_version") + description = ( + f"There is no version of the {library.display_name} library for Boost " + f"{selected_version.display_name}." + ) + first_version = library.first_boost_version + if first_version: + description += ( + f" The first release of {library.display_name} library was version " + f"{first_version.display_name}." + ) + context["library_version_missing_description"] = description + + newest_version = ( + Version.objects.active() + .filter(library_version__library=library, beta=False, full_release=True) + .order_by("-name") + .first() + ) + 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 diff --git a/static/css/v3/heros.css b/static/css/v3/heros.css index 31840cf23..7b7de1b68 100644 --- a/static/css/v3/heros.css +++ b/static/css/v3/heros.css @@ -179,6 +179,8 @@ display: none; } +/* Shared by the home hero and any base hero passing a cta_label/cta_url + (e.g. the library-unavailable variant), so the selector stays unscoped. */ .hero__actions { display: flex; flex-direction: row; @@ -777,6 +779,58 @@ html.dark .hero__links .btn-icon-library:hover { background: var(--color-surface-weak-accent-yellow); } +/* Library unavailable in the selected release (library subpage empty state): + the illustration sits on the page surface rather than the accent background + the other image heroes use. */ +.hero--library-unavailable, +.dark .hero--library-unavailable { + background-color: var(--color-surface-page); +} + +/* Raise the base hero's 662px cap so the flex split against the illustration + sets the text width instead (~700px at 1440px, matching Figma), which breaks + the 64px headline after "records" onto a second line. */ +.hero--library-unavailable .hero__content { + max-width: 830px; + gap: var(--space-xl); +} + +/* Figma spaces headline, sentence and CTA evenly at 32px; the base hero packs + the headline and sentence tighter. */ +.hero--library-unavailable .hero__text-block { + gap: var(--space-xl); +} + +/* Figma sits the illustration flush with the bottom of the block. The empty + state's copy is longer than a library hero's, so the block's fixed height + becomes a floor — otherwise 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 (position/z-index), which closes the blending group + above the section's background. Repeating the surface color here gives the + illustration the backdrop it needs to blend into. */ +.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 — otherwise its edge shows as a panel against + the page surface. Matches the multiply blend in Figma. */ +.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. diff --git a/templates/v3/includes/_hero_library.html b/templates/v3/includes/_hero_library.html index 56fae2c05..42fc8a384 100644 --- a/templates/v3/includes/_hero_library.html +++ b/templates/v3/includes/_hero_library.html @@ -13,6 +13,9 @@ source_url (optional) — Source code link URL. Omit to hide. slack_url (optional) — Discuss in Slack URL. Omit to hide. github_url (optional) — GitHub Issues URL. Omit to hide. + cta_label (optional) — Label for a primary hero button below the text. Needs cta_url. + cta_url (optional) — Destination for that button. Needs cta_label. + cta_icon_name (optional) — Icon for that button, from includes/icon.html. Defaults to "arrow-right". hero_image_url (optional) — Image URL. If omitted, image block is not shown. hero_image_url_mobile (optional) — Art-directed image swapped in at <=767px (e.g. a tighter crop). If omitted, the desktop image is used at all widths. hero_background_image_url (optional) — Background image URL for the hero container. @@ -57,6 +60,11 @@

+ {% if cta_label and cta_url %} +
+ {% include "v3/includes/_button_hero.html" with label=cta_label url=cta_url icon_name=cta_icon_name|default:"arrow-right" style="primary" %} +
+ {% endif %} {% if doc_url or source_url or slack_url or github_url or rss_url %}