Commit b0642299 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'user-avatar-vue-ce' into 'master'

Consolidate user avatar Vue logic

Closes #31017

See merge request !10718
parents 01a7f333 55737682
import Vue from 'vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
const Store = gl.issueBoards.BoardsStore;
......@@ -38,6 +39,9 @@ gl.issueBoards.IssueCardInner = Vue.extend({
maxCounter: 99,
};
},
components: {
userAvatarLink,
},
computed: {
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
......@@ -146,23 +150,16 @@ gl.issueBoards.IssueCardInner = Vue.extend({
</span>
</h4>
<div class="card-assignee">
<a
class="has-tooltip js-no-trigger"
:href="assigneeUrl(assignee)"
:title="assigneeUrlTitle(assignee)"
<user-avatar-link
v-for="(assignee, index) in issue.assignees"
v-if="shouldRenderAssignee(index)"
data-container="body"
data-placement="bottom"
>
<img
class="avatar avatar-inline s20"
:src="assignee.avatar"
width="20"
height="20"
:alt="avatarUrlTitle(assignee)"
/>
</a>
class="js-no-trigger"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar"
:tooltip-text="assigneeUrlTitle(assignee)"
tooltip-placement="bottom"
/>
<span
class="avatar-counter has-tooltip"
:title="assigneeCounterTooltip"
......
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
......@@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
items: Array,
stage: Object,
},
components: {
userAvatarImage,
},
template: `
<div>
<div class="events-description">
......@@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
<img class="avatar" :src="mergeRequest.author.avatarUrl">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
......
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
......@@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
items: Array,
stage: Object,
},
components: {
userAvatarImage,
},
template: `
<div>
<div class="events-description">
......@@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
......
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconCommit from '../svg/icon_commit.svg';
const global = window.gl || (window.gl = {});
......@@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
items: Array,
stage: Object,
},
components: {
userAvatarImage,
},
data() {
return { iconCommit };
},
template: `
<div>
<div class="events-description">
......@@ -24,7 +26,8 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="commit in items" class="stage-event-item">
<div class="item-details item-conmmit-component">
<img class="avatar" :src="commit.author.avatarUrl">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="commit.author.avatarUrl"/>
<h5 class="item-title commit-title">
<a :href="commit.commitUrl">
{{ commit.title }}
......
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
......@@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
items: Array,
stage: Object,
},
components: {
userAvatarImage,
},
template: `
<div>
<div class="events-description">
......@@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="issue.author.avatarUrl"/>
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
......
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {};
......@@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
items: Array,
stage: Object,
},
components: {
userAvatarImage,
},
template: `
<div>
<div class="events-description">
......@@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
<img class="avatar" :src="mergeRequest.author.avatarUrl">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
......
/* eslint-disable no-param-reassign */
import Vue from 'vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
import iconBranch from '../svg/icon_branch.svg';
const global = window.gl || (window.gl = {});
......@@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
data() {
return { iconBranch };
},
components: {
userAvatarImage,
},
template: `
<div>
<div class="events-description">
......@@ -22,7 +26,8 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
<ul class="stage-event-list">
<li v-for="build in items" class="stage-event-item item-build-component">
<div class="item-details">
<img class="avatar" :src="build.author.avatarUrl">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="build.author.avatarUrl"/>
<h5 class="item-title">
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
......
......@@ -3,6 +3,7 @@
import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
const DiffNoteAvatars = Vue.extend({
props: ['discussionId'],
......@@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({
collapseIcon,
};
},
components: {
userAvatarImage,
},
template: `
<div class="diff-comment-avatar-holders"
v-show="notesCount !== 0">
<div v-if="!isVisible">
<img v-for="note in notesSubset"
class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar"
width="19"
height="19"
role="button"
data-container="body"
data-placement="top"
data-html="true"
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image
v-for="note in notesSubset"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="clickedAvatar($event)"
:img-src="note.authorAvatar"
:tooltip-text="getTooltipText(note)"
:data-line-type="lineType"
:title="note.authorName + ': ' + note.noteTruncated"
:src="note.authorAvatar"
@click="clickedAvatar($event)" />
:size="19"
data-html="true"
/>
<span v-if="notesCount > shownAvatars"
class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
data-container="body"
......@@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({
setDiscussionVisible() {
this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible');
},
getTooltipText(note) {
return `${note.authorName}: ${note.noteTruncated}`;
},
},
});
......
<script>
import Timeago from 'timeago.js';
import _ from 'underscore';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import '../../lib/utils/text_utility';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
......@@ -20,6 +21,7 @@ const timeagoInstance = new Timeago();
export default {
components: {
userAvatarLink,
'commit-component': CommitComponent,
'actions-component': ActionsComponent,
'external-url-component': ExternalUrlComponent,
......@@ -468,15 +470,13 @@ export default {
<span v-if="!model.isFolder && deploymentHasUser">
by
<a
:href="deploymentUser.web_url"
class="js-deploy-user-container">
<img
class="avatar has-tooltip s20"
:src="deploymentUser.avatar_url"
:alt="userImageAltDescription"
:title="deploymentUser.username" />
</a>
<user-avatar-link
class="js-deploy-user-container"
:link-href="deploymentUser.web_url"
:img-src="deploymentUser.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="deploymentUser.username"
/>
</span>
</td>
......
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
props: [
'pipeline',
......@@ -7,6 +9,9 @@ export default {
return !!this.pipeline.user;
},
},
components: {
userAvatarLink,
},
template: `
<td>
<a
......@@ -15,18 +20,13 @@ export default {
<span class="pipeline-id">#{{pipeline.id}}</span>
</a>
<span>by</span>
<a
class="js-pipeline-url-user"
<user-avatar-link
v-if="user"
:href="pipeline.user.web_url">
<img
v-if="user"
class="avatar has-tooltip s20 "
:title="pipeline.user.name"
data-container="body"
:src="pipeline.user.avatar_url"
>
</a>
class="js-pipeline-url-user"
:link-href="pipeline.user.web_url"
:img-src="pipeline.user.avatar_url"
:tooltip-text="pipeline.user.name"
/>
<span
v-if="!user"
class="js-pipeline-url-api api">
......
import commitIconSvg from 'icons/_icon_commit.svg';
import userAvatarLink from './user_avatar/user_avatar_link.vue';
export default {
props: {
......@@ -110,6 +111,9 @@ export default {
return { commitIconSvg };
},
components: {
userAvatarLink,
},
template: `
<div class="branch-commit">
......@@ -133,16 +137,14 @@ export default {
<p class="commit-title">
<span v-if="title">
<a v-if="hasAuthor"
<user-avatar-link
v-if="hasAuthor"
class="avatar-image-container"
:href="author.web_url">
<img
class="avatar has-tooltip s20"
:src="author.avatar_url"
:alt="userImageAltDescription"
:title="author.username" />
</a>
:link-href="author.web_url"
:img-src="author.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
/>
<a class="commit-row-message"
:href="commitUrl">
{{title}}
......
<script>
/* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar-image
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:tooltip-text="tooltipText"
tooltip-placement="top"
/>
*/
import defaultAvatarUrl from 'images/no_avatar.png';
import TooltipMixin from '../../mixins/tooltip';
export default {
name: 'UserAvatarImage',
mixins: [TooltipMixin],
props: {
imgSrc: {
type: String,
required: false,
default: defaultAvatarUrl,
},
cssClasses: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: 'user avatar',
},
size: {
type: Number,
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
},
computed: {
tooltipContainer() {
return this.tooltipText ? 'body' : null;
},
avatarSizeClass() {
return `s${this.size}`;
},
},
};
</script>
<template>
<img
class="avatar"
:class="[avatarSizeClass, cssClasses]"
:src="imgSrc"
:width="size"
:height="size"
:alt="imgAlt"
:data-container="tooltipContainer"
:data-placement="tooltipPlacement"
:title="tooltipText"
ref="tooltip"
/>
</template>
<script>
/* This is a re-usable vue component for rendering a user avatar wrapped in
a clickable link (likely to the user's profile). The link, image, and
tooltip can be configured by props passed to this component.
Sample configuration:
<user-avatar-link
:link-href="userProfileUrl"
:img-src="userAvatarSrc"
:img-alt="tooltipText"
:img-size="20"
:tooltip-text="tooltipText"
tooltip-placement="top"
/>
*/
import userAvatarImage from './user_avatar_image.vue';
export default {
name: 'UserAvatarLink',
components: {
userAvatarImage,
},
props: {
linkHref: {
type: String,
required: false,
default: '',
},
imgSrc: {
type: String,
required: false,
default: '',
},
imgAlt: {
type: String,
required: false,
default: '',
},
imgCssClasses: {
type: String,
required: false,
default: '',
},
imgSize: {
type: Number,
required: false,
default: 20,
},
tooltipText: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'top',
},
},
};
</script>
<template>
<a
class="user-avatar-link"
:href="linkHref">
<user-avatar-image
:img-src="imgSrc"
:img-alt="imgAlt"
:css-classes="imgCssClasses"
:size="imgSize"
:tooltip-text="tooltipText"
:tooltip-placement="tooltipPlacement"
/>
</a>
</template>
<script>
/* This is a re-usable vue component for rendering a user avatar svg (typically
for a blank state). It will receive styles comparable to the user avatar,
but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
The svg and avatar size can be configured by props passed to this component.
Sample configuration:
<user-avatar-svg
:svg="potentialApproverSvg"
:size="20"
/>
*/
export default {
props: {
svg: {
type: String,
required: true,
},
size: {
type: Number,
required: false,
default: 20,
},
},
computed: {
avatarSizeClass() {
return `s${this.size}`;
},
},
};
</script>
<template>
<svg
:class="avatarSizeClass"
:height="size"
:width="size"
v-html="svg">
</svg>
</template>
......@@ -10,6 +10,8 @@
border-radius: $avatar_radius;
border: 1px solid $avatar-border;
&.s16 { @include avatar-size(16px, 6px); }
&.s18 { @include avatar-size(18px, 6px); }
&.s19 { @include avatar-size(19px, 6px); }
&.s20 { @include avatar-size(20px, 7px); }
&.s24 { @include avatar-size(24px, 8px); }
&.s26 { @include avatar-size(26px, 8px); }
......
......@@ -65,3 +65,7 @@
text-decoration: none;
}
}
.user-avatar-link {
text-decoration: none;
}
......@@ -68,10 +68,6 @@
margin: 0;
}
.avatar-image-container {
text-decoration: none;
}
.icon-play {
height: 13px;
width: 12px;
......
......@@ -90,9 +90,9 @@ var config = {
loader: 'raw-loader',
},
{
test: /\.gif$/,
test: /\.(gif|png)$/,
loader: 'url-loader',
query: { mimetype: 'image/gif' },
options: { limit: 2048 },
},
{
test: /\.(worker\.js|pdf|bmpr)$/,
......@@ -190,6 +190,7 @@ var config = {
'emojis': path.join(ROOT_PATH, 'fixtures/emojis'),
'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'),
'icons': path.join(ROOT_PATH, 'app/views/shared/icons'),
'images': path.join(ROOT_PATH, 'app/assets/images'),
'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'),
'vue$': 'vue/dist/vue.esm.js',
}
......
......@@ -91,7 +91,7 @@ feature 'Diff note avatars', feature: true, js: true do
page.within find("[id='#{position.line_code(project.repository)}']") do
find('.diff-notes-collapse').click
expect(first('img.js-diff-comment-avatar')["title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
end
end
......
......@@ -129,7 +129,7 @@ describe('Issue card component', () => {
it('sets title', () => {
expect(
component.$el.querySelector('.card-assignee a').getAttribute('title'),
component.$el.querySelector('.card-assignee img').getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`);
});
......
......@@ -60,7 +60,7 @@ describe('Pipeline Url Component', () => {
expect(
component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
).toEqual(mockData.pipeline.user.web_url);
expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name);
expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
});
......
......@@ -86,7 +86,7 @@ describe('Commit component', () => {
it('Should render the author avatar with title and alt attributes', () => {
expect(
component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('title'),
component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('data-original-title'),
).toContain(props.author.username);
expect(
component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('alt'),
......
......@@ -79,7 +79,7 @@ describe('Pipelines Table Row', () => {
).toEqual(pipeline.user.web_url);
expect(
component.$el.querySelector('td:nth-child(2) img').getAttribute('title'),
component.$el.querySelector('td:nth-child(2) img').getAttribute('data-original-title'),
).toEqual(pipeline.user.name);
});
});
......@@ -102,7 +102,7 @@ describe('Pipelines Table Row', () => {
}
const commitAuthorLink = commitAuthorElement.getAttribute('href');
const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('title');
const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('data-original-title');
return { commitAuthorElement, commitAuthorLink, commitAuthorName };
};
......
import Vue from 'vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
const UserAvatarImageComponent = Vue.extend(UserAvatarImage);
describe('User Avatar Image Component', function () {
describe('Initialization', function () {
beforeEach(function () {
this.propsData = {
size: 99,
imgSrc: 'myavatarurl.com',
imgAlt: 'mydisplayname',
cssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
};
this.userAvatarImage = new UserAvatarImageComponent({
propsData: this.propsData,
}).$mount();
});
it('should return a defined Vue component', function () {
expect(this.userAvatarImage).toBeDefined();
});
it('should have <img> as a child element', function () {
expect(this.userAvatarImage.$el.tagName).toBe('IMG');
});
it('should properly compute tooltipContainer', function () {
expect(this.userAvatarImage.tooltipContainer).toBe('body');
});
it('should properly render tooltipContainer', function () {
expect(this.userAvatarImage.$el.getAttribute('data-container')).toBe('body');
});
it('should properly compute avatarSizeClass', function () {
expect(this.userAvatarImage.avatarSizeClass).toBe('s99');
});
it('should properly render img css', function () {
const classList = this.userAvatarImage.$el.classList;
const containsAvatar = classList.contains('avatar');
const containsSizeClass = classList.contains('s99');
const containsCustomClass = classList.contains('myextraavatarclass');
expect(containsAvatar).toBe(true);
expect(containsSizeClass).toBe(true);
expect(containsCustomClass).toBe(true);
});
});
});
import Vue from 'vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
describe('User Avatar Link Component', function () {
beforeEach(function () {
this.propsData = {
linkHref: 'myavatarurl.com',
imgSize: 99,
imgSrc: 'myavatarurl.com',
imgAlt: 'mydisplayname',
imgCssClasses: 'myextraavatarclass',
tooltipText: 'tooltip text',
tooltipPlacement: 'bottom',
};
const UserAvatarLinkComponent = Vue.extend(UserAvatarLink);
this.userAvatarLink = new UserAvatarLinkComponent({
propsData: this.propsData,
}).$mount();
this.userAvatarImage = this.userAvatarLink.$children[0];
});
it('should return a defined Vue component', function () {
expect(this.userAvatarLink).toBeDefined();
});
it('should have user-avatar-image registered as child component', function () {
expect(this.userAvatarLink.$options.components.userAvatarImage).toBeDefined();
});
it('user-avatar-link should have user-avatar-image as child component', function () {
expect(this.userAvatarImage).toBeDefined();
});
it('should render <a> as a child element', function () {
expect(this.userAvatarLink.$el.tagName).toBe('A');
});
it('should have <img> as a child element', function () {
expect(this.userAvatarLink.$el.querySelector('img')).not.toBeNull();
});
it('should return neccessary props as defined', function () {
_.each(this.propsData, (val, key) => {
expect(this.userAvatarLink[key]).toBeDefined();
});
});
});
import Vue from 'vue';
import UserAvatarSvg from '~/vue_shared/components/user_avatar/user_avatar_svg.vue';
import avatarSvg from 'icons/_icon_random.svg';
const UserAvatarSvgComponent = Vue.extend(UserAvatarSvg);
describe('User Avatar Svg Component', function () {
describe('Initialization', function () {
beforeEach(function () {
this.propsData = {
size: 99,
svg: avatarSvg,
};
this.userAvatarSvg = new UserAvatarSvgComponent({
propsData: this.propsData,
}).$mount();
});
it('should return a defined Vue component', function () {
expect(this.userAvatarSvg).toBeDefined();
});
it('should have <svg> as a child element', function () {
expect(this.userAvatarSvg.$el.tagName).toEqual('svg');
expect(this.userAvatarSvg.$el.innerHTML).toContain('<path');
});
});
});
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