Skip to content

Closes 5813: Add Ranking-related SDC's and Refactor az_ranking to use them - #5834

Open
kevdevlu wants to merge 12 commits into
mainfrom
issue/5813
Open

Closes 5813: Add Ranking-related SDC's and Refactor az_ranking to use them#5834
kevdevlu wants to merge 12 commits into
mainfrom
issue/5813

Conversation

@kevdevlu

@kevdevlu kevdevlu commented Jul 24, 2026

Copy link
Copy Markdown
Member

Closes #5813, MAR-259 , and fixes a (not super related) ranking issue (#5156) as well

Description

Goal:

  • Make single-source-of-truth SDC's that would be used whether a user is:
    • creating rankings with a traditional az_flexible_page using the az_ranking paragraph type
      or
    • creating rankings with Drupal Canvas
  • Refactor az_ranking's AZRankingDefaultFormatter.php to use the above SDC's.

Key Decisions from Discussion with Chris:

  1. Use a new components folder in our az_quickstart profile as a place to put SDC's.
    Reasoning: we want to avoid adding more components to az_barrio. For this specific component, that would mean making az_ranking (a module) depend on a theme, which we want to avoid.

  2. Instead of using canvas:image ($ref: json-schema-definitions://canvas.module/image in our schema), we want to use something that doesn't depend on canvas and wouldn't break if canvas was not enabled.

A Caveat

Since we needed to add images without using canvas:image, claude helped me find inside Canvas:
src/Hook/ShapeMatchingHooks.php's mediaLibraryStorablePropShapeAlter() — a hook Canvas ships itself, gated on core's media_library module — which recognizes a plain string prop shaped exactly as:

src:
  type: string
  format: uri
  contentMediaType: image/*
  x-allowed-schemes:
    - public

But this is not anything that Canvas talks about in its API and so they might internally change it without telling anybody. In that case, the Editor experience will be broken, but the image will still render on the frontend. This is something we'd need to check every time we upgrade our version of Canvas.

Release notes

Related issues

How to test

  1. Enable Canvas
  2. Test we can achieve this page's display of rankings using Canvas: https://test-azs-arizona.pantheonsite.io/about/rankings .
    • Note: we deliberately didn't try to match the (1) sans-serif font in the heading, (2) non-underlined hover, (3) and more rounded border-radius
    • in tugboat you won't have components from az_storybook like Heading, Text-Image, and Horizontal Rule. So for testing just do without those components.
  3. Test using the traditional az_flexible_page and using az_ranking paragraph type works as well.

Types of changes

Arizona Quickstart (install profile, custom modules, custom theme)

  • Patch release changes
    • Bug fix
    • Accessibility, performance, or security improvement
    • Critical institutional link or brand change
    • Adding experimental module
    • Update experimental module
  • Minor release changes
    • New feature
    • Breaking or visual change to existing behavior
    • Upgrade experimental module to stable
    • Enable existing module by default or database update
    • Non-critical brand change
    • New internal API or API improvement with backwards compatibility
    • Risky or disruptive cleanup to comply with coding standards
    • High-risk or disruptive change (requires upgrade path, risks regression, etc.)
  • Other or unknown
    • Other or unknown

Drupal core

  • Patch release changes
    • Security update
    • Patch level release (non-security bug-fix release)
    • Patch removal that's no longer necessary
  • Minor release changes
    • Major or minor level update
  • Other or unknown
    • Other or unknown

Drupal contrib projects

  • Patch release changes
    • Security update
    • Patch or minor level update
    • Add new module
    • Patch removal that's no longer necessary
  • Minor release changes
    • Major level update
  • Other or unknown
    • Other or unknown

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My change requires release notes.

kevdevlu and others added 2 commits July 24, 2026 00:36
Ports the az_ranking paragraph's rendering into three profile-level Single
Directory Components, usable directly in Drupal Canvas as well as from the
Paragraphs pipeline in a later phase:

- ranking: a single text ranking card
- ranking-deck: a responsive CSS grid wrapper for multiple cards/images,
  replacing the Bootstrap row/col composition Canvas has no editor UI for
- image: a layout- and accessibility-aware image, also closing MAR-104
  (decorative image component). Uses a plain string prop shape that Canvas's
  own media_library shape-matching recognizes, giving a real media-library
  picker without requiring the Canvas module to render on Canvas-less sites.

Adds drupal/canvas to composer.json so the Tugboat PR preview build installs
Canvas for review; to be removed in a follow-up commit once review is done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AZRankingDefaultFormatter now renders through az_quickstart:ranking,
az_quickstart:image, and az_quickstart:ranking-deck instead of the legacy
#theme => az_ranking render array, so paragraph-authored and Canvas-composed
rankings share the same markup. Legacy stored values (backgrounds, link
styles, per-breakpoint column widths) are mapped to SDC prop tokens via new
class-constant lookup tables; link resolution keeps legacy's exact three-branch
logic, gated on the raw stored link_uri so placeholder '#' links keep their
button. AZRankingWidget's own edit-form preview (#theme => az_ranking) is
untouched, since it's a separate, still-legacy code path.

image's width_span became three per-breakpoint props (desktop/tablet/phone),
replacing a single dynamic value, because CSS Grid has no way to clamp a
span against its container's actual column count (w3c/csswg-drafts#5852) -
the formatter now clamps each breakpoint's span against the sibling
ranking-deck's actual configured columns in PHP instead. ranking-deck and
image also get new default columns/spans (1/2/4 and 1/2/2 phone/tablet/
desktop) for standalone Canvas placement.

ranking's source prop gains a pattern making it Canvas-editable as a
multi-line textarea, matching legacy's line-break-controlled attribution
text; ranking.twig's existing nl2br rendering needed no change.
@kevdevlu kevdevlu self-assigned this Jul 24, 2026
@kevdevlu
kevdevlu marked this pull request as ready for review July 24, 2026 17:08
@kevdevlu
kevdevlu requested review from a team as code owners July 24, 2026 17:08
Comment thread components/ranking-image/ranking-image.component.yml
Comment thread components/image/image.component.yml Outdated
x-allowed-schemes:
- public
examples:
- public://canyon_running.jpg

@trackleft trackleft Jul 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- public://canyon_running.jpg
- https://placehold.co/600x400?font=Proxima+Nova

@trackleft trackleft Jul 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should use placeholder images for this, preferably with a suggested size printed on them.

https://placehold.co/

@kevdevlu kevdevlu Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is we're trying to match this image shape, which requires public:// (and we're using this shape because compared to using $ref, this one doesn't crash if we don't have Canvas enabled):

(
        isset($storable_prop_shape->shape->schema['contentMediaType'])
        && $storable_prop_shape->shape->schema['contentMediaType'] === 'image/*'
        // Stream wrapper URIs can only be `format: uri|iri`.
        && JsonSchemaStringFormat::from($storable_prop_shape->shape->schema['format'])->allowsOnlyAbsoluteUri()
        // @see json-schema-definitions://canvas.module/stream-wrapper-image-uri
        && ($storable_prop_shape->shape->schema['x-allowed-schemes'] ?? []) === ['public']
      )

https://git.drupalcode.org/project/canvas/-/blob/1.x/src/Hook/ShapeMatchingHooks.php#L282

Which, x-allowed-schemes is only public, so we can have public://___________ but not https://_____________

But i totally get having a placeholder image (from an editor's perspective), so this commit is what i am suggesting: 00b6552

Open to any other ideas

kevdevlu and others added 6 commits July 27, 2026 02:52
The "Image" name was reserved for a future, more broadly-scoped
component; renamed az_quickstart:image to az_quickstart:ranking-image
(directory, component ID, name, CSS/JS) with no change to its actual
capability - it's still a general-purpose, layout-aware image, not
ranking-specific. ranking.component.yml/twig's own cross-references
updated to match.

Also restores WebP conversion and a size cap (lost when the legacy
#theme => image_formatter path was replaced by the plain-URI SDC prop
in an earlier commit): a new Drupal\az_media\Twig\ImageStyleTwigExtension
adds an image_style Twig filter, applied in ranking-image.twig, so both
the paragraph-authored and Canvas-placed pathways get it automatically
regardless of who populates the src prop. The az_ranking_responsive
image style itself moves from az_ranking to az_media (its name, upscale
setting, and WebP conversion left untouched) so a future az_media-only
Canvas site doesn't have to depend on az_ranking to get it - confirmed
via ConfigManager::uninstall() that this was never a deletion-safety
issue, just future-proofing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP
az_quickstart:ranking-image's src prop example previously pointed at
canyon_running.jpg, a real demo photo only present on sites that
enable az_demo (explicitly not for production). Adds a dedicated
1000x500 placeholder PNG shipped as a plain az_media asset instead,
copied into public:// via hook_install()/hook_update_N() - a raw
filesystem copy, not a managed file or media entity, so it exists on
every site (including production) without ever showing up as a
selectable option in the Media Library picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP
AZRankingComponentBuilder becomes the single source of truth mapping
ranking item values to az_quickstart:ranking/ranking-image render
arrays, shared by AZRankingDefaultFormatter and AZRankingWidget's live
edit-form preview - previously the widget preview still rendered
through the legacy #theme => 'az_ranking' template/hook_theme path
(az-ranking.html.twig, az_ranking_theme(), plus its own now-dead
az-ranking.css/az-ranking-image.css/az-ranking-focal-point-calc.js),
duplicating the formatter's class-mapping logic and free to drift out
of sync with what actually publishes. Both callers now build a plain
values array (AZRankingComponentBuilder::extractItemValues()) instead
of passing an AZRankingItem directly, since the widget's preview needs
to build that same array from Form API values instead of a field item
(see below) - keeping the builder item-agnostic is what lets it work
for both.

Also fixes az_quickstart#5156: after drag-and-drop reordering a
paragraph's ranking items, clicking "Update Preview"/"Add Another
Item"/"Remove" could leave previews showing stale data for the wrong
row. Root cause: the preview was built from $items[$delta] (stored
array order), while its sibling text/select fields are repopulated by
the Form API from #value (always correct for the current row, since
that's tree-position-based, not stored-order-based). Fixed the way
az_quickstart/az_quickstart#5309 fixed it upstream: build the preview
from the same Form API-populated #value the fields use, instead of the
item, via a new custom Form API element (AZRankingItemElement) whose
#process builds the fields and #after_build rebuilds the preview once
its siblings have resolved - a real, reusable Element plugin instead
of #after_build glue bolted onto Field API's own per-delta wrapper, so
the preview keeps direct access to its sibling fields (an element
scoped to the preview alone would lose that, since #after_build only
sees its own descendants). Also drops the old original_deltas widget-
state remap, which #5309 found actively breaks reorder rather than
protecting it - table-drag already moves whole rows (fields + preview
together) client-side, so no server-side delta remapping is needed.

Verified via a real Drupal form-build cycle (\Drupal::formBuilder()
->doBuildForm()) against real paragraph data, not just isolated calls:
confirmed #process and #after_build both fire in the correct order and
the resulting preview matches the real stored values exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP
ranking-image.js's object-position formula only ever uses the image's
width/height as a RATIO, never as absolute values, and
az_ranking_responsive's image_scale effect always preserves aspect
ratio (even when upscaling) - so the loaded <img>'s own
naturalWidth/naturalHeight gives the exact same ratio as the true
original, with no need to pass original dimensions down as a prop at
all. Switched to using them directly instead of reading
data-original-width/data-original-height, which also fixes a latent
bug: those attributes, when present with stale values (e.g. a
Canvas-placed instance left at its example defaults), would have fed
wrong data into the calculation instead of ever falling back.

With that dependency gone, original_width/original_height no longer
serve any purpose - their only other use (native <img> width/height
attributes) turned out to be inert too, since ranking-image.css
absolutely-positions the image at 100%/100% of its parent regardless
of intrinsic size. Removed the props entirely, along with the
AZRankingImageHelper computation that read them from the file (a real
file I/O call on every render) and the now-fully-unused ImageFactory
dependency it required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP
- ImageStyleTwigExtension: replace the static ImageStyle::load() call
  with an injected EntityTypeManagerInterface
  (DrupalPractice.Objects.GlobalClass.GlobalClass).
- AZRankingItemElement: fix an incorrect import - extended the
  deprecated Drupal\Core\Render\Element\RenderElement (aliased to
  RenderElementBase locally, which doesn't change which class is
  actually extended) instead of the real
  Drupal\Core\Render\Element\RenderElementBase.
- AZRankingItem: merge two adjacent docblocks
  ({@inheritdoc} immediately followed by a bare @todo-only block) into
  one - the second was missing a short description on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ6mWfeVEr5qdPLXAEmtqP
Comment thread composer.json Outdated
type: string
title: Image
description: Pick an image from the media library.
format: uri

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
format: uri
format: uri-reference

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This didn't work, but if we really want it to be able to take a https url image, i made a subbranch #5849 basically reproducing the internal mechanisms of canvas onto our az_media module > i don't recommend it because it adds so much complexity / extra maintenance just to allow https://placeholder.co to work

@joeparsons joeparsons added enhancement New feature or request minor release Issues/PRs that should only be made as part of a minor release (according to our release policy). developer experience labels Jul 27, 2026
@joeparsons joeparsons moved this from Todo to Needs review in 3.6.0-alpha1 Jul 27, 2026
kevdevlu and others added 2 commits July 28, 2026 15:32
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The legacy image path bubbled the file entity's cache tags via
AZRankingImageHelper::generateImageRenderArray(), which called
renderer->addCacheableDependency($build, $file). The SDC port dropped
that along with the @Renderer service argument.

AZRankingItem stores its media reference as a plain integer property,
not an entity reference, so Drupal contributes no cache metadata for it
automatically and nothing else compensated. Replacing a media entity's
image or moving its focal point would not invalidate an already-cached
ranking.

buildImageComponent() now attaches tags from both the media and file
entities. The media entity is tagged even when it carries no image,
since alt text and both focal point values live there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kevdevlu

Copy link
Copy Markdown
Member Author

Troy suggested at Jul 29 PR review: What if we used an internal placeholder generating service , like what the external placeholder.co does?

An SDC image prop carries only a URI string: Canvas resolves
media -> file -> uri and hands the template the last of those. The alt
text an author typed in the Media Library modal was therefore not
reachable from the component, so an image described once in the library
arrived at the component undescribed.

Add a `media_alt` Twig filter to az_media that walks that chain
backwards, and use it in ranking-image as a fallback: per-placement alt
wins, empty falls back to the media item, decorative overrides both.
The filter takes the media source field name as an argument so any
image-bearing SDC can use it, which is why it lives in az_media rather
than in a consuming module.

Also remove the `examples` value from the `alt` prop. Canvas uses
examples[0] as a prop's stored default value, not as placeholder text,
so "Placeholder image, 1000 by 500 pixels" was shipping verbatim as the
alt text of any real photograph an author did not edit - an
accessibility defect, and one that would also have masked the new
fallback by never being empty. No props on this component are required,
so component metadata stays valid without it.

Retitle both accessibility props to "(this placement)" and say plainly
in their descriptions how they relate to the media item's own values.
The two were being confused for each other, which is what surfaced the
missing carry-over in the first place.

Record in the template's docblock that Canvas independence is a hard
constraint rather than an incidental property, since this component
also renders the legacy az_ranking paragraph type on sites that may
never install Canvas. Canvas's `json-schema-definitions://` $ref scheme
and `apply_image_style` filter are both off limits here; a missing Twig
filter fails at compile time, so there is no graceful degradation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@az-digital-bot

Copy link
Copy Markdown
Contributor

Tugboat has finished building the preview for this pull request!

Link:

Dashboard:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

developer experience enhancement New feature or request minor release Issues/PRs that should only be made as part of a minor release (according to our release policy).

Projects

Status: Needs review

Development

Successfully merging this pull request may close these issues.

Use an SDC to output the rankings paragraph.

4 participants