diff options
Diffstat (limited to 'app/assets/javascripts/sidebar/components')
77 files changed, 3926 insertions, 223 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/crm_contacts/queries/get_issue_crm_contacts.query.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql deleted file mode 100644 index 30a0af10d56..00000000000 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql +++ /dev/null @@ -1,7 +0,0 @@ -#import "./issue_crm_contacts.fragment.graphql" - -query issueCrmContacts($id: IssueID!) { - issue(id: $id) { - ...CrmContacts - } -} diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql deleted file mode 100644 index 750e1f1d1af..00000000000 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql +++ /dev/null @@ -1,17 +0,0 @@ -fragment CrmContacts on Issue { - id - customerRelationsContacts { - nodes { - id - firstName - lastName - email - phone - description - organization { - id - name - } - } - } -} diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql deleted file mode 100644 index f3b6e4ec06f..00000000000 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql +++ /dev/null @@ -1,9 +0,0 @@ -#import "./issue_crm_contacts.fragment.graphql" - -subscription issueCrmContactsUpdated($id: IssuableID!) { - issueCrmContactsUpdated(issuableId: $id) { - ... on Issue { - ...CrmContacts - } - } -} 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/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql deleted file mode 100644 index cb9ee6abc9b..00000000000 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation updateIssueLocked($input: IssueSetLockedInput!) { - issueSetLocked(input: $input) { - issue { - id - discussionLocked - } - errors - } -} diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql deleted file mode 100644 index 11eb3611006..00000000000 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation updateMergeRequestLocked($input: MergeRequestSetLockedInput!) { - mergeRequestSetLocked(input: $input) { - mergeRequest { - id - discussionLocked - } - errors - } -} 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/graphql/mutations/update_issuable_severity.mutation.graphql b/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql deleted file mode 100644 index c9d36dfdb67..00000000000 --- a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql +++ /dev/null @@ -1,10 +0,0 @@ -mutation updateIssuableSeverity($projectPath: ID!, $severity: IssuableSeverity!, $iid: String!) { - issueSetSeverity(input: { iid: $iid, severity: $severity, projectPath: $projectPath }) { - errors - issue { - iid - id - 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/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql deleted file mode 100644 index 6e916893b5a..00000000000 --- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql +++ /dev/null @@ -1,17 +0,0 @@ -#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql" -#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql" - -mutation deleteTimelog($input: TimelogDeleteInput!) { - timelogDelete(input: $input) { - errors - timelog { - id - issue { - ...IssueTimeTrackingFragment - } - mergeRequest { - ...MergeRequestTimeTrackingFragment - } - } - } -} 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> |