summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/sidebar
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/sidebar')
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue54
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue403
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue40
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue128
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue95
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue4
-rw-r--r--app/assets/javascripts/sidebar/constants.js16
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js2
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js2
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js56
-rw-r--r--app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql5
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js15
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js2
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js16
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js8
37 files changed, 864 insertions, 161 deletions
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index fbbe2e341a7..d0a65b48522 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,8 +1,46 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
+import { isUserBusy } from '~/set_status_modal/utils';
import AssigneeAvatar from './assignee_avatar.vue';
+const I18N = {
+ BUSY: __('Busy'),
+ CANNOT_MERGE: __('Cannot merge'),
+ LC_CANNOT_MERGE: __('cannot merge'),
+};
+
+const paranthesize = (str) => `(${str})`;
+
+const generateAssigneeTooltip = ({
+ name,
+ availability,
+ cannotMerge = true,
+ tooltipHasName = false,
+}) => {
+ if (!tooltipHasName) {
+ return cannotMerge ? I18N.CANNOT_MERGE : '';
+ }
+
+ const statusInformation = [];
+ if (availability && isUserBusy(availability)) {
+ statusInformation.push(I18N.BUSY);
+ }
+
+ if (cannotMerge) {
+ statusInformation.push(I18N.LC_CANNOT_MERGE);
+ }
+
+ if (tooltipHasName && statusInformation.length) {
+ return sprintf(__('%{name} %{status}'), {
+ name,
+ status: statusInformation.map(paranthesize).join(' '),
+ });
+ }
+
+ return name;
+};
+
export default {
components: {
AssigneeAvatar,
@@ -37,15 +75,13 @@ export default {
return this.issuableType === 'merge_request' && !this.user.can_merge;
},
tooltipTitle() {
- if (this.cannotMerge && this.tooltipHasName) {
- return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name });
- } else if (this.cannotMerge) {
- return __('Cannot merge');
- } else if (this.tooltipHasName) {
- return this.user.name;
- }
-
- return '';
+ const { name = '', availability = '' } = this.user;
+ return generateAssigneeTooltip({
+ name,
+ availability,
+ cannotMerge: this.cannotMerge,
+ tooltipHasName: this.tooltipHasName,
+ });
},
tooltipOption() {
return {
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 84e7110e2b2..c3c009e680a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -11,10 +11,6 @@ export default {
UncollapsedAssigneeList,
},
props: {
- rootPath: {
- type: String,
- required: true,
- },
users: {
type: Array,
required: true,
@@ -36,7 +32,6 @@ export default {
sortedAssigness() {
const canMergeUsers = this.users.filter((user) => user.can_merge);
const canNotMergeUsers = this.users.filter((user) => !user.can_merge);
-
return [...canMergeUsers, ...canNotMergeUsers];
},
},
@@ -52,9 +47,9 @@ export default {
<div>
<collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" />
- <div class="value hide-collapsed">
+ <div data-testid="expanded-assignee" class="value hide-collapsed">
<template v-if="hasNoUsers">
- <span class="assign-yourself no-value qa-assign-yourself">
+ <span class="assign-yourself no-value">
{{ __('None') }}
<template v-if="editable">
-
@@ -65,12 +60,7 @@ export default {
</span>
</template>
- <uncollapsed-assignee-list
- v-else
- :users="sortedAssigness"
- :root-path="rootPath"
- :issuable-type="issuableType"
- />
+ <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index 0eee287e0c2..ca86d6c6c3e 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,7 +1,7 @@
<script>
+import actionCable from '~/actioncable_consumer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
-import actionCable from '~/actioncable_consumer';
export default {
subscription: null,
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
index 2f654409561..af4227fa48d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
@@ -1,9 +1,11 @@
<script>
import AssigneeAvatar from './assignee_avatar.vue';
+import UserNameWithStatus from './user_name_with_status.vue';
export default {
components: {
AssigneeAvatar,
+ UserNameWithStatus,
},
props: {
user: {
@@ -16,12 +18,20 @@ export default {
default: 'issue',
},
},
+ computed: {
+ availability() {
+ return this.user?.availability || '';
+ },
+ },
};
</script>
-
<template>
<button type="button" class="btn-link">
<assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
- <span class="author"> {{ user.name }} </span>
+ <user-name-with-status
+ :name="user.name"
+ :availability="availability"
+ container-classes="author"
+ />
</button>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index b713b0f960c..20667e695ce 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -1,11 +1,30 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
+import { isUserBusy } from '~/set_status_modal/utils';
import CollapsedAssignee from './collapsed_assignee.vue';
const DEFAULT_MAX_COUNTER = 99;
const DEFAULT_RENDER_COUNT = 5;
+const generateCollapsedAssigneeTooltip = ({ renderUsers, allUsers, tooltipTitleMergeStatus }) => {
+ const names = renderUsers.map(({ name, availability }) => {
+ if (availability && isUserBusy(availability)) {
+ return sprintf(__('%{name} (Busy)'), { name });
+ }
+ return name;
+ });
+
+ if (!allUsers.length) {
+ return __('Assignee(s)');
+ }
+ if (allUsers.length > names.length) {
+ names.push(sprintf(__('+ %{amount} more'), { amount: allUsers.length - names.length }));
+ }
+ const text = names.join(', ');
+ return tooltipTitleMergeStatus ? `${text} (${tooltipTitleMergeStatus})` : text;
+};
+
export default {
directives: {
GlTooltip: GlTooltipDirective,
@@ -74,19 +93,11 @@ export default {
tooltipTitle() {
const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length);
const renderUsers = this.users.slice(0, maxRender);
- const names = renderUsers.map((u) => u.name);
-
- if (!this.users.length) {
- return __('Assignee(s)');
- }
-
- if (this.users.length > names.length) {
- names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length }));
- }
-
- const text = names.join(', ');
-
- return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text;
+ return generateCollapsedAssigneeTooltip({
+ renderUsers,
+ allUsers: this.users,
+ tooltipTitleMergeStatus: this.tooltipTitleMergeStatus,
+ });
},
tooltipOptions() {
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index 3c1b3afe889..e2dc37a0ac2 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -8,12 +8,16 @@ export default {
GlButton,
UncollapsedAssigneeList,
},
- inject: ['rootPath'],
props: {
users: {
type: Array,
required: true,
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
},
computed: {
assigneesText() {
@@ -36,9 +40,9 @@ export default {
variant="link"
@click="$emit('assign-self')"
>
- <span class="gl-text-gray-400">{{ __('assign yourself') }}</span>
+ <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
</gl-button>
</div>
- <uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" />
+ <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index b9f268629fb..6595debf9a5 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -1,13 +1,13 @@
<script>
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue';
import AssigneesRealtime from './assignees_realtime.vue';
-import { __ } from '~/locale';
export default {
name: 'SidebarAssignees',
@@ -44,6 +44,11 @@ export default {
type: String,
required: true,
},
+ assigneeAvailabilityStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -101,6 +106,13 @@ export default {
return new Flash(__('Error occurred when saving assignees'));
});
},
+ exposeAvailabilityStatus(users) {
+ return users.map(({ username, ...rest }) => ({
+ ...rest,
+ username,
+ availability: this.assigneeAvailabilityStatus[username] || '',
+ }));
+ },
},
};
</script>
@@ -123,7 +135,7 @@ export default {
<assignees
v-if="!store.isFetching.assignees"
:root-path="relativeUrlRoot"
- :users="store.assignees"
+ :users="exposeAvailabilityStatus(store.assignees)"
:editable="store.editable"
:issuable-type="issuableType"
class="value"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
new file mode 100644
index 00000000000..8f3f77cb5f0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -0,0 +1,403 @@
+<script>
+import {
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import Vue from 'vue';
+import createFlash from '~/flash';
+import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
+import { IssuableType } from '~/issue_show/constants';
+import { __, n__ } from '~/locale';
+import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { assigneesQueries } from '~/sidebar/constants';
+import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
+
+export const assigneesWidget = Vue.observable({
+ updateAssignees: null,
+});
+
+export default {
+ i18n: {
+ unassigned: __('Unassigned'),
+ assignee: __('Assignee'),
+ assignees: __('Assignees'),
+ assignTo: __('Assign to'),
+ },
+ assigneesQueries,
+ components: {
+ SidebarEditableItem,
+ IssuableAssignees,
+ MultiSelectDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ },
+ props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ initialAssignees: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: IssuableType.Issue,
+ validator(value) {
+ return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
+ },
+ },
+ multipleAssignees: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ search: '',
+ issuable: {},
+ searchUsers: [],
+ selected: [],
+ isSettingAssignees: false,
+ isSearching: false,
+ };
+ },
+ apollo: {
+ issuable: {
+ query() {
+ return this.$options.assigneesQueries[this.issuableType].query;
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.issuable || data.project?.issuable;
+ },
+ result({ data }) {
+ const issuable = data.issuable || data.project?.issuable;
+ if (issuable) {
+ this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
+ }
+ },
+ error() {
+ createFlash({ message: __('An error occurred while fetching participants.') });
+ },
+ },
+ searchUsers: {
+ query: searchUsers,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ return data.users?.nodes || [];
+ },
+ debounce: 250,
+ skip() {
+ return this.isSearchEmpty;
+ },
+ error() {
+ createFlash({ message: __('An error occurred while searching users.') });
+ this.isSearching = false;
+ },
+ result() {
+ this.isSearching = false;
+ },
+ },
+ },
+ computed: {
+ queryVariables() {
+ return {
+ iid: this.iid,
+ fullPath: this.fullPath,
+ };
+ },
+ assignees() {
+ const currentAssignees = this.$apollo.queries.issuable.loading
+ ? this.initialAssignees
+ : this.issuable?.assignees?.nodes;
+ return currentAssignees || [];
+ },
+ participants() {
+ const users =
+ this.isSearchEmpty || this.isSearching
+ ? this.issuable?.participants?.nodes
+ : this.searchUsers;
+ return this.moveCurrentUserToStart(users);
+ },
+ assigneeText() {
+ const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
+ return n__('Assignee', '%d Assignees', items.length);
+ },
+ selectedFiltered() {
+ if (this.isSearchEmpty || this.isSearching) {
+ return this.selected;
+ }
+
+ const foundUsernames = this.searchUsers.map(({ username }) => username);
+ return this.selected.filter(({ username }) => foundUsernames.includes(username));
+ },
+ unselectedFiltered() {
+ return (
+ this.participants?.filter(({ username }) => !this.selectedUserNames.includes(username)) ||
+ []
+ );
+ },
+ selectedIsEmpty() {
+ return this.selectedFiltered.length === 0;
+ },
+ selectedUserNames() {
+ return this.selected.map(({ username }) => username);
+ },
+ isSearchEmpty() {
+ return this.search === '';
+ },
+ currentUser() {
+ return {
+ username: gon?.current_username,
+ name: gon?.current_user_fullname,
+ avatarUrl: gon?.current_user_avatar_url,
+ };
+ },
+ isAssigneesLoading() {
+ return !this.initialAssignees && this.$apollo.queries.issuable.loading;
+ },
+ isCurrentUserInParticipants() {
+ const isCurrentUser = (user) => user.username === this.currentUser.username;
+ return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser);
+ },
+ noUsersFound() {
+ return !this.isSearchEmpty && this.unselectedFiltered.length === 0;
+ },
+ showCurrentUser() {
+ return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
+ },
+ },
+ watch: {
+ // We need to add this watcher to track the moment when user is alredy typing
+ // but query is still not started due to debounce
+ search(newVal) {
+ if (newVal) {
+ this.isSearching = true;
+ }
+ },
+ },
+ created() {
+ assigneesWidget.updateAssignees = this.updateAssignees;
+ },
+ destroyed() {
+ assigneesWidget.updateAssignees = null;
+ },
+ methods: {
+ updateAssignees(assigneeUsernames) {
+ this.isSettingAssignees = true;
+ return this.$apollo
+ .mutate({
+ mutation: this.$options.assigneesQueries[this.issuableType].mutation,
+ variables: {
+ ...this.queryVariables,
+ assigneeUsernames,
+ },
+ })
+ .then(({ data }) => {
+ this.$emit('assignees-updated', data);
+ return data;
+ })
+ .catch(() => {
+ createFlash({ message: __('An error occurred while updating assignees.') });
+ })
+ .finally(() => {
+ this.isSettingAssignees = false;
+ });
+ },
+ selectAssignee(name) {
+ if (name === undefined) {
+ this.clearSelected();
+ return;
+ }
+
+ if (!this.multipleAssignees) {
+ this.selected = [name];
+ this.collapseWidget();
+ } else {
+ this.selected = this.selected.concat(name);
+ }
+ },
+ unselect(name) {
+ this.selected = this.selected.filter((user) => user.username !== name);
+
+ if (!this.multipleAssignees) {
+ this.collapseWidget();
+ }
+ },
+ assignSelf() {
+ this.updateAssignees(this.currentUser.username);
+ },
+ clearSelected() {
+ this.selected = [];
+ },
+ saveAssignees() {
+ this.updateAssignees(this.selectedUserNames);
+ },
+ isChecked(id) {
+ return this.selectedUserNames.includes(id);
+ },
+ async focusSearch() {
+ await this.$nextTick();
+ this.$refs.search.focusInput();
+ },
+ moveCurrentUserToStart(users) {
+ if (!users) {
+ return [];
+ }
+ const usersCopy = [...users];
+ const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
+
+ if (currentUser) {
+ const index = usersCopy.indexOf(currentUser);
+ usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
+ }
+
+ return usersCopy;
+ },
+ collapseWidget() {
+ this.$refs.toggle.collapse();
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="isAssigneesLoading"
+ class="gl-display-flex gl-align-items-center assignee"
+ data-testid="loading-assignees"
+ >
+ {{ __('Assignee') }}
+ <gl-loading-icon size="sm" class="gl-ml-2" />
+ </div>
+ <sidebar-editable-item
+ v-else
+ ref="toggle"
+ :loading="isSettingAssignees"
+ :title="assigneeText"
+ @open="focusSearch"
+ @close="saveAssignees"
+ >
+ <template #collapsed>
+ <issuable-assignees
+ :users="assignees"
+ :issuable-type="issuableType"
+ @assign-self="assignSelf"
+ />
+ </template>
+
+ <template #default>
+ <multi-select-dropdown
+ class="gl-w-full dropdown-menu-user"
+ :text="$options.i18n.assignees"
+ :header-text="$options.i18n.assignTo"
+ @toggle="collapseWidget"
+ >
+ <template #search>
+ <gl-search-box-by-type ref="search" v-model.trim="search" />
+ </template>
+ <template #items>
+ <gl-loading-icon
+ v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
+ 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'">{{
+ $options.i18n.unassigned
+ }}</span></gl-dropdown-item
+ >
+ <gl-dropdown-divider data-testid="unassign-divider" />
+ </template>
+ <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>
+ <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
+ <template v-if="showCurrentUser">
+ <gl-dropdown-item
+ data-testid="unselected-participant"
+ @click.stop="selectAssignee(currentUser)"
+ >
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="currentUser.name"
+ :sub-label="currentUser.username"
+ :src="currentUser.avatarUrl"
+ class="gl-align-items-center"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+ <gl-dropdown-item
+ v-for="unselectedUser in unselectedFiltered"
+ :key="unselectedUser.id"
+ 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">
+ {{ __('No matching results') }}
+ </gl-dropdown-item>
+ </template>
+ </template>
+ </multi-select-dropdown>
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 31d5d7c0077..36775648809 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -1,12 +1,14 @@
<script>
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
+import UserNameWithStatus from './user_name_with_status.vue';
const DEFAULT_RENDER_COUNT = 5;
export default {
components: {
AssigneeAvatarLink,
+ UserNameWithStatus,
},
props: {
users: {
@@ -55,6 +57,9 @@ export default {
toggleShowLess() {
this.showLess = !this.showLess;
},
+ userAvailability(u) {
+ return u?.availability || '';
+ },
},
};
</script>
@@ -68,7 +73,7 @@ export default {
:issuable-type="issuableType"
>
<div class="ml-2 gl-line-height-normal">
- <div>{{ firstUser.name }}</div>
+ <user-name-with-status :name="firstUser.name" :availability="userAvailability(firstUser)" />
<div>{{ username }}</div>
</div>
</assignee-avatar-link>
diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
new file mode 100644
index 00000000000..41b3b6c9a45
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { isUserBusy } from '~/set_status_modal/utils';
+
+export default {
+ name: 'UserNameWithStatus',
+ components: {
+ GlSprintf,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ containerClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ availability: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isBusy() {
+ return isUserBusy(this.availability);
+ },
+ },
+};
+</script>
+<template>
+ <span :class="containerClasses">
+ <gl-sprintf v-if="isBusy" :message="s__('UserAvailability|%{author} (Busy)')">
+ <template #author>{{ name }}</template>
+ </gl-sprintf>
+ <template v-else>{{ name }}</template>
+ </span>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index ce120ff82f3..57b3705e803 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -1,6 +1,6 @@
<script>
-import { mapState } from 'vuex';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { mapState } from 'vuex';
import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import EditForm from './edit_form.vue';
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
index 17e44cf0e1d..057224d5918 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue
@@ -1,7 +1,7 @@
<script>
import { GlSprintf } from '@gitlab/ui';
-import editFormButtons from './edit_form_buttons.vue';
import { __ } from '../../../locale';
+import editFormButtons from './edit_form_buttons.vue';
export default {
components: {
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
index d210f9efcb3..154a228c978 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -1,9 +1,9 @@
<script>
-import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
+import $ from 'jquery';
import { mapActions } from 'vuex';
-import { __ } from '~/locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import { __ } from '~/locale';
import eventHub from '../../event_hub';
export default {
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index e01e1f032e3..c9b6616e067 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -3,13 +3,13 @@ import $ from 'jquery';
import { camelCase, difference, union } from 'lodash';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import createFlash from '~/flash';
+import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
-import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils';
const mutationMap = {
[IssuableType.Issue]: {
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index 26a7c8e4a80..c3f31a3d220 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -1,9 +1,9 @@
<script>
-import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
+import $ from 'jquery';
import { mapActions } from 'vuex';
-import { __, sprintf } from '../../../locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import { __, sprintf } from '../../../locale';
import eventHub from '../../event_hub';
export default {
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index b96a2b93712..3468acb38e7 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,6 +1,6 @@
<script>
-import { mapGetters } from 'vuex';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index b1b04564a62..87780888c2f 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -76,8 +76,8 @@ export default {
class="d-inline-block"
>
<!-- use d-flex so that slot can be appropriately styled -->
- <span class="d-flex">
- <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
+ <span class="gl-display-flex gl-align-items-center">
+ <reviewer-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
<slot :user="user"></slot>
</span>
</gl-link>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index cd62fe5be0f..2c52d7142f7 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -46,6 +46,9 @@ export default {
assignSelf() {
this.$emit('assign-self');
},
+ requestReview(data) {
+ this.$emit('request-review', data);
+ },
},
};
</script>
@@ -56,7 +59,7 @@ export default {
<div class="value hide-collapsed">
<template v-if="hasNoUsers">
- <span class="assign-yourself no-value qa-assign-yourself">
+ <span class="assign-yourself no-value">
{{ __('None') }}
</span>
</template>
@@ -66,6 +69,7 @@ export default {
:users="sortedReviewers"
:root-path="rootPath"
:issuable-type="issuableType"
+ @request-review="requestReview"
/>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index 1a2473e5f6c..b5cf5df4957 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -1,14 +1,14 @@
<script>
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
-import { deprecatedCreateFlash as Flash } from '~/flash';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ReviewerTitle from './reviewer_title.vue';
import Reviewers from './reviewers.vue';
-import { __ } from '~/locale';
export default {
name: 'SidebarReviewers',
@@ -83,6 +83,9 @@ export default {
return new Flash(__('Error occurred when saving reviewers'));
});
},
+ requestReview(data) {
+ this.mediator.requestReview(data);
+ },
},
};
</script>
@@ -101,6 +104,7 @@ export default {
:editable="store.editable"
:issuable-type="issuableType"
class="value"
+ @request-review="requestReview"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index e82a271d007..cbd68f2513a 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,15 +1,19 @@
<script>
-// NOTE! For the first iteration, we are simply copying the implementation of Assignees
-// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
-import { __, sprintf } from '~/locale';
+import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
-const DEFAULT_RENDER_COUNT = 5;
+const LOADING_STATE = 'loading';
+const SUCCESS_STATE = 'success';
export default {
components: {
+ GlButton,
+ GlIcon,
ReviewerAvatarLink,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
users: {
type: Array,
@@ -28,76 +32,78 @@ export default {
data() {
return {
showLess: true,
+ loadingStates: {},
};
},
- computed: {
- firstUser() {
- return this.users[0];
- },
- hasOneUser() {
- return this.users.length === 1;
- },
- hiddenReviewersLabel() {
- const { numberOfHiddenReviewers } = this;
- return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers });
- },
- renderShowMoreSection() {
- return this.users.length > DEFAULT_RENDER_COUNT;
- },
- numberOfHiddenReviewers() {
- return this.users.length - DEFAULT_RENDER_COUNT;
- },
- uncollapsedUsers() {
- const uncollapsedLength = this.showLess
- ? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
- : this.users.length;
- return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users;
- },
- username() {
- return `@${this.firstUser.username}`;
+ watch: {
+ users: {
+ handler(users) {
+ this.loadingStates = users.reduce(
+ (acc, user) => ({
+ ...acc,
+ [user.id]: acc[user.id] || null,
+ }),
+ this.loadingStates,
+ );
+ },
+ immediate: true,
},
},
methods: {
toggleShowLess() {
this.showLess = !this.showLess;
},
+ reRequestReview(userId) {
+ this.loadingStates[userId] = LOADING_STATE;
+ this.$emit('request-review', { userId, callback: this.requestReviewComplete });
+ },
+ requestReviewComplete(userId, success) {
+ if (success) {
+ this.loadingStates[userId] = SUCCESS_STATE;
+
+ setTimeout(() => {
+ this.loadingStates[userId] = null;
+ }, 1500);
+ } else {
+ this.loadingStates[userId] = null;
+ }
+ },
},
+ LOADING_STATE,
+ SUCCESS_STATE,
};
</script>
<template>
- <reviewer-avatar-link
- v-if="hasOneUser"
- #default="{ user }"
- tooltip-placement="left"
- :tooltip-has-name="false"
- :user="firstUser"
- :root-path="rootPath"
- :issuable-type="issuableType"
- >
- <div class="gl-ml-3 gl-line-height-normal">
- <div class="author">{{ user.name }}</div>
- <div class="username">{{ username }}</div>
- </div>
- </reviewer-avatar-link>
- <div v-else>
- <div class="user-list">
- <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
- <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" />
- </div>
- </div>
- <div v-if="renderShowMoreSection" class="user-list-more">
- <button
- type="button"
- class="btn-link"
- data-qa-selector="more_reviewers_link"
- @click="toggleShowLess"
- >
- <template v-if="showLess">
- {{ hiddenReviewersLabel }}
- </template>
- <template v-else>{{ __('- show less') }}</template>
- </button>
+ <div>
+ <div
+ v-for="(user, index) in users"
+ :key="user.id"
+ :class="{ 'gl-mb-3': index !== users.length - 1 }"
+ data-testid="reviewer"
+ >
+ <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType">
+ <div class="gl-ml-3">@{{ user.username }}</div>
+ </reviewer-avatar-link>
+ <gl-icon
+ v-if="loadingStates[user.id] === $options.SUCCESS_STATE"
+ :size="24"
+ name="check"
+ class="float-right gl-text-green-500"
+ data-testid="re-request-success"
+ />
+ <gl-button
+ v-else-if="user.can_update_merge_request && user.reviewed"
+ v-gl-tooltip.left
+ :title="__('Re-request review')"
+ :loading="loadingStates[user.id] === $options.LOADING_STATE"
+ class="float-right gl-text-gray-500!"
+ size="small"
+ icon="redo"
+ variant="link"
+ data-testid="re-request-button"
+ @click="reRequestReview(user.id)"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index 0cf11e83349..6a6300dcde0 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -7,10 +7,10 @@ import {
GlSprintf,
GlLink,
} from '@gitlab/ui';
+import createFlash from '~/flash';
import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants';
import updateIssuableSeverity from './graphql/mutations/update_issuable_severity.mutation.graphql';
import SeverityToken from './severity.vue';
-import createFlash from '~/flash';
export default {
i18n: I18N,
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
new file mode 100644
index 00000000000..9da839cd133
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+
+export default {
+ components: { GlButton, GlLoadingIcon },
+ inject: ['canUpdate'],
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ edit: false,
+ };
+ },
+ destroyed() {
+ window.removeEventListener('click', this.collapseWhenOffClick);
+ window.removeEventListener('keyup', this.collapseOnEscape);
+ },
+ methods: {
+ collapseWhenOffClick({ target }) {
+ if (!this.$el.contains(target)) {
+ this.collapse();
+ }
+ },
+ collapseOnEscape({ key }) {
+ if (key === 'Escape') {
+ this.collapse();
+ }
+ },
+ expand() {
+ if (this.edit) {
+ return;
+ }
+
+ this.edit = true;
+ this.$emit('open');
+ window.addEventListener('click', this.collapseWhenOffClick);
+ window.addEventListener('keyup', this.collapseOnEscape);
+ },
+ collapse({ emitEvent = true } = {}) {
+ if (!this.edit) {
+ return;
+ }
+
+ this.edit = false;
+ if (emitEvent) {
+ this.$emit('close');
+ }
+ window.removeEventListener('click', this.collapseWhenOffClick);
+ window.removeEventListener('keyup', this.collapseOnEscape);
+ },
+ toggle({ emitEvent = true } = {}) {
+ if (this.edit) {
+ this.collapse({ emitEvent });
+ } else {
+ this.expand();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse">
+ <span data-testid="title">{{ title }}</span>
+ <gl-loading-icon v-if="loading" inline class="gl-ml-2" />
+ <gl-button
+ v-if="canUpdate"
+ variant="link"
+ class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle"
+ data-testid="edit-button"
+ @keyup.esc="toggle"
+ @click="toggle"
+ >
+ {{ __('Edit') }}
+ </gl-button>
+ </div>
+ <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
+ <slot name="collapsed">{{ __('None') }}</slot>
+ </div>
+ <div v-show="edit" data-testid="expanded-content">
+ <slot :edit="edit"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
index ee1c98e9d69..3ad097138a3 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
@@ -1,7 +1,7 @@
<script>
-import Store from '../../stores/sidebar_store';
import { deprecatedCreateFlash as Flash } from '../../../flash';
import { __ } from '../../../locale';
+import Store from '../../stores/sidebar_store';
import subscriptions from './subscriptions.vue';
export default {
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 6d21936791c..9b06c20a6f3 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -1,8 +1,7 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import Tracking from '~/tracking';
-import toggleButton from '~/vue_shared/components/toggle_button.vue';
import eventHub from '../../event_hub';
const ICON_ON = 'notifications';
@@ -16,7 +15,7 @@ export default {
},
components: {
GlIcon,
- toggleButton,
+ GlToggle,
},
mixins: [Tracking.mixin({ label: 'right_sidebar' })],
props: {
@@ -106,7 +105,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-display-flex gl-justify-content-space-between">
<span
ref="tooltip"
v-gl-tooltip.viewport.left
@@ -116,13 +115,13 @@ export default {
>
<gl-icon :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" />
</span>
- <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span>
- <toggle-button
+ <span class="hide-collapsed" data-testid="subscription-title"> {{ notificationText }} </span>
+ <gl-toggle
v-if="!projectEmailsDisabled"
- ref="toggleButton"
:is-loading="showLoadingState"
:value="subscribed"
- class="float-right hide-collapsed js-issuable-subscribe-button"
+ class="hide-collapsed"
+ data-testid="subscription-toggle"
@change="toggleSubscription"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index 8bc828091c0..e0f60b9af08 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
-import { sprintf, s__ } from '../../../locale';
import { joinPaths } from '~/lib/utils/url_utility';
+import { sprintf, s__ } from '../../../locale';
export default {
name: 'TimeTrackingHelpState',
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
index 26e0a0da860..c70d99ac178 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
@@ -4,11 +4,10 @@ import { intersection } from 'lodash';
import '~/smart_interval';
-import IssuableTimeTracker from './time_tracker.vue';
-
-import Store from '../../stores/sidebar_store';
-import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
+import Mediator from '../../sidebar_mediator';
+import Store from '../../stores/sidebar_store';
+import IssuableTimeTracker from './time_tracker.vue';
export default {
components: {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index 26b8e087512..4c095006dd7 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,12 +1,11 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { s__, __ } from '~/locale';
-import TimeTrackingHelpState from './help_state.vue';
+import eventHub from '../../event_hub';
import TimeTrackingCollapsedState from './collapsed_state.vue';
-import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue';
-
-import eventHub from '../../event_hub';
+import TimeTrackingHelpState from './help_state.vue';
+import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
export default {
name: 'IssuableTimeTracker',
@@ -48,11 +47,11 @@ export default {
/*
In issue list, "time-tracking-collapsed-state" is always rendered even if the sidebar isn't collapsed.
The actual hiding is controlled with css classes:
- Hide "time-tracking-collapsed-state"
+ Hide "time-tracking-collapsed-state"
if .right-sidebar .right-sidebar-collapsed .sidebar-collapsed-icon
Show "time-tracking-collapsed-state"
if .right-sidebar .right-sidebar-expanded .sidebar-collapsed-icon
-
+
In Swimlanes sidebar, we do not use collapsed state at all.
*/
showCollapsed: {
@@ -99,10 +98,12 @@ export default {
update(data) {
const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = data;
+ /* eslint-disable vue/no-mutating-props */
this.timeEstimate = timeEstimate;
this.timeSpent = timeSpent;
this.humanTimeEstimate = humanTimeEstimate;
this.humanTimeSpent = humanTimeSpent;
+ /* eslint-enable vue/no-mutating-props */
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index 1e3e870ec83..f589e7555b3 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -3,7 +3,7 @@ import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
const MARK_TEXT = __('Mark as done');
-const TODO_TEXT = __('Add a To-Do');
+const TODO_TEXT = __('Add a to do');
export default {
components: {
@@ -42,7 +42,7 @@ export default {
buttonClasses() {
return this.collapsed
? 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state'
- : 'btn btn-default btn-todo issuable-header-btn float-right';
+ : 'gl-button btn btn-default btn-todo issuable-header-btn float-right';
},
buttonLabel() {
return this.isTodo ? MARK_TEXT : TODO_TEXT;
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
new file mode 100644
index 00000000000..274aa237aea
--- /dev/null
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -0,0 +1,16 @@
+import { IssuableType } from '~/issue_show/constants';
+import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
+import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
+import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
+import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
+
+export const assigneesQueries = {
+ [IssuableType.Issue]: {
+ query: getIssueParticipants,
+ mutation: updateAssigneesMutation,
+ },
+ [IssuableType.MergeRequest]: {
+ query: getMergeRequestParticipants,
+ mutation: updateMergeRequestParticipantsMutation,
+ },
+};
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 55847fc43f0..21cd24b0842 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import { escape } from 'lodash';
-import { __ } from '~/locale';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { __ } from '~/locale';
function isValidProjectId(id) {
return id > 0;
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index 4d9e99941d1..b11c8f76a6d 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
-import timeTracker from './components/time_tracking/time_tracker.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
+import timeTracker from './components/time_tracking/time_tracker.vue';
export default class SidebarMilestone {
constructor() {
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 2760bf431ea..662edbc4f8d 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,22 +1,27 @@
import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
+import createFlash from '~/flash';
+import createDefaultClient from '~/lib/graphql';
+import {
+ isInIssuePage,
+ isInDesignPage,
+ isInIncidentPage,
+ parseBoolean,
+} from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
-import SidebarLabels from './components/labels/sidebar_labels.vue';
-import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
-import SidebarMoveIssue from './lib/sidebar_move_issue';
+import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
+import SidebarLabels from './components/labels/sidebar_labels.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue';
-import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
+import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
-import Translate from '../vue_shared/translate';
-import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
-import createDefaultClient from '~/lib/graphql';
-import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils';
-import createFlash from '~/flash';
-import { __ } from '~/locale';
+import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
+import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
+import SidebarMoveIssue from './lib/sidebar_move_issue';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -25,6 +30,28 @@ 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)
+ .map((el) => el.dataset)
+ .reduce(
+ (acc, { username, availability = '' }) => ({
+ ...acc,
+ [username]: availability,
+ }),
+ {},
+ );
+}
+
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
const apolloProvider = new VueApollo({
@@ -34,6 +61,7 @@ function mountAssigneesComponent(mediator) {
if (!el) return;
const { iid, fullPath } = getSidebarOptions();
+ const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
// eslint-disable-next-line no-new
new Vue({
el,
@@ -49,7 +77,9 @@ function mountAssigneesComponent(mediator) {
projectPath: fullPath,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
- issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request',
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request',
+ assigneeAvailabilityStatus,
},
}),
});
@@ -78,7 +108,7 @@ function mountReviewersComponent(mediator) {
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
- issuableType: isInIssuePage() ? 'issue' : 'merge_request',
+ issuableType: isInIssuePage() || isInDesignPage() ? 'issue' : 'merge_request',
},
}),
});
diff --git a/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql b/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql
new file mode 100644
index 00000000000..73765e7d77b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/reviewer_rereview.mutation.graphql
@@ -0,0 +1,5 @@
+mutation mergeRequestRequestRereview($projectPath: ID!, $iid: String!, $userId: ID!) {
+ mergeRequestReviewerRereview(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index a61af631661..f31e4a3e0dd 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -1,6 +1,8 @@
import sidebarDetailsQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
-import axios from '~/lib/utils/axios_utils';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
+import axios from '~/lib/utils/axios_utils';
+import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
export const gqClient = createGqClient(
{},
@@ -70,4 +72,15 @@ export default class SidebarService {
move_to_project_id: moveToProjectId,
});
}
+
+ requestReview(userId) {
+ return gqClient.mutate({
+ mutation: reviewerRereviewMutation,
+ variables: {
+ userId: convertToGraphQLId('User', `${userId}`), // eslint-disable-line @gitlab/require-i18n-strings
+ projectPath: this.fullPath,
+ iid: this.iid.toString(),
+ },
+ });
+ }
}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 377846db70e..063e3313a3c 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -1,5 +1,5 @@
-import Mediator from './sidebar_mediator';
import { mountSidebar, getSidebarOptions } from './mount_sidebar';
+import Mediator from './sidebar_mediator';
export default () => {
const mediator = new Mediator(getSidebarOptions());
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index d143283653b..bd382ed0fdb 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,8 +1,9 @@
import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
-import { visitUrl } from '../lib/utils/url_utility';
+import { __ } from '~/locale';
+import toast from '~/vue_shared/plugins/global_toast';
import { deprecatedCreateFlash as Flash } from '../flash';
+import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
-import { __ } from '~/locale';
export default class SidebarMediator {
constructor(options) {
@@ -51,6 +52,17 @@ export default class SidebarMediator {
return this.service.update(field, data);
}
+ requestReview({ userId, callback }) {
+ return this.service
+ .requestReview(userId)
+ .then(() => {
+ this.store.updateReviewer(userId);
+ toast(__('Requested review'));
+ callback(userId, true);
+ })
+ .catch(() => callback(userId, false));
+ }
+
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index d53393052eb..3c108b06eab 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -96,6 +96,14 @@ export default class SidebarStore {
}
}
+ updateReviewer(id) {
+ const reviewer = this.findReviewer({ id });
+
+ if (reviewer) {
+ reviewer.reviewed = false;
+ }
+ }
+
findAssignee(findAssignee) {
return this.assignees.find(({ id }) => id === findAssignee.id);
}