Commit 8d21cf6d authored by Scott Stern's avatar Scott Stern Committed by Rémy Coutable

Setup initial ActionCable configuration

Loads the actioncable libraries for use in realtime
sidebar updates
parent d6b36064
import { createConsumer } from '@rails/actioncable';
export default createConsumer();
#import "~/graphql_shared/fragments/author.fragment.graphql"
query getProjectIssue($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
assignees {
nodes {
...Author
id
state
}
}
}
}
}
<script>
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
import actionCable from '~/actioncable_consumer';
export default {
name: 'AssigneesRealtime',
props: {
mediator: {
type: Object,
required: true,
},
issuableIid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
apollo: {
project: {
query,
variables() {
return {
iid: this.issuableIid,
fullPath: this.projectPath,
};
},
result(data) {
this.handleFetchResult(data);
},
},
},
mounted() {
this.initActionCablePolling();
},
methods: {
received(data) {
if (data.event === 'updated') {
this.$apollo.queries.project.refetch();
}
},
initActionCablePolling() {
actionCable.subscriptions.create(
{
channel: 'IssuesChannel',
project_path: this.projectPath,
iid: this.issuableIid,
},
{ received: this.received },
);
},
handleFetchResult({ data }) {
const { nodes } = data.project.issue.assignees;
const assignees = nodes.map(n => ({
...n,
avatar_url: n.avatarUrl,
id: getIdFromGraphQLId(n.id),
}));
this.mediator.store.setAssigneesFromRealtime(assignees);
},
},
render() {
return this.$slots.default;
},
};
</script>
...@@ -3,8 +3,10 @@ import Flash from '~/flash'; ...@@ -3,8 +3,10 @@ import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store'; import Store from '~/sidebar/stores/sidebar_store';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AssigneeTitle from './assignee_title.vue'; import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue'; import Assignees from './assignees.vue';
import AssigneesRealtime from './assignees_realtime.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
export default { export default {
...@@ -12,7 +14,9 @@ export default { ...@@ -12,7 +14,9 @@ export default {
components: { components: {
AssigneeTitle, AssigneeTitle,
Assignees, Assignees,
AssigneesRealtime,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
mediator: { mediator: {
type: Object, type: Object,
...@@ -32,6 +36,14 @@ export default { ...@@ -32,6 +36,14 @@ export default {
required: false, required: false,
default: 'issue', default: 'issue',
}, },
issuableIid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -39,6 +51,12 @@ export default { ...@@ -39,6 +51,12 @@ export default {
loading: false, loading: false,
}; };
}, },
computed: {
shouldEnableRealtime() {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
},
},
created() { created() {
this.removeAssignee = this.store.removeAssignee.bind(this.store); this.removeAssignee = this.store.removeAssignee.bind(this.store);
this.addAssignee = this.store.addAssignee.bind(this.store); this.addAssignee = this.store.addAssignee.bind(this.store);
...@@ -84,6 +102,12 @@ export default { ...@@ -84,6 +102,12 @@ export default {
<template> <template>
<div> <div>
<assignees-realtime
v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
:mediator="mediator"
/>
<assignee-title <assignee-title
:number-of-assignees="store.assignees.length" :number-of-assignees="store.assignees.length"
:loading="loading || store.isFetching.assignees" :loading="loading || store.isFetching.assignees"
......
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
...@@ -8,17 +9,28 @@ import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; ...@@ -8,17 +9,28 @@ import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue'; import sidebarParticipants from './components/participants/sidebar_participants.vue';
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
Vue.use(Translate); Vue.use(Translate);
Vue.use(VueApollo);
function getSidebarOptions() {
return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
}
function mountAssigneesComponent(mediator) { function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees'); const el = document.getElementById('js-vue-sidebar-assignees');
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
if (!el) return; if (!el) return;
const { iid, fullPath } = getSidebarOptions();
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
apolloProvider,
components: { components: {
SidebarAssignees, SidebarAssignees,
}, },
...@@ -26,6 +38,8 @@ function mountAssigneesComponent(mediator) { ...@@ -26,6 +38,8 @@ function mountAssigneesComponent(mediator) {
createElement('sidebar-assignees', { createElement('sidebar-assignees', {
props: { props: {
mediator, mediator,
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field, field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'), signedIn: el.hasAttribute('data-signed-in'),
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
...@@ -144,6 +158,4 @@ export function mountSidebar(mediator) { ...@@ -144,6 +158,4 @@ export function mountSidebar(mediator) {
mountTimeTrackingComponent(); mountTimeTrackingComponent();
} }
export function getSidebarOptions() { export { getSidebarOptions };
return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
}
...@@ -89,6 +89,10 @@ export default class SidebarStore { ...@@ -89,6 +89,10 @@ export default class SidebarStore {
this.assignees = []; this.assignees = [];
} }
setAssigneesFromRealtime(data) {
this.assignees = data;
}
setAutocompleteProjects(projects) { setAutocompleteProjects(projects) {
this.autocompleteProjects = projects; this.autocompleteProjects = projects;
} }
......
...@@ -50,6 +50,10 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -50,6 +50,10 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:save_issuable_health_status, project.group, default_enabled: true) push_frontend_feature_flag(:save_issuable_health_status, project.group, default_enabled: true)
end end
before_action only: :show do
push_frontend_feature_flag(:real_time_issue_sidebar, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions] around_action :allow_gitaly_ref_name_caching, only: [:discussions]
respond_to :html respond_to :html
......
...@@ -14,6 +14,8 @@ module Types ...@@ -14,6 +14,8 @@ module Types
description: 'ID of the user' description: 'ID of the user'
field :name, GraphQL::STRING_TYPE, null: false, field :name, GraphQL::STRING_TYPE, null: false,
description: 'Human-readable name of the user' description: 'Human-readable name of the user'
field :state, GraphQL::STRING_TYPE, null: false,
description: 'State of the issue'
field :username, GraphQL::STRING_TYPE, null: false, field :username, GraphQL::STRING_TYPE, null: false,
description: 'Username of the user. Unique within this instance of GitLab' description: 'Username of the user. Unique within this instance of GitLab'
field :avatar_url, GraphQL::STRING_TYPE, null: true, field :avatar_url, GraphQL::STRING_TYPE, null: true,
......
...@@ -68,6 +68,7 @@ ...@@ -68,6 +68,7 @@
= csrf_meta_tags = csrf_meta_tags
= csp_meta_tag = csp_meta_tag
= action_cable_meta_tag
- unless browser.safari? - unless browser.safari?
%meta{ name: 'referrer', content: 'origin-when-cross-origin' } %meta{ name: 'referrer', content: 'origin-when-cross-origin' }
......
- issuable_type = issuable_sidebar[:type] - issuable_type = issuable_sidebar[:type]
- signed_in = !!issuable_sidebar.dig(:current_user, :id) - signed_in = !!issuable_sidebar.dig(:current_user, :id)
#js-vue-sidebar-assignees{ data: { field: "#{issuable_type}", signed_in: signed_in } } #js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in } }
.title.hide-collapsed .title.hide-collapsed
= _('Assignee') = _('Assignee')
.spinner.spinner-sm.align-bottom .spinner.spinner-sm.align-bottom
......
...@@ -10091,6 +10091,11 @@ type User { ...@@ -10091,6 +10091,11 @@ type User {
visibility: VisibilityScopesEnum visibility: VisibilityScopesEnum
): SnippetConnection ): SnippetConnection
"""
State of the issue
"""
state: String!
""" """
Todos of the user Todos of the user
""" """
......
...@@ -30221,6 +30221,24 @@ ...@@ -30221,6 +30221,24 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "state",
"description": "State of the issue",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "todos", "name": "todos",
"description": "Todos of the user", "description": "Todos of the user",
......
...@@ -1567,6 +1567,7 @@ Autogenerated return type of UpdateSnippet ...@@ -1567,6 +1567,7 @@ Autogenerated return type of UpdateSnippet
| `avatarUrl` | String | URL of the user's avatar | | `avatarUrl` | String | URL of the user's avatar |
| `id` | ID! | ID of the user | | `id` | ID! | ID of the user |
| `name` | String! | Human-readable name of the user | | `name` | String! | Human-readable name of the user |
| `state` | String! | State of the issue |
| `userPermissions` | UserPermissions! | Permissions for the current user on the resource | | `userPermissions` | UserPermissions! | Permissions for the current user on the resource |
| `username` | String! | Username of the user. Unique within this instance of GitLab | | `username` | String! | Username of the user. Unique within this instance of GitLab |
| `webUrl` | String! | Web URL of the user | | `webUrl` | String! | Web URL of the user |
......
import { shallowMount } from '@vue/test-utils';
import ActionCable from '@rails/actioncable';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import Mock from './mock_data';
import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
jest.mock('@rails/actioncable', () => {
const mockConsumer = { subscriptions: { create: jest.fn() } };
return {
createConsumer: jest.fn().mockReturnValue(mockConsumer),
};
});
describe('Assignees Realtime', () => {
let wrapper;
let mediator;
const createComponent = () => {
wrapper = shallowMount(AssigneesRealtime, {
propsData: {
issuableIid: '1',
mediator,
projectPath: 'path/to/project',
},
mocks: {
$apollo: {
query,
queries: {
project: {
refetch: jest.fn(),
},
},
},
},
});
};
beforeEach(() => {
mediator = new SidebarMediator(Mock.mediator);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
SidebarMediator.singleton = null;
});
describe('when handleFetchResult is called from smart query', () => {
it('sets assignees to the store', () => {
const data = {
project: {
issue: {
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();
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' });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1);
});
});
});
});
...@@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import axios from 'axios'; import axios from 'axios';
import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue'; import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.vue';
import Assigness from '~/sidebar/components/assignees/assignees.vue'; import Assigness from '~/sidebar/components/assignees/assignees.vue';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store'; import SidebarStore from '~/sidebar/stores/sidebar_store';
...@@ -12,12 +13,19 @@ describe('sidebar assignees', () => { ...@@ -12,12 +13,19 @@ describe('sidebar assignees', () => {
let wrapper; let wrapper;
let mediator; let mediator;
let axiosMock; let axiosMock;
const createComponent = (realTimeIssueSidebar = false, props) => {
const createComponent = () => {
wrapper = shallowMount(SidebarAssignees, { wrapper = shallowMount(SidebarAssignees, {
propsData: { propsData: {
issuableIid: '1',
mediator, mediator,
field: '', field: '',
projectPath: 'projectPath',
...props,
},
provide: {
glFeatures: {
realTimeIssueSidebar,
},
}, },
// Attaching to document is required because this component emits something from the parent element :/ // Attaching to document is required because this component emits something from the parent element :/
attachToDocument: true, attachToDocument: true,
...@@ -30,8 +38,6 @@ describe('sidebar assignees', () => { ...@@ -30,8 +38,6 @@ describe('sidebar assignees', () => {
jest.spyOn(mediator, 'saveAssignees'); jest.spyOn(mediator, 'saveAssignees');
jest.spyOn(mediator, 'assignYourself'); jest.spyOn(mediator, 'assignYourself');
createComponent();
}); });
afterEach(() => { afterEach(() => {
...@@ -45,6 +51,8 @@ describe('sidebar assignees', () => { ...@@ -45,6 +51,8 @@ describe('sidebar assignees', () => {
}); });
it('calls the mediator when saves the assignees', () => { it('calls the mediator when saves the assignees', () => {
createComponent();
expect(mediator.saveAssignees).not.toHaveBeenCalled(); expect(mediator.saveAssignees).not.toHaveBeenCalled();
wrapper.vm.saveAssignees(); wrapper.vm.saveAssignees();
...@@ -53,6 +61,8 @@ describe('sidebar assignees', () => { ...@@ -53,6 +61,8 @@ describe('sidebar assignees', () => {
}); });
it('calls the mediator when "assignSelf" method is called', () => { it('calls the mediator when "assignSelf" method is called', () => {
createComponent();
expect(mediator.assignYourself).not.toHaveBeenCalled(); expect(mediator.assignYourself).not.toHaveBeenCalled();
expect(mediator.store.assignees.length).toBe(0); expect(mediator.store.assignees.length).toBe(0);
...@@ -63,6 +73,8 @@ describe('sidebar assignees', () => { ...@@ -63,6 +73,8 @@ describe('sidebar assignees', () => {
}); });
it('hides assignees until fetched', () => { it('hides assignees until fetched', () => {
createComponent();
expect(wrapper.find(Assigness).exists()).toBe(false); expect(wrapper.find(Assigness).exists()).toBe(false);
wrapper.vm.store.isFetching.assignees = false; wrapper.vm.store.isFetching.assignees = false;
...@@ -71,4 +83,30 @@ describe('sidebar assignees', () => { ...@@ -71,4 +83,30 @@ describe('sidebar assignees', () => {
expect(wrapper.find(Assigness).exists()).toBe(true); expect(wrapper.find(Assigness).exists()).toBe(true);
}); });
}); });
describe('when realTimeIssueSidebar is turned on', () => {
describe('when issuableType is issue', () => {
it('finds AssigneesRealtime componeont', () => {
createComponent(true);
expect(wrapper.find(AssigneesRealtime).exists()).toBe(true);
});
});
describe('when issuableType is MR', () => {
it('does not find AssigneesRealtime componeont', () => {
createComponent(true, { issuableType: 'MR' });
expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
});
});
});
describe('when realTimeIssueSidebar is turned off', () => {
it('does not find AssigneesRealtime', () => {
createComponent(false, { issuableType: 'issue' });
expect(wrapper.find(AssigneesRealtime).exists()).toBe(false);
});
});
}); });
...@@ -9,7 +9,7 @@ describe GitlabSchema.types['User'] do ...@@ -9,7 +9,7 @@ describe GitlabSchema.types['User'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %w[ expected_fields = %w[
id user_permissions snippets name username avatarUrl webUrl todos id user_permissions snippets name username avatarUrl webUrl todos state
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
......
...@@ -985,6 +985,11 @@ ...@@ -985,6 +985,11 @@
consola "^2.10.1" consola "^2.10.1"
node-fetch "^2.6.0" node-fetch "^2.6.0"
"@rails/actioncable@^6.0.2-2":
version "6.0.2-2"
resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-6.0.2-2.tgz#237907f8111707950381387c273b19ac25958408"
integrity sha512-0sKStf8hnberH1TKup10PJ92JT2dVqf3gf+OT4lJ7DiYSBEuDcvICHxWsyML2oWTpjUhC4kLvUJ3pXL2JJrJuQ==
"@sentry/browser@^5.10.2": "@sentry/browser@^5.10.2":
version "5.10.2" version "5.10.2"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.10.2.tgz#0bbb05505c58ea998c833cffec3f922fe4b4fa58" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.10.2.tgz#0bbb05505c58ea998c833cffec3f922fe4b4fa58"
......
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