Commit 518ae111 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'ss/realtime-assignees' into 'master'

Realtime assignees frontend first iteration

See merge request gitlab-org/gitlab!30239
parents 9876d826 8d21cf6d
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';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
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 Assignees from './assignees.vue';
import AssigneesRealtime from './assignees_realtime.vue';
import { __ } from '~/locale';
export default {
......@@ -12,7 +14,9 @@ export default {
components: {
AssigneeTitle,
Assignees,
AssigneesRealtime,
},
mixins: [glFeatureFlagsMixin()],
props: {
mediator: {
type: Object,
......@@ -32,6 +36,14 @@ export default {
required: false,
default: 'issue',
},
issuableIid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
data() {
return {
......@@ -39,6 +51,12 @@ export default {
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() {
this.removeAssignee = this.store.removeAssignee.bind(this.store);
this.addAssignee = this.store.addAssignee.bind(this.store);
......@@ -84,6 +102,12 @@ export default {
<template>
<div>
<assignees-realtime
v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
:mediator="mediator"
/>
<assignee-title
:number-of-assignees="store.assignees.length"
:loading="loading || store.isFetching.assignees"
......
import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_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 sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
Vue.use(Translate);
Vue.use(VueApollo);
function getSidebarOptions() {
return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
}
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
if (!el) return;
const { iid, fullPath } = getSidebarOptions();
// eslint-disable-next-line no-new
new Vue({
el,
apolloProvider,
components: {
SidebarAssignees,
},
......@@ -26,6 +38,8 @@ function mountAssigneesComponent(mediator) {
createElement('sidebar-assignees', {
props: {
mediator,
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
......@@ -144,6 +158,4 @@ export function mountSidebar(mediator) {
mountTimeTrackingComponent();
}
export function getSidebarOptions() {
return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
}
export { getSidebarOptions };
......@@ -89,6 +89,10 @@ export default class SidebarStore {
this.assignees = [];
}
setAssigneesFromRealtime(data) {
this.assignees = data;
}
setAutocompleteProjects(projects) {
this.autocompleteProjects = projects;
}
......
......@@ -50,6 +50,10 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:save_issuable_health_status, project.group, default_enabled: true)
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]
respond_to :html
......
......@@ -14,6 +14,8 @@ module Types
description: 'ID of the user'
field :name, GraphQL::STRING_TYPE, null: false,
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,
description: 'Username of the user. Unique within this instance of GitLab'
field :avatar_url, GraphQL::STRING_TYPE, null: true,
......
......@@ -68,6 +68,7 @@
= csrf_meta_tags
= csp_meta_tag
= action_cable_meta_tag
- unless browser.safari?
%meta{ name: 'referrer', content: 'origin-when-cross-origin' }
......
- issuable_type = issuable_sidebar[:type]
- 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
= _('Assignee')
.spinner.spinner-sm.align-bottom
......
......@@ -10091,6 +10091,11 @@ type User {
visibility: VisibilityScopesEnum
): SnippetConnection
"""
State of the issue
"""
state: String!
"""
Todos of the user
"""
......
......@@ -30221,6 +30221,24 @@
"isDeprecated": false,
"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",
"description": "Todos of the user",
......
......@@ -1567,6 +1567,7 @@ Autogenerated return type of UpdateSnippet
| `avatarUrl` | String | URL of the user's avatar |
| `id` | ID! | ID 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 |
| `username` | String! | Username of the user. Unique within this instance of GitLab |
| `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';
import axios from 'axios';
import SidebarAssignees from '~/sidebar/components/assignees/sidebar_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 SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from '~/sidebar/stores/sidebar_store';
......@@ -12,12 +13,19 @@ describe('sidebar assignees', () => {
let wrapper;
let mediator;
let axiosMock;
const createComponent = () => {
const createComponent = (realTimeIssueSidebar = false, props) => {
wrapper = shallowMount(SidebarAssignees, {
propsData: {
issuableIid: '1',
mediator,
field: '',
projectPath: 'projectPath',
...props,
},
provide: {
glFeatures: {
realTimeIssueSidebar,
},
},
// Attaching to document is required because this component emits something from the parent element :/
attachToDocument: true,
......@@ -30,8 +38,6 @@ describe('sidebar assignees', () => {
jest.spyOn(mediator, 'saveAssignees');
jest.spyOn(mediator, 'assignYourself');
createComponent();
});
afterEach(() => {
......@@ -45,6 +51,8 @@ describe('sidebar assignees', () => {
});
it('calls the mediator when saves the assignees', () => {
createComponent();
expect(mediator.saveAssignees).not.toHaveBeenCalled();
wrapper.vm.saveAssignees();
......@@ -53,6 +61,8 @@ describe('sidebar assignees', () => {
});
it('calls the mediator when "assignSelf" method is called', () => {
createComponent();
expect(mediator.assignYourself).not.toHaveBeenCalled();
expect(mediator.store.assignees.length).toBe(0);
......@@ -63,6 +73,8 @@ describe('sidebar assignees', () => {
});
it('hides assignees until fetched', () => {
createComponent();
expect(wrapper.find(Assigness).exists()).toBe(false);
wrapper.vm.store.isFetching.assignees = false;
......@@ -71,4 +83,30 @@ describe('sidebar assignees', () => {
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
it 'has the expected fields' do
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)
......
......@@ -985,6 +985,11 @@
consola "^2.10.1"
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":
version "5.10.2"
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