diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js index 6e88a9980969357038bb05c33d0657e37d014bcb..06a34197faf6336ae0e37872b100ff9506665b47 100644 --- a/app/assets/javascripts/entrypoints/super_sidebar.js +++ b/app/assets/javascripts/entrypoints/super_sidebar.js @@ -1,6 +1,11 @@ import '~/webpack'; import '~/commons'; -import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle'; +import { + initSuperSidebar, + initSuperSidebarToggle, + initPageBreadcrumbs, +} from '~/super_sidebar/super_sidebar_bundle'; initSuperSidebar(); initSuperSidebarToggle(); +initPageBreadcrumbs(); diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 9ecbc8615bed1d748e52d14dbe60cdd1ab35df47..66d7284744ddfa14ba5e4a6a6f2a07a57a8c3ded 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { GlToast } from '@gitlab/ui'; +import { GlBreadcrumb, GlToast } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import createDefaultClient from '~/lib/graphql'; @@ -167,3 +167,24 @@ export const initSuperSidebarToggle = () => { }, }); }; + +export function initPageBreadcrumbs() { + const el = document.querySelector('#js-vue-page-breadcrumbs'); + if (!el) return false; + const { breadcrumbsJson } = el.dataset; + + const props = { + items: JSON.parse(breadcrumbsJson), + }; + + return new Vue({ + el, + render(h) { + return h(GlBreadcrumb, { + props, + attrs: { 'data-testid': 'breadcrumb-links' }, + class: 'gl-flex-grow-1', + }); + }, + }); +} diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb index 6baa085e3b47b4cfed9c6dda1a1d15040d54a524..f7bf9cd04c79abad1559e2a7e91f3e8c9abd9355 100644 --- a/app/components/pajamas/avatar_component.rb +++ b/app/components/pajamas/avatar_component.rb @@ -9,8 +9,8 @@ def name class AvatarComponent < Pajamas::Component include Gitlab::Utils::StrongMemoize - # @param item [User, Project, Group, AvatarEmail] - # @param alt [String] text for the alt tag + # @param item [User, Project, Group, AvatarEmail, String] + # @param alt [String] text for the alt attribute # @param class [String] custom CSS class(es) # @param size [Integer] size in pixel # @param [Hash] avatar_options @@ -28,8 +28,11 @@ def initialize(item, alt: nil, class: "", size: 64, avatar_options: {}) def avatar_classes classes = ["gl-avatar", "gl-avatar-s#{@size}", @class] - classes.push("gl-avatar-circle") if @item.is_a?(User) || @item.is_a?(AvatarEmail) - classes.push("gl-rounded-base!") if @item.is_a?(Project) || @item.is_a?(Group) + if @item.is_a?(User) || @item.is_a?(AvatarEmail) + classes.push("gl-avatar-circle") + else + classes.push("gl-rounded-base!") + end unless src classes.push("gl-avatar-identicon") @@ -41,7 +44,9 @@ def avatar_classes def src strong_memoize(:src) do - if @item.is_a?(User) + if @item.is_a?(String) + @item + elsif @item.is_a?(User) # Users show a gravatar instead of an identicon. Also avatars of # blocked users are only shown if the current_user is an admin. # To not duplicate this logic, we are using existing helpers here. @@ -67,7 +72,7 @@ def srcset end def alt - @alt || @item.name + @alt || @item.try(:name) end def initial diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb index 47f0d19bd3405fecb7422d4f3e414ebc4b7b9935..7026063df44a9d7a31dc4adb8bed811799f7b2d5 100644 --- a/app/helpers/breadcrumbs_helper.rb +++ b/app/helpers/breadcrumbs_helper.rb @@ -31,26 +31,31 @@ def add_to_breadcrumb_collapsed_links(link, location: :before) @breadcrumb_collapsed_links[location] << link end - def push_to_schema_breadcrumb(text, link, avatar = nil) - list_item = schema_list_item(text, link, schema_breadcrumb_list.size + 1, avatar) - - schema_breadcrumb_list.push(list_item) + def push_to_schema_breadcrumb(text, href, avatar = nil) + schema_breadcrumb_list.push({ text: text, href: href, avatar: avatar }) end def schema_breadcrumb_json { '@context': 'https://schema.org', '@type': 'BreadcrumbList', - 'itemListElement': build_item_list_elements&.map { |item| item.except('avatar') } + 'itemListElement': build_item_list_elements&.map&.with_index do |item, index| + { + '@type' => 'ListItem', + 'position' => index + 1, + 'name' => item[:text], + 'item' => ensure_absolute_url(item[:href]) + } + end }.to_json end def breadcrumbs_as_json schema_breadcrumb_list.map do |breadcrumb| { - text: breadcrumb['name'], - href: breadcrumb['item'], - avatarPath: breadcrumb['avatar'] + text: breadcrumb[:text], + href: breadcrumb[:href], + avatarPath: breadcrumb[:avatar] } end.to_json end @@ -62,29 +67,25 @@ def schema_breadcrumb_list end def build_item_list_elements - return @schema_breadcrumb_list unless @breadcrumbs_extra_links&.any? - last_element = schema_breadcrumb_list.pop - @breadcrumbs_extra_links.each do |el| - push_to_schema_breadcrumb(el[:text], el[:link]) + if @breadcrumbs_extra_links&.any? + @breadcrumbs_extra_links.each do |el| + push_to_schema_breadcrumb(el[:text], el[:link]) + end end - last_element['position'] = schema_breadcrumb_list.last['position'] + 1 - schema_breadcrumb_list.push(last_element) - end + if @breadcrumb_collapsed_links&.[](:after)&.any? + @breadcrumb_collapsed_links[:after].each do |el| + push_to_schema_breadcrumb(el[:text], el[:href], el[:avatar_url]) + end + end - def schema_list_item(text, link, position, avatar = nil) - { - '@type' => 'ListItem', - 'position' => position, - 'name' => text, - 'item' => ensure_absolute_link(link), - 'avatar' => avatar - } + schema_breadcrumb_list.push(last_element) if last_element + schema_breadcrumb_list end - def ensure_absolute_link(link) + def ensure_absolute_url(link) url = URI.parse(link) url.absolute? ? link : URI.join(request.base_url, link).to_s rescue URI::InvalidURIError diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index cb600f5a03b30dd8e93b472a69b25fff4bc7e5ae..1e3009d2e8cb23df1d9d30131b51f7136bc9d8a3 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -54,7 +54,10 @@ def group_title(group) sorted_ancestors(group).with_route.reverse_each.with_index do |parent, index| if index > 0 - add_to_breadcrumb_collapsed_links(group_title_link(parent), location: :before) + add_to_breadcrumb_collapsed_links( + { text: simple_sanitize(parent.name), href: group_path(parent), avatar_url: parent.try(:avatar_url) }, + location: :before + ) else full_title << breadcrumb_list_item(group_title_link(parent, hidable: false)) end diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 9b7171b9842bd084e4519ee70698a0c1f50ec8bc..32643976df6bc245ccec7bfeaec17d0044b13b54 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -44,7 +44,10 @@ def wiki_breadcrumb_collapsed_links(page_slug) page_slug_split .map do |dir_or_page| current_slug = "#{current_slug}#{dir_or_page}/" - add_to_breadcrumb_collapsed_links link_to(WikiPage.unhyphenize(dir_or_page).capitalize, wiki_page_path(@wiki, current_slug)), location: :after + add_to_breadcrumb_collapsed_links( + { text: WikiPage.unhyphenize(dir_or_page).capitalize, href: wiki_page_path(@wiki, current_slug) }, + location: :after + ) end end diff --git a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml index 11b765c3ceb5c2cf3ce7dc26588d8479385e6742..33a22dc870fcd0d6de6a75148f2ad620d2abf16b 100644 --- a/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml @@ -4,22 +4,25 @@ - unless @skip_current_level_breadcrumb - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) -%nav.breadcrumbs.gl-breadcrumbs.tmp-breadcrumbs-fix{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links' } } - %ul.breadcrumb.gl-breadcrumb-list.js-breadcrumbs-list.gl-flex-grow-1 - - unless hide_top_links - = header_title - - if @breadcrumbs_extra_links - - @breadcrumbs_extra_links.each do |extra| - = breadcrumb_list_item link_to(extra[:text], extra[:link]) - = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after - - unless @skip_current_level_breadcrumb - %li.gl-breadcrumb-item{ data: { testid: 'breadcrumb-current-link' } } - = link_to(@breadcrumb_title, breadcrumb_title_link) - -# haml-lint:disable InlineJavaScript - %script{ type: 'application/ld+json' } - :plain - #{schema_breadcrumb_json} -= yield :header_content +-# See https://schema.org/BreadcrumbList +-# haml-lint:disable InlineJavaScript +%script{ type: 'application/ld+json' } + :plain + #{schema_breadcrumb_json} - if Feature.enabled?(:vue_page_breadcrumbs) #js-vue-page-breadcrumbs{ data: { breadcrumbs_json: breadcrumbs_as_json } } +- else + %nav.breadcrumbs.gl-breadcrumbs.tmp-breadcrumbs-fix{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links' } } + %ul.breadcrumb.gl-breadcrumb-list.js-breadcrumbs-list.gl-flex-grow-1 + - unless hide_top_links + = header_title + - if @breadcrumbs_extra_links + - @breadcrumbs_extra_links.each do |extra| + = breadcrumb_list_item link_to(extra[:text], extra[:link]) + = render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after + - unless @skip_current_level_breadcrumb + %li.gl-breadcrumb-item{ data: { testid: 'breadcrumb-current-link' } } + = link_to(@breadcrumb_title, breadcrumb_title_link) + += yield :header_content diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml index 37bf8515a8c1558849d12094cf445f5519c240b6..c779bc98c7e3d7bb3426397f6a503de979d272e6 100644 --- a/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml +++ b/app/views/layouts/nav/breadcrumbs/_collapsed_inline_list.html.haml @@ -4,6 +4,9 @@ %li.expander.gl-breadcrumb-item.gl-display-inline-flex %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander.gl-ml-0{ type: "button", data: { container: "body" }, "aria-label": button_tooltip, title: button_tooltip } = sprite_icon("ellipsis_h", size: 12) - - @breadcrumb_collapsed_links[dropdown_location].each_with_index do |link, index| + - @breadcrumb_collapsed_links[dropdown_location].each_with_index do |item, index| %li.gl-breadcrumb-item{ :class => "gl-display-none!" } - = link + = link_to item[:href] do + - if item[:avatar_url] + = render Pajamas::AvatarComponent.new(item[:avatar_url], alt: item[:text], class: "avatar-tile", size: 16) + = item[:text] diff --git a/spec/components/pajamas/avatar_component_spec.rb b/spec/components/pajamas/avatar_component_spec.rb index 9c1a40ad5b5c7928a2491740cfb283bfecc35dcd..f148f62d1df76cbc6548c293e874148bb550a44c 100644 --- a/spec/components/pajamas/avatar_component_spec.rb +++ b/spec/components/pajamas/avatar_component_spec.rb @@ -50,6 +50,15 @@ end describe "avatar image" do + context "when src is a string" do + let(:item) { "https://uploads.example.com/avatars/123.png" } + + it "uses that string as image src" do + render_inline(described_class.new(item)) + expect(page).to have_css "img.gl-avatar[src='#{item}']" + end + end + context "when it has an uploaded image" do let(:item) { project } @@ -101,7 +110,7 @@ let(:item) { user } it "uses a gravatar" do - expect(rendered_content).to match /gravatar\.com/ + expect(rendered_content).to match(/gravatar\.com/) end end @@ -110,7 +119,7 @@ let(:item) { Pajamas::AvatarEmail.new('') } it "uses the default avatar" do - expect(rendered_content).to match /no_avatar/ + expect(rendered_content).to match(/no_avatar/) end end @@ -118,7 +127,7 @@ let(:item) { email } it "uses a agravatar" do - expect(rendered_content).to match /gravatar\.com/ + expect(rendered_content).to match(/gravatar\.com/) end end end diff --git a/spec/components/previews/pajamas/avatar_component_preview.rb b/spec/components/previews/pajamas/avatar_component_preview.rb index 147d89169b08046e86cdc31d8a48dd2daceef086..73ae0758aa53c50740eb0579fc3018fd398b7553 100644 --- a/spec/components/previews/pajamas/avatar_component_preview.rb +++ b/spec/components/previews/pajamas/avatar_component_preview.rb @@ -3,9 +3,17 @@ module Pajamas class AvatarComponentPreview < ViewComponent::Preview # Avatar # ---- - # See its design reference [here](https://design.gitlab.com/components/avatar). - def default - user + # See its Pajamas design reference [here](https://design.gitlab.com/components/avatar). + # + # The avatar component takes a single `item` param and a couple of optional arguments: + # - If the `item` is a plain `String`, this string will become the image `src`. In this case, also provide the + # `alt:` option, otherwise the resulting avatar image won't have an alt attribute. + # - If the `item` is a `User` object, the avatar will have a round shape. + # - For any other object (`Group`, `Project` etc) the shape will be rectangular with rounded corners. + # @param src text + # @param size select {{ Pajamas::AvatarComponent::SIZE_OPTIONS }} + def default(src: ActionController::Base.helpers.image_path('logo.svg'), size: 64) + render(Pajamas::AvatarComponent.new(src, size: size)) end # We show user avatars in a circle. diff --git a/spec/features/breadcrumbs_schema_markup_spec.rb b/spec/features/breadcrumbs_schema_markup_spec.rb index 6610519cd24dd23fa078d88c71d2f638c827c5f1..ca4a8e8ab864b9a3474b3a4827ef17667056ec23 100644 --- a/spec/features/breadcrumbs_schema_markup_spec.rb +++ b/spec/features/breadcrumbs_schema_markup_spec.rb @@ -9,6 +9,8 @@ let_it_be(:group) { create(:group, :public) } let_it_be(:subgroup) { create(:group, :public, parent: group) } let_it_be(:group_project) { create(:project, :public, namespace: subgroup) } + let_it_be(:wiki_home_page) { create(:wiki_page, project: project, title: 'home') } + let_it_be(:wiki_sub_page) { create(:wiki_page, project: project, title: 'home/subpage') } it 'generates the breadcrumb schema for user projects' do visit project_url(project) @@ -87,6 +89,28 @@ expect(item_list[3]['item']).to eq project_issue_url(project, issue) end + it 'generates the breadcrumb schema for wiki pages' do + visit project_wiki_path(project, wiki_sub_page) + + item_list = get_schema_content + + expect(item_list.size).to eq 5 + expect(item_list[0]['name']).to eq project.namespace.name + expect(item_list[0]['item']).to eq user_url(project.first_owner) + + expect(item_list[1]['name']).to eq project.name + expect(item_list[1]['item']).to eq project_url(project) + + expect(item_list[2]['name']).to eq 'Wiki' + expect(item_list[2]['item']).to eq project_wiki_url(project, wiki_home_page) + + expect(item_list[3]['name']).to eq 'Home' + expect(item_list[3]['item']).to eq "#{project_wiki_url(project, wiki_home_page)}/" + + expect(item_list[4]['name']).to eq 'subpage' + expect(item_list[4]['item']).to eq project_wiki_url(project, wiki_sub_page) + end + def get_schema_content content = find('script[type="application/ld+json"]', visible: false).text(:all) diff --git a/spec/helpers/breadcrumbs_helper_spec.rb b/spec/helpers/breadcrumbs_helper_spec.rb index e5968d22d63f3db8fb414486a794ab4df7828d20..73f1898c222325af3e28d7f2a63811a33db82352 100644 --- a/spec/helpers/breadcrumbs_helper_spec.rb +++ b/spec/helpers/breadcrumbs_helper_spec.rb @@ -17,43 +17,12 @@ subject { helper.push_to_schema_breadcrumb(element_name, link) } - it 'enqueue element name, link and position' do + it 'enqueues element name, link' do subject aggregate_failures do - expect(breadcrumb_list[0]['name']).to eq element_name - expect(breadcrumb_list[0]['item']).to eq link - expect(breadcrumb_list[0]['position']).to eq(1) - end - end - - context 'when link is relative' do - let(:link) { '/foo' } - - it 'converts the url into absolute' do - subject - - expect(breadcrumb_list[0]['item']).to eq "http://test.host#{link}" - end - end - - describe 'when link is invalid' do - let(:link) { 'invalid://foo[]' } - - it 'returns the current url' do - subject - - expect(breadcrumb_list[0]['item']).to eq 'http://test.host' - end - end - - describe 'when link is nil' do - let(:link) { nil } - - it 'returns the current url' do - subject - - expect(breadcrumb_list[0]['item']).to eq 'http://test.host' + expect(breadcrumb_list[0][:text]).to eq element_name + expect(breadcrumb_list[0][:href]).to eq link end end end @@ -86,6 +55,33 @@ expect(subject).to eq expected_result end + context 'when link is relative' do + let(:link) { '/foo' } + + it 'converts the url into absolute' do + add_to_breadcrumbs('no base', link) + expect(Gitlab::Json.parse(subject)['itemListElement'][0]['item']).to eq "http://test.host#{link}" + end + end + + describe 'when link is invalid' do + let(:link) { 'invalid://foo[]' } + + it 'returns the current url' do + add_to_breadcrumbs('invalid', link) + expect(Gitlab::Json.parse(subject)['itemListElement'][0]['item']).to eq 'http://test.host' + end + end + + describe 'when link is nil' do + let(:link) { nil } + + it 'returns the current url' do + add_to_breadcrumbs('nil', link) + expect(Gitlab::Json.parse(subject)['itemListElement'][0]['item']).to eq 'http://test.host' + end + end + context 'when extra breadcrumb element is added' do let(:extra_elements) do [ diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1e47810846810be8544996ec1db69737cf8371c4..6c4c7fc2d1fd76507da804b5da2142069dadfb6f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -339,6 +339,9 @@ # Experimental merge request dashboard stub_feature_flags(merge_request_dashboard: false) + + # Disable new Vue breadcrumbs while feature flag is still in wip state + stub_feature_flags(vue_page_breadcrumbs: false) else unstub_all_feature_flags end diff --git a/spec/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml_spec.rb b/spec/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml_spec.rb index 1436cde877bdc1ccbc6a627d1e284ec808b8dba9..283f971c139c6eeaa59a917a735124edb529586d 100644 --- a/spec/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml_spec.rb +++ b/spec/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml_spec.rb @@ -4,6 +4,10 @@ RSpec.describe 'layouts/nav/breadcrumbs/_breadcrumbs', feature_category: :navigation do describe 'element for Vue page breadcrumbs' do + before do + stub_feature_flags(vue_page_breadcrumbs: true) + end + context 'when the page has breadcrumbs' do let(:expected_json) do [