summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/sidebar/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/sidebar/components')
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue25
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue296
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue51
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue203
-rw-r--r--app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue41
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue38
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>