Commit 06413304 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu Committed by Natalia Tepluhina

Change real-time assignees to use GraphQL subscriptions

parent f247acf9
import { ApolloLink, Observable } from 'apollo-link';
import { print } from 'graphql';
import cable from '~/actioncable_consumer';
import { uuids } from '~/diffs/utils/uuids';
export default class ActionCableLink extends ApolloLink {
// eslint-disable-next-line class-methods-use-this
request(operation) {
return new Observable((observer) => {
const subscription = cable.subscriptions.create(
{
channel: 'GraphqlChannel',
query: operation.query ? print(operation.query) : null,
variables: operation.variables,
operationName: operation.operationName,
nonce: uuids()[0],
},
{
received(data) {
if (data.errors) {
observer.error(data.errors);
} else if (data.result) {
observer.next(data.result);
}
if (!data.more) {
observer.complete();
}
},
},
);
return {
unsubscribe() {
subscription.unsubscribe();
},
};
});
}
}
...@@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link'; ...@@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http'; import { BatchHttpLink } from 'apollo-link-batch-http';
import { createHttpLink } from 'apollo-link-http'; import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client'; import { createUploadLink } from 'apollo-upload-client';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
...@@ -83,15 +84,27 @@ export default (resolvers = {}, config = {}) => { ...@@ -83,15 +84,27 @@ export default (resolvers = {}, config = {}) => {
}); });
}); });
return new ApolloClient({ const hasSubscriptionOperation = ({ query: { definitions } }) => {
typeDefs, return definitions.some(
link: ApolloLink.from([ ({ kind, operation }) => kind === 'OperationDefinition' && operation === 'subscription',
);
};
const appLink = ApolloLink.split(
hasSubscriptionOperation,
new ActionCableLink(),
ApolloLink.from([
requestCounterLink, requestCounterLink,
performanceBarLink, performanceBarLink,
new StartupJSLink(), new StartupJSLink(),
apolloCaptchaLink, apolloCaptchaLink,
uploadsLink, uploadsLink,
]), ]),
);
return new ApolloClient({
typeDefs,
link: appLink,
cache: new InMemoryCache({ cache: new InMemoryCache({
...cacheConfig, ...cacheConfig,
freezeResults: assumeImmutableResults, freezeResults: assumeImmutableResults,
......
<script> <script>
import actionCable from '~/actioncable_consumer'; import produce from 'immer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import { assigneesQueries } from '~/sidebar/constants'; import { assigneesQueries } from '~/sidebar/constants';
export default { export default {
...@@ -12,60 +13,62 @@ export default { ...@@ -12,60 +13,62 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
issuableIid: { issuableType: {
type: String, type: String,
required: true, required: true,
}, },
projectPath: { issuableId: {
type: String, type: Number,
required: true, required: true,
}, },
issuableType: { queryVariables: {
type: String, type: Object,
required: true, required: true,
}, },
}, },
computed: {
issuableClass() {
return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
},
},
apollo: { apollo: {
workspace: { issuable: {
query() { query() {
return assigneesQueries[this.issuableType].query; return assigneesQueries[this.issuableType].query;
}, },
variables() {
return this.queryVariables;
},
update(data) {
return data.workspace?.issuable;
},
subscribeToMore: {
document() {
return assigneesQueries[this.issuableType].subscription;
},
variables() { variables() {
return { return {
iid: this.issuableIid, issuableId: convertToGraphQLId(this.issuableClass, this.issuableId),
fullPath: this.projectPath,
}; };
}, },
result(data) { updateQuery(prev, { subscriptionData }) {
if (prev && subscriptionData?.data?.issuableAssigneesUpdated) {
const data = produce(prev, (draftData) => {
draftData.workspace.issuable.assignees.nodes =
subscriptionData.data.issuableAssigneesUpdated.assignees.nodes;
});
if (this.mediator) { if (this.mediator) {
this.handleFetchResult(data); this.handleFetchResult(data);
} }
return data;
}
return prev;
}, },
}, },
}, },
mounted() {
this.initActionCablePolling();
},
beforeDestroy() {
this.$options.subscription.unsubscribe();
}, },
methods: { methods: {
received(data) { handleFetchResult(data) {
if (data.event === 'updated') {
this.$apollo.queries.workspace.refetch();
}
},
initActionCablePolling() {
this.$options.subscription = actionCable.subscriptions.create(
{
channel: 'IssuesChannel',
project_path: this.projectPath,
iid: this.issuableIid,
},
{ received: this.received },
);
},
handleFetchResult({ data }) {
const { nodes } = data.workspace.issuable.assignees; const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({ const assignees = nodes.map((n) => ({
......
...@@ -44,6 +44,10 @@ export default { ...@@ -44,6 +44,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
issuableId: {
type: Number,
required: true,
},
assigneeAvailabilityStatus: { assigneeAvailabilityStatus: {
type: Object, type: Object,
required: false, required: false,
...@@ -61,6 +65,12 @@ export default { ...@@ -61,6 +65,12 @@ export default {
// Note: Realtime is only available on issues right now, future support for MR wil be built later. // Note: Realtime is only available on issues right now, future support for MR wil be built later.
return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
}, },
queryVariables() {
return {
iid: this.issuableIid,
fullPath: this.projectPath,
};
},
relativeUrlRoot() { relativeUrlRoot() {
return gon.relative_url_root ?? ''; return gon.relative_url_root ?? '';
}, },
...@@ -121,9 +131,9 @@ export default { ...@@ -121,9 +131,9 @@ export default {
<div> <div>
<assignees-realtime <assignees-realtime
v-if="shouldEnableRealtime" v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
:issuable-type="issuableType" :issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
:mediator="mediator" :mediator="mediator"
/> />
<assignee-title <assignee-title
......
...@@ -73,6 +73,11 @@ export default { ...@@ -73,6 +73,11 @@ export default {
return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
}, },
}, },
issuableId: {
type: Number,
required: false,
default: null,
},
multipleAssignees: { multipleAssignees: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -340,9 +345,9 @@ export default { ...@@ -340,9 +345,9 @@ export default {
<div data-testid="assignees-widget"> <div data-testid="assignees-widget">
<sidebar-assignees-realtime <sidebar-assignees-realtime
v-if="shouldEnableRealtime" v-if="shouldEnableRealtime"
:project-path="fullPath"
:issuable-iid="iid"
:issuable-type="issuableType" :issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
/> />
<sidebar-editable-item <sidebar-editable-item
ref="toggle" ref="toggle"
......
import { IssuableType } from '~/issue_show/constants'; import { IssuableType } from '~/issue_show/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
...@@ -17,6 +18,7 @@ export const ASSIGNEES_DEBOUNCE_DELAY = 250; ...@@ -17,6 +18,7 @@ export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = { export const assigneesQueries = {
[IssuableType.Issue]: { [IssuableType.Issue]: {
query: getIssueParticipants, query: getIssueParticipants,
subscription: issuableAssigneesSubscription,
mutation: updateAssigneesMutation, mutation: updateAssigneesMutation,
}, },
[IssuableType.MergeRequest]: { [IssuableType.MergeRequest]: {
......
...@@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) { ...@@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) {
if (!el) return; if (!el) return;
const { iid, fullPath } = getSidebarOptions(); const { id, iid, fullPath } = getSidebarOptions();
const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData(); const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
...@@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) { ...@@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) {
isInIssuePage() || isInIncidentPage() || isInDesignPage() isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue ? IssuableType.Issue
: IssuableType.MergeRequest, : IssuableType.MergeRequest,
issuableId: id,
assigneeAvailabilityStatus, assigneeAvailabilityStatus,
}, },
}), }),
...@@ -85,7 +86,7 @@ function mountAssigneesComponent() { ...@@ -85,7 +86,7 @@ function mountAssigneesComponent() {
if (!el) return; if (!el) return;
const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions(); const { id, iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
...@@ -108,6 +109,7 @@ function mountAssigneesComponent() { ...@@ -108,6 +109,7 @@ function mountAssigneesComponent() {
isInIssuePage() || isInIncidentPage() || isInDesignPage() isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue ? IssuableType.Issue
: IssuableType.MergeRequest, : IssuableType.MergeRequest,
issuableId: id,
multipleAssignees: !el.dataset.maxAssignees, multipleAssignees: !el.dataset.maxAssignees,
}, },
scopedSlots: { scopedSlots: {
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
subscription issuableAssigneesUpdated($issuableId: IssuableID!) {
issuableAssigneesUpdated(issuableId: $issuableId) {
... on Issue {
assignees {
nodes {
...User
status {
availability
}
}
}
}
}
}
...@@ -386,6 +386,7 @@ module IssuablesHelper ...@@ -386,6 +386,7 @@ module IssuablesHelper
rootPath: root_path, rootPath: root_path,
fullPath: issuable[:project_full_path], fullPath: issuable[:project_full_path],
iid: issuable[:iid], iid: issuable[:iid],
id: issuable[:id],
severity: issuable[:severity], severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours, timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
createNoteEmail: issuable[:create_note_email], createNoteEmail: issuable[:create_note_email],
......
...@@ -22,11 +22,7 @@ RSpec.describe 'ActionCable logging', :js do ...@@ -22,11 +22,7 @@ RSpec.describe 'ActionCable logging', :js do
subscription_data = a_hash_including( subscription_data = a_hash_including(
remote_ip: '127.0.0.1', remote_ip: '127.0.0.1',
user_id: user.id, user_id: user.id,
username: user.username, username: user.username
params: a_hash_including(
project_path: project.full_path,
iid: issue.iid.to_s
)
) )
expect(ActiveSupport::Notifications).to receive(:instrument).with('subscribe.action_cable', subscription_data) expect(ActiveSupport::Notifications).to receive(:instrument).with('subscribe.action_cable', subscription_data)
......
import { print } from 'graphql';
import gql from 'graphql-tag';
import cable from '~/actioncable_consumer';
import ActionCableLink from '~/actioncable_link';
// Mock uuids module for determinism
jest.mock('~/diffs/utils/uuids', () => ({
uuids: () => ['testuuid'],
}));
const TEST_OPERATION = {
query: gql`
query foo {
project {
id
}
}
`,
operationName: 'foo',
variables: [],
};
/**
* Create an observer that passes calls to the given spy.
*
* This helps us assert which calls were made in what order.
*/
const createSpyObserver = (spy) => ({
next: (...args) => spy('next', ...args),
error: (...args) => spy('error', ...args),
complete: (...args) => spy('complete', ...args),
});
const notify = (...notifications) => {
notifications.forEach((data) => cable.subscriptions.notifyAll('received', data));
};
const getSubscriptionCount = () => cable.subscriptions.subscriptions.length;
describe('~/actioncable_link', () => {
let cableLink;
beforeEach(() => {
jest.spyOn(cable.subscriptions, 'create');
cableLink = new ActionCableLink();
});
describe('request', () => {
let subscription;
let spy;
beforeEach(() => {
spy = jest.fn();
subscription = cableLink.request(TEST_OPERATION).subscribe(createSpyObserver(spy));
});
afterEach(() => {
subscription.unsubscribe();
});
it('creates a subscription', () => {
expect(getSubscriptionCount()).toBe(1);
expect(cable.subscriptions.create).toHaveBeenCalledWith(
{
channel: 'GraphqlChannel',
nonce: 'testuuid',
...TEST_OPERATION,
query: print(TEST_OPERATION.query),
},
{ received: expect.any(Function) },
);
});
it('when "unsubscribe", unsubscribes underlying cable subscription', () => {
subscription.unsubscribe();
expect(getSubscriptionCount()).toBe(0);
});
it('when receives data, triggers observer until no ".more"', () => {
notify(
{ result: 'test result', more: true },
{ result: 'test result 2', more: true },
{ result: 'test result 3' },
{ result: 'test result 4' },
);
expect(spy.mock.calls).toEqual([
['next', 'test result'],
['next', 'test result 2'],
['next', 'test result 3'],
['complete'],
]);
});
it('when receives errors, triggers observer', () => {
notify(
{ result: 'test result', more: true },
{ result: 'test result 2', errors: ['boom!'], more: true },
{ result: 'test result 3' },
);
expect(spy.mock.calls).toEqual([
['next', 'test result'],
['error', ['boom!']],
]);
});
});
});
import ActionCable from '@rails/actioncable'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import { assigneesQueries } from '~/sidebar/constants'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import Mock from './mock_data'; import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data';
jest.mock('@rails/actioncable', () => { const localVue = createLocalVue();
const mockConsumer = { localVue.use(VueApollo);
subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) },
};
return {
createConsumer: jest.fn().mockReturnValue(mockConsumer),
};
});
describe('Assignees Realtime', () => { describe('Assignees Realtime', () => {
let wrapper; let wrapper;
let mediator; let mediator;
let fakeApollo;
const issuableQueryHandler = jest.fn().mockResolvedValue(issuableQueryResponse);
const subscriptionInitialHandler = jest.fn().mockResolvedValue(subscriptionNullResponse);
const createComponent = (issuableType = 'issue') => { const createComponent = ({
issuableType = 'issue',
issuableId = 1,
subscriptionHandler = subscriptionInitialHandler,
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
[issuableAssigneesSubscription, subscriptionHandler],
]);
wrapper = shallowMount(AssigneesRealtime, { wrapper = shallowMount(AssigneesRealtime, {
propsData: { propsData: {
issuableType,
issuableId,
queryVariables: {
issuableIid: '1', issuableIid: '1',
mediator,
projectPath: 'path/to/project', projectPath: 'path/to/project',
issuableType,
},
mocks: {
$apollo: {
query: assigneesQueries[issuableType].query,
queries: {
workspace: {
refetch: jest.fn(),
},
},
}, },
mediator,
}, },
apolloProvider: fakeApollo,
localVue,
}); });
}; };
...@@ -45,59 +48,24 @@ describe('Assignees Realtime', () => { ...@@ -45,59 +48,24 @@ describe('Assignees Realtime', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; fakeApollo = null;
SidebarMediator.singleton = null; SidebarMediator.singleton = null;
}); });
describe('when handleFetchResult is called from smart query', () => { it('calls the query with correct variables', () => {
it('sets assignees to the store', () => {
const data = {
workspace: {
issuable: {
assignees: {
nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }],
},
},
},
};
const expected = [{ id: 123, avatar_url: 'url', avatarUrl: 'url' }];
createComponent();
wrapper.vm.handleFetchResult({ data });
expect(mediator.store.assignees).toEqual(expected);
});
});
describe('when mounted', () => {
it('calls create subscription', () => {
const cable = ActionCable.createConsumer();
createComponent(); createComponent();
return wrapper.vm.$nextTick().then(() => { expect(issuableQueryHandler).toHaveBeenCalledWith({
expect(cable.subscriptions.create).toHaveBeenCalledTimes(1); issuableIid: '1',
expect(cable.subscriptions.create).toHaveBeenCalledWith( projectPath: 'path/to/project',
{
channel: 'IssuesChannel',
iid: wrapper.props('issuableIid'),
project_path: wrapper.props('projectPath'),
},
{ received: wrapper.vm.received },
);
});
}); });
}); });
describe('when subscription is recieved', () => { it('calls the subscription with correct variable for issue', () => {
it('refetches the GraphQL project query', () => {
createComponent(); createComponent();
wrapper.vm.received({ event: 'updated' }); expect(subscriptionInitialHandler).toHaveBeenCalledWith({
issuableId: 'gid://gitlab/Issue/1',
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1);
});
}); });
}); });
}); });
...@@ -487,6 +487,9 @@ describe('Sidebar assignees widget', () => { ...@@ -487,6 +487,9 @@ describe('Sidebar assignees widget', () => {
it('when realtime feature flag is enabled', async () => { it('when realtime feature flag is enabled', async () => {
createComponent({ createComponent({
props: {
issuableId: 1,
},
provide: { provide: {
glFeatures: { glFeatures: {
realTimeIssueSidebar: true, realTimeIssueSidebar: true,
......
...@@ -401,4 +401,10 @@ export const updateIssueAssigneesMutationResponse = { ...@@ -401,4 +401,10 @@ export const updateIssueAssigneesMutationResponse = {
}, },
}; };
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export default mockData; export default mockData;
...@@ -17,6 +17,7 @@ describe('sidebar assignees', () => { ...@@ -17,6 +17,7 @@ describe('sidebar assignees', () => {
wrapper = shallowMount(SidebarAssignees, { wrapper = shallowMount(SidebarAssignees, {
propsData: { propsData: {
issuableIid: '1', issuableIid: '1',
issuableId: 1,
mediator, mediator,
field: '', field: '',
projectPath: 'projectPath', projectPath: 'projectPath',
......
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