From baa37edd93d47e836835617ef08d6fc85ad3a689 Mon Sep 17 00:00:00 2001 From: Constance Okoghenun Date: Wed, 7 Nov 2018 17:20:17 +0000 Subject: Resolve "Issue board card design" --- .../boards/components/issue_card_inner.vue | 142 ++++++++++----- .../boards/components/issue_due_date.vue | 90 ++++++++++ .../boards/components/issue_time_estimate.vue | 48 +++++ app/assets/javascripts/boards/models/issue.js | 1 + .../javascripts/lib/utils/datetime_utility.js | 10 +- .../components/user_avatar/user_avatar_image.vue | 53 +++--- .../components/user_avatar/user_avatar_link.vue | 12 +- app/assets/stylesheets/framework/common.scss | 7 + app/assets/stylesheets/framework/variables.scss | 3 +- app/assets/stylesheets/pages/boards.scss | 197 ++++++++++++--------- app/serializers/issue_board_entity.rb | 1 + .../unreleased/47008-issue-board-card-design.yml | 5 + locale/gitlab.pot | 15 ++ spec/features/boards/add_issues_modal_spec.rb | 2 +- spec/features/boards/issue_ordering_spec.rb | 4 +- .../user_sees_avatar_on_diff_notes_spec.rb | 15 +- .../fixtures/api/schemas/entities/issue_board.json | 1 + spec/fixtures/api/schemas/issue.json | 1 + .../boards/components/issue_due_date_spec.js | 64 +++++++ .../boards/components/issue_time_estimate_spec.js | 40 +++++ spec/javascripts/boards/issue_card_spec.js | 25 ++- spec/javascripts/jobs/components/job_app_spec.js | 4 +- .../javascripts/lib/utils/datetime_utility_spec.js | 6 + .../javascripts/pipelines/header_component_spec.js | 2 +- spec/javascripts/pipelines/pipeline_url_spec.js | 5 +- .../pipelines/pipelines_table_row_spec.js | 8 +- .../vue_shared/components/commit_spec.js | 4 +- .../components/header_ci_component_spec.js | 2 +- .../user_avatar/user_avatar_image_spec.js | 57 ++++-- .../user_avatar/user_avatar_link_spec.js | 28 +-- 30 files changed, 628 insertions(+), 224 deletions(-) create mode 100644 app/assets/javascripts/boards/components/issue_due_date.vue create mode 100644 app/assets/javascripts/boards/components/issue_time_estimate.vue create mode 100644 changelogs/unreleased/47008-issue-board-card-design.yml create mode 100644 spec/javascripts/boards/components/issue_due_date_spec.js create mode 100644 spec/javascripts/boards/components/issue_time_estimate_spec.js diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index d956777a86b..2315a48a306 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,18 +1,24 @@ + + diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue new file mode 100644 index 00000000000..efc7daf7812 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 669630edcab..5e0f0b07247 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -30,6 +30,7 @@ class ListIssue { this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; this.milestone_id = obj.milestone_id; this.project_id = obj.project_id; + this.timeEstimate = obj.time_estimate; this.assignableLabelsEndpoint = obj.assignable_labels_endpoint; if (obj.project) { diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 46740308f17..e69e56c85be 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -454,12 +454,20 @@ export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) /** * Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included. + * If the 'fullNameFormat' param is passed it returns a non condensed string eg '1 week 3 days' */ -export const stringifyTime = timeObject => { +export const stringifyTime = (timeObject, fullNameFormat = false) => { const reducedTime = _.reduce( timeObject, (memo, unitValue, unitName) => { const isNonZero = !!unitValue; + + if (fullNameFormat && isNonZero) { + // Remove traling 's' if unit value is singular + const formatedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, ''); + return `${memo} ${unitValue} ${formatedUnitName}`; + } + return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; }, '', 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 7737b9f2697..4cfb1ded0a9 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 @@ -15,14 +15,14 @@ */ +import { GlTooltip } from '@gitlab-org/gitlab-ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { placeholderImage } from '../../../lazy_loader'; -import tooltip from '../../directives/tooltip'; export default { name: 'UserAvatarImage', - directives: { - tooltip, + components: { + GlTooltip, }, props: { lazy: { @@ -73,9 +73,6 @@ export default { resultantSrcAttribute() { return this.lazy ? placeholderImage : this.sanitizedSource; }, - tooltipContainer() { - return this.tooltipText ? 'body' : null; - }, avatarSizeClass() { return `s${this.size}`; }, @@ -84,22 +81,30 @@ export default { 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 index dd6f96e2609..351a639c6e8 100644 --- 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 @@ -17,9 +17,8 @@ */ -import { GlLink } from '@gitlab-org/gitlab-ui'; +import { GlLink, GlTooltipDirective } from '@gitlab-org/gitlab-ui'; import userAvatarImage from './user_avatar_image.vue'; -import tooltip from '../../directives/tooltip'; export default { name: 'UserAvatarLink', @@ -28,7 +27,7 @@ export default { userAvatarImage, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { linkHref: { @@ -94,11 +93,14 @@ export default { :size="imgSize" :tooltip-text="avatarTooltipText" :tooltip-placement="tooltipPlacement" - /> + + {{ username }} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index fa753b13e5f..626c8f92d1d 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -33,6 +33,11 @@ color: $brand-danger; } +.text-danger-muted, +.text-danger-muted:hover { + color: $red-300; +} + .text-warning, .text-warning:hover { color: $brand-warning; @@ -345,6 +350,7 @@ img.emoji { /** COMMON CLASSES **/ .prepend-top-0 { margin-top: 0; } .prepend-top-2 { margin-top: 2px; } +.prepend-top-4 { margin-top: $gl-padding-4; } .prepend-top-5 { margin-top: 5px; } .prepend-top-8 { margin-top: $grid-size; } .prepend-top-10 { margin-top: 10px; } @@ -365,6 +371,7 @@ img.emoji { .append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right: 20px; } .append-bottom-0 { margin-bottom: 0; } +.append-bottom-4 { margin-bottom: $gl-padding-4; } .append-bottom-5 { margin-bottom: 5px; } .append-bottom-8 { margin-bottom: $grid-size; } .append-bottom-10 { margin-bottom: 10px; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index bfcac3f1c3f..016fee862e8 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -195,6 +195,7 @@ $well-light-text-color: #5b6169; * Text */ $gl-font-size: 14px; +$gl-font-size-xs: 11px; $gl-font-size-small: 12px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; @@ -440,7 +441,7 @@ $ci-skipped-color: #888; * Boards */ $issue-boards-font-size: 14px; -$issue-boards-card-shadow: rgba(186, 186, 186, 0.5); +$issue-boards-card-shadow: rgba(0, 0, 0, 0.1); /* The following heights are used in boards.scss and are used for calculation of the board height. They probably should be derived in a smarter way. diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 54fbd40cece..c6074eb9df4 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -90,20 +90,14 @@ } .with-performance-bar & { - height: calc( - 100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height} - ); + height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height}); @include media-breakpoint-only(sm) { - height: calc( - 100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height} - ); + height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height}); } @include media-breakpoint-up(md) { - height: calc( - 100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height} - ); + height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height}); } } } @@ -271,7 +265,7 @@ height: 100%; width: 100%; margin-bottom: 0; - padding: 5px; + padding: $gl-padding-4; list-style: none; overflow-y: auto; overflow-x: hidden; @@ -284,14 +278,16 @@ .board-card { position: relative; - padding: 11px 10px 11px $gl-padding; + padding: $gl-padding; background: $white-light; border-radius: $border-radius-default; + border: 1px solid $theme-gray-200; box-shadow: 0 1px 2px $issue-boards-card-shadow; list-style: none; + line-height: $gl-padding; &:not(:last-child) { - margin-bottom: 5px; + margin-bottom: $gl-padding-8; } &.is-active, @@ -302,113 +298,120 @@ .badge { border: 0; outline: 0; + + &:hover { + text-decoration: underline; + } + + @include media-breakpoint-down(lg) { + font-size: $gl-font-size-xs; + padding-left: $gl-padding-4; + padding-right: $gl-padding-4; + font-weight: $gl-font-weight-bold; + } + } + + svg { + vertical-align: top; } .confidential-icon { - vertical-align: text-top; - margin-right: 5px; + color: $orange-600; + cursor: help; + } + + @include media-breakpoint-down(md) { + padding: $gl-padding-8; } } .board-card-title { @include overflow-break-word(); - margin: 0 30px 0 0; font-size: 1em; - line-height: inherit; a { color: $gl-text-color; - margin-right: 2px; + } + + @include media-breakpoint-down(md) { + font-size: $label-font-size; } } .board-card-header { display: flex; - min-height: 20px; - - .board-card-assignee { - display: flex; - justify-content: flex-end; - position: absolute; - right: 15px; - height: 20px; - width: 20px; +} - .avatar-counter { - display: none; - vertical-align: middle; - min-width: 20px; - line-height: 19px; - height: 20px; - padding-left: 2px; - padding-right: 2px; - border-radius: 2em; - } +.board-card-assignee { + display: flex; + margin-top: -$gl-padding-4; + margin-bottom: -$gl-padding-4; + + .avatar-counter { + vertical-align: middle; + line-height: $gl-padding-24; + min-width: $gl-padding-24; + height: $gl-padding-24; + border-radius: $gl-padding-24; + background-color: $gl-text-color-tertiary; + font-size: $gl-font-size-xs; + cursor: help; + font-weight: $gl-font-weight-bold; + margin-left: -$gl-padding-4; + border: 0; + padding: 0 $gl-padding-4; - img { - vertical-align: top; + @include media-breakpoint-down(md) { + min-width: auto; + height: $gl-padding; + border-radius: $gl-padding; + line-height: $gl-padding; } + } - a { - position: relative; - margin-left: -15px; - } + img { + vertical-align: top; + } - a:nth-child(1) { - z-index: 3; - } + .user-avatar-link:not(:only-child) { + margin-left: -$gl-padding-4; - a:nth-child(2) { + &:nth-of-type(1) { z-index: 2; } - a:nth-child(3) { + &:nth-of-type(2) { z-index: 1; } + } - a:nth-child(4) { - display: none; - } - - &:hover { - .avatar-counter { - display: inline-block; - } - - a { - position: static; - background-color: $white-light; - transition: background-color 0s; - margin-left: auto; - - &:nth-child(4) { - display: block; - } + .avatar { + margin: 0; - &:first-child:not(:only-child) { - box-shadow: -10px 0 10px 1px $white-light; - } - } + @include media-breakpoint-down(md) { + width: $gl-padding; + height: $gl-padding; } } - .avatar { - margin: 0; + @include media-breakpoint-down(md) { + margin-top: 0; + margin-bottom: 0; } } -.board-card-footer { - margin: 0 0 5px; +.board-card-number { + font-size: $gl-font-size-xs; + color: $gl-text-color-secondary; + overflow: hidden; - .badge { - margin-top: 5px; - margin-right: 6px; + @include media-breakpoint-up(md) { + font-size: $label-font-size; } } -.board-card-number { - font-size: 12px; - color: $gl-text-color-secondary; +.board-card-number-container { + overflow: hidden; } .issue-boards-search { @@ -474,8 +477,7 @@ .right-sidebar.right-sidebar-expanded { &.boards-sidebar-slide-enter-active, &.boards-sidebar-slide-leave-active { - transition: width $sidebar-transition-duration, - padding $sidebar-transition-duration; + transition: width $sidebar-transition-duration, padding $sidebar-transition-duration; } &.boards-sidebar-slide-enter, @@ -650,3 +652,36 @@ } } } + +.board-card-info { + color: $gl-text-color-secondary; + white-space: nowrap; + margin-right: $gl-padding-8; + + &:not(.board-card-weight) { + cursor: help; + } + + &.board-card-weight { + color: $gl-text-color; + cursor: pointer; + + &:hover { + color: initial; + text-decoration: underline; + } + } + + .board-card-info-icon { + color: $theme-gray-600; + margin-right: $gl-padding-4; + } + + @include media-breakpoint-down(md) { + font-size: $label-font-size; + } +} + +.board-issue-path.js-show-tooltip { + cursor: help; +} diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index 6a9e9638e70..4e3d03b236b 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -12,6 +12,7 @@ class IssueBoardEntity < Grape::Entity expose :project_id expose :relative_position expose :weight, if: -> (*) { respond_to?(:weight) } + expose :time_estimate expose :project do |issue| API::Entities::Project.represent issue.project, only: [:id, :path] diff --git a/changelogs/unreleased/47008-issue-board-card-design.yml b/changelogs/unreleased/47008-issue-board-card-design.yml new file mode 100644 index 00000000000..39238687943 --- /dev/null +++ b/changelogs/unreleased/47008-issue-board-card-design.yml @@ -0,0 +1,5 @@ +--- +title: Issue board card design +merge_request: 21229 +author: +type: changed diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6383f770003..3182ffb27b9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -103,6 +103,9 @@ msgstr "" msgid "%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)" msgstr "" +msgid "%{count} more assignees" +msgstr "" + msgid "%{count} participant" msgid_plural "%{count} participants" msgstr[0] "" @@ -6371,6 +6374,9 @@ msgstr "" msgid "Time between merge request creation and merge/close" msgstr "" +msgid "Time estimate" +msgstr "" + msgid "Time remaining" msgstr "" @@ -6585,6 +6591,9 @@ msgstr "" msgid "To validate your GitLab CI configurations, go to 'CI/CD → Pipelines' inside your project, and click on the 'CI Lint' button." msgstr "" +msgid "Today" +msgstr "" + msgid "Todo" msgstr "" @@ -6618,6 +6627,9 @@ msgstr "" msgid "Token" msgstr "" +msgid "Tomorrow" +msgstr "" + msgid "Too many changes to show." msgstr "" @@ -7086,6 +7098,9 @@ msgstr "" msgid "Yes, let me map Google Code users to full names or GitLab users." msgstr "" +msgid "Yesterday" +msgstr "" + msgid "You are an admin, which means granting access to %{client_name} will allow them to interact with GitLab as an admin as well. Proceed with caution." msgstr "" diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb index eebc987499d..030993462b5 100644 --- a/spec/features/boards/add_issues_modal_spec.rb +++ b/spec/features/boards/add_issues_modal_spec.rb @@ -160,7 +160,7 @@ describe 'Issue Boards add issue modal', :js do it 'changes button text with plural' do page.within('.add-issues-modal') do - all('.board-card .board-card-number').each do |el| + all('.board-card .js-board-card-number-container').each do |el| el.click end diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb index ec0ca21450a..21779336559 100644 --- a/spec/features/boards/issue_ordering_spec.rb +++ b/spec/features/boards/issue_ordering_spec.rb @@ -78,7 +78,7 @@ describe 'Issue Boards', :js do end it 'moves from bottom to top' do - drag(from_index: 2, to_index: 0) + drag(from_index: 2, to_index: 0, duration: 1020) wait_for_requests @@ -130,7 +130,7 @@ describe 'Issue Boards', :js do end it 'moves to bottom of another list' do - drag(list_from_index: 1, list_to_index: 2, to_index: 2) + drag(list_from_index: 1, list_to_index: 2, to_index: 2, duration: 1020) wait_for_requests diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb index d3da8cc6752..b58c433bbfe 100644 --- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb +++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb @@ -89,16 +89,17 @@ describe 'Merge request > User sees avatars on diff notes', :js do page.within find_line(position.line_code(project.repository)) do find('.diff-notes-collapse').send_keys(:return) - expect(page).to have_selector('img.js-diff-comment-avatar', count: 1) + expect(page).to have_selector('.js-diff-comment-avatar img', count: 1) end end it 'shows comment on note avatar' do page.within find_line(position.line_code(project.repository)) do find('.diff-notes-collapse').send_keys(:return) - - expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}") + first('.js-diff-comment-avatar img').hover end + + expect(page).to have_content "#{note.author.name}: #{note.note.truncate(17)}" end it 'toggles comments when clicking avatar' do @@ -109,7 +110,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do expect(page).not_to have_selector('.notes_holder') page.within find_line(position.line_code(project.repository)) do - first('img.js-diff-comment-avatar').click + first('.js-diff-comment-avatar img').click end expect(page).to have_selector('.notes_holder') @@ -125,7 +126,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do wait_for_requests page.within find_line(position.line_code(project.repository)) do - expect(page).not_to have_selector('img.js-diff-comment-avatar') + expect(page).not_to have_selector('.js-diff-comment-avatar img') end end @@ -143,7 +144,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do page.within find_line(position.line_code(project.repository)) do find('.diff-notes-collapse').send_keys(:return) - expect(page).to have_selector('img.js-diff-comment-avatar', count: 2) + expect(page).to have_selector('.js-diff-comment-avatar img', count: 2) end end @@ -162,7 +163,7 @@ describe 'Merge request > User sees avatars on diff notes', :js do page.within find_line(position.line_code(project.repository)) do find('.diff-notes-collapse').send_keys(:return) - expect(page).to have_selector('img.js-diff-comment-avatar', count: 3) + expect(page).to have_selector('.js-diff-comment-avatar img', count: 3) expect(find('.diff-comments-more-count')).to have_content '+1' end end diff --git a/spec/fixtures/api/schemas/entities/issue_board.json b/spec/fixtures/api/schemas/entities/issue_board.json index 8d821ebb843..3e252ddd13c 100644 --- a/spec/fixtures/api/schemas/entities/issue_board.json +++ b/spec/fixtures/api/schemas/entities/issue_board.json @@ -8,6 +8,7 @@ "due_date": { "type": "date" }, "project_id": { "type": "integer" }, "relative_position": { "type": ["integer", "null"] }, + "time_estimate": { "type": "integer" }, "weight": { "type": "integer" }, "project": { "type": "object", diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index 4878df43d28..a83ec55cede 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -13,6 +13,7 @@ "confidential": { "type": "boolean" }, "due_date": { "type": ["date", "null"] }, "relative_position": { "type": "integer" }, + "time_estimate": { "type": "integer" }, "issue_sidebar_endpoint": { "type": "string" }, "toggle_subscription_endpoint": { "type": "string" }, "assignable_labels_endpoint": { "type": "string" }, diff --git a/spec/javascripts/boards/components/issue_due_date_spec.js b/spec/javascripts/boards/components/issue_due_date_spec.js new file mode 100644 index 00000000000..9e49330c052 --- /dev/null +++ b/spec/javascripts/boards/components/issue_due_date_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import dateFormat from 'dateformat'; +import IssueDueDate from '~/boards/components/issue_due_date.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Issue Due Date component', () => { + let vm; + let date; + const Component = Vue.extend(IssueDueDate); + const createComponent = (dueDate = new Date()) => + mountComponent(Component, { date: dateFormat(dueDate, 'yyyy-mm-dd', true) }); + + beforeEach(() => { + date = new Date(); + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render "Today" if the due date is today', () => { + const timeContainer = vm.$el.querySelector('time'); + + expect(timeContainer.textContent.trim()).toEqual('Today'); + }); + + it('should render "Yesterday" if the due date is yesterday', () => { + date.setDate(date.getDate() - 1); + vm = createComponent(date); + + expect(vm.$el.querySelector('time').textContent.trim()).toEqual('Yesterday'); + }); + + it('should render "Tomorrow" if the due date is one day from now', () => { + date.setDate(date.getDate() + 1); + vm = createComponent(date); + + expect(vm.$el.querySelector('time').textContent.trim()).toEqual('Tomorrow'); + }); + + it('should render day of the week if due date is one week away', () => { + date.setDate(date.getDate() + 5); + vm = createComponent(date); + + expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, 'dddd', true)); + }); + + it('should render month and day for other dates', () => { + date.setDate(date.getDate() + 17); + vm = createComponent(date); + + expect(vm.$el.querySelector('time').textContent.trim()).toEqual( + dateFormat(date, 'mmm d', true), + ); + }); + + it('should contain the correct `.text-danger` css class for overdue issue', () => { + date.setDate(date.getDate() - 17); + vm = createComponent(date); + + expect(vm.$el.querySelector('time').classList.contains('text-danger')).toEqual(true); + }); +}); diff --git a/spec/javascripts/boards/components/issue_time_estimate_spec.js b/spec/javascripts/boards/components/issue_time_estimate_spec.js new file mode 100644 index 00000000000..ba65d3287da --- /dev/null +++ b/spec/javascripts/boards/components/issue_time_estimate_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Issue Tine Estimate component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(IssueTimeEstimate); + vm = mountComponent(Component, { + estimate: 374460, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders the correct time estimate', () => { + expect(vm.$el.querySelector('time').textContent.trim()).toEqual('2w 3d 1m'); + }); + + it('renders expanded time estimate in tooltip', () => { + expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain( + '2 weeks 3 days 1 minute', + ); + }); + + it('prevents tooltip xss', done => { + const alertSpy = spyOn(window, 'alert'); + vm.estimate = 'Foo '; + + vm.$nextTick(() => { + expect(alertSpy).not.toHaveBeenCalled(); + expect(vm.$el.querySelector('time').textContent.trim()).toEqual('0m'); + expect(vm.$el.querySelector('.js-issue-time-estimate').textContent).toContain('0m'); + done(); + }); + }); +}); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 58b7d45d913..6eda5047dd0 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -117,11 +117,9 @@ describe('Issue card component', () => { }); it('sets title', () => { - expect( - component.$el - .querySelector('.board-card-assignee img') - .getAttribute('data-original-title'), - ).toContain(`Assigned to ${user.name}`); + expect(component.$el.querySelector('.js-assignee-tooltip').textContent).toContain( + `${user.name}`, + ); }); it('sets users path', () => { @@ -154,7 +152,7 @@ describe('Issue card component', () => { it('displays defaults avatar if users avatar is null', () => { expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull(); expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe( - 'default_avatar?width=20', + 'default_avatar?width=24', ); }); }); @@ -163,7 +161,6 @@ describe('Issue card component', () => { describe('multiple assignees', () => { beforeEach(done => { component.issue.assignees = [ - user, new ListAssignee({ id: 2, name: 'user2', @@ -187,11 +184,11 @@ describe('Issue card component', () => { Vue.nextTick(() => done()); }); - it('renders all four assignees', () => { - expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(4); + it('renders all three assignees', () => { + expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3); }); - describe('more than four assignees', () => { + describe('more than three assignees', () => { beforeEach(done => { component.issue.assignees.push( new ListAssignee({ @@ -207,12 +204,12 @@ describe('Issue card component', () => { it('renders more avatar counter', () => { expect( - component.$el.querySelector('.board-card-assignee .avatar-counter').innerText, + component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(), ).toEqual('+2'); }); - it('renders three assignees', () => { - expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3); + it('renders two assignees', () => { + expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(2); }); it('renders 99+ avatar counter', done => { @@ -228,7 +225,7 @@ describe('Issue card component', () => { Vue.nextTick(() => { expect( - component.$el.querySelector('.board-card-assignee .avatar-counter').innerText, + component.$el.querySelector('.board-card-assignee .avatar-counter').innerText.trim(), ).toEqual('99+'); done(); }); diff --git a/spec/javascripts/jobs/components/job_app_spec.js b/spec/javascripts/jobs/components/job_app_spec.js index 98c995393b9..fcf3780f0ea 100644 --- a/spec/javascripts/jobs/components/job_app_spec.js +++ b/spec/javascripts/jobs/components/job_app_spec.js @@ -102,7 +102,7 @@ describe('Job App ', () => { .querySelector('.header-main-content') .textContent.replace(/\s+/g, ' ') .trim(), - ).toEqual('passed Job #4757 triggered 1 year ago by Root'); + ).toContain('passed Job #4757 triggered 1 year ago by Root'); done(); }, 0); }); @@ -128,7 +128,7 @@ describe('Job App ', () => { .querySelector('.header-main-content') .textContent.replace(/\s+/g, ' ') .trim(), - ).toEqual('passed Job #4757 created 3 weeks ago by Root'); + ).toContain('passed Job #4757 created 3 weeks ago by Root'); done(); }, 0); }); diff --git a/spec/javascripts/lib/utils/datetime_utility_spec.js b/spec/javascripts/lib/utils/datetime_utility_spec.js index d699e66b8ca..bebe76f76c5 100644 --- a/spec/javascripts/lib/utils/datetime_utility_spec.js +++ b/spec/javascripts/lib/utils/datetime_utility_spec.js @@ -336,6 +336,12 @@ describe('prettyTime methods', () => { expect(timeString).toBe('0m'); }); + + it('should return non-condensed representation of time object', () => { + const timeObject = { weeks: 1, days: 0, hours: 1, minutes: 0 }; + + expect(datetimeUtility.stringifyTime(timeObject, true)).toEqual('1 week 1 hour'); + }); }); describe('abbreviateTime', () => { diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js index 473a062fc40..556a0976b29 100644 --- a/spec/javascripts/pipelines/header_component_spec.js +++ b/spec/javascripts/pipelines/header_component_spec.js @@ -51,7 +51,7 @@ describe('Pipeline details header', () => { .querySelector('.header-main-content') .textContent.replace(/\s+/g, ' ') .trim(), - ).toEqual('failed Pipeline #123 triggered 3 weeks ago by Foo'); + ).toContain('failed Pipeline #123 triggered 3 weeks ago by Foo'); }); describe('action buttons', () => { diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index c9011b403b7..d6c44f4c976 100644 --- a/spec/javascripts/pipelines/pipeline_url_spec.js +++ b/spec/javascripts/pipelines/pipeline_url_spec.js @@ -63,12 +63,15 @@ describe('Pipeline Url Component', () => { }).$mount(); const image = component.$el.querySelector('.js-pipeline-url-user img'); + const tooltip = component.$el.querySelector( + '.js-pipeline-url-user .js-user-avatar-image-toolip', + ); expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual( mockData.pipeline.user.web_url, ); - expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name); + expect(tooltip.textContent.trim()).toEqual(mockData.pipeline.user.name); expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`); }); diff --git a/spec/javascripts/pipelines/pipelines_table_row_spec.js b/spec/javascripts/pipelines/pipelines_table_row_spec.js index 506d01f5ec1..4c575536f0e 100644 --- a/spec/javascripts/pipelines/pipelines_table_row_spec.js +++ b/spec/javascripts/pipelines/pipelines_table_row_spec.js @@ -86,8 +86,8 @@ describe('Pipelines Table Row', () => { expect( component.$el - .querySelector('.table-section:nth-child(2) img') - .getAttribute('data-original-title'), + .querySelector('.table-section:nth-child(2) .js-user-avatar-image-toolip') + .textContent.trim(), ).toEqual(pipeline.user.name); }); }); @@ -112,8 +112,8 @@ describe('Pipelines Table Row', () => { const commitAuthorLink = commitAuthorElement.getAttribute('href'); const commitAuthorName = commitAuthorElement - .querySelector('img.avatar') - .getAttribute('data-original-title'); + .querySelector('.js-user-avatar-image-toolip') + .textContent.trim(); return { commitAuthorElement, commitAuthorLink, commitAuthorName }; }; diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js index 97dacec1fce..18fcdf7ede1 100644 --- a/spec/javascripts/vue_shared/components/commit_spec.js +++ b/spec/javascripts/vue_shared/components/commit_spec.js @@ -98,8 +98,8 @@ 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('data-original-title'), + .querySelector('.commit-title .avatar-image-container .js-user-avatar-image-toolip') + .textContent.trim(), ).toContain(props.author.username); expect( diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js index 3bf497bc00b..7a741bdc067 100644 --- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -73,7 +73,7 @@ describe('Header CI Component', () => { }); it('should render user icon and name', () => { - expect(vm.$el.querySelector('.js-user-link').textContent.trim()).toEqual(props.user.name); + expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name); }); it('should render provided actions', () => { diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js index dc7652c77f7..5c4aa7cf844 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { placeholderImage } from '~/lazy_loader'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper'; const DEFAULT_PROPS = { size: 99, @@ -32,18 +32,12 @@ describe('User Avatar Image Component', function() { }); it('should have as a child element', function() { - expect(vm.$el.tagName).toBe('IMG'); - expect(vm.$el.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); - expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); - expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt); - }); - - it('should properly compute tooltipContainer', function() { - expect(vm.tooltipContainer).toBe('body'); - }); + const imageElement = vm.$el.querySelector('img'); - it('should properly render tooltipContainer', function() { - expect(vm.$el.getAttribute('data-container')).toBe('body'); + expect(imageElement).not.toBe(null); + expect(imageElement.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(imageElement.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(imageElement.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt); }); it('should properly compute avatarSizeClass', function() { @@ -51,7 +45,7 @@ describe('User Avatar Image Component', function() { }); it('should properly render img css', function() { - const { classList } = vm.$el; + const { classList } = vm.$el.querySelector('img'); const containsAvatar = classList.contains('avatar'); const containsSizeClass = classList.contains('s99'); const containsCustomClass = classList.contains(DEFAULT_PROPS.cssClasses); @@ -73,12 +67,41 @@ describe('User Avatar Image Component', function() { }); it('should add lazy attributes', function() { - const { classList } = vm.$el; - const lazyClass = classList.contains('lazy'); + const imageElement = vm.$el.querySelector('img'); + const lazyClass = imageElement.classList.contains('lazy'); expect(lazyClass).toBe(true); - expect(vm.$el.getAttribute('src')).toBe(placeholderImage); - expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(imageElement.getAttribute('src')).toBe(placeholderImage); + expect(imageElement.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + }); + }); + + describe('dynamic tooltip content', () => { + const props = DEFAULT_PROPS; + const slots = { + default: ['Action!'], + }; + + beforeEach(() => { + vm = mountComponentWithSlots(UserAvatarImage, { props, slots }).$mount(); + }); + + it('renders the tooltip slot', () => { + expect(vm.$el.querySelector('.js-user-avatar-image-toolip')).not.toBe(null); + }); + + it('renders the tooltip content', () => { + expect(vm.$el.querySelector('.js-user-avatar-image-toolip').textContent).toContain( + slots.default[0], + ); + }); + + it('does not render tooltip data attributes for on avatar image', () => { + const avatarImg = vm.$el.querySelector('img'); + + expect(avatarImg.dataset.originalTitle).not.toBeDefined(); + expect(avatarImg.dataset.placement).not.toBeDefined(); + expect(avatarImg.dataset.container).not.toBeDefined(); }); }); }); diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js index e022245d3ea..0151ad23ba2 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -60,39 +60,43 @@ describe('User Avatar Link Component', function() { it('should only render image tag in link', function() { const childElements = this.userAvatarLink.$el.childNodes; - expect(childElements[0].tagName).toBe('IMG'); + expect(this.userAvatarLink.$el.querySelector('img')).not.toBe('null'); // Vue will render the hidden component as expect(childElements[1].tagName).toBeUndefined(); }); it('should render avatar image tooltip', function() { - expect(this.userAvatarLink.$el.querySelector('img').dataset.originalTitle).toEqual( - this.propsData.tooltipText, - ); + expect(this.userAvatarLink.shouldShowUsername).toBe(false); + expect(this.userAvatarLink.avatarTooltipText).toEqual(this.propsData.tooltipText); }); }); describe('username', function() { it('should not render avatar image tooltip', function() { - expect(this.userAvatarLink.$el.querySelector('img').dataset.originalTitle).toEqual(''); + expect( + this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(), + ).toEqual(''); }); it('should render username prop in ', function() { - expect(this.userAvatarLink.$el.querySelector('span').innerText.trim()).toEqual( - this.propsData.username, - ); + expect( + this.userAvatarLink.$el.querySelector('.js-user-avatar-link-username').innerText.trim(), + ).toEqual(this.propsData.username); }); it('should render text tooltip for ', function() { - expect(this.userAvatarLink.$el.querySelector('span').dataset.originalTitle).toEqual( - this.propsData.tooltipText, - ); + expect( + this.userAvatarLink.$el.querySelector('.js-user-avatar-link-username').dataset + .originalTitle, + ).toEqual(this.propsData.tooltipText); }); it('should render text tooltip placement for ', function() { expect( - this.userAvatarLink.$el.querySelector('span').getAttribute('tooltip-placement'), + this.userAvatarLink.$el + .querySelector('.js-user-avatar-link-username') + .getAttribute('tooltip-placement'), ).toEqual(this.propsData.tooltipPlacement); }); }); -- cgit v1.2.3