From 3c668fa04fd7e0a1d925e9666eb727ed3e83d145 Mon Sep 17 00:00:00 2001 From: Bryce Johnson <bryce@gitlab.com> Date: Sat, 15 Apr 2017 19:38:07 -0400 Subject: [PATCH 1/3] Consolidate user avatar Vue logic --- .../boards/components/issue_card_inner.js | 27 +++---- .../components/stage_code_component.js | 7 +- .../components/stage_issue_component.js | 8 +- .../components/stage_plan_component.js | 9 ++- .../components/stage_production_component.js | 8 +- .../components/stage_review_component.js | 8 +- .../components/stage_staging_component.js | 7 +- .../components/diff_note_avatars.js | 28 ++++--- .../components/environment_item.vue | 18 ++--- .../pipelines/components/pipeline_url.js | 22 ++--- .../vue_shared/components/commit.js | 20 ++--- .../user_avatar/user_avatar_image.vue | 79 ++++++++++++++++++ .../user_avatar/user_avatar_link.vue | 80 +++++++++++++++++++ .../user_avatar/user_avatar_size_mixin.js | 13 +++ .../user_avatar/user_avatar_svg.vue | 42 ++++++++++ app/assets/stylesheets/framework/icons.scss | 4 + .../stylesheets/pages/environments.scss | 4 - config/webpack.config.js | 5 +- .../merge_requests/diff_notes_avatars_spec.rb | 2 +- spec/javascripts/boards/issue_card_spec.js | 2 +- .../pipelines/pipeline_url_spec.js | 2 +- .../vue_shared/components/commit_spec.js | 2 +- .../components/pipelines_table_row_spec.js | 4 +- .../components/user_avatar_image_spec.js | 60 ++++++++++++++ .../components/user_avatar_link_spec.js | 58 ++++++++++++++ .../components/user_avatar_size_mixin_spec.js | 37 +++++++++ .../components/user_avatar_svg_spec.js | 35 ++++++++ 27 files changed, 513 insertions(+), 78 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue create mode 100644 app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue create mode 100644 app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js create mode 100644 app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue create mode 100644 spec/javascripts/vue_shared/components/user_avatar_image_spec.js create mode 100644 spec/javascripts/vue_shared/components/user_avatar_link_spec.js create mode 100644 spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js create mode 100644 spec/javascripts/vue_shared/components/user_avatar_svg_spec.js diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 710207db0c7..4699ef5a51c 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -1,4 +1,5 @@ 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" diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index 0d9ad197abf..eeb61826ace 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -1,6 +1,7 @@ /* 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 }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index ad285874643..09fb390787d 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -1,6 +1,6 @@ /* 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 }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index dec1704395e..cd7a94b67c1 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -1,5 +1,6 @@ /* 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 }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index a14ebc3ece9..bdf86b4ff3c 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -1,6 +1,6 @@ /* 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 }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index 1a5bf9bc0b5..cfb7a4ab576 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -1,6 +1,6 @@ /* 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 }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index 1f7c673b1d4..97a849c4feb 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -1,5 +1,6 @@ /* 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> diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index 5f533b5761c..517bdb6be09 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -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}`; + }, }, }); diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 1f01629aa1b..012ff1f975b 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,6 +1,7 @@ <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> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js index ea8aaca6c9c..7cd2e0f9366 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.js +++ b/app/assets/javascripts/pipelines/components/pipeline_url.js @@ -1,3 +1,5 @@ +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"> diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index 9b060a0a35f..23bc5fbc034 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -1,4 +1,5 @@ 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}} diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue new file mode 100644 index 00000000000..4891e7f927c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -0,0 +1,79 @@ +<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 UserAvatarSizeMixin from './user_avatar_size_mixin'; +import TooltipMixin from '../../mixins/tooltip'; + +export default { + name: 'UserAvatarImage', + mixins: [UserAvatarSizeMixin, 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; + }, + imgCssClasses() { + return `avatar ${this.avatarSizeClass} ${this.cssClasses}`; + }, + }, +}; +</script> + +<template> + <img + :class="imgCssClasses" + :src="imgSrc" + :style="avatarSizeStylesMap" + :alt="imgAlt" + :data-container="tooltipContainer" + :data-placement="tooltipPlacement" + :title="tooltipText" + ref="tooltip" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue new file mode 100644 index 00000000000..95898d54cf7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -0,0 +1,80 @@ +<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> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js new file mode 100644 index 00000000000..b6155ffd28e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js @@ -0,0 +1,13 @@ +export default { + computed: { + avatarSizeStylesMap() { + return { + width: `${this.size}px`, + height: `${this.size}px`, + }; + }, + avatarSizeClass() { + return `s${this.size}`; + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue new file mode 100644 index 00000000000..39b4d37c91e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue @@ -0,0 +1,42 @@ +<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" + /> + +*/ + +import UserAvatarSizeMixin from './user_avatar_size_mixin'; + +export default { + mixins: [UserAvatarSizeMixin], + props: { + svg: { + type: String, + required: true, + }, + size: { + type: Number, + required: false, + default: 20, + }, + }, +}; +</script> + +<template> + <svg + :class="avatarSizeClass" + :style="avatarSizeStylesMap" + v-html="svg"> + </svg> +</template> + diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 1b7d4e42258..ef864e8f6a9 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -65,3 +65,7 @@ text-decoration: none; } } + +.user-avatar-link { + text-decoration: none; +} diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index a42ae7e55a5..48d3b7b1d07 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -68,10 +68,6 @@ margin: 0; } - .avatar-image-container { - text-decoration: none; - } - .icon-play { height: 13px; width: 12px; diff --git a/config/webpack.config.js b/config/webpack.config.js index 0781017c89f..7bc225968de 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -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', } diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb index b2e170513c4..ccf047d3efa 100644 --- a/spec/features/merge_requests/diff_notes_avatars_spec.rb +++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb @@ -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 diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index fddde799d01..bd9b4fbfdd3 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -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}`); }); diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index 53931d67ad7..0bcc3905702 100644 --- a/spec/javascripts/pipelines/pipeline_url_spec.js +++ b/spec/javascripts/pipelines/pipeline_url_spec.js @@ -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); }); diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js index 242010ba688..0638483e7aa 100644 --- a/spec/javascripts/vue_shared/components/commit_spec.js +++ b/spec/javascripts/vue_shared/components/commit_spec.js @@ -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'), diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js index 14280751053..286118917e8 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js @@ -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 }; }; diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js new file mode 100644 index 00000000000..0b5ec736b1e --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js @@ -0,0 +1,60 @@ +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(); + + this.imageElement = this.userAvatarImage.$el.outerHTML; + }); + + it('should return a defined Vue component', function () { + expect(this.userAvatarImage).toBeDefined(); + }); + + it('should have <img> as a child element', function () { + const componentImgTag = this.userAvatarImage.$el.outerHTML; + expect(componentImgTag).toContain('<img'); + }); + + it('should return neccessary props as defined', function () { + _.each(this.propsData, (val, key) => { + expect(this.userAvatarImage[key]).toBeDefined(); + }); + }); + + it('should properly compute tooltipContainer', function () { + expect(this.userAvatarImage.tooltipContainer).toBe('body'); + }); + + it('should properly render tooltipContainer', function () { + expect(this.imageElement).toContain('data-container="body"'); + }); + + it('should properly compute avatarSizeClass', function () { + expect(this.userAvatarImage.avatarSizeClass).toBe('s99'); + }); + + it('should properly compute imgCssClasses', function () { + expect(this.userAvatarImage.imgCssClasses).toBe('avatar s99 myextraavatarclass'); + }); + + it('should properly render imgCssClasses', function () { + expect(this.imageElement).toContain('avatar s99 myextraavatarclass'); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js new file mode 100644 index 00000000000..770daa9f0de --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js @@ -0,0 +1,58 @@ +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 () { + const componentLinkTag = this.userAvatarLink.$el.outerHTML; + expect(componentLinkTag).toContain('<a'); + }); + + it('should have <img> as a child element', function () { + const componentImgTag = this.userAvatarLink.$el.outerHTML; + expect(componentImgTag).toContain('<img'); + }); + + it('should return neccessary props as defined', function () { + _.each(this.propsData, (val, key) => { + expect(this.userAvatarLink[key]).toBeDefined(); + }); + }); + + it('should include props in the rendered output', function () { + _.each(this.propsData, (val) => { + expect(this.userAvatarLink.$el.outerHTML).toContain(val); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js b/spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js new file mode 100644 index 00000000000..b37813cdb3d --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import UserAvatarSizeMixin from '~/vue_shared/components/user_avatar/user_avatar_size_mixin'; + +describe('User Avatar Size Mixin', () => { + beforeEach(() => { + this.vueInstance = new Vue({ + data: { + size: 99, + }, + mixins: [UserAvatarSizeMixin], + }); + }); + + describe('#avatarSizeClass', () => { + it('should be a defined computed value', () => { + expect(this.vueInstance.avatarSizeClass).toBeDefined(); + }); + + it('should correctly transform size into the class name', () => { + expect(this.vueInstance.avatarSizeClass).toBe('s99'); + }); + }); + + describe('#avatarSizeStylesMap', () => { + it('should be a defined computed value', () => { + expect(this.vueInstance.avatarSizeStylesMap).toBeDefined(); + }); + + it('should return a correctly formatted styles width', () => { + expect(this.vueInstance.avatarSizeStylesMap.width).toBe('99px'); + }); + + it('should return a correctly formatted styles height', () => { + expect(this.vueInstance.avatarSizeStylesMap.height).toBe('99px'); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js new file mode 100644 index 00000000000..809886c5dbd --- /dev/null +++ b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js @@ -0,0 +1,35 @@ +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'); + }); + + it('should return neccessary props as defined', function () { + _.each(this.propsData, (val, key) => { + expect(this.userAvatarSvg[key]).toBeDefined(); + }); + }); + }); +}); -- GitLab From b73959a94bcace3d0af7819f7621b308980c49d9 Mon Sep 17 00:00:00 2001 From: Bryce Johnson <bryce@gitlab.com> Date: Wed, 17 May 2017 11:22:26 -0400 Subject: [PATCH 2/3] Fixes per feedback on user avatar components. --- .../user_avatar/user_avatar_image.vue | 13 ++++--- .../user_avatar/user_avatar_size_mixin.js | 13 ------- .../user_avatar/user_avatar_svg.vue | 11 ++++-- .../components/user_avatar_image_spec.js | 26 +++++-------- .../components/user_avatar_link_spec.js | 12 +----- .../components/user_avatar_size_mixin_spec.js | 37 ------------------- .../components/user_avatar_svg_spec.js | 6 --- 7 files changed, 26 insertions(+), 92 deletions(-) delete mode 100644 app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js delete mode 100644 spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 4891e7f927c..b8db6afda12 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -16,12 +16,11 @@ */ import defaultAvatarUrl from 'images/no_avatar.png'; -import UserAvatarSizeMixin from './user_avatar_size_mixin'; import TooltipMixin from '../../mixins/tooltip'; export default { name: 'UserAvatarImage', - mixins: [UserAvatarSizeMixin, TooltipMixin], + mixins: [TooltipMixin], props: { imgSrc: { type: String, @@ -58,8 +57,8 @@ export default { tooltipContainer() { return this.tooltipText ? 'body' : null; }, - imgCssClasses() { - return `avatar ${this.avatarSizeClass} ${this.cssClasses}`; + avatarSizeClass() { + return `s${this.size}`; }, }, }; @@ -67,9 +66,11 @@ export default { <template> <img - :class="imgCssClasses" + class="avatar" + :class="[avatarSizeClass, cssClasses]" :src="imgSrc" - :style="avatarSizeStylesMap" + :width="size" + :height="size" :alt="imgAlt" :data-container="tooltipContainer" :data-placement="tooltipPlacement" diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js deleted file mode 100644 index b6155ffd28e..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_size_mixin.js +++ /dev/null @@ -1,13 +0,0 @@ -export default { - computed: { - avatarSizeStylesMap() { - return { - width: `${this.size}px`, - height: `${this.size}px`, - }; - }, - avatarSizeClass() { - return `s${this.size}`; - }, - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue index 39b4d37c91e..d2ff2ac006e 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue @@ -14,10 +14,7 @@ */ -import UserAvatarSizeMixin from './user_avatar_size_mixin'; - export default { - mixins: [UserAvatarSizeMixin], props: { svg: { type: String, @@ -29,13 +26,19 @@ export default { default: 20, }, }, + computed: { + avatarSizeClass() { + return `s${this.size}`; + }, + }, }; </script> <template> <svg :class="avatarSizeClass" - :style="avatarSizeStylesMap" + :height="size" + :width="size" v-html="svg"> </svg> </template> diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js index 0b5ec736b1e..8daa7610274 100644 --- a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js @@ -18,8 +18,6 @@ describe('User Avatar Image Component', function () { this.userAvatarImage = new UserAvatarImageComponent({ propsData: this.propsData, }).$mount(); - - this.imageElement = this.userAvatarImage.$el.outerHTML; }); it('should return a defined Vue component', function () { @@ -27,14 +25,7 @@ describe('User Avatar Image Component', function () { }); it('should have <img> as a child element', function () { - const componentImgTag = this.userAvatarImage.$el.outerHTML; - expect(componentImgTag).toContain('<img'); - }); - - it('should return neccessary props as defined', function () { - _.each(this.propsData, (val, key) => { - expect(this.userAvatarImage[key]).toBeDefined(); - }); + expect(this.userAvatarImage.$el.tagName).toBe('IMG'); }); it('should properly compute tooltipContainer', function () { @@ -42,19 +33,22 @@ describe('User Avatar Image Component', function () { }); it('should properly render tooltipContainer', function () { - expect(this.imageElement).toContain('data-container="body"'); + expect(this.userAvatarImage.$el.getAttribute('data-container')).toBe('body'); }); it('should properly compute avatarSizeClass', function () { expect(this.userAvatarImage.avatarSizeClass).toBe('s99'); }); - it('should properly compute imgCssClasses', function () { - expect(this.userAvatarImage.imgCssClasses).toBe('avatar s99 myextraavatarclass'); - }); + 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'); - it('should properly render imgCssClasses', function () { - expect(this.imageElement).toContain('avatar s99 myextraavatarclass'); + expect(containsAvatar).toBe(true); + expect(containsSizeClass).toBe(true); + expect(containsCustomClass).toBe(true); }); }); }); diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js index 770daa9f0de..52e450e9ba5 100644 --- a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js @@ -35,13 +35,11 @@ describe('User Avatar Link Component', function () { }); it('should render <a> as a child element', function () { - const componentLinkTag = this.userAvatarLink.$el.outerHTML; - expect(componentLinkTag).toContain('<a'); + expect(this.userAvatarLink.$el.tagName).toBe('A'); }); it('should have <img> as a child element', function () { - const componentImgTag = this.userAvatarLink.$el.outerHTML; - expect(componentImgTag).toContain('<img'); + expect(this.userAvatarLink.$el.querySelector('img')).not.toBeNull(); }); it('should return neccessary props as defined', function () { @@ -49,10 +47,4 @@ describe('User Avatar Link Component', function () { expect(this.userAvatarLink[key]).toBeDefined(); }); }); - - it('should include props in the rendered output', function () { - _.each(this.propsData, (val) => { - expect(this.userAvatarLink.$el.outerHTML).toContain(val); - }); - }); }); diff --git a/spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js b/spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js deleted file mode 100644 index b37813cdb3d..00000000000 --- a/spec/javascripts/vue_shared/components/user_avatar_size_mixin_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import Vue from 'vue'; -import UserAvatarSizeMixin from '~/vue_shared/components/user_avatar/user_avatar_size_mixin'; - -describe('User Avatar Size Mixin', () => { - beforeEach(() => { - this.vueInstance = new Vue({ - data: { - size: 99, - }, - mixins: [UserAvatarSizeMixin], - }); - }); - - describe('#avatarSizeClass', () => { - it('should be a defined computed value', () => { - expect(this.vueInstance.avatarSizeClass).toBeDefined(); - }); - - it('should correctly transform size into the class name', () => { - expect(this.vueInstance.avatarSizeClass).toBe('s99'); - }); - }); - - describe('#avatarSizeStylesMap', () => { - it('should be a defined computed value', () => { - expect(this.vueInstance.avatarSizeStylesMap).toBeDefined(); - }); - - it('should return a correctly formatted styles width', () => { - expect(this.vueInstance.avatarSizeStylesMap.width).toBe('99px'); - }); - - it('should return a correctly formatted styles height', () => { - expect(this.vueInstance.avatarSizeStylesMap.height).toBe('99px'); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js index 809886c5dbd..b8d639ffbec 100644 --- a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js @@ -25,11 +25,5 @@ describe('User Avatar Svg Component', function () { expect(this.userAvatarSvg.$el.tagName).toEqual('svg'); expect(this.userAvatarSvg.$el.innerHTML).toContain('<path'); }); - - it('should return neccessary props as defined', function () { - _.each(this.propsData, (val, key) => { - expect(this.userAvatarSvg[key]).toBeDefined(); - }); - }); }); }); -- GitLab From 5573768213850a977cae75b500765d3cb7c5406c Mon Sep 17 00:00:00 2001 From: Bryce Johnson <bryce@gitlab.com> Date: Thu, 18 May 2017 20:06:04 -0400 Subject: [PATCH 3/3] Add s selectors for supported avatars. --- app/assets/stylesheets/framework/avatar.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 91c1ebd5a7d..4ae2b164d2e 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -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); } -- GitLab