summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2017-10-31 16:15:05 +0000
committerPhil Hughes <me@iamphill.com>2017-10-31 16:15:05 +0000
commitfe80967bfbde9d235ea16ceb4d3b02929a60bd4c (patch)
treeb45870b8c946e5304a2fd3ad64a1453d166da0ca
parent74a0e855e1d5049e265c85ac01d511c25f2a46f1 (diff)
parent6dc9028fbb3856c8c7814446ed176880f7d5213f (diff)
downloadgitlab-ce-fe80967bfbde9d235ea16ceb4d3b02929a60bd4c.tar.gz
Merge branch '23206-load-participants-async' into 'master'
Load participants async Closes #23206 See merge request gitlab-org/gitlab-ce!14836
-rw-r--r--app/assets/javascripts/issuable_context.js28
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue125
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants.vue26
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue45
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue60
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js5
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js34
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js16
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js2
-rw-r--r--app/assets/stylesheets/pages/issuable.scss6
-rw-r--r--app/controllers/projects/issues_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb2
-rw-r--r--app/helpers/issuables_helper.rb23
-rw-r--r--app/models/concerns/subscribable.rb2
-rw-r--r--app/serializers/issuable_sidebar_entity.rb16
-rw-r--r--app/serializers/issue_serializer.rb15
-rw-r--r--app/serializers/issue_sidebar_entity.rb3
-rw-r--r--app/serializers/merge_request_basic_entity.rb6
-rw-r--r--app/serializers/merge_request_serializer.rb9
-rw-r--r--app/views/shared/issuable/_participants.html.haml18
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml13
-rw-r--r--changelogs/unreleased/23206-load-participants-async.yml5
-rw-r--r--features/steps/project/issues/issues.rb6
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb6
-rw-r--r--spec/features/boards/boards_spec.rb2
-rw-r--r--spec/features/projects/merge_requests/user_manages_subscription_spec.rb2
-rw-r--r--spec/fixtures/api/schemas/entities/issue.json44
-rw-r--r--spec/fixtures/api/schemas/entities/issue_sidebar.json21
-rw-r--r--spec/fixtures/api/schemas/entities/label.json26
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request_basic.json4
-rw-r--r--spec/fixtures/api/schemas/issue.json27
-rw-r--r--spec/javascripts/issuable_context_spec.js33
-rw-r--r--spec/javascripts/sidebar/mock_data.js2
-rw-r--r--spec/javascripts/sidebar/participants_spec.js174
-rw-r--r--spec/javascripts/sidebar/sidebar_mediator_spec.js17
-rw-r--r--spec/javascripts/sidebar/sidebar_service_spec.js17
-rw-r--r--spec/javascripts/sidebar/sidebar_store_spec.js93
-rw-r--r--spec/javascripts/sidebar/sidebar_subscriptions_spec.js36
-rw-r--r--spec/javascripts/sidebar/subscriptions_spec.js42
-rw-r--r--spec/models/concerns/subscribable_spec.rb6
-rw-r--r--spec/serializers/issue_serializer_spec.rb27
-rw-r--r--spec/serializers/merge_request_basic_serializer_spec.rb10
-rw-r--r--spec/serializers/merge_request_serializer_spec.rb12
-rw-r--r--spec/views/shared/issuable/_participants.html.haml.rb26
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