Commit 05def397 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '322109-add-assignees-widget-to-issue-and-merge-request-sidebar' into 'master'

[RUN-AS-IF-FOSS] Resolve "Add assignees widget to issue sidebar"

See merge request gitlab-org/gitlab!56742
parents 11c906bb acdd883d
fragment UserAvailability on User {
status {
availability
}
}
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query usersSearch($search: String!, $fullPath: ID!) {
workspace: project(fullPath: $fullPath) {
......@@ -6,6 +7,7 @@ query usersSearch($search: String!, $fullPath: ID!) {
nodes {
user {
...User
...UserAvailability
}
}
}
......
......@@ -19,8 +19,10 @@ export default {
GlLink,
GlModal,
},
inject: {
props: {
membersPath: {
type: String,
required: false,
default: '',
},
},
......
......@@ -7,14 +7,20 @@ export default {
components: {
GlLink,
},
inject: {
props: {
displayText: {
type: String,
required: false,
default: '',
},
event: {
type: String,
required: false,
default: '',
},
label: {
type: String,
required: false,
default: '',
},
},
......
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import { isInIssuePage, isInDesignPage } from '~/lib/utils/common_utils';
import InviteMemberModal from './components/invite_member_modal.vue';
Vue.use(GlToast);
......@@ -7,7 +8,7 @@ Vue.use(GlToast);
export default function initInviteMembersModal() {
const el = document.querySelector('.js-invite-member-modal');
if (!el) {
if (!el || isInDesignPage() || isInIssuePage()) {
return false;
}
......@@ -15,7 +16,9 @@ export default function initInviteMembersModal() {
return new Vue({
el,
provide: { membersPath },
render: (createElement) => createElement(InviteMemberModal),
render: (createElement) =>
createElement(InviteMemberModal, {
props: { membersPath },
}),
});
}
......@@ -10,7 +10,9 @@ export default function initInviteMembersTrigger() {
return new Vue({
el,
provide: { ...el.dataset },
render: (createElement) => createElement(InviteMemberTrigger),
render: (createElement) =>
createElement(InviteMemberTrigger, {
props: { ...el.dataset },
}),
});
}
#import "~/graphql_shared/fragments/author.fragment.graphql"
query getProjectIssue($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
id
assignees {
nodes {
...Author
id
state
}
}
}
}
}
......@@ -103,10 +103,10 @@ export default {
v-gl-tooltip="tooltipOption"
:href="assigneeUrl"
:title="tooltipTitle"
class="d-inline-block"
class="gl-display-inline-block"
>
<!-- use d-flex so that slot can be appropriately styled -->
<span class="d-flex">
<span class="gl-display-flex">
<assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot></slot>
</span>
......
<script>
import actionCable from '~/actioncable_consumer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
import { assigneesQueries } from '~/sidebar/constants';
export default {
subscription: null,
......@@ -9,7 +9,8 @@ export default {
props: {
mediator: {
type: Object,
required: true,
required: false,
default: null,
},
issuableIid: {
type: String,
......@@ -19,10 +20,16 @@ export default {
type: String,
required: true,
},
issuableType: {
type: String,
required: true,
},
},
apollo: {
project: {
query,
workspace: {
query() {
return assigneesQueries[this.issuableType].query;
},
variables() {
return {
iid: this.issuableIid,
......@@ -30,7 +37,9 @@ export default {
};
},
result(data) {
this.handleFetchResult(data);
if (this.mediator) {
this.handleFetchResult(data);
}
},
},
},
......@@ -43,7 +52,7 @@ export default {
methods: {
received(data) {
if (data.event === 'updated') {
this.$apollo.queries.project.refetch();
this.$apollo.queries.workspace.refetch();
}
},
initActionCablePolling() {
......@@ -57,7 +66,7 @@ export default {
);
},
handleFetchResult({ data }) {
const { nodes } = data.project.issue.assignees;
const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({
...n,
......@@ -69,7 +78,7 @@ export default {
},
},
render() {
return this.$slots.default;
return null;
},
};
</script>
......@@ -18,6 +18,11 @@ export default {
required: false,
default: 'issue',
},
signedIn: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
assigneesText() {
......@@ -34,20 +39,28 @@ export default {
<div class="gl-display-flex gl-flex-direction-column issuable-assignees">
<div
v-if="emptyUsers"
class="gl-display-flex gl-align-items-center gl-text-gray-500"
class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed"
data-testid="none"
>
<span> {{ __('None') }} -</span>
<gl-button
data-testid="assign-yourself"
category="tertiary"
variant="link"
class="gl-ml-2"
@click="$emit('assign-self')"
>
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
</gl-button>
<span> {{ __('None') }}</span>
<template v-if="signedIn">
<span class="gl-ml-2">-</span>
<gl-button
data-testid="assign-yourself"
category="tertiary"
variant="link"
class="gl-ml-2"
@click="$emit('assign-self')"
>
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
</gl-button>
</template>
</div>
<uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
<uncollapsed-assignee-list
v-else
:users="users"
:issuable-type="issuableType"
class="gl-mt-2 hide-collapsed"
/>
</div>
</template>
......@@ -123,6 +123,7 @@ export default {
v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
:issuable-type="issuableType"
:mediator="mediator"
/>
<assignee-title
......
<script>
import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue';
import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { __ } from '~/locale';
export default {
displayText: __('Invite members'),
dataTrackLabel: 'edit_assignee',
components: {
InviteMemberTrigger,
InviteMemberModal,
InviteMembersTrigger,
},
inject: {
projectMembersPath: {
default: '',
},
directlyInviteMembers: {
default: false,
},
},
computed: {
trackEvent() {
return this.directlyInviteMembers ? 'click_invite_members' : 'click_invite_members_version_b';
},
},
};
</script>
<template>
<div>
<invite-members-trigger
v-if="directlyInviteMembers"
trigger-element="anchor"
:display-text="$options.displayText"
:event="trackEvent"
:label="$options.dataTrackLabel"
classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
/>
<template v-else>
<invite-member-trigger
:display-text="$options.displayText"
:event="trackEvent"
:label="$options.dataTrackLabel"
class="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
/>
<invite-member-modal :members-path="projectMembersPath" />
</template>
</div>
</template>
<script>
import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
components: {
GlAvatarLabeled,
GlAvatarLink,
},
props: {
user: {
type: Object,
required: true,
},
},
computed: {
userLabel() {
if (!this.user.status) {
return this.user.name;
}
return sprintf(s__('UserAvailability|%{author} (Busy)'), {
author: this.user.name,
});
},
},
};
</script>
<template>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="userLabel"
:sub-label="user.username"
:src="user.avatarUrl || user.avatar || user.avatar_url"
class="gl-align-items-center"
/>
</gl-avatar-link>
</template>
<script>
import { IssuableType } from '~/issue_show/constants';
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
......@@ -58,7 +59,10 @@ export default {
this.showLess = !this.showLess;
},
userAvailability(u) {
return u?.availability || '';
if (this.issuableType === IssuableType.MergeRequest) {
return u?.availability || '';
}
return u?.status?.availability || '';
},
},
};
......
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: { GlButton, GlLoadingIcon },
......@@ -20,6 +21,16 @@ export default {
required: false,
default: false,
},
initialLoading: {
type: Boolean,
required: false,
default: false,
},
isDirty: {
type: Boolean,
required: false,
default: false,
},
tracking: {
type: Object,
required: false,
......@@ -35,6 +46,11 @@ export default {
edit: false,
};
},
computed: {
editButtonText() {
return this.isDirty ? __('Apply') : __('Edit');
},
},
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
......@@ -86,15 +102,15 @@ export default {
<template>
<div>
<div class="gl-display-flex gl-align-items-center" @click.self="collapse">
<span class="hide-collapsed" data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
<span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span>
<gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" />
<gl-loading-icon
v-if="loading && isClassicSidebar"
inline
class="gl-mx-auto gl-my-0 hide-expanded"
/>
<gl-button
v-if="canUpdate"
v-if="canUpdate && !initialLoading"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed"
data-testid="edit-button"
......@@ -105,14 +121,16 @@ export default {
@keyup.esc="toggle"
@click="toggle"
>
{{ __('Edit') }}
{{ editButtonText }}
</gl-button>
</div>
<div v-show="!edit" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
<slot :edit="edit"></slot>
</div>
<template v-if="!initialLoading">
<div v-show="!edit" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
<slot :edit="edit"></slot>
</div>
</template>
</div>
</template>
......@@ -10,6 +10,8 @@ import {
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
......@@ -32,15 +34,6 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
/**
* Extracts the list of assignees with availability information from a hidden input
* field and converts to a key:value pair for use in the sidebar assignees component.
* The assignee username is used as the key and their busy status is the value
*
* e.g { root: 'busy', admin: '' }
*
* @returns {Object}
*/
function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl)
......@@ -54,7 +47,7 @@ function getSidebarAssigneeAvailabilityData() {
);
}
function mountAssigneesComponent(mediator) {
function mountAssigneesComponentDeprecated(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
if (!el) return;
......@@ -86,6 +79,51 @@ function mountAssigneesComponent(mediator) {
});
}
function mountAssigneesComponent() {
const el = document.getElementById('js-vue-sidebar-assignees');
if (!el) return;
const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
// eslint-disable-next-line no-new
new Vue({
el,
apolloProvider,
components: {
SidebarAssigneesWidget,
},
provide: {
canUpdate: editable,
projectMembersPath,
directlyInviteMembers: el.hasAttribute('data-directly-invite-members'),
indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'),
},
render: (createElement) =>
createElement('sidebar-assignees-widget', {
props: {
iid: String(iid),
fullPath,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
multipleAssignees: !el.dataset.maxAssignees,
},
scopedSlots: {
collapsed: ({ users, onClick }) =>
createElement(CollapsedAssigneeList, {
props: {
users,
},
nativeOn: {
click: onClick,
},
}),
},
}),
});
}
function mountReviewersComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-reviewers');
......@@ -342,7 +380,11 @@ function mountCopyEmailComponent() {
}
export function mountSidebar(mediator) {
mountAssigneesComponent(mediator);
if (isInIssuePage() || isInDesignPage()) {
mountAssigneesComponent();
} else {
mountAssigneesComponentDeprecated(mediator);
}
mountReviewersComponent(mediator);
mountConfidentialComponent(mediator);
mountReferenceComponent(mediator);
......
......@@ -30,5 +30,8 @@ export default {
<gl-dropdown-form>
<slot name="items"></slot>
</gl-dropdown-form>
<template #footer>
<slot name="footer"></slot>
</template>
</gl-dropdown>
</template>
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
......@@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
...UserAvailability
}
}
assignees {
nodes {
...User
...UserAvailability
}
}
}
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
......@@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
...UserAvailability
}
}
assignees {
nodes {
...User
...UserAvailability
}
}
}
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issuableSetAssignees: issueSetAssignees(
......@@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
assignees {
nodes {
...User
...UserAvailability
}
}
participants {
nodes {
...User
...UserAvailability
}
}
}
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees(
......@@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!,
assignees {
nodes {
...User
...UserAvailability
}
}
participants {
nodes {
...User
...UserAvailability
}
}
}
......
......@@ -389,7 +389,8 @@ module IssuablesHelper
severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
createNoteEmail: issuable[:create_note_email],
issuableType: issuable[:type]
issuableType: issuable[:type],
projectMembersPath: project_project_members_path(@project, sort: :access_level_desc)
}
end
......
- issuable_type = issuable_sidebar[:type]
- dropdown_options = assignees_dropdown_options(issuable_type)
#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in } }
#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in, max_assignees: dropdown_options[:data][:"max-select"], directly_invite_members: directly_invite_members?, indirectly_invite_members: indirectly_invite_members? } }
.title.hide-collapsed
= _('Assignee')
= loading_icon(css_class: 'gl-vertical-align-text-bottom')
......@@ -29,7 +30,6 @@
null_user: true,
display: 'static' } }
- dropdown_options = assignees_dropdown_options(issuable_type)
- title = dropdown_options[:title]
- options[:toggle_class] += ' js-multiselect js-save-user-data'
- data = { field_name: "#{issuable_type}[assignee_ids][]" }
......
---
title: Assignee dropdown in issue page displays only participants by default
merge_request: 56742
author:
type: changed
......@@ -48,7 +48,7 @@ RSpec.describe 'Issue Boards', :js do
first('.gl-avatar-labeled').click
end
click_button('Edit')
click_button('Apply')
wait_for_requests
expect(page).to have_content(assignee)
......@@ -73,7 +73,7 @@ RSpec.describe 'Issue Boards', :js do
all('.gl-avatar-labeled')[1].click
end
click_button('Edit')
click_button('Apply')
wait_for_requests
expect(page).to have_link(nil, title: user.name)
......@@ -94,7 +94,7 @@ RSpec.describe 'Issue Boards', :js do
find('[data-testid="unassign"]').click
end
click_button('Edit')
click_button('Apply')
wait_for_requests
expect(page).to have_content('None')
......@@ -134,7 +134,7 @@ RSpec.describe 'Issue Boards', :js do
first('.gl-avatar-labeled').click
end
click_button('Edit')
click_button('Apply')
wait_for_requests
expect(page).to have_content(assignee)
......
......@@ -17,6 +17,21 @@ RSpec.describe 'Issue Sidebar' do
sign_in(user)
end
context 'Assignees', :js do
let(:user2) { create(:user) }
let(:issue2) { create(:issue, project: project, author: user2) }
it 'shows label text as "Apply" when assignees are changed' do
project.add_developer(user)
visit_issue(project, issue2)
open_assignees_dropdown
click_on 'Unassigned'
expect(page).to have_content('Apply')
end
end
context 'updating weight', :js do
before do
project.add_maintainer(user)
......@@ -210,4 +225,11 @@ RSpec.describe 'Issue Sidebar' do
find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').click
find('aside.right-sidebar.right-sidebar-expanded')
end
def open_assignees_dropdown
page.within('.assignee') do
click_button('Edit')
wait_for_requests
end
end
end
describe('Sidebar', () => {
beforeEach(() => loadFixtures('issues/open-issue.html'));
it('does not have a max select', () => {
const dropdown = document.querySelector('.js-author-search');
expect(dropdown.dataset.maxSelect).toBeUndefined();
});
});
......@@ -63,7 +63,7 @@ export const mockMutationResponse = {
issuableSetIteration: {
errors: [],
issuable: {
id: mockIssueId,
id: 'gid://gitlab/Issue/1',
iteration: {
id: 'gid://gitlab/Iteration/2',
title: 'Awesome Iteration',
......@@ -76,134 +76,3 @@ export const mockMutationResponse = {
},
},
};
export const issuableQueryResponse = {
data: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: {
nodes: [
{
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'johndoe',
webUrl: '/john',
},
],
},
assignees: {
nodes: [
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
],
},
},
},
},
};
export const searchQueryResponse = {
data: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{
user: {
id: '1',
avatarUrl: '/avatar',
name: 'root',
username: 'root',
webUrl: 'root',
},
},
{
user: {
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
},
},
],
},
},
},
};
export const updateIssueAssigneesMutationResponse = {
data: {
issuableSetAssignees: {
issuable: {
id: 'gid://gitlab/Issue/1',
iid: '1',
assignees: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
],
__typename: 'UserConnection',
},
participants: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
},
{
__typename: 'User',
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
},
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
},
},
};
......@@ -41,7 +41,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do
first('.gl-avatar-labeled').click
end
click_button('Edit')
click_button('Apply')
wait_for_requests
expect(page).to have_content(assignee)
......@@ -63,7 +63,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do
find('[data-testid="unassign"]').click
end
click_button('Edit')
click_button('Apply')
wait_for_requests
expect(page).to have_content('None')
......@@ -102,7 +102,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do
first('.gl-avatar-labeled').click
end
click_button('Edit')
click_button('Apply')
wait_for_requests
expect(page).to have_content(assignee)
......
......@@ -30,29 +30,80 @@ RSpec.describe 'Issue Sidebar' do
let(:user2) { create(:user) }
let(:issue2) { create(:issue, project: project, author: user2) }
include_examples 'issuable invite members experiments' do
let(:issuable_path) { project_issue_path(project, issue2) }
context 'when a privileged user can invite' do
it 'shows a link for inviting members and launches invite modal' do
project.add_maintainer(user)
visit_issue(project, issue2)
open_assignees_dropdown
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite members')
expect(page).to have_selector('[data-track-event="click_invite_members"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]')
end
click_link 'Invite members'
expect(page).to have_content("You're inviting members to the")
end
end
context 'when user is a developer' do
context 'when invite_members_version_b experiment is enabled' do
before do
stub_experiment_for_subject(invite_members_version_b: true)
end
it 'shows a link for inviting members and follows through to modal' do
project.add_developer(user)
visit_issue(project, issue2)
find('.block.assignee .edit-link').click
open_assignees_dropdown
wait_for_requests
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite members', href: '#')
expect(page).to have_selector('[data-track-event="click_invite_members_version_b"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]')
end
click_link 'Invite members'
expect(page).to have_content("Oops, this feature isn't ready yet")
end
end
context 'when invite_members_version_b experiment is disabled' do
it 'shows author in assignee dropdown and no invite link' do
project.add_developer(user)
visit_issue(project, issue2)
open_assignees_dropdown
page.within '.dropdown-menu-user' do
expect(page).not_to have_link('Invite members')
end
end
end
context 'when user is a developer' do
before do
project.add_developer(user)
visit_issue(project, issue2)
end
it 'shows author in assignee dropdown' do
open_assignees_dropdown
page.within '.dropdown-menu-user' do
expect(page).to have_content(user2.name)
end
end
it 'shows author when filtering assignee dropdown' do
open_assignees_dropdown
page.within '.dropdown-menu-user' do
find('.dropdown-input-field').set(user2.name)
find('.js-dropdown-input-field').find('input').set(user2.name)
wait_for_requests
......@@ -61,23 +112,18 @@ RSpec.describe 'Issue Sidebar' do
end
it 'assigns yourself' do
find('.block.assignee .dropdown-menu-toggle').click
click_button 'assign yourself'
wait_for_requests
find('.block.assignee .edit-link').click
page.within '.dropdown-menu-user' do
expect(page.find('.dropdown-header')).to be_visible
expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name)
page.within '.assignee' do
expect(page).to have_content(user.name)
end
end
it 'keeps your filtered term after filtering and dismissing the dropdown' do
find('.dropdown-input-field').set(user2.name)
open_assignees_dropdown
find('.js-dropdown-input-field').find('input').set(user2.name)
wait_for_requests
page.within '.dropdown-menu-user' do
......@@ -86,23 +132,15 @@ RSpec.describe 'Issue Sidebar' do
end
find('.js-right-sidebar').click
find('.block.assignee .edit-link').click
expect(page.all('.dropdown-menu-user li').length).to eq(1)
expect(find('.dropdown-input-field').value).to eq(user2.name)
end
end
it 'shows label text as "Apply" when assignees are changed' do
project.add_developer(user)
visit_issue(project, issue2)
find('.block.assignee .edit-link').click
wait_for_requests
open_assignees_dropdown
click_on 'Unassigned'
page.within('.assignee') do
expect(page.all('[data-testid="selected-participant"]').length).to eq(1)
end
expect(page).to have_link('Apply')
expect(find('.js-dropdown-input-field').find('input').value).to eq(user2.name)
end
end
end
......@@ -334,4 +372,11 @@ RSpec.describe 'Issue Sidebar' do
find('aside.right-sidebar.right-sidebar-collapsed .js-sidebar-toggle').click
find('aside.right-sidebar.right-sidebar-expanded')
end
def open_assignees_dropdown
page.within('.assignee') do
click_button('Edit')
wait_for_requests
end
end
end
......@@ -168,21 +168,19 @@ RSpec.describe "Issues > User edits issue", :js do
describe 'update assignee' do
context 'by authorized user' do
def close_dropdown_menu_if_visible
find('.dropdown-menu-toggle', visible: :all).tap do |toggle|
toggle.click if toggle.visible?
end
end
it 'allows user to select unassigned' do
visit project_issue_path(project, issue)
page.within('.assignee') do
expect(page).to have_content "#{user.name}"
click_link 'Edit'
click_link 'Unassigned'
first('.title').click
click_button('Edit')
wait_for_requests
find('[data-testid="unassign"]').click
find('[data-testid="title"]').click
wait_for_requests
expect(page).to have_content 'None - assign yourself'
end
end
......@@ -193,10 +191,8 @@ RSpec.describe "Issues > User edits issue", :js do
page.within('.assignee') do
expect(page).to have_content "None"
end
page.within '.assignee' do
click_link 'Edit'
click_button('Edit')
wait_for_requests
end
page.within '.dropdown-menu-user' do
......@@ -204,6 +200,9 @@ RSpec.describe "Issues > User edits issue", :js do
end
page.within('.assignee') do
find('[data-testid="title"]').click
wait_for_requests
expect(page).to have_content user.name
end
end
......@@ -216,14 +215,14 @@ RSpec.describe "Issues > User edits issue", :js do
page.within '.assignee' do
expect(page).to have_content user.name
click_link 'Edit'
click_button('Edit')
wait_for_requests
click_link user.name
close_dropdown_menu_if_visible
find('[data-testid="title"]').click
wait_for_requests
page.within '.value .assign-yourself' do
expect(page).to have_content "None"
end
expect(page).to have_content "None"
end
end
end
......
......@@ -19,11 +19,14 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
expect(page.find('.assignee')).to have_content 'None'
end
gitlab_sign_in(user)
sign_in(user)
visit project_issue_path(project, issue)
expect(page.find('.assignee')).to have_content 'None'
click_button 'assign yourself'
wait_for_requests
expect(page.find('.assignee')).to have_content user.name
using_session :other_session do
expect(page.find('.assignee')).to have_content user.name
......
......@@ -212,8 +212,10 @@ RSpec.describe 'User edit profile' do
end
it 'shows author as busy in the assignee dropdown' do
find('.block.assignee .edit-link').click
wait_for_requests
page.within('.assignee') do
click_button('Edit')
wait_for_requests
end
page.within '.dropdown-menu-user' do
expect(page).to have_content("#{user.name} (Busy)")
......@@ -227,7 +229,7 @@ RSpec.describe 'User edit profile' do
visit project_issue_path(project, issue)
wait_for_requests
expect(page.find('[data-testid="expanded-assignee"]')).to have_text("#{user.name} (Busy)")
expect(page.find('.issuable-assignees')).to have_content("#{user.name} (Busy)")
end
end
......
......@@ -9,7 +9,7 @@ const memberPath = 'member_path';
const GlEmoji = { template: '<img />' };
const createComponent = () => {
return shallowMount(InviteMemberModal, {
provide: {
propsData: {
membersPath: memberPath,
},
stubs: {
......
......@@ -5,7 +5,7 @@ import InviteMemberTrigger from '~/invite_member/components/invite_member_trigge
import triggerProvides from './invite_member_trigger_mock_data';
const createComponent = () => {
return shallowMount(InviteMemberTrigger, { provide: triggerProvides });
return shallowMount(InviteMemberTrigger, { propsData: triggerProvides });
};
describe('InviteMemberTrigger', () => {
......
import ActionCable from '@rails/actioncable';
import { shallowMount } from '@vue/test-utils';
import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import { assigneesQueries } from '~/sidebar/constants';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import Mock from './mock_data';
......@@ -18,18 +18,19 @@ describe('Assignees Realtime', () => {
let wrapper;
let mediator;
const createComponent = () => {
const createComponent = (issuableType = 'issue') => {
wrapper = shallowMount(AssigneesRealtime, {
propsData: {
issuableIid: '1',
mediator,
projectPath: 'path/to/project',
issuableType,
},
mocks: {
$apollo: {
query,
query: assigneesQueries[issuableType].query,
queries: {
project: {
workspace: {
refetch: jest.fn(),
},
},
......@@ -51,8 +52,8 @@ describe('Assignees Realtime', () => {
describe('when handleFetchResult is called from smart query', () => {
it('sets assignees to the store', () => {
const data = {
project: {
issue: {
workspace: {
issuable: {
assignees: {
nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }],
},
......@@ -95,7 +96,7 @@ describe('Assignees Realtime', () => {
wrapper.vm.received({ event: 'updated' });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.$apollo.queries.project.refetch).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.queries.workspace.refetch).toHaveBeenCalledTimes(1);
});
});
});
......
......@@ -7,8 +7,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
......@@ -40,21 +43,23 @@ const initialAssignees = [
},
];
describe('BoardCardAssigneeDropdown', () => {
describe('Sidebar assignees widget', () => {
let wrapper;
let fakeApollo;
const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdown = () => wrapper.findComponent(MultiSelectDropdown);
const findAssigneesLoading = () => wrapper.find('[data-testid="loading-assignees"]');
const findInviteMembersLink = () => wrapper.findComponent(SidebarInviteMembers);
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]');
const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]');
const findUnselectedParticipants = () =>
wrapper.findAll('[data-testid="unselected-participant"]');
const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
......@@ -65,6 +70,7 @@ describe('BoardCardAssigneeDropdown', () => {
searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse),
updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess,
props = {},
provide = {},
} = {}) => {
fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler],
......@@ -88,6 +94,7 @@ describe('BoardCardAssigneeDropdown', () => {
provide: {
canUpdate: true,
rootPath: '/',
...provide,
},
stubs: {
SidebarEditableItem,
......@@ -99,28 +106,27 @@ describe('BoardCardAssigneeDropdown', () => {
};
beforeEach(() => {
window.gon = window.gon || {};
window.gon.current_username = 'root';
window.gon.current_user_fullname = 'Administrator';
window.gon.current_user_avatar_url = '/root';
gon.current_username = 'root';
gon.current_user_fullname = 'Administrator';
gon.current_user_avatar_url = '/root';
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
fakeApollo = null;
delete window.gon.current_username;
delete gon.current_username;
});
describe('with passed initial assignees', () => {
it('does not show loading state when query is loading', () => {
it('passes `initialLoading` as false to editable item', () => {
createComponent({
props: {
initialAssignees,
},
});
expect(findAssigneesLoading().exists()).toBe(false);
expect(findEditableItem().props('initialLoading')).toBe(false);
});
it('renders an initial assignees list with initialAssignees prop', () => {
......@@ -158,10 +164,10 @@ describe('BoardCardAssigneeDropdown', () => {
});
describe('without passed initial assignees', () => {
it('shows loading state when query is loading', () => {
it('passes `initialLoading` as true to editable item', () => {
createComponent();
expect(findAssigneesLoading().exists()).toBe(true);
expect(findEditableItem().props('initialLoading')).toBe(true);
});
it('renders assignees list from API response when resolved', async () => {
......@@ -232,6 +238,7 @@ describe('BoardCardAssigneeDropdown', () => {
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
],
],
......@@ -239,9 +246,9 @@ describe('BoardCardAssigneeDropdown', () => {
});
it('renders current user if they are not in participants or assignees', async () => {
window.gon.current_username = 'random';
window.gon.current_user_fullname = 'Mr Random';
window.gon.current_user_avatar_url = '/random';
gon.current_username = 'random';
gon.current_user_fullname = 'Mr Random';
gon.current_user_avatar_url = '/random';
createComponent();
await waitForPromises();
......@@ -393,6 +400,7 @@ describe('BoardCardAssigneeDropdown', () => {
name: 'Roodie',
username: 'roodie',
webUrl: '/roodie',
status: null,
});
const issuableQueryHandler = jest.fn().mockResolvedValue(responseCopy);
......@@ -454,4 +462,97 @@ describe('BoardCardAssigneeDropdown', () => {
});
});
});
describe('when user is not signed in', () => {
beforeEach(() => {
gon.current_username = undefined;
createComponent();
});
it('does not show current user in the dropdown', () => {
expandDropdown();
expect(findCurrentUser().exists()).toBe(false);
});
it('passes signedIn prop as false to IssuableAssignees', () => {
expect(findAssignees().props('signedIn')).toBe(false);
});
});
it('when realtime feature flag is disabled', async () => {
createComponent();
await waitForPromises();
expect(findRealtimeAssignees().exists()).toBe(false);
});
it('when realtime feature flag is enabled', async () => {
createComponent({
provide: {
glFeatures: {
realTimeIssueSidebar: true,
},
},
});
await waitForPromises();
expect(findRealtimeAssignees().exists()).toBe(true);
});
describe('when making changes to participants list', () => {
beforeEach(async () => {
createComponent();
});
it('passes falsy `isDirty` prop to editable item if no changes to selected users were made', () => {
expandDropdown();
expect(findEditableItem().props('isDirty')).toBe(false);
});
it('passes truthy `isDirty` prop if selected users list was changed', async () => {
expandDropdown();
expect(findEditableItem().props('isDirty')).toBe(false);
findUnselectedParticipants().at(0).vm.$emit('click');
await nextTick();
expect(findEditableItem().props('isDirty')).toBe(true);
});
it('passes falsy `isDirty` prop after dropdown is closed', async () => {
expandDropdown();
findUnselectedParticipants().at(0).vm.$emit('click');
findEditableItem().vm.$emit('close');
await waitForPromises();
expect(findEditableItem().props('isDirty')).toBe(false);
});
});
it('does not render invite members link on non-issue sidebar', async () => {
createComponent({ props: { issuableType: IssuableType.MergeRequest } });
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(false);
});
it('does not render invite members link if `directlyInviteMembers` and `indirectlyInviteMembers` were not passed', async () => {
createComponent();
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(false);
});
it('renders invite members link if `directlyInviteMembers` is true', async () => {
createComponent({
provide: {
directlyInviteMembers: true,
},
});
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(true);
});
it('renders invite members link if `indirectlyInviteMembers` is true', async () => {
createComponent({
provide: {
indirectlyInviteMembers: true,
},
});
await waitForPromises();
expect(findInviteMembersLink().exists()).toBe(true);
});
});
......@@ -5,7 +5,7 @@ import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'
describe('boards sidebar remove issue', () => {
let wrapper;
const findLoader = () => wrapper.find(GlLoadingIcon);
const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
const findTitle = () => wrapper.find('[data-testid="title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
......@@ -117,4 +117,35 @@ describe('boards sidebar remove issue', () => {
expect(wrapper.emitted().close).toBeUndefined();
});
it('renders `Edit` test when passed `isDirty` prop is false', () => {
createComponent({ props: { isDirty: false }, canUpdate: true });
expect(findEditButton().text()).toBe('Edit');
});
it('renders `Apply` test when passed `isDirty` prop is true', () => {
createComponent({ props: { isDirty: true }, canUpdate: true });
expect(findEditButton().text()).toBe('Apply');
});
describe('when initial loading is true', () => {
beforeEach(() => {
createComponent({ props: { initialLoading: true } });
});
it('renders loading icon', () => {
expect(findLoader().exists()).toBe(true);
});
it('does not render edit button', () => {
expect(findEditButton().exists()).toBe(false);
});
it('does not render collapsed and expanded content', () => {
expect(findCollapsed().exists()).toBe(false);
expect(findExpanded().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue';
import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue';
const testProjectMembersPath = 'test-path';
describe('Sidebar invite members component', () => {
let wrapper;
const findDirectInviteLink = () => wrapper.findComponent(InviteMembersTrigger);
const findIndirectInviteLink = () => wrapper.findComponent(InviteMemberTrigger);
const findInviteModal = () => wrapper.findComponent(InviteMemberModal);
const createComponent = ({ directlyInviteMembers = false } = {}) => {
wrapper = shallowMount(SidebarInviteMembers, {
provide: {
directlyInviteMembers,
projectMembersPath: testProjectMembersPath,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('when directly inviting members', () => {
beforeEach(() => {
createComponent({ directlyInviteMembers: true });
});
it('renders a direct link to project members path', () => {
expect(findDirectInviteLink().exists()).toBe(true);
});
it('does not render invite members trigger and modal components', () => {
expect(findIndirectInviteLink().exists()).toBe(false);
expect(findInviteModal().exists()).toBe(false);
});
});
describe('when indirectly inviting members', () => {
beforeEach(() => {
createComponent();
});
it('does not render a direct link to project members path', () => {
expect(findDirectInviteLink().exists()).toBe(false);
});
it('does not render invite members trigger and modal components', () => {
expect(findIndirectInviteLink().exists()).toBe(true);
expect(findInviteModal().exists()).toBe(true);
expect(findInviteModal().props('membersPath')).toBe(testProjectMembersPath);
});
});
});
import { GlAvatarLabeled } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
const user = {
name: 'John Doe',
username: 'johndoe',
webUrl: '/link',
avatarUrl: '/avatar',
};
describe('Sidebar participant component', () => {
let wrapper;
const findAvatar = () => wrapper.findComponent(GlAvatarLabeled);
const createComponent = (status = null) => {
wrapper = shallowMount(SidebarParticipant, {
propsData: {
user: {
...user,
status,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('when user is not busy', () => {
createComponent();
expect(findAvatar().props('label')).toBe(user.name);
});
it('when user is busy', () => {
createComponent({ availability: 'BUSY' });
expect(findAvatar().props('label')).toBe(`${user.name} (Busy)`);
});
});
......@@ -5,12 +5,15 @@ import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_
describe('IssuableAssignees', () => {
let wrapper;
const createComponent = (props = { users: [] }) => {
const createComponent = (props = {}) => {
wrapper = shallowMount(IssuableAssignees, {
provide: {
rootPath: '',
},
propsData: { ...props },
propsData: {
users: [],
...props,
},
});
};
const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList);
......@@ -22,12 +25,14 @@ describe('IssuableAssignees', () => {
});
describe('when no assignees are present', () => {
beforeEach(() => {
createComponent();
it('renders "None - assign yourself" when user is logged in', () => {
createComponent({ signedIn: true });
expect(findEmptyAssignee().text()).toBe('None - assign yourself');
});
it('renders "None - assign yourself"', () => {
expect(findEmptyAssignee().text()).toBe('None - assign yourself');
it('renders "None" when user is not logged in', () => {
createComponent();
expect(findEmptyAssignee().text()).toBe('None');
});
});
......@@ -41,7 +46,7 @@ describe('IssuableAssignees', () => {
describe('when clicking "assign yourself"', () => {
it('emits "assign-self"', () => {
createComponent();
createComponent({ signedIn: true });
wrapper.find('[data-testid="assign-yourself"]').vm.$emit('click');
expect(wrapper.emitted('assign-self')).toHaveLength(1);
});
......
......@@ -245,4 +245,147 @@ export const issueReferenceResponse = (reference) => ({
},
},
});
export const issuableQueryResponse = {
data: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
iid: '1',
participants: {
nodes: [
{
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
status: {
availability: 'BUSY',
},
},
{
id: 'gid://gitlab/User/3',
avatarUrl: '/avatar',
name: 'John Doe',
username: 'johndoe',
webUrl: '/john',
status: null,
},
],
},
assignees: {
nodes: [
{
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
status: null,
},
],
},
},
},
},
};
export const searchQueryResponse = {
data: {
workspace: {
__typename: 'Project',
users: {
nodes: [
{
user: {
id: '1',
avatarUrl: '/avatar',
name: 'root',
username: 'root',
webUrl: 'root',
status: null,
},
},
{
user: {
id: '2',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
status: null,
},
},
],
},
},
},
};
export const updateIssueAssigneesMutationResponse = {
data: {
issuableSetAssignees: {
issuable: {
id: 'gid://gitlab/Issue/1',
iid: '1',
assignees: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
],
__typename: 'UserConnection',
},
participants: {
nodes: [
{
__typename: 'User',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
{
__typename: 'User',
id: 'gid://gitlab/User/2',
avatarUrl:
'https://www.gravatar.com/avatar/a95e5b71488f4b9d69ce5ff58bfd28d6?s=80\u0026d=identicon',
name: 'Jacki Kub',
username: 'francina.skiles',
webUrl: '/franc',
status: null,
},
],
__typename: 'UserConnection',
},
__typename: 'Issue',
},
},
},
};
export default mockData;
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