Commit b9693249 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '273751-fj-structured-data-for-groups' into 'master'

Resolve "Provide structured data for groups"

See merge request gitlab-org/gitlab!47374
parents 5cd7a57c 7b8fd9ef
...@@ -74,6 +74,9 @@ export default { ...@@ -74,6 +74,9 @@ export default {
visibilityTooltip() { visibilityTooltip() {
return GROUP_VISIBILITY_TYPE[this.group.visibility]; return GROUP_VISIBILITY_TYPE[this.group.visibility];
}, },
microdata() {
return this.group.microdata || {};
},
}, },
mounted() { mounted() {
if (this.group.name === 'Learn GitLab') { if (this.group.name === 'Learn GitLab') {
...@@ -99,7 +102,15 @@ export default { ...@@ -99,7 +102,15 @@ export default {
</script> </script>
<template> <template>
<li :id="groupDomId" :class="rowClass" class="group-row" @click.stop="onClickRowGroup"> <li
:id="groupDomId"
:class="rowClass"
class="group-row"
:itemprop="microdata.itemprop"
:itemtype="microdata.itemtype"
:itemscope="microdata.itemscope"
@click.stop="onClickRowGroup"
>
<div <div
:class="{ 'project-row-contents': !isGroup }" :class="{ 'project-row-contents': !isGroup }"
class="group-row-contents d-flex align-items-center py-2 pr-3" class="group-row-contents d-flex align-items-center py-2 pr-3"
...@@ -118,7 +129,13 @@ export default { ...@@ -118,7 +129,13 @@ export default {
class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 " class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 "
> >
<a :href="group.relativePath" class="no-expand"> <a :href="group.relativePath" class="no-expand">
<img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" /> <img
v-if="hasAvatar"
:src="group.avatarUrl"
data-testid="group-avatar"
class="avatar s40"
:itemprop="microdata.imageItemprop"
/>
<identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" /> <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" />
</a> </a>
</div> </div>
...@@ -127,9 +144,11 @@ export default { ...@@ -127,9 +144,11 @@ export default {
<div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3"> <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
<a <a
v-gl-tooltip.bottom v-gl-tooltip.bottom
data-testid="group-name"
:href="group.relativePath" :href="group.relativePath"
:title="group.fullName" :title="group.fullName"
class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!" class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!"
:itemprop="microdata.nameItemprop"
>{{ >{{
// ending bracket must be by closing tag to prevent // ending bracket must be by closing tag to prevent
// link hover text-decoration from over-extending // link hover text-decoration from over-extending
...@@ -146,7 +165,12 @@ export default { ...@@ -146,7 +165,12 @@ export default {
</span> </span>
</div> </div>
<div v-if="group.description" class="description"> <div v-if="group.description" class="description">
<span v-html="group.description"> </span> <span
:itemprop="microdata.descriptionItemprop"
data-testid="group-description"
v-html="group.description"
>
</span>
</div> </div>
</div> </div>
<div v-if="isGroupPendingRemoval"> <div v-if="isGroupPendingRemoval">
......
...@@ -47,8 +47,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { ...@@ -47,8 +47,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
data() { data() {
const { dataset } = dataEl || this.$options.el; const { dataset } = dataEl || this.$options.el;
const hideProjects = parseBoolean(dataset.hideProjects); const hideProjects = parseBoolean(dataset.hideProjects);
const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup);
const service = new GroupsService(endpoint || dataset.endpoint); const service = new GroupsService(endpoint || dataset.endpoint);
const store = new GroupsStore(hideProjects); const store = new GroupsStore({ hideProjects, showSchemaMarkup });
return { return {
action, action,
......
import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils'; import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
import { getGroupItemMicrodata } from './utils';
export default class GroupsStore { export default class GroupsStore {
constructor(hideProjects) { constructor({ hideProjects = false, showSchemaMarkup = false } = {}) {
this.state = {}; this.state = {};
this.state.groups = []; this.state.groups = [];
this.state.pageInfo = {}; this.state.pageInfo = {};
this.hideProjects = hideProjects; this.hideProjects = hideProjects;
this.showSchemaMarkup = showSchemaMarkup;
} }
setGroups(rawGroups) { setGroups(rawGroups) {
...@@ -94,6 +96,7 @@ export default class GroupsStore { ...@@ -94,6 +96,7 @@ export default class GroupsStore {
starCount: rawGroupItem.star_count, starCount: rawGroupItem.star_count,
updatedAt: rawGroupItem.updated_at, updatedAt: rawGroupItem.updated_at,
pendingRemoval: rawGroupItem.marked_for_deletion, pendingRemoval: rawGroupItem.marked_for_deletion,
microdata: this.showSchemaMarkup ? getGroupItemMicrodata(rawGroupItem) : {},
}; };
} }
......
export const getGroupItemMicrodata = ({ type }) => {
const defaultMicrodata = {
itemscope: true,
itemtype: 'https://schema.org/Thing',
itemprop: 'owns',
imageItemprop: 'image',
nameItemprop: 'name',
descriptionItemprop: 'description',
};
switch (type) {
case 'group':
return {
...defaultMicrodata,
itemtype: 'https://schema.org/Organization',
itemprop: 'subOrganization',
imageItemprop: 'logo',
};
case 'project':
return {
...defaultMicrodata,
itemtype: 'https://schema.org/SoftwareSourceCode',
};
default:
return defaultMicrodata;
}
};
...@@ -6,10 +6,10 @@ ...@@ -6,10 +6,10 @@
.row.mb-3 .row.mb-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex .home-panel-title-row.col-md-12.col-lg-6.d-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none
= group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64) = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo')
.d-flex.flex-column.flex-wrap.align-items-baseline .d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline .d-inline-flex.align-items-baseline
%h1.home-panel-title.gl-mt-3.gl-mb-2 %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' }
= @group.name = @group.name
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) = visibility_level_icon(@group.visibility_level, options: {class: 'icon'})
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
- if @group.description.present? - if @group.description.present?
.group-home-desc.mt-1 .group-home-desc.mt-1
.home-panel-description .home-panel-description
.home-panel-description-markdown.read-more-container .home-panel-description-markdown.read-more-container{ itemprop: 'description' }
= markdown_field(@group, :description) = markdown_field(@group, :description)
%button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
= _("Read more") = _("Read more")
...@@ -3,6 +3,6 @@ ...@@ -3,6 +3,6 @@
= render "shared/groups/empty_state" = render "shared/groups/empty_state"
%section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } %section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder .js-groups-list-holder{ data: { show_schema_markup: 'true'} }
.loading-container.text-center.prepend-top-20 .loading-container.text-center.prepend-top-20
.spinner.spinner-md .spinner.spinner-md
- breadcrumb_title _("Details") - breadcrumb_title _("Details")
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- page_itemtype 'https://schema.org/Organization'
- if show_thanks_for_purchase_banner? - if show_thanks_for_purchase_banner?
= render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
......
---
title: Add SEO structured markup for groups
merge_request: 47374
author:
type: added
...@@ -193,4 +193,69 @@ RSpec.describe 'Group show page' do ...@@ -193,4 +193,69 @@ RSpec.describe 'Group show page' do
it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet' it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
end end
context 'structured schema markup' do
let_it_be(:group) { create(:group, :public, :with_avatar, description: 'foo') }
let_it_be(:subgroup) { create(:group, :public, :with_avatar, parent: group, description: 'bar') }
let_it_be_with_reload(:project) { create(:project, :public, :with_avatar, namespace: group, description: 'foo') }
let_it_be(:subproject) { create(:project, :public, :with_avatar, namespace: subgroup, description: 'bar') }
it 'shows Organization structured markup', :js do
visit path
wait_for_all_requests
aggregate_failures do
expect(page).to have_selector('.content[itemscope][itemtype="https://schema.org/Organization"]')
page.within('.group-home-panel') do
expect(page).to have_selector('img.avatar[itemprop="logo"]')
expect(page).to have_selector('[itemprop="name"]', text: group.name)
expect(page).to have_selector('[itemprop="description"]', text: group.description)
end
page.within('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]') do
expect(page).to have_selector('img.avatar[itemprop="image"]')
expect(page).to have_selector('[itemprop="name"]', text: project.name)
expect(page).to have_selector('[itemprop="description"]', text: project.description)
end
# Finding the subgroup row and expanding it
el = find('[itemprop="subOrganization"][itemtype="https://schema.org/Organization"]')
el.click
wait_for_all_requests
page.within(el) do
expect(page).to have_selector('img.avatar[itemprop="logo"]')
expect(page).to have_selector('[itemprop="name"]', text: subgroup.name)
expect(page).to have_selector('[itemprop="description"]', text: subgroup.description)
page.within('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]') do
expect(page).to have_selector('img.avatar[itemprop="image"]')
expect(page).to have_selector('[itemprop="name"]', text: subproject.name)
expect(page).to have_selector('[itemprop="description"]', text: subproject.description)
end
end
end
end
it 'does not include structured markup in shared projects tab', :js do
other_project = create(:project, :public)
other_project.project_group_links.create!(group: group)
visit group_shared_path(group)
wait_for_all_requests
expect(page).to have_selector('li.group-row')
expect(page).not_to have_selector('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]')
end
it 'does not include structured markup in archived projects tab', :js do
project.update!(archived: true)
visit group_archived_path(group)
wait_for_all_requests
expect(page).to have_selector('li.group-row')
expect(page).not_to have_selector('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]')
end
end
end end
...@@ -35,7 +35,7 @@ describe('AppComponent', () => { ...@@ -35,7 +35,7 @@ describe('AppComponent', () => {
let mock; let mock;
let getGroupsSpy; let getGroupsSpy;
const store = new GroupsStore(false); const store = new GroupsStore({ hideProjects: false });
const service = new GroupsService(mockEndpoint); const service = new GroupsService(mockEndpoint);
const createShallowComponent = (hideProjects = false) => { const createShallowComponent = (hideProjects = false) => {
......
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper'; import mountComponent from 'helpers/vue_mount_component_helper';
import groupItemComponent from '~/groups/components/group_item.vue'; import groupItemComponent from '~/groups/components/group_item.vue';
import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue';
import { getGroupItemMicrodata } from '~/groups/store/utils';
import eventHub from '~/groups/event_hub'; import eventHub from '~/groups/event_hub';
import * as urlUtilities from '~/lib/utils/url_utility'; import * as urlUtilities from '~/lib/utils/url_utility';
import { mockParentGroupItem, mockChildren } from '../mock_data'; import { mockParentGroupItem, mockChildren } from '../mock_data';
...@@ -30,6 +31,11 @@ describe('GroupItemComponent', () => { ...@@ -30,6 +31,11 @@ describe('GroupItemComponent', () => {
vm.$destroy(); vm.$destroy();
}); });
const withMicrodata = group => ({
...group,
microdata: getGroupItemMicrodata(group),
});
describe('computed', () => { describe('computed', () => {
describe('groupDomId', () => { describe('groupDomId', () => {
it('should return ID string suffixed with group ID', () => { it('should return ID string suffixed with group ID', () => {
...@@ -212,4 +218,47 @@ describe('GroupItemComponent', () => { ...@@ -212,4 +218,47 @@ describe('GroupItemComponent', () => {
expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
}); });
}); });
describe('schema.org props', () => {
describe('when showSchemaMarkup is disabled on the group', () => {
it.each(['itemprop', 'itemtype', 'itemscope'], 'it does not set %s', attr => {
expect(vm.$el.getAttribute(attr)).toBeNull();
});
it.each(
['.js-group-avatar', '.js-group-name', '.js-group-description'],
'it does not set `itemprop` on sub-nodes',
selector => {
expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBeNull();
},
);
});
describe('when group has microdata', () => {
beforeEach(() => {
const group = withMicrodata({
...mockParentGroupItem,
avatarUrl: 'http://foo.bar',
description: 'Foo Bar',
});
vm = createComponent(group);
});
it.each`
attr | value
${'itemscope'} | ${'itemscope'}
${'itemtype'} | ${'https://schema.org/Organization'}
${'itemprop'} | ${'subOrganization'}
`('it does set correct $attr', ({ attr, value } = {}) => {
expect(vm.$el.getAttribute(attr)).toBe(value);
});
it.each`
selector | propValue
${'[data-testid="group-avatar"]'} | ${'logo'}
${'[data-testid="group-name"]'} | ${'name'}
${'[data-testid="group-description"]'} | ${'description'}
`('it does set correct $selector', ({ selector, propValue } = {}) => {
expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBe(propValue);
});
});
});
}); });
import GroupsStore from '~/groups/store/groups_store'; import GroupsStore from '~/groups/store/groups_store';
import { getGroupItemMicrodata } from '~/groups/store/utils';
import { import {
mockGroups, mockGroups,
mockSearchedGroups, mockSearchedGroups,
...@@ -17,9 +18,9 @@ describe('ProjectsStore', () => { ...@@ -17,9 +18,9 @@ describe('ProjectsStore', () => {
expect(Object.keys(store.state).length).toBe(2); expect(Object.keys(store.state).length).toBe(2);
expect(Array.isArray(store.state.groups)).toBeTruthy(); expect(Array.isArray(store.state.groups)).toBeTruthy();
expect(Object.keys(store.state.pageInfo).length).toBe(0); expect(Object.keys(store.state.pageInfo).length).toBe(0);
expect(store.hideProjects).not.toBeDefined(); expect(store.hideProjects).toBeFalsy();
store = new GroupsStore(true); store = new GroupsStore({ hideProjects: true });
expect(store.hideProjects).toBeTruthy(); expect(store.hideProjects).toBeTruthy();
}); });
...@@ -86,22 +87,30 @@ describe('ProjectsStore', () => { ...@@ -86,22 +87,30 @@ describe('ProjectsStore', () => {
describe('formatGroupItem', () => { describe('formatGroupItem', () => {
it('should parse group item object and return updated object', () => { it('should parse group item object and return updated object', () => {
let store; const store = new GroupsStore();
let updatedGroupItem; const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
store = new GroupsStore();
updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
expect(updatedGroupItem.isChildrenLoading).toBe(false); expect(updatedGroupItem.isChildrenLoading).toBe(false);
expect(updatedGroupItem.isBeingRemoved).toBe(false); expect(updatedGroupItem.isBeingRemoved).toBe(false);
expect(updatedGroupItem.microdata).toEqual({});
});
store = new GroupsStore(true); it('with hideProjects', () => {
updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); const store = new GroupsStore({ hideProjects: true });
const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
expect(updatedGroupItem.microdata).toEqual({});
});
it('with showSchemaMarkup', () => {
const store = new GroupsStore({ showSchemaMarkup: true });
const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
expect(updatedGroupItem.microdata).toEqual(getGroupItemMicrodata(mockRawChildren[0]));
}); });
}); });
......
import { getGroupItemMicrodata } from '~/groups/store/utils';
describe('~/groups/store/utils', () => {
describe('getGroupItemMetadata', () => {
it('has default type', () => {
expect(getGroupItemMicrodata({ type: 'silly' })).toMatchInlineSnapshot(`
Object {
"descriptionItemprop": "description",
"imageItemprop": "image",
"itemprop": "owns",
"itemscope": true,
"itemtype": "https://schema.org/Thing",
"nameItemprop": "name",
}
`);
});
it('has group props', () => {
expect(getGroupItemMicrodata({ type: 'group' })).toMatchInlineSnapshot(`
Object {
"descriptionItemprop": "description",
"imageItemprop": "logo",
"itemprop": "subOrganization",
"itemscope": true,
"itemtype": "https://schema.org/Organization",
"nameItemprop": "name",
}
`);
});
it('has project props', () => {
expect(getGroupItemMicrodata({ type: 'project' })).toMatchInlineSnapshot(`
Object {
"descriptionItemprop": "description",
"imageItemprop": "image",
"itemprop": "owns",
"itemscope": true,
"itemtype": "https://schema.org/SoftwareSourceCode",
"nameItemprop": "name",
}
`);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment