diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-25 09:10:18 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-25 09:10:18 +0300 |
commit | 4ae91e9f5004d78168b5b50f9ce51db045e479d0 (patch) | |
tree | 5b19e0ee9302696df06bbf980133d44883ba89ba /app/assets/javascripts/related_issues | |
parent | c9c77a8466e7cc2b88b6c8ae54365b4123caa2d0 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/related_issues')
10 files changed, 1365 insertions, 0 deletions
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue new file mode 100644 index 00000000000..dc01ab025d5 --- /dev/null +++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue @@ -0,0 +1,206 @@ +<script> +import { GlFormGroup, GlFormRadioGroup, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import RelatedIssuableInput from './related_issuable_input.vue'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; + +import { + issuableTypesMap, + itemAddFailureTypesMap, + linkedIssueTypesMap, + addRelatedIssueErrorMap, + addRelatedItemErrorMap, +} from '../constants'; + +export default { + name: 'AddIssuableForm', + components: { + GlFormGroup, + GlFormRadioGroup, + GlLoadingIcon, + RelatedIssuableInput, + }, + props: { + inputValue: { + type: String, + required: true, + }, + pendingReferences: { + type: Array, + required: false, + default: () => [], + }, + autoCompleteSources: { + type: Object, + required: false, + default: () => ({}), + }, + showCategorizedIssues: { + type: Boolean, + required: false, + default: false, + }, + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, + pathIdSeparator: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: false, + default: issuableTypesMap.ISSUE, + }, + hasError: { + type: Boolean, + required: false, + default: false, + }, + itemAddFailureType: { + type: String, + required: false, + default: itemAddFailureTypesMap.NOT_FOUND, + }, + itemAddFailureMessage: { + type: String, + required: false, + default: '', + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + linkedIssueType: linkedIssueTypesMap.RELATES_TO, + linkedIssueTypes: [ + { + text: __('relates to'), + value: linkedIssueTypesMap.RELATES_TO, + }, + { + text: __('blocks'), + value: linkedIssueTypesMap.BLOCKS, + }, + { + text: __('is blocked by'), + value: linkedIssueTypesMap.IS_BLOCKED_BY, + }, + ], + }; + }, + computed: { + isSubmitButtonDisabled() { + return ( + (this.inputValue.length === 0 && this.pendingReferences.length === 0) || this.isSubmitting + ); + }, + addRelatedErrorMessage() { + if (this.itemAddFailureMessage) { + return this.itemAddFailureMessage; + } else if (this.itemAddFailureType === itemAddFailureTypesMap.NOT_FOUND) { + return addRelatedIssueErrorMap[this.issuableType]; + } + // Only other failure is MAX_NUMBER_OF_CHILD_EPICS at the moment + return addRelatedItemErrorMap[this.itemAddFailureType]; + }, + transformedAutocompleteSources() { + if (!this.confidential) { + return this.autoCompleteSources; + } + + if (!this.autoCompleteSources?.issues || !this.autoCompleteSources?.epics) { + return this.autoCompleteSources; + } + + return { + ...this.autoCompleteSources, + issues: mergeUrlParams({ confidential_only: true }, this.autoCompleteSources.issues), + epics: mergeUrlParams({ confidential_only: true }, this.autoCompleteSources.epics), + }; + }, + }, + methods: { + onPendingIssuableRemoveRequest(params) { + this.$emit('pendingIssuableRemoveRequest', params); + }, + onFormSubmit() { + this.$emit('addIssuableFormSubmit', { + pendingReferences: this.$refs.relatedIssuableInput.$refs.input.value, + linkedIssueType: this.linkedIssueType, + }); + }, + onFormCancel() { + this.$emit('addIssuableFormCancel'); + }, + onAddIssuableFormInput(params) { + this.$emit('addIssuableFormInput', params); + }, + onAddIssuableFormBlur(params) { + this.$emit('addIssuableFormBlur', params); + }, + }, +}; +</script> + +<template> + <form @submit.prevent="onFormSubmit"> + <template v-if="showCategorizedIssues"> + <gl-form-group + :label="__('The current issue')" + label-for="linked-issue-type-radio" + label-class="label-bold" + class="mb-2" + > + <gl-form-radio-group + id="linked-issue-type-radio" + v-model="linkedIssueType" + :options="linkedIssueTypes" + :checked="linkedIssueType" + /> + </gl-form-group> + <p class="bold"> + {{ __('the following issue(s)') }} + </p> + </template> + <related-issuable-input + ref="relatedIssuableInput" + input-id="add-related-issues-form-input" + :confidential="confidential" + :focus-on-mount="true" + :references="pendingReferences" + :path-id-separator="pathIdSeparator" + :input-value="inputValue" + :auto-complete-sources="transformedAutocompleteSources" + :auto-complete-options="{ issues: true, epics: true }" + :issuable-type="issuableType" + @pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest" + @formCancel="onFormCancel" + @addIssuableFormBlur="onAddIssuableFormBlur" + @addIssuableFormInput="onAddIssuableFormInput" + /> + <p v-if="hasError" class="gl-field-error"> + {{ addRelatedErrorMessage }} + </p> + <div class="add-issuable-form-actions clearfix"> + <button + ref="addButton" + :disabled="isSubmitButtonDisabled" + type="submit" + class="js-add-issuable-form-add-button btn btn-success float-left qa-add-issue-button" + :class="{ disabled: isSubmitButtonDisabled }" + > + {{ __('Add') }} + <gl-loading-icon v-if="isSubmitting" ref="loadingIcon" :inline="true" /> + </button> + <button type="button" class="btn btn-default float-right" @click="onFormCancel"> + {{ __('Cancel') }} + </button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue new file mode 100644 index 00000000000..5a2fe03f752 --- /dev/null +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -0,0 +1,115 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; + +export default { + name: 'IssueToken', + components: { + GlIcon, + }, + mixins: [relatedIssuableMixin], + props: { + isCondensed: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + removeButtonLabel() { + const { displayReference } = this; + /* + * Giving false as third argument to prevent unescaping of ampersand in + * epic reference. Eg. &42 will remain &42 instead of &42 + * + * https://docs.gitlab.com/ee/development/i18n/externalization.html#interpolation + */ + return sprintf(__('Remove %{displayReference}'), { displayReference }, false); + }, + stateTitle() { + if (this.isCondensed) return ''; + + return this.isOpen ? __('Open') : __('Closed'); + }, + innerComponentType() { + return this.isCondensed ? 'span' : 'div'; + }, + issueTitle() { + return this.isCondensed ? this.title : ''; + }, + }, +}; +</script> + +<template> + <div + :class="{ + 'issue-token': isCondensed, + 'flex-row issuable-info-container': !isCondensed, + }" + > + <component + :is="computedLinkElementType" + ref="link" + v-tooltip + :class="{ + 'issue-token-link': isCondensed, + 'issuable-main-info': !isCondensed, + }" + :href="computedPath" + :title="issueTitle" + data-placement="top" + > + <component + :is="innerComponentType" + v-if="hasTitle" + ref="title" + :class="{ + 'issue-token-title issue-token-end': isCondensed, + 'issue-title block-truncated': !isCondensed, + 'issue-token-title-standalone': !canRemove, + }" + class="js-issue-token-title" + > + <span class="issue-token-title-text">{{ title }}</span> + </component> + <component + :is="innerComponentType" + ref="reference" + :class="{ + 'issue-token-reference': isCondensed, + 'issuable-info': !isCondensed, + }" + > + <gl-icon + v-if="hasState" + v-tooltip + :class="iconClass" + :name="iconName" + :size="12" + :title="stateTitle" + :aria-label="state" + /> + {{ displayReference }} + </component> + </component> + <button + v-if="canRemove" + ref="removeButton" + v-tooltip + :class="{ + 'issue-token-remove-button': isCondensed, + 'btn btn-default': !isCondensed, + }" + :title="removeButtonLabel" + :aria-label="removeButtonLabel" + :disabled="removeDisabled" + type="button" + class="js-issue-token-remove-button" + @click="onRemoveRequest" + > + <i class="fa fa-times" aria-hidden="true"></i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue new file mode 100644 index 00000000000..1931cfb2c00 --- /dev/null +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -0,0 +1,231 @@ +<script> +import $ from 'jquery'; +import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; +import issueToken from './issue_token.vue'; +import { + autoCompleteTextMap, + inputPlaceholderConfidentialTextMap, + inputPlaceholderTextMap, + issuableTypesMap, +} from '../constants'; + +const SPACE_FACTOR = 1; + +export default { + name: 'RelatedIssuableInput', + components: { + issueToken, + }, + props: { + inputId: { + type: String, + required: false, + default: '', + }, + references: { + type: Array, + required: false, + default: () => [], + }, + pathIdSeparator: { + type: String, + required: true, + }, + inputValue: { + type: String, + required: false, + default: '', + }, + focusOnMount: { + type: Boolean, + required: false, + default: false, + }, + autoCompleteSources: { + type: Object, + required: false, + default: () => ({}), + }, + autoCompleteOptions: { + type: Object, + required: false, + default: () => ({}), + }, + issuableType: { + type: String, + required: false, + default: issuableTypesMap.ISSUE, + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isInputFocused: false, + isAutoCompleteOpen: false, + areEventsAssigned: false, + }; + }, + computed: { + inputPlaceholder() { + const { issuableType, allowAutoComplete, confidential } = this; + const inputPlaceholderMapping = confidential + ? inputPlaceholderConfidentialTextMap + : inputPlaceholderTextMap; + const allowAutoCompleteText = autoCompleteTextMap[allowAutoComplete][issuableType]; + return `${inputPlaceholderMapping[issuableType]}${allowAutoCompleteText}`; + }, + allowAutoComplete() { + return Object.keys(this.autoCompleteSources).length > 0; + }, + }, + mounted() { + this.setupAutoComplete(); + if (this.focusOnMount) { + this.$nextTick() + .then(() => { + this.$refs.input.focus(); + }) + .catch(() => {}); + } + }, + beforeUpdate() { + this.setupAutoComplete(); + }, + beforeDestroy() { + const $input = $(this.$refs.input); + $input.off('shown-issues.atwho'); + $input.off('hidden-issues.atwho'); + $input.off('inserted-issues.atwho', this.onInput); + }, + methods: { + onAutoCompleteToggled(isOpen) { + this.isAutoCompleteOpen = isOpen; + }, + onInputWrapperClick() { + this.$refs.input.focus(); + }, + onInput() { + const { value } = this.$refs.input; + const caretPos = this.$refs.input.selectionStart; + const rawRefs = value.split(/\s/); + let touchedReference; + let position = 0; + + const untouchedRawRefs = rawRefs + .filter(ref => { + let isTouched = false; + + if (caretPos >= position && caretPos <= position + ref.length) { + touchedReference = ref; + isTouched = true; + } + + position = position + ref.length + SPACE_FACTOR; + + return !isTouched; + }) + .filter(ref => ref.trim().length > 0); + + this.$emit('addIssuableFormInput', { + newValue: value, + untouchedRawReferences: untouchedRawRefs, + touchedReference, + caretPos, + }); + }, + onBlur(event) { + // Early exit if this Blur event is caused by card header + const container = this.$root.$el.querySelector('.js-button-container'); + if (container && container.contains(event.relatedTarget)) { + return; + } + + this.isInputFocused = false; + + // Avoid tokenizing partial input when clicking an autocomplete item + if (!this.isAutoCompleteOpen) { + const { value } = this.$refs.input; + // Avoid event emission when only pathIdSeparator has been typed + if (value !== this.pathIdSeparator) { + this.$emit('addIssuableFormBlur', value); + } + } + }, + onFocus() { + this.isInputFocused = true; + }, + setupAutoComplete() { + const $input = $(this.$refs.input); + + if (this.allowAutoComplete) { + this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources); + this.gfmAutoComplete.setup($input, this.autoCompleteOptions); + } + + if (!this.areEventsAssigned) { + $input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true)); + $input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, true)); + } + this.areEventsAssigned = true; + }, + onIssuableFormWrapperClick() { + this.$refs.input.focus(); + }, + }, +}; +</script> + +<template> + <div + ref="issuableFormWrapper" + :class="{ focus: isInputFocused }" + class="add-issuable-form-input-wrapper form-control gl-field-error-outline" + role="button" + @click="onIssuableFormWrapperClick" + > + <ul class="add-issuable-form-input-token-list"> + <!-- + We need to ensure this key changes any time the pendingReferences array is updated + else two consecutive pending ref strings in an array with the same name will collide + and cause odd behavior when one is removed. + --> + <li + v-for="(reference, index) in references" + :key="`related-issues-token-${reference}`" + class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item" + > + <issue-token + :id-key="index" + :display-reference="reference.text || reference" + :can-remove="true" + :is-condensed="true" + :path-id-separator="pathIdSeparator" + event-namespace="pendingIssuable" + @pendingIssuableRemoveRequest=" + params => { + $emit('pendingIssuableRemoveRequest', params); + } + " + /> + </li> + <li class="add-issuable-form-input-list-item"> + <input + :id="inputId" + ref="input" + :value="inputValue" + :placeholder="inputPlaceholder" + type="text" + class="js-add-issuable-form-input add-issuable-form-input qa-add-issue-input" + @input="onInput" + @focus="onFocus" + @blur="onBlur" + @keyup.escape.exact="$emit('addIssuableFormCancel')" + /> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue new file mode 100644 index 00000000000..f7a79c62716 --- /dev/null +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -0,0 +1,215 @@ +<script> +import { GlLink, GlIcon, GlButton } from '@gitlab/ui'; +import AddIssuableForm from './add_issuable_form.vue'; +import RelatedIssuesList from './related_issues_list.vue'; +import { + issuableIconMap, + issuableQaClassMap, + linkedIssueTypesMap, + linkedIssueTypesTextMap, +} from '../constants'; + +export default { + name: 'RelatedIssuesBlock', + components: { + GlLink, + GlButton, + GlIcon, + AddIssuableForm, + RelatedIssuesList, + }, + props: { + isFetching: { + type: Boolean, + required: false, + default: false, + }, + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, + relatedIssues: { + type: Array, + required: false, + default: () => [], + }, + canAdmin: { + type: Boolean, + required: false, + default: false, + }, + canReorder: { + type: Boolean, + required: false, + default: false, + }, + isFormVisible: { + type: Boolean, + required: false, + default: false, + }, + pendingReferences: { + type: Array, + required: false, + default: () => [], + }, + inputValue: { + type: String, + required: false, + default: '', + }, + pathIdSeparator: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: false, + default: '', + }, + autoCompleteSources: { + type: Object, + required: false, + default: () => ({}), + }, + issuableType: { + type: String, + required: true, + }, + showCategorizedIssues: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + hasRelatedIssues() { + return this.relatedIssues.length > 0; + }, + categorisedIssues() { + if (this.showCategorizedIssues) { + return Object.values(linkedIssueTypesMap) + .map(linkType => ({ + linkType, + issues: this.relatedIssues.filter(issue => issue.linkType === linkType), + })) + .filter(obj => obj.issues.length > 0); + } + + return [{ issues: this.relatedIssues }]; + }, + shouldShowTokenBody() { + return this.hasRelatedIssues || this.isFetching; + }, + hasBody() { + return this.isFormVisible || this.shouldShowTokenBody; + }, + badgeLabel() { + return this.isFetching && this.relatedIssues.length === 0 ? '...' : this.relatedIssues.length; + }, + hasHelpPath() { + return this.helpPath.length > 0; + }, + issuableTypeIcon() { + return issuableIconMap[this.issuableType]; + }, + qaClass() { + return issuableQaClassMap[this.issuableType]; + }, + }, + linkedIssueTypesTextMap, +}; +</script> + +<template> + <div id="related-issues" class="related-issues-block"> + <div class="card card-slim gl-overflow-hidden"> + <div + :class="{ 'panel-empty-heading border-bottom-0': !hasBody }" + class="card-header gl-display-flex gl-justify-content-space-between" + > + <h3 + class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7" + > + <gl-link + id="user-content-related-issues" + class="anchor position-absolute gl-text-decoration-none" + href="#related-issues" + aria-hidden="true" + /> + <slot name="headerText">{{ __('Linked issues') }}</slot> + <gl-link + v-if="hasHelpPath" + :href="helpPath" + target="_blank" + class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500" + :aria-label="__('Read more about related issues')" + > + <gl-icon name="question" :size="12" role="text" /> + </gl-link> + + <div class="gl-display-inline-flex"> + <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-5"> + <span class="gl-display-inline-flex gl-align-items-center"> + <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" /> + {{ badgeLabel }} + </span> + </div> + <gl-button + v-if="canAdmin" + data-qa-selector="related_issues_plus_button" + icon="plus" + :aria-label="__('Add a related issue')" + :class="qaClass" + class="js-issue-count-badge-add-button" + @click="$emit('toggleAddRelatedIssuesForm', $event)" + /> + </div> + </h3> + <slot name="headerActions"></slot> + </div> + <div + class="linked-issues-card-body bg-gray-light" + :class="{ + 'gl-p-5': isFormVisible || shouldShowTokenBody, + }" + > + <div + v-if="isFormVisible" + class="js-add-related-issues-form-area card-body bordered-box bg-white" + > + <add-issuable-form + :show-categorized-issues="showCategorizedIssues" + :is-submitting="isSubmitting" + :issuable-type="issuableType" + :input-value="inputValue" + :pending-references="pendingReferences" + :auto-complete-sources="autoCompleteSources" + :path-id-separator="pathIdSeparator" + @pendingIssuableRemoveRequest="$emit('pendingIssuableRemoveRequest', $event)" + @addIssuableFormInput="$emit('addIssuableFormInput', $event)" + @addIssuableFormBlur="$emit('addIssuableFormBlur', $event)" + @addIssuableFormSubmit="$emit('addIssuableFormSubmit', $event)" + @addIssuableFormCancel="$emit('addIssuableFormCancel', $event)" + /> + </div> + <template v-if="shouldShowTokenBody"> + <related-issues-list + v-for="category in categorisedIssues" + :key="category.linkType" + :heading="$options.linkedIssueTypesTextMap[category.linkType]" + :can-admin="canAdmin" + :can-reorder="canReorder" + :is-fetching="isFetching" + :issuable-type="issuableType" + :path-id-separator="pathIdSeparator" + :related-issues="category.issues" + @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" + @saveReorder="$emit('saveReorder', $event)" + /> + </template> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue new file mode 100644 index 00000000000..a75fe4397bb --- /dev/null +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -0,0 +1,146 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import Sortable from 'sortablejs'; +import sortableConfig from 'ee_else_ce/sortable/sortable_config'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + name: 'RelatedIssuesList', + directives: { + tooltip, + }, + components: { + GlLoadingIcon, + RelatedIssuableItem, + }, + props: { + canAdmin: { + type: Boolean, + required: false, + default: false, + }, + canReorder: { + type: Boolean, + required: false, + default: false, + }, + heading: { + type: String, + required: false, + default: '', + }, + isFetching: { + type: Boolean, + required: false, + default: false, + }, + issuableType: { + type: String, + required: true, + }, + pathIdSeparator: { + type: String, + required: true, + }, + relatedIssues: { + type: Array, + required: false, + default: () => [], + }, + }, + mounted() { + if (this.canReorder) { + this.sortable = Sortable.create(this.$refs.list, { + ...sortableConfig, + onStart: this.addDraggingCursor, + onEnd: this.reordered, + }); + } + }, + methods: { + getBeforeAfterId(itemEl) { + const prevItemEl = itemEl.previousElementSibling; + const nextItemEl = itemEl.nextElementSibling; + + return { + beforeId: prevItemEl && parseInt(prevItemEl.dataset.orderingId, 0), + afterId: nextItemEl && parseInt(nextItemEl.dataset.orderingId, 0), + }; + }, + reordered(event) { + this.removeDraggingCursor(); + const { beforeId, afterId } = this.getBeforeAfterId(event.item); + const { oldIndex, newIndex } = event; + + this.$emit('saveReorder', { + issueId: parseInt(event.item.dataset.key, 10), + oldIndex, + newIndex, + afterId, + beforeId, + }); + }, + addDraggingCursor() { + document.body.classList.add('is-dragging'); + }, + removeDraggingCursor() { + document.body.classList.remove('is-dragging'); + }, + issuableOrderingId({ epicIssueId, id }) { + return this.issuableType === 'issue' ? epicIssueId : id; + }, + }, +}; +</script> + +<template> + <div> + <h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4> + <div + class="related-issues-token-body bordered-box bg-white" + :class="{ 'sortable-container': canReorder }" + > + <div v-if="isFetching" class="related-issues-loading-icon qa-related-issues-loading-icon"> + <gl-loading-icon ref="loadingIcon" label="Fetching linked issues" class="gl-mt-2" /> + </div> + <ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list"> + <li + v-for="issue in relatedIssues" + :key="issue.id" + :class="{ + 'user-can-drag': canReorder, + 'sortable-row': canReorder, + 'card card-slim': canReorder, + }" + :data-key="issue.id" + :data-ordering-id="issuableOrderingId(issue)" + class="js-related-issues-token-list-item list-item pt-0 pb-0" + > + <related-issuable-item + :id-key="issue.id" + :display-reference="issue.reference" + :confidential="issue.confidential" + :title="issue.title" + :path="issue.path" + :state="issue.state" + :milestone="issue.milestone" + :assignees="issue.assignees" + :created-at="issue.createdAt" + :closed-at="issue.closedAt" + :weight="issue.weight" + :due-date="issue.dueDate" + :can-remove="canAdmin" + :can-reorder="canReorder" + :path-id-separator="pathIdSeparator" + :is-locked="issue.lockIssueRemoval" + :locked-message="issue.lockedMessage" + event-namespace="relatedIssue" + class="qa-related-issuable-item" + @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" + /> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue new file mode 100644 index 00000000000..6f68b25b6fb --- /dev/null +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -0,0 +1,247 @@ +<script> +/* +`rawReferences` are separated by spaces. +Given `abc 123 zxc`, `rawReferences = ['abc', '123', 'zxc']` + +Consider you are typing `abc 123 zxc` in the input and your caret position is +at position 4 right before the `123` `rawReference`. Then you type `#` and +it becomes a valid reference, `#123`, but we don't want to jump it straight into +`pendingReferences` because you could still want to type. Say you typed `999` +and now we have `#999123`. Only when you move your caret away from that `rawReference` +do we actually put it in the `pendingReferences`. + +Your caret can stop touching a `rawReference` can happen in a variety of ways: + + - As you type, we only tokenize after you type a space or move with the arrow keys + - On blur, we consider your caret not touching anything + +--- + + - When you click the "Add related issues"(in the `AddIssuableForm`), + we submit the `pendingReferences` to the server and they come back as actual `relatedIssues` + - When you click the "Cancel"(in the `AddIssuableForm`), we clear out `pendingReferences` + and hide the `AddIssuableForm` area. + +*/ +import { deprecatedCreateFlash as Flash } from '~/flash'; +import { __ } from '~/locale'; +import RelatedIssuesBlock from './related_issues_block.vue'; +import RelatedIssuesStore from '../stores/related_issues_store'; +import RelatedIssuesService from '../services/related_issues_service'; +import { + relatedIssuesRemoveErrorMap, + pathIndeterminateErrorMap, + addRelatedIssueErrorMap, + issuableTypesMap, + PathIdSeparator, +} from '../constants'; + +export default { + name: 'RelatedIssuesRoot', + components: { + relatedIssuesBlock: RelatedIssuesBlock, + }, + props: { + endpoint: { + type: String, + required: true, + }, + canAdmin: { + type: Boolean, + required: false, + default: false, + }, + canReorder: { + type: Boolean, + required: false, + default: false, + }, + helpPath: { + type: String, + required: false, + default: '', + }, + issuableType: { + type: String, + required: false, + default: issuableTypesMap.ISSUE, + }, + allowAutoComplete: { + type: Boolean, + required: false, + default: true, + }, + pathIdSeparator: { + type: String, + required: false, + default: PathIdSeparator.Issue, + }, + cssClass: { + type: String, + required: false, + default: '', + }, + showCategorizedIssues: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + this.store = new RelatedIssuesStore(); + + return { + state: this.store.state, + isFetching: false, + isSubmitting: false, + isFormVisible: false, + inputValue: '', + }; + }, + computed: { + autoCompleteSources() { + if (!this.allowAutoComplete) return {}; + return gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources; + }, + }, + created() { + this.service = new RelatedIssuesService(this.endpoint); + this.fetchRelatedIssues(); + }, + methods: { + findRelatedIssueById(id) { + return this.state.relatedIssues.find(issue => issue.id === id); + }, + onRelatedIssueRemoveRequest(idToRemove) { + const issueToRemove = this.findRelatedIssueById(idToRemove); + + if (issueToRemove) { + RelatedIssuesService.remove(issueToRemove.relationPath) + .then(({ data }) => { + this.store.setRelatedIssues(data.issuables); + }) + .catch(res => { + if (res && res.status !== 404) { + Flash(relatedIssuesRemoveErrorMap[this.issuableType]); + } + }); + } else { + Flash(pathIndeterminateErrorMap[this.issuableType]); + } + }, + onToggleAddRelatedIssuesForm() { + this.isFormVisible = !this.isFormVisible; + }, + onPendingIssueRemoveRequest(indexToRemove) { + this.store.removePendingRelatedIssue(indexToRemove); + }, + onPendingFormSubmit(event) { + this.processAllReferences(event.pendingReferences); + + if (this.state.pendingReferences.length > 0) { + this.isSubmitting = true; + this.service + .addRelatedIssues(this.state.pendingReferences, event.linkedIssueType) + .then(({ data }) => { + // We could potentially lose some pending issues in the interim here + this.store.setPendingReferences([]); + this.store.setRelatedIssues(data.issuables); + + // Close the form on submission + this.isFormVisible = false; + }) + .catch(({ response }) => { + let errorMessage = addRelatedIssueErrorMap[this.issuableType]; + if (response && response.data && response.data.message) { + errorMessage = response.data.message; + } + Flash(errorMessage); + }) + .finally(() => { + this.isSubmitting = false; + }); + } + }, + onPendingFormCancel() { + this.isFormVisible = false; + this.store.setPendingReferences([]); + this.inputValue = ''; + }, + fetchRelatedIssues() { + this.isFetching = true; + this.service + .fetchRelatedIssues() + .then(({ data }) => { + this.store.setRelatedIssues(data); + }) + .catch(() => { + this.store.setRelatedIssues([]); + Flash(__('An error occurred while fetching issues.')); + }) + .finally(() => { + this.isFetching = false; + }); + }, + saveIssueOrder({ issueId, beforeId, afterId, oldIndex, newIndex }) { + const issueToReorder = this.findRelatedIssueById(issueId); + + if (issueToReorder) { + RelatedIssuesService.saveOrder({ + endpoint: issueToReorder.relationPath, + move_before_id: beforeId, + move_after_id: afterId, + }) + .then(({ data }) => { + if (!data.message) { + this.store.updateIssueOrder(oldIndex, newIndex); + } + }) + .catch(() => { + Flash(__('An error occurred while reordering issues.')); + }); + } + }, + onInput({ untouchedRawReferences, touchedReference }) { + this.store.addPendingReferences(untouchedRawReferences); + + this.inputValue = `${touchedReference}`; + }, + onBlur(newValue) { + this.processAllReferences(newValue); + }, + processAllReferences(value = '') { + const rawReferences = value.split(/\s+/).filter(reference => reference.trim().length > 0); + + this.store.addPendingReferences(rawReferences); + this.inputValue = ''; + }, + }, +}; +</script> + +<template> + <related-issues-block + :class="cssClass" + :help-path="helpPath" + :is-fetching="isFetching" + :is-submitting="isSubmitting" + :related-issues="state.relatedIssues" + :can-admin="canAdmin" + :can-reorder="canReorder" + :pending-references="state.pendingReferences" + :is-form-visible="isFormVisible" + :input-value="inputValue" + :auto-complete-sources="autoCompleteSources" + :issuable-type="issuableType" + :path-id-separator="pathIdSeparator" + :show-categorized-issues="showCategorizedIssues" + @saveReorder="saveIssueOrder" + @toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm" + @addIssuableFormInput="onInput" + @addIssuableFormBlur="onBlur" + @addIssuableFormSubmit="onPendingFormSubmit" + @addIssuableFormCancel="onPendingFormCancel" + @pendingIssuableRemoveRequest="onPendingIssueRemoveRequest" + @relatedIssueRemoveRequest="onRelatedIssueRemoveRequest" + /> +</template> diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js new file mode 100644 index 00000000000..1e95bf22ad2 --- /dev/null +++ b/app/assets/javascripts/related_issues/constants.js @@ -0,0 +1,94 @@ +import { __ } from '~/locale'; + +export const issuableTypesMap = { + ISSUE: 'issue', + EPIC: 'epic', + MERGE_REQUEST: 'merge_request', +}; + +export const linkedIssueTypesMap = { + BLOCKS: 'blocks', + IS_BLOCKED_BY: 'is_blocked_by', + RELATES_TO: 'relates_to', +}; + +export const linkedIssueTypesTextMap = { + [linkedIssueTypesMap.RELATES_TO]: __('Relates to'), + [linkedIssueTypesMap.BLOCKS]: __('Blocks'), + [linkedIssueTypesMap.IS_BLOCKED_BY]: __('Is blocked by'), +}; + +export const autoCompleteTextMap = { + true: { + [issuableTypesMap.ISSUE]: __(' or <#issue id>'), + [issuableTypesMap.EPIC]: __(' or <&epic id>'), + [issuableTypesMap.MERGE_REQUEST]: __(' or <!merge request id>'), + }, + false: { + [issuableTypesMap.ISSUE]: '', + [issuableTypesMap.EPIC]: '', + [issuableTypesMap.MERGE_REQUEST]: __(' or references (e.g. path/to/project!merge_request_id)'), + }, +}; + +export const inputPlaceholderTextMap = { + [issuableTypesMap.ISSUE]: __('Paste issue link'), + [issuableTypesMap.EPIC]: __('Paste epic link'), + [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'), +}; + +export const inputPlaceholderConfidentialTextMap = { + [issuableTypesMap.ISSUE]: __('Paste confidential issue link'), + [issuableTypesMap.EPIC]: __('Paste confidential epic link'), + [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'), +}; + +export const relatedIssuesRemoveErrorMap = { + [issuableTypesMap.ISSUE]: __('An error occurred while removing issues.'), + [issuableTypesMap.EPIC]: __('An error occurred while removing epics.'), +}; + +export const pathIndeterminateErrorMap = { + [issuableTypesMap.ISSUE]: __('We could not determine the path to remove the issue'), + [issuableTypesMap.EPIC]: __('We could not determine the path to remove the epic'), +}; + +export const itemAddFailureTypesMap = { + NOT_FOUND: 'not_found', + MAX_NUMBER_OF_CHILD_EPICS: 'conflict', +}; + +export const addRelatedIssueErrorMap = { + [issuableTypesMap.ISSUE]: __('Issue cannot be found.'), + [issuableTypesMap.EPIC]: __('Epic cannot be found.'), +}; + +export const addRelatedItemErrorMap = { + [itemAddFailureTypesMap.MAX_NUMBER_OF_CHILD_EPICS]: __( + 'This epic already has the maximum number of child epics.', + ), +}; + +/** + * These are used to map issuableType to the correct icon. + * Since these are never used for any display purposes, don't wrap + * them inside i18n functions. + */ +export const issuableIconMap = { + [issuableTypesMap.ISSUE]: 'issues', + [issuableTypesMap.EPIC]: 'epic', +}; + +/** + * These are used to map issuableType to the correct QA class. + * Since these are never used for any display purposes, don't wrap + * them inside i18n functions. + */ +export const issuableQaClassMap = { + [issuableTypesMap.EPIC]: 'qa-add-epics-button', +}; + +export const PathIdSeparator = { + Epic: '&', + Issue: '#', +}; diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js new file mode 100644 index 00000000000..2e8626890cb --- /dev/null +++ b/app/assets/javascripts/related_issues/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import RelatedIssuesRoot from './components/related_issues_root.vue'; + +export default function initRelatedIssues() { + const relatedIssuesRootElement = document.querySelector('.js-related-issues-root'); + if (relatedIssuesRootElement) { + // eslint-disable-next-line no-new + new Vue({ + el: relatedIssuesRootElement, + components: { + relatedIssuesRoot: RelatedIssuesRoot, + }, + render: createElement => + createElement('related-issues-root', { + props: { + endpoint: relatedIssuesRootElement.dataset.endpoint, + canAdmin: parseBoolean(relatedIssuesRootElement.dataset.canAddRelatedIssues), + helpPath: relatedIssuesRootElement.dataset.helpPath, + showCategorizedIssues: parseBoolean( + relatedIssuesRootElement.dataset.showCategorizedIssues, + ), + }, + }), + }); + } +} diff --git a/app/assets/javascripts/related_issues/services/related_issues_service.js b/app/assets/javascripts/related_issues/services/related_issues_service.js new file mode 100644 index 00000000000..3c19f63157e --- /dev/null +++ b/app/assets/javascripts/related_issues/services/related_issues_service.js @@ -0,0 +1,34 @@ +import axios from '~/lib/utils/axios_utils'; +import { linkedIssueTypesMap } from '../constants'; + +class RelatedIssuesService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + fetchRelatedIssues() { + return axios.get(this.endpoint); + } + + addRelatedIssues(newIssueReferences, linkType = linkedIssueTypesMap.RELATES_TO) { + return axios.post(this.endpoint, { + issuable_references: newIssueReferences, + link_type: linkType, + }); + } + + static saveOrder({ endpoint, move_before_id, move_after_id }) { + return axios.put(endpoint, { + epic: { + move_before_id, + move_after_id, + }, + }); + } + + static remove(endpoint) { + return axios.delete(endpoint); + } +} + +export default RelatedIssuesService; diff --git a/app/assets/javascripts/related_issues/stores/related_issues_store.js b/app/assets/javascripts/related_issues/stores/related_issues_store.js new file mode 100644 index 00000000000..14d71628cad --- /dev/null +++ b/app/assets/javascripts/related_issues/stores/related_issues_store.js @@ -0,0 +1,50 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +class RelatedIssuesStore { + constructor() { + this.state = { + // Stores issue objects of the known related issues + relatedIssues: [], + // Stores references of the "staging area" related issues that are planned to be added + pendingReferences: [], + }; + } + + setRelatedIssues(issues = []) { + this.state.relatedIssues = convertObjectPropsToCamelCase(issues, { deep: true }); + } + + addRelatedIssues(issues = []) { + this.setRelatedIssues(this.state.relatedIssues.concat(issues)); + } + + removeRelatedIssue(issue) { + this.state.relatedIssues = this.state.relatedIssues.filter(x => x.id !== issue.id); + } + + updateIssueOrder(oldIndex, newIndex) { + if (this.state.relatedIssues.length > 0) { + const updatedIssue = this.state.relatedIssues.splice(oldIndex, 1)[0]; + this.state.relatedIssues.splice(newIndex, 0, updatedIssue); + } + } + + setPendingReferences(issues) { + // Remove duplicates but retain order. + // If you don't do this, Vue will be confused by duplicates and refuse to delete them all. + this.state.pendingReferences = issues.filter((ref, idx) => issues.indexOf(ref) === idx); + } + + addPendingReferences(references = []) { + const issues = this.state.pendingReferences.concat(references); + this.setPendingReferences(issues); + } + + removePendingRelatedIssue(indexToRemove) { + this.state.pendingReferences = this.state.pendingReferences.filter( + (reference, index) => index !== indexToRemove, + ); + } +} + +export default RelatedIssuesStore; |