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';
import { BatchHttpLink } from 'apollo-link-batch-http';
import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
......@@ -83,15 +84,27 @@ export default (resolvers = {}, config = {}) => {
});
});
return new ApolloClient({
typeDefs,
link: ApolloLink.from([
const hasSubscriptionOperation = ({ query: { definitions } }) => {
return definitions.some(
({ kind, operation }) => kind === 'OperationDefinition' && operation === 'subscription',
);
};
const appLink = ApolloLink.split(
hasSubscriptionOperation,
new ActionCableLink(),
ApolloLink.from([
requestCounterLink,
performanceBarLink,
new StartupJSLink(),
apolloCaptchaLink,
uploadsLink,
]),
);
return new ApolloClient({
typeDefs,
link: appLink,
cache: new InMemoryCache({
...cacheConfig,
freezeResults: assumeImmutableResults,
......
<script>
import actionCable from '~/actioncable_consumer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import produce from 'immer';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import { assigneesQueries } from '~/sidebar/constants';
export default {
......@@ -12,60 +13,62 @@ export default {
required: false,
default: null,
},
issuableIid: {
issuableType: {
type: String,
required: true,
},
projectPath: {
type: String,
issuableId: {
type: Number,
required: true,
},
issuableType: {
type: String,
queryVariables: {
type: Object,
required: true,
},
},
computed: {
issuableClass() {
return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
},
},
apollo: {
workspace: {
issuable: {
query() {
return assigneesQueries[this.issuableType].query;
},
variables() {
return {
iid: this.issuableIid,
fullPath: this.projectPath,
};
return this.queryVariables;
},
update(data) {
return data.workspace?.issuable;
},
result(data) {
if (this.mediator) {
this.handleFetchResult(data);
}
subscribeToMore: {
document() {
return assigneesQueries[this.issuableType].subscription;
},
variables() {
return {
issuableId: convertToGraphQLId(this.issuableClass, 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;
});
if (this.mediator) {
this.handleFetchResult(data);
}
return data;
}
return prev;
},
},
},
},
mounted() {
this.initActionCablePolling();
},
beforeDestroy() {
this.$options.subscription.unsubscribe();
},
methods: {
received(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 }) {
handleFetchResult(data) {
const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({
......
......@@ -44,6 +44,10 @@ export default {
type: String,
required: true,
},
issuableId: {
type: Number,
required: true,
},
assigneeAvailabilityStatus: {
type: Object,
required: false,
......@@ -61,6 +65,12 @@ export default {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
},
queryVariables() {
return {
iid: this.issuableIid,
fullPath: this.projectPath,
};
},
relativeUrlRoot() {
return gon.relative_url_root ?? '';
},
......@@ -121,9 +131,9 @@ export default {
<div>
<assignees-realtime
v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
:issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
:mediator="mediator"
/>
<assignee-title
......
......@@ -73,6 +73,11 @@ export default {
return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
},
},
issuableId: {
type: Number,
required: false,
default: null,
},
multipleAssignees: {
type: Boolean,
required: false,
......@@ -340,9 +345,9 @@ export default {
<div data-testid="assignees-widget">
<sidebar-assignees-realtime
v-if="shouldEnableRealtime"
:project-path="fullPath"
:issuable-iid="iid"
:issuable-type="issuableType"
:issuable-id="issuableId"
:query-variables="queryVariables"
/>
<sidebar-editable-item
ref="toggle"
......
import { IssuableType } from '~/issue_show/constants';
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 issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
......@@ -17,6 +18,7 @@ export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
subscription: issuableAssigneesSubscription,
mutation: updateAssigneesMutation,
},
[IssuableType.MergeRequest]: {
......
......@@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) {
if (!el) return;
const { iid, fullPath } = getSidebarOptions();
const { id, iid, fullPath } = getSidebarOptions();
const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
// eslint-disable-next-line no-new
new Vue({
......@@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) {
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
issuableId: id,
assigneeAvailabilityStatus,
},
}),
......@@ -85,7 +86,7 @@ function mountAssigneesComponent() {
if (!el) return;
const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
const { id, iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
// eslint-disable-next-line no-new
new Vue({
el,
......@@ -108,6 +109,7 @@ function mountAssigneesComponent() {
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
issuableId: id,
multipleAssignees: !el.dataset.maxAssignees,
},
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
rootPath: root_path,
fullPath: issuable[:project_full_path],
iid: issuable[:iid],
id: issuable[:id],
severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
createNoteEmail: issuable[:create_note_email],
......
......@@ -22,11 +22,7 @@ RSpec.describe 'ActionCable logging', :js do
subscription_data = a_hash_including(
remote_ip: '127.0.0.1',
user_id: user.id,
username: user.username,
params: a_hash_including(
project_path: project.full_path,
iid: issue.iid.to_s
)
username: user.username
)
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 } from '@vue/test-utils';
import { shallowMount, createLocalVue } 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 { assigneesQueries } from '~/sidebar/constants';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
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 mockConsumer = {
subscriptions: { create: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }) },
};
return {
createConsumer: jest.fn().mockReturnValue(mockConsumer),
};
});
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Assignees Realtime', () => {
let wrapper;
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, {
propsData: {
issuableIid: '1',
mediator,
projectPath: 'path/to/project',
issuableType,
},
mocks: {
$apollo: {
query: assigneesQueries[issuableType].query,
queries: {
workspace: {
refetch: jest.fn(),
},
},
issuableId,
queryVariables: {
issuableIid: '1',
projectPath: 'path/to/project',
},
mediator,
},
apolloProvider: fakeApollo,
localVue,
});
};
......@@ -45,59 +48,24 @@ describe('Assignees Realtime', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
fakeApollo = null;
SidebarMediator.singleton = null;
});
describe('when handleFetchResult is called from smart query', () => {
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();
it('calls the query with correct variables', () => {
createComponent();
wrapper.vm.handleFetchResult({ data });
expect(mediator.store.assignees).toEqual(expected);
expect(issuableQueryHandler).toHaveBeenCalledWith({
issuableIid: '1',
projectPath: 'path/to/project',
});
});
describe('when mounted', () => {
it('calls create subscription', () => {
const cable = ActionCable.createConsumer();
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(cable.subscriptions.create).toHaveBeenCalledTimes(1);
expect(cable.subscriptions.create).toHaveBeenCalledWith(
{
channel: 'IssuesChannel',
iid: wrapper.props('issuableIid'),
project_path: wrapper.props('projectPath'),
},
{ received: wrapper.vm.received },
);
});
});
});
describe('when subscription is recieved', () => {
it('refetches the GraphQL project query', () => {
createComponent();
wrapper.vm.received({ event: 'updated' });
it('calls the subscription with correct variable for issue', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1);
});
expect(subscriptionInitialHandler).toHaveBeenCalledWith({
issuableId: 'gid://gitlab/Issue/1',
});
});
});
......@@ -487,6 +487,9 @@ describe('Sidebar assignees widget', () => {
it('when realtime feature flag is enabled', async () => {
createComponent({
props: {
issuableId: 1,
},
provide: {
glFeatures: {
realTimeIssueSidebar: true,
......
......@@ -401,4 +401,10 @@ export const updateIssueAssigneesMutationResponse = {
},
};
export const subscriptionNullResponse = {
data: {
issuableAssigneesUpdated: null,
},
};
export default mockData;
......@@ -17,6 +17,7 @@ describe('sidebar assignees', () => {
wrapper = shallowMount(SidebarAssignees, {
propsData: {
issuableIid: '1',
issuableId: 1,
mediator,
field: '',
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