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>
import produce from 'immer';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants';
import { assigneesQueries } from '~/sidebar/constants';
......@@ -17,10 +16,6 @@ export default {
type: String,
required: true,
},
issuableId: {
type: Number,
required: true,
},
queryVariables: {
type: Object,
required: true,
......@@ -30,6 +25,9 @@ export default {
issuableClass() {
return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
},
issuableId() {
return this.issuable?.id;
},
},
apollo: {
issuable: {
......@@ -48,29 +46,36 @@ export default {
},
variables() {
return {
issuableId: convertToGraphQLId(this.issuableClass, this.issuableId),
issuableId: this.issuableId,
};
},
updateQuery(prev, { subscriptionData }) {
if (prev && subscriptionData?.data?.issuableAssigneesUpdated) {
const data = produce(prev, (draftData) => {
draftData.workspace.issuable.assignees.nodes =
subscriptionData.data.issuableAssigneesUpdated.assignees.nodes;
});
skip() {
return !this.issuableId;
},
updateQuery(
_,
{
subscriptionData: {
data: { issuableAssigneesUpdated },
},
},
) {
if (issuableAssigneesUpdated) {
const {
id,
assignees: { nodes },
} = issuableAssigneesUpdated;
if (this.mediator) {
this.handleFetchResult(data);
this.handleFetchResult(nodes);
}
return data;
this.$emit('assigneesUpdated', { id, assignees: nodes });
}
return prev;
},
},
},
},
methods: {
handleFetchResult(data) {
const { nodes } = data.workspace.issuable.assignees;
handleFetchResult(nodes) {
const assignees = nodes.map((n) => ({
...n,
avatar_url: n.avatarUrl,
......
......@@ -232,6 +232,7 @@ export default {
:issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
@assigneesUpdated="$emit('assignees-updated', $event)"
/>
<sidebar-editable-item
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>
import { debounce } from 'lodash';
import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issues/constants';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { issuableLabelsQueries } from '~/sidebar/constants';
......@@ -21,6 +24,7 @@ export default {
DropdownContents,
SidebarEditableItem,
},
mixins: [glFeatureFlagsMixin()],
inject: {
allowLabelEdit: {
default: false,
......@@ -106,7 +110,7 @@ export default {
data() {
return {
contentIsOnViewport: true,
issuableLabels: [],
issuable: null,
labelsSelectInProgress: false,
oldIid: null,
sidebarExpandedOnClick: false,
......@@ -114,14 +118,23 @@ export default {
},
computed: {
isLoading() {
return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading;
return this.labelsSelectInProgress || this.$apollo.queries.issuable.loading;
},
issuableLabelIds() {
return this.issuableLabels.map((label) => label.id);
},
issuableLabels() {
return this.issuable?.labels.nodes || [];
},
issuableId() {
return this.issuable?.id;
},
isRealtimeEnabled() {
return this.glFeatures.realtimeLabels;
},
},
apollo: {
issuableLabels: {
issuable: {
query() {
return issuableLabelsQueries[this.issuableType].issuableQuery;
},
......@@ -135,11 +148,40 @@ export default {
};
},
update(data) {
return data.workspace?.issuable?.labels.nodes || [];
return data.workspace?.issuable;
},
error() {
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: {
......
......@@ -9,6 +9,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action do
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(:realtime_labels, group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control { }
e.candidate { }
......
......@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action do
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(:realtime_labels, project&.group, default_enabled: :yaml)
experiment(:prominent_create_board_btn, subject: current_user) do |e|
e.control { }
e.candidate { }
......
......@@ -49,6 +49,7 @@ class Projects::IssuesController < Projects::ApplicationController
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(: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?)
end
......
......@@ -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(:secure_vulnerability_training, 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
push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
......
......@@ -12,4 +12,8 @@ module GraphqlTriggers
def self.issuable_title_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable)
end
def self.issuable_labels_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableLabelsUpdated', { issuable_id: issuable.to_gid }, issuable)
end
end
......@@ -12,5 +12,8 @@ module Types
field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true,
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
......@@ -525,12 +525,23 @@ class IssuableBaseService < ::BaseProjectService
attrs_changed || labels_changed || assignees_changed || reviewers_changed
end
def has_label_changes?(issuable, old_labels)
Set.new(issuable.labels) != Set.new(old_labels)
end
def invalidate_cache_counts(issuable, users: [])
users.each do |user|
user.public_send("invalidate_#{issuable.noteable_target_type_name}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
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
def handle_changes(issuable, options)
end
......
......@@ -63,6 +63,7 @@ module Issues
handle_assignee_changes(issue, old_assignees)
handle_confidential_change(issue)
handle_label_changes(issue, old_labels)
handle_added_labels(issue, old_labels)
handle_milestone_change(issue)
handle_added_mentions(issue, old_mentioned_users)
......
......@@ -34,6 +34,7 @@ module MergeRequests
handle_target_branch_change(merge_request)
handle_milestone_change(merge_request)
handle_draft_status_change(merge_request, changed_fields)
handle_label_changes(merge_request, old_labels)
track_title_and_desc_edits(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
before_action :redirect_to_recent_board, only: [:index]
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'
feature_category :portfolio_management
......
......@@ -19,6 +19,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do
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(:realtime_labels, group, default_enabled: :yaml)
end
feature_category :portfolio_management
......
......@@ -45,9 +45,7 @@ module Epics
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
old_labels = old_associations.fetch(:labels, [])
if has_changes?(epic, old_labels: old_labels)
todo_service.resolve_todos_for_target(epic, current_user)
end
handle_label_changes(epic, old_labels)
todo_service.update_epic(epic, current_user, old_mentioned_users)
......@@ -56,6 +54,14 @@ module Epics
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)
if epic.confidential?
::Gitlab::UsageDataCounters::EpicActivityUniqueCounter.track_epic_confidential_action(author: current_user)
......
......@@ -537,6 +537,16 @@ RSpec.describe Epics::UpdateService do
let(:parent) { group }
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
before do
stub_licensed_features(epics: true, subepics: true)
......
......@@ -6,6 +6,10 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
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
project.add_developer(user)
......@@ -32,4 +36,37 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
expect(page.find('.assignee')).to have_content user.name
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
......@@ -2,11 +2,16 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import SidebarMediator from '~/sidebar/sidebar_mediator';
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);
......@@ -20,7 +25,6 @@ describe('Assignees Realtime', () => {
const createComponent = ({
issuableType = 'issue',
issuableId = 1,
subscriptionHandler = subscriptionInitialHandler,
} = {}) => {
fakeApollo = createMockApollo([
......@@ -30,7 +34,6 @@ describe('Assignees Realtime', () => {
wrapper = shallowMount(AssigneesRealtime, {
propsData: {
issuableType,
issuableId,
queryVariables: {
issuableIid: '1',
projectPath: 'path/to/project',
......@@ -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();
await waitForPromises();
expect(subscriptionInitialHandler).toHaveBeenCalledWith({
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 = {
},
};
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 = {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
......
......@@ -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 updateIssueLabelsMutation from '~/boards/graphql/issue_set_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 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');
......@@ -21,6 +27,7 @@ Vue.use(VueApollo);
const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse);
const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse);
const subscriptionHandler = jest.fn().mockResolvedValue(issuableLabelsSubscriptionResponse);
const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const updateLabelsMutation = {
......@@ -42,10 +49,12 @@ describe('LabelsSelectRoot', () => {
issuableType = IssuableType.Issue,
queryHandler = successfulQueryHandler,
mutationHandler = successfulMutationHandler,
isRealtimeEnabled = false,
} = {}) => {
const mockApollo = createMockApollo([
[issueLabelsQuery, queryHandler],
[updateLabelsMutation[issuableType], mutationHandler],
[issuableLabelsSubscription, subscriptionHandler],
]);
wrapper = shallowMount(LabelsSelectRoot, {
......@@ -65,6 +74,9 @@ describe('LabelsSelectRoot', () => {
allowLabelEdit: true,
allowLabelCreate: true,
labelsManagePath: 'test',
glFeatures: {
realtimeLabels: isRealtimeEnabled,
},
},
});
};
......@@ -190,5 +202,26 @@ describe('LabelsSelectRoot', () => {
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 = {
},
};
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 = {
data: {
updateIssuableLabels: {
......
......@@ -31,4 +31,20 @@ RSpec.describe GraphqlTriggers do
GraphqlTriggers.issuable_title_updated(work_item)
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
......@@ -8,6 +8,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do
issuable_assignees_updated
issue_crm_contacts_updated
issuable_title_updated
issuable_labels_updated
]
expect(described_class).to have_graphql_fields(*expected_fields).only
......
......@@ -1361,6 +1361,16 @@ RSpec.describe Issues::UpdateService, :mailer do
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
let(:existing_issue) { create(:issue, project: project) }
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
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) }
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
......@@ -23,3 +23,33 @@ RSpec.shared_examples 'issuable update service' do
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