diff options
Diffstat (limited to 'app/assets/javascripts/sidebar/components')
15 files changed, 581 insertions, 231 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 d0a65b48522..98fc0b0a783 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -103,10 +103,10 @@ export default { v-gl-tooltip="tooltipOption" :href="assigneeUrl" :title="tooltipTitle" - class="d-inline-block" + class="gl-display-inline-block" > <!-- use d-flex so that slot can be appropriately styled --> - <span class="d-flex"> + <span class="gl-display-flex"> <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> <slot></slot> </span> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index ca86d6c6c3e..f98798582c1 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 { assigneesQueries } from '~/sidebar/constants'; export default { subscription: null, @@ -9,7 +9,8 @@ export default { props: { mediator: { type: Object, - required: true, + required: false, + default: null, }, issuableIid: { type: String, @@ -19,10 +20,16 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, }, apollo: { - project: { - query, + workspace: { + query() { + return assigneesQueries[this.issuableType].query; + }, variables() { return { iid: this.issuableIid, @@ -30,7 +37,9 @@ export default { }; }, result(data) { - this.handleFetchResult(data); + if (this.mediator) { + this.handleFetchResult(data); + } }, }, }, @@ -43,7 +52,7 @@ export default { methods: { received(data) { if (data.event === 'updated') { - this.$apollo.queries.project.refetch(); + this.$apollo.queries.workspace.refetch(); } }, initActionCablePolling() { @@ -57,7 +66,7 @@ export default { ); }, handleFetchResult({ data }) { - const { nodes } = data.project.issue.assignees; + const { nodes } = data.workspace.issuable.assignees; const assignees = nodes.map((n) => ({ ...n, @@ -69,7 +78,7 @@ export default { }, }, render() { - return this.$slots.default; + return null; }, }; </script> diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index b53b7039018..e93aced12f3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -18,6 +18,11 @@ export default { required: false, default: 'issue', }, + signedIn: { + type: Boolean, + required: false, + default: false, + }, }, computed: { assigneesText() { @@ -34,20 +39,28 @@ export default { <div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div v-if="emptyUsers" - class="gl-display-flex gl-align-items-center gl-text-gray-500" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed" data-testid="none" > - <span> {{ __('None') }} -</span> - <gl-button - data-testid="assign-yourself" - category="tertiary" - variant="link" - class="gl-ml-2" - @click="$emit('assign-self')" - > - <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> - </gl-button> + <span> {{ __('None') }}</span> + <template v-if="signedIn"> + <span class="gl-ml-2">-</span> + <gl-button + data-testid="assign-yourself" + category="tertiary" + variant="link" + class="gl-ml-2" + @click="$emit('assign-self')" + > + <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> + </gl-button> + </template> </div> - <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" /> + <uncollapsed-assignee-list + v-else + :users="users" + :issuable-type="issuableType" + class="gl-mt-2 hide-collapsed" + /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 6595debf9a5..e15ea595190 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -123,6 +123,7 @@ export default { v-if="shouldEnableRealtime" :issuable-iid="issuableIid" :project-path="projectPath" + :issuable-type="issuableType" :mediator="mediator" /> <assignee-title diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index cc2201ad359..78cac989850 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,26 +1,28 @@ <script> -import { - GlDropdownItem, - GlDropdownDivider, - GlAvatarLabeled, - GlAvatarLink, - GlSearchBoxByType, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlDropdownItem, GlDropdownDivider, 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 SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SidebarInviteMembers from './sidebar_invite_members.vue'; +import SidebarParticipant from './sidebar_participant.vue'; export const assigneesWidget = Vue.observable({ updateAssignees: null, }); + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + export default { i18n: { unassigned: __('Unassigned'), @@ -28,17 +30,26 @@ export default { assignees: __('Assignees'), assignTo: __('Assign to'), }, - assigneesQueries, components: { SidebarEditableItem, IssuableAssignees, MultiSelectDropdown, GlDropdownItem, GlDropdownDivider, - GlAvatarLabeled, - GlAvatarLink, GlSearchBoxByType, GlLoadingIcon, + SidebarInviteMembers, + SidebarParticipant, + SidebarAssigneesRealtime, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + directlyInviteMembers: { + default: false, + }, + indirectlyInviteMembers: { + default: false, + }, }, props: { iid: { @@ -76,12 +87,13 @@ export default { selected: [], isSettingAssignees: false, isSearching: false, + isDirty: false, }; }, apollo: { issuable: { query() { - return this.$options.assigneesQueries[this.issuableType].query; + return assigneesQueries[this.issuableType].query; }, variables() { return this.queryVariables; @@ -109,15 +121,20 @@ export default { }, update(data) { const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || []; - const mergedSearchResults = this.participants.reduce((acc, current) => { - if ( - !acc.some((user) => current.username === user.username) && - (current.name.includes(this.search) || current.username.includes(this.search)) - ) { + const filteredParticipants = this.participants.filter( + (user) => + user.name.toLowerCase().includes(this.search.toLowerCase()) || + user.username.toLowerCase().includes(this.search.toLowerCase()), + ); + const mergedSearchResults = searchResults.reduce((acc, current) => { + // Some users are duplicated in the query result: + // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 + if (!acc.some((user) => current.username === user.username)) { acc.push(current); } return acc; - }, searchResults); + }, filteredParticipants); + return mergedSearchResults; }, debounce: ASSIGNEES_DEBOUNCE_DELAY, @@ -134,6 +151,10 @@ export default { }, }, computed: { + shouldEnableRealtime() { + // Note: Realtime is only available on issues right now, future support for MR wil be built later. + return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue; + }, queryVariables() { return { iid: this.iid, @@ -155,6 +176,9 @@ export default { }, assigneeText() { const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected; + if (!items) { + return __('Assignee'); + } return n__('Assignee', '%d Assignees', items.length); }, selectedFiltered() { @@ -197,8 +221,15 @@ export default { noUsersFound() { return !this.isSearchEmpty && this.searchUsers.length === 0; }, + signedIn() { + return this.currentUser.username !== undefined; + }, showCurrentUser() { - return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching); + return ( + this.signedIn && + !this.isCurrentUserInParticipants && + (this.isSearchEmpty || this.isSearching) + ); }, }, watch: { @@ -221,7 +252,7 @@ export default { this.isSettingAssignees = true; return this.$apollo .mutate({ - mutation: this.$options.assigneesQueries[this.issuableType].mutation, + mutation: assigneesQueries[this.issuableType].mutation, variables: { ...this.queryVariables, assigneeUsernames, @@ -239,20 +270,22 @@ export default { }); }, selectAssignee(name) { - if (name === undefined) { - this.clearSelected(); - return; - } + this.isDirty = true; if (!this.multipleAssignees) { - this.selected = [name]; + this.selected = name ? [name] : []; this.collapseWidget(); - } else { - this.selected = this.selected.concat(name); + return; + } + if (name === undefined) { + this.clearSelected(); + return; } + this.selected = this.selected.concat(name); }, unselect(name) { this.selected = this.selected.filter((user) => user.username !== name); + this.isDirty = true; if (!this.multipleAssignees) { this.collapseWidget(); @@ -265,7 +298,9 @@ export default { this.selected = []; }, saveAssignees() { + this.isDirty = false; this.updateAssignees(this.selectedUserNames); + this.$el.dispatchEvent(hideDropdownEvent); }, isChecked(id) { return this.selectedUserNames.includes(id); @@ -291,6 +326,9 @@ export default { collapseWidget() { this.$refs.toggle.collapse(); }, + expandWidget() { + this.$refs.toggle.expand(); + }, showDivider(list) { return list.length > 0 && this.isSearchEmpty; }, @@ -299,121 +337,113 @@ export default { </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" - class="gl-mt-2" - @assign-self="assignSelf" - /> - </template> + <div data-testid="assignees-widget"> + <sidebar-assignees-realtime + v-if="shouldEnableRealtime" + :project-path="fullPath" + :issuable-iid="iid" + :issuable-type="issuableType" + /> + <sidebar-editable-item + ref="toggle" + :loading="isSettingAssignees" + :initial-loading="isAssigneesLoading" + :title="assigneeText" + :is-dirty="isDirty" + @open="focusSearch" + @close="saveAssignees" + > + <template #collapsed> + <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot> + <issuable-assignees + :users="assignees" + :issuable-type="issuableType" + :signed-in="signedIn" + @assign-self="assignSelf" + @expand-widget="expandWidget" + /> + </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"> + <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" + class="js-dropdown-input-field" + /> + </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'" + class="gl-font-weight-bold" + >{{ $options.i18n.unassigned }}</span + ></gl-dropdown-item + > + </template> + <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> <gl-dropdown-item - :is-checked="selectedIsEmpty" + v-for="item in selectedFiltered" + :key="item.id" + :is-checked="isChecked(item.username)" :is-check-centered="true" - data-testid="unassign" - @click="selectAssignee()" + data-testid="selected-participant" + @click.stop="unselect(item.username)" > - <span - :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" - class="gl-font-weight-bold" - >{{ $options.i18n.unassigned }}</span - ></gl-dropdown-item + <sidebar-participant :user="item" /> + </gl-dropdown-item> + <template v-if="showCurrentUser"> + <gl-dropdown-divider /> + <gl-dropdown-item + data-testid="current-user" + @click.stop="selectAssignee(currentUser)" + > + <sidebar-participant :user="currentUser" class="gl-pl-6!" /> + </gl-dropdown-item> + </template> + <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> + <gl-dropdown-item + v-for="unselectedUser in unselectedFiltered" + :key="unselectedUser.id" + data-testid="unselected-participant" + @click="selectAssignee(unselectedUser)" > - </template> - <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> - <gl-dropdown-item - v-for="item in selectedFiltered" - :key="item.id" - :is-checked="isChecked(item.username)" - :is-check-centered="true" - data-testid="selected-participant" - @click.stop="unselect(item.username)" - > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="item.name" - :sub-label="item.username" - :src="item.avatarUrl || item.avatar || item.avatar_url" - class="gl-align-items-center" - /> - </gl-avatar-link> - </gl-dropdown-item> - <template v-if="showCurrentUser"> - <gl-dropdown-divider /> + <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> + </gl-dropdown-item> <gl-dropdown-item - data-testid="current-user" - @click.stop="selectAssignee(currentUser)" + v-if="noUsersFound && !isSearching" + data-testid="empty-results" + class="gl-pl-6!" > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="currentUser.name" - :sub-label="currentUser.username" - :src="currentUser.avatarUrl" - class="gl-align-items-center gl-pl-6!" - /> - </gl-avatar-link> + {{ __('No matching results') }} </gl-dropdown-item> </template> - <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> - <gl-dropdown-item - v-for="unselectedUser in unselectedFiltered" - :key="unselectedUser.id" - data-testid="unselected-participant" - @click="selectAssignee(unselectedUser)" - > - <gl-avatar-link class="gl-pl-6!"> - <gl-avatar-labeled - :size="32" - :label="unselectedUser.name" - :sub-label="unselectedUser.username" - :src="unselectedUser.avatarUrl || unselectedUser.avatar" - class="gl-align-items-center" - /> - </gl-avatar-link> - </gl-dropdown-item> - <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results"> - {{ __('No matching results') }} + </template> + <template #footer> + <gl-dropdown-item> + <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" /> </gl-dropdown-item> </template> - </template> - </multi-select-dropdown> - </template> - </sidebar-editable-item> + </multi-select-dropdown> + </template> + </sidebar-editable-item> + </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue new file mode 100644 index 00000000000..9952c6db582 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -0,0 +1,51 @@ +<script> +import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; +import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { __ } from '~/locale'; + +export default { + displayText: __('Invite members'), + dataTrackLabel: 'edit_assignee', + components: { + InviteMemberTrigger, + InviteMemberModal, + InviteMembersTrigger, + }, + inject: { + projectMembersPath: { + default: '', + }, + directlyInviteMembers: { + default: false, + }, + }, + computed: { + trackEvent() { + return this.directlyInviteMembers ? 'click_invite_members' : 'click_invite_members_version_b'; + }, + }, +}; +</script> + +<template> + <div> + <invite-members-trigger + v-if="directlyInviteMembers" + trigger-element="anchor" + :display-text="$options.displayText" + :event="trackEvent" + :label="$options.dataTrackLabel" + classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> + <template v-else> + <invite-member-trigger + :display-text="$options.displayText" + :event="trackEvent" + :label="$options.dataTrackLabel" + class="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> + <invite-member-modal :members-path="projectMembersPath" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue new file mode 100644 index 00000000000..e2a38a100b9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -0,0 +1,39 @@ +<script> +import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + components: { + GlAvatarLabeled, + GlAvatarLink, + }, + props: { + user: { + type: Object, + required: true, + }, + }, + computed: { + userLabel() { + if (!this.user.status) { + return this.user.name; + } + return sprintf(s__('UserAvailability|%{author} (Busy)'), { + author: this.user.name, + }); + }, + }, +}; +</script> + +<template> + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="userLabel" + :sub-label="user.username" + :src="user.avatarUrl || user.avatar || user.avatar_url" + class="gl-align-items-center" + /> + </gl-avatar-link> +</template> 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 d0da4a9c75a..b7080bb05b8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,4 +1,5 @@ <script> +import { IssuableType } from '~/issue_show/constants'; import { __, sprintf } from '~/locale'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -58,7 +59,10 @@ export default { this.showLess = !this.showLess; }, userAvailability(u) { - return u?.availability || ''; + if (this.issuableType === IssuableType.MergeRequest) { + return u?.availability || ''; + } + return u?.status?.availability || ''; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index a21ac73f131..1fb4bd26533 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -18,8 +18,15 @@ export default { GlSprintf, GlButton, }, - inject: ['fullPath', 'iid'], props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, confidential: { required: true, type: Boolean, @@ -121,7 +128,7 @@ export default { </gl-button> <gl-button category="secondary" - variant="warning" + variant="confirm" :disabled="loading" :loading="loading" data-testid="confidential-toggle" diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index ec5f07f9785..372368707af 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -27,8 +27,20 @@ export default { SidebarConfidentialityContent, SidebarConfidentialityForm, }, - inject: ['fullPath', 'iid'], + inject: { + isClassicSidebar: { + default: false, + }, + }, props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, issuableType: { required: true, type: String, @@ -126,6 +138,7 @@ export default { v-if="!isLoading" :confidential="confidential" :issuable-type="issuableType" + :class="{ 'gl-mt-3': !isClassicSidebar }" @expandSidebar="expandSidebar" /> </div> @@ -133,6 +146,8 @@ export default { <template #default> <sidebar-confidentiality-content :confidential="confidential" :issuable-type="issuableType" /> <sidebar-confidentiality-form + :iid="iid" + :full-path="fullPath" :confidential="confidential" :issuable-type="issuableType" @closeForm="closeForm" diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue index 8c8241cf6a4..0d8cb8cb2b6 100644 --- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue +++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue @@ -1,43 +1,24 @@ <script> -import { s__, __, sprintf } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '../../vue_shared/components/sidebar/copyable_field.vue'; export default { - i18n: { - copyEmail: __('Copy email address'), - }, components: { - ClipboardButton, + CopyableField, }, props: { - copyText: { + issueEmailAddress: { type: String, required: true, }, }, - computed: { - emailText() { - return sprintf(s__('RightSidebar|Issue email: %{copyText}'), { copyText: this.copyText }); - }, - }, }; </script> <template> - <div + <copyable-field data-qa-selector="copy-forward-email" - class="copy-email-address gl-display-flex gl-align-items-center gl-justify-content-space-between" - > - <span - class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap hide-collapsed gl-w-85p" - >{{ emailText }}</span - > - <clipboard-button - class="copy-email-button gl-bg-none!" - category="tertiary" - :title="$options.i18n.copyEmail" - :text="copyText" - tooltip-placement="left" - /> - </div> + :name="s__('RightSidebar|Issue email')" + :clipboard-tooltip-text="s__('RightSidebar|Copy email address')" + :value="issueEmailAddress" + /> </template> diff --git a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue new file mode 100644 index 00000000000..141c2b3aae9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue @@ -0,0 +1,203 @@ +<script> +import { GlButton, GlIcon, GlDatepicker, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { dueDateQueries } from '~/sidebar/constants'; + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + property: 'dueDate', + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + GlIcon, + GlDatepicker, + SidebarEditableItem, + }, + inject: ['fullPath', 'iid', 'canUpdate'], + props: { + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + dueDate: null, + loading: false, + }; + }, + apollo: { + dueDate: { + query() { + return dueDateQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable?.dueDate || null; + }, + result({ data }) { + this.$emit('dueDateUpdated', data.workspace?.issuable?.dueDate); + }, + error() { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { + issuableType: this.issuableType, + }), + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.dueDate.loading || this.loading; + }, + hasDueDate() { + return this.dueDate !== null; + }, + parsedDueDate() { + if (!this.hasDueDate) { + return null; + } + + return parsePikadayDate(this.dueDate); + }, + formattedDueDate() { + if (!this.hasDueDate) { + return this.$options.i18n.noDueDate; + } + + return dateInWords(this.parsedDueDate, true); + }, + workspacePath() { + return this.issuableType === IssuableType.Issue + ? { + projectPath: this.fullPath, + } + : { + groupPath: this.fullPath, + }; + }, + }, + methods: { + closeForm() { + this.$refs.editable.collapse(); + this.$el.dispatchEvent(hideDropdownEvent); + this.$emit('closeForm'); + }, + openDatePicker() { + this.$refs.datePicker.calendar.show(); + }, + setDueDate(date) { + this.loading = true; + this.$refs.editable.collapse(); + this.$apollo + .mutate({ + mutation: dueDateQueries[this.issuableType].mutation, + variables: { + input: { + ...this.workspacePath, + iid: this.iid, + dueDate: date ? formatDate(date, 'yyyy-mm-dd') : null, + }, + }, + }) + .then( + ({ + data: { + issuableSetDueDate: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } else { + this.$emit('closeForm'); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { + issuableType: this.issuableType, + }), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, + i18n: { + dueDate: __('Due date'), + noDueDate: __('None'), + removeDueDate: __('remove due date'), + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="$options.i18n.dueDate" + :tracking="$options.tracking" + :loading="isLoading" + class="block" + data-testid="due-date" + @open="openDatePicker" + > + <template #collapsed> + <div v-gl-tooltip :title="$options.i18n.dueDate" class="sidebar-collapsed-icon"> + <gl-icon :size="16" name="calendar" /> + <span class="collapse-truncated-title">{{ formattedDueDate }}</span> + </div> + <div class="gl-display-flex gl-align-items-center hide-collapsed"> + <span + :class="hasDueDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'" + data-testid="sidebar-duedate-value" + > + {{ formattedDueDate }} + </span> + <div v-if="hasDueDate && canUpdate" class="gl-display-flex"> + <span class="gl-px-2">-</span> + <gl-button + variant="link" + class="gl-text-gray-500!" + data-testid="reset-button" + :disabled="isLoading" + @click="setDueDate(null)" + > + {{ $options.i18n.removeDueDate }} + </gl-button> + </div> + </div> + </template> + <template #default> + <gl-datepicker + ref="datePicker" + :value="parsedDueDate" + show-clear-button + @input="setDueDate" + @clear="setDueDate(null)" + /> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue index 567c921b74e..d07c6e0cbd2 100644 --- a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue +++ b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue @@ -1,17 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { referenceQueries } from '~/sidebar/constants'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; export default { - i18n: { - copyReference: __('Copy reference'), - text: __('Reference'), - }, components: { - ClipboardButton, - GlLoadingIcon, + CopyableField, }, inject: ['fullPath', 'iid'], props: { @@ -56,29 +50,10 @@ export default { </script> <template> - <div class="sub-block"> - <clipboard-button - v-if="!isLoading" - :title="$options.i18n.copyReference" - :text="reference" - category="tertiary" - css-class="sidebar-collapsed-icon dont-change-state" - tooltip-placement="left" - /> - <div class="gl-display-flex gl-align-items-center gl-justify-between gl-mb-2 hide-collapsed"> - <span class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap"> - {{ $options.i18n.text }}: {{ reference }} - <gl-loading-icon v-if="isLoading" inline :label="$options.i18n.text" /> - </span> - <clipboard-button - v-if="!isLoading" - :title="$options.i18n.copyReference" - :text="reference" - size="small" - category="tertiary" - css-class="gl-mr-1" - tooltip-placement="left" - /> - </div> - </div> + <copyable-field + class="sub-block" + :is-loading="isLoading" + :name="__('Reference')" + :value="reference" + /> </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 dd1d54d67f2..c6fef86c6ff 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,12 +1,15 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { __, sprintf, s__ } from '~/locale'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; const SUCCESS_STATE = 'success'; export default { + i18n: { + reRequestReview: __('Re-request review'), + }, components: { GlButton, GlIcon, @@ -109,7 +112,8 @@ export default { <gl-button v-else-if="user.can_update_merge_request && user.reviewed" v-gl-tooltip.left - :title="__('Re-request review')" + :title="$options.i18n.reRequestReview" + :aria-label="$options.i18n.reRequestReview" :loading="loadingStates[user.id] === $options.LOADING_STATE" class="float-right gl-text-gray-500!" size="small" diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 4ab4606ac1c..caf1c92c28a 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { GlButton, GlLoadingIcon }, @@ -20,6 +21,16 @@ export default { required: false, default: false, }, + initialLoading: { + type: Boolean, + required: false, + default: false, + }, + isDirty: { + type: Boolean, + required: false, + default: false, + }, tracking: { type: Object, required: false, @@ -35,6 +46,11 @@ export default { edit: false, }; }, + computed: { + editButtonText() { + return this.isDirty ? __('Apply') : __('Edit'); + }, + }, destroyed() { window.removeEventListener('click', this.collapseWhenOffClick); window.removeEventListener('keyup', this.collapseOnEscape); @@ -86,15 +102,15 @@ export default { <template> <div> <div class="gl-display-flex gl-align-items-center" @click.self="collapse"> - <span class="hide-collapsed" data-testid="title">{{ title }}</span> - <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" /> + <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span> + <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" /> <gl-loading-icon v-if="loading && isClassicSidebar" inline class="gl-mx-auto gl-my-0 hide-expanded" /> <gl-button - v-if="canUpdate" + v-if="canUpdate && !initialLoading" variant="link" class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" data-testid="edit-button" @@ -105,14 +121,16 @@ export default { @keyup.esc="toggle" @click="toggle" > - {{ __('Edit') }} + {{ editButtonText }} </gl-button> </div> - <div v-show="!edit" data-testid="collapsed-content"> - <slot name="collapsed">{{ __('None') }}</slot> - </div> - <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> - <slot :edit="edit"></slot> - </div> + <template v-if="!initialLoading"> + <div v-show="!edit" data-testid="collapsed-content"> + <slot name="collapsed">{{ __('None') }}</slot> + </div> + <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> + <slot :edit="edit"></slot> + </div> + </template> </div> </template> |