diff options
Diffstat (limited to 'app/assets/javascripts/work_items/components')
25 files changed, 654 insertions, 245 deletions
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue index 1ead16c944b..425679c1400 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue @@ -1,13 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Tracking from '~/tracking'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; export default { components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, LocalStorageSync, }, mixins: [Tracking.mixin()], @@ -25,7 +24,7 @@ export default { type: String, required: true, }, - filterOptions: { + items: { type: Array, required: true, }, @@ -63,8 +62,7 @@ export default { }, selectedSortOption() { return ( - this.filterOptions.find(({ key }) => this.sortFilterProp === key) || - this.defaultSortFilterProp + this.items.find(({ key }) => this.sortFilterProp === key) || this.defaultSortFilterProp ); }, }, @@ -94,23 +92,14 @@ export default { as-string @input="setDiscussionFilterOption" /> - <gl-dropdown - class="gl-xs-w-full" - size="small" - :text="getDropdownSelectedText" + <gl-collapsible-listbox + :toggle-text="getDropdownSelectedText" :disabled="loading" - right - > - <gl-dropdown-item - v-for="{ text, key, testid } in filterOptions" - :key="text" - :data-testid="testid" - is-check-item - :is-checked="isSortDropdownItemActive(key)" - @click="fetchFilteredDiscussions(key)" - > - {{ text }} - </gl-dropdown-item> - </gl-dropdown> + :items="items" + :selected="sortFilterProp" + placement="right" + size="small" + @select="fetchFilteredDiscussions" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index 66ad3d50287..57faed61280 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -74,6 +74,11 @@ export default { required: false, default: false, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -263,6 +268,7 @@ export default { :work-item-id="workItemId" :autofocus="autofocus" :comment-button-text="commentButtonText" + :is-work-item-confidential="isWorkItemConfidential" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" @error="$emit('error', $event)" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index b143c529014..a79169bde1e 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -3,11 +3,13 @@ import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; -import { STATE_OPEN, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { STATE_OPEN, TRACKING_CATEGORY_SHOW, TASK_TYPE_NAME } from '~/work_items/constants'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; +import CommentFieldLayout from '~/notes/components/comment_field_layout.vue'; export default { i18n: { @@ -22,6 +24,7 @@ export default { markdownDocsPath: helpPagePath('user/markdown'), }, components: { + CommentFieldLayout, GlButton, MarkdownEditor, GlFormCheckbox, @@ -89,6 +92,11 @@ export default { required: false, default: false, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -119,6 +127,23 @@ export default { commentButtonTextComputed() { return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText; }, + workItemDocPath() { + return this.workItemType === TASK_TYPE_NAME ? 'user/tasks.html' : 'user/okrs.html'; + }, + workItemDocAnchor() { + return this.workItemType === TASK_TYPE_NAME ? 'confidential-tasks' : 'confidential-okrs'; + }, + getWorkItemData() { + return { + confidential: this.isWorkItemConfidential, + confidential_issues_docs_path: helpPagePath(this.workItemDocPath, { + anchor: this.workItemDocAnchor, + }), + }; + }, + workItemTypeKey() { + return capitalizeFirstCharacter(this.workItemType).replace(' ', ''); + }, }, methods: { setCommentText(newText) { @@ -158,66 +183,73 @@ export default { <template> <div class="timeline-discussion-body gl-overflow-visible!"> <div class="note-body gl-p-0! gl-overflow-visible!"> - <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> - <markdown-editor - :value="commentText" - :render-markdown-path="markdownPreviewPath" - :markdown-docs-path="$options.constantOptions.markdownDocsPath" - :autocomplete-data-sources="autocompleteDataSources" - :form-field-props="formFieldProps" - :add-spacing-classes="false" - data-testid="work-item-add-comment" - class="gl-mb-5" - use-bottom-toolbar - supports-quick-actions - :autofocus="autofocus" - @input="setCommentText" - @keydown.meta.enter="$emit('submitForm', { commentText, isNoteInternal })" - @keydown.ctrl.enter="$emit('submitForm', { commentText, isNoteInternal })" - @keydown.esc.stop="cancelEditing" - /> - <gl-form-checkbox - v-if="isNewDiscussion" - v-model="isNoteInternal" - class="gl-mb-2" - data-testid="internal-note-checkbox" + <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1 new-note"> + <comment-field-layout + :with-alert-container="isWorkItemConfidential" + :noteable-data="getWorkItemData" + :noteable-type="workItemTypeKey" > - {{ $options.i18n.internal }} - <gl-icon - v-gl-tooltip:tooltipcontainer.bottom - name="question-o" - :size="16" - :title="$options.i18n.internalVisibility" - class="gl-text-blue-500" + <markdown-editor + :value="commentText" + :render-markdown-path="markdownPreviewPath" + :markdown-docs-path="$options.constantOptions.markdownDocsPath" + :autocomplete-data-sources="autocompleteDataSources" + :form-field-props="formFieldProps" + :add-spacing-classes="false" + data-testid="work-item-add-comment" + use-bottom-toolbar + supports-quick-actions + :autofocus="autofocus" + @input="setCommentText" + @keydown.meta.enter="$emit('submitForm', { commentText, isNoteInternal })" + @keydown.ctrl.enter="$emit('submitForm', { commentText, isNoteInternal })" + @keydown.esc.stop="cancelEditing" + /> + </comment-field-layout> + <div class="note-form-actions"> + <gl-form-checkbox + v-if="isNewDiscussion" + v-model="isNoteInternal" + class="gl-mb-2" + data-testid="internal-note-checkbox" + > + {{ $options.i18n.internal }} + <gl-icon + v-gl-tooltip:tooltipcontainer.bottom + name="question-o" + :size="16" + :title="$options.i18n.internalVisibility" + class="gl-text-blue-500" + /> + </gl-form-checkbox> + <gl-button + category="primary" + variant="confirm" + data-testid="confirm-button" + :disabled="!commentText.length" + :loading="isSubmitting" + @click="$emit('submitForm', { commentText, isNoteInternal })" + >{{ commentButtonTextComputed }} + </gl-button> + <work-item-state-toggle-button + v-if="isNewDiscussion" + class="gl-ml-3" + :work-item-id="workItemId" + :work-item-state="workItemState" + :work-item-type="workItemType" + can-update + @error="$emit('error', $event)" /> - </gl-form-checkbox> - <gl-button - category="primary" - variant="confirm" - data-testid="confirm-button" - :disabled="!commentText.length" - :loading="isSubmitting" - @click="$emit('submitForm', { commentText, isNoteInternal })" - >{{ commentButtonTextComputed }} - </gl-button> - <work-item-state-toggle-button - v-if="isNewDiscussion" - class="gl-ml-3" - :work-item-id="workItemId" - :work-item-state="workItemState" - :work-item-type="workItemType" - can-update - @error="$emit('error', $event)" - /> - <gl-button - v-else - data-testid="cancel-button" - category="primary" - class="gl-ml-3" - :loading="updateInProgress" - @click="cancelEditing" - >{{ $options.i18n.cancelButtonText }} - </gl-button> + <gl-button + v-else + data-testid="cancel-button" + category="primary" + class="gl-ml-3" + :loading="updateInProgress" + @click="cancelEditing" + >{{ $options.i18n.cancelButtonText }} + </gl-button> + </div> </form> </div> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index f030363664f..fd8842aa01a 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -65,6 +65,11 @@ export default { required: false, default: false, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -235,6 +240,7 @@ export default { :autocomplete-data-sources="autocompleteDataSources" :markdown-preview-path="markdownPreviewPath" :is-internal-thread="note.internal" + :is-work-item-confidential="isWorkItemConfidential" @startReplying="showReplyForm" @cancelEditing="hideReplyForm" @replied="onReplied" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 92560f2da9e..b5e3ea68725 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -163,6 +163,9 @@ export default { projectName() { return this.workItem?.project?.name; }, + isWorkItemConfidential() { + return this.workItem?.confidential; + }, }, apollo: { workItem: { @@ -314,6 +317,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :work-item-id="workItemId" :autofocus="isEditing" + :is-work-item-confidential="isWorkItemConfidential" class="gl-pl-3 gl-mt-3" @cancelEditing="isEditing = false" @submitForm="updateNote" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue index 0c1419e983f..1578c78ac4f 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue @@ -64,7 +64,7 @@ export default { :work-item-type="workItemType" :loading="disableActivityFilterSort" :sort-filter-prop="discussionFilter" - :filter-options="$options.WORK_ITEM_ACTIVITY_FILTER_OPTIONS" + :items="$options.WORK_ITEM_ACTIVITY_FILTER_OPTIONS" :storage-key="$options.WORK_ITEM_NOTES_FILTER_KEY" :default-sort-filter-prop="$options.WORK_ITEM_NOTES_FILTER_ALL_NOTES" tracking-action="work_item_notes_filter_changed" @@ -77,7 +77,7 @@ export default { :work-item-type="workItemType" :loading="disableActivityFilterSort" :sort-filter-prop="sortOrder" - :filter-options="$options.WORK_ITEM_ACTIVITY_SORT_OPTIONS" + :items="$options.WORK_ITEM_ACTIVITY_SORT_OPTIONS" :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY" :default-sort-filter-prop="$options.ASC" tracking-action="work_item_notes_sort_order_changed" diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue index 0a38dcb77f6..f50cfac90f7 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue @@ -43,15 +43,6 @@ export default { type: Boolean, required: true, }, - parentWorkItemId: { - type: String, - required: true, - }, - workItemType: { - type: String, - required: false, - default: '', - }, childPath: { type: String, required: true, @@ -158,7 +149,7 @@ export default { </span> <gl-link :href="childPath" - class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" + class="gl-text-truncate gl-font-weight-semibold" data-testid="item-title" @click="$emit('click', $event)" @mouseover="$emit('mouseover')" diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue index ddeac2b92ae..38d8d239a7e 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue @@ -42,7 +42,8 @@ export default { assigneesContainerClass() { if (this.assignees.length === 2) { return 'fixed-width-avatars-2'; - } else if (this.assignees.length > 2) { + } + if (this.assignees.length > 2) { return 'fixed-width-avatars-3'; } return ''; diff --git a/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue index 53e8eedf060..12b7bade31d 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue @@ -1,29 +1,28 @@ <script> -import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; export default { components: { - GlDropdownItem, - GlDropdown, - GlIcon, + GlDisclosureDropdownItem, + GlDisclosureDropdown, }, }; </script> <template> <div class="gl-ml-5"> - <gl-dropdown + <gl-disclosure-dropdown category="tertiary" toggle-class="btn-icon btn-sm" - :right="true" + icon="ellipsis_v" data-testid="work_items_links_menu" + :aria-label="__(`More actions`)" + text-sr-only + no-caret > - <template #button-content> - <gl-icon name="ellipsis_v" :size="14" /> - </template> - <gl-dropdown-item @click="$emit('removeChild')"> - {{ s__('WorkItem|Remove') }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-item @action="$emit('removeChild')"> + <template #list-item>{{ s__('WorkItem|Remove') }}</template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </div> </template> diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue new file mode 100644 index 00000000000..7b38e838033 --- /dev/null +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -0,0 +1,145 @@ +<script> +import { GlTokenSelector } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; +import { + WORK_ITEMS_TYPE_MAP, + WORK_ITEM_TYPE_ENUM_TASK, + I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, + sprintfWorkItem, +} from '../../constants'; + +export default { + components: { + GlTokenSelector, + }, + props: { + value: { + type: Array, + required: false, + default: () => [], + }, + fullPath: { + type: String, + required: true, + }, + childrenType: { + type: String, + required: false, + default: WORK_ITEM_TYPE_ENUM_TASK, + }, + childrenIds: { + type: Array, + required: false, + default: () => [], + }, + parentWorkItemId: { + type: String, + required: true, + }, + areWorkItemsToAddValid: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + availableWorkItems: { + query: projectWorkItemsQuery, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.search?.title || this.search, + types: [this.childrenType], + in: this.search ? 'TITLE' : undefined, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace.workItems.nodes.filter( + (wi) => !this.childrenIds.includes(wi.id) && this.parentWorkItemId !== wi.id, + ); + }, + }, + }, + data() { + return { + availableWorkItems: [], + search: '', + searchStarted: false, + }; + }, + computed: { + workItemsToAdd: { + get() { + return this.value; + }, + set(workItemsToAdd) { + this.$emit('input', workItemsToAdd); + }, + }, + isLoading() { + return this.$apollo.queries.availableWorkItems.loading; + }, + addInputPlaceholder() { + return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); + }, + childrenTypeName() { + return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name; + }, + tokenSelectorContainerClass() { + return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + methods: { + getIdFromGraphQLId, + setSearchKey(value) { + this.search = value; + }, + handleFocus() { + this.searchStarted = true; + }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + }, +}; +</script> +<template> + <gl-token-selector + v-model="workItemsToAdd" + :dropdown-items="availableWorkItems" + :loading="isLoading" + :placeholder="addInputPlaceholder" + menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" + :container-class="tokenSelectorContainerClass" + data-testid="work-item-token-select-input" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + > + <template #token-content="{ token }"> + {{ token.title }} + </template> + <template #dropdown-item-content="{ dropdownItem }"> + <div class="gl-display-flex"> + <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> + <div class="gl-text-truncate">{{ dropdownItem.title }}</div> + </div> + </template> + </gl-token-selector> +</template> diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue index f343f787358..27de858fe4e 100644 --- a/app/assets/javascripts/work_items/components/widget_wrapper.vue +++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue @@ -14,6 +14,11 @@ export default { required: false, default: '', }, + widgetName: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -30,6 +35,12 @@ export default { isOpenString() { return this.isOpen ? 'true' : 'false'; }, + anchorLink() { + return `#${this.widgetName}`; + }, + anchorLinkId() { + return `user-content-${this.widgetName}-links`; + }, }, methods: { hide() { @@ -46,14 +57,14 @@ export default { </script> <template> - <div id="tasks" class="gl-new-card" :aria-expanded="isOpenString"> + <div :id="widgetName" class="gl-new-card" :aria-expanded="isOpenString"> <div class="gl-new-card-header"> <div class="gl-new-card-title-wrapper"> <h3 class="gl-new-card-title"> <gl-link - id="user-content-tasks-links" - class="anchor position-absolute gl-text-decoration-none" - href="#tasks" + :id="anchorLinkId" + class="gl-text-decoration-none" + :href="anchorLink" aria-hidden="true" /> <slot name="header"></slot> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index e8fe64c932b..18aa4d55086 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -1,8 +1,7 @@ <script> import { - GlDropdown, - GlDropdownItem, - GlDropdownForm, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlDropdownDivider, GlModal, GlModalDirective, @@ -53,9 +52,8 @@ export default { emailAddressCopied: __('Email address copied'), }, components: { - GlDropdown, - GlDropdownItem, - GlDropdownForm, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlDropdownDivider, GlModal, GlToggle, @@ -180,13 +178,16 @@ export default { navigator.clipboard.writeText(text); } toast(message); + this.closeDropdown(); }, handleToggleWorkItemConfidentiality() { this.track('click_toggle_work_item_confidentiality'); this.$emit('toggleWorkItemConfidentiality', !this.isConfidential); + this.closeDropdown(); }, handleDelete() { this.$refs.modal.show(); + this.closeDropdown(); }, handleDeleteWorkItem() { this.track('click_delete_work_item'); @@ -275,6 +276,9 @@ export default { throwConvertError() { this.$emit('error', this.i18n.convertError); }, + closeDropdown() { + this.$refs.workItemsMoreActions.close(); + }, async promoteToObjective() { try { const { @@ -300,6 +304,8 @@ export default { } catch (error) { this.throwConvertError(); Sentry.captureException(error); + } finally { + this.closeDropdown(); } }, }, @@ -308,77 +314,87 @@ export default { <template> <div> - <gl-dropdown + <gl-disclosure-dropdown + ref="workItemsMoreActions" icon="ellipsis_v" data-testid="work-item-actions-dropdown" text-sr-only :text="__('More actions')" category="tertiary" + :auto-close="false" no-caret right > <template v-if="$options.isLoggedIn"> - <gl-dropdown-form - class="work-item-notifications-form" + <gl-disclosure-dropdown-item + class="gl-display-flex gl-justify-content-end gl-w-full" :data-testid="$options.notificationsToggleFormTestId" > - <div class="gl-px-5 gl-pb-2 gl-pt-1"> + <template #list-item> <gl-toggle :value="subscribedToNotifications" :label="$options.i18n.notifications" :data-testid="$options.notificationsToggleTestId" + class="work-item-notification-toggle" label-position="left" label-id="notifications-toggle" @change="toggleNotifications($event)" /> - </div> - </gl-dropdown-form> + </template> + </gl-disclosure-dropdown-item> <gl-dropdown-divider /> </template> - <gl-dropdown-item + + <gl-disclosure-dropdown-item v-if="canPromoteToObjective" :data-testid="$options.promoteActionTestId" - @click="promoteToObjective" + @action="promoteToObjective" > - {{ __('Promote to objective') }} - </gl-dropdown-item> + <template #list-item>{{ __('Promote to objective') }}</template> + </gl-disclosure-dropdown-item> <template v-if="canUpdate && !isParentConfidential"> - <gl-dropdown-item + <gl-disclosure-dropdown-item :data-testid="$options.confidentialityTestId" - @click="handleToggleWorkItemConfidentiality" - >{{ + @action="handleToggleWorkItemConfidentiality" + ><template #list-item>{{ isConfidential ? $options.i18n.disableTaskConfidentiality : $options.i18n.enableTaskConfidentiality - }}</gl-dropdown-item + }}</template></gl-disclosure-dropdown-item > </template> - <gl-dropdown-item + <gl-disclosure-dropdown-item ref="workItemReference" :data-testid="$options.copyReferenceTestId" :data-clipboard-text="workItemReference" - @click="copyToClipboard(workItemReference, $options.i18n.referenceCopied)" - >{{ $options.i18n.copyReference }}</gl-dropdown-item + @action="copyToClipboard(workItemReference, $options.i18n.referenceCopied)" + ><template #list-item>{{ + $options.i18n.copyReference + }}</template></gl-disclosure-dropdown-item > <template v-if="$options.isLoggedIn && workItemCreateNoteEmail"> - <gl-dropdown-item + <gl-disclosure-dropdown-item ref="workItemCreateNoteEmail" :data-testid="$options.copyCreateNoteEmailTestId" :data-clipboard-text="workItemCreateNoteEmail" - @click="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)" - >{{ i18n.copyCreateNoteEmail }}</gl-dropdown-item + @action="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)" + ><template #list-item>{{ + i18n.copyCreateNoteEmail + }}</template></gl-disclosure-dropdown-item > - <gl-dropdown-divider v-if="canDelete" /> </template> - <gl-dropdown-item + <gl-dropdown-divider v-if="canDelete" /> + <gl-disclosure-dropdown-item v-if="canDelete" :data-testid="$options.deleteActionTestId" variant="danger" - @click="handleDelete" + @action="handleDelete" > - {{ i18n.deleteWorkItem }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item + ><span class="text-danger">{{ i18n.deleteWorkItem }}</span></template + > + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> <gl-modal ref="modal" modal-id="work-item-confirm-delete" diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4b4aa7f96ca..f9527884adc 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -388,7 +388,7 @@ export default { :display-text="__('Invite members')" trigger-element="side-nav" icon="plus" - trigger-source="work-item-assignees-dropdown" + trigger-source="work_item_assignees_dropdown" classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2" /> </gl-dropdown-item> diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue index f93ea4a0753..14e55134048 100644 --- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -84,13 +84,13 @@ export default { <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> <confidentiality-badge v-if="isWorkItemConfidential" - class="gl-vertical-align-middle gl-display-inline-flex!" - data-testid="confidential" - :workspace-type="$options.WORKSPACE_PROJECT" + class="gl-vertical-align-middle gl-display-inline-flex! gl-mr-2" :issuable-type="workItemType" + :workspace-type="$options.WORKSPACE_PROJECT" + hide-text-in-small-screens /> <work-item-type-icon - class="gl-vertical-align-middle gl-mr-0!" + class="gl-vertical-align-middle" :work-item-icon-name="workItemIconName" :work-item-type="workItemType" show-text diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index d826ef9cbe7..edecd7addcc 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -31,6 +31,7 @@ import { WORK_ITEM_TYPE_VALUE_ISSUE, WORK_ITEM_TYPE_VALUE_OBJECTIVE, WIDGET_TYPE_NOTES, + WIDGET_TYPE_LINKED_ITEMS, } from '../constants'; import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql'; @@ -50,6 +51,7 @@ import WorkItemNotes from './work_item_notes.vue'; import WorkItemDetailModal from './work_item_detail_modal.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; import WorkItemStateToggleButton from './work_item_state_toggle_button.vue'; +import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue'; export default { i18n, @@ -79,6 +81,7 @@ export default { AbuseCategorySelector, GlIntersectionObserver, ConfidentialityBadge, + WorkItemRelationships, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath', 'reportAbusePath'], @@ -259,6 +262,15 @@ export default { showIntersectionObserver() { return !this.isModal && this.workItemsMvc2Enabled; }, + hasLinkedWorkItems() { + return this.glFeatures.linkedWorkItems; + }, + workItemLinkedItems() { + return this.isWidgetPresent(WIDGET_TYPE_LINKED_ITEMS); + }, + showWorkItemLinkedItems() { + return this.hasLinkedWorkItems && this.workItemLinkedItems; + }, }, mounted() { if (this.modalWorkItemIid) { @@ -515,9 +527,9 @@ export default { <gl-loading-icon v-if="updateInProgress" class="gl-mr-3" /> <confidentiality-badge v-if="workItem.confidential" - data-testid="confidential" - :workspace-type="$options.WORKSPACE_PROJECT" + class="gl-mr-3" :issuable-type="workItemType" + :workspace-type="$options.WORKSPACE_PROJECT" /> <work-item-todos v-if="showWorkItemCurrentUserTodos" @@ -591,6 +603,12 @@ export default { @show-modal="openInModal" @addChild="$emit('addChild')" /> + <work-item-relationships + v-if="showWorkItemLinkedItems" + :work-item-iid="workItemIid" + :work-item-full-path="workItem.project.fullPath" + @showModal="openInModal" + /> <work-item-notes v-if="workItemNotes" :work-item-id="workItem.id" @@ -600,6 +618,7 @@ export default { :assignees="workItemAssignees && workItemAssignees.assignees.nodes" :can-set-work-item-metadata="canAssignUnassignUser" :report-abuse-path="reportAbusePath" + :is-work-item-confidential="workItem.confidential" class="gl-pt-5" @error="updateError = $event" @has-notes="updateHasNotes" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue index bf427feaa35..9d9414b5399 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue @@ -56,14 +56,14 @@ export default { return isLoggedIn() && this.canUpdate; }, treeRootWrapper() { - return this.canReorder ? Draggable : 'div'; + return this.canReorder ? Draggable : 'ul'; }, treeRootOptions() { const options = { ...defaultSortableOptions, fallbackOnBody: false, group: 'sortable-container', - tag: 'div', + tag: 'ul', 'ghost-class': 'tree-item-drag-active', 'data-parent-id': this.workItemId, value: this.children, @@ -248,6 +248,7 @@ export default { <component :is="treeRootWrapper" v-bind="treeRootOptions" + class="content-list" :class="{ 'gl-cursor-grab sortable-container': canReorder }" @end="handleDragOnEnd" > diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index a9b0c2b98bf..679287338c8 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -13,6 +13,7 @@ import { WIDGET_TYPE_HIERARCHY, WORK_ITEM_NAME_TO_ICON_MAP, } from '../../constants'; +import { workItemPath } from '../../utils'; import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; import WorkItemTreeChildren from './work_item_tree_children.vue'; @@ -90,7 +91,7 @@ export default { return this.isItemOpen ? __('Created') : __('Closed'); }, childPath() { - return `${gon?.relative_url_root || ''}/${this.fullPath}/-/work_items/${this.childItem.iid}`; + return workItemPath(this.fullPath, this.childItem.iid); }, chevronType() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; @@ -212,7 +213,7 @@ export default { </script> <template> - <div class="tree-item"> + <li class="tree-item"> <div class="gl-display-flex gl-align-items-flex-start" :class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }" @@ -249,5 +250,5 @@ export default { @removeChild="removeChild" @click="$emit('click', $event)" /> - </div> + </li> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index a0ff693e156..eb836007e75 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -103,6 +103,7 @@ export default { isReportDrawerOpen: false, reportedUserId: 0, reportedUrl: '', + widgetName: 'tasks', }; }, computed: { @@ -166,7 +167,6 @@ export default { this.updateWorkItemIdUrlQuery(child); }, async closeModal() { - this.activeChild = {}; this.updateWorkItemIdUrlQuery(); }, handleWorkItemDeleted(child) { @@ -206,6 +206,7 @@ export default { <widget-wrapper ref="wrapper" :error="error" + :widget-name="widgetName" data-testid="work-item-links" @dismissAlert="error = undefined" > diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 4960189fb48..55440e1603c 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -3,19 +3,15 @@ import { GlAlert, GlFormGroup, GlForm, - GlTokenSelector, GlButton, GlFormInput, GlFormCheckbox, GlTooltip, } from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; +import WorkItemTokenInput from '../shared/work_item_token_input.vue'; import { addHierarchyChild } from '../../graphql/cache_utils'; import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql'; -import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; import { @@ -23,7 +19,6 @@ import { WORK_ITEMS_TYPE_MAP, WORK_ITEM_TYPE_ENUM_TASK, I18N_WORK_ITEM_CREATE_BUTTON_LABEL, - I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, I18N_WORK_ITEM_ADD_BUTTON_LABEL, I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, @@ -35,12 +30,12 @@ export default { components: { GlAlert, GlForm, - GlTokenSelector, GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlTooltip, + WorkItemTokenInput, }, inject: ['fullPath', 'hasIterationsFeature'], props: { @@ -101,35 +96,14 @@ export default { return data.workspace?.workItemTypes?.nodes; }, }, - availableWorkItems: { - query: projectWorkItemsQuery, - variables() { - return { - fullPath: this.fullPath, - searchTerm: this.search?.title || this.search, - types: [this.childrenType], - in: this.search ? 'TITLE' : undefined, - }; - }, - skip() { - return !this.searchStarted; - }, - update(data) { - return data.workspace.workItems.nodes.filter( - (wi) => !this.childrenIds.includes(wi.id) && this.issuableGid !== wi.id, - ); - }, - }, }, data() { return { workItemTypes: [], - availableWorkItems: [], - search: '', - searchStarted: false, + workItemsToAdd: [], error: null, + search: '', childToCreateTitle: null, - workItemsToAdd: [], confidential: this.parentConfidential, }; }, @@ -177,7 +151,8 @@ export default { addOrCreateButtonLabel() { if (this.isCreateForm) { return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName); - } else if (this.workItemsToAdd.length > 1) { + } + if (this.workItemsToAdd.length > 1) { return sprintfWorkItem(I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, this.childrenTypeName); } return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName); @@ -216,15 +191,6 @@ export default { } return this.workItemsToAdd.length === 0 || !this.areWorkItemsToAddValid; }, - isLoading() { - return this.$apollo.queries.availableWorkItems.loading; - }, - addInputPlaceholder() { - return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); - }, - tokenSelectorContainerClass() { - return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : ''; - }, invalidWorkItemsToAdd() { return this.parentConfidential ? this.workItemsToAdd.filter((workItem) => !workItem.confidential) @@ -249,11 +215,7 @@ export default { ); }, }, - created() { - this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, methods: { - getIdFromGraphQLId, getConfidentialityTooltipTarget() { // We want tooltip to be anchored to `input` within checkbox component // but `$el.querySelector('input')` doesn't work. 🤷♂️ @@ -317,20 +279,6 @@ export default { this.childToCreateTitle = null; }); }, - setSearchKey(value) { - this.search = value; - }, - handleFocus() { - this.searchStarted = true; - }, - handleMouseOver() { - this.timeout = setTimeout(() => { - this.searchStarted = true; - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, - handleMouseOut() { - clearTimeout(this.timeout); - }, }, i18n: { inputLabel: __('Title'), @@ -385,30 +333,16 @@ export default { >{{ confidentialityCheckboxTooltip }}</gl-tooltip > <div class="gl-mb-4"> - <gl-token-selector + <work-item-token-input v-if="!isCreateForm" v-model="workItemsToAdd" - :dropdown-items="availableWorkItems" - :loading="isLoading" - :placeholder="addInputPlaceholder" - menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" - :container-class="tokenSelectorContainerClass" - data-testid="work-item-token-select-input" - @text-input="debouncedSearchKeyUpdate" - @focus="handleFocus" - @mouseover.native="handleMouseOver" - @mouseout.native="handleMouseOut" - > - <template #token-content="{ token }"> - {{ token.title }} - </template> - <template #dropdown-item-content="{ dropdownItem }"> - <div class="gl-display-flex"> - <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> - <div class="gl-text-truncate">{{ dropdownItem.title }}</div> - </div> - </template> - </gl-token-selector> + :is-create-form="isCreateForm" + :parent-work-item-id="issuableGid" + :children-type="childrenType" + :children-ids="childrenIds" + :are-work-items-to-add-valid="areWorkItemsToAddValid" + :full-path="fullPath" + /> <div v-if="showWorkItemsToAddInvalidMessage" class="gl-text-red-500" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 246eac82c78..bc3f5201fb8 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -64,6 +64,7 @@ export default { isShownAddForm: false, formType: null, childType: null, + widgetName: 'tasks', }; }, computed: { @@ -101,6 +102,7 @@ export default { <template> <widget-wrapper ref="wrapper" + :widget-name="widgetName" :error="error" data-testid="work-item-tree" @dismissAlert="error = undefined" diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index 8fc460294e6..256f8ed53d1 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -79,6 +79,11 @@ export default { type: String, required: true, }, + isWorkItemConfidential: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -124,6 +129,7 @@ export default { isNewDiscussion: true, markdownPreviewPath: this.markdownPreviewPath, autocompleteDataSources: this.autocompleteDataSources, + isWorkItemConfidential: this.isWorkItemConfidential, }; }, notesArray() { @@ -366,6 +372,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :assignees="assignees" :can-set-work-item-metadata="canSetWorkItemMetadata" + :is-work-item-confidential="isWorkItemConfidential" @deleteNote="showDeleteNoteModal($event, discussion)" @reportAbuse="reportAbuse(true, $event)" @error="$emit('error', $event)" diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue new file mode 100644 index 00000000000..cbe830f9565 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue @@ -0,0 +1,61 @@ +<script> +import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; +import { workItemPath } from '../../utils'; + +export default { + components: { + WorkItemLinkChildContents, + }, + props: { + linkedItems: { + type: Array, + required: false, + default: () => [], + }, + heading: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + workItemFullPath: { + type: String, + required: true, + }, + }, + methods: { + linkedItemPath(fullPath, id) { + return workItemPath(fullPath, id); + }, + }, +}; +</script> +<template> + <div> + <h4 + v-if="heading" + data-testid="work-items-list-heading" + class="gl-font-sm gl-font-weight-semibold gl-text-gray-700 gl-mx-2 gl-mt-3 gl-mb-2" + > + {{ heading }} + </h4> + <div class="work-items-list-body"> + <ul ref="list" class="work-items-list content-list"> + <li + v-for="linkedItem in linkedItems" + :key="linkedItem.workItem.id" + class="gl-pt-0! gl-pb-0! gl-border-b-0!" + > + <work-item-link-child-contents + :child-item="linkedItem.workItem" + :can-update="canUpdate" + :child-path="linkedItemPath(workItemFullPath, linkedItem.workItem.iid)" + @click="$emit('showModal', { event: $event, child: linkedItem.workItem })" + /> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue new file mode 100644 index 00000000000..4f6879e9605 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue @@ -0,0 +1,185 @@ +<script> +import { GlLoadingIcon, GlIcon, GlButton } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import { WIDGET_TYPE_LINKED_ITEMS, LINKED_CATEGORIES_MAP } from '../../constants'; + +import WidgetWrapper from '../widget_wrapper.vue'; +import WorkItemRelationshipList from './work_item_relationship_list.vue'; + +export default { + components: { + GlLoadingIcon, + GlIcon, + GlButton, + WidgetWrapper, + WorkItemRelationshipList, + }, + props: { + workItemIid: { + type: String, + required: true, + }, + workItemFullPath: { + type: String, + required: true, + }, + }, + apollo: { + workItem: { + query: workItemByIidQuery, + variables() { + return { + fullPath: this.workItemFullPath, + iid: this.workItemIid, + }; + }, + update(data) { + return data.workspace.workItems.nodes[0] ?? {}; + }, + context: { + isSingleRequest: true, + }, + skip() { + return !this.workItemIid; + }, + error(e) { + this.error = e.message || this.$options.i18n.fetchError; + }, + async result() { + // When work items are switched in a modal, the data props are not getting reset. + // Thus, duplicating the work items in the list. + // Here, the existing list are cleared before the new items are pushed. + this.linksRelatesTo = []; + this.linksIsBlockedBy = []; + this.linksBlocks = []; + + this.linkedWorkItems.forEach((item) => { + if (item.linkType === LINKED_CATEGORIES_MAP.RELATES_TO) { + this.linksRelatesTo.push(item); + } else if (item.linkType === LINKED_CATEGORIES_MAP.IS_BLOCKED_BY) { + this.linksIsBlockedBy.push(item); + } else if (item.linkType === LINKED_CATEGORIES_MAP.BLOCKS) { + this.linksBlocks.push(item); + } + }); + }, + }, + }, + data() { + return { + error: '', + linksRelatesTo: [], + linksIsBlockedBy: [], + linksBlocks: [], + widgetName: 'linkeditems', + }; + }, + computed: { + canUpdate() { + // This will be false untill we implement remove item mutation + return false; + }, + isLoading() { + return this.$apollo.queries.workItem.loading; + }, + linkedWorkItemsWidget() { + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS); + }, + linkedWorkItems() { + return this.linkedWorkItemsWidget?.linkedItems?.nodes || []; + }, + linkedWorkItemsCount() { + return this.linkedWorkItems.length; + }, + isEmptyRelatedWorkItems() { + return !this.error && this.linkedWorkItems.length === 0; + }, + }, + i18n: { + title: s__('WorkItem|Linked Items'), + fetchError: s__('WorkItem|Something went wrong when fetching tasks. Please refresh this page.'), + emptyStateMessage: s__( + "WorkItem|Link work items together to show that they're related or that one is blocking others.", + ), + addChildButtonLabel: s__('WorkItem|Add'), + relatedToTitle: s__('WorkItem|Related to'), + blockingTitle: s__('WorkItem|Blocking'), + blockedByTitle: s__('WorkItem|Blocked by'), + addLinkedWorkItemButtonLabel: s__('WorkItem|Add'), + }, +}; +</script> +<template> + <widget-wrapper + :error="error" + class="work-item-relationships" + :widget-name="widgetName" + @dismissAlert="error = undefined" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h3 class="gl-new-card-title"> + {{ $options.i18n.title }} + </h3> + <div v-if="linkedWorkItemsCount" class="gl-new-card-count"> + <gl-icon name="link" class="gl-mr-2" /> + <span data-testid="linked-items-count">{{ linkedWorkItemsCount }}</span> + </div> + </div> + </template> + <template #header-right> + <gl-button size="small" class="gl-ml-3"> + <slot name="add-button-text">{{ $options.i18n.addLinkedWorkItemButtonLabel }}</slot> + </gl-button> + </template> + <template #body> + <div class="gl-new-card-content"> + <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" /> + <template v-else> + <div v-if="isEmptyRelatedWorkItems" data-testid="links-empty"> + <p class="gl-new-card-empty"> + {{ $options.i18n.emptyStateMessage }} + </p> + </div> + <template v-else> + <work-item-relationship-list + v-if="linksBlocks.length" + :class="{ + 'gl-pb-3 gl-mb-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': + linksIsBlockedBy.length, + }" + :linked-items="linksBlocks" + :heading="$options.i18n.blockingTitle" + :work-item-full-path="workItemFullPath" + :can-update="canUpdate" + @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + /> + <work-item-relationship-list + v-if="linksIsBlockedBy.length" + :class="{ + 'gl-pb-3 gl-mb-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': + linksRelatesTo.length, + }" + :linked-items="linksIsBlockedBy" + :heading="$options.i18n.blockedByTitle" + :work-item-full-path="workItemFullPath" + :can-update="canUpdate" + @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + /> + <work-item-relationship-list + v-if="linksRelatesTo.length" + :linked-items="linksRelatesTo" + :heading="$options.i18n.relatedToTitle" + :work-item-full-path="workItemFullPath" + :can-update="canUpdate" + @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" + /> + </template> + </template> + </div> + </template> + </widget-wrapper> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_state_badge.vue b/app/assets/javascripts/work_items/components/work_item_state_badge.vue index 1d1bc7352b1..5c5b41b38e6 100644 --- a/app/assets/javascripts/work_items/components/work_item_state_badge.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_badge.vue @@ -1,11 +1,12 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { STATE_OPEN } from '../constants'; export default { components: { GlBadge, + GlIcon, }, props: { workItemState: { @@ -31,11 +32,8 @@ export default { </script> <template> - <gl-badge - :icon="workItemStateIcon" - :variant="workItemStateVariant" - class="gl-mr-2 gl-vertical-align-middle" - > - {{ stateText }} + <gl-badge :variant="workItemStateVariant" class="gl-mr-2 gl-vertical-align-middle"> + <gl-icon :name="workItemStateIcon" :size="16" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ stateText }}</span> </gl-badge> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index f27ae5f4e6d..5426f3965b3 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -53,7 +53,7 @@ export default { </script> <template> - <span class="gl-mr-2"> + <span> <gl-icon v-gl-tooltip.hover="showTooltipOnHover" :name="iconName" |