diff options
author | Eric Eastwood <contact@ericeastwood.com> | 2017-10-31 16:15:03 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2017-10-31 16:15:03 +0000 |
commit | 6dc9028fbb3856c8c7814446ed176880f7d5213f (patch) | |
tree | b45870b8c946e5304a2fd3ad64a1453d166da0ca | |
parent | 74a0e855e1d5049e265c85ac01d511c25f2a46f1 (diff) | |
download | gitlab-ce-6dc9028fbb3856c8c7814446ed176880f7d5213f.tar.gz |
Load participants async
45 files changed, 911 insertions, 205 deletions
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 5bc7f8d9cb9..da99394ff90 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -2,11 +2,8 @@ import Cookies from 'js-cookie'; import bp from './breakpoints'; import UsersSelect from './users_select'; -const PARTICIPANTS_ROW_COUNT = 7; - export default class IssuableContext { constructor(currentUser) { - this.initParticipants(); this.userSelect = new UsersSelect(currentUser); $('select.select2').select2({ @@ -51,29 +48,4 @@ export default class IssuableContext { } }); } - - initParticipants() { - $(document).on('click', '.js-participants-more', this.toggleHiddenParticipants); - return $('.js-participants-author').each(function forEachAuthor(i) { - if (i >= PARTICIPANTS_ROW_COUNT) { - $(this).addClass('js-participants-hidden').hide(); - } - }); - } - - toggleHiddenParticipants() { - const currentText = $(this).text().trim(); - const lessText = $(this).data('less-text'); - const originalText = $(this).data('original-text'); - - if (currentText === originalText) { - $(this).text(lessText); - - if (gl.lazyLoader) gl.lazyLoader.loadCheck(); - } else { - $(this).text(originalText); - } - - $('.js-participants-hidden').toggle(); - } } diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue new file mode 100644 index 00000000000..b8510a6ce3a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -0,0 +1,125 @@ +<script> +import { __, n__, sprintf } from '../../../locale'; +import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + participants: { + type: Array, + required: false, + default: () => [], + }, + numberOfLessParticipants: { + type: Number, + required: false, + default: 7, + }, + }, + data() { + return { + isShowingMoreParticipants: false, + }; + }, + components: { + loadingIcon, + userAvatarImage, + }, + computed: { + lessParticipants() { + return this.participants.slice(0, this.numberOfLessParticipants); + }, + visibleParticipants() { + return this.isShowingMoreParticipants ? this.participants : this.lessParticipants; + }, + hasMoreParticipants() { + return this.participants.length > this.numberOfLessParticipants; + }, + toggleLabel() { + let label = ''; + if (this.isShowingMoreParticipants) { + label = __('- show less'); + } else { + label = sprintf(__('+ %{moreCount} more'), { + moreCount: this.participants.length - this.numberOfLessParticipants, + }); + } + + return label; + }, + participantLabel() { + return sprintf( + n__('%{count} participant', '%{count} participants', this.participants.length), + { count: this.loading ? '' : this.participantCount }, + ); + }, + participantCount() { + return this.participants.length; + }, + }, + methods: { + toggleMoreParticipants() { + this.isShowingMoreParticipants = !this.isShowingMoreParticipants; + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-users" + aria-hidden="true"> + </i> + <loading-icon + v-if="loading" + class="js-participants-collapsed-loading-icon" /> + <span + v-else + class="js-participants-collapsed-count"> + {{ participantCount }} + </span> + </div> + <div class="title hide-collapsed"> + <loading-icon + v-if="loading" + :inline="true" + class="js-participants-expanded-loading-icon" /> + {{ participantLabel }} + </div> + <div class="participants-list hide-collapsed"> + <div + v-for="participant in visibleParticipants" + :key="participant.id" + class="participants-author js-participants-author"> + <a + class="author_link" + :href="participant.web_url"> + <user-avatar-image + :lazy="true" + :img-src="participant.avatar_url" + css-classes="avatar-inline" + :size="24" + :tooltip-text="participant.name" + tooltip-placement="bottom" /> + </a> + </div> + </div> + <div + v-if="hasMoreParticipants" + class="participants-more hide-collapsed"> + <button + type="button" + class="btn-transparent btn-blank js-toggle-participants-button" + @click="toggleMoreParticipants"> + {{ toggleLabel }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue new file mode 100644 index 00000000000..c1296b28db7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue @@ -0,0 +1,26 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import participants from './participants.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + participants, + }, +}; +</script> + +<template> + <div class="block participants"> + <participants + :loading="store.isFetching.participants" + :participants="store.participants" + :number-of-less-participants="7" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue new file mode 100644 index 00000000000..4ad3d469f25 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -0,0 +1,45 @@ +<script> +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; +import eventHub from '../../event_hub'; +import Flash from '../../../flash'; +import subscriptions from './subscriptions.vue'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + + components: { + subscriptions, + }, + + methods: { + onToggleSubscription() { + this.mediator.toggleSubscription() + .catch(() => { + Flash('Error occurred when toggling the notification subscription'); + }); + }, + }, + + created() { + eventHub.$on('toggleSubscription', this.onToggleSubscription); + }, + + beforeDestroy() { + eventHub.$off('toggleSubscription', this.onToggleSubscription); + }, +}; +</script> + +<template> + <div class="block subscriptions"> + <subscriptions + :loading="store.isFetching.subscriptions" + :subscribed="store.subscribed" /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue new file mode 100644 index 00000000000..a3a8213d63a --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -0,0 +1,60 @@ +<script> +import { __ } from '../../../locale'; +import eventHub from '../../event_hub'; +import loadingButton from '../../../vue_shared/components/loading_button.vue'; + +export default { + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + subscribed: { + type: Boolean, + required: false, + }, + }, + components: { + loadingButton, + }, + computed: { + buttonLabel() { + let label; + if (this.subscribed === false) { + label = __('Subscribe'); + } else if (this.subscribed === true) { + label = __('Unsubscribe'); + } + + return label; + }, + }, + methods: { + toggleSubscription() { + eventHub.$emit('toggleSubscription'); + }, + }, +}; +</script> + +<template> + <div> + <div class="sidebar-collapsed-icon"> + <i + class="fa fa-rss" + aria-hidden="true"> + </i> + </div> + <span class="issuable-header-text hide-collapsed pull-left"> + {{ __('Notifications') }} + </span> + <loading-button + ref="loadingButton" + class="btn btn-default pull-right hide-collapsed js-issuable-subscribe-button" + :loading="loading" + :label="buttonLabel" + @click="toggleSubscription" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 604648407a4..37c97225bfd 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -7,6 +7,7 @@ export default class SidebarService { constructor(endpointMap) { if (!SidebarService.singleton) { this.endpoint = endpointMap.endpoint; + this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint; this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; @@ -36,6 +37,10 @@ export default class SidebarService { }); } + toggleSubscription() { + return Vue.http.post(this.toggleSubscriptionEndpoint); + } + moveIssue(moveToProjectId) { return Vue.http.post(this.moveIssueEndpoint, { move_to_project_id: moveToProjectId, diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 09b9d75c02d..2650bb725d4 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -4,6 +4,8 @@ import SidebarAssignees from './components/assignees/sidebar_assignees'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; +import sidebarParticipants from './components/participants/sidebar_participants.vue'; +import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import Translate from '../vue_shared/translate'; import Mediator from './sidebar_mediator'; @@ -49,6 +51,36 @@ function mountLockComponent(mediator) { }).$mount(el); } +function mountParticipantsComponent() { + const el = document.querySelector('.js-sidebar-participants-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarParticipants, + }, + render: createElement => createElement('sidebar-participants', {}), + }); +} + +function mountSubscriptionsComponent() { + const el = document.querySelector('.js-sidebar-subscriptions-entry-point'); + + if (!el) return; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + sidebarSubscriptions, + }, + render: createElement => createElement('sidebar-subscriptions', {}), + }); +} + function domContentLoaded() { const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); const mediator = new Mediator(sidebarOptions); @@ -63,6 +95,8 @@ function domContentLoaded() { mountConfidentialComponent(mediator); mountLockComponent(mediator); + mountParticipantsComponent(); + mountSubscriptionsComponent(); new SidebarMoveIssue( mediator, diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index ede3a0de144..2bda5a47791 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -8,6 +8,7 @@ export default class SidebarMediator { this.store = new Store(options); this.service = new Service({ endpoint: options.endpoint, + toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint, moveIssueEndpoint: options.moveIssueEndpoint, projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, }); @@ -39,10 +40,25 @@ export default class SidebarMediator { .then((data) => { this.store.setAssigneeData(data); this.store.setTimeTrackingData(data); + this.store.setParticipantsData(data); + this.store.setSubscriptionsData(data); }) .catch(() => new Flash('Error occurred when fetching sidebar data')); } + toggleSubscription() { + this.store.setFetchingState('subscriptions', true); + return this.service.toggleSubscription() + .then(() => { + this.store.setSubscribedState(!this.store.subscribed); + this.store.setFetchingState('subscriptions', false); + }) + .catch((err) => { + this.store.setFetchingState('subscriptions', false); + throw err; + }); + } + fetchAutocompleteProjects(searchTerm) { return this.service.getProjectsAutocomplete(searchTerm) .then(response => response.json()) diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index d5d04103f3f..3150221b685 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -12,10 +12,14 @@ export default class SidebarStore { this.assignees = []; this.isFetching = { assignees: true, + participants: true, + subscriptions: true, }; this.autocompleteProjects = []; this.moveToProjectId = 0; this.isLockDialogOpen = false; + this.participants = []; + this.subscribed = null; SidebarStore.singleton = this; } @@ -37,6 +41,20 @@ export default class SidebarStore { this.humanTotalTimeSpent = data.human_total_time_spent; } + setParticipantsData(data) { + this.isFetching.participants = false; + this.participants = data.participants || []; + } + + setSubscriptionsData(data) { + this.isFetching.subscriptions = false; + this.subscribed = data.subscribed || false; + } + + setFetchingState(key, value) { + this.isFetching[key] = value; + } + addAssignee(assignee) { if (!this.findAssignee(assignee)) { this.assignees.push(assignee); @@ -61,6 +79,10 @@ export default class SidebarStore { this.autocompleteProjects = projects; } + setSubscribedState(subscribed) { + this.subscribed = subscribed; + } + setMoveToProjectId(moveToProjectId) { this.moveToProjectId = moveToProjectId; } diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 79c3d335679..99f5c305df5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -11,7 +11,7 @@ export default class MRWidgetService { this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath); - this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`); + this.pollResource = Vue.resource(`${endpoints.statusPath}?serializer=basic`); this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 48532503263..88600a0e6d3 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -542,7 +542,9 @@ } .participants-list { - margin: -5px; + display: flex; + flex-wrap: wrap; + margin: -7px; } @@ -553,7 +555,7 @@ .participants-author { display: inline-block; - padding: 5px; + padding: 7px; &:nth-of-type(7n) { padding-right: 0; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index fe1334c0cfe..6a5e4538717 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -74,7 +74,7 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: serializer.represent(@issue) + render json: serializer.represent(@issue, serializer: params[:serializer]) end end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c5204080333..2b0294c8387 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -83,7 +83,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo format.json do Gitlab::PollingInterval.set_header(response, interval: 10_000) - render json: serializer.represent(@merge_request, basic: params[:basic]) + render json: serializer.represent(@merge_request, serializer: params[:serializer]) end format.patch do diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index baa2d6e375e..d0069cd48cf 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -33,15 +33,17 @@ module IssuablesHelper end def serialize_issuable(issuable) - case issuable - when Issue - IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json - when MergeRequest - MergeRequestSerializer - .new(current_user: current_user, project: issuable.project) - .represent(issuable) - .to_json - end + serializer_klass = case issuable + when Issue + IssueSerializer + when MergeRequest + MergeRequestSerializer + end + + serializer_klass + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end def template_dropdown_tag(issuable, &block) @@ -357,7 +359,8 @@ module IssuablesHelper def issuable_sidebar_options(issuable, can_edit_issuable) { - endpoint: "#{issuable_json_path(issuable)}?basic=true", + endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar", + toggleSubscriptionEndpoint: toggle_subscription_path(issuable), moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), editable: can_edit_issuable, diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 274b38a7708..f478c8ede18 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -13,6 +13,8 @@ module Subscribable end def subscribed?(user, project = nil) + return false unless user + if subscription = subscriptions.find_by(user: user, project: project) subscription.subscribed else diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_entity.rb new file mode 100644 index 00000000000..ff23d8bf0c7 --- /dev/null +++ b/app/serializers/issuable_sidebar_entity.rb @@ -0,0 +1,16 @@ +class IssuableSidebarEntity < Grape::Entity + include RequestAwareEntity + + expose :participants, using: ::API::Entities::UserBasic do |issuable| + issuable.participants(request.current_user) + end + + expose :subscribed do |issuable| + issuable.subscribed?(request.current_user, issuable.project) + end + + expose :time_estimate + expose :total_time_spent + expose :human_time_estimate + expose :human_total_time_spent +end diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index 4fff54a9126..2555595379b 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -1,3 +1,16 @@ class IssueSerializer < BaseSerializer - entity IssueEntity + # This overrided method takes care of which entity should be used + # to serialize the `issue` based on `basic` key in `opts` param. + # Hence, `entity` doesn't need to be declared on the class scope. + def represent(merge_request, opts = {}) + entity = + case opts[:serializer] + when 'sidebar' + IssueSidebarEntity + else + IssueEntity + end + + super(merge_request, opts, entity) + end end diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_entity.rb new file mode 100644 index 00000000000..6c823dbfe95 --- /dev/null +++ b/app/serializers/issue_sidebar_entity.rb @@ -0,0 +1,3 @@ +class IssueSidebarEntity < IssuableSidebarEntity + expose :assignees, using: API::Entities::UserBasic +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 8461f158bb5..d54a6516aed 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,11 +1,7 @@ -class MergeRequestBasicEntity < Grape::Entity +class MergeRequestBasicEntity < IssuableSidebarEntity expose :assignee_id expose :merge_status expose :merge_error expose :state expose :source_branch_exists?, as: :source_branch_exists - expose :time_estimate - expose :total_time_spent - expose :human_time_estimate - expose :human_total_time_spent end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index f67034ce47a..e9d98d8baca 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -3,7 +3,14 @@ class MergeRequestSerializer < BaseSerializer # to serialize the `merge_request` based on `basic` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(merge_request, opts = {}) - entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity + entity = + case opts[:serializer] + when 'basic', 'sidebar' + MergeRequestBasicEntity + else + MergeRequestEntity + end + super(merge_request, opts, entity) end end diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml deleted file mode 100644 index 3f553c9fede..00000000000 --- a/app/views/shared/issuable/_participants.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- participants_row = 7 -- participants_size = participants.size -- participants_extra = participants_size - participants_row -.block.participants - .sidebar-collapsed-icon - = icon('users') - %span - = participants.count - .title.hide-collapsed - = pluralize participants.count, "participant" - .hide-collapsed.participants-list - - participants.each do |participant| - .participants-author.js-participants-author - = link_to_member(@project, participant, name: false, size: 24, lazy_load: true) - - if participants_extra > 0 - .hide-collapsed.participants-more - %button.btn-transparent.btn-blank.js-participants-more{ type: 'button', data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 7b7411b1e23..e0009a35b9f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -123,17 +123,10 @@ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe #js-lock-entry-point - = render "shared/issuable/participants", participants: issuable.participants(current_user) + .js-sidebar-participants-entry-point + - if current_user - - subscribed = issuable.subscribed?(current_user, @project) - .block.light.subscription{ data: { url: toggle_subscription_path(issuable) } } - .sidebar-collapsed-icon - = icon('rss', 'aria-hidden': 'true') - %span.issuable-header-text.hide-collapsed.pull-left - Notifications - - subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed' - %button.btn.btn-default.pull-right.js-subscribe-button.issuable-subscribe-button.hide-collapsed{ type: "button" } - %span= subscribed ? 'Unsubscribe' : 'Subscribe' + .js-sidebar-subscriptions-entry-point - project_ref = cross_project_reference(@project, issuable) .block.project-reference diff --git a/changelogs/unreleased/23206-load-participants-async.yml b/changelogs/unreleased/23206-load-participants-async.yml new file mode 100644 index 00000000000..12ab43fb88f --- /dev/null +++ b/changelogs/unreleased/23206-load-participants-async.yml @@ -0,0 +1,5 @@ +--- +title: Update participants and subscriptions button in issuable sidebar to be async +merge_request: 14836 +author: +type: changed diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 2c3ef2efd52..3843374678c 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -20,11 +20,13 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I should see that I am subscribed' do - expect(find('.issuable-subscribe-button span')).to have_content 'Unsubscribe' + wait_for_requests + expect(find('.js-issuable-subscribe-button span')).to have_content 'Unsubscribe' end step 'I should see that I am unsubscribed' do - expect(find('.issuable-subscribe-button span')).to have_content 'Subscribe' + wait_for_requests + expect(find('.js-issuable-subscribe-button span')).to have_content 'Subscribe' end step 'I click link "Closed"' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index b7cccddefdd..52ef8c6a589 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -83,15 +83,15 @@ describe Projects::MergeRequestsController do end describe 'as json' do - context 'with basic param' do + context 'with basic serializer param' do it 'renders basic MR entity as json' do - go(basic: true, format: :json) + go(serializer: 'basic', format: :json) expect(response).to match_response_schema('entities/merge_request_basic') end end - context 'without basic param' do + context 'without basic serializer param' do it 'renders the merge request in the json format' do go(format: :json) diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 60ed17c0c81..ebe6939df4c 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -538,7 +538,7 @@ describe 'Issue Boards', :js do end it 'does not show create new list' do - expect(page).not_to have_selector('.js-new-board-list') + expect(page).not_to have_button('.js-new-board-list') end it 'does not allow dragging' do diff --git a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb index 30a80f8e652..4ca435491cb 100644 --- a/spec/features/projects/merge_requests/user_manages_subscription_spec.rb +++ b/spec/features/projects/merge_requests/user_manages_subscription_spec.rb @@ -13,7 +13,7 @@ describe 'User manages subscription', :js do end it 'toggles subscription' do - subscribe_button = find('.issuable-subscribe-button span') + subscribe_button = find('.js-issuable-subscribe-button') expect(subscribe_button).to have_content('Subscribe') diff --git a/spec/fixtures/api/schemas/entities/issue.json b/spec/fixtures/api/schemas/entities/issue.json new file mode 100644 index 00000000000..3d3329a3406 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "author_id": { "type": "integer" }, + "description": { "type": ["string", "null"] }, + "lock_version": { "type": ["string", "null"] }, + "milestone_id": { "type": ["string", "null"] }, + "title": { "type": "string" }, + "moved_to_id": { "type": ["integer", "null"] }, + "project_id": { "type": "integer" }, + "web_url": { "type": "string" }, + "state": { "type": "string" }, + "create_note_path": { "type": "string" }, + "preview_note_path": { "type": "string" }, + "current_user": { + "type": "object", + "properties": { + "can_create_note": { "type": "boolean" }, + "can_update": { "type": "boolean" } + } + }, + "created_at": { "type": "date-time" }, + "updated_at": { "type": "date-time" }, + "branch_name": { "type": ["string", "null"] }, + "due_date": { "type": "date" }, + "confidential": { "type": "boolean" }, + "discussion_locked": { "type": ["boolean", "null"] }, + "updated_by_id": { "type": ["string", "null"] }, + "deleted_at": { "type": ["string", "null"] }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["integer", "null"] }, + "human_total_time_spent": { "type": ["integer", "null"] }, + "milestone": { "type": ["object", "null"] }, + "labels": { + "type": "array", + "items": { "$ref": "label.json" } + }, + "assignees": { "type": ["array", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/issue_sidebar.json b/spec/fixtures/api/schemas/entities/issue_sidebar.json new file mode 100644 index 00000000000..682e345d5f5 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/issue_sidebar.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "subscribed": { "type": "boolean" }, + "time_estimate": { "type": "integer" }, + "total_time_spent": { "type": "integer" }, + "human_time_estimate": { "type": ["integer", "null"] }, + "human_total_time_spent": { "type": ["integer", "null"] }, + "participants": { + "type": "array", + "items": { "$ref": "../public_api/v4/user/basic.json" } + }, + "assignees": { + "type": "array", + "items": { "$ref": "../public_api/v4/user/basic.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/entities/label.json b/spec/fixtures/api/schemas/entities/label.json new file mode 100644 index 00000000000..40dff764c17 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/label.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + }, + "description": { "type": ["string", "null"] }, + "text_color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + }, + "type": { "type": "string" }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } + }, + "additionalProperties": false +}
\ No newline at end of file diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json index 6b14188582a..995f13381ad 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_basic.json +++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json @@ -9,7 +9,9 @@ "human_time_estimate": { "type": ["string", "null"] }, "human_total_time_spent": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] }, - "assignee_id": { "type": ["integer", "null"] } + "assignee_id": { "type": ["integer", "null"] }, + "subscribed": { "type": ["boolean", "null"] }, + "participants": { "type": "array" } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index e1f62508933..a55ecaa5697 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -19,32 +19,7 @@ }, "labels": { "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "color", - "description", - "title", - "priority" - ], - "properties": { - "id": { "type": "integer" }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" - }, - "description": { "type": ["string", "null"] }, - "text_color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" - }, - "type": { "type": "string" }, - "title": { "type": "string" }, - "priority": { "type": ["integer", "null"] } - }, - "additionalProperties": false - } + "items": { "$ref": "entities/label.json" } }, "assignee": { "id": { "type": "integet" }, diff --git a/spec/javascripts/issuable_context_spec.js b/spec/javascripts/issuable_context_spec.js deleted file mode 100644 index f266209027a..00000000000 --- a/spec/javascripts/issuable_context_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import $ from 'jquery'; -import IssuableContext from '~/issuable_context'; - -describe('IssuableContext', () => { - describe('toggleHiddenParticipants', () => { - const event = jasmine.createSpyObj('event', ['preventDefault']); - - beforeEach(() => { - spyOn($.fn, 'data').and.returnValue('data'); - spyOn($.fn, 'text').and.returnValue('data'); - }); - - afterEach(() => { - gl.lazyLoader = undefined; - }); - - it('calls loadCheck if lazyLoader is set', () => { - gl.lazyLoader = jasmine.createSpyObj('lazyLoader', ['loadCheck']); - - IssuableContext.prototype.toggleHiddenParticipants(event); - - expect(gl.lazyLoader.loadCheck).toHaveBeenCalled(); - }); - - it('does not throw if lazyLoader is not defined', () => { - gl.lazyLoader = undefined; - - const toggle = IssuableContext.prototype.toggleHiddenParticipants.bind(null, event); - - expect(toggle).not.toThrow(); - }); - }); -}); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js index e2b6bcabc98..0682b463043 100644 --- a/spec/javascripts/sidebar/mock_data.js +++ b/spec/javascripts/sidebar/mock_data.js @@ -109,12 +109,14 @@ const sidebarMockData = { labels: [], web_url: '/root/some-project/issues/5', }, + '/gitlab-org/gitlab-shell/issues/5/toggle_subscription': {}, }, }; export default { mediator: { endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', editable: true, diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js new file mode 100644 index 00000000000..30cc549c7c0 --- /dev/null +++ b/spec/javascripts/sidebar/participants_spec.js @@ -0,0 +1,174 @@ +import Vue from 'vue'; +import participants from '~/sidebar/components/participants/participants.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [ + PARTICIPANT, + { ...PARTICIPANT, id: 2 }, + { ...PARTICIPANT, id: 3 }, +]; + +describe('Participants', function () { + let vm; + let Participants; + + beforeEach(() => { + Participants = Vue.extend(participants); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed sidebar state', () => { + it('shows loading spinner when loading', () => { + vm = mountComponent(Participants, { + loading: true, + }); + + expect(vm.$el.querySelector('.js-participants-collapsed-loading-icon')).toBeDefined(); + }); + + it('shows participant count when given', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + }); + const countEl = vm.$el.querySelector('.js-participants-collapsed-count'); + + expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`); + }); + + it('shows full participant count when there are hidden participants', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 1, + }); + const countEl = vm.$el.querySelector('.js-participants-collapsed-count'); + + expect(countEl.textContent.trim()).toBe(`${PARTICIPANT_LIST.length}`); + }); + }); + + describe('expanded sidebar state', () => { + it('shows loading spinner when loading', () => { + vm = mountComponent(Participants, { + loading: true, + }); + + expect(vm.$el.querySelector('.js-participants-expanded-loading-icon')).toBeDefined(); + }); + + it('when only showing visible participants, shows an avatar only for each participant under the limit', (done) => { + const numberOfLessParticipants = 2; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + vm.isShowingMoreParticipants = false; + + Vue.nextTick() + .then(() => { + const participantEls = vm.$el.querySelectorAll('.js-participants-author'); + + expect(participantEls.length).toBe(numberOfLessParticipants); + }) + .then(done) + .catch(done.fail); + }); + + it('when only showing all participants, each has an avatar', (done) => { + const numberOfLessParticipants = 2; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + vm.isShowingMoreParticipants = true; + + Vue.nextTick() + .then(() => { + const participantEls = vm.$el.querySelectorAll('.js-participants-author'); + + expect(participantEls.length).toBe(PARTICIPANT_LIST.length); + }) + .then(done) + .catch(done.fail); + }); + + it('does not have more participants link when they can all be shown', () => { + const numberOfLessParticipants = 100; + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants, + }); + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(PARTICIPANT_LIST.length).toBeLessThan(numberOfLessParticipants); + expect(moreParticipantLink).toBeNull(); + }); + + it('when too many participants, has more participants link to show more', (done) => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + vm.isShowingMoreParticipants = false; + + Vue.nextTick() + .then(() => { + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(moreParticipantLink.textContent.trim()).toBe('+ 1 more'); + }) + .then(done) + .catch(done.fail); + }); + + it('when too many participants and already showing them, has more participants link to show less', (done) => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + vm.isShowingMoreParticipants = true; + + Vue.nextTick() + .then(() => { + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(moreParticipantLink.textContent.trim()).toBe('- show less'); + }) + .then(done) + .catch(done.fail); + }); + + it('clicking more participants link emits event', () => { + vm = mountComponent(Participants, { + loading: false, + participants: PARTICIPANT_LIST, + numberOfLessParticipants: 2, + }); + const moreParticipantLink = vm.$el.querySelector('.js-toggle-participants-button'); + + expect(vm.isShowingMoreParticipants).toBe(false); + + moreParticipantLink.click(); + + expect(vm.isShowingMoreParticipants).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index 3aa8ca5db0d..7deb1fd2118 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -57,8 +57,8 @@ describe('Sidebar mediator', () => { .then(() => { expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm); expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled(); - done(); }) + .then(done) .catch(done.fail); }); @@ -72,8 +72,21 @@ describe('Sidebar mediator', () => { .then(() => { expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId); expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5'); - done(); }) + .then(done) + .catch(done.fail); + }); + + it('toggle subscription', (done) => { + this.mediator.store.setSubscribedState(false); + spyOn(this.mediator.service, 'toggleSubscription').and.callThrough(); + + this.mediator.toggleSubscription() + .then(() => { + expect(this.mediator.service.toggleSubscription).toHaveBeenCalled(); + expect(this.mediator.store.subscribed).toEqual(true); + }) + .then(done) .catch(done.fail); }); }); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index a4bd8ba8d88..7324d34d84a 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -7,6 +7,7 @@ describe('Sidebar service', () => { Vue.http.interceptors.push(Mock.sidebarMockInterceptor); this.service = new SidebarService({ endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription', moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', }); @@ -23,6 +24,7 @@ describe('Sidebar service', () => { expect(resp).toBeDefined(); done(); }) + .then(done) .catch(done.fail); }); @@ -30,8 +32,8 @@ describe('Sidebar service', () => { this.service.update('issue[assignee_ids]', [1]) .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) .catch(done.fail); }); @@ -39,8 +41,8 @@ describe('Sidebar service', () => { this.service.getProjectsAutocomplete() .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) .catch(done.fail); }); @@ -48,8 +50,17 @@ describe('Sidebar service', () => { this.service.moveIssue(123) .then((resp) => { expect(resp).toBeDefined(); - done(); }) + .then(done) + .catch(done.fail); + }); + + it('toggles the subscription', (done) => { + this.service.toggleSubscription() + .then((resp) => { + expect(resp).toBeDefined(); + }) + .then(done) .catch(done.fail); }); }); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js index 69eb3839d67..51dee64fb93 100644 --- a/spec/javascripts/sidebar/sidebar_store_spec.js +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -2,21 +2,36 @@ import SidebarStore from '~/sidebar/stores/sidebar_store'; import Mock from './mock_data'; import UsersMockHelper from '../helpers/user_mock_data_helper'; -describe('Sidebar store', () => { - const assignee = { - id: 2, - name: 'gitlab user 2', - username: 'gitlab2', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }; - - const anotherAssignee = { - id: 3, - name: 'gitlab user 3', - username: 'gitlab3', - avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }; +const ASSIGNEE = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const ANOTHER_ASSINEE = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [ + PARTICIPANT, + { ...PARTICIPANT, id: 2 }, + { ...PARTICIPANT, id: 3 }, +]; +describe('Sidebar store', () => { beforeEach(() => { this.store = new SidebarStore({ currentUser: { @@ -40,23 +55,23 @@ describe('Sidebar store', () => { }); it('adds a new assignee', () => { - this.store.addAssignee(assignee); + this.store.addAssignee(ASSIGNEE); expect(this.store.assignees.length).toEqual(1); }); it('removes an assignee', () => { - this.store.removeAssignee(assignee); + this.store.removeAssignee(ASSIGNEE); expect(this.store.assignees.length).toEqual(0); }); it('finds an existent assignee', () => { let foundAssignee; - this.store.addAssignee(assignee); - foundAssignee = this.store.findAssignee(assignee); + this.store.addAssignee(ASSIGNEE); + foundAssignee = this.store.findAssignee(ASSIGNEE); expect(foundAssignee).toBeDefined(); - expect(foundAssignee).toEqual(assignee); - foundAssignee = this.store.findAssignee(anotherAssignee); + expect(foundAssignee).toEqual(ASSIGNEE); + foundAssignee = this.store.findAssignee(ANOTHER_ASSINEE); expect(foundAssignee).toBeUndefined(); }); @@ -65,6 +80,28 @@ describe('Sidebar store', () => { expect(this.store.assignees.length).toEqual(0); }); + it('sets participants data', () => { + expect(this.store.participants.length).toEqual(0); + + this.store.setParticipantsData({ + participants: PARTICIPANT_LIST, + }); + + expect(this.store.isFetching.participants).toEqual(false); + expect(this.store.participants.length).toEqual(PARTICIPANT_LIST.length); + }); + + it('sets subcriptions data', () => { + expect(this.store.subscribed).toEqual(null); + + this.store.setSubscriptionsData({ + subscribed: true, + }); + + expect(this.store.isFetching.subscriptions).toEqual(false); + expect(this.store.subscribed).toEqual(true); + }); + it('set assigned data', () => { const users = { assignees: UsersMockHelper.createNumberRandomUsers(3), @@ -75,6 +112,14 @@ describe('Sidebar store', () => { expect(this.store.assignees.length).toEqual(3); }); + it('sets fetching state', () => { + expect(this.store.isFetching.participants).toEqual(true); + + this.store.setFetchingState('participants', false); + + expect(this.store.isFetching.participants).toEqual(false); + }); + it('set time tracking data', () => { this.store.setTimeTrackingData(Mock.time); expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); @@ -90,6 +135,14 @@ describe('Sidebar store', () => { expect(this.store.autocompleteProjects).toEqual(projects); }); + it('sets subscribed state', () => { + expect(this.store.subscribed).toEqual(null); + + this.store.setSubscribedState(true); + + expect(this.store.subscribed).toEqual(true); + }); + it('set move to project ID', () => { const projectId = 7; this.store.setMoveToProjectId(projectId); diff --git a/spec/javascripts/sidebar/sidebar_subscriptions_spec.js b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js new file mode 100644 index 00000000000..7adf22b0f1f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_subscriptions_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import sidebarSubscriptions from '~/sidebar/components/subscriptions/sidebar_subscriptions.vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import eventHub from '~/sidebar/event_hub'; +import mountComponent from '../helpers/vue_mount_component_helper'; +import Mock from './mock_data'; + +describe('Sidebar Subscriptions', function () { + let vm; + let SidebarSubscriptions; + + beforeEach(() => { + SidebarSubscriptions = Vue.extend(sidebarSubscriptions); + // Setup the stores, services, etc + // eslint-disable-next-line no-new + new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + vm.$destroy(); + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator toggleSubscription on event', () => { + spyOn(SidebarMediator.prototype, 'toggleSubscription').and.returnValue(Promise.resolve()); + vm = mountComponent(SidebarSubscriptions, {}); + + eventHub.$emit('toggleSubscription'); + + expect(SidebarMediator.prototype.toggleSubscription).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/sidebar/subscriptions_spec.js b/spec/javascripts/sidebar/subscriptions_spec.js new file mode 100644 index 00000000000..9b33dd02fb9 --- /dev/null +++ b/spec/javascripts/sidebar/subscriptions_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +describe('Subscriptions', function () { + let vm; + let Subscriptions; + + beforeEach(() => { + Subscriptions = Vue.extend(subscriptions); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('shows loading spinner when loading', () => { + vm = mountComponent(Subscriptions, { + loading: true, + subscribed: undefined, + }); + + expect(vm.$refs.loadingButton.loading).toBe(true); + expect(vm.$refs.loadingButton.label).toBeUndefined(); + }); + + it('has "Subscribe" text when currently not subscribed', () => { + vm = mountComponent(Subscriptions, { + subscribed: false, + }); + + expect(vm.$refs.loadingButton.label).toBe('Subscribe'); + }); + + it('has "Unsubscribe" text when currently not subscribed', () => { + vm = mountComponent(Subscriptions, { + subscribed: true, + }); + + expect(vm.$refs.loadingButton.label).toBe('Unsubscribe'); + }); +}); diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index 28ff8158e0e..45dfb136aea 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -6,6 +6,12 @@ describe Subscribable, 'Subscribable' do let(:user_1) { create(:user) } describe '#subscribed?' do + context 'without user' do + it 'returns false' do + expect(resource.subscribed?(nil, project)).to be_falsey + end + end + context 'without project' do it 'returns false when no subscription exists' do expect(resource.subscribed?(user_1)).to be_falsey diff --git a/spec/serializers/issue_serializer_spec.rb b/spec/serializers/issue_serializer_spec.rb new file mode 100644 index 00000000000..75578816e75 --- /dev/null +++ b/spec/serializers/issue_serializer_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe IssueSerializer do + let(:resource) { create(:issue) } + let(:user) { create(:user) } + let(:json_entity) do + described_class.new(current_user: user) + .represent(resource, serializer: serializer) + .with_indifferent_access + end + + context 'non-sidebar issue serialization' do + let(:serializer) { nil } + + it 'matches issue json schema' do + expect(json_entity).to match_schema('entities/issue') + end + end + + context 'sidebar issue serialization' do + let(:serializer) { 'sidebar' } + + it 'matches sidebar issue json schema' do + expect(json_entity).to match_schema('entities/issue_sidebar') + end + end +end diff --git a/spec/serializers/merge_request_basic_serializer_spec.rb b/spec/serializers/merge_request_basic_serializer_spec.rb index 4daf5a59d0c..1fad8e6bc5d 100644 --- a/spec/serializers/merge_request_basic_serializer_spec.rb +++ b/spec/serializers/merge_request_basic_serializer_spec.rb @@ -4,9 +4,13 @@ describe MergeRequestBasicSerializer do let(:resource) { create(:merge_request) } let(:user) { create(:user) } - subject { described_class.new.represent(resource) } + let(:json_entity) do + described_class.new(current_user: user) + .represent(resource, serializer: 'basic') + .with_indifferent_access + end - it 'has important MergeRequest attributes' do - expect(subject).to include(:merge_status) + it 'matches basic merge request json' do + expect(json_entity).to match_schema('entities/merge_request_basic') end end diff --git a/spec/serializers/merge_request_serializer_spec.rb b/spec/serializers/merge_request_serializer_spec.rb index 73fbecc153d..e3abefa6d63 100644 --- a/spec/serializers/merge_request_serializer_spec.rb +++ b/spec/serializers/merge_request_serializer_spec.rb @@ -9,11 +9,11 @@ describe MergeRequestSerializer do end describe '#represent' do - let(:opts) { { basic: basic } } - subject { serializer.represent(merge_request, basic: basic) } + let(:opts) { { serializer: serializer_entity } } + subject { serializer.represent(merge_request, serializer: serializer_entity) } - context 'when basic param is truthy' do - let(:basic) { true } + context 'when passing basic serializer param' do + let(:serializer_entity) { 'basic' } it 'calls super class #represent with correct params' do expect_any_instance_of(BaseSerializer).to receive(:represent) @@ -23,8 +23,8 @@ describe MergeRequestSerializer do end end - context 'when basic param is falsy' do - let(:basic) { false } + context 'when serializer param is falsy' do + let(:serializer_entity) { nil } it 'calls super class #represent with correct params' do expect_any_instance_of(BaseSerializer).to receive(:represent) diff --git a/spec/views/shared/issuable/_participants.html.haml.rb b/spec/views/shared/issuable/_participants.html.haml.rb deleted file mode 100644 index 51059d4c0d7..00000000000 --- a/spec/views/shared/issuable/_participants.html.haml.rb +++ /dev/null @@ -1,26 +0,0 @@ -require 'spec_helper' -require 'nokogiri' - -describe 'shared/issuable/_participants.html.haml' do - let(:project) { create(:project) } - let(:participants) { create_list(:user, 100) } - - before do - allow(view).to receive_messages(project: project, - participants: participants) - end - - it 'renders lazy loaded avatars' do - render 'shared/issuable/participants' - - html = Nokogiri::HTML(rendered) - - avatars = html.css('.participants-author img') - - avatars.each do |avatar| - expect(avatar[:class]).to include('lazy') - expect(avatar[:src]).to eql(LazyImageTagHelper.placeholder_image) - expect(avatar[:"data-src"]).to match('http://www.gravatar.com/avatar/') - end - end -end |