diff options
Diffstat (limited to 'app/assets/javascripts/sidebar')
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); } |