diff options
Diffstat (limited to 'app/assets/javascripts/work_items/components')
24 files changed, 520 insertions, 438 deletions
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue deleted file mode 100644 index 9053d8972de..00000000000 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ /dev/null @@ -1,79 +0,0 @@ -<script> -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { STATE_OPEN, STATE_CLOSED } from '../constants'; - -export default { - i18n: { - status: __('Status'), - }, - states: [ - { - value: STATE_OPEN, - text: __('Open'), - }, - { - value: STATE_CLOSED, - text: __('Closed'), - }, - ], - components: { - GlFormGroup, - GlFormSelect, - }, - props: { - state: { - type: String, - required: true, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - currentState() { - return this.$options.states[this.state]; - }, - }, - methods: { - setState(newState) { - if (newState !== this.state) { - this.$emit('changed', newState); - } - }, - }, - labelId: 'work-item-state-select', -}; -</script> - -<template> - <gl-form-group - :label="$options.i18n.status" - :label-for="$options.labelId" - label-cols="3" - label-cols-lg="2" - label-class="gl-pb-0! gl-overflow-wrap-break work-item-field-label" - class="gl-align-items-center" - > - <gl-form-select - :id="$options.labelId" - :value="state" - :options="$options.states" - :disabled="disabled" - data-testid="work-item-state-select" - class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1 work-item-field-value" - :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" - @change="setState" - /> - </gl-form-group> -</template> - -<style> -.hide-select-decoration:not(:focus, :hover), -.hide-select-decoration:disabled { - background-image: none; - box-shadow: none; -} -</style> diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 1dc6d341811..74bcc2717bd 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -52,7 +52,7 @@ export default { :aria-label="__('Title')" :data-placeholder="placeholder" :contenteditable="!disabled" - class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base gl-display-block" + class="hide-unfocused-input-decoration gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-rounded-base gl-display-block" :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }" @paste="handlePaste" @blur="handleBlur" 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 c330eccb186..66ad3d50287 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 @@ -265,6 +265,7 @@ export default { :comment-button-text="commentButtonText" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" + @error="$emit('error', $event)" /> <textarea v-else 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 c317ec48732..b143c529014 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 @@ -1,22 +1,13 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; -import { - I18N_WORK_ITEM_ERROR_UPDATING, - sprintfWorkItem, - STATE_OPEN, - STATE_EVENT_REOPEN, - STATE_EVENT_CLOSE, - TRACKING_CATEGORY_SHOW, - i18n, -} from '~/work_items/constants'; +import { STATE_OPEN, TRACKING_CATEGORY_SHOW } 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 { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; export default { i18n: { @@ -25,6 +16,7 @@ export default { 'Notes|Internal notes are only visible to members with the role of Reporter or higher', ), addInternalNote: __('Add internal note'), + cancelButtonText: __('Cancel'), }, constantOptions: { markdownDocsPath: helpPagePath('user/markdown'), @@ -34,6 +26,7 @@ export default { MarkdownEditor, GlFormCheckbox, GlIcon, + WorkItemStateToggleButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -123,14 +116,6 @@ export default { isWorkItemOpen() { return this.workItemState === STATE_OPEN; }, - toggleWorkItemStateText() { - return this.isWorkItemOpen - ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }) - : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }); - }, - cancelButtonText() { - return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel'); - }, commentButtonTextComputed() { return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText; }, @@ -166,48 +151,6 @@ export default { this.$emit('cancelEditing'); clearDraft(this.autosaveKey); }, - async toggleWorkItemState() { - const input = { - id: this.workItemId, - stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, - }; - - this.updateInProgress = true; - - try { - this.track('updated_state'); - - const { mutation, variables } = getUpdateWorkItemMutation({ - workItemParentId: this.workItemParentId, - input, - }); - - const { data } = await this.$apollo.mutate({ - mutation, - variables, - }); - - const errors = data.workItemUpdate?.errors; - - if (errors?.length) { - this.$emit('error', i18n.updateError); - } - } catch (error) { - const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); - - this.$emit('error', msg); - Sentry.captureException(error); - } - - this.updateInProgress = false; - }, - cancelButtonAction() { - if (this.isNewDiscussion) { - this.toggleWorkItemState(); - } else { - this.cancelEditing(); - } - }, }, }; </script> @@ -257,13 +200,23 @@ export default { @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="cancelButtonAction" - >{{ cancelButtonText }} + @click="cancelEditing" + >{{ $options.i18n.cancelButtonText }} </gl-button> </form> </div> 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 a2667a379e1..92560f2da9e 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 @@ -3,7 +3,7 @@ import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; -import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants'; +import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import Tracking from '~/tracking'; import { updateDraft, clearDraft } from '~/lib/utils/autosave'; import { renderMarkdown } from '~/notes/utils'; @@ -17,6 +17,7 @@ import NoteActions from '~/work_items/components/notes/work_item_note_actions.vu import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import { isAssigneesWidget } from '../../utils'; import WorkItemCommentForm from './work_item_comment_form.vue'; import WorkItemNoteAwardsList from './work_item_note_awards_list.vue'; @@ -228,8 +229,6 @@ export default { newAssignees = [...this.assignees, this.author]; } - const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES; - const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget); const editedWorkItemWidgets = [...this.workItem.widgets]; 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 new file mode 100644 index 00000000000..0a38dcb77f6 --- /dev/null +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue @@ -0,0 +1,196 @@ +<script> +import { GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; +import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue'; +import { + STATE_OPEN, + TASK_TYPE_NAME, + WIDGET_TYPE_PROGRESS, + WIDGET_TYPE_HIERARCHY, + WIDGET_TYPE_HEALTH_STATUS, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, + WORK_ITEM_NAME_TO_ICON_MAP, +} from '../../constants'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; + +export default { + i18n: { + confidential: __('Confidential'), + created: __('Created'), + closed: __('Closed'), + }, + components: { + GlLabel, + GlLink, + GlIcon, + RichTimestampTooltip, + WorkItemLinkChildMetadata, + WorkItemLinksMenu, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + childItem: { + type: Object, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + parentWorkItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + childPath: { + type: String, + required: true, + }, + }, + computed: { + labels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; + }, + metadataWidgets() { + return this.childItem.widgets?.reduce((metadataWidgets, widget) => { + // Skip Hierarchy widget as it is not part of metadata. + if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) { + // eslint-disable-next-line no-param-reassign + metadataWidgets[widget.type] = widget; + } + return metadataWidgets; + }, {}); + }, + allowsScopedLabels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; + }, + isChildItemOpen() { + return this.childItem.state === STATE_OPEN; + }, + iconName() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isChildItemOpen ? 'issue-open-m' : 'issue-close'; + } + return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType]; + }, + childItemType() { + return this.childItem.workItemType.name; + }, + iconClass() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isChildItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + } + return ''; + }, + stateTimestamp() { + return this.isChildItemOpen ? this.childItem.createdAt : this.childItem.closedAt; + }, + stateTimestampTypeText() { + return this.isChildItemOpen ? this.$options.i18n.created : this.$options.i18n.closed; + }, + hasMetadata() { + if (this.metadataWidgets) { + return ( + Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) || + Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) || + Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) || + this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 || + this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0 + ); + } + return false; + }, + }, + methods: { + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, + }, +}; +</script> + +<template> + <div + class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base" + data-testid="links-child" + > + <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" + > + <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> + <span + :id="`stateIcon-${childItem.id}`" + class="gl-cursor-help" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <span v-if="childItem.confidential"> + <gl-icon + v-gl-tooltip.top + name="eye-slash" + class="gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="$options.i18n.confidential" + :title="$options.i18n.confidential" + /> + </span> + <gl-link + :href="childPath" + class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :metadata-widgets="metadataWidgets" + class="gl-ml-6 ml-xl-0" + /> + </div> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" + /> + </div> + </div> + <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> + <work-item-links-menu + data-testid="links-menu" + @removeChild="$emit('removeChild', childItem)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue index ddeac2b92ae..ddeac2b92ae 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue index 53e8eedf060..53e8eedf060 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue 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 76a04bede61..e8fe64c932b 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -8,6 +8,7 @@ import { GlModalDirective, GlToggle, } from '@gitlab/ui'; +import { produce } from 'immer'; import * as Sentry from '@sentry/browser'; @@ -15,6 +16,7 @@ import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import toast from '~/vue_shared/plugins/global_toast'; import { isLoggedIn } from '~/lib/utils/common_utils'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { sprintfWorkItem, @@ -127,6 +129,10 @@ export default { required: false, default: false, }, + workItemIid: { + type: String, + required: true, + }, }, apollo: { workItemTypes: { @@ -168,16 +174,6 @@ export default { return this.workItemTypes.find((type) => type.name === WORK_ITEM_TYPE_VALUE_OBJECTIVE).id; }, }, - watch: { - subscribedToNotifications() { - /** - * To toggle the value if mutation fails, assign the - * subscribedToNotifications boolean value directly - * to data prop. - */ - this.initialSubscribed = this.subscribedToNotifications; - }, - }, methods: { copyToClipboard(text, message) { if (this.isModal) { @@ -203,10 +199,9 @@ export default { }, toggleNotifications(subscribed) { const inputVariables = { - id: this.workItemId, - notificationsWidget: { - subscribed, - }, + projectPath: this.fullPath, + iid: this.workItemIid, + subscribedState: subscribed, }; this.$apollo .mutate({ @@ -215,27 +210,34 @@ export default { input: inputVariables, }, optimisticResponse: { - workItemUpdate: { - errors: [], - workItem: { + updateWorkItemNotificationsSubscription: { + issue: { id: this.workItemId, - widgets: [ - { - type: WIDGET_TYPE_NOTIFICATIONS, - subscribed, - __typename: 'WorkItemWidgetNotifications', - }, - ], - __typename: 'WorkItem', + subscribed, + }, + errors: [], + }, + }, + update: ( + cache, + { + data: { + updateWorkItemNotificationsSubscription: { issue = {} }, }, - __typename: 'WorkItemUpdatePayload', }, + ) => { + // As the mutation and the query both are different, + // overwrite the subscribed value in the cache + this.updateWorkItemNotificationsWidgetCache({ + cache, + issue, + }); }, }) .then( ({ data: { - workItemUpdate: { errors }, + updateWorkItemNotificationsSubscription: { errors }, }, }) => { if (errors?.length) { @@ -251,6 +253,25 @@ export default { Sentry.captureException(error); }); }, + updateWorkItemNotificationsWidgetCache({ cache, issue }) { + const query = { + query: workItemByIidQuery, + variables: { fullPath: this.fullPath, iid: this.workItemIid }, + }; + // Read the work item object + const sourceData = cache.readQuery(query); + + const newData = produce(sourceData, (draftState) => { + const { widgets } = draftState.workspace.workItems.nodes[0]; + + const widgetNotifications = widgets.find(({ type }) => type === WIDGET_TYPE_NOTIFICATIONS); + // overwrite the subscribed value + widgetNotifications.subscribed = issue.subscribed; + }); + + // write to the cache + cache.writeQuery({ ...query, data: newData }); + }, throwConvertError() { this.$emit('error', this.i18n.convertError); }, @@ -275,6 +296,7 @@ export default { } this.$toast.show(s__('WorkItem|Promoted to objective.')); this.track('promote_kr_to_objective'); + this.$emit('promotedToObjective'); } catch (error) { this.throwConvertError(); Sentry.captureException(error); 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 f7ac63e16c3..4b4aa7f96ca 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -148,7 +148,7 @@ export default { }; }, containerClass() { - return !this.isEditing ? 'gl-shadow-none!' : ''; + return !this.isEditing ? 'gl-shadow-none! hide-unfocused-input-decoration' : ''; }, isLoadingUsers() { return this.$apollo.queries.users.loading; @@ -318,7 +318,7 @@ export default { :loading="isLoadingUsers && !isLoadingMore" :view-only="!canUpdate" :allow-clear-all="isEditing" - class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2 work-item-field-value" + class="assignees-selector hide-unfocused-input-decoration work-item-field-value gl-flex-grow-1 gl-border gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" data-testid="work-item-assignees-input" @input="handleAssigneesInput" @text-input="debouncedSearchKeyUpdate" diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index c727075eaac..139f0f7919c 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -11,7 +11,6 @@ import { WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, } from '../constants'; -import WorkItemState from './work_item_state.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; @@ -23,7 +22,6 @@ export default { WorkItemMilestone, WorkItemAssignees, WorkItemDueDate, - WorkItemState, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), @@ -97,12 +95,6 @@ export default { <template> <div class="work-item-attributes-wrapper"> - <work-item-state - :work-item="workItem" - :work-item-parent-id="workItemParentId" - :can-update="canUpdate" - @error="$emit('error', $event)" - /> <work-item-assignees v-if="workItemAssignees" :can-update="canUpdate" diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue index 3dd3a072d0f..44bd17b59a2 100644 --- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue +++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue @@ -51,7 +51,7 @@ export default { return window.gon.current_user_fullname; }, /** - * Parse and convert award emoji list to a format that AwardsList can understand + * Parse and convert emoji reactions list to a format that AwardsList can understand */ awards() { if (!this.awardEmoji) { @@ -91,12 +91,15 @@ export default { skip() { return !this.workItemIid; }, - result() { + result({ data }) { if (this.hasNextPage) { this.fetchAwardEmojis(); } else { this.isLoading = false; } + if (data) { + this.$emit('emoji-updated', data.workspace?.workItems?.nodes[0]); + } }, error() { this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR); @@ -125,7 +128,7 @@ export default { ); }, /** - * Prepare award emoji nodes based on emoji name + * Prepare emoji reactions nodes based on emoji name * and whether the user has toggled the emoji off or on */ getAwardEmojiNodes(name, toggledOn) { @@ -204,7 +207,7 @@ export default { }, }, ) => { - // update the cache of award emoji widget object + // update the cache of emoji reactions widget object this.updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }); }, }) 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 78a86aa49a4..f93ea4a0753 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 @@ -1,7 +1,11 @@ <script> -import { GlAvatarLink, GlSprintf } from '@gitlab/ui'; +import { GlAvatarLink, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { WORKSPACE_PROJECT } from '~/issues/constants'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; export default { @@ -9,6 +13,10 @@ export default { GlAvatarLink, GlSprintf, TimeAgoTooltip, + WorkItemStateBadge, + WorkItemTypeIcon, + ConfidentialityBadge, + GlLoadingIcon, }, inject: ['fullPath'], props: { @@ -17,6 +25,11 @@ export default { required: false, default: null, }, + updateInProgress: { + type: Boolean, + required: false, + default: false, + }, }, computed: { createdAt() { @@ -31,6 +44,18 @@ export default { authorId() { return getIdFromGraphQLId(this.author.id); }, + workItemState() { + return this.workItem?.state; + }, + workItemType() { + return this.workItem?.workItemType?.name; + }, + workItemIconName() { + return this.workItem?.workItemType?.iconName; + }, + isWorkItemConfidential() { + return this.workItem?.confidential; + }, }, apollo: { workItem: { @@ -49,13 +74,29 @@ export default { }, }, }, + WORKSPACE_PROJECT, }; </script> <template> - <div class="gl-mb-3"> - <span data-testid="work-item-created"> - <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')"> + <div class="gl-mb-3 gl-text-gray-700"> + <work-item-state-badge v-if="workItemState" :work-item-state="workItemState" /> + <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" + :issuable-type="workItemType" + /> + <work-item-type-icon + class="gl-vertical-align-middle gl-mr-0!" + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType" + show-text + /> + <span data-testid="work-item-created" class="gl-vertical-align-middle"> + <gl-sprintf v-if="author.name" :message="__('created %{timeAgo} by %{author}')"> <template #timeAgo> <time-ago-tooltip :time="createdAt" /> </template> @@ -70,7 +111,7 @@ export default { </gl-avatar-link> </template> </gl-sprintf> - <gl-sprintf v-else-if="createdAt" :message="__('Created %{timeAgo}')"> + <gl-sprintf v-else-if="createdAt" :message="__('created %{timeAgo}')"> <template #timeAgo> <time-ago-tooltip :time="createdAt" /> </template> @@ -79,7 +120,7 @@ export default { <span v-if="updatedAt" - class="gl-ml-5 gl-display-none gl-sm-display-inline-block" + class="gl-ml-5 gl-display-none gl-sm-display-inline-block gl-vertical-align-middle" data-testid="work-item-updated" > <gl-sprintf :message="__('Updated %{timeAgo}')"> 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 1402b313cee..d826ef9cbe7 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -5,7 +5,6 @@ import { GlSkeletonLoader, GlLoadingIcon, GlIcon, - GlBadge, GlButton, GlTooltipDirective, GlEmptyState, @@ -19,8 +18,9 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isLoggedIn } from '~/lib/utils/common_utils'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import { WORKSPACE_PROJECT } from '~/issues/constants'; import { - sprintfWorkItem, i18n, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_NOTIFICATIONS, @@ -49,6 +49,7 @@ import WorkItemDescription from './work_item_description.vue'; 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'; export default { i18n, @@ -57,8 +58,8 @@ export default { }, isLoggedIn: isLoggedIn(), components: { + WorkItemStateToggleButton, GlAlert, - GlBadge, GlButton, GlLoadingIcon, GlSkeletonLoader, @@ -77,6 +78,7 @@ export default { WorkItemDetailModal, AbuseCategorySelector, GlIntersectionObserver, + ConfidentialityBadge, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath', 'reportAbusePath'], @@ -134,6 +136,7 @@ export default { if (!res.data) { return; } + this.$emit('work-item-updated', this.workItem); if (isEmpty(this.workItem)) { this.setEmptyState(); } @@ -169,7 +172,7 @@ export default { return this.workItem.workItemType?.id; }, workItemBreadcrumbReference() { - return this.workItemType ? `${this.workItemType} #${this.workItem.iid}` : ''; + return this.workItemType ? `#${this.workItem.iid}` : ''; }, canUpdate() { return this.workItem?.userPermissions?.updateWorkItem; @@ -183,9 +186,6 @@ export default { canAssignUnassignUser() { return this.workItemAssignees && this.canSetWorkItemMetadata; }, - confidentialTooltip() { - return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType); - }, fullPath() { return this.workItem?.project.fullPath; }, @@ -374,8 +374,8 @@ export default { } }, }, - WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WORKSPACE_PROJECT, }; </script> @@ -397,13 +397,13 @@ export default { <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0" + class="list-unstyled gl-display-flex gl-min-w-0 gl-mr-auto gl-mb-0 gl-z-index-0" data-testid="work-item-parent" > - <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> + <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-min-w-0"> <gl-button v-gl-tooltip.hover - class="gl-text-truncate gl-max-w-full" + class="gl-text-truncate" :icon="parentWorkItemIconName" category="tertiary" :href="parentUrl" @@ -418,7 +418,8 @@ export default { > <work-item-type-icon :work-item-icon-name="workItemIconName" - :work-item-type="workItemType && workItemType.toUpperCase()" + :work-item-type="workItemType" + show-text /> {{ workItemBreadcrumbReference }} </li> @@ -430,20 +431,19 @@ export default { > <work-item-type-icon :work-item-icon-name="workItemIconName" - :work-item-type="workItemType && workItemType.toUpperCase()" + :work-item-type="workItemType" + show-text /> {{ workItemBreadcrumbReference }} </div> - <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> - <gl-badge - v-if="workItem.confidential" - v-gl-tooltip.bottom - :title="confidentialTooltip" - variant="warning" - icon="eye-slash" - class="gl-mr-3 gl-cursor-help" - >{{ __('Confidential') }}</gl-badge - > + <work-item-state-toggle-button + v-if="canUpdate" + :work-item-id="workItem.id" + :work-item-state="workItem.state" + :work-item-parent-id="workItemParentId" + :work-item-type="workItemType" + @error="updateError = $event" + /> <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" @@ -464,9 +464,11 @@ export default { :work-item-reference="workItem.reference" :work-item-create-note-email="workItem.createNoteEmail" :is-modal="isModal" + :work-item-iid="workItemIid" @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" /> <gl-button v-if="isModal" @@ -488,7 +490,10 @@ export default { :can-update="canUpdate" @error="updateError = $event" /> - <work-item-created-updated :work-item-iid="workItemIid" /> + <work-item-created-updated + :work-item-iid="workItemIid" + :update-in-progress="updateInProgress" + /> </div> <gl-intersection-observer v-if="showIntersectionObserver" @@ -508,15 +513,12 @@ export default { {{ workItem.title }} </span> <gl-loading-icon v-if="updateInProgress" class="gl-mr-3" /> - <gl-badge + <confidentiality-badge v-if="workItem.confidential" - v-gl-tooltip.bottom - :title="confidentialTooltip" - variant="warning" - icon="eye-slash" - class="gl-mr-3 gl-cursor-help" - >{{ __('Confidential') }}</gl-badge - > + data-testid="confidential" + :workspace-type="$options.WORKSPACE_PROJECT" + :issuable-type="workItemType" + /> <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" @@ -537,11 +539,13 @@ export default { :work-item-reference="workItem.reference" :work-item-create-note-email="workItem.createNoteEmail" :is-modal="isModal" + :work-item-iid="workItemIid" @deleteWorkItem=" $emit('deleteWorkItem', { workItemType, workItemId: workItem.id }) " @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" /> </div> </div> @@ -573,6 +577,7 @@ export default { :award-emoji="workItemAwardEmoji.awardEmoji" :work-item-iid="workItemIid" @error="updateError = $event" + @emoji-updated="$emit('work-item-emoji-updated', $event)" /> <work-item-tree v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" @@ -584,6 +589,7 @@ export default { :can-update="canUpdate" :confidential="workItem.confidential" @show-modal="openInModal" + @addChild="$emit('addChild')" /> <work-item-notes v-if="workItemNotes" diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue index b4b3049d669..1aa62a2b906 100644 --- a/app/assets/javascripts/work_items/components/work_item_due_date.vue +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -219,7 +219,6 @@ export default { ref="startDatePicker" v-model="dirtyStartDate" container="body" - data-testid="work-item-start-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.startDateInputId" show-clear-button @@ -250,7 +249,6 @@ export default { ref="dueDatePicker" v-model="dirtyDueDate" container="body" - data-testid="work-item-due-date-picker" :disabled="isDatepickerDisabled" :input-id="$options.dueDateInputId" :min-date="dirtyStartDate" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 8676456a6a4..1405a12a101 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -9,13 +9,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; - -import { - i18n, - I18N_WORK_ITEM_ERROR_FETCHING_LABELS, - TRACKING_CATEGORY_SHOW, - WIDGET_TYPE_LABELS, -} from '../constants'; +import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants'; +import { isLabelsWidget } from '../utils'; function isTokenSelectorElement(el) { return ( @@ -121,13 +116,13 @@ export default { return this.labelsWidget?.allowsScopedLabels; }, containerClass() { - return !this.isEditing ? 'gl-shadow-none!' : ''; + return !this.isEditing ? 'gl-shadow-none! hide-unfocused-input-decoration' : ''; }, isLoading() { return this.$apollo.queries.searchLabels.loading; }, labelsWidget() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + return this.workItem?.widgets?.find(isLabelsWidget); }, labels() { return this.labelsWidget?.labels?.nodes || []; @@ -272,7 +267,7 @@ export default { :loading="isLoading" :view-only="!canUpdate" :allow-clear-all="isEditing" - class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2! work-item-field-value" + class="hide-unfocused-input-decoration work-item-field-value gl-flex-grow-1 gl-border gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" menu-class="token-selector-menu-class" data-testid="work-item-labels-input" :class="{ 'gl-hover-border-gray-200': canUpdate }" diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue index dc5bcdc3dcc..c5be1a3ead3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue @@ -1,7 +1,7 @@ <script> -import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; const objectiveActionItems = [ { @@ -29,10 +29,30 @@ export default { keyResultActionItems, objectiveActionItems, components: { - GlDropdown, - GlDropdownSectionHeader, - GlDropdownItem, - GlDropdownDivider, + GlDisclosureDropdown, + }, + computed: { + objectiveDropdownItems() { + return { + name: __('Objective'), + items: this.$options.objectiveActionItems.map((item) => ({ + text: item.title, + action: () => this.change(item), + })), + }; + }, + keyResultDropdownItems() { + return { + name: __('Key result'), + items: this.$options.keyResultActionItems.map((item) => ({ + text: item.title, + action: () => this.change(item), + })), + }; + }, + dropdownItems() { + return [this.objectiveDropdownItems, this.keyResultDropdownItems]; + }, }, methods: { change({ eventName }) { @@ -43,24 +63,10 @@ export default { </script> <template> - <gl-dropdown :text="__('Add')" size="small" right> - <gl-dropdown-section-header>{{ __('Objective') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="item in $options.objectiveActionItems" - :key="item.eventName" - @click="change(item)" - > - {{ item.title }} - </gl-dropdown-item> - - <gl-dropdown-divider /> - <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="item in $options.keyResultActionItems" - :key="item.eventName" - @click="change(item)" - > - {{ item.title }} - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown + :toggle-text="__('Add')" + size="small" + placement="right" + :items="dropdownItems" + /> </template> 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 ec44a654e89..a9b0c2b98bf 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 @@ -1,39 +1,27 @@ <script> -import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import * as Sentry from '@sentry/browser'; import { __, s__ } from '~/locale'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; -import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; -import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import { STATE_OPEN, TASK_TYPE_NAME, WORK_ITEM_TYPE_VALUE_OBJECTIVE, - WIDGET_TYPE_PROGRESS, - WIDGET_TYPE_HEALTH_STATUS, - WIDGET_TYPE_MILESTONE, WIDGET_TYPE_HIERARCHY, - WIDGET_TYPE_ASSIGNEES, - WIDGET_TYPE_LABELS, WORK_ITEM_NAME_TO_ICON_MAP, } from '../../constants'; import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; -import WorkItemLinksMenu from './work_item_links_menu.vue'; +import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue'; import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { - GlLabel, - GlLink, GlButton, - GlIcon, - RichTimestampTooltip, - WorkItemLinkChildMetadata, - WorkItemLinksMenu, WorkItemTreeChildren, + WorkItemLinkChildContents, }, directives: { GlTooltip: GlTooltipDirective, @@ -74,25 +62,9 @@ export default { }; }, computed: { - labels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; - }, - allowsScopedLabels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; - }, canHaveChildren() { return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; }, - metadataWidgets() { - return this.childItem.widgets?.reduce((metadataWidgets, widget) => { - // Skip Hierarchy widget as it is not part of metadata. - if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) { - // eslint-disable-next-line no-param-reassign - metadataWidgets[widget.type] = widget; - } - return metadataWidgets; - }, {}); - }, isItemOpen() { return this.childItem.state === STATE_OPEN; }, @@ -126,18 +98,6 @@ export default { chevronTooltip() { return this.isExpanded ? __('Collapse') : __('Expand'); }, - hasMetadata() { - if (this.metadataWidgets) { - return ( - Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) || - Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) || - Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) || - this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 || - this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0 - ); - } - return false; - }, }, watch: { childItem: { @@ -270,81 +230,15 @@ export default { data-testid="expand-child" @click="toggleItem" /> - <div - class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base" - data-testid="links-child" - > - <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> - <div - class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" - > - <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> - <span - :id="`stateIcon-${childItem.id}`" - class="gl-cursor-help" - data-testid="item-status-icon" - > - <gl-icon - class="gl-text-secondary" - :class="iconClass" - :name="iconName" - :aria-label="stateTimestampTypeText" - /> - </span> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" - /> - <span v-if="childItem.confidential"> - <gl-icon - v-gl-tooltip.top - name="eye-slash" - class="gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" - /> - </span> - <gl-link - :href="childPath" - class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" - data-testid="item-title" - @click="$emit('click', $event)" - @mouseover="$emit('mouseover')" - @mouseout="$emit('mouseout')" - > - {{ childItem.title }} - </gl-link> - </div> - <work-item-link-child-metadata - v-if="hasMetadata" - :metadata-widgets="metadataWidgets" - class="gl-ml-6 ml-xl-0" - /> - </div> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> - <gl-label - v-for="label in labels" - :key="label.id" - :title="label.title" - :background-color="label.color" - :description="label.description" - :scoped="showScopedLabel(label)" - class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" - tooltip-placement="top" - /> - </div> - </div> - <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> - <work-item-links-menu - :work-item-id="childItem.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="$emit('removeChild', childItem)" - /> - </div> - </div> + <work-item-link-child-contents + :child-item="childItem" + :can-update="canUpdate" + :parent-work-item-id="issuableGid" + :work-item-type="workItemType" + :child-path="childPath" + @click="$emit('click', $event)" + @removeChild="$emit('removeChild', childItem)" + /> </div> <work-item-tree-children v-if="isExpanded" 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 bfc6ceefccc..a0ff693e156 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 @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlIcon, + GlLoadingIcon, + GlTooltipDirective, +} from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -20,8 +26,8 @@ import WorkItemChildrenWrapper from './work_item_children_wrapper.vue'; export default { components: { - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlIcon, GlLoadingIcon, WidgetWrapper, @@ -211,26 +217,30 @@ export default { </span> </template> <template #header-right> - <gl-dropdown + <gl-disclosure-dropdown v-if="canUpdate && canAddTask" - right + placement="right" size="small" - :text="$options.i18n.addChildButtonLabel" + :toggle-text="$options.i18n.addChildButtonLabel" data-testid="toggle-form" > - <gl-dropdown-item + <gl-disclosure-dropdown-item data-testid="toggle-create-form" - @click="showAddForm($options.FORM_TYPES.create)" + @action="showAddForm($options.FORM_TYPES.create)" > - {{ $options.i18n.createChildOptionLabel }} - </gl-dropdown-item> - <gl-dropdown-item + <template #list-item> + {{ $options.i18n.createChildOptionLabel }} + </template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item data-testid="toggle-add-form" - @click="showAddForm($options.FORM_TYPES.add)" + @action="showAddForm($options.FORM_TYPES.add)" > - {{ $options.i18n.addChildOptionLabel }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item> + {{ $options.i18n.addChildOptionLabel }} + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </template> <template #body> <div class="gl-new-card-content"> 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 db649913602..4960189fb48 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 @@ -306,6 +306,7 @@ export default { [this.error] = data.workItemCreate.errors; } else { this.unsetError(); + this.$emit('addChild'); } }) .catch(() => { 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 83f3c391769..246eac82c78 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 @@ -145,6 +145,7 @@ export default { :children-ids="childrenIds" :parent-confidential="confidential" @cancel="hideAddForm" + @addChild="$emit('addChild')" /> <work-item-children-wrapper :children="children" 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 new file mode 100644 index 00000000000..1d1bc7352b1 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_state_badge.vue @@ -0,0 +1,41 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { STATE_OPEN } from '../constants'; + +export default { + components: { + GlBadge, + }, + props: { + workItemState: { + type: String, + required: true, + }, + }, + computed: { + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + stateText() { + return this.isWorkItemOpen ? __('Open') : __('Closed'); + }, + workItemStateIcon() { + return this.isWorkItemOpen ? 'issue-open-m' : 'issue-close'; + }, + workItemStateVariant() { + return this.isWorkItemOpen ? 'success' : 'info'; + }, + }, +}; +</script> + +<template> + <gl-badge + :icon="workItemStateIcon" + :variant="workItemStateVariant" + class="gl-mr-2 gl-vertical-align-middle" + > + {{ stateText }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue index 3880ae25c8c..0ea30845466 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue @@ -1,26 +1,35 @@ <script> +import { GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; +import { __, sprintf } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_UPDATING, STATE_OPEN, - STATE_CLOSED, STATE_EVENT_CLOSE, STATE_EVENT_REOPEN, TRACKING_CATEGORY_SHOW, } from '../constants'; -import { getUpdateWorkItemMutation } from './update_work_item'; -import ItemState from './item_state.vue'; export default { components: { - ItemState, + GlButton, }, mixins: [Tracking.mixin()], props: { - workItem: { - type: Object, + workItemState: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, required: true, }, workItemParentId: { @@ -28,11 +37,6 @@ export default { required: false, default: null, }, - canUpdate: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -40,8 +44,16 @@ export default { }; }, computed: { - workItemType() { - return this.workItem.workItemType?.name; + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + toggleWorkItemStateText() { + const baseText = this.isWorkItemOpen + ? __('Close %{workItemType}') + : __('Reopen %{workItemType}'); + return capitalizeFirstCharacter( + sprintf(baseText, { workItemType: this.workItemType.toLowerCase() }), + ); }, tracking() { return { @@ -52,25 +64,10 @@ export default { }, }, methods: { - updateWorkItemState(newState) { - const stateEventMap = { - [STATE_OPEN]: STATE_EVENT_REOPEN, - [STATE_CLOSED]: STATE_EVENT_CLOSE, - }; - - const stateEvent = stateEventMap[newState]; - - this.updateWorkItem(stateEvent); - }, - - async updateWorkItem(updatedState) { - if (!updatedState) { - return; - } - + async updateWorkItem() { const input = { - id: this.workItem.id, - stateEvent: updatedState, + id: this.workItemId, + stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, }; this.updateInProgress = true; @@ -107,10 +104,10 @@ export default { </script> <template> - <item-state - v-if="workItem.state" - :state="workItem.state" - :disabled="updateInProgress || !canUpdate" - @changed="updateWorkItemState" - /> + <gl-button + :loading="updateInProgress" + data-testid="work-item-state-toggle" + @click="updateWorkItem" + >{{ toggleWorkItemStateText }}</gl-button + > </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 96a6493357c..f27ae5f4e6d 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 @@ -32,13 +32,18 @@ export default { }, }, computed: { + workItemTypeUppercase() { + return this.workItemType.toUpperCase().split(' ').join('_'); + }, iconName() { return ( - this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' + this.workItemIconName || + WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.icon || + 'issue-type-issue' ); }, workItemTypeName() { - return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name; + return WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.name; }, workItemTooltipTitle() { return this.showTooltipOnHover ? this.workItemTypeName : ''; @@ -48,12 +53,12 @@ export default { </script> <template> - <span> + <span class="gl-mr-2"> <gl-icon v-gl-tooltip.hover="showTooltipOnHover" :name="iconName" :title="workItemTooltipTitle" - class="gl-mr-2 gl-text-secondary" + class="gl-text-secondary" /> <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span> </span> |