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 "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query usersSearch($search: String!, $fullPath: ID!) { query usersSearch($search: String!, $fullPath: ID!) {
workspace: project(fullPath: $fullPath) { workspace: project(fullPath: $fullPath) {
...@@ -6,6 +7,7 @@ query usersSearch($search: String!, $fullPath: ID!) { ...@@ -6,6 +7,7 @@ query usersSearch($search: String!, $fullPath: ID!) {
nodes { nodes {
user { user {
...User ...User
...UserAvailability
} }
} }
} }
......
...@@ -19,8 +19,10 @@ export default { ...@@ -19,8 +19,10 @@ export default {
GlLink, GlLink,
GlModal, GlModal,
}, },
inject: { props: {
membersPath: { membersPath: {
type: String,
required: false,
default: '', default: '',
}, },
}, },
......
...@@ -7,14 +7,20 @@ export default { ...@@ -7,14 +7,20 @@ export default {
components: { components: {
GlLink, GlLink,
}, },
inject: { props: {
displayText: { displayText: {
type: String,
required: false,
default: '', default: '',
}, },
event: { event: {
type: String,
required: false,
default: '', default: '',
}, },
label: { label: {
type: String,
required: false,
default: '', default: '',
}, },
}, },
......
import { GlToast } from '@gitlab/ui'; import { GlToast } from '@gitlab/ui';
import Vue from 'vue'; import Vue from 'vue';
import { isInIssuePage, isInDesignPage } from '~/lib/utils/common_utils';
import InviteMemberModal from './components/invite_member_modal.vue'; import InviteMemberModal from './components/invite_member_modal.vue';
Vue.use(GlToast); Vue.use(GlToast);
...@@ -7,7 +8,7 @@ Vue.use(GlToast); ...@@ -7,7 +8,7 @@ Vue.use(GlToast);
export default function initInviteMembersModal() { export default function initInviteMembersModal() {
const el = document.querySelector('.js-invite-member-modal'); const el = document.querySelector('.js-invite-member-modal');
if (!el) { if (!el || isInDesignPage() || isInIssuePage()) {
return false; return false;
} }
...@@ -15,7 +16,9 @@ export default function initInviteMembersModal() { ...@@ -15,7 +16,9 @@ export default function initInviteMembersModal() {
return new Vue({ return new Vue({
el, el,
provide: { membersPath }, render: (createElement) =>
render: (createElement) => createElement(InviteMemberModal), createElement(InviteMemberModal, {
props: { membersPath },
}),
}); });
} }
...@@ -10,7 +10,9 @@ export default function initInviteMembersTrigger() { ...@@ -10,7 +10,9 @@ export default function initInviteMembersTrigger() {
return new Vue({ return new Vue({
el, el,
provide: { ...el.dataset }, render: (createElement) =>
render: (createElement) => createElement(InviteMemberTrigger), 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 { ...@@ -103,10 +103,10 @@ export default {
v-gl-tooltip="tooltipOption" v-gl-tooltip="tooltipOption"
:href="assigneeUrl" :href="assigneeUrl"
:title="tooltipTitle" :title="tooltipTitle"
class="d-inline-block" class="gl-display-inline-block"
> >
<!-- use d-flex so that slot can be appropriately styled --> <!-- 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" /> <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot></slot> <slot></slot>
</span> </span>
......
<script> <script>
import actionCable from '~/actioncable_consumer'; import actionCable from '~/actioncable_consumer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; import { assigneesQueries } from '~/sidebar/constants';
export default { export default {
subscription: null, subscription: null,
...@@ -9,7 +9,8 @@ export default { ...@@ -9,7 +9,8 @@ export default {
props: { props: {
mediator: { mediator: {
type: Object, type: Object,
required: true, required: false,
default: null,
}, },
issuableIid: { issuableIid: {
type: String, type: String,
...@@ -19,10 +20,16 @@ export default { ...@@ -19,10 +20,16 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
issuableType: {
type: String,
required: true,
},
}, },
apollo: { apollo: {
project: { workspace: {
query, query() {
return assigneesQueries[this.issuableType].query;
},
variables() { variables() {
return { return {
iid: this.issuableIid, iid: this.issuableIid,
...@@ -30,7 +37,9 @@ export default { ...@@ -30,7 +37,9 @@ export default {
}; };
}, },
result(data) { result(data) {
this.handleFetchResult(data); if (this.mediator) {
this.handleFetchResult(data);
}
}, },
}, },
}, },
...@@ -43,7 +52,7 @@ export default { ...@@ -43,7 +52,7 @@ export default {
methods: { methods: {
received(data) { received(data) {
if (data.event === 'updated') { if (data.event === 'updated') {
this.$apollo.queries.project.refetch(); this.$apollo.queries.workspace.refetch();
} }
}, },
initActionCablePolling() { initActionCablePolling() {
...@@ -57,7 +66,7 @@ export default { ...@@ -57,7 +66,7 @@ export default {
); );
}, },
handleFetchResult({ data }) { handleFetchResult({ data }) {
const { nodes } = data.project.issue.assignees; const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({ const assignees = nodes.map((n) => ({
...n, ...n,
...@@ -69,7 +78,7 @@ export default { ...@@ -69,7 +78,7 @@ export default {
}, },
}, },
render() { render() {
return this.$slots.default; return null;
}, },
}; };
</script> </script>
...@@ -18,6 +18,11 @@ export default { ...@@ -18,6 +18,11 @@ export default {
required: false, required: false,
default: 'issue', default: 'issue',
}, },
signedIn: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
assigneesText() { assigneesText() {
...@@ -34,20 +39,28 @@ export default { ...@@ -34,20 +39,28 @@ export default {
<div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div class="gl-display-flex gl-flex-direction-column issuable-assignees">
<div <div
v-if="emptyUsers" 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" data-testid="none"
> >
<span> {{ __('None') }} -</span> <span> {{ __('None') }}</span>
<gl-button <template v-if="signedIn">
data-testid="assign-yourself" <span class="gl-ml-2">-</span>
category="tertiary" <gl-button
variant="link" data-testid="assign-yourself"
class="gl-ml-2" category="tertiary"
@click="$emit('assign-self')" variant="link"
> class="gl-ml-2"
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> @click="$emit('assign-self')"
</gl-button> >
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
</gl-button>
</template>
</div> </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> </div>
</template> </template>
...@@ -123,6 +123,7 @@ export default { ...@@ -123,6 +123,7 @@ export default {
v-if="shouldEnableRealtime" v-if="shouldEnableRealtime"
:issuable-iid="issuableIid" :issuable-iid="issuableIid"
:project-path="projectPath" :project-path="projectPath"
:issuable-type="issuableType"
:mediator="mediator" :mediator="mediator"
/> />
<assignee-title <assignee-title
......
<script> <script>
import { import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants'; import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale'; import { __, n__ } from '~/locale';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SidebarInviteMembers from './sidebar_invite_members.vue';
import SidebarParticipant from './sidebar_participant.vue';
export const assigneesWidget = Vue.observable({ export const assigneesWidget = Vue.observable({
updateAssignees: null, updateAssignees: null,
}); });
const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
bubbles: true,
});
export default { export default {
i18n: { i18n: {
unassigned: __('Unassigned'), unassigned: __('Unassigned'),
...@@ -28,17 +30,26 @@ export default { ...@@ -28,17 +30,26 @@ export default {
assignees: __('Assignees'), assignees: __('Assignees'),
assignTo: __('Assign to'), assignTo: __('Assign to'),
}, },
assigneesQueries,
components: { components: {
SidebarEditableItem, SidebarEditableItem,
IssuableAssignees, IssuableAssignees,
MultiSelectDropdown, MultiSelectDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownDivider, GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon, GlLoadingIcon,
SidebarInviteMembers,
SidebarParticipant,
SidebarAssigneesRealtime,
},
mixins: [glFeatureFlagsMixin()],
inject: {
directlyInviteMembers: {
default: false,
},
indirectlyInviteMembers: {
default: false,
},
}, },
props: { props: {
iid: { iid: {
...@@ -76,12 +87,13 @@ export default { ...@@ -76,12 +87,13 @@ export default {
selected: [], selected: [],
isSettingAssignees: false, isSettingAssignees: false,
isSearching: false, isSearching: false,
isDirty: false,
}; };
}, },
apollo: { apollo: {
issuable: { issuable: {
query() { query() {
return this.$options.assigneesQueries[this.issuableType].query; return assigneesQueries[this.issuableType].query;
}, },
variables() { variables() {
return this.queryVariables; return this.queryVariables;
...@@ -134,6 +146,10 @@ export default { ...@@ -134,6 +146,10 @@ export default {
}, },
}, },
computed: { 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 === IssuableType.Issue;
},
queryVariables() { queryVariables() {
return { return {
iid: this.iid, iid: this.iid,
...@@ -155,6 +171,9 @@ export default { ...@@ -155,6 +171,9 @@ export default {
}, },
assigneeText() { assigneeText() {
const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected; const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
if (!items) {
return __('Assignee');
}
return n__('Assignee', '%d Assignees', items.length); return n__('Assignee', '%d Assignees', items.length);
}, },
selectedFiltered() { selectedFiltered() {
...@@ -197,8 +216,15 @@ export default { ...@@ -197,8 +216,15 @@ export default {
noUsersFound() { noUsersFound() {
return !this.isSearchEmpty && this.searchUsers.length === 0; return !this.isSearchEmpty && this.searchUsers.length === 0;
}, },
signedIn() {
return this.currentUser.username !== undefined;
},
showCurrentUser() { showCurrentUser() {
return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching); return (
this.signedIn &&
!this.isCurrentUserInParticipants &&
(this.isSearchEmpty || this.isSearching)
);
}, },
}, },
watch: { watch: {
...@@ -221,7 +247,7 @@ export default { ...@@ -221,7 +247,7 @@ export default {
this.isSettingAssignees = true; this.isSettingAssignees = true;
return this.$apollo return this.$apollo
.mutate({ .mutate({
mutation: this.$options.assigneesQueries[this.issuableType].mutation, mutation: assigneesQueries[this.issuableType].mutation,
variables: { variables: {
...this.queryVariables, ...this.queryVariables,
assigneeUsernames, assigneeUsernames,
...@@ -239,20 +265,22 @@ export default { ...@@ -239,20 +265,22 @@ export default {
}); });
}, },
selectAssignee(name) { selectAssignee(name) {
if (name === undefined) { this.isDirty = true;
this.clearSelected();
return;
}
if (!this.multipleAssignees) { if (!this.multipleAssignees) {
this.selected = [name]; this.selected = name ? [name] : [];
this.collapseWidget(); this.collapseWidget();
} else { return;
this.selected = this.selected.concat(name);
} }
if (name === undefined) {
this.clearSelected();
return;
}
this.selected = this.selected.concat(name);
}, },
unselect(name) { unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name); this.selected = this.selected.filter((user) => user.username !== name);
this.isDirty = true;
if (!this.multipleAssignees) { if (!this.multipleAssignees) {
this.collapseWidget(); this.collapseWidget();
...@@ -265,7 +293,9 @@ export default { ...@@ -265,7 +293,9 @@ export default {
this.selected = []; this.selected = [];
}, },
saveAssignees() { saveAssignees() {
this.isDirty = false;
this.updateAssignees(this.selectedUserNames); this.updateAssignees(this.selectedUserNames);
this.$el.dispatchEvent(hideDropdownEvent);
}, },
isChecked(id) { isChecked(id) {
return this.selectedUserNames.includes(id); return this.selectedUserNames.includes(id);
...@@ -291,6 +321,9 @@ export default { ...@@ -291,6 +321,9 @@ export default {
collapseWidget() { collapseWidget() {
this.$refs.toggle.collapse(); this.$refs.toggle.collapse();
}, },
expandWidget() {
this.$refs.toggle.expand();
},
showDivider(list) { showDivider(list) {
return list.length > 0 && this.isSearchEmpty; return list.length > 0 && this.isSearchEmpty;
}, },
...@@ -299,121 +332,113 @@ export default { ...@@ -299,121 +332,113 @@ export default {
</script> </script>
<template> <template>
<div <div data-testid="assignees-widget">
v-if="isAssigneesLoading" <sidebar-assignees-realtime
class="gl-display-flex gl-align-items-center assignee" v-if="shouldEnableRealtime"
data-testid="loading-assignees" :project-path="fullPath"
> :issuable-iid="iid"
{{ __('Assignee') }} :issuable-type="issuableType"
<gl-loading-icon size="sm" class="gl-ml-2" /> />
</div> <sidebar-editable-item
<sidebar-editable-item ref="toggle"
v-else :loading="isSettingAssignees"
ref="toggle" :initial-loading="isAssigneesLoading"
:loading="isSettingAssignees" :title="assigneeText"
:title="assigneeText" :is-dirty="isDirty"
@open="focusSearch" @open="focusSearch"
@close="saveAssignees" @close="saveAssignees"
> >
<template #collapsed> <template #collapsed>
<issuable-assignees <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot>
:users="assignees" <issuable-assignees
:issuable-type="issuableType" :users="assignees"
class="gl-mt-2" :issuable-type="issuableType"
@assign-self="assignSelf" :signed-in="signedIn"
/> @assign-self="assignSelf"
</template> @expand-widget="expandWidget"
/>
</template>
<template #default> <template #default>
<multi-select-dropdown <multi-select-dropdown
class="gl-w-full dropdown-menu-user" class="gl-w-full dropdown-menu-user"
:text="$options.i18n.assignees" :text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo" :header-text="$options.i18n.assignTo"
@toggle="collapseWidget" @toggle="collapseWidget"
> >
<template #search> <template #search>
<gl-search-box-by-type ref="search" v-model.trim="search" /> <gl-search-box-by-type
</template> ref="search"
<template #items> v-model.trim="search"
<gl-loading-icon class="js-dropdown-input-field"
v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading" />
data-testid="loading-participants" </template>
size="lg" <template #items>
/> <gl-loading-icon
<template v-else> v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
<template v-if="isSearchEmpty || isSearching"> data-testid="loading-participants"
size="lg"
/>
<template v-else>
<template v-if="isSearchEmpty || isSearching">
<gl-dropdown-item
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
@click="selectAssignee()"
>
<span
:class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'"
class="gl-font-weight-bold"
>{{ $options.i18n.unassigned }}</span
></gl-dropdown-item
>
</template>
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item <gl-dropdown-item
:is-checked="selectedIsEmpty" v-for="item in selectedFiltered"
:key="item.id"
:is-checked="isChecked(item.username)"
:is-check-centered="true" :is-check-centered="true"
data-testid="unassign" data-testid="selected-participant"
@click="selectAssignee()" @click.stop="unselect(item.username)"
> >
<span <sidebar-participant :user="item" />
:class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" </gl-dropdown-item>
class="gl-font-weight-bold" <template v-if="showCurrentUser">
>{{ $options.i18n.unassigned }}</span <gl-dropdown-divider />
></gl-dropdown-item <gl-dropdown-item
data-testid="current-user"
@click.stop="selectAssignee(currentUser)"
>
<sidebar-participant :user="currentUser" class="gl-pl-6!" />
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
data-testid="unselected-participant"
@click="selectAssignee(unselectedUser)"
> >
</template> <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> </gl-dropdown-item>
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
:is-checked="isChecked(item.username)"
:is-check-centered="true"
data-testid="selected-participant"
@click.stop="unselect(item.username)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="item.name"
:sub-label="item.username"
:src="item.avatarUrl || item.avatar || item.avatar_url"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
<gl-dropdown-item <gl-dropdown-item
data-testid="current-user" v-if="noUsersFound && !isSearching"
@click.stop="selectAssignee(currentUser)" data-testid="empty-results"
class="gl-pl-6!"
> >
<gl-avatar-link> {{ __('No matching results') }}
<gl-avatar-labeled
:size="32"
:label="currentUser.name"
:sub-label="currentUser.username"
:src="currentUser.avatarUrl"
class="gl-align-items-center gl-pl-6!"
/>
</gl-avatar-link>
</gl-dropdown-item> </gl-dropdown-item>
</template> </template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> </template>
<gl-dropdown-item <template #footer>
v-for="unselectedUser in unselectedFiltered" <gl-dropdown-item>
:key="unselectedUser.id" <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" />
data-testid="unselected-participant"
@click="selectAssignee(unselectedUser)"
>
<gl-avatar-link class="gl-pl-6!">
<gl-avatar-labeled
:size="32"
:label="unselectedUser.name"
:sub-label="unselectedUser.username"
:src="unselectedUser.avatarUrl || unselectedUser.avatar"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results">
{{ __('No matching results') }}
</gl-dropdown-item> </gl-dropdown-item>
</template> </template>
</template> </multi-select-dropdown>
</multi-select-dropdown> </template>
</template> </sidebar-editable-item>
</sidebar-editable-item> </div>
</template> </template>
<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> <script>
import { IssuableType } from '~/issue_show/constants';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue'; import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue'; import UserNameWithStatus from './user_name_with_status.vue';
...@@ -58,7 +59,10 @@ export default { ...@@ -58,7 +59,10 @@ export default {
this.showLess = !this.showLess; this.showLess = !this.showLess;
}, },
userAvailability(u) { userAvailability(u) {
return u?.availability || ''; if (this.issuableType === IssuableType.MergeRequest) {
return u?.availability || '';
}
return u?.status?.availability || '';
}, },
}, },
}; };
......
<script> <script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default { export default {
components: { GlButton, GlLoadingIcon }, components: { GlButton, GlLoadingIcon },
...@@ -20,6 +21,16 @@ export default { ...@@ -20,6 +21,16 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
initialLoading: {
type: Boolean,
required: false,
default: false,
},
isDirty: {
type: Boolean,
required: false,
default: false,
},
tracking: { tracking: {
type: Object, type: Object,
required: false, required: false,
...@@ -35,6 +46,11 @@ export default { ...@@ -35,6 +46,11 @@ export default {
edit: false, edit: false,
}; };
}, },
computed: {
editButtonText() {
return this.isDirty ? __('Apply') : __('Edit');
},
},
destroyed() { destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick); window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape); window.removeEventListener('keyup', this.collapseOnEscape);
...@@ -86,15 +102,15 @@ export default { ...@@ -86,15 +102,15 @@ export default {
<template> <template>
<div> <div>
<div class="gl-display-flex gl-align-items-center" @click.self="collapse"> <div class="gl-display-flex gl-align-items-center" @click.self="collapse">
<span class="hide-collapsed" data-testid="title">{{ title }}</span> <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" /> <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" />
<gl-loading-icon <gl-loading-icon
v-if="loading && isClassicSidebar" v-if="loading && isClassicSidebar"
inline inline
class="gl-mx-auto gl-my-0 hide-expanded" class="gl-mx-auto gl-my-0 hide-expanded"
/> />
<gl-button <gl-button
v-if="canUpdate" v-if="canUpdate && !initialLoading"
variant="link" variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed"
data-testid="edit-button" data-testid="edit-button"
...@@ -105,14 +121,16 @@ export default { ...@@ -105,14 +121,16 @@ export default {
@keyup.esc="toggle" @keyup.esc="toggle"
@click="toggle" @click="toggle"
> >
{{ __('Edit') }} {{ editButtonText }}
</gl-button> </gl-button>
</div> </div>
<div v-show="!edit" data-testid="collapsed-content"> <template v-if="!initialLoading">
<slot name="collapsed">{{ __('None') }}</slot> <div v-show="!edit" data-testid="collapsed-content">
</div> <slot name="collapsed">{{ __('None') }}</slot>
<div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> </div>
<slot :edit="edit"></slot> <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
</div> <slot :edit="edit"></slot>
</div>
</template>
</div> </div>
</template> </template>
...@@ -10,6 +10,8 @@ import { ...@@ -10,6 +10,8 @@ import {
parseBoolean, parseBoolean,
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import { __ } from '~/locale'; 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 SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import { apolloProvider } from '~/sidebar/graphql'; import { apolloProvider } from '~/sidebar/graphql';
...@@ -32,15 +34,6 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op ...@@ -32,15 +34,6 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML); 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() { function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input'); const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl) return Array.from(sidebarAssigneeEl)
...@@ -54,7 +47,7 @@ function getSidebarAssigneeAvailabilityData() { ...@@ -54,7 +47,7 @@ function getSidebarAssigneeAvailabilityData() {
); );
} }
function mountAssigneesComponent(mediator) { function mountAssigneesComponentDeprecated(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees'); const el = document.getElementById('js-vue-sidebar-assignees');
if (!el) return; if (!el) return;
...@@ -86,6 +79,51 @@ function mountAssigneesComponent(mediator) { ...@@ -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) { function mountReviewersComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-reviewers'); const el = document.getElementById('js-vue-sidebar-reviewers');
...@@ -342,7 +380,11 @@ function mountCopyEmailComponent() { ...@@ -342,7 +380,11 @@ function mountCopyEmailComponent() {
} }
export function mountSidebar(mediator) { export function mountSidebar(mediator) {
mountAssigneesComponent(mediator); if (isInIssuePage() || isInDesignPage()) {
mountAssigneesComponent();
} else {
mountAssigneesComponentDeprecated(mediator);
}
mountReviewersComponent(mediator); mountReviewersComponent(mediator);
mountConfidentialComponent(mediator); mountConfidentialComponent(mediator);
mountReferenceComponent(mediator); mountReferenceComponent(mediator);
......
...@@ -30,5 +30,8 @@ export default { ...@@ -30,5 +30,8 @@ export default {
<gl-dropdown-form> <gl-dropdown-form>
<slot name="items"></slot> <slot name="items"></slot>
</gl-dropdown-form> </gl-dropdown-form>
<template #footer>
<slot name="footer"></slot>
</template>
</gl-dropdown> </gl-dropdown>
</template> </template>
#import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) { query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) { workspace: project(fullPath: $fullPath) {
...@@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) { ...@@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
participants { participants {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
assignees { assignees {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
} }
......
#import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) { query getMrParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) { workspace: project(fullPath: $fullPath) {
...@@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { ...@@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
participants { participants {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
assignees { assignees {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
} }
......
#import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issuableSetAssignees: issueSetAssignees( issuableSetAssignees: issueSetAssignees(
...@@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP ...@@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
assignees { assignees {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
participants { participants {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
} }
......
#import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees( mergeRequestSetAssignees(
...@@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, ...@@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!,
assignees { assignees {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
participants { participants {
nodes { nodes {
...User ...User
...UserAvailability
} }
} }
} }
......
...@@ -389,7 +389,8 @@ module IssuablesHelper ...@@ -389,7 +389,8 @@ module IssuablesHelper
severity: issuable[:severity], severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours, timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
createNoteEmail: issuable[:create_note_email], createNoteEmail: issuable[:create_note_email],
issuableType: issuable[:type] issuableType: issuable[:type],
projectMembersPath: project_project_members_path(@project, sort: :access_level_desc)
} }
end end
......
- issuable_type = issuable_sidebar[:type] - 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 .title.hide-collapsed
= _('Assignee') = _('Assignee')
= loading_icon(css_class: 'gl-vertical-align-text-bottom') = loading_icon(css_class: 'gl-vertical-align-text-bottom')
...@@ -29,7 +30,6 @@ ...@@ -29,7 +30,6 @@
null_user: true, null_user: true,
display: 'static' } } display: 'static' } }
- dropdown_options = assignees_dropdown_options(issuable_type)
- title = dropdown_options[:title] - title = dropdown_options[:title]
- options[:toggle_class] += ' js-multiselect js-save-user-data' - options[:toggle_class] += ' js-multiselect js-save-user-data'
- data = { field_name: "#{issuable_type}[assignee_ids][]" } - 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 ...@@ -48,7 +48,7 @@ RSpec.describe 'Issue Boards', :js do
first('.gl-avatar-labeled').click first('.gl-avatar-labeled').click
end end
click_button('Edit') click_button('Apply')
wait_for_requests wait_for_requests
expect(page).to have_content(assignee) expect(page).to have_content(assignee)
...@@ -73,7 +73,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -73,7 +73,7 @@ RSpec.describe 'Issue Boards', :js do
all('.gl-avatar-labeled')[1].click all('.gl-avatar-labeled')[1].click
end end
click_button('Edit') click_button('Apply')
wait_for_requests wait_for_requests
expect(page).to have_link(nil, title: user.name) expect(page).to have_link(nil, title: user.name)
...@@ -94,7 +94,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -94,7 +94,7 @@ RSpec.describe 'Issue Boards', :js do
find('[data-testid="unassign"]').click find('[data-testid="unassign"]').click
end end
click_button('Edit') click_button('Apply')
wait_for_requests wait_for_requests
expect(page).to have_content('None') expect(page).to have_content('None')
...@@ -134,7 +134,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -134,7 +134,7 @@ RSpec.describe 'Issue Boards', :js do
first('.gl-avatar-labeled').click first('.gl-avatar-labeled').click
end end
click_button('Edit') click_button('Apply')
wait_for_requests wait_for_requests
expect(page).to have_content(assignee) expect(page).to have_content(assignee)
......
...@@ -17,6 +17,21 @@ RSpec.describe 'Issue Sidebar' do ...@@ -17,6 +17,21 @@ RSpec.describe 'Issue Sidebar' do
sign_in(user) sign_in(user)
end 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 context 'updating weight', :js do
before do before do
project.add_maintainer(user) project.add_maintainer(user)
...@@ -210,4 +225,11 @@ RSpec.describe 'Issue Sidebar' do ...@@ -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-collapsed .js-sidebar-toggle').click
find('aside.right-sidebar.right-sidebar-expanded') find('aside.right-sidebar.right-sidebar-expanded')
end end
def open_assignees_dropdown
page.within('.assignee') do
click_button('Edit')
wait_for_requests
end
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 = { ...@@ -63,7 +63,7 @@ export const mockMutationResponse = {
issuableSetIteration: { issuableSetIteration: {
errors: [], errors: [],
issuable: { issuable: {
id: mockIssueId, id: 'gid://gitlab/Issue/1',
iteration: { iteration: {
id: 'gid://gitlab/Iteration/2', id: 'gid://gitlab/Iteration/2',
title: 'Awesome Iteration', title: 'Awesome Iteration',
...@@ -76,134 +76,3 @@ export const mockMutationResponse = { ...@@ -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 ...@@ -41,7 +41,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do
first('.gl-avatar-labeled').click first('.gl-avatar-labeled').click
end end
click_button('Edit') click_button('Apply')
wait_for_requests wait_for_requests
expect(page).to have_content(assignee) expect(page).to have_content(assignee)
...@@ -63,7 +63,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do ...@@ -63,7 +63,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do
find('[data-testid="unassign"]').click find('[data-testid="unassign"]').click
end end
click_button('Edit') click_button('Apply')
wait_for_requests wait_for_requests
expect(page).to have_content('None') expect(page).to have_content('None')
...@@ -102,7 +102,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do ...@@ -102,7 +102,7 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do
first('.gl-avatar-labeled').click first('.gl-avatar-labeled').click
end end
click_button('Edit') click_button('Apply')
wait_for_requests wait_for_requests
expect(page).to have_content(assignee) expect(page).to have_content(assignee)
......
...@@ -30,29 +30,80 @@ RSpec.describe 'Issue Sidebar' do ...@@ -30,29 +30,80 @@ RSpec.describe 'Issue Sidebar' do
let(:user2) { create(:user) } let(:user2) { create(:user) }
let(:issue2) { create(:issue, project: project, author: user2) } let(:issue2) { create(:issue, project: project, author: user2) }
include_examples 'issuable invite members experiments' do context 'when a privileged user can invite' do
let(:issuable_path) { project_issue_path(project, issue2) } 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 end
context 'when user is a developer' do context 'when invite_members_version_b experiment is enabled' do
before 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) project.add_developer(user)
visit_issue(project, issue2) 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 end
it 'shows author in assignee dropdown' do it 'shows author in assignee dropdown' do
open_assignees_dropdown
page.within '.dropdown-menu-user' do page.within '.dropdown-menu-user' do
expect(page).to have_content(user2.name) expect(page).to have_content(user2.name)
end end
end end
it 'shows author when filtering assignee dropdown' do it 'shows author when filtering assignee dropdown' do
open_assignees_dropdown
page.within '.dropdown-menu-user' do 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 wait_for_requests
...@@ -61,23 +112,18 @@ RSpec.describe 'Issue Sidebar' do ...@@ -61,23 +112,18 @@ RSpec.describe 'Issue Sidebar' do
end end
it 'assigns yourself' do it 'assigns yourself' do
find('.block.assignee .dropdown-menu-toggle').click
click_button 'assign yourself' click_button 'assign yourself'
wait_for_requests wait_for_requests
find('.block.assignee .edit-link').click page.within '.assignee' do
expect(page).to have_content(user.name)
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)
end end
end end
it 'keeps your filtered term after filtering and dismissing the dropdown' do 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 wait_for_requests
page.within '.dropdown-menu-user' do page.within '.dropdown-menu-user' do
...@@ -86,23 +132,15 @@ RSpec.describe 'Issue Sidebar' do ...@@ -86,23 +132,15 @@ RSpec.describe 'Issue Sidebar' do
end end
find('.js-right-sidebar').click 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 open_assignees_dropdown
wait_for_requests
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
end end
...@@ -334,4 +372,11 @@ RSpec.describe 'Issue Sidebar' do ...@@ -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-collapsed .js-sidebar-toggle').click
find('aside.right-sidebar.right-sidebar-expanded') find('aside.right-sidebar.right-sidebar-expanded')
end end
def open_assignees_dropdown
page.within('.assignee') do
click_button('Edit')
wait_for_requests
end
end
end end
...@@ -168,21 +168,19 @@ RSpec.describe "Issues > User edits issue", :js do ...@@ -168,21 +168,19 @@ RSpec.describe "Issues > User edits issue", :js do
describe 'update assignee' do describe 'update assignee' do
context 'by authorized user' 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 it 'allows user to select unassigned' do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
page.within('.assignee') do page.within('.assignee') do
expect(page).to have_content "#{user.name}" expect(page).to have_content "#{user.name}"
click_link 'Edit' click_button('Edit')
click_link 'Unassigned' wait_for_requests
first('.title').click
find('[data-testid="unassign"]').click
find('[data-testid="title"]').click
wait_for_requests
expect(page).to have_content 'None - assign yourself' expect(page).to have_content 'None - assign yourself'
end end
end end
...@@ -193,10 +191,8 @@ RSpec.describe "Issues > User edits issue", :js do ...@@ -193,10 +191,8 @@ RSpec.describe "Issues > User edits issue", :js do
page.within('.assignee') do page.within('.assignee') do
expect(page).to have_content "None" expect(page).to have_content "None"
end click_button('Edit')
wait_for_requests
page.within '.assignee' do
click_link 'Edit'
end end
page.within '.dropdown-menu-user' do page.within '.dropdown-menu-user' do
...@@ -204,6 +200,9 @@ RSpec.describe "Issues > User edits issue", :js do ...@@ -204,6 +200,9 @@ RSpec.describe "Issues > User edits issue", :js do
end end
page.within('.assignee') do page.within('.assignee') do
find('[data-testid="title"]').click
wait_for_requests
expect(page).to have_content user.name expect(page).to have_content user.name
end end
end end
...@@ -216,14 +215,14 @@ RSpec.describe "Issues > User edits issue", :js do ...@@ -216,14 +215,14 @@ RSpec.describe "Issues > User edits issue", :js do
page.within '.assignee' do page.within '.assignee' do
expect(page).to have_content user.name expect(page).to have_content user.name
click_link 'Edit' click_button('Edit')
wait_for_requests
click_link user.name 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"
expect(page).to have_content "None"
end
end end
end end
end end
......
...@@ -19,11 +19,14 @@ RSpec.describe 'Issues > Real-time sidebar', :js do ...@@ -19,11 +19,14 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
expect(page.find('.assignee')).to have_content 'None' expect(page.find('.assignee')).to have_content 'None'
end end
gitlab_sign_in(user) sign_in(user)
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
expect(page.find('.assignee')).to have_content 'None' expect(page.find('.assignee')).to have_content 'None'
click_button 'assign yourself' click_button 'assign yourself'
wait_for_requests
expect(page.find('.assignee')).to have_content user.name
using_session :other_session do using_session :other_session do
expect(page.find('.assignee')).to have_content user.name expect(page.find('.assignee')).to have_content user.name
......
...@@ -212,8 +212,10 @@ RSpec.describe 'User edit profile' do ...@@ -212,8 +212,10 @@ RSpec.describe 'User edit profile' do
end end
it 'shows author as busy in the assignee dropdown' do it 'shows author as busy in the assignee dropdown' do
find('.block.assignee .edit-link').click page.within('.assignee') do
wait_for_requests click_button('Edit')
wait_for_requests
end
page.within '.dropdown-menu-user' do page.within '.dropdown-menu-user' do
expect(page).to have_content("#{user.name} (Busy)") expect(page).to have_content("#{user.name} (Busy)")
...@@ -227,7 +229,7 @@ RSpec.describe 'User edit profile' do ...@@ -227,7 +229,7 @@ RSpec.describe 'User edit profile' do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
wait_for_requests 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
end end
......
...@@ -9,7 +9,7 @@ const memberPath = 'member_path'; ...@@ -9,7 +9,7 @@ const memberPath = 'member_path';
const GlEmoji = { template: '<img />' }; const GlEmoji = { template: '<img />' };
const createComponent = () => { const createComponent = () => {
return shallowMount(InviteMemberModal, { return shallowMount(InviteMemberModal, {
provide: { propsData: {
membersPath: memberPath, membersPath: memberPath,
}, },
stubs: { stubs: {
......
...@@ -5,7 +5,7 @@ import InviteMemberTrigger from '~/invite_member/components/invite_member_trigge ...@@ -5,7 +5,7 @@ import InviteMemberTrigger from '~/invite_member/components/invite_member_trigge
import triggerProvides from './invite_member_trigger_mock_data'; import triggerProvides from './invite_member_trigger_mock_data';
const createComponent = () => { const createComponent = () => {
return shallowMount(InviteMemberTrigger, { provide: triggerProvides }); return shallowMount(InviteMemberTrigger, { propsData: triggerProvides });
}; };
describe('InviteMemberTrigger', () => { describe('InviteMemberTrigger', () => {
......
import ActionCable from '@rails/actioncable'; import ActionCable from '@rails/actioncable';
import { shallowMount } from '@vue/test-utils'; 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 AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import { assigneesQueries } from '~/sidebar/constants';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import Mock from './mock_data'; import Mock from './mock_data';
...@@ -18,18 +18,19 @@ describe('Assignees Realtime', () => { ...@@ -18,18 +18,19 @@ describe('Assignees Realtime', () => {
let wrapper; let wrapper;
let mediator; let mediator;
const createComponent = () => { const createComponent = (issuableType = 'issue') => {
wrapper = shallowMount(AssigneesRealtime, { wrapper = shallowMount(AssigneesRealtime, {
propsData: { propsData: {
issuableIid: '1', issuableIid: '1',
mediator, mediator,
projectPath: 'path/to/project', projectPath: 'path/to/project',
issuableType,
}, },
mocks: { mocks: {
$apollo: { $apollo: {
query, query: assigneesQueries[issuableType].query,
queries: { queries: {
project: { workspace: {
refetch: jest.fn(), refetch: jest.fn(),
}, },
}, },
...@@ -51,8 +52,8 @@ describe('Assignees Realtime', () => { ...@@ -51,8 +52,8 @@ describe('Assignees Realtime', () => {
describe('when handleFetchResult is called from smart query', () => { describe('when handleFetchResult is called from smart query', () => {
it('sets assignees to the store', () => { it('sets assignees to the store', () => {
const data = { const data = {
project: { workspace: {
issue: { issuable: {
assignees: { assignees: {
nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }], nodes: [{ id: 'gid://gitlab/Environments/123', avatarUrl: 'url' }],
}, },
...@@ -95,7 +96,7 @@ describe('Assignees Realtime', () => { ...@@ -95,7 +96,7 @@ describe('Assignees Realtime', () => {
wrapper.vm.received({ event: 'updated' }); wrapper.vm.received({ event: 'updated' });
return wrapper.vm.$nextTick().then(() => { 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'; ...@@ -7,8 +7,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql'; 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 IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.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 SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
...@@ -40,21 +43,23 @@ const initialAssignees = [ ...@@ -40,21 +43,23 @@ const initialAssignees = [
}, },
]; ];
describe('BoardCardAssigneeDropdown', () => { describe('Sidebar assignees widget', () => {
let wrapper; let wrapper;
let fakeApollo; let fakeApollo;
const findAssignees = () => wrapper.findComponent(IssuableAssignees); const findAssignees = () => wrapper.findComponent(IssuableAssignees);
const findRealtimeAssignees = () => wrapper.findComponent(SidebarAssigneesRealtime);
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdown = () => wrapper.findComponent(MultiSelectDropdown); 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 findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]');
const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]'); const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]');
const findUnselectedParticipants = () => const findUnselectedParticipants = () =>
wrapper.findAll('[data-testid="unselected-participant"]'); wrapper.findAll('[data-testid="unselected-participant"]');
const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]'); const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
const expandDropdown = () => wrapper.vm.$refs.toggle.expand(); const expandDropdown = () => wrapper.vm.$refs.toggle.expand();
...@@ -65,6 +70,7 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -65,6 +70,7 @@ describe('BoardCardAssigneeDropdown', () => {
searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse), searchQueryHandler = jest.fn().mockResolvedValue(searchQueryResponse),
updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess, updateIssueAssigneesMutationHandler = updateIssueAssigneesMutationSuccess,
props = {}, props = {},
provide = {},
} = {}) => { } = {}) => {
fakeApollo = createMockApollo([ fakeApollo = createMockApollo([
[getIssueParticipantsQuery, issuableQueryHandler], [getIssueParticipantsQuery, issuableQueryHandler],
...@@ -88,6 +94,7 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -88,6 +94,7 @@ describe('BoardCardAssigneeDropdown', () => {
provide: { provide: {
canUpdate: true, canUpdate: true,
rootPath: '/', rootPath: '/',
...provide,
}, },
stubs: { stubs: {
SidebarEditableItem, SidebarEditableItem,
...@@ -99,28 +106,27 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -99,28 +106,27 @@ describe('BoardCardAssigneeDropdown', () => {
}; };
beforeEach(() => { beforeEach(() => {
window.gon = window.gon || {}; gon.current_username = 'root';
window.gon.current_username = 'root'; gon.current_user_fullname = 'Administrator';
window.gon.current_user_fullname = 'Administrator'; gon.current_user_avatar_url = '/root';
window.gon.current_user_avatar_url = '/root';
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
fakeApollo = null; fakeApollo = null;
delete window.gon.current_username; delete gon.current_username;
}); });
describe('with passed initial assignees', () => { describe('with passed initial assignees', () => {
it('does not show loading state when query is loading', () => { it('passes `initialLoading` as false to editable item', () => {
createComponent({ createComponent({
props: { props: {
initialAssignees, initialAssignees,
}, },
}); });
expect(findAssigneesLoading().exists()).toBe(false); expect(findEditableItem().props('initialLoading')).toBe(false);
}); });
it('renders an initial assignees list with initialAssignees prop', () => { it('renders an initial assignees list with initialAssignees prop', () => {
...@@ -158,10 +164,10 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -158,10 +164,10 @@ describe('BoardCardAssigneeDropdown', () => {
}); });
describe('without passed initial assignees', () => { describe('without passed initial assignees', () => {
it('shows loading state when query is loading', () => { it('passes `initialLoading` as true to editable item', () => {
createComponent(); createComponent();
expect(findAssigneesLoading().exists()).toBe(true); expect(findEditableItem().props('initialLoading')).toBe(true);
}); });
it('renders assignees list from API response when resolved', async () => { it('renders assignees list from API response when resolved', async () => {
...@@ -232,6 +238,7 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -232,6 +238,7 @@ describe('BoardCardAssigneeDropdown', () => {
name: 'Administrator', name: 'Administrator',
username: 'root', username: 'root',
webUrl: '/root', webUrl: '/root',
status: null,
}, },
], ],
], ],
...@@ -239,9 +246,9 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -239,9 +246,9 @@ describe('BoardCardAssigneeDropdown', () => {
}); });
it('renders current user if they are not in participants or assignees', async () => { it('renders current user if they are not in participants or assignees', async () => {
window.gon.current_username = 'random'; gon.current_username = 'random';
window.gon.current_user_fullname = 'Mr Random'; gon.current_user_fullname = 'Mr Random';
window.gon.current_user_avatar_url = '/random'; gon.current_user_avatar_url = '/random';
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
...@@ -393,6 +400,7 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -393,6 +400,7 @@ describe('BoardCardAssigneeDropdown', () => {
name: 'Roodie', name: 'Roodie',
username: 'roodie', username: 'roodie',
webUrl: '/roodie', webUrl: '/roodie',
status: null,
}); });
const issuableQueryHandler = jest.fn().mockResolvedValue(responseCopy); const issuableQueryHandler = jest.fn().mockResolvedValue(responseCopy);
...@@ -454,4 +462,97 @@ describe('BoardCardAssigneeDropdown', () => { ...@@ -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' ...@@ -5,7 +5,7 @@ import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'
describe('boards sidebar remove issue', () => { describe('boards sidebar remove issue', () => {
let wrapper; let wrapper;
const findLoader = () => wrapper.find(GlLoadingIcon); const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findEditButton = () => wrapper.find('[data-testid="edit-button"]'); const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
const findTitle = () => wrapper.find('[data-testid="title"]'); const findTitle = () => wrapper.find('[data-testid="title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
...@@ -117,4 +117,35 @@ describe('boards sidebar remove issue', () => { ...@@ -117,4 +117,35 @@ describe('boards sidebar remove issue', () => {
expect(wrapper.emitted().close).toBeUndefined(); 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_ ...@@ -5,12 +5,15 @@ import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_
describe('IssuableAssignees', () => { describe('IssuableAssignees', () => {
let wrapper; let wrapper;
const createComponent = (props = { users: [] }) => { const createComponent = (props = {}) => {
wrapper = shallowMount(IssuableAssignees, { wrapper = shallowMount(IssuableAssignees, {
provide: { provide: {
rootPath: '', rootPath: '',
}, },
propsData: { ...props }, propsData: {
users: [],
...props,
},
}); });
}; };
const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList);
...@@ -22,12 +25,14 @@ describe('IssuableAssignees', () => { ...@@ -22,12 +25,14 @@ describe('IssuableAssignees', () => {
}); });
describe('when no assignees are present', () => { describe('when no assignees are present', () => {
beforeEach(() => { it('renders "None - assign yourself" when user is logged in', () => {
createComponent(); createComponent({ signedIn: true });
expect(findEmptyAssignee().text()).toBe('None - assign yourself');
}); });
it('renders "None - assign yourself"', () => { it('renders "None" when user is not logged in', () => {
expect(findEmptyAssignee().text()).toBe('None - assign yourself'); createComponent();
expect(findEmptyAssignee().text()).toBe('None');
}); });
}); });
...@@ -41,7 +46,7 @@ describe('IssuableAssignees', () => { ...@@ -41,7 +46,7 @@ describe('IssuableAssignees', () => {
describe('when clicking "assign yourself"', () => { describe('when clicking "assign yourself"', () => {
it('emits "assign-self"', () => { it('emits "assign-self"', () => {
createComponent(); createComponent({ signedIn: true });
wrapper.find('[data-testid="assign-yourself"]').vm.$emit('click'); wrapper.find('[data-testid="assign-yourself"]').vm.$emit('click');
expect(wrapper.emitted('assign-self')).toHaveLength(1); expect(wrapper.emitted('assign-self')).toHaveLength(1);
}); });
......
...@@ -245,4 +245,147 @@ export const issueReferenceResponse = (reference) => ({ ...@@ -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; 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