Commit c2d400a0 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '241538-update-issue-labels-on-issue-detail-view-in-real-time' into 'master'

Resolve "Update Issue labels on Issue detail view in real-time"

See merge request gitlab-org/gitlab!83743
parents 17bc3585 6fa5b214
<script> <script>
import produce from 'immer'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants'; import { IssuableType } from '~/issues/constants';
import { assigneesQueries } from '~/sidebar/constants'; import { assigneesQueries } from '~/sidebar/constants';
...@@ -17,10 +16,6 @@ export default { ...@@ -17,10 +16,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
issuableId: {
type: Number,
required: true,
},
queryVariables: { queryVariables: {
type: Object, type: Object,
required: true, required: true,
...@@ -30,6 +25,9 @@ export default { ...@@ -30,6 +25,9 @@ export default {
issuableClass() { issuableClass() {
return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType); return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
}, },
issuableId() {
return this.issuable?.id;
},
}, },
apollo: { apollo: {
issuable: { issuable: {
...@@ -48,29 +46,36 @@ export default { ...@@ -48,29 +46,36 @@ export default {
}, },
variables() { variables() {
return { return {
issuableId: convertToGraphQLId(this.issuableClass, this.issuableId), issuableId: this.issuableId,
}; };
}, },
updateQuery(prev, { subscriptionData }) { skip() {
if (prev && subscriptionData?.data?.issuableAssigneesUpdated) { return !this.issuableId;
const data = produce(prev, (draftData) => { },
draftData.workspace.issuable.assignees.nodes = updateQuery(
subscriptionData.data.issuableAssigneesUpdated.assignees.nodes; _,
}); {
subscriptionData: {
data: { issuableAssigneesUpdated },
},
},
) {
if (issuableAssigneesUpdated) {
const {
id,
assignees: { nodes },
} = issuableAssigneesUpdated;
if (this.mediator) { if (this.mediator) {
this.handleFetchResult(data); this.handleFetchResult(nodes);
} }
return data; this.$emit('assigneesUpdated', { id, assignees: nodes });
} }
return prev;
}, },
}, },
}, },
}, },
methods: { methods: {
handleFetchResult(data) { handleFetchResult(nodes) {
const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({ const assignees = nodes.map((n) => ({
...n, ...n,
avatar_url: n.avatarUrl, avatar_url: n.avatarUrl,
......
...@@ -232,6 +232,7 @@ export default { ...@@ -232,6 +232,7 @@ export default {
:issuable-type="issuableType" :issuable-type="issuableType"
:issuable-id="issuableId" :issuable-id="issuableId"
:query-variables="queryVariables" :query-variables="queryVariables"
@assigneesUpdated="$emit('assignees-updated', $event)"
/> />
<sidebar-editable-item <sidebar-editable-item
ref="toggle" ref="toggle"
......
#import "~/graphql_shared/fragments/label.fragment.graphql"
subscription issuableLabelsUpdated($issuableId: IssuableID!) {
issuableLabelsUpdated(issuableId: $issuableId) {
... on Issue {
id
labels {
nodes {
...Label
}
}
}
... on MergeRequest {
id
labels {
nodes {
...Label
}
}
}
}
}
<script> <script>
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issues/constants'; import { IssuableType } from '~/issues/constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { issuableLabelsQueries } from '~/sidebar/constants'; import { issuableLabelsQueries } from '~/sidebar/constants';
...@@ -21,6 +24,7 @@ export default { ...@@ -21,6 +24,7 @@ export default {
DropdownContents, DropdownContents,
SidebarEditableItem, SidebarEditableItem,
}, },
mixins: [glFeatureFlagsMixin()],
inject: { inject: {
allowLabelEdit: { allowLabelEdit: {
default: false, default: false,
...@@ -106,7 +110,7 @@ export default { ...@@ -106,7 +110,7 @@ export default {
data() { data() {
return { return {
contentIsOnViewport: true, contentIsOnViewport: true,
issuableLabels: [], issuable: null,
labelsSelectInProgress: false, labelsSelectInProgress: false,
oldIid: null, oldIid: null,
sidebarExpandedOnClick: false, sidebarExpandedOnClick: false,
...@@ -114,14 +118,23 @@ export default { ...@@ -114,14 +118,23 @@ export default {
}, },
computed: { computed: {
isLoading() { isLoading() {
return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading; return this.labelsSelectInProgress || this.$apollo.queries.issuable.loading;
}, },
issuableLabelIds() { issuableLabelIds() {
return this.issuableLabels.map((label) => label.id); return this.issuableLabels.map((label) => label.id);
}, },
issuableLabels() {
return this.issuable?.labels.nodes || [];
},
issuableId() {
return this.issuable?.id;
},
isRealtimeEnabled() {
return this.glFeatures.realtimeLabels;
},
}, },
apollo: { apollo: {
issuableLabels: { issuable: {
query() { query() {
return issuableLabelsQueries[this.issuableType].issuableQuery; return issuableLabelsQueries[this.issuableType].issuableQuery;
}, },
...@@ -135,11 +148,40 @@ export default { ...@@ -135,11 +148,40 @@ export default {
}; };
}, },
update(data) { update(data) {
return data.workspace?.issuable?.labels.nodes || []; return data.workspace?.issuable;
}, },
error() { error() {
createFlash({ message: __('Error fetching labels.') }); createFlash({ message: __('Error fetching labels.') });
}, },
subscribeToMore: {
document() {
return issuableLabelsSubscription;
},
variables() {
return {
issuableId: this.issuableId,
};
},
skip() {
return !this.issuableId || !this.isDropdownVariantSidebar || !this.isRealtimeEnabled;
},
updateQuery(
_,
{
subscriptionData: {
data: { issuableLabelsUpdated },
},
},
) {
if (issuableLabelsUpdated) {
const {
id,
labels: { nodes },
} = issuableLabelsUpdated;
this.$emit('updateSelectedLabels', { id, labels: nodes });
}
},
},
}, },
}, },
watch: { watch: {
......
...@@ -9,6 +9,7 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -9,6 +9,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
push_frontend_feature_flag(:realtime_labels, group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e| experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control { } e.control { }
e.candidate { } e.candidate { }
......
...@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml) push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:realtime_labels, project&.group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e| experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control { } e.control { }
e.candidate { } e.candidate { }
......
...@@ -49,6 +49,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -49,6 +49,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:confidential_notes, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, project, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_issue_discussions, project, default_enabled: :yaml) push_frontend_feature_flag(:paginated_issue_discussions, project, default_enabled: :yaml)
push_frontend_feature_flag(:realtime_labels, project, default_enabled: :yaml)
push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?)
end end
......
...@@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml) push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml) push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
push_frontend_feature_flag(:realtime_labels, project, default_enabled: :yaml)
# Usage data feature flags # Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml) push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
......
...@@ -12,4 +12,8 @@ module GraphqlTriggers ...@@ -12,4 +12,8 @@ module GraphqlTriggers
def self.issuable_title_updated(issuable) def self.issuable_title_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable) GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable)
end end
def self.issuable_labels_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable)
end
end end
...@@ -12,5 +12,8 @@ module Types ...@@ -12,5 +12,8 @@ module Types
field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true, field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the title of an issuable is updated.' description: 'Triggered when the title of an issuable is updated.'
field :issuable_labels_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the labels of an issuable are updated.'
end end
end end
...@@ -525,12 +525,23 @@ class IssuableBaseService < ::BaseProjectService ...@@ -525,12 +525,23 @@ class IssuableBaseService < ::BaseProjectService
attrs_changed || labels_changed || assignees_changed || reviewers_changed attrs_changed || labels_changed || assignees_changed || reviewers_changed
end end
def has_label_changes?(issuable, old_labels)
Set.new(issuable.labels) != Set.new(old_labels)
end
def invalidate_cache_counts(issuable, users: []) def invalidate_cache_counts(issuable, users: [])
users.each do |user| users.each do |user|
user.public_send("invalidate_#{issuable.noteable_target_type_name}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend user.public_send("invalidate_#{issuable.noteable_target_type_name}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
end end
end end
# override if needed
def handle_label_changes(issuable, old_labels)
return unless has_label_changes?(issuable, old_labels)
GraphqlTriggers.issuable_labels_updated(issuable)
end
# override if needed # override if needed
def handle_changes(issuable, options) def handle_changes(issuable, options)
end end
......
...@@ -63,6 +63,7 @@ module Issues ...@@ -63,6 +63,7 @@ module Issues
handle_assignee_changes(issue, old_assignees) handle_assignee_changes(issue, old_assignees)
handle_confidential_change(issue) handle_confidential_change(issue)
handle_label_changes(issue, old_labels)
handle_added_labels(issue, old_labels) handle_added_labels(issue, old_labels)
handle_milestone_change(issue) handle_milestone_change(issue)
handle_added_mentions(issue, old_mentioned_users) handle_added_mentions(issue, old_mentioned_users)
......
...@@ -34,6 +34,7 @@ module MergeRequests ...@@ -34,6 +34,7 @@ module MergeRequests
handle_target_branch_change(merge_request) handle_target_branch_change(merge_request)
handle_milestone_change(merge_request) handle_milestone_change(merge_request)
handle_draft_status_change(merge_request, changed_fields) handle_draft_status_change(merge_request, changed_fields)
handle_label_changes(merge_request, old_labels)
track_title_and_desc_edits(changed_fields) track_title_and_desc_edits(changed_fields)
track_discussion_lock_toggle(merge_request, changed_fields) track_discussion_lock_toggle(merge_request, changed_fields)
......
---
name: realtime_labels
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83743
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/357370
milestone: '14.10'
type: development
group: group::project management
default_enabled: false
#import "~/graphql_shared/fragments/label.fragment.graphql"
subscription issuableLabelsUpdatedEE($issuableId: IssuableID!) {
issuableLabelsUpdated(issuableId: $issuableId) {
... on Issue {
id
labels {
nodes {
...Label
}
}
}
... on MergeRequest {
id
labels {
nodes {
...Label
}
}
}
... on Epic {
id
labels {
nodes {
...Label
}
}
}
}
}
...@@ -9,6 +9,10 @@ class Groups::EpicBoardsController < Groups::ApplicationController ...@@ -9,6 +9,10 @@ class Groups::EpicBoardsController < Groups::ApplicationController
before_action :redirect_to_recent_board, only: [:index] before_action :redirect_to_recent_board, only: [:index]
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:realtime_labels, group, default_enabled: :yaml)
end
track_redis_hll_event :index, :show, name: 'g_project_management_users_viewing_epic_boards' track_redis_hll_event :index, :show, name: 'g_project_management_users_viewing_epic_boards'
feature_category :portfolio_management feature_category :portfolio_management
......
...@@ -19,6 +19,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -19,6 +19,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:related_epics_widget, @group, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:related_epics_widget, @group, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, @group, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:confidential_notes, @group, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:realtime_labels, group, default_enabled: :yaml)
end end
feature_category :portfolio_management feature_category :portfolio_management
......
...@@ -45,9 +45,7 @@ module Epics ...@@ -45,9 +45,7 @@ module Epics
old_mentioned_users = old_associations.fetch(:mentioned_users, []) old_mentioned_users = old_associations.fetch(:mentioned_users, [])
old_labels = old_associations.fetch(:labels, []) old_labels = old_associations.fetch(:labels, [])
if has_changes?(epic, old_labels: old_labels) handle_label_changes(epic, old_labels)
todo_service.resolve_todos_for_target(epic, current_user)
end
todo_service.update_epic(epic, current_user, old_mentioned_users) todo_service.update_epic(epic, current_user, old_mentioned_users)
...@@ -56,6 +54,14 @@ module Epics ...@@ -56,6 +54,14 @@ module Epics
end end
end end
def handle_label_changes(epic, old_labels)
return unless has_label_changes?(epic, old_labels)
super
todo_service.resolve_todos_for_target(epic, current_user)
end
def handle_confidentiality_change(epic) def handle_confidentiality_change(epic)
if epic.confidential? if epic.confidential?
::Gitlab::UsageDataCounters::EpicActivityUniqueCounter.track_epic_confidential_action(author: current_user) ::Gitlab::UsageDataCounters::EpicActivityUniqueCounter.track_epic_confidential_action(author: current_user)
......
...@@ -537,6 +537,16 @@ RSpec.describe Epics::UpdateService do ...@@ -537,6 +537,16 @@ RSpec.describe Epics::UpdateService do
let(:parent) { group } let(:parent) { group }
end end
it_behaves_like 'broadcasting issuable labels updates' do
let(:label_a) { create(:group_label, group: group) }
let(:label_b) { create(:group_label, group: group) }
let(:issuable) { epic }
def update_issuable(update_params)
update_epic(update_params)
end
end
context 'with quick actions in the description' do context 'with quick actions in the description' do
before do before do
stub_licensed_features(epics: true, subepics: true) stub_licensed_features(epics: true, subepics: true)
......
...@@ -6,6 +6,10 @@ RSpec.describe 'Issues > Real-time sidebar', :js do ...@@ -6,6 +6,10 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
let_it_be(:project) { create(:project, :public) } let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) } let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:label) { create(:label, project: project, name: 'Development') }
let(:labels_widget) { find('[data-testid="sidebar-labels"]') }
let(:labels_value) { find('[data-testid="value-wrapper"]') }
before_all do before_all do
project.add_developer(user) project.add_developer(user)
...@@ -32,4 +36,37 @@ RSpec.describe 'Issues > Real-time sidebar', :js do ...@@ -32,4 +36,37 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
expect(page.find('.assignee')).to have_content user.name expect(page.find('.assignee')).to have_content user.name
end end
end end
it 'updates the label in real-time' do
Capybara::Session.new(:other_session)
using_session :other_session do
visit project_issue_path(project, issue)
wait_for_requests
expect(labels_value).to have_content('None')
end
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
expect(labels_value).to have_content('None')
page.within(labels_widget) do
click_on 'Edit'
end
wait_for_all_requests
click_button label.name
click_button 'Close'
wait_for_requests
expect(labels_value).to have_content(label.name)
using_session :other_session do
expect(labels_value).to have_content(label.name)
end
end
end end
...@@ -2,11 +2,16 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,11 +2,16 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data'; import Mock, {
issuableQueryResponse,
subscriptionNullResponse,
subscriptionResponse,
} from './mock_data';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -20,7 +25,6 @@ describe('Assignees Realtime', () => { ...@@ -20,7 +25,6 @@ describe('Assignees Realtime', () => {
const createComponent = ({ const createComponent = ({
issuableType = 'issue', issuableType = 'issue',
issuableId = 1,
subscriptionHandler = subscriptionInitialHandler, subscriptionHandler = subscriptionInitialHandler,
} = {}) => { } = {}) => {
fakeApollo = createMockApollo([ fakeApollo = createMockApollo([
...@@ -30,7 +34,6 @@ describe('Assignees Realtime', () => { ...@@ -30,7 +34,6 @@ describe('Assignees Realtime', () => {
wrapper = shallowMount(AssigneesRealtime, { wrapper = shallowMount(AssigneesRealtime, {
propsData: { propsData: {
issuableType, issuableType,
issuableId,
queryVariables: { queryVariables: {
issuableIid: '1', issuableIid: '1',
projectPath: 'path/to/project', projectPath: 'path/to/project',
...@@ -60,11 +63,23 @@ describe('Assignees Realtime', () => { ...@@ -60,11 +63,23 @@ describe('Assignees Realtime', () => {
}); });
}); });
it('calls the subscription with correct variable for issue', () => { it('calls the subscription with correct variable for issue', async () => {
createComponent(); createComponent();
await waitForPromises();
expect(subscriptionInitialHandler).toHaveBeenCalledWith({ expect(subscriptionInitialHandler).toHaveBeenCalledWith({
issuableId: 'gid://gitlab/Issue/1', issuableId: 'gid://gitlab/Issue/1',
}); });
}); });
it('emits an `assigneesUpdated` event on subscription response', async () => {
createComponent({
subscriptionHandler: jest.fn().mockResolvedValue(subscriptionResponse),
});
await waitForPromises();
expect(wrapper.emitted('assigneesUpdated')).toEqual([
[{ id: '1', assignees: subscriptionResponse.data.issuableAssigneesUpdated.assignees.nodes }],
]);
});
}); });
...@@ -415,6 +415,28 @@ export const subscriptionNullResponse = { ...@@ -415,6 +415,28 @@ export const subscriptionNullResponse = {
}, },
}; };
export const subscriptionResponse = {
data: {
issuableAssigneesUpdated: {
id: '1',
assignees: {
nodes: [
{
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
],
},
},
},
};
const mockUser1 = { const mockUser1 = {
__typename: 'UserCore', __typename: 'UserCore',
id: 'gid://gitlab/User/1', id: 'gid://gitlab/User/1',
......
...@@ -11,9 +11,15 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/ ...@@ -11,9 +11,15 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/
import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse } from './mock_data'; import {
mockConfig,
issuableLabelsQueryResponse,
updateLabelsMutationResponse,
issuableLabelsSubscriptionResponse,
} from './mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -21,6 +27,7 @@ Vue.use(VueApollo); ...@@ -21,6 +27,7 @@ Vue.use(VueApollo);
const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse);
const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse); const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse);
const subscriptionHandler = jest.fn().mockResolvedValue(issuableLabelsSubscriptionResponse);
const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const updateLabelsMutation = { const updateLabelsMutation = {
...@@ -42,10 +49,12 @@ describe('LabelsSelectRoot', () => { ...@@ -42,10 +49,12 @@ describe('LabelsSelectRoot', () => {
issuableType = IssuableType.Issue, issuableType = IssuableType.Issue,
queryHandler = successfulQueryHandler, queryHandler = successfulQueryHandler,
mutationHandler = successfulMutationHandler, mutationHandler = successfulMutationHandler,
isRealtimeEnabled = false,
} = {}) => { } = {}) => {
const mockApollo = createMockApollo([ const mockApollo = createMockApollo([
[issueLabelsQuery, queryHandler], [issueLabelsQuery, queryHandler],
[updateLabelsMutation[issuableType], mutationHandler], [updateLabelsMutation[issuableType], mutationHandler],
[issuableLabelsSubscription, subscriptionHandler],
]); ]);
wrapper = shallowMount(LabelsSelectRoot, { wrapper = shallowMount(LabelsSelectRoot, {
...@@ -65,6 +74,9 @@ describe('LabelsSelectRoot', () => { ...@@ -65,6 +74,9 @@ describe('LabelsSelectRoot', () => {
allowLabelEdit: true, allowLabelEdit: true,
allowLabelCreate: true, allowLabelCreate: true,
labelsManagePath: 'test', labelsManagePath: 'test',
glFeatures: {
realtimeLabels: isRealtimeEnabled,
},
}, },
}); });
}; };
...@@ -190,5 +202,26 @@ describe('LabelsSelectRoot', () => { ...@@ -190,5 +202,26 @@ describe('LabelsSelectRoot', () => {
message: 'An error occurred while updating labels.', message: 'An error occurred while updating labels.',
}); });
}); });
it('does not emit `updateSelectedLabels` event when the subscription is triggered and FF is disabled', async () => {
createComponent();
await waitForPromises();
expect(wrapper.emitted('updateSelectedLabels')).toBeUndefined();
});
it('emits `updateSelectedLabels` event when the subscription is triggered and FF is enabled', async () => {
createComponent({ isRealtimeEnabled: true });
await waitForPromises();
expect(wrapper.emitted('updateSelectedLabels')).toEqual([
[
{
id: '1',
labels: issuableLabelsSubscriptionResponse.data.issuableLabelsUpdated.labels.nodes,
},
],
]);
});
}); });
}); });
...@@ -141,6 +141,34 @@ export const issuableLabelsQueryResponse = { ...@@ -141,6 +141,34 @@ export const issuableLabelsQueryResponse = {
}, },
}; };
export const issuableLabelsSubscriptionResponse = {
data: {
issuableLabelsUpdated: {
id: '1',
labels: {
nodes: [
{
__typename: 'Label',
color: '#330066',
description: null,
id: 'gid://gitlab/ProjectLabel/1',
title: 'Label1',
textColor: '#000000',
},
{
__typename: 'Label',
color: '#000000',
description: null,
id: 'gid://gitlab/ProjectLabel/2',
title: 'Label2',
textColor: '#ffffff',
},
],
},
},
},
};
export const updateLabelsMutationResponse = { export const updateLabelsMutationResponse = {
data: { data: {
updateIssuableLabels: { updateIssuableLabels: {
......
...@@ -31,4 +31,20 @@ RSpec.describe GraphqlTriggers do ...@@ -31,4 +31,20 @@ RSpec.describe GraphqlTriggers do
GraphqlTriggers.issuable_title_updated(work_item) GraphqlTriggers.issuable_title_updated(work_item)
end end
end end
describe '.issuable_labels_updated' do
it 'triggers the issuableLabelsUpdated subscription' do
project = create(:project)
labels = create_list(:label, 3, project: project)
issue = create(:issue, labels: labels)
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
'issuableLabelsUpdated',
{ issuable_id: issue.to_gid },
issue
)
GraphqlTriggers.issuable_labels_updated(issue)
end
end
end end
...@@ -8,6 +8,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do ...@@ -8,6 +8,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do
issuable_assignees_updated issuable_assignees_updated
issue_crm_contacts_updated issue_crm_contacts_updated
issuable_title_updated issuable_title_updated
issuable_labels_updated
] ]
expect(described_class).to have_graphql_fields(*expected_fields).only expect(described_class).to have_graphql_fields(*expected_fields).only
......
...@@ -1361,6 +1361,16 @@ RSpec.describe Issues::UpdateService, :mailer do ...@@ -1361,6 +1361,16 @@ RSpec.describe Issues::UpdateService, :mailer do
end end
end end
it_behaves_like 'broadcasting issuable labels updates' do
let(:label_a) { label }
let(:label_b) { label2 }
let(:issuable) { issue }
def update_issuable(update_params)
update_issue(update_params)
end
end
it_behaves_like 'issuable record that supports quick actions' do it_behaves_like 'issuable record that supports quick actions' do
let(:existing_issue) { create(:issue, project: project) } let(:existing_issue) { create(:issue, project: project) }
let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_issue) } let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_issue) }
......
...@@ -1192,5 +1192,15 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -1192,5 +1192,15 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
let(:existing_merge_request) { create(:merge_request, source_project: project) } let(:existing_merge_request) { create(:merge_request, source_project: project) }
let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_merge_request) } let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_merge_request) }
end end
it_behaves_like 'broadcasting issuable labels updates' do
let(:label_a) { label }
let(:label_b) { create(:label, project: project) }
let(:issuable) { merge_request }
def update_issuable(update_params)
update_merge_request(update_params)
end
end
end end
end end
...@@ -23,3 +23,33 @@ RSpec.shared_examples 'issuable update service' do ...@@ -23,3 +23,33 @@ RSpec.shared_examples 'issuable update service' do
end end
end end
end end
RSpec.shared_examples 'broadcasting issuable labels updates' do
before do
update_issuable(label_ids: [label_a.id])
end
context 'when label is added' do
it 'triggers the GraphQL subscription' do
expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
update_issuable({ add_label_ids: [label_b.id] })
end
end
context 'when label is removed' do
it 'triggers the GraphQL subscription' do
expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
update_issuable({ remove_label_ids: [label_a.id] })
end
end
context 'when label is unchanged' do
it 'does not trigger the GraphQL subscription' do
expect(GraphqlTriggers).not_to receive(:issuable_labels_updated).with(issuable)
update_issuable({ label_ids: [label_a.id] })
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment