From 54c4637eb3df707423dc6831b11a9277a69709e3 Mon Sep 17 00:00:00 2001 From: nod_ <nod_@598310.no-reply.drupal.org> Date: Wed, 22 Jan 2025 12:21:36 +0100 Subject: [PATCH] Issue #3484600 by anjali rathod, plopesc, finnsky, lauriii, m4olivei, ckrina, berdir, smustgrave, catch: Show entity information on the Top Bar --- .../components/badge/badge.component.yml | 27 ++++ .../navigation/components/badge/badge.css | 24 +++ .../components/badge/badge.pcss.css | 20 +++ .../navigation/components/badge/badge.twig | 5 + .../components/title/assets/database.svg | 1 + .../components/title/assets/file.svg | 1 + .../components/title/title.component.yml | 54 +++++++ .../navigation/components/title/title.css | 52 +++++++ .../components/title/title.pcss.css | 54 +++++++ .../navigation/components/title/title.twig | 21 +++ .../toolbar-button/toolbar-button.css | 7 +- .../toolbar-button/toolbar-button.pcss.css | 7 +- .../navigation/css/components/top-bar.css | 13 +- .../css/components/top-bar.pcss.css | 14 +- .../navigation/navigation.services.yml | 12 +- .../navigation/src/EntityRouteHelper.php | 141 +++++++++++++++++ .../navigation/src/NavigationRenderer.php | 83 +--------- .../src/Plugin/TopBarItem/PageContext.php | 147 ++++++++++++++++++ .../NavigationTopBarPageContextTest.php | 104 +++++++++++++ .../FunctionalJavascript/PerformanceTest.php | 6 +- 20 files changed, 695 insertions(+), 98 deletions(-) create mode 100644 core/modules/navigation/components/badge/badge.component.yml create mode 100644 core/modules/navigation/components/badge/badge.css create mode 100644 core/modules/navigation/components/badge/badge.pcss.css create mode 100644 core/modules/navigation/components/badge/badge.twig create mode 100644 core/modules/navigation/components/title/assets/database.svg create mode 100644 core/modules/navigation/components/title/assets/file.svg create mode 100644 core/modules/navigation/components/title/title.component.yml create mode 100644 core/modules/navigation/components/title/title.css create mode 100644 core/modules/navigation/components/title/title.pcss.css create mode 100644 core/modules/navigation/components/title/title.twig create mode 100644 core/modules/navigation/src/EntityRouteHelper.php create mode 100644 core/modules/navigation/src/Plugin/TopBarItem/PageContext.php create mode 100644 core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php diff --git a/core/modules/navigation/components/badge/badge.component.yml b/core/modules/navigation/components/badge/badge.component.yml new file mode 100644 index 000000000000..a7bb04f963eb --- /dev/null +++ b/core/modules/navigation/components/badge/badge.component.yml @@ -0,0 +1,27 @@ +# This is so your IDE knows about the syntax for fixes and autocomplete. +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json + +# The human readable name. +name: Badge + +# Status can be: "experimental", "stable", "deprecated", "obsolete". +status: experimental + +# Schema for the props. We support www.json-schema.org. Learn more about the +# syntax there. +props: + # Props are always an object with keys. Each key is a variable in your + # component template. + type: object + + properties: + status: + type: string + default: info + enum: + - info + - success +slots: + label: + type: string + description: Label text diff --git a/core/modules/navigation/components/badge/badge.css b/core/modules/navigation/components/badge/badge.css new file mode 100644 index 000000000000..ab8825a2596c --- /dev/null +++ b/core/modules/navigation/components/badge/badge.css @@ -0,0 +1,24 @@ +/* + * DO NOT EDIT THIS FILE. + * See the following change record for more information, + * https://www.drupal.org/node/3084859 + * @preserve + */ +/* cspell:ignore csvg cpath wght */ +/** + * @file + * Toolbar badge styles. + */ +.toolbar-badge { + margin-top: 0.1875rem; + padding: var(--admin-toolbar-space-4) var(--admin-toolbar-space-8); + border-radius: 1rem; + background-color: var(--admin-toolbar-color-gray-100); + font-size: var(--admin-toolbar-font-size-label-sm); + line-height: var(--admin-toolbar-line-height-label-sm); + font-variation-settings: "wght" 700; +} +.toolbar-badge--success { + color: var(--admin-toolbar-color-green-600); + background-color: var(--admin-toolbar-color-green-050); +} diff --git a/core/modules/navigation/components/badge/badge.pcss.css b/core/modules/navigation/components/badge/badge.pcss.css new file mode 100644 index 000000000000..c7c924ab89f3 --- /dev/null +++ b/core/modules/navigation/components/badge/badge.pcss.css @@ -0,0 +1,20 @@ +/* cspell:ignore csvg cpath wght */ +/** + * @file + * Toolbar badge styles. + */ + +.toolbar-badge { + margin-top: 3px; + padding: var(--admin-toolbar-space-4) var(--admin-toolbar-space-8); + border-radius: 1rem; + background-color: var(--admin-toolbar-color-gray-100); + font-size: var(--admin-toolbar-font-size-label-sm); + line-height: var(--admin-toolbar-line-height-label-sm); + font-variation-settings: "wght" 700; +} + +.toolbar-badge--success { + color: var(--admin-toolbar-color-green-600); + background-color: var(--admin-toolbar-color-green-050); +} diff --git a/core/modules/navigation/components/badge/badge.twig b/core/modules/navigation/components/badge/badge.twig new file mode 100644 index 000000000000..909fd9c8c1c9 --- /dev/null +++ b/core/modules/navigation/components/badge/badge.twig @@ -0,0 +1,5 @@ +<div{{ attributes.addClass('toolbar-badge', 'toolbar-badge--' ~ status|default('info')) }}> + {% block label %} + {{ label }} + {% endblock %} +</div> diff --git a/core/modules/navigation/components/title/assets/database.svg b/core/modules/navigation/components/title/assets/database.svg new file mode 100644 index 000000000000..4455da4bd919 --- /dev/null +++ b/core/modules/navigation/components/title/assets/database.svg @@ -0,0 +1 @@ +<svg fill="none" height="12" viewBox="0 0 12 12" width="12" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m2 2.5c0 .00062.00009.00292.00153.00765.00169.00557.00588.01658.01601.03307.02122.03457.0645.08654.14558.15044.16509.13011.43496.26914.81301.39516.75088.25029 1.82002.41368 3.02387.41368 1.20386 0 2.27299-.16339 3.02387-.41368.37805-.12602.64792-.26505.81301-.39516.08108-.0639.12436-.11587.14558-.15044.01013-.01649.01431-.0275.01601-.03307.00146-.00479.00153-.00695.00153-.00754 0-.00003 0 .00002 0 0 0-.00036 0-.00258-.00153-.00776-.0017-.00557-.00588-.01658-.01601-.03307-.02122-.03457-.0645-.08654-.14558-.15044-.16509-.13011-.43496-.26914-.81301-.39516-.75088-.25029-1.82001-.41368-3.02387-.41368-1.20385 0-2.27299.16339-3.02387.41368-.37805.12602-.64792.26505-.81301.39516-.08108.0639-.12436.11587-.14558.15044-.01013.01649-.01432.0275-.01601.03307-.00154.00504-.00153.00721-.00153.00765zm8 1.2633c-.20061.10259-.42333.19284-.65991.2717-.8778.2926-2.05866.465-3.34009.465s-2.46229-.1724-3.34009-.465c-.23658-.07886-.4593-.16911-.65991-.2717v2.2367l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40183.77887.25962 1.86698.41368 3.02387.41368s2.245-.15406 3.02387-.41368c.39108-.13036.65958-.27264.82012-.40183.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00047-.00159.00076-.00279.00093-.00362.0001-.00051.00016-.00088.0002-.00112l.00007-.00065zm1-1.2633c0-.42597-.2594-.75221-.5441-.97657-.2904-.22881-.67952-.413-1.11581-.558432-.8778-.2926-2.05866-.464998-3.34009-.464998s-2.46229.172398-3.34009.464998c-.43629.145432-.82543.329622-1.11576.558432-.2847.22436-.54415.5506-.54415.97657v7c0 .41262.2462.7359.52907.9636.28749.2313.678.4205 1.13084.5714.90895.303 2.11003.465 3.34009.465s2.43114-.162 3.34009-.465c.45283-.1509.84331-.3401 1.13081-.5714.2829-.2277.5291-.55098.5291-.9636zm-1 4.76299c-.19854.10174-.42052.19222-.65991.27201-.90895.30299-2.11003.465-3.34009.465s-2.43114-.16201-3.34009-.465c-.23939-.07979-.46137-.17027-.65991-.27201v2.23701l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40181.77887.2596 1.86698.4137 3.02387.4137s2.245-.1541 3.02387-.4137c.39108-.13034.65958-.27262.82012-.40181.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00076-.00255.00103-.00409.00113-.00474l.00007-.00065z" fill="#000" fill-rule="evenodd"/></svg> \ No newline at end of file diff --git a/core/modules/navigation/components/title/assets/file.svg b/core/modules/navigation/components/title/assets/file.svg new file mode 100644 index 000000000000..655ae03b145c --- /dev/null +++ b/core/modules/navigation/components/title/assets/file.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z"></path></svg> diff --git a/core/modules/navigation/components/title/title.component.yml b/core/modules/navigation/components/title/title.component.yml new file mode 100644 index 000000000000..696960d455f5 --- /dev/null +++ b/core/modules/navigation/components/title/title.component.yml @@ -0,0 +1,54 @@ +# This is so your IDE knows about the syntax for fixes and autocomplete. +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json + +# The human readable name. +name: Title + +# Status can be: "experimental", "stable", "deprecated", "obsolete". +status: experimental + +# Schema for the props. We support www.json-schema.org. Learn more about the +# syntax there. +props: + type: object + properties: + modifiers: + type: array + title: Modifier classes. + description: + Title modifiers. + https://en.bem.info/methodology/css/#modifiers + items: + type: string + enum: + - ellipsis + - xs + extra_classes: + type: array + title: Extra classes. + description: + External modifiers added from the placement context. + https://en.bem.info/methodology/css/#mixes + items: + type: string + html_tag: + type: string + title: HTML tag for title + # Limit the available options by using enums. + enum: + - h1 + - h2 + - h3 + - h4 + - h5 + - h6 + - span + # Provide a default value + default: h2 + icon: + title: Icon + type: string +slots: + content: + title: Content + description: Content of title. diff --git a/core/modules/navigation/components/title/title.css b/core/modules/navigation/components/title/title.css new file mode 100644 index 000000000000..ac78c925f14a --- /dev/null +++ b/core/modules/navigation/components/title/title.css @@ -0,0 +1,52 @@ +/* + * DO NOT EDIT THIS FILE. + * See the following change record for more information, + * https://www.drupal.org/node/3084859 + * @preserve + */ +/* cspell:ignore csvg cpath wght */ +/** + * @file + * Toolbar title styles. + */ +.toolbar-title { + font-variation-settings: "wght" 500; +} +/* Sizes aligned with variables from css/base/variables.pcss.css */ +.toolbar-title--xs { + font-size: var(--admin-toolbar-font-size-heading-xs); +} +.toolbar-title--ellipsis .toolbar-title__label { + overflow: hidden; + max-width: var(--toolbar--title-max-width); + white-space: nowrap; + text-overflow: ellipsis; +} +/* Class starts with `toolbar-title--icon` */ +[class*="toolbar-title--icon"] { + display: flex; + align-items: center; + gap: var(--admin-toolbar-space-8); +} +[class*="toolbar-title--icon"]::before { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + margin-top: 1px; + content: ""; + color: currentColor; + background-color: currentColor; + inline-size: var(--admin-toolbar-space-16); + block-size: var(--admin-toolbar-space-16); + mask-repeat: no-repeat; + mask-position: center center; + mask-size: 100% auto; + mask-image: var(--icon); +} +.toolbar-title--icon--file { + --icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' fill='%23000000' viewBox='0 0 256 256'%3e%3cpath d='M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z'%3e%3c/path%3e%3c/svg%3e"); +} +.toolbar-title--icon--database { + --icon: url("data:image/svg+xml,%3csvg fill='none' height='12' viewBox='0 0 12 12' width='12' xmlns='http://www.w3.org/2000/svg'%3e%3cpath clip-rule='evenodd' d='m2 2.5c0 .00062.00009.00292.00153.00765.00169.00557.00588.01658.01601.03307.02122.03457.0645.08654.14558.15044.16509.13011.43496.26914.81301.39516.75088.25029 1.82002.41368 3.02387.41368 1.20386 0 2.27299-.16339 3.02387-.41368.37805-.12602.64792-.26505.81301-.39516.08108-.0639.12436-.11587.14558-.15044.01013-.01649.01431-.0275.01601-.03307.00146-.00479.00153-.00695.00153-.00754 0-.00003 0 .00002 0 0 0-.00036 0-.00258-.00153-.00776-.0017-.00557-.00588-.01658-.01601-.03307-.02122-.03457-.0645-.08654-.14558-.15044-.16509-.13011-.43496-.26914-.81301-.39516-.75088-.25029-1.82001-.41368-3.02387-.41368-1.20385 0-2.27299.16339-3.02387.41368-.37805.12602-.64792.26505-.81301.39516-.08108.0639-.12436.11587-.14558.15044-.01013.01649-.01432.0275-.01601.03307-.00154.00504-.00153.00721-.00153.00765zm8 1.2633c-.20061.10259-.42333.19284-.65991.2717-.8778.2926-2.05866.465-3.34009.465s-2.46229-.1724-3.34009-.465c-.23658-.07886-.4593-.16911-.65991-.2717v2.2367l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40183.77887.25962 1.86698.41368 3.02387.41368s2.245-.15406 3.02387-.41368c.39108-.13036.65958-.27264.82012-.40183.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00047-.00159.00076-.00279.00093-.00362.0001-.00051.00016-.00088.0002-.00112l.00007-.00065zm1-1.2633c0-.42597-.2594-.75221-.5441-.97657-.2904-.22881-.67952-.413-1.11581-.558432-.8778-.2926-2.05866-.464998-3.34009-.464998s-2.46229.172398-3.34009.464998c-.43629.145432-.82543.329622-1.11576.558432-.2847.22436-.54415.5506-.54415.97657v7c0 .41262.2462.7359.52907.9636.28749.2313.678.4205 1.13084.5714.90895.303 2.11003.465 3.34009.465s2.43114-.162 3.34009-.465c.45283-.1509.84331-.3401 1.13081-.5714.2829-.2277.5291-.55098.5291-.9636zm-1 4.76299c-.19854.10174-.42052.19222-.65991.27201-.90895.30299-2.11003.465-3.34009.465s-2.43114-.16201-3.34009-.465c-.23939-.07979-.46137-.17027-.65991-.27201v2.23701l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40181.77887.2596 1.86698.4137 3.02387.4137s2.245-.1541 3.02387-.4137c.39108-.13034.65958-.27262.82012-.40181.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00076-.00255.00103-.00409.00113-.00474l.00007-.00065z' fill='%23000' fill-rule='evenodd'/%3e%3c/svg%3e"); +} diff --git a/core/modules/navigation/components/title/title.pcss.css b/core/modules/navigation/components/title/title.pcss.css new file mode 100644 index 000000000000..52b098fb0cca --- /dev/null +++ b/core/modules/navigation/components/title/title.pcss.css @@ -0,0 +1,54 @@ +/* cspell:ignore csvg cpath wght */ +/** + * @file + * Toolbar title styles. + */ + +.toolbar-title { + font-variation-settings: "wght" 500; +} + +/* Sizes aligned with variables from css/base/variables.pcss.css */ +.toolbar-title--xs { + font-size: var(--admin-toolbar-font-size-heading-xs); +} + +.toolbar-title--ellipsis { + .toolbar-title__label { + overflow: hidden; + max-width: var(--toolbar--title-max-width); + white-space: nowrap; + text-overflow: ellipsis; + } +} + +/* Class starts with `toolbar-title--icon` */ +[class*="toolbar-title--icon"] { + display: flex; + align-items: center; + gap: var(--admin-toolbar-space-8); + + &::before { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + margin-top: 1px; + content: ""; + color: currentColor; + background-color: currentColor; + inline-size: var(--admin-toolbar-space-16); + block-size: var(--admin-toolbar-space-16); + mask-repeat: no-repeat; + mask-position: center center; + mask-size: 100% auto; + mask-image: var(--icon); + } +} + +.toolbar-title--icon--file { + --icon: url(./assets/file.svg); +} +.toolbar-title--icon--database { + --icon: url(./assets/database.svg); +} diff --git a/core/modules/navigation/components/title/title.twig b/core/modules/navigation/components/title/title.twig new file mode 100644 index 000000000000..169ebf2fddd1 --- /dev/null +++ b/core/modules/navigation/components/title/title.twig @@ -0,0 +1,21 @@ +{% + set classes = [ + 'toolbar-title', + icon ? 'toolbar-title--icon--' ~ icon : '', + ] +%} +{% if modifiers is iterable %} + {% set classes = classes|merge(modifiers|map(modifier => "toolbar-title--#{modifier}")) %} +{% endif %} + +{% if extra_classes is iterable %} + {% set classes = classes|merge(extra_classes) %} +{% endif %} + +<{{html_tag|default('h2')}}{{attributes.addClass(classes)}}> + <span class="toolbar-title__label"> + {% block content %} + {{ content }} + {% endblock %} + </span> +</{{html_tag|default('h2')}}> diff --git a/core/modules/navigation/components/toolbar-button/toolbar-button.css b/core/modules/navigation/components/toolbar-button/toolbar-button.css index 439287b8b85d..3ee7973abef1 100644 --- a/core/modules/navigation/components/toolbar-button/toolbar-button.css +++ b/core/modules/navigation/components/toolbar-button/toolbar-button.css @@ -27,9 +27,8 @@ } .toolbar-button { z-index: 1; + flex-grow: 0; align-items: center; - padding-inline: var(--admin-toolbar-space-16); - padding-block: var(--admin-toolbar-space-10); min-height: var(--admin-toolbar-space-40); cursor: pointer; text-align: start; @@ -41,8 +40,10 @@ border-radius: var(--admin-toolbar-space-8); background-color: var(--toolbar-button-bg); font-size: var(--admin-toolbar-font-size-info-sm); - font-variation-settings: "wght" 700; line-height: var(--admin-toolbar-line-height-info-sm); + padding-inline: var(--admin-toolbar-space-16); + padding-block: var(--admin-toolbar-space-10); + font-variation-settings: "wght" 700; gap: calc(0.5 * var(--admin-toolbar-rem)); } .toolbar-button:has(+ .toolbar-popover__wrapper .is-active) { diff --git a/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css b/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css index 26726918ad24..8adb302c6d58 100644 --- a/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css +++ b/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css @@ -25,9 +25,8 @@ .toolbar-button { z-index: 1; + flex-grow: 0; align-items: center; - padding-inline: var(--admin-toolbar-space-16); - padding-block: var(--admin-toolbar-space-10); min-height: var(--admin-toolbar-space-40); cursor: pointer; text-align: start; @@ -38,8 +37,10 @@ border-radius: var(--admin-toolbar-space-8); background-color: var(--toolbar-button-bg); font-size: var(--admin-toolbar-font-size-info-sm); - font-variation-settings: "wght" 700; line-height: var(--admin-toolbar-line-height-info-sm); + padding-inline: var(--admin-toolbar-space-16); + padding-block: var(--admin-toolbar-space-10); + font-variation-settings: "wght" 700; gap: calc(0.5 * var(--admin-toolbar-rem)); &:has(+ .toolbar-popover__wrapper .is-active) { diff --git a/core/modules/navigation/css/components/top-bar.css b/core/modules/navigation/css/components/top-bar.css index 9e0eb2f928bf..65a489cd78b7 100644 --- a/core/modules/navigation/css/components/top-bar.css +++ b/core/modules/navigation/css/components/top-bar.css @@ -46,6 +46,7 @@ } .top-bar__actions { display: flex; + justify-content: end; gap: 0.5rem; } @media (min-width: 64rem) { @@ -56,9 +57,9 @@ } .top-bar__content { display: grid; - grid-auto-flow: column; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: space-between; + justify-content: center; gap: var(--admin-toolbar-space-16); width: 100%; } @@ -73,9 +74,13 @@ } .top-bar__context { display: flex; - gap: 0.5rem; + flex-wrap: wrap; align-items: center; - justify-content: start; + justify-content: center; + gap: var(--admin-toolbar-space-20); +} +.top-bar__title { + --toolbar--title-max-width: 40ch; } .top-bar__tools { display: flex; diff --git a/core/modules/navigation/css/components/top-bar.pcss.css b/core/modules/navigation/css/components/top-bar.pcss.css index 3a1c5cfb254e..b55604dfa569 100644 --- a/core/modules/navigation/css/components/top-bar.pcss.css +++ b/core/modules/navigation/css/components/top-bar.pcss.css @@ -45,6 +45,7 @@ .top-bar__actions { display: flex; + justify-content: end; gap: 0.5rem; @media (--admin-toolbar-desktop) { @@ -55,9 +56,9 @@ .top-bar__content { display: grid; - grid-auto-flow: column; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: space-between; + justify-content: center; gap: var(--admin-toolbar-space-16); width: 100%; } @@ -73,9 +74,14 @@ .top-bar__context { display: flex; - gap: 0.5rem; + flex-wrap: wrap; align-items: center; - justify-content: start; + justify-content: center; + gap: var(--admin-toolbar-space-20); +} + +.top-bar__title { + --toolbar--title-max-width: 40ch; } .top-bar__tools { diff --git a/core/modules/navigation/navigation.services.yml b/core/modules/navigation/navigation.services.yml index 871b4b6adba7..4a6b77128eec 100644 --- a/core/modules/navigation/navigation.services.yml +++ b/core/modules/navigation/navigation.services.yml @@ -9,7 +9,6 @@ services: '@module_handler', '@current_route_match', '@plugin.manager.menu.local_task', - '@entity_type.manager', '@image.factory', '@file_url_generator', '@plugin.manager.layout_builder.section_storage', @@ -17,6 +16,7 @@ services: '@extension.list.module', '@current_user', '%renderer.config%', + '@navigation.entity_route_helper', ] Drupal\navigation\NavigationRenderer: '@navigation.renderer' @@ -31,6 +31,16 @@ services: '@callable_resolver', ] + navigation.entity_route_helper: + class: Drupal\navigation\EntityRouteHelper + arguments: + [ + '@current_route_match', + '@entity_type.manager', + '@cache.discovery', + ] + Drupal\navigation\EntityRouteHelper: '@navigation.entity_route_helper' + navigation.user_lazy_builder: class: Drupal\navigation\UserLazyBuilder arguments: ['@current_user'] diff --git a/core/modules/navigation/src/EntityRouteHelper.php b/core/modules/navigation/src/EntityRouteHelper.php new file mode 100644 index 000000000000..104e08b7fcf5 --- /dev/null +++ b/core/modules/navigation/src/EntityRouteHelper.php @@ -0,0 +1,141 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation; + +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Routing\CurrentRouteMatch; + +/** + * Helper service to detect entity routes. + * + * @internal + */ +class EntityRouteHelper { + + const ENTITY_ROUTE_CID = 'navigation_content_entity_paths'; + + /** + * A list of all the link paths of enabled content entities. + * + * @var array + */ + protected array $contentEntityPaths; + + /** + * EntityRouteHelper constructor. + * + * @param \Drupal\Core\Routing\CurrentRouteMatch $routeMatch + * The route match. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend + * The cache backend. + */ + public function __construct( + protected CurrentRouteMatch $routeMatch, + protected EntityTypeManagerInterface $entityTypeManager, + protected CacheBackendInterface $cacheBackend, + ) { + } + + /** + * Determines if content entity route condition is met. + * + * @return bool + * TRUE if the content entity route condition is met, FALSE otherwise. + */ + public function isContentEntityRoute(): bool { + return array_key_exists($this->routeMatch->getRouteObject()->getPath(), $this->getContentEntityPaths()); + } + + public function getContentEntityFromRoute(): ?ContentEntityInterface { + $path = $this->routeMatch->getRouteObject()->getPath(); + if (!$entity_type = $this->getContentEntityPaths()[$path] ?? NULL) { + return NULL; + } + + $entity = $this->routeMatch->getParameter($entity_type); + if ($entity instanceof ContentEntityInterface && $entity->getEntityTypeId() === $entity_type) { + return $entity; + } + + return NULL; + } + + /** + * Returns the paths for the link templates of all content entities. + * + * @return array + * An array of all content entity type IDs, keyed by the corresponding link + * template paths. + */ + protected function getContentEntityPaths(): array { + if (isset($this->contentEntityPaths)) { + return $this->contentEntityPaths; + } + + $content_entity_paths = $this->cacheBackend->get(static::ENTITY_ROUTE_CID); + + if (isset($content_entity_paths->data)) { + $this->contentEntityPaths = $content_entity_paths->data; + return $this->contentEntityPaths; + } + + $this->contentEntityPaths = $this->doGetContentEntityPaths(); + $this->cacheBackend->set(static::ENTITY_ROUTE_CID, $this->contentEntityPaths, CacheBackendInterface::CACHE_PERMANENT, ['entity_types', 'routes']); + + return $this->contentEntityPaths; + } + + protected function doGetContentEntityPaths(): array { + $content_entity_paths = []; + $entity_types = $this->entityTypeManager->getDefinitions(); + foreach ($entity_types as $entity_type) { + if ($entity_type->entityClassImplements(ContentEntityInterface::class)) { + $entity_paths = $this->getContentEntityTypePaths($entity_type); + $content_entity_paths = array_merge($content_entity_paths, $entity_paths); + } + } + + return $content_entity_paths; + } + + /** + * Returns the path for the link template for a given content entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + * + * @return array + * Array containing the paths for the given content entity type. + */ + protected function getContentEntityTypePaths(EntityTypeInterface $entity_type): array { + $paths = array_filter($entity_type->getLinkTemplates(), fn ($template) => $template !== 'collection', ARRAY_FILTER_USE_KEY); + if ($this->isLayoutBuilderEntityType($entity_type)) { + $paths[] = $entity_type->getLinkTemplate('canonical') . '/layout'; + } + return array_fill_keys($paths, $entity_type->id()); + } + + /** + * Determines if a given entity type is layout builder relevant or not. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return bool + * Whether this entity type is a Layout builder candidate or not + * + * @see \Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage::getEntityTypes() + */ + protected function isLayoutBuilderEntityType(EntityTypeInterface $entity_type): bool { + return $entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasHandlerClass('form', 'layout_builder') && $entity_type->hasViewBuilderClass() && $entity_type->hasLinkTemplate('canonical'); + } + +} diff --git a/core/modules/navigation/src/NavigationRenderer.php b/core/modules/navigation/src/NavigationRenderer.php index 5c0f40b8a72e..753f53bb937e 100644 --- a/core/modules/navigation/src/NavigationRenderer.php +++ b/core/modules/navigation/src/NavigationRenderer.php @@ -5,13 +5,9 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Component\Utility\SortArray; use Drupal\Core\Block\BlockPluginInterface; -use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Config\ConfigFactoryInterface; -use Drupal\Core\Entity\ContentEntityInterface; -use Drupal\Core\Entity\EntityTypeInterface; -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\File\FileUrlGeneratorInterface; @@ -48,13 +44,6 @@ final class NavigationRenderer { */ const LOGO_PROVIDER_CUSTOM = 'custom'; - /** - * A list of all the link paths of enabled content entities. - * - * @var array - */ - protected array $contentEntityPaths; - /** * The navigation local tasks render array. * @@ -70,7 +59,6 @@ public function __construct( private ModuleHandlerInterface $moduleHandler, private RouteMatchInterface $routeMatch, private LocalTaskManagerInterface $localTaskManager, - private EntityTypeManagerInterface $entityTypeManager, private ImageFactory $imageFactory, private FileUrlGeneratorInterface $fileUrlGenerator, private SectionStorageManagerInterface $sectionStorageManager, @@ -78,6 +66,7 @@ public function __construct( private ModuleExtensionList $moduleExtensionList, private AccountInterface $currentUser, private array $rendererConfig, + private EntityRouteHelper $entityRouteHelper, ) {} /** @@ -271,7 +260,7 @@ public function getLocalTasks(): array { ]; // For now, we're only interested in local tasks corresponding to a content // entity. - if (!$this->meetsContentEntityRoutesCondition()) { + if (!$this->entityRouteHelper->isContentEntityRoute()) { return $this->localTasks; } $entity_local_tasks = $this->localTaskManager->getLocalTasks($this->routeMatch->getRouteName()); @@ -315,70 +304,4 @@ public function hasLocalTasks(): bool { return !empty($local_tasks['tasks']); } - /** - * Determines if content entity route condition is met. - * - * @return bool - * TRUE if the content entity route condition is met, FALSE otherwise. - */ - protected function meetsContentEntityRoutesCondition(): bool { - return array_key_exists($this->routeMatch->getRouteObject()->getPath(), $this->getContentEntityPaths()); - } - - /** - * Returns the paths for the link templates of all content entities. - * - * @return array - * An array of all content entity type IDs, keyed by the corresponding link - * template paths. - */ - protected function getContentEntityPaths(): array { - if (isset($this->contentEntityPaths)) { - return $this->contentEntityPaths; - } - - $this->contentEntityPaths = []; - $entity_types = $this->entityTypeManager->getDefinitions(); - foreach ($entity_types as $entity_type) { - if ($entity_type->entityClassImplements(ContentEntityInterface::class)) { - $entity_paths = $this->getContentEntityTypePaths($entity_type); - $this->contentEntityPaths = array_merge($this->contentEntityPaths, $entity_paths); - } - } - - return $this->contentEntityPaths; - } - - /** - * Returns the path for the link template for a given content entity type. - * - * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type - * The entity type definition. - * - * @return array - * Array containing the paths for the given content entity type. - */ - protected function getContentEntityTypePaths(EntityTypeInterface $entity_type): array { - $paths = array_filter($entity_type->getLinkTemplates(), fn ($template) => $template !== 'collection', ARRAY_FILTER_USE_KEY); - if ($this->isLayoutBuilderEntityType($entity_type)) { - $paths[] = $entity_type->getLinkTemplate('canonical') . '/layout'; - } - return array_fill_keys($paths, $entity_type->id()); - } - - /** - * Determines if a given entity type is layout builder relevant or not. - * - * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type - * The entity type. - * - * @return bool - * Whether this entity type is a Layout builder candidate or not - * - * @see \Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage::getEntityTypes() - */ - protected function isLayoutBuilderEntityType(EntityTypeInterface $entity_type): bool { - return $entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasHandlerClass('form', 'layout_builder') && $entity_type->hasViewBuilderClass() && $entity_type->hasLinkTemplate('canonical'); - } - } diff --git a/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php b/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php new file mode 100644 index 000000000000..47cad358b4a9 --- /dev/null +++ b/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation\Plugin\TopBarItem; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityPublishedInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\navigation\Attribute\TopBarItem; +use Drupal\navigation\EntityRouteHelper; +use Drupal\navigation\TopBarItemBase; +use Drupal\navigation\TopBarRegion; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides the Page Context top bar item. + */ +#[TopBarItem( + id: 'page_context', + region: TopBarRegion::Context, + label: new TranslatableMarkup('Page Context'), +)] +class PageContext extends TopBarItemBase implements ContainerFactoryPluginInterface { + + use StringTranslationTrait; + + /** + * Constructs a new PageContext instance. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin ID for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager service. + * @param \Drupal\navigation\EntityRouteHelper $entityRouteHelper + * The entity route helper service. + */ + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + private EntityTypeManagerInterface $entityTypeManager, + private EntityRouteHelper $entityRouteHelper, + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get(EntityTypeManagerInterface::class), + $container->get(EntityRouteHelper::class) + ); + } + + /** + * {@inheritdoc} + */ + public function build(): array { + $build = [ + '#cache' => [ + 'contexts' => ['route'], + ], + ]; + + if (!$entity = $this->entityRouteHelper->getContentEntityFromRoute()) { + return $build; + } + + $build += [ + [ + '#type' => 'component', + '#component' => 'navigation:title', + '#props' => [ + 'icon' => 'database', + 'html_tag' => 'span', + 'modifiers' => ['ellipsis', 'xs'], + 'extra_classes' => ['top-bar__title'], + ], + '#slots' => [ + 'content' => $entity->label(), + ], + ], + ]; + + if ($label = $this->getBadgeLabel($entity)) { + $build += [ + '#type' => 'component', + '#component' => 'navigation:badge', + '#props' => [ + 'status' => $this->getBadgeStatus($entity) ?? 'info', + ], + '#slots' => [ + 'label' => $label, + ], + ]; + } + + return $build; + } + + /** + * Retrieves the badge label for the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which the label is being retrieved. + * + * @return string|null + * The translated status if available. NULL otherwise. + * The status if available. NULL otherwise. + */ + protected function getBadgeLabel(EntityInterface $entity): ?string { + if (!$entity instanceof EntityPublishedInterface) { + return NULL; + } + return (string) ($entity->isPublished() ? $this->t('Published') : $this->t('Unpublished')); + } + + /** + * Retrieves the badge status for the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which the status is being retrieved. + * + * @return string|null + * The badge status if available. NULL otherwise. + */ + protected function getBadgeStatus(EntityInterface $entity): ?string { + if (!$entity instanceof EntityPublishedInterface) { + return NULL; + } + return $entity->isPublished() ? 'success' : 'info'; + } + +} diff --git a/core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php b/core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php new file mode 100644 index 000000000000..800106b1e0f4 --- /dev/null +++ b/core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\navigation\Functional; + +use Drupal\Core\Url; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\node\Traits\ContentTypeCreationTrait; +use Drupal\Tests\node\Traits\NodeCreationTrait; +use Drupal\user\UserInterface; + +/** + * Tests the PageContext top bar item functionality. + * + * @group navigation + */ +class NavigationTopBarPageContextTest extends BrowserTestBase { + + use ContentTypeCreationTrait; + use NodeCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'node', + 'navigation', + 'navigation_top_bar', + 'test_page_test', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * An admin user to configure the test environment. + * + * @var \Drupal\user\UserInterface + */ + protected UserInterface $adminUser; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Create and log in an administrative user. + $this->adminUser = $this->drupalCreateUser([ + 'access navigation', + 'bypass node access', + ]); + $this->drupalLogin($this->adminUser); + + // Ensure the 'article' content type exists. + $this->createContentType(['type' => 'article', 'name' => 'Article']); + } + + /** + * Tests the PageContext top bar item output for a published node. + */ + public function testPageContextTopBarItemNode(): void { + // Create a published node entity. + $node = $this->createNode([ + 'type' => 'article', + 'title' => 'No easy twist on the bow', + 'status' => 1, + 'uid' => $this->adminUser->id(), + ]); + + $test_page_url = Url::fromRoute('test_page_test.test_page'); + $this->drupalGet($test_page_url); + // Ensure the top bar item is not present. + $this->assertSession()->elementNotExists('css', '.top-bar .top-bar__context .toolbar-title'); + + // Test the PageContext output for the published node. + $this->drupalGet($node->toUrl()); + // Ensure the top bar exists and is valid. + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow'); + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Published'); + $this->drupalGet($node->toUrl('edit-form')); + // Ensure the top bar exists and is valid. + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow'); + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Published'); + + // Unpublish the node. + $node->setUnpublished(); + $node->save(); + + // Test the PageContext output for the unpublished node. + $this->drupalGet($node->toUrl()); + // Ensure the top bar exists and is valid. + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow'); + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Unpublished'); + $this->drupalGet($node->toUrl('edit-form')); + // Ensure the top bar exists and is valid. + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow'); + $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Unpublished'); + } + +} diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php index 7a4d2b68b40b..44d172b1cad9 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php @@ -73,11 +73,11 @@ public function testLogin(): void { $expected = [ 'QueryCount' => 4, - 'CacheGetCount' => 60, + 'CacheGetCount' => 61, 'CacheSetCount' => 2, 'CacheDeleteCount' => 0, - 'CacheTagChecksumCount' => 2, - 'CacheTagIsValidCount' => 29, + 'CacheTagChecksumCount' => 3, + 'CacheTagIsValidCount' => 31, 'CacheTagInvalidationCount' => 0, 'ScriptCount' => 2, 'ScriptBytes' => 215500, -- GitLab