Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/sidebar')
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/copy/copy_email_to_clipboard.vue (renamed from app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue)2
-rw-r--r--app/assets/javascripts/sidebar/components/copy/copyable_field.vue86
-rw-r--r--app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue (renamed from app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue)4
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/constants.js25
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/escalation_status.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/utils.js5
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js5
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue45
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue48
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue122
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue230
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue46
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue74
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue53
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue109
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue345
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js69
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js53
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js22
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js113
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js30
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js13
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue244
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue200
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue177
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue91
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue125
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue73
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue21
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue441
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js22
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue217
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issues_button.vue171
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/severity/constants.js41
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/status/status_dropdown.vue57
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue51
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/constants.js1
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue227
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue40
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue44
-rw-r--r--app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue55
-rw-r--r--app/assets/javascripts/sidebar/constants.js187
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js1
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js100
-rw-r--r--app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql17
-rw-r--r--app/assets/javascripts/sidebar/queries/delete_timelog.mutation.graphql (renamed from app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql22
-rw-r--r--app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql21
-rw-r--r--app/assets/javascripts/sidebar/queries/get_issue_crm_contacts.query.graphql (renamed from app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql17
-rw-r--r--app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql13
-rw-r--r--app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql26
-rw-r--r--app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql30
-rw-r--r--app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql17
-rw-r--r--app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql13
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_crm_contacts.fragment.graphql (renamed from app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_crm_contacts.subscription.graphql (renamed from app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql22
-rw-r--r--app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql5
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issuable_severity.mutation.graphql (renamed from app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql18
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_lock.mutation.graphql (renamed from app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_lock.mutation.graphql (renamed from app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql)0
-rw-r--r--app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql21
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js15
-rw-r--r--app/assets/javascripts/sidebar/utils.js24
95 files changed, 4425 insertions, 215 deletions
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index 78d12ac113b..93fcf2cf1c9 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 { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants';
-import { assigneesQueries } from '~/sidebar/constants';
+import { assigneesQueries } from '../../constants';
export default {
subscription: null,
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index 4408ebb881b..fd51cd5bb16 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { n__ } from '~/locale';
-import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue';
+import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue';
export default {
components: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 15fd365b4da..7979f450fdd 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -2,9 +2,9 @@
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
-import eventHub from '~/sidebar/event_hub';
-import Store from '~/sidebar/stores/sidebar_store';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import eventHub from '../../event_hub';
+import Store from '../../stores/sidebar_store';
import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue';
import AssigneesRealtime from './assignees_realtime.vue';
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 395dcf73693..d6c679f2f07 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -4,12 +4,12 @@ import Vue from 'vue';
import { createAlert } from '~/flash';
import { IssuableType } from '~/issues/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 } from '~/sidebar/constants';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { assigneesQueries } from '../../constants';
+import SidebarEditableItem from '../sidebar_editable_item.vue';
+import SidebarAssigneesRealtime from './assignees_realtime.vue';
+import IssuableAssignees from './issuable_assignees.vue';
import SidebarInviteMembers from './sidebar_invite_members.vue';
export const assigneesWidget = Vue.observable({
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 3532b75b6e7..dbedfe57325 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -3,7 +3,7 @@ import { GlSprintf, GlButton } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale';
-import { confidentialityQueries } from '~/sidebar/constants';
+import { confidentialityQueries } from '../../constants';
export default {
i18n: {
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 f3bd58c11d4..c2f239b56c7 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -3,8 +3,8 @@ import produce from 'immer';
import Vue from 'vue';
import { createAlert } from '~/flash';
import { __, sprintf } from '~/locale';
-import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import { confidentialityQueries, Tracking } from '~/sidebar/constants';
+import { confidentialityQueries, Tracking } from '../../constants';
+import SidebarEditableItem from '../sidebar_editable_item.vue';
import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue';
import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue';
diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy/copy_email_to_clipboard.vue
index fd652583f76..96ecdc84ef5 100644
--- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
+++ b/app/assets/javascripts/sidebar/components/copy/copy_email_to_clipboard.vue
@@ -1,5 +1,5 @@
<script>
-import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
+import CopyableField from './copyable_field.vue';
export default {
components: {
diff --git a/app/assets/javascripts/sidebar/components/copy/copyable_field.vue b/app/assets/javascripts/sidebar/components/copy/copyable_field.vue
new file mode 100644
index 00000000000..6538de085b0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/copy/copyable_field.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+/**
+ * Renders an inline field, whose value can be copied to the clipboard,
+ * for use in the GitLab sidebar (issues, MRs, etc.).
+ */
+export default {
+ name: 'CopyableField',
+ components: {
+ ClipboardButton,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clipboardTooltipText: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ clipboardProps() {
+ return {
+ category: 'tertiary',
+ tooltipBoundary: 'viewport',
+ tooltipPlacement: 'left',
+ text: this.value,
+ title:
+ this.clipboardTooltipText ||
+ sprintf(this.$options.i18n.clipboardTooltip, { name: this.name }),
+ };
+ },
+ loadingIconLabel() {
+ return sprintf(this.$options.i18n.loadingIconLabel, { name: this.name });
+ },
+ },
+ i18n: {
+ loadingIconLabel: __('Loading %{name}'),
+ clipboardTooltip: __('Copy %{name}'),
+ templateText: s__('Sidebar|%{name}: %{value}'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <clipboard-button
+ v-if="!isLoading"
+ css-class="sidebar-collapsed-icon js-dont-change-state gl-rounded-0! gl-hover-bg-transparent"
+ v-bind="clipboardProps"
+ />
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between hide-collapsed"
+ >
+ <span
+ class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap"
+ :title="value"
+ >
+ <gl-sprintf :message="$options.i18n.templateText">
+ <template #name>{{ name }}</template>
+ <template #value>{{ value }}</template>
+ </gl-sprintf>
+ </span>
+
+ <gl-loading-icon v-if="isLoading" size="sm" inline :label="loadingIconLabel" />
+ <clipboard-button v-else size="small" v-bind="clipboardProps" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue
index d07c6e0cbd2..3287539e502 100644
--- a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
+++ b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue
@@ -1,7 +1,7 @@
<script>
import { __ } from '~/locale';
-import { referenceQueries } from '~/sidebar/constants';
-import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
+import { referenceQueries } from '../../constants';
+import CopyableField from './copyable_field.vue';
export default {
components: {
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
index 81090bfa062..0660e4f58e4 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
@@ -4,8 +4,8 @@ import { __, n__, sprintf } from '~/locale';
import { createAlert } from '~/flash';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
-import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql';
-import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql';
+import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql';
+import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql';
export default {
components: {
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index c262d65f6ce..eb48732f558 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -4,14 +4,8 @@ import { createAlert } from '~/flash';
import { IssuableType } from '~/issues/constants';
import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
-import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import {
- dateFields,
- dateTypes,
- dueDateQueries,
- startDateQueries,
- Tracking,
-} from '~/sidebar/constants';
+import { dateFields, dateTypes, dueDateQueries, startDateQueries, Tracking } from '../../constants';
+import SidebarEditableItem from '../sidebar_editable_item.vue';
import SidebarFormattedDate from './sidebar_formatted_date.vue';
import SidebarInheritDate from './sidebar_inherit_date.vue';
diff --git a/app/assets/javascripts/sidebar/components/incidents/constants.js b/app/assets/javascripts/sidebar/components/incidents/constants.js
deleted file mode 100644
index cd05a6099fd..00000000000
--- a/app/assets/javascripts/sidebar/components/incidents/constants.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { s__ } from '~/locale';
-
-export const STATUS_TRIGGERED = 'TRIGGERED';
-export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED';
-export const STATUS_RESOLVED = 'RESOLVED';
-
-export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered');
-export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged');
-export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved');
-
-export const STATUS_LABELS = {
- [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL,
- [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL,
- [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL,
-};
-
-export const i18n = {
- fetchError: s__(
- 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.',
- ),
- title: s__('IncidentManagement|Status'),
- updateError: s__(
- 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.',
- ),
-};
diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
index 9c41db98c63..72a572087c7 100644
--- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
@@ -1,7 +1,12 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants';
-import { getStatusLabel } from './utils';
+import {
+ INCIDENTS_I18N as i18n,
+ STATUS_ACKNOWLEDGED,
+ STATUS_TRIGGERED,
+ STATUS_RESOLVED,
+} from '../../constants';
+import { getStatusLabel } from '../../utils';
const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED];
diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
index 67ae1e6fcab..f7daad63f45 100644
--- a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
@@ -1,12 +1,15 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants';
import { createAlert } from '~/flash';
import { logError } from '~/lib/logger';
import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
+import {
+ escalationStatusQuery,
+ escalationStatusMutation,
+ INCIDENTS_I18N as i18n,
+} from '../../constants';
+import { getStatusLabel } from '../../utils';
import SidebarEditableItem from '../sidebar_editable_item.vue';
-import { i18n } from './constants';
-import { getStatusLabel } from './utils';
export default {
i18n,
diff --git a/app/assets/javascripts/sidebar/components/incidents/utils.js b/app/assets/javascripts/sidebar/components/incidents/utils.js
deleted file mode 100644
index 59bf1ea466c..00000000000
--- a/app/assets/javascripts/sidebar/components/incidents/utils.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { s__ } from '~/locale';
-
-import { STATUS_LABELS } from './constants';
-
-export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None');
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js
new file mode 100644
index 00000000000..00c54313292
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js
@@ -0,0 +1,5 @@
+export const DropdownVariant = {
+ Sidebar: 'sidebar',
+ Standalone: 'standalone',
+ Embedded: 'embedded',
+};
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue
new file mode 100644
index 00000000000..864d9b308e7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget` instead.
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ computed: {
+ ...mapGetters([
+ 'dropdownButtonText',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ handleButtonClick(e) {
+ if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
+ this.toggleDropdownContents();
+ }
+
+ if (this.isDropdownVariantStandalone) {
+ e.stopPropagation();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
+ @click="handleButtonClick"
+ >
+ <span class="dropdown-toggle-text gl-pointer-events-none flex-fill">
+ {{ dropdownButtonText }}
+ </span>
+ <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
new file mode 100644
index 00000000000..89a976d45fa
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue
@@ -0,0 +1,48 @@
+<script>
+import { mapGetters, mapState } from 'vuex';
+
+import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
+import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue` instead.
+export default {
+ components: {
+ DropdownContentsLabelsView,
+ DropdownContentsCreateView,
+ },
+ props: {
+ renderOnTop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState(['showDropdownContentsCreateView']),
+ ...mapGetters(['isDropdownVariantSidebar']),
+ dropdownContentsView() {
+ if (this.showDropdownContentsCreateView) {
+ return 'dropdown-contents-create-view';
+ }
+ return 'dropdown-contents-labels-view';
+ },
+ directionStyle() {
+ const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
+ return this.renderOnTop ? { bottom } : {};
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
+ data-testid="labels-select-dropdown-contents"
+ data-qa-selector="labels_dropdown_content"
+ :style="directionStyle"
+ >
+ <component :is="dropdownContentsView" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
new file mode 100644
index 00000000000..b8afa67a947
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue` instead.
+export default {
+ components: {
+ GlButton,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ data() {
+ return {
+ labelTitle: '',
+ selectedColor: '',
+ };
+ },
+ computed: {
+ ...mapState(['labelsCreateTitle', 'labelCreateInProgress']),
+ disableCreate() {
+ return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress;
+ },
+ suggestedColors() {
+ const colorsMap = gon.suggested_label_colors;
+ return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
+ },
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']),
+ getColorCode(color) {
+ return Object.keys(color).pop();
+ },
+ getColorName(color) {
+ return Object.values(color).pop();
+ },
+ handleColorClick(color) {
+ this.selectedColor = this.getColorCode(color);
+ },
+ handleCreateClick() {
+ this.createLabel({
+ title: this.labelTitle,
+ color: this.selectedColor,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="labels-select-contents-create js-labels-create">
+ <div class="dropdown-title d-flex align-items-center pt-0 pb-2 gl-mb-0">
+ <gl-button
+ :aria-label="__('Go back')"
+ category="tertiary"
+ size="small"
+ class="js-btn-back dropdown-header-button p-0"
+ icon="arrow-left"
+ @click="toggleDropdownContentsCreateView"
+ />
+ <span class="flex-grow-1">{{ labelsCreateTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ category="tertiary"
+ size="small"
+ class="dropdown-header-button p-0"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <div class="dropdown-input">
+ <gl-form-input
+ v-model.trim="labelTitle"
+ :placeholder="__('Name new label')"
+ :autofocus="true"
+ />
+ </div>
+ <div class="dropdown-content px-2">
+ <div class="suggest-colors suggest-colors-dropdown mt-0 mb-2">
+ <gl-link
+ v-for="(color, index) in suggestedColors"
+ :key="index"
+ v-gl-tooltip:tooltipcontainer
+ :style="{ backgroundColor: getColorCode(color) }"
+ :title="getColorName(color)"
+ @click.prevent="handleColorClick(color)"
+ />
+ </div>
+ <div class="color-input-container gl-display-flex">
+ <span
+ class="dropdown-label-color-preview position-relative position-relative d-inline-block"
+ :style="{ backgroundColor: selectedColor }"
+ ></span>
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
+ :placeholder="__('Use custom color #FF0000')"
+ />
+ </div>
+ </div>
+ <div class="dropdown-actions clearfix pt-2 px-2">
+ <gl-button
+ :disabled="disableCreate"
+ category="primary"
+ variant="confirm"
+ class="float-left d-flex align-items-center"
+ @click="handleCreateClick"
+ >
+ <gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" />
+ {{ __('Create') }}
+ </gl-button>
+ <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
new file mode 100644
index 00000000000..ee6b531c1ca
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
@@ -0,0 +1,230 @@
+<script>
+import {
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ GlButton,
+ GlSearchBoxByType,
+ GlLink,
+} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { mapState, mapGetters, mapActions } from 'vuex';
+
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+
+import LabelItem from './label_item.vue';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue` instead.
+export default {
+ components: {
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ GlButton,
+ GlSearchBoxByType,
+ GlLink,
+ LabelItem,
+ },
+ data() {
+ return {
+ searchKey: '',
+ currentHighlightItem: -1,
+ };
+ },
+ computed: {
+ ...mapState([
+ 'allowLabelCreate',
+ 'allowMultiselect',
+ 'labelsManagePath',
+ 'labels',
+ 'labelsFetchInProgress',
+ 'labelsListTitle',
+ 'footerCreateLabelTitle',
+ 'footerManageLabelTitle',
+ ]),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
+ visibleLabels() {
+ if (this.searchKey) {
+ return fuzzaldrinPlus.filter(this.labels, this.searchKey, {
+ key: ['title'],
+ });
+ }
+ return this.labels;
+ },
+ showDropdownFooter() {
+ return (
+ (this.isDropdownVariantSidebar || this.isDropdownVariantEmbedded) &&
+ (this.allowLabelCreate || this.labelsManagePath)
+ );
+ },
+ showNoMatchingResultsMessage() {
+ return Boolean(this.searchKey) && this.visibleLabels.length === 0;
+ },
+ },
+ watch: {
+ searchKey(value) {
+ // When there is search string present
+ // and there are matching results,
+ // highlight first item by default.
+ if (value && this.visibleLabels.length) {
+ this.currentHighlightItem = 0;
+ }
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'toggleDropdownContents',
+ 'toggleDropdownContentsCreateView',
+ 'fetchLabels',
+ 'receiveLabelsSuccess',
+ 'updateSelectedLabels',
+ 'toggleDropdownContents',
+ ]),
+ isLabelSelected(label) {
+ return this.selectedLabelsList.includes(label.id);
+ },
+ /**
+ * This method scrolls item from dropdown into
+ * the view if it is off the viewable area of the
+ * container.
+ */
+ scrollIntoViewIfNeeded() {
+ const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused');
+
+ if (highlightedLabel) {
+ const container = this.$refs.labelsListContainer.getBoundingClientRect();
+ const label = highlightedLabel.getBoundingClientRect();
+
+ if (label.bottom > container.bottom) {
+ this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom;
+ } else if (label.top < container.top) {
+ this.$refs.labelsListContainer.scrollTop -= container.top - label.top;
+ }
+ }
+ },
+ handleComponentAppear() {
+ // We can avoid putting `catch` block here
+ // as failure is handled within actions.js already.
+ return this.fetchLabels().then(() => {
+ this.$refs.searchInput.focusInput();
+ });
+ },
+ /**
+ * We want to remove loaded labels to ensure component
+ * fetches fresh set of labels every time when shown.
+ */
+ handleComponentDisappear() {
+ this.receiveLabelsSuccess([]);
+ },
+ handleCreateLabelClick() {
+ this.receiveLabelsSuccess([]);
+ this.toggleDropdownContentsCreateView();
+ },
+ /**
+ * This method enables keyboard navigation support for
+ * the dropdown.
+ */
+ handleKeyDown(e) {
+ if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) {
+ this.currentHighlightItem -= 1;
+ } else if (
+ e.keyCode === DOWN_KEY_CODE &&
+ this.currentHighlightItem < this.visibleLabels.length - 1
+ ) {
+ this.currentHighlightItem += 1;
+ } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
+ this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
+ this.searchKey = '';
+
+ // Prevent parent form submission upon hitting enter.
+ e.preventDefault();
+ } else if (e.keyCode === ESC_KEY_CODE) {
+ this.toggleDropdownContents();
+ }
+
+ if (e.keyCode !== ESC_KEY_CODE) {
+ // Scroll the list only after highlighting
+ // styles are rendered completely.
+ this.$nextTick(() => {
+ this.scrollIntoViewIfNeeded();
+ });
+ }
+ },
+ handleLabelClick(label) {
+ this.updateSelectedLabels([label]);
+ if (!this.allowMultiselect) this.toggleDropdownContents();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
+ <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-title"
+ >
+ <span class="flex-grow-1">{{ labelsListTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ category="tertiary"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <div class="dropdown-input" @click.stop="() => {}">
+ <gl-search-box-by-type
+ ref="searchInput"
+ v-model="searchKey"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ />
+ </div>
+ <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
+ <gl-loading-icon
+ v-if="labelsFetchInProgress"
+ class="labels-fetch-loading gl-align-items-center w-100 h-100"
+ size="lg"
+ />
+ <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word">
+ <label-item
+ v-for="(label, index) in visibleLabels"
+ :key="label.id"
+ :label="label"
+ :is-label-set="label.set"
+ :is-label-indeterminate="label.indeterminate"
+ :highlight="index === currentHighlightItem"
+ @clickLabel="handleLabelClick(label)"
+ />
+ <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
+ {{ __('No matching results') }}
+ </li>
+ </ul>
+ </div>
+ <div v-if="showDropdownFooter" class="dropdown-footer" data-testid="dropdown-footer">
+ <ul class="list-unstyled">
+ <li v-if="allowLabelCreate">
+ <gl-link
+ class="gl-display-flex w-100 flex-row text-break-word label-item"
+ @click="handleCreateLabelClick"
+ >
+ {{ footerCreateLabelTitle }}
+ </gl-link>
+ </li>
+ <li v-if="labelsManagePath">
+ <gl-link
+ :href="labelsManagePath"
+ class="gl-display-flex flex-row text-break-word label-item"
+ >
+ {{ footerManageLabelTitle }}
+ </gl-link>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue
new file mode 100644
index 00000000000..1e9edd222c5
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue` instead.
+export default {
+ components: {
+ GlButton,
+ GlLoadingIcon,
+ },
+ props: {
+ labelsSelectInProgress: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['allowLabelEdit', 'labelsFetchInProgress']),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ },
+};
+</script>
+
+<template>
+ <div
+ class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold gl-mb-0"
+ >
+ {{ __('Labels') }}
+ <template v-if="allowLabelEdit">
+ <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline />
+ <gl-button
+ category="tertiary"
+ size="small"
+ class="float-right js-sidebar-dropdown-toggle gl-mr-n2"
+ data-qa-selector="labels_edit_button"
+ @click="toggleDropdownContents"
+ >
+ {{ __('Edit') }}
+ </gl-button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue
new file mode 100644
index 00000000000..583f060be8a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlLabel } from '@gitlab/ui';
+import { sortBy } from 'lodash';
+import { mapState } from 'vuex';
+
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue` instead.
+export default {
+ components: {
+ GlLabel,
+ },
+ props: {
+ disableLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'selectedLabels',
+ 'allowLabelRemove',
+ 'allowScopedLabels',
+ 'labelsFilterBasePath',
+ 'labelsFilterParam',
+ ]),
+ sortedSelectedLabels() {
+ return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1));
+ },
+ },
+ methods: {
+ labelFilterUrl(label) {
+ return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent(
+ label.title,
+ )}`;
+ },
+ scopedLabel(label) {
+ return this.allowScopedLabels && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'has-labels': selectedLabels.length,
+ }"
+ class="hide-collapsed value issuable-show-labels js-value"
+ >
+ <span v-if="!selectedLabels.length" class="text-secondary">
+ <slot></slot>
+ </span>
+ <template v-for="label in sortedSelectedLabels" v-else>
+ <gl-label
+ :key="label.id"
+ data-qa-selector="selected_label_content"
+ :data-qa-label-name="label.title"
+ :title="label.title"
+ :description="label.description"
+ :background-color="label.color"
+ :target="labelFilterUrl(label)"
+ :scoped="scopedLabel(label)"
+ :show-close-button="allowLabelRemove"
+ :disabled="disableLabels"
+ tooltip-placement="top"
+ @close="$emit('onLabelRemove', label.id)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue
new file mode 100644
index 00000000000..e84da6ee12b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget` instead.
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ labelsList() {
+ const labelsString = this.labels.length
+ ? this.labels
+ .slice(0, 5)
+ .map((label) => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
+
+ if (this.labels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.labels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
+ },
+ methods: {
+ handleClick() {
+ this.$emit('onValueClick');
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-gl-tooltip.left.viewport="labelsList" class="sidebar-collapsed-icon" @click="handleClick">
+ <gl-icon name="labels" />
+ <span>{{ labels.length }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
new file mode 100644
index 00000000000..135fa9f6228
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue` instead.
+export default {
+ functional: true,
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ isLabelSet: {
+ type: Boolean,
+ required: true,
+ },
+ isLabelIndeterminate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ highlight: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ render(h, { props, listeners }) {
+ const { label, highlight, isLabelSet, isLabelIndeterminate } = props;
+
+ const labelColorBox = h('span', {
+ class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
+ style: {
+ backgroundColor: label.color,
+ },
+ attrs: {
+ 'data-testid': 'label-color-box',
+ },
+ });
+
+ const checkedIcon = h(GlIcon, {
+ class: {
+ 'gl-mr-3 gl-flex-shrink-0 has-tooltip': true,
+ hidden: !isLabelSet,
+ },
+ attrs: {
+ title: __('Selected for all items.'),
+ 'data-testid': 'checked-icon',
+ },
+ props: {
+ name: 'mobile-issue-close',
+ },
+ });
+
+ const indeterminateIcon = h(GlIcon, {
+ class: {
+ 'gl-mr-3 gl-flex-shrink-0 has-tooltip': true,
+ hidden: !isLabelIndeterminate,
+ },
+ attrs: {
+ title: __('Selected for some items.'),
+ 'data-testid': 'indeterminate-icon',
+ },
+ props: {
+ name: 'dash',
+ },
+ });
+
+ const noIcon = h('span', {
+ class: {
+ 'gl-mr-5 gl-pr-3': true,
+ hidden: isLabelSet || isLabelIndeterminate,
+ },
+ attrs: {
+ 'data-testid': 'no-icon',
+ },
+ });
+
+ const labelTitle = h('span', label.title);
+
+ const labelLink = h(
+ GlLink,
+ {
+ class: 'gl-display-flex gl-align-items-center label-item gl-text-body',
+ on: {
+ click: () => {
+ listeners.clickLabel(label);
+ },
+ },
+ },
+ [noIcon, checkedIcon, indeterminateIcon, labelColorBox, labelTitle],
+ );
+
+ return h(
+ 'li',
+ {
+ class: {
+ 'gl-display-block': true,
+ 'gl-text-left': true,
+ 'is-focused': highlight,
+ },
+ },
+ [labelLink],
+ );
+ },
+};
+</script>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
new file mode 100644
index 00000000000..2a78db352d7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
@@ -0,0 +1,345 @@
+<script>
+import $ from 'jquery';
+import Vue from 'vue';
+import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
+import { isInViewport } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+
+import { DropdownVariant } from './constants';
+import DropdownButton from './dropdown_button.vue';
+import DropdownContents from './dropdown_contents.vue';
+import DropdownTitle from './dropdown_title.vue';
+import DropdownValue from './dropdown_value.vue';
+import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
+import labelsSelectModule from './store';
+
+Vue.use(Vuex);
+
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue` instead.
+export default {
+ store: new Vuex.Store(labelsSelectModule()),
+ components: {
+ DropdownTitle,
+ DropdownValue,
+ DropdownButton,
+ DropdownContents,
+ DropdownValueCollapsed,
+ },
+ props: {
+ allowLabelRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowLabelEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowLabelCreate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowMultiselect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowMultipleScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: DropdownVariant.Sidebar,
+ },
+ selectedLabels: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ hideCollapsedView: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ labelsSelectInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ labelsFetchPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsManagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsFilterBasePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
+ dropdownButtonText: {
+ type: String,
+ required: false,
+ default: __('Label'),
+ },
+ labelsListTitle: {
+ type: String,
+ required: false,
+ default: __('Assign labels'),
+ },
+ labelsCreateTitle: {
+ type: String,
+ required: false,
+ default: __('Create group label'),
+ },
+ footerCreateLabelTitle: {
+ type: String,
+ required: false,
+ default: __('Create group label'),
+ },
+ footerManageLabelTitle: {
+ type: String,
+ required: false,
+ default: __('Manage group labels'),
+ },
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ contentIsOnViewport: true,
+ };
+ },
+ computed: {
+ ...mapState(['showDropdownButton', 'showDropdownContents']),
+ ...mapGetters([
+ 'isDropdownVariantSidebar',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
+ dropdownButtonVisible() {
+ return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
+ },
+ },
+ watch: {
+ selectedLabels(selectedLabels) {
+ this.setInitialState({
+ selectedLabels,
+ });
+ setTimeout(() => this.updateLabelsSetState(), 100);
+ },
+ showDropdownContents(showDropdownContents) {
+ this.setContentIsOnViewport(showDropdownContents);
+ },
+ isEditing(newVal) {
+ if (newVal) {
+ this.toggleDropdownContents();
+ }
+ },
+ },
+ mounted() {
+ this.setInitialState({
+ variant: this.variant,
+ allowLabelRemove: this.allowLabelRemove,
+ allowLabelEdit: this.allowLabelEdit,
+ allowLabelCreate: this.allowLabelCreate,
+ allowMultiselect: this.allowMultiselect,
+ allowScopedLabels: this.allowScopedLabels,
+ allowMultipleScopedLabels: this.allowMultipleScopedLabels,
+ dropdownButtonText: this.dropdownButtonText,
+ selectedLabels: this.selectedLabels,
+ labelsFetchPath: this.labelsFetchPath,
+ labelsManagePath: this.labelsManagePath,
+ labelsFilterBasePath: this.labelsFilterBasePath,
+ labelsFilterParam: this.labelsFilterParam,
+ labelsListTitle: this.labelsListTitle,
+ labelsCreateTitle: this.labelsCreateTitle,
+ footerCreateLabelTitle: this.footerCreateLabelTitle,
+ footerManageLabelTitle: this.footerManageLabelTitle,
+ });
+
+ this.$store.subscribeAction({
+ after: this.handleVuexActionDispatch,
+ });
+
+ document.addEventListener('mousedown', this.handleDocumentMousedown);
+ document.addEventListener('click', this.handleDocumentClick);
+
+ this.updateLabelsSetState();
+ },
+ beforeDestroy() {
+ document.removeEventListener('mousedown', this.handleDocumentMousedown);
+ document.removeEventListener('click', this.handleDocumentClick);
+ },
+ methods: {
+ ...mapActions(['setInitialState', 'toggleDropdownContents', 'updateLabelsSetState']),
+ /**
+ * This method differentiates between
+ * dispatched actions and calls necessary method.
+ */
+ handleVuexActionDispatch(action, state) {
+ if (
+ action.type === 'toggleDropdownContents' &&
+ !state.showDropdownButton &&
+ !state.showDropdownContents
+ ) {
+ const filterTouchedLabelsFn = (label) => label.touched;
+ const filterSetLabelsFn = (label) => label.set;
+ const labels = this.isDropdownVariantEmbedded
+ ? state.labels.filter(filterSetLabelsFn)
+ : state.labels.filter(filterTouchedLabelsFn);
+ this.handleDropdownClose(labels, state.labels.filter(filterTouchedLabelsFn));
+ }
+ },
+ /**
+ * This method stores a mousedown event's target.
+ * Required by the click listener because the click
+ * event itself has no reference to this element.
+ */
+ handleDocumentMousedown({ target }) {
+ this.mousedownTarget = target;
+ },
+ /**
+ * This method listens for document-wide click event
+ * and toggle dropdown if user clicks anywhere outside
+ * the dropdown while dropdown is visible.
+ */
+ handleDocumentClick({ target }) {
+ // We also perform the toggle exception check for the
+ // last mousedown event's target to avoid hiding the
+ // box when the mousedown happened inside the box and
+ // only the mouseup did not.
+ if (
+ this.showDropdownContents &&
+ !this.preventDropdownToggleOnClick(target) &&
+ !this.preventDropdownToggleOnClick(this.mousedownTarget)
+ ) {
+ this.toggleDropdownContents();
+ }
+ },
+ /**
+ * This method checks whether a given click target
+ * should prevent the dropdown from being toggled.
+ */
+ preventDropdownToggleOnClick(target) {
+ // This approach of element detection is needed
+ // as the dropdown wrapper is not using `GlDropdown` as
+ // it will also require us to use `BDropdownForm`
+ // which is yet to be implemented in GitLab UI.
+ const hasExceptionClass = [
+ 'js-dropdown-button',
+ 'js-btn-cancel-create',
+ 'js-sidebar-dropdown-toggle',
+ ].some(
+ (className) =>
+ target?.classList.contains(className) ||
+ target?.parentElement?.classList.contains(className),
+ );
+
+ const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ (className) => $(target).parents(className).length,
+ );
+
+ const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
+
+ const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
+
+ return (
+ hasExceptionClass ||
+ hasExceptionParent ||
+ isInDropdownButtonCollapsed ||
+ isInDropdownContents
+ );
+ },
+ handleDropdownClose(labels, touchedLabels) {
+ // Only emit label updates if there are any
+ // labels to update on UI.
+ if (labels.length) this.$emit('updateSelectedLabels', labels);
+ this.$emit('onDropdownClose', touchedLabels);
+ },
+ handleCollapsedValueClick() {
+ this.$emit('toggleCollapse');
+ },
+ setContentIsOnViewport(showDropdownContents) {
+ if (!showDropdownContents) {
+ this.contentIsOnViewport = true;
+
+ return;
+ }
+
+ this.$nextTick(() => {
+ if (this.$refs.dropdownContents) {
+ this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el);
+ }
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-wrapper position-relative"
+ :class="{
+ 'is-standalone': isDropdownVariantStandalone,
+ 'is-embedded': isDropdownVariantEmbedded,
+ }"
+ >
+ <template v-if="isDropdownVariantSidebar">
+ <dropdown-value-collapsed
+ v-if="!hideCollapsedView"
+ ref="dropdownButtonCollapsed"
+ :labels="selectedLabels"
+ @onValueClick="handleCollapsedValueClick"
+ />
+ <dropdown-title
+ :allow-label-edit="allowLabelEdit"
+ :labels-select-in-progress="labelsSelectInProgress"
+ />
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ @onLabelRemove="$emit('onLabelRemove', $event)"
+ >
+ <slot></slot>
+ </dropdown-value>
+ <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
+ />
+ </template>
+ <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
+ <dropdown-button v-show="dropdownButtonVisible" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
new file mode 100644
index 00000000000..2dab97826b9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
@@ -0,0 +1,69 @@
+import { createAlert } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import * as types from './mutation_types';
+
+export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props);
+
+export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON);
+export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS);
+
+export const toggleDropdownContentsCreateView = ({ commit }) =>
+ commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW);
+
+export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS);
+export const receiveLabelsSuccess = ({ commit }, labels) =>
+ commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
+export const receiveLabelsFailure = ({ commit }) => {
+ commit(types.RECEIVE_SET_LABELS_FAILURE);
+ createAlert({
+ message: __('Error fetching labels.'),
+ });
+};
+export const fetchLabels = ({ state, dispatch }, options) => {
+ if (state.labelsFetched && (!options || !options.refetch)) {
+ return Promise.resolve();
+ }
+
+ dispatch('requestLabels');
+ return axios
+ .get(state.labelsFetchPath)
+ .then(({ data }) => {
+ dispatch('receiveLabelsSuccess', data);
+ })
+ .catch(() => dispatch('receiveLabelsFailure'));
+};
+
+export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL);
+export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
+export const receiveCreateLabelFailure = ({ commit }) => {
+ commit(types.RECEIVE_CREATE_LABEL_FAILURE);
+ createAlert({
+ message: __('Error creating label.'),
+ });
+};
+export const createLabel = ({ state, dispatch }, label) => {
+ dispatch('requestCreateLabel');
+ axios
+ .post(state.labelsManagePath, {
+ label,
+ })
+ .then(({ data }) => {
+ if (data.id) {
+ dispatch('fetchLabels', { refetch: true });
+ dispatch('receiveCreateLabelSuccess');
+ dispatch('toggleDropdownContentsCreateView');
+ } else {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Error Creating Label');
+ }
+ })
+ .catch(() => {
+ dispatch('receiveCreateLabelFailure');
+ });
+};
+
+export const updateSelectedLabels = ({ commit }, labels) =>
+ commit(types.UPDATE_SELECTED_LABELS, { labels });
+
+export const updateLabelsSetState = ({ commit }) => commit(types.UPDATE_LABELS_SET_STATE);
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
new file mode 100644
index 00000000000..ef3eedd9bb2
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
@@ -0,0 +1,53 @@
+import { __, s__, sprintf } from '~/locale';
+import { DropdownVariant } from '../constants';
+
+/**
+ * Returns string representing current labels
+ * selection on dropdown button.
+ *
+ * @param {object} state
+ */
+export const dropdownButtonText = (state, getters) => {
+ const selectedLabels =
+ getters.isDropdownVariantSidebar || getters.isDropdownVariantEmbedded
+ ? state.labels.filter((label) => label.set || label.indeterminate)
+ : state.selectedLabels;
+
+ if (!selectedLabels.length) {
+ return state.dropdownButtonText || __('Label');
+ } else if (selectedLabels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: selectedLabels[0].title,
+ remainingLabelCount: selectedLabels.length - 1,
+ });
+ }
+ return selectedLabels[0].title;
+};
+
+/**
+ * Returns array containing only label IDs from
+ * selectedLabels array.
+ * @param {object} state
+ */
+export const selectedLabelsList = (state) => state.selectedLabels.map((label) => label.id);
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {object} state
+ */
+export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `standalone`
+ * @param {object} state
+ */
+export const isDropdownVariantStandalone = (state) => state.variant === DropdownVariant.Standalone;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {object} state
+ */
+export const isDropdownVariantEmbedded = (state) => state.variant === DropdownVariant.Embedded;
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js
new file mode 100644
index 00000000000..5f61cb732c8
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js
@@ -0,0 +1,12 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+export default () => ({
+ namespaced: true,
+ state: state(),
+ actions,
+ getters,
+ mutations,
+});
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js
new file mode 100644
index 00000000000..f26e36031f4
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js
@@ -0,0 +1,22 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+
+export const REQUEST_LABELS = 'REQUEST_LABELS';
+export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
+
+export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS';
+export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS';
+export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE';
+
+export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL';
+export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS';
+export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
+
+export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
+export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
+
+export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
+
+export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
+
+export const UPDATE_LABELS_SET_STATE = 'UPDATE_LABELS_SET_STATE';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
new file mode 100644
index 00000000000..c85d9befcbb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
@@ -0,0 +1,113 @@
+import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
+import { DropdownVariant } from '../constants';
+import * as types from './mutation_types';
+
+const transformLabels = (labels, selectedLabels) =>
+ labels.map((label) => {
+ const selectedLabel = selectedLabels.find(({ id }) => id === label.id);
+
+ return {
+ ...label,
+ set: Boolean(selectedLabel?.set),
+ indeterminate: Boolean(selectedLabel?.indeterminate),
+ };
+ });
+
+export default {
+ [types.SET_INITIAL_STATE](state, props) {
+ // We need to ensure that selectedLabels have
+ // `set` & `indeterminate` properties defined.
+ if (props.selectedLabels?.length) {
+ props.selectedLabels.forEach((label) => {
+ /* eslint-disable no-param-reassign */
+ if (label.set === undefined && label.indeterminate === undefined) {
+ label.set = true;
+ label.indeterminate = false;
+ } else if (label.set === undefined && label.indeterminate !== undefined) {
+ label.set = false;
+ } else if (label.set !== undefined && label.indeterminate === undefined) {
+ label.indeterminate = false;
+ } else {
+ label.set = false;
+ label.indeterminate = false;
+ }
+ /* eslint-enable no-param-reassign */
+ });
+ }
+
+ Object.assign(state, { ...props });
+ },
+
+ [types.TOGGLE_DROPDOWN_BUTTON](state) {
+ state.showDropdownButton = !state.showDropdownButton;
+ },
+
+ [types.TOGGLE_DROPDOWN_CONTENTS](state) {
+ if (state.variant === DropdownVariant.Sidebar) {
+ state.showDropdownButton = !state.showDropdownButton;
+ }
+ state.showDropdownContents = !state.showDropdownContents;
+ // Ensure that Create View is hidden by default
+ // when dropdown contents are revealed.
+ if (state.showDropdownContents) {
+ state.showDropdownContentsCreateView = false;
+ }
+ },
+
+ [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) {
+ state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView;
+ },
+
+ [types.REQUEST_LABELS](state) {
+ state.labelsFetchInProgress = true;
+ },
+ [types.RECEIVE_SET_LABELS_SUCCESS](state, labels) {
+ // Iterate over every label and add a `set` prop
+ // to determine whether it is already a part of
+ // selectedLabels array.
+ state.labelsFetchInProgress = false;
+ state.labelsFetched = true;
+ state.labels = transformLabels(labels, state.selectedLabels);
+ },
+ [types.RECEIVE_SET_LABELS_FAILURE](state) {
+ state.labelsFetchInProgress = false;
+ },
+
+ [types.REQUEST_CREATE_LABEL](state) {
+ state.labelCreateInProgress = true;
+ },
+ [types.RECEIVE_CREATE_LABEL_SUCCESS](state) {
+ state.labelCreateInProgress = false;
+ },
+ [types.RECEIVE_CREATE_LABEL_FAILURE](state) {
+ state.labelCreateInProgress = false;
+ },
+
+ [types.UPDATE_SELECTED_LABELS](state, { labels }) {
+ // Find the label to update from all the labels
+ // and change `set` prop value to represent their current state.
+ const labelId = labels.pop()?.id;
+ const candidateLabel = state.labels.find((label) => labelId === label.id);
+ if (candidateLabel) {
+ candidateLabel.touched = true;
+ candidateLabel.set = candidateLabel.indeterminate ? true : !candidateLabel.set;
+ candidateLabel.indeterminate = false;
+ }
+
+ if (isScopedLabel(candidateLabel) && !state.allowMultipleScopedLabels) {
+ const currentActiveScopedLabel = state.labels.find(
+ ({ set, title }) =>
+ set &&
+ title !== candidateLabel.title &&
+ scopedLabelKey({ title }) === scopedLabelKey(candidateLabel),
+ );
+ if (currentActiveScopedLabel) {
+ currentActiveScopedLabel.set = false;
+ }
+ }
+ },
+
+ [types.UPDATE_LABELS_SET_STATE](state) {
+ state.labels = transformLabels(state.labels, state.selectedLabels);
+ },
+};
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js
new file mode 100644
index 00000000000..0185d5f88e1
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js
@@ -0,0 +1,30 @@
+export default () => ({
+ // Initial Data
+ labels: [],
+ labelsFetched: false,
+ selectedLabels: [],
+ labelsListTitle: '',
+ labelsCreateTitle: '',
+ footerCreateLabelTitle: '',
+ footerManageLabelTitle: '',
+ dropdownButtonText: '',
+
+ // Paths
+ namespace: '',
+ labelsFetchPath: '',
+ labelsFilterBasePath: '',
+
+ // UI Flags
+ variant: '',
+ allowLabelRemove: false,
+ allowLabelCreate: false,
+ allowLabelEdit: false,
+ allowScopedLabels: false,
+ allowMultiselect: false,
+ showDropdownButton: false,
+ showDropdownContents: false,
+ showDropdownContentsCreateView: false,
+ labelsFetchInProgress: false,
+ labelCreateInProgress: false,
+ selectedLabelsUpdated: false,
+});
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
new file mode 100644
index 00000000000..cd671b4d8f5
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
@@ -0,0 +1,13 @@
+export const SCOPED_LABEL_DELIMITER = '::';
+export const DEBOUNCE_DROPDOWN_DELAY = 200;
+
+export const DropdownVariant = {
+ Sidebar: 'sidebar',
+ Standalone: 'standalone',
+ Embedded: 'embedded',
+};
+
+export const LabelType = {
+ group: 'group',
+ project: 'project',
+};
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
new file mode 100644
index 00000000000..83df9056af2
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
@@ -0,0 +1,244 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { __, s__, sprintf } from '~/locale';
+import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
+import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
+import DropdownFooter from './dropdown_footer.vue';
+import DropdownHeader from './dropdown_header.vue';
+import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils';
+
+export default {
+ components: {
+ DropdownContentsLabelsView,
+ DropdownContentsCreateView,
+ DropdownHeader,
+ DropdownFooter,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLink,
+ },
+ props: {
+ labelsCreateTitle: {
+ type: String,
+ required: true,
+ },
+ selectedLabels: {
+ type: Array,
+ required: true,
+ },
+ allowMultiselect: {
+ type: Boolean,
+ required: true,
+ },
+ labelsListTitle: {
+ type: String,
+ required: true,
+ },
+ dropdownButtonText: {
+ type: String,
+ required: true,
+ },
+ footerCreateLabelTitle: {
+ type: String,
+ required: true,
+ },
+ footerManageLabelTitle: {
+ type: String,
+ required: true,
+ },
+ variant: {
+ type: String,
+ required: true,
+ },
+ isVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ workspaceType: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: true,
+ },
+ labelCreateType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showDropdownContentsCreateView: false,
+ localSelectedLabels: [...this.selectedLabels],
+ isDirty: false,
+ searchKey: '',
+ };
+ },
+ computed: {
+ dropdownContentsView() {
+ if (this.showDropdownContentsCreateView) {
+ return 'dropdown-contents-create-view';
+ }
+ return 'dropdown-contents-labels-view';
+ },
+ dropdownTitle() {
+ return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
+ },
+ buttonText() {
+ if (!this.localSelectedLabels.length) {
+ return this.dropdownButtonText || __('Label');
+ } else if (this.localSelectedLabels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: this.localSelectedLabels[0].title,
+ remainingLabelCount: this.localSelectedLabels.length - 1,
+ });
+ }
+ return this.localSelectedLabels[0].title;
+ },
+ showDropdownFooter() {
+ return !this.showDropdownContentsCreateView && !this.isStandalone;
+ },
+ isStandalone() {
+ return isDropdownVariantStandalone(this.variant);
+ },
+ isSidebar() {
+ return isDropdownVariantSidebar(this.variant);
+ },
+ },
+ watch: {
+ localSelectedLabels: {
+ handler() {
+ this.isDirty = true;
+ },
+ deep: true,
+ },
+ isVisible(newVal) {
+ if (newVal) {
+ this.$refs.dropdown.show();
+ this.isDirty = false;
+ this.localSelectedLabels = this.selectedLabels;
+ } else {
+ this.$refs.dropdown.hide();
+ this.setLabels();
+ }
+ },
+ selectedLabels(newVal) {
+ if (!this.isDirty || !this.isSidebar) {
+ this.localSelectedLabels = newVal;
+ }
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ beforeDestroy() {
+ this.debouncedSearchKeyUpdate.cancel();
+ },
+ methods: {
+ toggleDropdownContentsCreateView() {
+ this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
+ },
+ toggleDropdownContent() {
+ this.toggleDropdownContentsCreateView();
+ // Required to recalculate dropdown position as its size changes
+ if (this.$refs.dropdown?.$refs.dropdown) {
+ this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
+ }
+ },
+ setLabels() {
+ if (!this.isDirty) {
+ return;
+ }
+ this.$emit('setLabels', this.localSelectedLabels);
+ },
+ handleDropdownHide() {
+ this.$emit('closeDropdown');
+ if (!this.isSidebar) {
+ this.setLabels();
+ }
+ },
+ setSearchKey(value) {
+ this.searchKey = value;
+ },
+ setFocus() {
+ this.$refs.header.focusInput();
+ },
+ hideDropdown() {
+ this.$refs.dropdown.hide();
+ },
+ showDropdown() {
+ this.$refs.dropdown.show();
+ },
+ clearSearch() {
+ if (!this.allowMultiselect || this.isStandalone) {
+ return;
+ }
+ this.searchKey = '';
+ this.setFocus();
+ },
+ selectFirstItem() {
+ this.$refs.dropdownContentsView.selectFirstItem();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ :text="buttonText"
+ class="gl-w-full"
+ block
+ data-testid="labels-select-dropdown-contents"
+ data-qa-selector="labels_dropdown_content"
+ @hide="handleDropdownHide"
+ @shown="setFocus"
+ >
+ <template #header>
+ <dropdown-header
+ ref="header"
+ :search-key="searchKey"
+ :labels-create-title="labelsCreateTitle"
+ :labels-list-title="labelsListTitle"
+ :show-dropdown-contents-create-view="showDropdownContentsCreateView"
+ :is-standalone="isStandalone"
+ @toggleDropdownContentsCreateView="toggleDropdownContent"
+ @closeDropdown="hideDropdown"
+ @input="debouncedSearchKeyUpdate"
+ @searchEnter="selectFirstItem"
+ />
+ </template>
+ <template #default>
+ <component
+ :is="dropdownContentsView"
+ ref="dropdownContentsView"
+ v-model="localSelectedLabels"
+ :search-key="searchKey"
+ :allow-multiselect="allowMultiselect"
+ :full-path="fullPath"
+ :workspace-type="workspaceType"
+ :attr-workspace-path="attrWorkspacePath"
+ :label-create-type="labelCreateType"
+ @hideCreateView="toggleDropdownContent"
+ @input="clearSearch"
+ />
+ </template>
+ <template #footer>
+ <dropdown-footer
+ v-if="showDropdownFooter"
+ :footer-create-label-title="footerCreateLabelTitle"
+ :footer-manage-label-title="footerManageLabelTitle"
+ @toggleDropdownContentsCreateView="toggleDropdownContent"
+ />
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
new file mode 100644
index 00000000000..aa1184ed314
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
@@ -0,0 +1,200 @@
+<script>
+import {
+ GlAlert,
+ GlTooltipDirective,
+ GlButton,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import produce from 'immer';
+import { createAlert } from '~/flash';
+import { __ } from '~/locale';
+import { workspaceLabelsQueries } from '../../../constants';
+import createLabelMutation from './graphql/create_label.mutation.graphql';
+import { LabelType } from './constants';
+
+const errorMessage = __('Error creating label.');
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: true,
+ },
+ labelCreateType: {
+ type: String,
+ required: true,
+ },
+ workspaceType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ labelTitle: '',
+ selectedColor: '',
+ labelCreateInProgress: false,
+ error: undefined,
+ };
+ },
+ computed: {
+ disableCreate() {
+ return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress;
+ },
+ suggestedColors() {
+ const colorsMap = gon.suggested_label_colors;
+ return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
+ },
+ mutationVariables() {
+ const attributePath = this.labelCreateType === LabelType.group ? 'groupPath' : 'projectPath';
+
+ return {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ [attributePath]: this.attrWorkspacePath,
+ };
+ },
+ },
+ methods: {
+ getColorCode(color) {
+ return Object.keys(color).pop();
+ },
+ getColorName(color) {
+ return Object.values(color).pop();
+ },
+ handleColorClick(color) {
+ this.selectedColor = this.getColorCode(color);
+ },
+ updateLabelsInCache(store, label) {
+ const { query } = workspaceLabelsQueries[this.workspaceType];
+
+ const sourceData = store.readQuery({
+ query,
+ variables: { fullPath: this.fullPath, searchTerm: '' },
+ });
+
+ const collator = new Intl.Collator('en');
+ const data = produce(sourceData, (draftData) => {
+ const { nodes } = draftData.workspace.labels;
+ nodes.push(label);
+ nodes.sort((a, b) => collator.compare(a.title, b.title));
+ });
+
+ store.writeQuery({
+ query,
+ variables: { fullPath: this.fullPath, searchTerm: '' },
+ data,
+ });
+ },
+ async createLabel() {
+ this.labelCreateInProgress = true;
+ try {
+ const {
+ data: { labelCreate },
+ } = await this.$apollo.mutate({
+ mutation: createLabelMutation,
+ variables: this.mutationVariables,
+ update: (
+ store,
+ {
+ data: {
+ labelCreate: { label },
+ },
+ },
+ ) => {
+ if (label) {
+ this.updateLabelsInCache(store, label);
+ }
+ },
+ });
+ if (labelCreate.errors.length) {
+ [this.error] = labelCreate.errors;
+ } else {
+ this.$emit('hideCreateView');
+ }
+ } catch {
+ createAlert({ message: errorMessage });
+ }
+ this.labelCreateInProgress = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="labels-select-contents-create js-labels-create">
+ <div class="dropdown-input">
+ <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mt-3">
+ {{ error }}
+ </gl-alert>
+ <gl-form-input
+ v-model.trim="labelTitle"
+ class="gl-mt-3"
+ :placeholder="__('Name new label')"
+ :autofocus="true"
+ data-testid="label-title-input"
+ />
+ </div>
+ <div class="dropdown-content gl-px-3">
+ <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3! gl-mb-0">
+ <gl-link
+ v-for="(color, index) in suggestedColors"
+ :key="index"
+ v-gl-tooltip:tooltipcontainer
+ :style="{ backgroundColor: getColorCode(color) }"
+ :title="getColorName(color)"
+ @click.prevent="handleColorClick(color)"
+ />
+ </div>
+ <div class="color-input-container gl-display-flex">
+ <span
+ class="dropdown-label-color-preview gl-relative gl-display-inline-block"
+ data-testid="selected-color"
+ :style="{ backgroundColor: selectedColor }"
+ ></span>
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
+ :placeholder="__('Use custom color #FF0000')"
+ data-testid="selected-color-text"
+ />
+ </div>
+ </div>
+ <div class="dropdown-actions gl-display-flex gl-justify-content-space-between gl-pt-3 gl-px-3">
+ <gl-button
+ :disabled="disableCreate"
+ category="primary"
+ variant="confirm"
+ class="gl-display-flex gl-align-items-center"
+ data-testid="create-button"
+ @click="createLabel"
+ >
+ <gl-loading-icon v-if="labelCreateInProgress" size="sm" :inline="true" class="mr-1" />
+ {{ __('Create') }}
+ </gl-button>
+ <gl-button
+ class="js-btn-cancel-create"
+ data-testid="cancel-button"
+ @click.stop="$emit('hideCreateView')"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
new file mode 100644
index 00000000000..c1939dc7785
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
@@ -0,0 +1,177 @@
+<script>
+import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { createAlert } from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { __ } from '~/locale';
+import { workspaceLabelsQueries } from '../../../constants';
+import LabelItem from './label_item.vue';
+
+export default {
+ components: {
+ GlDropdownForm,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlIntersectionObserver,
+ LabelItem,
+ },
+ model: {
+ prop: 'localSelectedLabels',
+ },
+ props: {
+ allowMultiselect: {
+ type: Boolean,
+ required: true,
+ },
+ localSelectedLabels: {
+ type: Array,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ searchKey: {
+ type: String,
+ required: true,
+ },
+ workspaceType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ labels: [],
+ isVisible: false,
+ };
+ },
+ apollo: {
+ labels: {
+ query() {
+ return workspaceLabelsQueries[this.workspaceType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ searchTerm: this.searchKey,
+ };
+ },
+ skip() {
+ return this.searchKey.length === 1 || !this.isVisible;
+ },
+ update: (data) => data.workspace?.labels?.nodes || [],
+ error() {
+ createAlert({ message: __('Error fetching labels.') });
+ },
+ },
+ },
+ computed: {
+ labelsFetchInProgress() {
+ return this.$apollo.queries.labels.loading;
+ },
+ localSelectedLabelsIds() {
+ return this.localSelectedLabels.map((label) => getIdFromGraphQLId(label.id));
+ },
+ visibleLabels() {
+ if (this.searchKey) {
+ return fuzzaldrinPlus.filter(this.labels, this.searchKey, {
+ key: ['title'],
+ });
+ }
+ return this.labels;
+ },
+ showNoMatchingResultsMessage() {
+ return Boolean(this.searchKey) && this.visibleLabels.length === 0;
+ },
+ shouldHighlightFirstItem() {
+ return this.searchKey !== '' && this.visibleLabels.length > 0;
+ },
+ },
+ methods: {
+ isLabelSelected(label) {
+ return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id));
+ },
+ /**
+ * This method scrolls item from dropdown into
+ * the view if it is off the viewable area of the
+ * container.
+ */
+ scrollIntoViewIfNeeded() {
+ const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused');
+
+ if (highlightedLabel) {
+ const container = this.$refs.labelsListContainer.getBoundingClientRect();
+ const label = highlightedLabel.getBoundingClientRect();
+
+ if (label.bottom > container.bottom) {
+ this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom;
+ } else if (label.top < container.top) {
+ this.$refs.labelsListContainer.scrollTop -= container.top - label.top;
+ }
+ }
+ },
+ updateSelectedLabels(label) {
+ let labels;
+ if (this.isLabelSelected(label)) {
+ labels = this.localSelectedLabels.filter(
+ ({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id,
+ );
+ } else {
+ labels = [...this.localSelectedLabels, label];
+ }
+ this.$emit('input', labels);
+ },
+ handleLabelClick(label) {
+ this.updateSelectedLabels(label);
+ if (!this.allowMultiselect) {
+ this.$emit('closeDropdown', this.localSelectedLabels);
+ }
+ },
+ onDropdownAppear() {
+ this.isVisible = true;
+ },
+ selectFirstItem() {
+ if (this.shouldHighlightFirstItem) {
+ this.handleLabelClick(this.visibleLabels[0]);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer @appear="onDropdownAppear">
+ <gl-dropdown-form class="labels-select-contents-list js-labels-list">
+ <div ref="labelsListContainer" data-testid="dropdown-content">
+ <gl-loading-icon
+ v-if="labelsFetchInProgress"
+ class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3"
+ size="lg"
+ />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="(label, index) in visibleLabels"
+ :key="label.id"
+ :is-checked="isLabelSelected(label)"
+ is-check-centered
+ is-check-item
+ :active="shouldHighlightFirstItem && index === 0"
+ active-class="is-focused"
+ data-testid="labels-list"
+ @click.native.capture.stop="handleLabelClick(label)"
+ >
+ <label-item :label="label" />
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-show="showNoMatchingResultsMessage"
+ class="gl-p-3 gl-text-center"
+ data-testid="no-results"
+ >
+ {{ __('No matching results') }}
+ </gl-dropdown-item>
+ </template>
+ </div>
+ </gl-dropdown-form>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue
new file mode 100644
index 00000000000..e67e704ffb8
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdownItem,
+ },
+ inject: ['allowLabelCreate', 'labelsManagePath'],
+ props: {
+ footerCreateLabelTitle: {
+ type: String,
+ required: true,
+ },
+ footerManageLabelTitle: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="dropdown-footer">
+ <gl-dropdown-item
+ v-if="allowLabelCreate"
+ data-testid="create-label-button"
+ @click.capture.native.stop="$emit('toggleDropdownContentsCreateView')"
+ >
+ {{ footerCreateLabelTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
+ {{ footerManageLabelTitle }}
+ </gl-dropdown-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue
new file mode 100644
index 00000000000..154a8e866d0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton, GlSearchBoxByType } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ GlSearchBoxByType,
+ },
+ props: {
+ labelsCreateTitle: {
+ type: String,
+ required: true,
+ },
+ labelsListTitle: {
+ type: String,
+ required: true,
+ },
+ showDropdownContentsCreateView: {
+ type: Boolean,
+ required: true,
+ },
+ labelsFetchInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ searchKey: {
+ type: String,
+ required: true,
+ },
+ isStandalone: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ dropdownTitle() {
+ return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
+ },
+ },
+ methods: {
+ focusInput() {
+ this.$refs.searchInput?.focusInput();
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="dropdown-header">
+ <div
+ v-if="!isStandalone"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3! gl-mb-0"
+ data-testid="dropdown-header-title"
+ >
+ <gl-button
+ v-if="showDropdownContentsCreateView"
+ :aria-label="__('Go back')"
+ variant="link"
+ size="small"
+ class="js-btn-back dropdown-header-button gl-p-0"
+ icon="arrow-left"
+ data-testid="go-back-button"
+ @click.stop="$emit('toggleDropdownContentsCreateView')"
+ />
+ <span class="gl-flex-grow-1">{{ dropdownTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ data-testid="close-button"
+ data-qa-selector="close_labels_dropdown_button"
+ @click="$emit('closeDropdown')"
+ />
+ </div>
+ <gl-search-box-by-type
+ v-if="!showDropdownContentsCreateView"
+ ref="searchInput"
+ :value="searchKey"
+ :placeholder="__('Search labels')"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ data-testid="dropdown-input-field"
+ @input="$emit('input', $event)"
+ @keydown.enter="$emit('searchEnter', $event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue
new file mode 100644
index 00000000000..57e3ee4aaa5
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue
@@ -0,0 +1,125 @@
+<script>
+import { GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui';
+import { sortBy } from 'lodash';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ GlLabel,
+ },
+ inject: ['allowScopedLabels'],
+ props: {
+ disableLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectedLabels: {
+ type: Array,
+ required: true,
+ },
+ allowLabelRemove: {
+ type: Boolean,
+ required: true,
+ },
+ labelsFilterBasePath: {
+ type: String,
+ required: true,
+ },
+ labelsFilterParam: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ sortedSelectedLabels() {
+ return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1));
+ },
+ labelsList() {
+ const labelsString = this.selectedLabels.length
+ ? this.selectedLabels
+ .slice(0, 5)
+ .map((label) => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
+
+ if (this.selectedLabels.length > 5) {
+ return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
+ labelsString,
+ remainingLabelCount: this.selectedLabels.length - 5,
+ });
+ }
+
+ return labelsString;
+ },
+ },
+ methods: {
+ labelFilterUrl(label) {
+ return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent(
+ label.title,
+ )}`;
+ },
+ scopedLabel(label) {
+ return this.allowScopedLabels && isScopedLabel(label);
+ },
+ removeLabel(labelId) {
+ this.$emit('onLabelRemove', labelId);
+ },
+ handleCollapsedClick() {
+ this.$emit('onCollapsedValueClick');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'has-labels': selectedLabels.length,
+ }"
+ class="value issuable-show-labels js-value"
+ data-testid="value-wrapper"
+ >
+ <div
+ v-gl-tooltip.left.viewport
+ :title="labelsList"
+ class="sidebar-collapsed-icon"
+ @click="handleCollapsedClick"
+ >
+ <gl-icon name="labels" />
+ <span class="collapse-truncated-title gl-pt-2 gl-px-3 gl-font-sm">{{
+ selectedLabels.length
+ }}</span>
+ </div>
+ <span
+ v-if="!selectedLabels.length"
+ class="text-secondary hide-collapsed"
+ data-testid="empty-placeholder"
+ >
+ <slot></slot>
+ </span>
+ <template v-else>
+ <gl-label
+ v-for="label in sortedSelectedLabels"
+ :key="label.id"
+ class="hide-collapsed"
+ data-qa-selector="selected_label_content"
+ :data-qa-label-name="label.title"
+ :title="label.title"
+ :description="label.description"
+ :background-color="label.color"
+ :target="labelFilterUrl(label)"
+ :scoped="scopedLabel(label)"
+ :show-close-button="allowLabelRemove"
+ :disabled="disableLabels"
+ tooltip-placement="top"
+ @close="removeLabel(label.id)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
new file mode 100644
index 00000000000..3a93fc7f3b2
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlLabel } from '@gitlab/ui';
+import { sortBy } from 'lodash';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ GlLabel,
+ },
+ inject: ['allowScopedLabels'],
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectedLabels: {
+ type: Array,
+ required: true,
+ },
+ allowLabelRemove: {
+ type: Boolean,
+ required: true,
+ },
+ labelsFilterBasePath: {
+ type: String,
+ required: true,
+ },
+ labelsFilterParam: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ sortedSelectedLabels() {
+ return sortBy(this.selectedLabels, (label) => isScopedLabel(label));
+ },
+ },
+ methods: {
+ buildFilterUrl({ title }) {
+ const { labelsFilterBasePath: basePath, labelsFilterParam: filterParam } = this;
+
+ return `${basePath}?${filterParam}[]=${encodeURIComponent(title)}`;
+ },
+ showScopedLabel(label) {
+ return this.allowScopedLabels && isScopedLabel(label);
+ },
+ removeLabel(labelId) {
+ this.$emit('onLabelRemove', labelId);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-label
+ v-for="label in sortedSelectedLabels"
+ :key="label.id"
+ class="gl-mr-2 gl-mb-2"
+ :data-qa-label-name="label.title"
+ :title="label.title"
+ :description="label.description"
+ :background-color="label.color"
+ :target="buildFilterUrl(label)"
+ :scoped="showScopedLabel(label)"
+ :show-close-button="allowLabelRemove"
+ :disabled="disabled"
+ tooltip-placement="top"
+ @close="removeLabel(label.id)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql
new file mode 100644
index 00000000000..a9c791091fc
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql
@@ -0,0 +1,12 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPath: ID) {
+ labelCreate(
+ input: { title: $title, color: $color, projectPath: $projectPath, groupPath: $groupPath }
+ ) {
+ label {
+ ...Label
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql
new file mode 100644
index 00000000000..c442c17eb88
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql
@@ -0,0 +1,15 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+query epicLabels($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ id
+ issuable: epic(iid: $iid) {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql
new file mode 100644
index 00000000000..cb054e2968f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql
@@ -0,0 +1,15 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+mutation updateEpicLabels($input: UpdateEpicInput!) {
+ updateIssuableLabels: updateEpic(input: $input) {
+ issuable: epic {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql
new file mode 100644
index 00000000000..ce1a69f84c0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql
@@ -0,0 +1,12 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+query groupLabels($fullPath: ID!, $searchTerm: String) {
+ workspace: group(fullPath: $fullPath) {
+ id
+ labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) {
+ nodes {
+ ...Label
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql
new file mode 100644
index 00000000000..2904857270e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql
@@ -0,0 +1,15 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+query issueLabels($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: issue(iid: $iid) {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql
new file mode 100644
index 00000000000..e0cdfd91658
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql
@@ -0,0 +1,15 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+query mergeRequestLabels($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: mergeRequest(iid: $iid) {
+ id
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql
new file mode 100644
index 00000000000..a7c24620aad
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql
@@ -0,0 +1,12 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+query projectLabels($fullPath: ID!, $searchTerm: String) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ labels(searchTerm: $searchTerm, includeAncestorGroups: true) {
+ nodes {
+ ...Label
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
new file mode 100644
index 00000000000..314ffbaf84c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
@@ -0,0 +1,21 @@
+<script>
+export default {
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-word-break-word">
+ <span
+ class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3"
+ :style="{ 'background-color': label.color }"
+ data-testid="label-color-box"
+ ></span>
+ <span>{{ label.title }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
new file mode 100644
index 00000000000..b7b4bbac661
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
@@ -0,0 +1,441 @@
+<script>
+import { debounce } from 'lodash';
+import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
+import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/flash';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { IssuableType } from '~/issues/constants';
+
+import { __ } from '~/locale';
+import { issuableLabelsQueries } from '../../../constants';
+import SidebarEditableItem from '../../sidebar_editable_item.vue';
+import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants';
+import DropdownContents from './dropdown_contents.vue';
+import DropdownValue from './dropdown_value.vue';
+import EmbeddedLabelsList from './embedded_labels_list.vue';
+import {
+ isDropdownVariantSidebar,
+ isDropdownVariantStandalone,
+ isDropdownVariantEmbedded,
+} from './utils';
+
+export default {
+ components: {
+ DropdownValue,
+ DropdownContents,
+ EmbeddedLabelsList,
+ SidebarEditableItem,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ allowLabelEdit: {
+ default: false,
+ },
+ },
+ props: {
+ iid: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ allowLabelRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowMultiselect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showEmbeddedLabelsList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: DropdownVariant.Sidebar,
+ },
+ labelsFilterBasePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
+ dropdownButtonText: {
+ type: String,
+ required: false,
+ default: __('Label'),
+ },
+ labelsListTitle: {
+ type: String,
+ required: false,
+ default: __('Assign labels'),
+ },
+ labelsCreateTitle: {
+ type: String,
+ required: false,
+ default: __('Create group label'),
+ },
+ footerCreateLabelTitle: {
+ type: String,
+ required: false,
+ default: __('Create group label'),
+ },
+ footerManageLabelTitle: {
+ type: String,
+ required: false,
+ default: __('Manage group labels'),
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ workspaceType: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: true,
+ },
+ labelCreateType: {
+ type: String,
+ required: true,
+ },
+ selectedLabels: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ contentIsOnViewport: true,
+ issuable: null,
+ labelsSelectInProgress: false,
+ oldIid: null,
+ sidebarExpandedOnClick: false,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.labelsSelectInProgress || this.$apollo.queries.issuable.loading;
+ },
+ issuableLabelIds() {
+ return this.issuableLabels.map((label) => label.id);
+ },
+ issuableLabels() {
+ if (this.iid !== '') {
+ return this.issuable?.labels.nodes || [];
+ }
+
+ return this.selectedLabels || [];
+ },
+ issuableId() {
+ return this.issuable?.id;
+ },
+ isRealtimeEnabled() {
+ return this.glFeatures.realtimeLabels;
+ },
+ isLabelListEnabled() {
+ return this.showEmbeddedLabelsList && isDropdownVariantEmbedded(this.variant);
+ },
+ },
+ apollo: {
+ issuable: {
+ query() {
+ return issuableLabelsQueries[this.issuableType].issuableQuery;
+ },
+ skip() {
+ return !isDropdownVariantSidebar(this.variant);
+ },
+ variables() {
+ return {
+ iid: this.iid,
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable;
+ },
+ error() {
+ createAlert({ message: __('Error fetching labels.') });
+ },
+ subscribeToMore: {
+ document() {
+ return issuableLabelsSubscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return !this.issuableId || !this.isDropdownVariantSidebar;
+ },
+ updateQuery(
+ _,
+ {
+ subscriptionData: {
+ data: { issuableLabelsUpdated },
+ },
+ },
+ ) {
+ if (issuableLabelsUpdated) {
+ const {
+ id,
+ labels: { nodes },
+ } = issuableLabelsUpdated;
+ this.$emit('updateSelectedLabels', { id, labels: nodes });
+ }
+ },
+ },
+ },
+ },
+ watch: {
+ iid(_, oldVal) {
+ this.oldIid = oldVal;
+ },
+ },
+ mounted() {
+ document.addEventListener('toggleSidebarRevealLabelsDropdown', this.handleCollapsedValueClick);
+ },
+ beforeDestroy() {
+ document.removeEventListener(
+ 'toggleSidebarRevealLabelsDropdown',
+ this.handleCollapsedValueClick,
+ );
+ },
+ methods: {
+ handleDropdownClose(labels) {
+ if (this.iid !== '') {
+ this.updateSelectedLabels(this.getUpdateVariables(labels));
+ } else {
+ this.$emit('updateSelectedLabels', { labels });
+ }
+
+ this.collapseEditableItem();
+ },
+ collapseEditableItem() {
+ this.$refs.editable?.collapse();
+ if (this.sidebarExpandedOnClick) {
+ this.sidebarExpandedOnClick = false;
+ this.$emit('toggleCollapse');
+ }
+ },
+ handleCollapsedValueClick() {
+ this.sidebarExpandedOnClick = true;
+ this.$emit('toggleCollapse');
+ debounce(() => {
+ this.$refs.editable.toggle();
+ this.$refs.dropdownContents.showDropdown();
+ }, DEBOUNCE_DROPDOWN_DELAY)();
+ },
+ getUpdateVariables(labels) {
+ let labelIds = [];
+
+ labelIds = labels.map(({ id }) => id);
+ const currentIid = this.oldIid || this.iid;
+
+ const updateVariables = {
+ iid: currentIid,
+ projectPath: this.fullPath,
+ labelIds,
+ };
+
+ switch (this.issuableType) {
+ case IssuableType.Issue:
+ return updateVariables;
+ case IssuableType.MergeRequest:
+ return {
+ ...updateVariables,
+ operationMode: MutationOperationMode.Replace,
+ };
+ case IssuableType.Epic:
+ return {
+ iid: currentIid,
+ groupPath: this.fullPath,
+ addLabelIds: labelIds.map((id) => getIdFromGraphQLId(id)),
+ removeLabelIds: this.issuableLabelIds
+ .filter((id) => !labelIds.includes(id))
+ .map((id) => getIdFromGraphQLId(id)),
+ };
+ default:
+ return {};
+ }
+ },
+ updateSelectedLabels(inputVariables) {
+ this.labelsSelectInProgress = true;
+
+ this.$apollo
+ .mutate({
+ mutation: issuableLabelsQueries[this.issuableType].mutation,
+ variables: { input: inputVariables },
+ })
+ .then(({ data }) => {
+ if (data.updateIssuableLabels?.errors?.length) {
+ throw new Error();
+ }
+
+ this.$emit('updateSelectedLabels', {
+ id: data.updateIssuableLabels?.issuable?.id,
+ labels: data.updateIssuableLabels?.issuable?.labels?.nodes,
+ });
+ })
+ .catch((error) =>
+ createAlert({
+ message: __('An error occurred while updating labels.'),
+ captureError: true,
+ error,
+ }),
+ )
+ .finally(() => {
+ this.labelsSelectInProgress = false;
+ });
+ },
+ getRemoveVariables(labelId) {
+ const removeVariables = {
+ iid: this.iid,
+ projectPath: this.fullPath,
+ };
+
+ switch (this.issuableType) {
+ case IssuableType.Issue:
+ return {
+ ...removeVariables,
+ removeLabelIds: [labelId],
+ };
+ case IssuableType.MergeRequest:
+ return {
+ ...removeVariables,
+ labelIds: [labelId],
+ operationMode: MutationOperationMode.Remove,
+ };
+ case IssuableType.Epic:
+ return {
+ iid: this.iid,
+ removeLabelIds: [getIdFromGraphQLId(labelId)],
+ groupPath: this.fullPath,
+ };
+ default:
+ return {};
+ }
+ },
+ handleLabelRemove(labelId) {
+ if (this.iid !== '') {
+ this.updateSelectedLabels(this.getRemoveVariables(labelId));
+ }
+
+ this.$emit('onLabelRemove', labelId);
+ },
+ isDropdownVariantSidebar,
+ isDropdownVariantStandalone,
+ isDropdownVariantEmbedded,
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-wrapper gl-relative"
+ :class="{
+ 'is-standalone': isDropdownVariantStandalone(variant),
+ 'is-embedded': isDropdownVariantEmbedded(variant),
+ }"
+ data-testid="sidebar-labels"
+ data-qa-selector="labels_block"
+ >
+ <template v-if="isDropdownVariantSidebar(variant)">
+ <sidebar-editable-item
+ ref="editable"
+ :title="__('Labels')"
+ :loading="isLoading"
+ :can-edit="allowLabelEdit"
+ @open="oldIid = null"
+ >
+ <template #collapsed>
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ :selected-labels="issuableLabels"
+ :allow-label-remove="allowLabelRemove"
+ :labels-filter-base-path="labelsFilterBasePath"
+ :labels-filter-param="labelsFilterParam"
+ @onLabelRemove="handleLabelRemove"
+ @onCollapsedValueClick="handleCollapsedValueClick"
+ >
+ <slot></slot>
+ </dropdown-value>
+ </template>
+ <template #default="{ edit }">
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ :selected-labels="issuableLabels"
+ :allow-label-remove="allowLabelRemove"
+ :labels-filter-base-path="labelsFilterBasePath"
+ :labels-filter-param="labelsFilterParam"
+ class="gl-mb-2"
+ @onLabelRemove="handleLabelRemove"
+ >
+ <slot></slot>
+ </dropdown-value>
+ <dropdown-contents
+ ref="dropdownContents"
+ :dropdown-button-text="dropdownButtonText"
+ :allow-multiselect="allowMultiselect"
+ :labels-list-title="labelsListTitle"
+ :footer-create-label-title="footerCreateLabelTitle"
+ :footer-manage-label-title="footerManageLabelTitle"
+ :labels-create-title="labelsCreateTitle"
+ :selected-labels="issuableLabels"
+ :variant="variant"
+ :is-visible="edit"
+ :full-path="fullPath"
+ :workspace-type="workspaceType"
+ :attr-workspace-path="attrWorkspacePath"
+ :label-create-type="labelCreateType"
+ @setLabels="handleDropdownClose"
+ @closeDropdown="collapseEditableItem"
+ />
+ </template>
+ </sidebar-editable-item>
+ </template>
+ <template v-else>
+ <dropdown-contents
+ ref="dropdownContents"
+ :allow-multiselect="allowMultiselect"
+ :dropdown-button-text="dropdownButtonText"
+ :labels-list-title="labelsListTitle"
+ :footer-create-label-title="footerCreateLabelTitle"
+ :footer-manage-label-title="footerManageLabelTitle"
+ :labels-create-title="labelsCreateTitle"
+ :selected-labels="issuableLabels"
+ :variant="variant"
+ :full-path="fullPath"
+ :workspace-type="workspaceType"
+ :attr-workspace-path="attrWorkspacePath"
+ :label-create-type="labelCreateType"
+ @setLabels="handleDropdownClose"
+ />
+ <embedded-labels-list
+ v-if="isLabelListEnabled"
+ :disabled="labelsSelectInProgress"
+ :selected-labels="issuableLabels"
+ :allow-label-remove="allowLabelRemove"
+ :labels-filter-base-path="labelsFilterBasePath"
+ :labels-filter-param="labelsFilterParam"
+ @onLabelRemove="handleLabelRemove"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
new file mode 100644
index 00000000000..b5cd946a189
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
@@ -0,0 +1,22 @@
+import { DropdownVariant } from './constants';
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {string} variant
+ */
+export const isDropdownVariantSidebar = (variant) => variant === DropdownVariant.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `standalone`
+ * @param {string} variant
+ */
+export const isDropdownVariantStandalone = (variant) => variant === DropdownVariant.Standalone;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {string} variant
+ */
+export const isDropdownVariantEmbedded = (variant) => variant === DropdownVariant.Embedded;
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 d32d8a7b044..cdce6617591 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -4,8 +4,8 @@ import { mapGetters, mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { createAlert } from '~/flash';
-import eventHub from '~/sidebar/event_hub';
import toast from '~/vue_shared/plugins/global_toast';
+import eventHub from '../../event_hub';
import EditForm from './edit_form.vue';
export default {
@@ -111,9 +111,9 @@ export default {
</script>
<template>
- <li v-if="isMergeRequest" class="gl-new-dropdown-item">
+ <li v-if="isMergeRequest" class="gl-dropdown-item">
<button type="button" class="dropdown-item" @click="toggleLocked">
- <span class="gl-new-dropdown-item-text-wrapper">
+ <span class="gl-dropdown-item-text-wrapper">
<template v-if="isLocked">
{{ __('Unlock merge request') }}
</template>
diff --git a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
new file mode 100644
index 00000000000..02323e5a0c6
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
@@ -0,0 +1,217 @@
+<script>
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlButton,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ projectsFetchPath: {
+ type: String,
+ required: true,
+ },
+ dropdownButtonTitle: {
+ type: String,
+ required: true,
+ },
+ dropdownHeaderTitle: {
+ type: String,
+ required: true,
+ },
+ moveInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ projectsListLoading: false,
+ projectsListLoadFailed: false,
+ searchKey: '',
+ projects: [],
+ selectedProject: null,
+ projectItemClick: false,
+ };
+ },
+ computed: {
+ hasNoSearchResults() {
+ return Boolean(
+ !this.projectsListLoading &&
+ !this.projectsListLoadFailed &&
+ this.searchKey &&
+ !this.projects.length,
+ );
+ },
+ failedToLoadResults() {
+ return !this.projectsListLoading && this.projectsListLoadFailed;
+ },
+ },
+ watch: {
+ searchKey(value = '') {
+ this.fetchProjects(value);
+ },
+ },
+ methods: {
+ fetchProjects(search = '') {
+ this.projectsListLoading = true;
+ this.projectsListLoadFailed = false;
+ return axios
+ .get(this.projectsFetchPath, {
+ params: {
+ search,
+ },
+ })
+ .then(({ data }) => {
+ this.projects = data;
+ this.$refs.searchInput.focusInput();
+ })
+ .catch(() => {
+ this.projectsListLoadFailed = true;
+ })
+ .finally(() => {
+ this.projectsListLoading = false;
+ });
+ },
+ isSelectedProject(project) {
+ if (this.selectedProject) {
+ return this.selectedProject.id === project.id;
+ }
+ return false;
+ },
+ /**
+ * This handler is to prevent dropdown
+ * from closing when an item is selected
+ * and emit an event only when dropdown closes.
+ */
+ handleDropdownHide(e) {
+ if (this.projectItemClick) {
+ e.preventDefault();
+ this.projectItemClick = false;
+ } else {
+ this.$emit('dropdown-close');
+ }
+ },
+ handleDropdownCloseClick() {
+ this.$refs.dropdown.hide();
+ },
+ handleProjectSelect(project) {
+ this.selectedProject = project.id === this.selectedProject?.id ? null : project;
+ this.projectItemClick = true;
+ },
+ handleMoveClick() {
+ this.$refs.dropdown.hide();
+ this.$emit('move-issuable', this.selectedProject);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown">
+ <div
+ v-gl-tooltip.left.viewport
+ data-testid="move-collapsed"
+ :title="dropdownButtonTitle"
+ class="sidebar-collapsed-icon"
+ @click="$emit('toggle-collapse')"
+ >
+ <gl-icon name="arrow-right" />
+ </div>
+ <gl-dropdown
+ ref="dropdown"
+ :block="true"
+ :disabled="moveInProgress || disabled"
+ class="hide-collapsed"
+ toggle-class="js-sidebar-dropdown-toggle"
+ @shown="fetchProjects"
+ @hide="handleDropdownHide"
+ >
+ <template #button-content
+ ><gl-loading-icon v-if="moveInProgress" size="sm" class="gl-mr-3" />{{
+ dropdownButtonTitle
+ }}</template
+ >
+ <gl-dropdown-form class="gl-pt-0">
+ <div
+ data-testid="header"
+ class="gl-display-flex gl-pb-3 gl-border-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <span class="gl-flex-grow-1 gl-text-center gl-font-weight-bold gl-py-1">{{
+ dropdownHeaderTitle
+ }}</span>
+ <gl-button
+ variant="link"
+ icon="close"
+ class="gl-mr-2 gl-w-auto! gl-p-2!"
+ :aria-label="__('Close')"
+ @click.prevent="handleDropdownCloseClick"
+ />
+ </div>
+ <gl-search-box-by-type
+ ref="searchInput"
+ v-model.trim="searchKey"
+ :placeholder="__('Search project')"
+ :debounce="300"
+ />
+ <div data-testid="content" class="dropdown-content">
+ <gl-loading-icon v-if="projectsListLoading" size="lg" class="gl-p-5" />
+ <ul v-else>
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="project.id"
+ is-check-item
+ :is-checked="isSelectedProject(project)"
+ @click.stop.prevent="handleProjectSelect(project)"
+ >{{ project.name_with_namespace }}</gl-dropdown-item
+ >
+ </ul>
+ <div v-if="hasNoSearchResults" class="gl-text-center gl-p-3">
+ {{ __('No matching results') }}
+ </div>
+ <div v-if="failedToLoadResults" class="gl-text-center gl-p-3">
+ {{ __('Failed to load projects') }}
+ </div>
+ </div>
+ <div
+ data-testid="footer"
+ class="gl-pt-3 gl-px-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
+ >
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :disabled="!Boolean(selectedProject)"
+ class="gl-text-center! issuable-move-button"
+ @click="handleMoveClick"
+ >{{ __('Move') }}</gl-button
+ >
+ </div>
+ </gl-dropdown-form>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/move/move_issues_button.vue b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
new file mode 100644
index 00000000000..ab4ac9500ad
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
@@ -0,0 +1,171 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { logError } from '~/lib/logger';
+import { s__ } from '~/locale';
+import {
+ WORK_ITEM_TYPE_ENUM_ISSUE,
+ WORK_ITEM_TYPE_ENUM_INCIDENT,
+ WORK_ITEM_TYPE_ENUM_TASK,
+ WORK_ITEM_TYPE_ENUM_TEST_CASE,
+} from '~/work_items/constants';
+import issuableEventHub from '~/issues/list/eventhub';
+import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+import getIssuesCountQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
+import moveIssueMutation from '../../queries/move_issue.mutation.graphql';
+import IssuableMoveDropdown from './issuable_move_dropdown.vue';
+
+export default {
+ name: 'MoveIssuesButton',
+ components: {
+ IssuableMoveDropdown,
+ GlAlert,
+ },
+ props: {
+ projectFullPath: {
+ type: String,
+ required: true,
+ },
+ projectsFetchPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedIssuables: [],
+ moveInProgress: false,
+ };
+ },
+ computed: {
+ cannotMoveTasksWarningTitle() {
+ if (this.tasksSelected && this.testCasesSelected) {
+ return s__('Issues|Tasks and test cases can not be moved.');
+ }
+
+ if (this.testCasesSelected) {
+ return s__('Issues|Test cases can not be moved.');
+ }
+
+ return s__('Issues|Tasks can not be moved.');
+ },
+ issuesSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_ISSUE);
+ },
+ incidentsSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_INCIDENT);
+ },
+ tasksSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_TASK);
+ },
+ testCasesSelected() {
+ return this.selectedIssuables.some((item) => item.type === WORK_ITEM_TYPE_ENUM_TEST_CASE);
+ },
+ },
+ mounted() {
+ issuableEventHub.$on('issuables:issuableChecked', this.handleIssuableChecked);
+ },
+ beforeDestroy() {
+ issuableEventHub.$off('issuables:issuableChecked', this.handleIssuableChecked);
+ },
+ methods: {
+ handleIssuableChecked(issuable, value) {
+ if (value) {
+ this.selectedIssuables.push(issuable);
+ } else {
+ const index = this.selectedIssuables.indexOf(issuable);
+ if (index > -1) {
+ this.selectedIssuables.splice(index, 1);
+ }
+ }
+ },
+ moveIssues(targetProject) {
+ const iids = this.selectedIssuables.reduce((result, issueData) => {
+ if (
+ issueData.type === WORK_ITEM_TYPE_ENUM_ISSUE ||
+ issueData.type === WORK_ITEM_TYPE_ENUM_INCIDENT
+ ) {
+ result.push(issueData.iid);
+ }
+ return result;
+ }, []);
+
+ if (iids.length === 0) {
+ return;
+ }
+
+ this.moveInProgress = true;
+ issuableEventHub.$emit('issuables:bulkMoveStarted');
+
+ const promises = iids.map((id) => {
+ return this.moveIssue(id, targetProject);
+ });
+
+ Promise.all(promises)
+ .then((promisesResult) => {
+ let foundError = false;
+
+ for (const promiseResult of promisesResult) {
+ if (promiseResult.data.issueMove?.errors?.length) {
+ foundError = true;
+ logError(
+ `Error moving issue. Error message: ${promiseResult.data.issueMove.errors[0].message}`,
+ );
+ }
+ }
+
+ if (!foundError) {
+ const client = this.$apollo.provider.defaultClient;
+ client.refetchQueries({
+ include: [getIssuesQuery, getIssuesCountQuery],
+ });
+ this.moveInProgress = false;
+ this.selectedIssuables = [];
+ issuableEventHub.$emit('issuables:bulkMoveEnded');
+ } else {
+ throw new Error();
+ }
+ })
+ .catch(() => {
+ this.moveInProgress = false;
+ issuableEventHub.$emit('issuables:bulkMoveEnded');
+
+ createAlert({
+ message: s__(`Issues|There was an error while moving the issues.`),
+ });
+ });
+ },
+ moveIssue(issueIid, targetProject) {
+ return this.$apollo.mutate({
+ mutation: moveIssueMutation,
+ variables: {
+ moveIssueInput: {
+ projectPath: this.projectFullPath,
+ iid: issueIid,
+ targetProjectPath: targetProject.full_path,
+ },
+ },
+ });
+ },
+ },
+ i18n: {
+ dropdownButtonTitle: s__('Issues|Move selected'),
+ },
+};
+</script>
+<template>
+ <div>
+ <issuable-move-dropdown
+ :project-full-path="projectFullPath"
+ :projects-fetch-path="projectsFetchPath"
+ :move-in-progress="moveInProgress"
+ :disabled="!issuesSelected && !incidentsSelected"
+ :dropdown-header-title="$options.i18n.dropdownButtonTitle"
+ :dropdown-button-title="$options.i18n.dropdownButtonTitle"
+ @move-issuable="moveIssues"
+ />
+ <gl-alert v-if="tasksSelected || testCasesSelected" :dismissible="false" variant="warning">
+ {{ cannotMoveTasksWarningTitle }}
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
index 46a04725a49..b0556e22a8d 100644
--- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
+++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
@@ -1,6 +1,6 @@
<script>
import { __ } from '~/locale';
-import { participantsQueries } from '~/sidebar/constants';
+import { participantsQueries } from '../../constants';
import Participants from './participants.vue';
export default {
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index 5e1172ad835..7af8dcb4e3e 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -58,11 +58,21 @@ export default {
<collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" />
<div class="value hide-collapsed">
- <template v-if="hasNoUsers">
- <span class="no-value">
- {{ __('None') }}
- </span>
- </template>
+ <span v-if="hasNoUsers" class="no-value" data-testid="no-value">
+ {{ __('None') }}
+ <template v-if="editable">
+ -
+ <button
+ type="button"
+ class="gl-button btn-link gl-reset-color!"
+ data-testid="assign-yourself"
+ data-qa-selector="assign_yourself_button"
+ @click="assignSelf"
+ >
+ {{ __('assign yourself') }}
+ </button>
+ </template>
+ </span>
<uncollapsed-reviewer-list
v-else
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index 5f1350690eb..faa36f3d8d2 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -5,12 +5,12 @@ import Vue from 'vue';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
-import eventHub from '~/sidebar/event_hub';
-import Store from '~/sidebar/stores/sidebar_store';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql';
-import mergeRequestReviewersUpdatedSubscription from '~/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import eventHub from '../../event_hub';
+import getMergeRequestReviewersQuery from '../../queries/get_merge_request_reviewers.query.graphql';
+import mergeRequestReviewersUpdatedSubscription from '../../queries/merge_request_reviewers.subscription.graphql';
+import Store from '../../stores/sidebar_store';
import ReviewerTitle from './reviewer_title.vue';
import Reviewers from './reviewers.vue';
@@ -143,6 +143,13 @@ export default {
eventHub.$off('sidebar.saveReviewers', this.saveReviewers);
},
methods: {
+ reviewBySelf() {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
+
+ this.mediator.addSelfReview();
+ this.saveReviewers();
+ },
saveReviewers() {
this.loading = true;
@@ -181,6 +188,7 @@ export default {
:editable="canUpdate"
:issuable-type="issuableType"
@request-review="requestReview"
+ @assign-self="reviewBySelf"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/severity/constants.js b/app/assets/javascripts/sidebar/components/severity/constants.js
deleted file mode 100644
index 4f58ff38121..00000000000
--- a/app/assets/javascripts/sidebar/components/severity/constants.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { __, s__ } from '~/locale';
-
-export const INCIDENT_SEVERITY = {
- CRITICAL: {
- value: 'CRITICAL',
- icon: 'critical',
- label: s__('IncidentManagement|Critical - S1'),
- },
- HIGH: {
- value: 'HIGH',
- icon: 'high',
- label: s__('IncidentManagement|High - S2'),
- },
- MEDIUM: {
- value: 'MEDIUM',
- icon: 'medium',
- label: s__('IncidentManagement|Medium - S3'),
- },
- LOW: {
- value: 'LOW',
- icon: 'low',
- label: s__('IncidentManagement|Low - S4'),
- },
- UNKNOWN: {
- value: 'UNKNOWN',
- icon: 'unknown',
- label: s__('IncidentManagement|Unknown'),
- },
-};
-
-export const ISSUABLE_TYPES = {
- INCIDENT: 'incident',
-};
-
-export const I18N = {
- UPDATE_SEVERITY_ERROR: s__('SeverityWidget|There was an error while updating severity.'),
- TRY_AGAIN: __('Please try again'),
- EDIT: __('Edit'),
- SEVERITY: s__('SeverityWidget|Severity'),
- SEVERITY_VALUE: s__('SeverityWidget|Severity: %{severity}'),
-};
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index f02e0c783e1..5b624c17b0c 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -8,8 +8,8 @@ import {
GlButton,
} from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants';
-import updateIssuableSeverity from './graphql/mutations/update_issuable_severity.mutation.graphql';
+import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql';
+import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants';
import SeverityToken from './severity.vue';
export default {
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index a685929cdea..35667495ace 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -6,7 +6,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
-import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
dropdowni18nText,
@@ -17,6 +16,7 @@ import {
Tracking,
} from 'ee_else_ce/sidebar/constants';
import SidebarDropdown from './sidebar_dropdown.vue';
+import SidebarEditableItem from './sidebar_editable_item.vue';
export default {
i18n: {
diff --git a/app/assets/javascripts/sidebar/components/status/status_dropdown.vue b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue
new file mode 100644
index 00000000000..7763ec00091
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { statusDropdownOptions } from '../../constants';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ data() {
+ return {
+ status: null,
+ };
+ },
+ computed: {
+ dropdownText() {
+ return this.status?.text ?? this.$options.i18n.defaultDropdownText;
+ },
+ selectedValue() {
+ return this.status?.value;
+ },
+ },
+ methods: {
+ onDropdownItemClick(statusOption) {
+ // clear status if the currently checked status is clicked again
+ if (this.status?.value === statusOption.value) {
+ this.status = null;
+ } else {
+ this.status = statusOption;
+ }
+ },
+ },
+ i18n: {
+ dropdownTitle: __('Change status'),
+ defaultDropdownText: __('Select status'),
+ },
+ statusDropdownOptions,
+};
+</script>
+<template>
+ <div>
+ <input type="hidden" name="update[state_event]" :value="selectedValue" />
+ <gl-dropdown :text="dropdownText" :title="$options.i18n.dropdownTitle" class="gl-w-full">
+ <gl-dropdown-item
+ v-for="statusOption in $options.statusDropdownOptions"
+ :key="statusOption.value"
+ :is-checked="selectedValue === statusOption.value"
+ is-check-item
+ :title="statusOption.text"
+ @click="onDropdownItemClick(statusOption)"
+ >
+ {{ statusOption.text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index 99e7c825b72..0fba1cb5e4e 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -4,10 +4,10 @@ import { createAlert } from '~/flash';
import { IssuableType } from '~/issues/constants';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
-import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import toast from '~/vue_shared/plugins/global_toast';
-import { subscribedQueries, Tracking } from '~/sidebar/constants';
+import { subscribedQueries, Tracking } from '../../constants';
+import SidebarEditableItem from '../sidebar_editable_item.vue';
const ICON_ON = 'notifications';
const ICON_OFF = 'notifications-off';
@@ -182,7 +182,7 @@ export default {
</script>
<template>
- <gl-dropdown-form v-if="isMergeRequest" class="gl-new-dropdown-item">
+ <gl-dropdown-form v-if="isMergeRequest" class="gl-dropdown-item">
<div class="gl-px-5 gl-pb-2 gl-pt-1">
<gl-toggle
:value="subscribed"
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue
new file mode 100644
index 00000000000..4c3ba76d12d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { subscriptionsDropdownOptions } from '../../constants';
+
+export default {
+ subscriptionsDropdownOptions,
+ i18n: {
+ defaultDropdownText: __('Select subscription'),
+ headerText: __('Change subscription'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ data() {
+ return {
+ subscription: undefined,
+ };
+ },
+ computed: {
+ dropdownText() {
+ return this.subscription?.text ?? this.$options.i18n.defaultDropdownText;
+ },
+ selectedValue() {
+ return this.subscription?.value;
+ },
+ },
+ methods: {
+ handleClick(option) {
+ this.subscription = option.value === this.subscription?.value ? undefined : option;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <input type="hidden" name="update[subscription_event]" :value="selectedValue" />
+ <gl-dropdown class="gl-w-full" :header-text="$options.i18n.headerText" :text="dropdownText">
+ <gl-dropdown-item
+ v-for="subscriptionsOption in $options.subscriptionsDropdownOptions"
+ :key="subscriptionsOption.value"
+ is-check-item
+ :is-checked="selectedValue === subscriptionsOption.value"
+ @click="handleClick(subscriptionsOption)"
+ >
+ {{ subscriptionsOption.text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/constants.js b/app/assets/javascripts/sidebar/components/time_tracking/constants.js
new file mode 100644
index 00000000000..56e986e3b27
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/constants.js
@@ -0,0 +1 @@
+export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
new file mode 100644
index 00000000000..ec8e1ee9952
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
@@ -0,0 +1,227 @@
+<script>
+import {
+ GlFormGroup,
+ GlFormInput,
+ GlDatepicker,
+ GlFormTextarea,
+ GlModal,
+ GlAlert,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+import createTimelogMutation from '../../queries/create_timelog.mutation.graphql';
+import { CREATE_TIMELOG_MODAL_ID } from './constants';
+
+export default {
+ components: {
+ GlDatepicker,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlModal,
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['issuableType'],
+ props: {
+ issuableId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ timeSpent: '',
+ spentAt: null,
+ summary: '',
+ isLoading: false,
+ saveError: '',
+ };
+ },
+ computed: {
+ submitDisabled() {
+ return this.isLoading || this.timeSpent.length === 0;
+ },
+ primaryProps() {
+ return {
+ text: s__('CreateTimelogForm|Save'),
+ attributes: [
+ {
+ variant: 'confirm',
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
+ },
+ ],
+ };
+ },
+ cancelProps() {
+ return {
+ text: s__('CreateTimelogForm|Cancel'),
+ };
+ },
+ timeTrackingDocsPath() {
+ return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md');
+ },
+ issuableTypeName() {
+ return this.isIssue()
+ ? s__('CreateTimelogForm|issue')
+ : s__('CreateTimelogForm|merge request');
+ },
+ },
+ methods: {
+ resetModal() {
+ this.isLoading = false;
+ this.timeSpent = '';
+ this.spentAt = null;
+ this.summary = '';
+ this.saveError = '';
+ },
+ close() {
+ this.resetModal();
+ this.$refs.modal.close();
+ },
+ registerTimeSpent(event) {
+ event.preventDefault();
+
+ if (this.timeSpent.length === 0) {
+ return;
+ }
+
+ this.isLoading = true;
+ this.saveError = '';
+
+ this.$apollo
+ .mutate({
+ mutation: createTimelogMutation,
+ variables: {
+ input: {
+ timeSpent: this.timeSpent,
+ spentAt: this.spentAt
+ ? formatDate(this.spentAt, 'isoDateTime')
+ : formatDate(Date.now(), 'isoDateTime'),
+ summary: this.summary,
+ issuableId: this.getIssuableId(),
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.timelogCreate?.errors.length) {
+ this.saveError = data.timelogCreate.errors[0].message || data.timelogCreate.errors[0];
+ } else {
+ this.close();
+ }
+ })
+ .catch((error) => {
+ this.saveError =
+ error?.message ||
+ s__('CreateTimelogForm|An error occurred while saving the time entry.');
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ isIssue() {
+ return this.issuableType === 'issue';
+ },
+ getGraphQLEntityType() {
+ return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST;
+ },
+ updateSpentAtDate(val) {
+ this.spentAt = val;
+ },
+ getIssuableId() {
+ return convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId);
+ },
+ },
+ CREATE_TIMELOG_MODAL_ID,
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :title="s__('CreateTimelogForm|Add time entry')"
+ :modal-id="$options.CREATE_TIMELOG_MODAL_ID"
+ size="sm"
+ data-testid="create-timelog-modal"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="registerTimeSpent"
+ @cancel="close"
+ @close="close"
+ @hide="close"
+ >
+ <p data-testid="timetracking-docs-link">
+ <gl-sprintf
+ :message="
+ s__(
+ 'CreateTimelogForm|Track time spent on this %{issuableTypeNameStart}%{issuableTypeNameEnd}. %{timeTrackingDocsLinkStart}%{timeTrackingDocsLinkEnd}',
+ )
+ "
+ >
+ <template #issuableTypeName>{{ issuableTypeName }}</template>
+ <template #timeTrackingDocsLink>
+ <gl-link :href="timeTrackingDocsPath" target="_blank">{{
+ s__('CreateTimelogForm|How do I track and estimate time?')
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <form
+ class="gl-display-flex gl-flex-direction-column js-quick-submit"
+ @submit.prevent="registerTimeSpent"
+ >
+ <div class="gl-display-flex gl-gap-3">
+ <gl-form-group
+ key="time-spent"
+ label-for="time-spent"
+ :label="s__(`CreateTimelogForm|Time spent`)"
+ :description="s__(`CreateTimelogForm|Example: 1h 30m`)"
+ >
+ <gl-form-input
+ id="time-spent"
+ ref="timeSpent"
+ v-model="timeSpent"
+ class="gl-form-input-sm"
+ autocomplete="off"
+ />
+ </gl-form-group>
+ <gl-form-group
+ key="spent-at"
+ optional
+ label-for="spent-at"
+ :label="s__(`CreateTimelogForm|Spent at`)"
+ >
+ <gl-datepicker
+ :target="null"
+ :value="spentAt"
+ show-clear-button
+ autocomplete="off"
+ size="small"
+ @input="updateSpentAtDate"
+ @clear="updateSpentAtDate(null)"
+ />
+ </gl-form-group>
+ </div>
+ <gl-form-group
+ :label="s__('CreateTimelogForm|Summary')"
+ optional
+ label-for="summary"
+ class="gl-mb-0"
+ >
+ <gl-form-textarea id="summary" v-model="summary" rows="3" :no-resize="true" />
+ </gl-form-group>
+ <gl-alert v-if="saveError" variant="danger" class="gl-mt-5" :dismissible="false">
+ {{ saveError }}
+ </gl-alert>
+ <!-- This is needed to have the quick-submit behaviour (with Ctrl + Enter or Cmd + Enter) -->
+ <input type="submit" hidden />
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index 91c15061fb9..6cd9596e43f 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -1,5 +1,6 @@
<script>
-import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
import { joinPaths } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
@@ -9,7 +10,7 @@ export default {
GlButton,
},
directives: {
- SafeHtml: GlSafeHtmlDirective,
+ SafeHtml,
},
computed: {
href() {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index 124464088cf..6f4ced06ddf 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -5,8 +5,8 @@ import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import { __, s__ } from '~/locale';
-import { timelogQueries } from '~/sidebar/constants';
-import deleteTimelogMutation from './graphql/mutations/delete_timelog.mutation.graphql';
+import { timelogQueries } from '../../constants';
+import deleteTimelogMutation from '../../queries/delete_timelog.mutation.graphql';
const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
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 62b05421884..06adc048942 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
@@ -30,6 +30,11 @@ export default {
required: false,
default: false,
},
+ canAddTimeEntries: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
mounted() {
this.listenForQuickActions();
@@ -67,6 +72,7 @@ export default {
:issuable-id="issuableId"
:issuable-iid="issuableIid"
:limit-to-hours="limitToHours"
+ :can-add-time-entries="canAddTimeEntries"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index 13981c477c6..b32836dc87d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -9,15 +9,17 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, __ } from '~/locale';
-import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '~/sidebar/constants';
+import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '../../constants';
import eventHub from '../../event_hub';
import TimeTrackingCollapsedState from './collapsed_state.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue';
-import TimeTrackingHelpState from './help_state.vue';
import TimeTrackingReport from './report.vue';
import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
+import { CREATE_TIMELOG_MODAL_ID } from './constants';
+import CreateTimelogForm from './create_timelog_form.vue';
export default {
name: 'IssuableTimeTracker',
@@ -34,8 +36,8 @@ export default {
TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane,
TimeTrackingComparisonPane,
- TimeTrackingHelpState,
TimeTrackingReport,
+ CreateTimelogForm,
},
directives: {
GlModal: GlModalDirective,
@@ -87,6 +89,11 @@ export default {
default: true,
required: false,
},
+ canAddTimeEntries: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -192,12 +199,12 @@ export default {
eventHub.$on('timeTracker:refresh', this.refresh);
},
methods: {
- toggleHelpState(show) {
- this.showHelp = show;
- },
refresh() {
this.$apollo.queries.issuableTimeTracking.refetch();
},
+ openRegisterTimeSpentModal() {
+ this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID);
+ },
},
};
</script>
@@ -215,24 +222,21 @@ export default {
:time-estimate-human-readable="humanTimeEstimate"
/>
<div
- class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center gl-font-weight-bold gl-mr-3"
+ class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center gl-font-weight-bold"
>
{{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline />
<gl-button
- :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'"
+ v-if="canAddTimeEntries"
+ v-gl-tooltip.left
category="tertiary"
size="small"
- variant="link"
class="gl-ml-auto"
- @click="toggleHelpState(!showHelpState)"
+ data-testid="add-time-entry-button"
+ :title="__('Add time entry')"
+ @click="openRegisterTimeSpentModal()"
>
- <gl-icon
- v-gl-tooltip.left
- :title="timeTrackingIconTitle"
- :name="timeTrackingIconName"
- class="gl-text-gray-900!"
- />
+ <gl-icon name="plus" class="gl-text-gray-900!" />
</gl-button>
</div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
@@ -272,9 +276,7 @@ export default {
<time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" />
</gl-modal>
</template>
- <transition name="help-state-toggle">
- <time-tracking-help-state v-if="showHelpState" />
- </transition>
+ <create-timelog-form :issuable-id="issuableId" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index 5da2d65723a..b86ff279fd8 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -3,11 +3,11 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { produce } from 'immer';
import { createAlert } from '~/flash';
import { __, sprintf } from '~/locale';
-import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants';
-import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils';
-import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
+import { todoQueries, TodoMutationTypes, todoMutations } from '../../constants';
+import { todoLabel } from '../../utils';
+import TodoButton from './todo_button.vue';
const trackingMixin = Tracking.mixin();
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue
new file mode 100644
index 00000000000..b49b8fc389b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { todoLabel, updateGlobalTodoCount } from '../../utils';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ isTodo: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ computed: {
+ buttonLabel() {
+ return todoLabel(this.isTodo);
+ },
+ },
+ methods: {
+ incrementGlobalTodoCount() {
+ updateGlobalTodoCount(1);
+ },
+ decrementGlobalTodoCount() {
+ updateGlobalTodoCount(-1);
+ },
+ onToggle(event) {
+ if (this.isTodo) {
+ this.decrementGlobalTodoCount();
+ } else {
+ this.incrementGlobalTodoCount();
+ }
+ this.$emit('click', event);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="onToggle($event)">
+ {{ buttonLabel }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
new file mode 100644
index 00000000000..6dacf4e10d3
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'ToggleSidebar',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ collapsed: {
+ type: Boolean,
+ required: true,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ tooltipLabel() {
+ return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar');
+ },
+ buttonIcon() {
+ return this.collapsed ? 'chevron-double-lg-left' : 'chevron-double-lg-right';
+ },
+ allCssClasses() {
+ return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }];
+ },
+ },
+ methods: {
+ toggle() {
+ this.$emit('toggle');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip:body.viewport.left
+ :title="tooltipLabel"
+ :class="allCssClasses"
+ class="gutter-toggle btn-sidebar-action js-sidebar-vue-toggle"
+ :icon="buttonIcon"
+ category="tertiary"
+ size="small"
+ :aria-label="__('toggle collapse')"
+ @click="toggle"
+ />
+</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 67b9b540e91..825a89daf58 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -4,55 +4,55 @@ import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutatio
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
import { IssuableType, WorkspaceType } from '~/issues/constants';
-import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
-import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
-import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql';
-import epicReferenceQuery from '~/sidebar/queries/epic_reference.query.graphql';
-import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql';
-import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql';
-import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql';
-import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
-import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
-import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
-import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
-import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
-import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql';
-import issueTodoQuery from '~/sidebar/queries/issue_todo.query.graphql';
-import mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql';
-import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
-import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
-import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql';
-import mergeRequestTodoQuery from '~/sidebar/queries/merge_request_todo.query.graphql';
-import todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql';
-import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql';
-import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
-import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql';
-import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql';
-import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
-import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
-import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
-import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql';
-import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
-import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
-import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
-import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql';
-import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
-import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql';
-import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
-import mergeRequestLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql';
-import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
-import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql';
-import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
-import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
-import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql';
-import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql';
-import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
-import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
-import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
-import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
-import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql';
-import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql';
+import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql';
+import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
+import groupLabelsQuery from './components/labels/labels_select_widget/graphql/group_labels.query.graphql';
+import issueLabelsQuery from './components/labels/labels_select_widget/graphql/issue_labels.query.graphql';
+import mergeRequestLabelsQuery from './components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql';
+import projectLabelsQuery from './components/labels/labels_select_widget/graphql/project_labels.query.graphql';
+import epicConfidentialQuery from './queries/epic_confidential.query.graphql';
+import epicDueDateQuery from './queries/epic_due_date.query.graphql';
+import epicParticipantsQuery from './queries/epic_participants.query.graphql';
+import epicReferenceQuery from './queries/epic_reference.query.graphql';
+import epicStartDateQuery from './queries/epic_start_date.query.graphql';
+import epicSubscribedQuery from './queries/epic_subscribed.query.graphql';
+import epicTodoQuery from './queries/epic_todo.query.graphql';
+import issuableAssigneesSubscription from './queries/issuable_assignees.subscription.graphql';
+import issueConfidentialQuery from './queries/issue_confidential.query.graphql';
+import issueDueDateQuery from './queries/issue_due_date.query.graphql';
+import issueReferenceQuery from './queries/issue_reference.query.graphql';
+import issueSubscribedQuery from './queries/issue_subscribed.query.graphql';
+import issueTimeTrackingQuery from './queries/issue_time_tracking.query.graphql';
+import issueTodoQuery from './queries/issue_todo.query.graphql';
+import mergeRequestMilestone from './queries/merge_request_milestone.query.graphql';
+import mergeRequestReferenceQuery from './queries/merge_request_reference.query.graphql';
+import mergeRequestSubscribed from './queries/merge_request_subscribed.query.graphql';
+import mergeRequestTimeTrackingQuery from './queries/merge_request_time_tracking.query.graphql';
+import mergeRequestTodoQuery from './queries/merge_request_todo.query.graphql';
+import todoCreateMutation from './queries/todo_create.mutation.graphql';
+import todoMarkDoneMutation from './queries/todo_mark_done.mutation.graphql';
+import updateEpicConfidentialMutation from './queries/update_epic_confidential.mutation.graphql';
+import updateEpicDueDateMutation from './queries/update_epic_due_date.mutation.graphql';
+import updateEpicStartDateMutation from './queries/update_epic_start_date.mutation.graphql';
+import updateEpicSubscriptionMutation from './queries/update_epic_subscription.mutation.graphql';
+import updateIssueConfidentialMutation from './queries/update_issue_confidential.mutation.graphql';
+import updateIssueDueDateMutation from './queries/update_issue_due_date.mutation.graphql';
+import updateIssueSubscriptionMutation from './queries/update_issue_subscription.mutation.graphql';
+import mergeRequestMilestoneMutation from './queries/update_merge_request_milestone.mutation.graphql';
+import updateMergeRequestLabelsMutation from './queries/update_merge_request_labels.mutation.graphql';
+import updateMergeRequestSubscriptionMutation from './queries/update_merge_request_subscription.mutation.graphql';
+import getAlertAssignees from './queries/get_alert_assignees.query.graphql';
+import getIssueAssignees from './queries/get_issue_assignees.query.graphql';
+import issueParticipantsQuery from './queries/get_issue_participants.query.graphql';
+import getIssueTimelogsQuery from './queries/get_issue_timelogs.query.graphql';
+import getMergeRequestAssignees from './queries/get_mr_assignees.query.graphql';
+import getMergeRequestParticipants from './queries/get_mr_participants.query.graphql';
+import getMrTimelogsQuery from './queries/get_mr_timelogs.query.graphql';
+import updateIssueAssigneesMutation from './queries/update_issue_assignees.mutation.graphql';
+import updateMergeRequestAssigneesMutation from './queries/update_mr_assignees.mutation.graphql';
+import getEscalationStatusQuery from './queries/escalation_status.query.graphql';
+import updateEscalationStatusMutation from './queries/update_escalation_status.mutation.graphql';
import groupMilestonesQuery from './queries/group_milestones.query.graphql';
import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql';
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
@@ -350,3 +350,94 @@ export const escalationStatusQuery = getEscalationStatusQuery;
export const escalationStatusMutation = updateEscalationStatusMutation;
export const HOW_TO_TRACK_TIME = __('How to track time');
+
+export const statusDropdownOptions = [
+ {
+ text: __('Open'),
+ value: 'reopen',
+ },
+ {
+ text: __('Closed'),
+ value: 'close',
+ },
+];
+
+export const subscriptionsDropdownOptions = [
+ {
+ text: __('Subscribe'),
+ value: 'subscribe',
+ },
+ {
+ text: __('Unsubscribe'),
+ value: 'unsubscribe',
+ },
+];
+
+export const INCIDENT_SEVERITY = {
+ CRITICAL: {
+ value: 'CRITICAL',
+ icon: 'critical',
+ label: s__('IncidentManagement|Critical - S1'),
+ },
+ HIGH: {
+ value: 'HIGH',
+ icon: 'high',
+ label: s__('IncidentManagement|High - S2'),
+ },
+ MEDIUM: {
+ value: 'MEDIUM',
+ icon: 'medium',
+ label: s__('IncidentManagement|Medium - S3'),
+ },
+ LOW: {
+ value: 'LOW',
+ icon: 'low',
+ label: s__('IncidentManagement|Low - S4'),
+ },
+ UNKNOWN: {
+ value: 'UNKNOWN',
+ icon: 'unknown',
+ label: s__('IncidentManagement|Unknown'),
+ },
+};
+
+export const ISSUABLE_TYPES = {
+ INCIDENT: 'incident',
+};
+
+export const MILESTONE_STATE = {
+ ACTIVE: 'active',
+ CLOSED: 'closed',
+};
+
+export const SEVERITY_I18N = {
+ UPDATE_SEVERITY_ERROR: s__('SeverityWidget|There was an error while updating severity.'),
+ TRY_AGAIN: __('Please try again'),
+ EDIT: __('Edit'),
+ SEVERITY: s__('SeverityWidget|Severity'),
+ SEVERITY_VALUE: s__('SeverityWidget|Severity: %{severity}'),
+};
+
+export const STATUS_TRIGGERED = 'TRIGGERED';
+export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED';
+export const STATUS_RESOLVED = 'RESOLVED';
+
+export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered');
+export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged');
+export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved');
+
+export const STATUS_LABELS = {
+ [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL,
+ [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL,
+ [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL,
+};
+
+export const INCIDENTS_I18N = {
+ fetchError: s__(
+ 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.',
+ ),
+ title: s__('IncidentManagement|Status'),
+ updateError: s__(
+ 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.',
+ ),
+};
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index afce59d304f..b908cf0cd9e 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -39,6 +39,7 @@ export default class SidebarMilestone {
humanTimeEstimate,
humanTotalTimeSpent: humanTimeSpent,
},
+ canAddTimeEntries: false,
},
}),
});
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index b37486283ca..a308dc8d13c 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -6,6 +6,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issues/constants';
+import { gqlClient } from '~/issues/list/graphql';
import {
isInIssuePage,
isInDesignPage,
@@ -14,33 +15,36 @@ import {
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
-import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
-import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
-import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
-import MilestoneDropdown from '~/sidebar/components/milestone/milestone_dropdown.vue';
-import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
-import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
-import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
-import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import { apolloProvider } from '~/graphql_shared/issuable_client';
-import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
-import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
-import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
-import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
-import Translate from '../vue_shared/translate';
+import Translate from '~/vue_shared/translate';
+import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
-import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
+import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue';
+import SidebarConfidentialityWidget from './components/confidential/sidebar_confidentiality_widget.vue';
+import CopyEmailToClipboard from './components/copy/copy_email_to_clipboard.vue';
+import SidebarDueDateWidget from './components/date/sidebar_date_widget.vue';
import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
+import { DropdownVariant } from './components/labels/labels_select_vue/constants';
+import { LabelType } from './components/labels/labels_select_widget/constants';
+import LabelsSelectWidget from './components/labels/labels_select_widget/labels_select_root.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
+import MilestoneDropdown from './components/milestone/milestone_dropdown.vue';
+import MoveIssuesButton from './components/move/move_issues_button.vue';
+import SidebarParticipantsWidget from './components/participants/sidebar_participants_widget.vue';
+import SidebarReferenceWidget from './components/copy/sidebar_reference_widget.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
+import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue';
+import StatusDropdown from './components/status/status_dropdown.vue';
import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue';
+import SubscriptionsDropdown from './components/subscriptions/subscriptions_dropdown.vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
+import SidebarTodoWidget from './components/todo_toggle/sidebar_todo_widget.vue';
import { IssuableAttributeType } from './constants';
-import SidebarMoveIssue from './lib/sidebar_move_issue';
import CrmContacts from './components/crm_contacts/crm_contacts.vue';
+import SidebarMoveIssue from './lib/sidebar_move_issue';
+import trackShowInviteMemberLink from './track_invite_members';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -540,7 +544,15 @@ function mountSidebarSubscriptionsWidget() {
function mountSidebarTimeTracking() {
const el = document.querySelector('.js-sidebar-time-tracking-root');
- const { id, iid, fullPath, issuableType, timeTrackingLimitToHours } = getSidebarOptions();
+
+ const {
+ id,
+ iid,
+ fullPath,
+ issuableType,
+ timeTrackingLimitToHours,
+ canCreateTimelogs,
+ } = getSidebarOptions();
if (!el) {
return null;
@@ -558,6 +570,7 @@ function mountSidebarTimeTracking() {
issuableId: id.toString(),
issuableIid: iid.toString(),
limitToHours: timeTrackingLimitToHours,
+ canAddTimeEntries: canCreateTimelogs,
},
}),
});
@@ -635,6 +648,59 @@ function mountCopyEmailToClipboard() {
});
}
+export function mountMoveIssuesButton() {
+ const el = document.querySelector('.js-move-issues');
+
+ if (!el) {
+ return null;
+ }
+
+ Vue.use(VueApollo);
+
+ return new Vue({
+ el,
+ name: 'MoveIssuesRoot',
+ apolloProvider: new VueApollo({
+ defaultClient: gqlClient,
+ }),
+ render: (createElement) =>
+ createElement(MoveIssuesButton, {
+ props: {
+ projectFullPath: el.dataset.projectFullPath,
+ projectsFetchPath: el.dataset.projectsFetchPath,
+ },
+ }),
+ });
+}
+
+export function mountStatusDropdown() {
+ const el = document.querySelector('.js-status-dropdown');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'StatusDropdownRoot',
+ render: (createElement) => createElement(StatusDropdown),
+ });
+}
+
+export function mountSubscriptionsDropdown() {
+ const el = document.querySelector('.js-subscriptions-dropdown');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'SubscriptionsDropdownRoot',
+ render: (createElement) => createElement(SubscriptionsDropdown),
+ });
+}
+
const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
diff --git a/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql
new file mode 100644
index 00000000000..a8692387a46
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql"
+#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql"
+
+mutation createTimelog($input: TimelogCreateInput!) {
+ timelogCreate(input: $input) {
+ errors
+ timelog {
+ id
+ issue {
+ ...IssueTimeTrackingFragment
+ }
+ mergeRequest {
+ ...MergeRequestTimeTrackingFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/queries/delete_timelog.mutation.graphql
index 6e916893b5a..6e916893b5a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/delete_timelog.mutation.graphql
diff --git a/app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql
new file mode 100644
index 00000000000..171eca50eab
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql
@@ -0,0 +1,22 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query alertAssignees(
+ $domain: AlertManagementDomainFilter = threat_monitoring
+ $fullPath: ID!
+ $iid: String!
+) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: alertManagementAlert(domain: $domain, iid: $iid) {
+ id
+ iid
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql
new file mode 100644
index 00000000000..4af07366a6d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql
@@ -0,0 +1,21 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query issueAssignees($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: issue(iid: $iid) {
+ id
+ author {
+ ...User
+ ...UserAvailability
+ }
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_crm_contacts.query.graphql
index 30a0af10d56..30a0af10d56 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/get_issue_crm_contacts.query.graphql
diff --git a/app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql
new file mode 100644
index 00000000000..eae5e96ac46
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query issueParticipants($fullPath: ID!, $iid: String!, $getStatus: Boolean = false) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: issue(iid: $iid) {
+ id
+ participants {
+ nodes {
+ ...User
+ ...UserAvailability @include(if: $getStatus)
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql
new file mode 100644
index 00000000000..b127b8ec5a9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+query issueTimeTrackingReport($id: IssueID!) {
+ issuable: issue(id: $id) {
+ id
+ title
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql
new file mode 100644
index 00000000000..f087ca6c982
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql
@@ -0,0 +1,26 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query mergeRequestReviewers($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: mergeRequest(iid: $iid) {
+ id
+ reviewers {
+ nodes {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ canUpdate
+ approved
+ reviewed
+ }
+ }
+ }
+ userPermissions {
+ adminMergeRequest
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql
new file mode 100644
index 00000000000..f70cd723f2e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql
@@ -0,0 +1,30 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query getMrAssignees($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: mergeRequest(iid: $iid) {
+ id
+ author {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
+ }
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
+ }
+ }
+ userPermissions {
+ canMerge
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql
new file mode 100644
index 00000000000..2781ac71f31
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query getMrParticipants($fullPath: ID!, $iid: String!, $getStatus: Boolean = false) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: mergeRequest(iid: $iid) {
+ id
+ participants {
+ nodes {
+ ...User
+ ...UserAvailability @include(if: $getStatus)
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql
new file mode 100644
index 00000000000..17f548b44b5
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+query mrTimeTrackingReport($id: MergeRequestID!) {
+ issuable: mergeRequest(id: $id) {
+ id
+ title
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.fragment.graphql
index 750e1f1d1af..750e1f1d1af 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.fragment.graphql
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.subscription.graphql
index f3b6e4ec06f..f3b6e4ec06f 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.subscription.graphql
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql
new file mode 100644
index 00000000000..a1b16b378b3
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql
@@ -0,0 +1,22 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+subscription mergeRequestReviewersUpdated($issuableId: IssuableID!) {
+ mergeRequestReviewersUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ reviewers {
+ nodes {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ canUpdate
+ approved
+ reviewed
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql
new file mode 100644
index 00000000000..d350072425b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql
@@ -0,0 +1,5 @@
+mutation moveIssue($moveIssueInput: IssueMoveInput!) {
+ issueMove(input: $moveIssueInput) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issuable_severity.mutation.graphql
index c9d36dfdb67..c9d36dfdb67 100644
--- a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_issuable_severity.mutation.graphql
diff --git a/app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql
new file mode 100644
index 00000000000..24de5ea4fe3
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -0,0 +1,18 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
+ issuableSetAssignees: issueSetAssignees(
+ input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
+ ) {
+ issuable: issue {
+ id
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_lock.mutation.graphql
index cb9ee6abc9b..cb9ee6abc9b 100644
--- a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_issue_lock.mutation.graphql
diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_lock.mutation.graphql
index 11eb3611006..11eb3611006 100644
--- a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_lock.mutation.graphql
diff --git a/app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql
new file mode 100644
index 00000000000..5fec2ccbdfb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql
@@ -0,0 +1,21 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
+ issuableSetAssignees: mergeRequestSetAssignees(
+ input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
+ ) {
+ issuable: mergeRequest {
+ id
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 912f0fdcbef..c6a66ab2275 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,9 +1,9 @@
-import Store from '~/sidebar/stores/sidebar_store';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
-import { visitUrl } from '../lib/utils/url_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
import Service from './services/sidebar_service';
+import Store from './stores/sidebar_store';
export default class SidebarMediator {
constructor(options) {
@@ -31,6 +31,9 @@ export default class SidebarMediator {
assignYourself() {
this.store.addAssignee(this.store.currentUser);
}
+ addSelfReview() {
+ this.store.addReviewer(this.store.currentUser);
+ }
async saveAssignees(field) {
const selected = this.store.assignees.map((u) => u.id);
@@ -56,12 +59,14 @@ export default class SidebarMediator {
}
async saveReviewers(field) {
- const selected = this.store.reviewers.map((u) => u.id);
+ const selectedReviewers = this.store.reviewers;
+ const selectedIds = selectedReviewers.map((u) => u.id);
+ const suggestedSelectedIds = selectedReviewers.filter((u) => u.suggested).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 };
+ const reviewers = selectedIds.length === 0 ? [0] : selectedIds;
+ const data = { reviewer_ids: reviewers, suggested_reviewer_ids: suggestedSelectedIds };
try {
const res = await this.service.update(field, data);
diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js
new file mode 100644
index 00000000000..6b90fb80abf
--- /dev/null
+++ b/app/assets/javascripts/sidebar/utils.js
@@ -0,0 +1,24 @@
+import { __, s__ } from '~/locale';
+import { STATUS_LABELS } from './constants';
+
+export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None');
+
+export const todoLabel = (hasTodo) => {
+ return hasTodo ? __('Mark as done') : __('Add a to do');
+};
+
+export const updateGlobalTodoCount = (additionalTodoCount) => {
+ const countContainer = document.querySelector('.js-todos-count');
+
+ if (countContainer === null) return;
+
+ const currentCount = parseInt(countContainer.innerText, 10);
+
+ const todoToggleEvent = new CustomEvent('todo:toggle', {
+ detail: {
+ count: Math.max(currentCount + additionalTodoCount, 0),
+ },
+ });
+
+ document.dispatchEvent(todoToggleEvent);
+};