diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
commit | 48aff82709769b098321c738f3444b9bdaa694c6 (patch) | |
tree | e00c7c43e2d9b603a5a6af576b1685e400410dee /app/assets/javascripts/sidebar | |
parent | 879f5329ee916a948223f8f43d77fba4da6cd028 (diff) | |
download | gitlab-ce-48aff82709769b098321c738f3444b9bdaa694c6.tar.gz |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/sidebar')
21 files changed, 846 insertions, 130 deletions
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 5c67e429383..20dc7cb07e7 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,11 +1,12 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { n__ } from '~/locale'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { n__, __ } from '~/locale'; export default { name: 'AssigneeTitle', components: { GlLoadingIcon, + GlIcon, }, props: { loading: { @@ -26,12 +27,19 @@ export default { required: false, default: false, }, + changing: { + type: Boolean, + required: true, + }, }, computed: { assigneeTitle() { const assignees = this.numberOfAssignees; return n__('Assignee', `%d Assignees`, assignees); }, + titleCopy() { + return this.changing ? __('Apply') : __('Edit'); + }, }, }; </script> @@ -43,11 +51,12 @@ export default { v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" href="#" + data-test-id="edit-link" data-track-event="click_edit_button" data-track-label="right_sidebar" data-track-property="assignee" > - {{ __('Edit') }} + {{ titleCopy }} </a> <a v-if="showToggle" @@ -56,7 +65,7 @@ export default { href="#" role="button" > - <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> + <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" /> </a> </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 2f714ac3847..b9f268629fb 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -56,6 +56,9 @@ export default { // Note: Realtime is only available on issues right now, future support for MR wil be built later. return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; }, + relativeUrlRoot() { + return gon.relative_url_root ?? ''; + }, }, created() { this.removeAssignee = this.store.removeAssignee.bind(this.store); @@ -89,6 +92,8 @@ export default { .saveAssignees(this.field) .then(() => { this.loading = false; + this.store.resetChanging(); + refreshUserMergeRequestCounts(); }) .catch(() => { @@ -113,10 +118,11 @@ export default { :loading="loading || store.isFetching.assignees" :editable="store.editable" :show-toggle="!signedIn" + :changing="store.changing" /> <assignees v-if="!store.isFetching.assignees" - :root-path="store.rootPath" + :root-path="relativeUrlRoot" :users="store.assignees" :editable="store.editable" :issuable-type="issuableType" 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 86bfacbfb9e..46d51138ccf 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; @@ -8,7 +8,7 @@ import eventHub from '../../event_hub'; export default { components: { - GlLoadingIcon, + GlButton, }, props: { fullPath: { @@ -64,18 +64,18 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> + <gl-button class="gl-mr-3" @click="closeForm"> {{ __('Cancel') }} - </button> - <button - type="button" - class="btn btn-close" - data-testid="confidential-toggle" + </gl-button> + <gl-button + category="secondary" + variant="warning" :disabled="isLoading" + :loading="isLoading" + data-testid="confidential-toggle" @click.prevent="submitForm" > - <gl-loading-icon v-if="isLoading" inline /> {{ toggleButtonText }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index d7be8927c29..1af1bc18e3e 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -1,7 +1,6 @@ <script> import $ from 'jquery'; import { difference, union } from 'lodash'; -import { mapState, mapActions } from 'vuex'; import flash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -26,47 +25,49 @@ export default { 'projectIssuesPath', 'projectPath', ], - data: () => ({ - labelsSelectInProgress: false, - }), - computed: { - ...mapState(['selectedLabels']), - }, - mounted() { - this.setInitialState({ + data() { + return { + isLabelsSelectInProgress: false, selectedLabels: this.initiallySelectedLabels, - }); + }; }, methods: { - ...mapActions(['setInitialState', 'replaceSelectedLabels']), handleDropdownClose() { $(this.$el).trigger('hidden.gl.dropdown'); }, - handleUpdateSelectedLabels(labels) { + handleUpdateSelectedLabels(dropdownLabels) { const currentLabelIds = this.selectedLabels.map(label => label.id); - const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id); - const userRemovedLabelIds = labels.filter(label => !label.set).map(label => label.id); + const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id); + const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id); - const issuableLabels = difference( - union(currentLabelIds, userAddedLabelIds), - userRemovedLabelIds, - ); + const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); - this.labelsSelectInProgress = true; + this.updateSelectedLabels(labelIds); + }, + handleLabelRemove(labelId) { + const currentLabelIds = this.selectedLabels.map(label => label.id); + const labelIds = difference(currentLabelIds, [labelId]); + + this.updateSelectedLabels(labelIds); + }, + updateSelectedLabels(labelIds) { + this.isLabelsSelectInProgress = true; axios({ data: { [this.issuableType]: { - label_ids: issuableLabels, + label_ids: labelIds, }, }, method: 'put', url: this.labelsUpdatePath, }) - .then(({ data }) => this.replaceSelectedLabels(data.labels)) + .then(({ data }) => { + this.selectedLabels = data.labels; + }) .catch(() => flash(__('An error occurred while updating labels.'))) .finally(() => { - this.labelsSelectInProgress = false; + this.isLabelsSelectInProgress = false; }); }, }, @@ -76,6 +77,7 @@ export default { <template> <labels-select class="block labels js-labels-block" + :allow-label-remove="allowLabelEdit" :allow-label-create="allowLabelCreate" :allow-label-edit="allowLabelEdit" :allow-multiselect="true" @@ -86,10 +88,12 @@ export default { :labels-fetch-path="labelsFetchPath" :labels-filter-base-path="projectIssuesPath" :labels-manage-path="labelsManagePath" - :labels-select-in-progress="labelsSelectInProgress" + :labels-select-in-progress="isLabelsSelectInProgress" :selected-labels="selectedLabels" :variant="$options.sidebar" + data-qa-selector="labels_block" @onDropdownClose="handleDropdownClose" + @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > {{ __('None') }} 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 ea7230ae488..26a7c8e4a80 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, sprintf } from '../../../locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; @@ -8,7 +8,7 @@ import eventHub from '../../event_hub'; export default { components: { - GlLoadingIcon, + GlButton, }, inject: ['fullPath'], props: { @@ -65,19 +65,19 @@ export default { <template> <div class="sidebar-item-warning-message-actions"> - <button type="button" class="btn btn-default gl-mr-3" @click="closeForm"> + <gl-button class="gl-mr-3" @click="closeForm"> {{ __('Cancel') }} - </button> + </gl-button> - <button - type="button" + <gl-button data-testid="lock-toggle" - class="btn btn-close" + category="secondary" + variant="warning" :disabled="isLoading" + :loading="isLoading" @click.prevent="submitForm" > - <gl-loading-icon v-if="isLoading" inline /> {{ buttonText }} - </button> + </gl-button> </div> </template> 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 53ee7f46ad9..b96a2b93712 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -1,8 +1,7 @@ <script> import { mapGetters } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; @@ -26,7 +25,7 @@ export default { }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { @@ -79,13 +78,9 @@ export default { <template> <div class="block issuable-sidebar-item lock"> <div - v-tooltip - :title="tooltipLabel" + v-gl-tooltip.left.viewport="{ title: tooltipLabel }" class="sidebar-collapsed-icon" data-testid="sidebar-collapse-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" @click="toggleForm" > <gl-icon :name="lockStatus.icon" class="sidebar-item-icon is-active" /> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index e7dbc47aea1..c3a08f760a0 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,12 +1,11 @@ <script> -import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { userAvatarImage, @@ -87,12 +86,9 @@ export default { <div> <div v-if="showParticipantLabel" - v-tooltip + v-gl-tooltip.left.viewport :title="participantLabel" class="sidebar-collapsed-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" @click="onClickCollapsedIcon" > <gl-icon name="users" /> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue new file mode 100644 index 00000000000..6de926e0ff9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue @@ -0,0 +1,24 @@ +<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 ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + }, + props: { + user: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <button type="button" class="btn-link"> + <reviewer-avatar :user="user" :img-size="24" /> + <span class="author"> {{ user.name }} </span> + </button> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue new file mode 100644 index 00000000000..45707c18f7b --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -0,0 +1,107 @@ +<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 { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import CollapsedReviewer from './collapsed_reviewer.vue'; + +const DEFAULT_MAX_COUNTER = 99; +const DEFAULT_RENDER_COUNT = 5; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CollapsedReviewer, + GlIcon, + }, + props: { + users: { + type: Array, + required: true, + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + hasMoreThanOneReviewer() { + return this.users.length > 1; + }, + hasMoreThanTwoReviewers() { + return this.users.length > 2; + }, + allReviewersCanMerge() { + return this.users.every(user => user.can_merge); + }, + sidebarAvatarCounter() { + if (this.users.length > DEFAULT_MAX_COUNTER) { + return `${DEFAULT_MAX_COUNTER}+`; + } + + return `+${this.users.length - 1}`; + }, + collapsedUsers() { + const collapsedLength = this.hasMoreThanTwoReviewers ? 1 : this.users.length; + + return this.users.slice(0, collapsedLength); + }, + tooltipTitleMergeStatus() { + const mergeLength = this.users.filter(u => u.can_merge).length; + + if (mergeLength === this.users.length) { + return ''; + } else if (mergeLength > 0) { + return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { + mergeLength, + usersLength: this.users.length, + }); + } + + return this.users.length === 1 ? __('cannot merge') : __('no one can merge'); + }, + 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 __('Reviewer(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; + }, + + tooltipOptions() { + return { container: 'body', placement: 'left', boundary: 'viewport' }; + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="tooltipOptions" + :class="{ 'multiple-users': hasMoreThanOneReviewer }" + :title="tooltipTitle" + class="sidebar-collapsed-icon sidebar-collapsed-user" + > + <gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" /> + <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" /> + <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button"> + <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <i + v-if="!allReviewersCanMerge" + aria-hidden="true" + class="fa fa-exclamation-triangle merge-icon" + ></i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue new file mode 100644 index 00000000000..9fa3fa38eac --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue @@ -0,0 +1,43 @@ +<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'; + +export default { + props: { + user: { + type: Object, + required: true, + }, + imgSize: { + type: Number, + required: true, + }, + }, + computed: { + reviewerAlt() { + return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); + }, + avatarUrl() { + return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + }, + hasMergeIcon() { + return !this.user.can_merge; + }, + }, +}; +</script> + +<template> + <span class="position-relative"> + <img + :alt="reviewerAlt" + :src="avatarUrl" + :width="imgSize" + :class="`s${imgSize}`" + class="avatar avatar-inline m-0" + data-qa-selector="avatar_image" + /> + <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + </span> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue new file mode 100644 index 00000000000..b1b04564a62 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue @@ -0,0 +1,84 @@ +<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 { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import ReviewerAvatar from './reviewer_avatar.vue'; + +export default { + components: { + ReviewerAvatar, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + user: { + type: Object, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + tooltipPlacement: { + type: String, + default: 'bottom', + required: false, + }, + tooltipHasName: { + type: Boolean, + default: true, + required: false, + }, + issuableType: { + type: String, + default: 'issue', + required: false, + }, + }, + computed: { + cannotMerge() { + 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 ''; + }, + tooltipOption() { + return { + container: 'body', + placement: this.tooltipPlacement, + boundary: 'viewport', + }; + }, + reviewerUrl() { + return this.user.web_url; + }, + }, +}; +</script> + +<template> + <!-- must be `d-inline-block` or parent flex-basis causes width issues --> + <gl-link + v-gl-tooltip="tooltipOption" + :href="reviewerUrl" + :title="tooltipTitle" + 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" /> + <slot :user="user"></slot> + </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue new file mode 100644 index 00000000000..4f4f7002dc9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue @@ -0,0 +1,65 @@ +<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 { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + name: 'ReviewerTitle', + components: { + GlLoadingIcon, + GlIcon, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfReviewers: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + showToggle: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + reviewerTitle() { + const reviewers = this.numberOfReviewers; + return n__('Reviewer', `%d Reviewers`, reviewers); + }, + }, +}; +</script> +<template> + <div class="title hide-collapsed"> + {{ reviewerTitle }} + <gl-loading-icon v-if="loading" inline class="align-bottom" /> + <a + v-if="editable" + class="js-sidebar-dropdown-toggle edit-link float-right" + href="#" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="reviewer" + > + {{ __('Edit') }} + </a> + <a + v-if="showToggle" + :aria-label="__('Toggle sidebar')" + class="gutter-toggle float-right js-sidebar-toggle" + href="#" + role="button" + > + <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue new file mode 100644 index 00000000000..6a3d88f6385 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -0,0 +1,72 @@ +<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 CollapsedReviewerList from './collapsed_reviewer_list.vue'; +import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue'; + +export default { + // name: 'Reviewers' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Reviewers', + components: { + CollapsedReviewerList, + UncollapsedReviewerList, + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + hasNoUsers() { + return !this.users.length; + }, + sortedReviewers() { + const canMergeUsers = this.users.filter(user => user.can_merge); + const canNotMergeUsers = this.users.filter(user => !user.can_merge); + + return [...canMergeUsers, ...canNotMergeUsers]; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + }, +}; +</script> + +<template> + <div> + <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" /> + + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value qa-assign-yourself"> + {{ __('None') }} + </span> + </template> + + <uncollapsed-reviewer-list + v-else + :users="sortedReviewers" + :root-path="rootPath" + :issuable-type="issuableType" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue new file mode 100644 index 00000000000..aee94a55134 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -0,0 +1,112 @@ +<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 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', + components: { + ReviewerTitle, + Reviewers, + }, + mixins: [glFeatureFlagsMixin()], + props: { + mediator: { + type: Object, + required: true, + }, + field: { + type: String, + required: true, + }, + signedIn: { + type: Boolean, + required: false, + default: false, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + issuableIid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + store: new Store(), + loading: false, + }; + }, + computed: { + relativeUrlRoot() { + return gon.relative_url_root ?? ''; + }, + }, + created() { + this.removeReviewer = this.store.removeReviewer.bind(this.store); + this.addReviewer = this.store.addReviewer.bind(this.store); + this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store); + + // Get events from deprecatedJQueryDropdown + eventHub.$on('sidebar.removeReviewer', this.removeReviewer); + eventHub.$on('sidebar.addReviewer', this.addReviewer); + eventHub.$on('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$on('sidebar.saveReviewers', this.saveReviewers); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeReviewer', this.removeReviewer); + eventHub.$off('sidebar.addReviewer', this.addReviewer); + eventHub.$off('sidebar.removeAllReviewers', this.removeAllReviewers); + eventHub.$off('sidebar.saveReviewers', this.saveReviewers); + }, + methods: { + saveReviewers() { + this.loading = true; + + this.mediator + .saveReviewers(this.field) + .then(() => { + this.loading = false; + // Uncomment once this issue has been addressed > https://gitlab.com/gitlab-org/gitlab/-/issues/237922 + // refreshUserMergeRequestCounts(); + }) + .catch(() => { + this.loading = false; + return new Flash(__('Error occurred when saving reviewers')); + }); + }, + }, +}; +</script> + +<template> + <div> + <reviewer-title + :number-of-reviewers="store.reviewers.length" + :loading="loading || store.isFetching.reviewers" + :editable="store.editable" + :show-toggle="!signedIn" + /> + <reviewers + v-if="!store.isFetching.reviewers" + :root-path="relativeUrlRoot" + :users="store.reviewers" + :editable="store.editable" + :issuable-type="issuableType" + class="value" + /> + </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 new file mode 100644 index 00000000000..e82a271d007 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -0,0 +1,103 @@ +<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 ReviewerAvatarLink from './reviewer_avatar_link.vue'; + +const DEFAULT_RENDER_COUNT = 5; + +export default { + components: { + ReviewerAvatarLink, + }, + props: { + users: { + type: Array, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + data() { + return { + showLess: true, + }; + }, + 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}`; + }, + }, + methods: { + toggleShowLess() { + this.showLess = !this.showLess; + }, + }, +}; +</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> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index bc2319c0f36..9d72bf4394e 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -1,7 +1,6 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'TimeTrackingCollapsedState', @@ -9,7 +8,7 @@ export default { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { showComparisonState: { @@ -97,14 +96,7 @@ export default { </script> <template> - <div - v-tooltip - :title="tooltipText" - class="sidebar-collapsed-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" - > + <div v-gl-tooltip:body.viewport.left :title="tooltipText" class="sidebar-collapsed-icon"> <gl-icon name="timer" /> <div class="time-tracking-collapsed-summary"> <div :class="divClass"> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index 4cb8d9ebd62..d4cc98e3743 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -1,7 +1,6 @@ <script> -import { GlProgressBar } from '@gitlab/ui'; +import { GlProgressBar, GlTooltipDirective } from '@gitlab/ui'; import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; -import tooltip from '../../../vue_shared/directives/tooltip'; import { s__, sprintf } from '~/locale'; export default { @@ -10,7 +9,7 @@ export default { GlProgressBar, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { timeSpent: { @@ -73,7 +72,7 @@ export default { <template> <div class="time-tracking-comparison-pane"> <div - v-tooltip + v-gl-tooltip :title="timeRemainingTooltip" :class="timeRemainingStatusClass" class="compare-meter" 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 05ad7b4ea3e..406677941b7 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 @@ -26,11 +26,14 @@ export default { methods: { listenForQuickActions() { $(document).on('ajax:success', '.gfm-form', this.quickActionListened); + eventHub.$on('timeTrackingUpdated', data => { - this.quickActionListened(null, data); + this.quickActionListened({ detail: [data] }); }); }, - quickActionListened(e, data) { + quickActionListened(e) { + const data = e.detail[0]; + const subscribedCommands = ['spend_time', 'time_estimate']; let changedCommands; if (data !== undefined) { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index be559b16420..00b4e2de5e5 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,10 +1,10 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import Vuex from 'vuex'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; 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 IssuableLockForm from './components/lock/issuable_lock_form.vue'; @@ -13,17 +13,15 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio import SidebarSeverity from './components/severity/sidebar_severity.vue'; import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; -import { store } from '~/notes/stores'; -import { isInIssuePage, parseBoolean } from '~/lib/utils/common_utils'; -import mergeRequestStore from '~/mr_notes/stores'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; Vue.use(Translate); Vue.use(VueApollo); -Vue.use(Vuex); -function getSidebarOptions() { - return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); +function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) { + return JSON.parse(sidebarOptEl.innerHTML); } function mountAssigneesComponent(mediator) { @@ -50,6 +48,36 @@ function mountAssigneesComponent(mediator) { projectPath: fullPath, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), + issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request', + }, + }), + }); +} + +function mountReviewersComponent(mediator) { + const el = document.getElementById('js-vue-sidebar-reviewers'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + if (!el) return; + + const { iid, fullPath } = getSidebarOptions(); + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarReviewers, + }, + render: createElement => + createElement('sidebar-reviewers', { + props: { + mediator, + issuableIid: String(iid), + projectPath: fullPath, + field: el.dataset.field, + signedIn: el.hasAttribute('data-signed-in'), issuableType: isInIssuePage() ? 'issue' : 'merge_request', }, }), @@ -63,8 +91,6 @@ export function mountSidebarLabels() { return false; } - const labelsStore = new Vuex.Store(labelsSelectModule()); - return new Vue({ el, provide: { @@ -74,7 +100,6 @@ export function mountSidebarLabels() { allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), }, - store: labelsStore, render: createElement => createElement(SidebarLabels), }); } @@ -89,47 +114,74 @@ function mountConfidentialComponent(mediator) { const dataNode = document.getElementById('js-confidential-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - // eslint-disable-next-line no-new - new Vue({ - el, - store, - components: { - ConfidentialIssueSidebar, - }, - render: createElement => - createElement('confidential-issue-sidebar', { - props: { - iid: String(iid), - fullPath, - isEditable: initialData.is_editable, - service: mediator.service, - }, - }), - }); + import(/* webpackChunkName: 'notesStore' */ '~/notes/stores') + .then( + ({ store }) => + new Vue({ + el, + store, + components: { + ConfidentialIssueSidebar, + }, + render: createElement => + createElement('confidential-issue-sidebar', { + props: { + iid: String(iid), + fullPath, + isEditable: initialData.is_editable, + service: mediator.service, + }, + }), + }), + ) + .catch(() => { + createFlash({ message: __('Failed to load sidebar confidential toggle') }); + }); } function mountLockComponent() { const el = document.getElementById('js-lock-entry-point'); + + if (!el) { + return; + } + const { fullPath } = getSidebarOptions(); const dataNode = document.getElementById('js-lock-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - return el - ? new Vue({ - el, - store: isInIssuePage() ? store : mergeRequestStore, - provide: { - fullPath, - }, - render: createElement => - createElement(IssuableLockForm, { - props: { - isEditable: initialData.is_editable, - }, - }), - }) - : undefined; + let importStore; + if (isInIssuePage() || isInIncidentPage()) { + importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then( + ({ store }) => store, + ); + } else { + importStore = import(/* webpackChunkName: 'mrNotesStore' */ '~/mr_notes/stores').then( + store => store.default, + ); + } + + importStore + .then( + store => + new Vue({ + el, + store, + provide: { + fullPath, + }, + render: createElement => + createElement(IssuableLockForm, { + props: { + isEditable: initialData.is_editable, + }, + }), + }), + ) + .catch(() => { + createFlash({ message: __('Failed to load sidebar lock status') }); + }); } function mountParticipantsComponent(mediator) { @@ -218,8 +270,9 @@ function mountSeverityComponent() { export function mountSidebar(mediator) { mountAssigneesComponent(mediator); + mountReviewersComponent(mediator); mountConfidentialComponent(mediator); - mountLockComponent(mediator); + mountLockComponent(); mountParticipantsComponent(mediator); mountSubscriptionsComponent(mediator); diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 8f1f76a2e02..2146fb83b13 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -40,6 +40,17 @@ export default class SidebarMediator { return this.service.update(field, data); } + saveReviewers(field) { + const selected = this.store.reviewers.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + const reviewers = selected.length === 0 ? [0] : selected; + const data = { reviewer_ids: reviewers }; + + return this.service.update(field, data); + } + setMoveToProjectId(projectId) { this.store.setMoveToProjectId(projectId); } @@ -55,6 +66,7 @@ export default class SidebarMediator { processFetchedData(data) { this.store.setAssigneeData(data); + this.store.setReviewerData(data); this.store.setTimeTrackingData(data); this.store.setParticipantsData(data); this.store.setSubscriptionsData(data); diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 095f93b72a9..d53393052eb 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -18,8 +18,10 @@ export default class SidebarStore { this.humanTimeSpent = ''; this.timeTrackingLimitToHours = timeTrackingLimitToHours; this.assignees = []; + this.reviewers = []; this.isFetching = { assignees: true, + reviewers: true, participants: true, subscriptions: true, }; @@ -31,17 +33,29 @@ export default class SidebarStore { this.projectEmailsDisabled = false; this.subscribeDisabledDescription = ''; this.subscribed = null; + this.changing = false; SidebarStore.singleton = this; } - setAssigneeData(data) { + setAssigneeData({ assignees }) { this.isFetching.assignees = false; - if (data.assignees) { - this.assignees = data.assignees; + if (assignees) { + this.assignees = assignees; } } + setReviewerData({ reviewers }) { + this.isFetching.reviewers = false; + if (reviewers) { + this.reviewers = reviewers; + } + } + + resetChanging() { + this.changing = false; + } + setTimeTrackingData(data) { this.timeEstimate = data.time_estimate; this.totalTimeSpent = data.total_time_spent; @@ -71,24 +85,47 @@ export default class SidebarStore { addAssignee(assignee) { if (!this.findAssignee(assignee)) { + this.changing = true; this.assignees.push(assignee); } } + addReviewer(reviewer) { + if (!this.findReviewer(reviewer)) { + this.reviewers.push(reviewer); + } + } + findAssignee(findAssignee) { - return this.assignees.find(assignee => assignee.id === findAssignee.id); + return this.assignees.find(({ id }) => id === findAssignee.id); } - removeAssignee(removeAssignee) { - if (removeAssignee) { - this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + findReviewer(findReviewer) { + return this.reviewers.find(({ id }) => id === findReviewer.id); + } + + removeAssignee(assignee) { + if (assignee) { + this.changing = true; + this.assignees = this.assignees.filter(({ id }) => id !== assignee.id); + } + } + + removeReviewer(reviewer) { + if (reviewer) { + this.reviewers = this.reviewers.filter(({ id }) => id !== reviewer.id); } } removeAllAssignees() { + this.changing = true; this.assignees = []; } + removeAllReviewers() { + this.reviewers = []; + } + setAssigneesFromRealtime(data) { this.assignees = data; } |