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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-09-19 04:45:44 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-19 04:45:44 +0300
commit85dc423f7090da0a52c73eb66faf22ddb20efff9 (patch)
tree9160f299afd8c80c038f08e1545be119f5e3f1e1 /app/assets/javascripts/related_issues
parent15c2c8c66dbe422588e5411eee7e68f1fa440bb8 (diff)
Add latest changes from gitlab-org/gitlab@13-4-stable-ee
Diffstat (limited to 'app/assets/javascripts/related_issues')
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue207
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue115
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue231
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue215
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue146
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue247
-rw-r--r--app/assets/javascripts/related_issues/constants.js106
-rw-r--r--app/assets/javascripts/related_issues/index.js27
-rw-r--r--app/assets/javascripts/related_issues/services/related_issues_service.js34
-rw-r--r--app/assets/javascripts/related_issues/stores/related_issues_store.js50
10 files changed, 1378 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..63d61989cba
--- /dev/null
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -0,0 +1,207 @@
+<script>
+import { GlFormGroup, GlFormRadioGroup, GlButton } 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,
+ RelatedIssuableInput,
+ GlButton,
+ },
+ 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">
+ <gl-button
+ ref="addButton"
+ category="primary"
+ variant="success"
+ :disabled="isSubmitButtonDisabled"
+ :loading="isSubmitting"
+ type="submit"
+ class="js-add-issuable-form-add-button float-left qa-add-issue-button"
+ >
+ {{ __('Add') }}
+ </gl-button>
+ <gl-button class="float-right" @click="onFormCancel">
+ {{ __('Cancel') }}
+ </gl-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..31d0c7dbbb0
--- /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 &amp;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"
+ >
+ <gl-icon name="close" aria-hidden="true" />
+ </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..89eae069a24
--- /dev/null
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -0,0 +1,106 @@
+import { __, sprintf } 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]: sprintf(
+ __(' or %{emphasisStart}#issue id%{emphasisEnd}'),
+ { emphasisStart: '<', emphasisEnd: '>' },
+ false,
+ ),
+ [issuableTypesMap.EPIC]: sprintf(
+ __(' or %{emphasisStart}&epic id%{emphasisEnd}'),
+ { emphasisStart: '<', emphasisEnd: '>' },
+ false,
+ ),
+ [issuableTypesMap.MERGE_REQUEST]: sprintf(
+ __(' or %{emphasisStart}!merge request id%{emphasisEnd}'),
+ { emphasisStart: '<', emphasisEnd: '>' },
+ false,
+ ),
+ },
+ 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;