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>2021-12-20 16:37:47 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 16:37:47 +0300
commitaee0a117a889461ce8ced6fcf73207fe017f1d99 (patch)
tree891d9ef189227a8445d83f35c1b0fc99573f4380 /app/assets/javascripts/vue_shared/issuable
parent8d46af3258650d305f53b819eabf7ab18d22f59e (diff)
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared/issuable')
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue44
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue128
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue35
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue303
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue363
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue67
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js51
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue194
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue52
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue15
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue167
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue152
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue162
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue101
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/event_hub.js3
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue86
-rw-r--r--app/assets/javascripts/vue_shared/issuable/sidebar/constants.js1
18 files changed, 1929 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
new file mode 100644
index 00000000000..f4cbaba9313
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
@@ -0,0 +1,44 @@
+<script>
+import IssuableForm from './issuable_form.vue';
+
+export default {
+ components: {
+ IssuableForm,
+ },
+ props: {
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ labelsFetchPath: {
+ type: String,
+ required: true,
+ },
+ labelsManagePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-create-container">
+ <slot name="title"></slot>
+ <hr class="gl-mt-0" />
+ <issuable-form
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ :labels-fetch-path="labelsFetchPath"
+ :labels-manage-path="labelsManagePath"
+ >
+ <template #actions="issuableMeta">
+ <slot name="actions" v-bind="issuableMeta"></slot>
+ </template>
+ </issuable-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
new file mode 100644
index 00000000000..c216a05bdb0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -0,0 +1,128 @@
+<script>
+import { GlForm, GlFormInput } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
+import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
+
+export default {
+ LabelSelectVariant: DropdownVariant,
+ components: {
+ GlForm,
+ GlFormInput,
+ MarkdownField,
+ LabelsSelect,
+ },
+ props: {
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ labelsFetchPath: {
+ type: String,
+ required: true,
+ },
+ labelsManagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ issuableTitle: '',
+ issuableDescription: '',
+ selectedLabels: [],
+ };
+ },
+ methods: {
+ handleUpdateSelectedLabels(labels) {
+ if (labels.length) {
+ this.selectedLabels = labels;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form class="common-note-form gfm-form" @submit.stop.prevent>
+ <div data-testid="issuable-title" class="form-group row">
+ <label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label>
+ <div class="col-sm-10">
+ <gl-form-input
+ id="issuable-title"
+ v-model="issuableTitle"
+ :autofocus="true"
+ :placeholder="__('Title')"
+ />
+ </div>
+ </div>
+ <div data-testid="issuable-description" class="form-group row">
+ <label for="issuable-description" class="col-form-label col-sm-2">{{
+ __('Description')
+ }}</label>
+ <div class="col-sm-10">
+ <markdown-field
+ :markdown-preview-path="descriptionPreviewPath"
+ :markdown-docs-path="descriptionHelpPath"
+ :add-spacing-classes="false"
+ :show-suggest-popover="true"
+ :textarea-value="issuableDescription"
+ >
+ <template #textarea>
+ <textarea
+ id="issuable-description"
+ ref="textarea"
+ v-model="issuableDescription"
+ dir="auto"
+ class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files hereā€¦')"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-lg-6">
+ <div data-testid="issuable-labels" class="form-group row">
+ <label for="issuable-labels" class="col-form-label col-md-2 col-lg-4">{{
+ __('Labels')
+ }}</label>
+ <div class="col-md-8 col-sm-10">
+ <div class="issuable-form-select-holder">
+ <labels-select
+ :allow-label-edit="true"
+ :allow-label-create="true"
+ :allow-multiselect="true"
+ :allow-scoped-labels="true"
+ :labels-fetch-path="labelsFetchPath"
+ :labels-manage-path="labelsManagePath"
+ :selected-labels="selectedLabels"
+ :labels-list-title="__('Select label')"
+ :footer-create-label-title="__('Create project label')"
+ :footer-manage-label-title="__('Manage project labels')"
+ :variant="$options.LabelSelectVariant.Embedded"
+ @updateSelectedLabels="handleUpdateSelectedLabels"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ data-testid="issuable-create-actions"
+ class="footer-block row-content-block gl-display-flex"
+ >
+ <slot
+ name="actions"
+ :issuable-title="issuableTitle"
+ :issuable-description="issuableDescription"
+ :selected-labels="selectedLabels"
+ ></slot>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue
new file mode 100644
index 00000000000..5ca9e50d854
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue
@@ -0,0 +1,35 @@
+<script>
+export default {
+ props: {
+ expanded: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ watch: {
+ expanded(value) {
+ const layoutPageEl = document.querySelector('.layout-page');
+
+ if (layoutPageEl) {
+ layoutPageEl.classList.toggle('right-sidebar-expanded', value);
+ layoutPageEl.classList.toggle('right-sidebar-collapsed', !value);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="{ 'right-sidebar-expanded': expanded, 'right-sidebar-collapsed': !expanded }"
+ class="issues-bulk-update right-sidebar"
+ aria-live="polite"
+ >
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <slot name="bulk-edit-actions"></slot>
+ </div>
+ <slot name="sidebar-items"></slot>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
new file mode 100644
index 00000000000..0bb0e0d9fb0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -0,0 +1,303 @@
+<script>
+import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility';
+import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
+import { __, n__, sprintf } from '~/locale';
+import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ GlLabel,
+ GlFormCheckbox,
+ GlSprintf,
+ IssuableAssignees,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ issuableSymbol: {
+ type: String,
+ required: true,
+ },
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ enableLabelPermalinks: {
+ type: Boolean,
+ required: true,
+ },
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
+ showCheckbox: {
+ type: Boolean,
+ required: true,
+ },
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ issuableId() {
+ return getIdFromGraphQLId(this.issuable.id);
+ },
+ createdInPastDay() {
+ const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date());
+ return createdSecondsAgo < SECONDS_IN_DAY;
+ },
+ author() {
+ return this.issuable.author;
+ },
+ webUrl() {
+ return this.issuable.gitlabWebUrl || this.issuable.webUrl;
+ },
+ authorId() {
+ return getIdFromGraphQLId(this.author.id);
+ },
+ isIssuableUrlExternal() {
+ return isExternal(this.webUrl);
+ },
+ reference() {
+ return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
+ },
+ labels() {
+ return this.issuable.labels?.nodes || this.issuable.labels || [];
+ },
+ labelIdsString() {
+ return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id)));
+ },
+ assignees() {
+ return this.issuable.assignees?.nodes || this.issuable.assignees || [];
+ },
+ createdAt() {
+ return getTimeago().format(this.issuable.createdAt);
+ },
+ updatedAt() {
+ return sprintf(__('updated %{timeAgo}'), {
+ timeAgo: getTimeago().format(this.issuable.updatedAt),
+ });
+ },
+ issuableTitleProps() {
+ if (this.isIssuableUrlExternal) {
+ return {
+ target: '_blank',
+ };
+ }
+ return {};
+ },
+ taskStatus() {
+ const { completedCount, count } = this.issuable.taskCompletionStatus || {};
+ if (!count) {
+ return undefined;
+ }
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
+ notesCount() {
+ return this.issuable.userDiscussionsCount ?? this.issuable.userNotesCount;
+ },
+ showDiscussions() {
+ return typeof this.notesCount === 'number';
+ },
+ showIssuableMeta() {
+ return Boolean(
+ this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees,
+ );
+ },
+ issuableNotesLink() {
+ return setUrlFragment(this.webUrl, 'notes');
+ },
+ },
+ methods: {
+ hasSlotContents(slotName) {
+ return Boolean(this.$slots[slotName]);
+ },
+ scopedLabel(label) {
+ return isScopedLabel(label);
+ },
+ labelTitle(label) {
+ return label.title || label.name;
+ },
+ labelTarget(label) {
+ if (this.enableLabelPermalinks) {
+ const value = encodeURIComponent(this.labelTitle(label));
+ return `?${this.labelFilterParam}[]=${value}`;
+ }
+ return '#';
+ },
+ /**
+ * This is needed as an independent method since
+ * when user changes current page, `$refs.authorLink`
+ * will be null until next page results are loaded & rendered.
+ */
+ getAuthorPopoverTarget() {
+ if (this.$refs.authorLink) {
+ return this.$refs.authorLink.$el;
+ }
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ :id="`issuable_${issuableId}`"
+ class="issue gl-display-flex! gl-px-5!"
+ :class="{ closed: issuable.closedAt, today: createdInPastDay }"
+ :data-labels="labelIdsString"
+ :data-qa-issue-id="issuableId"
+ >
+ <gl-form-checkbox
+ v-if="showCheckbox"
+ class="issue-check gl-mr-0"
+ :checked="checked"
+ :data-id="issuableId"
+ @input="$emit('checked-input', $event)"
+ >
+ <span class="gl-sr-only">{{ issuable.title }}</span>
+ </gl-form-checkbox>
+ <div class="issuable-main-info">
+ <div data-testid="issuable-title" class="issue-title title">
+ <gl-icon
+ v-if="issuable.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :title="__('Confidential')"
+ :aria-label="__('Confidential')"
+ />
+ <gl-icon
+ v-if="issuable.hidden"
+ v-gl-tooltip
+ name="spam"
+ :title="__('This issue is hidden because its author has been banned')"
+ :aria-label="__('Hidden')"
+ />
+ <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps">
+ {{ issuable.title }}
+ <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
+ </gl-link>
+ <span
+ v-if="taskStatus"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ data-testid="task-status"
+ >
+ {{ taskStatus }}
+ </span>
+ </div>
+ <div class="issuable-info">
+ <slot v-if="hasSlotContents('reference')" name="reference"></slot>
+ <span v-else data-testid="issuable-reference" class="issuable-reference">
+ {{ reference }}
+ </span>
+ <span class="gl-display-none gl-sm-display-inline">
+ <span aria-hidden="true">&middot;</span>
+ <span class="issuable-authored gl-mr-3">
+ <gl-sprintf :message="__('created %{timeAgo} by %{author}')">
+ <template #timeAgo>
+ <span
+ v-gl-tooltip.bottom
+ :title="tooltipTitle(issuable.createdAt)"
+ data-testid="issuable-created-at"
+ >
+ {{ createdAt }}
+ </span>
+ </template>
+ <template #author>
+ <slot v-if="hasSlotContents('author')" name="author"></slot>
+ <gl-link
+ v-else
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :data-avatar-url="author.avatarUrl"
+ :href="author.webUrl"
+ data-testid="issuable-author"
+ class="author-link js-user-link"
+ >
+ <span class="author">{{ author.name }}</span>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ <slot name="timeframe"></slot>
+ </span>
+ &nbsp;
+ <span v-if="labels.length" role="group" :aria-label="__('Labels')">
+ <gl-label
+ v-for="(label, index) in labels"
+ :key="index"
+ :background-color="label.color"
+ :title="labelTitle(label)"
+ :description="label.description"
+ :scoped="scopedLabel(label)"
+ :target="labelTarget(label)"
+ :class="{ 'gl-ml-2': index }"
+ size="sm"
+ />
+ </span>
+ </div>
+ </div>
+ <div class="issuable-meta">
+ <ul v-if="showIssuableMeta" class="controls">
+ <li v-if="hasSlotContents('status')" class="issuable-status">
+ <slot name="status"></slot>
+ </li>
+ <li v-if="assignees.length">
+ <issuable-assignees
+ :assignees="assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ <slot name="statistics"></slot>
+ <li
+ v-if="showDiscussions"
+ data-testid="issuable-discussions"
+ class="issuable-comments gl-display-none gl-sm-display-block"
+ >
+ <gl-link
+ v-gl-tooltip.top
+ :title="__('Comments')"
+ :href="issuableNotesLink"
+ :class="{ 'no-comments': !notesCount }"
+ class="gl-reset-color!"
+ >
+ <gl-icon name="comments" />
+ {{ notesCount }}
+ </gl-link>
+ </li>
+ </ul>
+ <div
+ v-gl-tooltip.bottom
+ class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block"
+ :title="tooltipTitle(issuable.updatedAt)"
+ data-testid="issuable-updated-at"
+ >
+ {{ updatedAt }}
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
new file mode 100644
index 00000000000..2f8401b45f0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -0,0 +1,363 @@
+<script>
+import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+import { DEFAULT_SKELETON_COUNT } from '../constants';
+import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
+import IssuableItem from './issuable_item.vue';
+import IssuableTabs from './issuable_tabs.vue';
+
+const VueDraggable = () => import('vuedraggable');
+
+export default {
+ vueDraggableAttributes: {
+ animation: 200,
+ ghostClass: 'gl-visibility-hidden',
+ tag: 'ul',
+ },
+ components: {
+ GlAlert,
+ GlKeysetPagination,
+ GlSkeletonLoading,
+ IssuableTabs,
+ FilteredSearchBar,
+ IssuableItem,
+ IssuableBulkEditSidebar,
+ GlPagination,
+ VueDraggable,
+ },
+ props: {
+ namespace: {
+ type: String,
+ required: true,
+ },
+ recentSearchesStorageKey: {
+ type: String,
+ required: true,
+ },
+ searchInputPlaceholder: {
+ type: String,
+ required: true,
+ },
+ searchTokens: {
+ type: Array,
+ required: true,
+ },
+ sortOptions: {
+ type: Array,
+ required: true,
+ },
+ urlParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ initialFilterValue: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ initialSortBy: {
+ type: String,
+ required: false,
+ default: 'created_desc',
+ },
+ issuables: {
+ type: Array,
+ required: true,
+ },
+ tabs: {
+ type: Array,
+ required: true,
+ },
+ tabCounts: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ currentTab: {
+ type: String,
+ required: true,
+ },
+ issuableSymbol: {
+ type: String,
+ required: false,
+ default: '#',
+ },
+ issuablesLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showPaginationControls: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showBulkEditSidebar: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ defaultPageSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ totalItems: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ currentPage: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ previousPage: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ nextPage: {
+ type: Number,
+ required: false,
+ default: 2,
+ },
+ enableLabelPermalinks: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ labelFilterParam: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ isManualOrdering: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ useKeysetPagination: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasNextPage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasPreviousPage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ error: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ checkedIssuables: {},
+ };
+ },
+ computed: {
+ skeletonItemCount() {
+ const { totalItems, defaultPageSize, currentPage } = this;
+ const totalPages = Math.ceil(totalItems / defaultPageSize);
+
+ if (totalPages) {
+ return currentPage < totalPages
+ ? defaultPageSize
+ : totalItems % defaultPageSize || defaultPageSize;
+ }
+ return DEFAULT_SKELETON_COUNT;
+ },
+ allIssuablesChecked() {
+ return this.bulkEditIssuables.length === this.issuables.length;
+ },
+ /**
+ * Returns all the checked issuables from `checkedIssuables` map.
+ */
+ bulkEditIssuables() {
+ return Object.keys(this.checkedIssuables).reduce((acc, issuableId) => {
+ if (this.checkedIssuables[issuableId].checked) {
+ acc.push(this.checkedIssuables[issuableId].issuable);
+ }
+ return acc;
+ }, []);
+ },
+ issuablesWrapper() {
+ return this.isManualOrdering ? VueDraggable : 'ul';
+ },
+ },
+ watch: {
+ issuables(list) {
+ this.checkedIssuables = list.reduce((acc, issuable) => {
+ const id = this.issuableId(issuable);
+ acc[id] = {
+ // By default, an issuable is not checked,
+ // But if `checkedIssuables` is already
+ // populated, use existing value.
+ checked:
+ typeof this.checkedIssuables[id] !== 'boolean'
+ ? false
+ : this.checkedIssuables[id].checked,
+ // We're caching issuable reference here
+ // for ease of populating in `bulkEditIssuables`.
+ issuable,
+ };
+ return acc;
+ }, {});
+ },
+ urlParams: {
+ deep: true,
+ immediate: true,
+ handler(params) {
+ if (Object.keys(params).length) {
+ updateHistory({
+ url: setUrlParams(params, window.location.href, true, false, true),
+ title: document.title,
+ replace: true,
+ });
+ }
+ },
+ },
+ },
+ methods: {
+ issuableId(issuable) {
+ return getIdFromGraphQLId(issuable.id) || issuable.iid || uniqueId();
+ },
+ issuableChecked(issuable) {
+ return this.checkedIssuables[this.issuableId(issuable)]?.checked;
+ },
+ handleIssuableCheckedInput(issuable, value) {
+ this.checkedIssuables[this.issuableId(issuable)].checked = value;
+ this.$emit('update-legacy-bulk-edit');
+ },
+ handleAllIssuablesCheckedInput(value) {
+ Object.keys(this.checkedIssuables).forEach((issuableId) => {
+ this.checkedIssuables[issuableId].checked = value;
+ });
+ this.$emit('update-legacy-bulk-edit');
+ },
+ handleVueDraggableUpdate({ newIndex, oldIndex }) {
+ this.$emit('reorder', { newIndex, oldIndex });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-list-container">
+ <issuable-tabs
+ :tabs="tabs"
+ :tab-counts="tabCounts"
+ :current-tab="currentTab"
+ @click="$emit('click-tab', $event)"
+ >
+ <template #nav-actions>
+ <slot name="nav-actions"></slot>
+ </template>
+ </issuable-tabs>
+ <filtered-search-bar
+ :namespace="namespace"
+ :recent-searches-storage-key="recentSearchesStorageKey"
+ :search-input-placeholder="searchInputPlaceholder"
+ :tokens="searchTokens"
+ :sort-options="sortOptions"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ :show-checkbox="showBulkEditSidebar"
+ :checkbox-checked="allIssuablesChecked"
+ class="gl-flex-grow-1 gl-border-t-none row-content-block"
+ data-qa-selector="issuable_search_container"
+ @checked-input="handleAllIssuablesCheckedInput"
+ @onFilter="$emit('filter', $event)"
+ @onSort="$emit('sort', $event)"
+ />
+ <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismiss-alert')">{{ error }}</gl-alert>
+ <issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar">
+ <template #bulk-edit-actions>
+ <slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot>
+ </template>
+ <template #sidebar-items>
+ <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot>
+ </template>
+ </issuable-bulk-edit-sidebar>
+ <ul v-if="issuablesLoading" class="content-list">
+ <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
+ <gl-skeleton-loading />
+ </li>
+ </ul>
+ <template v-else>
+ <component
+ :is="issuablesWrapper"
+ v-if="issuables.length > 0"
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ v-bind="$options.vueDraggableAttributes"
+ @update="handleVueDraggableUpdate"
+ >
+ <issuable-item
+ v-for="issuable in issuables"
+ :key="issuableId(issuable)"
+ :class="{ 'gl-cursor-grab': isManualOrdering }"
+ data-qa-selector="issuable_container"
+ :data-qa-issuable-title="issuable.title"
+ :issuable-symbol="issuableSymbol"
+ :issuable="issuable"
+ :enable-label-permalinks="enableLabelPermalinks"
+ :label-filter-param="labelFilterParam"
+ :show-checkbox="showBulkEditSidebar"
+ :checked="issuableChecked(issuable)"
+ @checked-input="handleIssuableCheckedInput(issuable, $event)"
+ >
+ <template #reference>
+ <slot name="reference" :issuable="issuable"></slot>
+ </template>
+ <template #author>
+ <slot name="author" :author="issuable.author"></slot>
+ </template>
+ <template #timeframe>
+ <slot name="timeframe" :issuable="issuable"></slot>
+ </template>
+ <template #status>
+ <slot name="status" :issuable="issuable"></slot>
+ </template>
+ <template #statistics>
+ <slot name="statistics" :issuable="issuable"></slot>
+ </template>
+ </issuable-item>
+ </component>
+ <slot v-else name="empty-state"></slot>
+ </template>
+
+ <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
+ <gl-keyset-pagination
+ :has-next-page="hasNextPage"
+ :has-previous-page="hasPreviousPage"
+ @next="$emit('next-page')"
+ @prev="$emit('previous-page')"
+ />
+ </div>
+ <gl-pagination
+ v-else-if="showPaginationControls"
+ :per-page="defaultPageSize"
+ :total-items="totalItems"
+ :value="currentPage"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="$emit('page-change', $event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
new file mode 100644
index 00000000000..3ff87ba3c4f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ GlBadge,
+ },
+ props: {
+ tabs: {
+ type: Array,
+ required: true,
+ },
+ tabCounts: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ currentTab: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ isTabActive(tabName) {
+ return tabName === this.currentTab;
+ },
+ isTabCountNumeric(tab) {
+ return Number.isInteger(this.tabCounts[tab.name]);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="top-area">
+ <gl-tabs
+ class="gl-display-flex gl-flex-grow-1 gl-p-0 gl-m-0 mobile-separator issuable-state-filters"
+ nav-class="gl-border-b-0"
+ >
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.id"
+ :active="isTabActive(tab.name)"
+ @click="$emit('click', tab.name)"
+ >
+ <template #title>
+ <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`">
+ {{ tab.title }}
+ </span>
+ <gl-badge
+ v-if="tabCounts && isTabCountNumeric(tab)"
+ variant="muted"
+ size="sm"
+ class="gl-tab-counter-badge"
+ >
+ {{ tabCounts[tab.name] }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ <div class="nav-controls">
+ <slot name="nav-actions"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
new file mode 100644
index 00000000000..773ad0f8e93
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -0,0 +1,51 @@
+import { __ } from '~/locale';
+
+export const IssuableStates = {
+ Opened: 'opened',
+ Closed: 'closed',
+ All: 'all',
+};
+
+export const IssuableListTabs = [
+ {
+ id: 'state-opened',
+ name: IssuableStates.Opened,
+ title: __('Open'),
+ titleTooltip: __('Filter by issues that are currently opened.'),
+ },
+ {
+ id: 'state-closed',
+ name: IssuableStates.Closed,
+ title: __('Closed'),
+ titleTooltip: __('Filter by issues that are currently closed.'),
+ },
+ {
+ id: 'state-all',
+ name: IssuableStates.All,
+ title: __('All'),
+ titleTooltip: __('Show all issues.'),
+ },
+];
+
+export const AvailableSortOptions = [
+ {
+ id: 1,
+ title: __('Created date'),
+ sortDirection: {
+ descending: 'created_desc',
+ ascending: 'created_asc',
+ },
+ },
+ {
+ id: 2,
+ title: __('Last updated'),
+ sortDirection: {
+ descending: 'updated_desc',
+ ascending: 'updated_asc',
+ },
+ },
+];
+
+export const DEFAULT_PAGE_SIZE = 20;
+
+export const DEFAULT_SKELETON_COUNT = 5;
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
new file mode 100644
index 00000000000..05dc1650379
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
@@ -0,0 +1,194 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+import TaskList from '~/task_list';
+
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import IssuableDescription from './issuable_description.vue';
+import IssuableEditForm from './issuable_edit_form.vue';
+import IssuableTitle from './issuable_title.vue';
+
+export default {
+ components: {
+ GlLink,
+ TimeAgoTooltip,
+ IssuableTitle,
+ IssuableDescription,
+ IssuableEditForm,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: true,
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ enableEdit: {
+ type: Boolean,
+ required: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: true,
+ },
+ enableAutosave: {
+ type: Boolean,
+ required: true,
+ },
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editFormVisible: {
+ type: Boolean,
+ required: true,
+ },
+ showFieldTitle: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ isUpdated() {
+ return Boolean(this.issuable.updatedAt);
+ },
+ updatedBy() {
+ return this.issuable.updatedBy;
+ },
+ },
+ watch: {
+ /**
+ * When user switches between view and edit modes,
+ * taskList instance becomes invalid so whenever
+ * view mode is rendered, we need to re-initialize
+ * taskList to ensure the behaviour functional.
+ */
+ editFormVisible(value) {
+ if (!value) {
+ this.$nextTick(() => {
+ this.initTaskList();
+ });
+ }
+ },
+ },
+ mounted() {
+ if (this.enableEdit && this.enableTaskList) {
+ this.initTaskList();
+ }
+ },
+ methods: {
+ initTaskList() {
+ this.taskList = new TaskList({
+ /**
+ * We have hard-coded dataType to `issue`
+ * as currently only `issue` types can handle
+ * task-lists, however, we can still use
+ * task lists in Issue, Test Cases and Incidents
+ * as all of those are derived from `issue`.
+ */
+ dataType: 'issue',
+ fieldName: 'description',
+ lockVersion: this.taskListLockVersion,
+ selector: '.js-detail-page-description',
+ onSuccess: this.handleTaskListUpdateSuccess.bind(this),
+ onError: this.handleTaskListUpdateFailure.bind(this),
+ });
+ },
+ handleTaskListUpdateSuccess(updatedIssuable) {
+ this.$emit('task-list-update-success', updatedIssuable);
+ },
+ handleTaskListUpdateFailure() {
+ this.$emit('task-list-update-failure');
+ },
+ handleKeydownTitle(e, issuableMeta) {
+ this.$emit('keydown-title', e, issuableMeta);
+ },
+ handleKeydownDescription(e, issuableMeta) {
+ this.$emit('keydown-description', e, issuableMeta);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issue-details issuable-details">
+ <div class="detail-page-description js-detail-page-description content-block">
+ <issuable-edit-form
+ v-if="editFormVisible"
+ :issuable="issuable"
+ :enable-autocomplete="enableAutocomplete"
+ :enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
+ :show-field-title="showFieldTitle"
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ @keydown-title="handleKeydownTitle"
+ @keydown-description="handleKeydownDescription"
+ >
+ <template #edit-form-actions="issuableMeta">
+ <slot name="edit-form-actions" v-bind="issuableMeta"></slot>
+ </template>
+ </issuable-edit-form>
+ <template v-else>
+ <issuable-title
+ :issuable="issuable"
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :enable-edit="enableEdit"
+ @edit-issuable="$emit('edit-issuable', $event)"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ </issuable-title>
+ <issuable-description
+ v-if="issuable.descriptionHtml"
+ :issuable="issuable"
+ :enable-task-list="enableTaskList"
+ :can-edit="enableEdit"
+ :task-list-update-path="taskListUpdatePath"
+ />
+ <small v-if="isUpdated" class="edited-text gl-font-sm!">
+ {{ __('Edited') }}
+ <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" />
+ <span v-if="updatedBy">
+ {{ __('by') }}
+ <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!">
+ <span>{{ updatedBy.name }}</span>
+ </gl-link>
+ </span>
+ </small>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
new file mode 100644
index 00000000000..f57b5b2deb4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ enableTaskList: {
+ type: Boolean,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs.gfmContainer).renderGFM();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }">
+ <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
+ <textarea
+ v-if="issuable.description && enableTaskList"
+ ref="textarea"
+ :value="issuable.description"
+ :data-update-url="taskListUpdatePath"
+ class="gl-display-none js-task-list-field"
+ dir="auto"
+ >
+ </textarea>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue
new file mode 100644
index 00000000000..5858af6cc51
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ name: 'IssuableDiscussion',
+};
+</script>
+
+<template>
+ <section class="issuable-discussion">
+ <div>
+ <ul class="notes main-notes-list timeline">
+ <slot name="discussion"></slot>
+ </ul>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
new file mode 100644
index 00000000000..33dca3e9332
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue
@@ -0,0 +1,167 @@
+<script>
+import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import $ from 'jquery';
+
+import Autosave from '~/autosave';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import ZenMode from '~/zen_mode';
+
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ MarkdownField,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: true,
+ },
+ enableAutosave: {
+ type: Boolean,
+ required: true,
+ },
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
+ showFieldTitle: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ title: '',
+ description: '',
+ };
+ },
+ watch: {
+ issuable: {
+ handler(value) {
+ this.title = value?.title || '';
+ this.description = value?.description || '';
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
+ created() {
+ eventHub.$on('update.issuable', this.resetAutosave);
+ eventHub.$on('close.form', this.resetAutosave);
+ },
+ mounted() {
+ if (this.enableAutosave) this.initAutosave();
+
+ // eslint-disable-next-line no-new
+ if (this.enableZenMode) new ZenMode();
+ },
+ beforeDestroy() {
+ eventHub.$off('update.issuable', this.resetAutosave);
+ eventHub.$off('close.form', this.resetAutosave);
+ },
+ methods: {
+ initAutosave() {
+ const { titleInput, descriptionInput } = this.$refs;
+
+ if (!titleInput || !descriptionInput) return;
+
+ this.autosaveTitle = new Autosave($(titleInput.$el), [
+ document.location.pathname,
+ document.location.search,
+ 'title',
+ ]);
+
+ this.autosaveDescription = new Autosave($(descriptionInput.$el), [
+ document.location.pathname,
+ document.location.search,
+ 'description',
+ ]);
+ },
+ resetAutosave() {
+ this.autosaveTitle.reset();
+ this.autosaveDescription.reset();
+ },
+ handleKeydown(e, inputType) {
+ this.$emit(`keydown-${inputType}`, e, {
+ issuableTitle: this.title,
+ issuableDescription: this.description,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form>
+ <gl-form-group
+ data-testid="title"
+ :label="__('Title')"
+ :label-sr-only="!showFieldTitle"
+ label-for="issuable-title"
+ class="col-12 gl-px-0"
+ >
+ <gl-form-input
+ id="issuable-title"
+ ref="titleInput"
+ v-model.trim="title"
+ :placeholder="__('Title')"
+ :aria-label="__('Title')"
+ :autofocus="true"
+ class="qa-title-input"
+ @keydown="handleKeydown($event, 'title')"
+ />
+ </gl-form-group>
+ <gl-form-group
+ data-testid="description"
+ :label="__('Description')"
+ :label-sr-only="!showFieldTitle"
+ label-for="issuable-description"
+ label-class="gl-pb-0!"
+ class="col-12 gl-px-0 common-note-form"
+ >
+ <markdown-field
+ :markdown-preview-path="descriptionPreviewPath"
+ :markdown-docs-path="descriptionHelpPath"
+ :enable-autocomplete="enableAutocomplete"
+ :textarea-value="description"
+ >
+ <template #textarea>
+ <textarea
+ id="issuable-description"
+ ref="descriptionInput"
+ v-model="description"
+ :data-supports-quick-actions="enableAutocomplete"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files hereā€¦')"
+ class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
+ dir="auto"
+ @keydown="handleKeydown($event, 'description')"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 gl-px-0 clearfix">
+ <slot
+ name="edit-form-actions"
+ :issuable-title="title"
+ :issuable-description="description"
+ ></slot>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
new file mode 100644
index 00000000000..d7da533d055
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -0,0 +1,152 @@
+<script>
+import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
+
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isExternal } from '~/lib/utils/url_utility';
+import { n__, sprintf } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlButton,
+ GlAvatarLink,
+ GlAvatarLabeled,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ createdAt: {
+ type: String,
+ required: true,
+ },
+ author: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ blocked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ confidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ authorId() {
+ return getIdFromGraphQLId(`${this.author.id}`);
+ },
+ isAuthorExternal() {
+ return isExternal(this.author.webUrl);
+ },
+ taskStatusString() {
+ const { count, completedCount } = this.taskCompletionStatus;
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} task completed',
+ '%{completedCount} of %{count} tasks completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
+ },
+ mounted() {
+ this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
+ },
+ methods: {
+ handleRightSidebarToggleClick() {
+ if (this.toggleSidebarButtonEl) {
+ this.toggleSidebarButtonEl.dispatchEvent(new Event('click'));
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="detail-page-header">
+ <div class="detail-page-header-body">
+ <div data-testid="status" class="issuable-status-box status-box" :class="statusBadgeClass">
+ <gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" />
+ <span class="d-none d-sm-block"><slot name="status-badge"></slot></span>
+ </div>
+ <div class="issuable-meta gl-display-flex gl-align-items-center d-md-inline-block">
+ <div v-if="blocked || confidential" class="gl-display-inline-block">
+ <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
+ <gl-icon name="lock" :aria-label="__('Blocked')" />
+ </div>
+ <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline">
+ <gl-icon name="eye-slash" :aria-label="__('Confidential')" />
+ </div>
+ </div>
+ <span>
+ {{ __('Opened') }}
+ <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
+ {{ __('by') }}
+ </span>
+ <gl-avatar-link
+ data-testid="avatar"
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :href="author.webUrl"
+ target="_blank"
+ class="js-user-link gl-vertical-align-middle gl-ml-2"
+ >
+ <gl-avatar-labeled
+ :size="24"
+ :src="author.avatarUrl"
+ :label="author.name"
+ class="d-none d-sm-inline-flex gl-mx-1"
+ >
+ <template #meta>
+ <gl-icon v-if="isAuthorExternal" name="external-link" />
+ </template>
+ </gl-avatar-labeled>
+ <strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
+ </gl-avatar-link>
+ <span
+ v-if="taskCompletionStatus"
+ data-testid="task-status"
+ class="gl-display-none gl-md-display-block gl-lg-display-inline-block"
+ >{{ taskStatusString }}</span
+ >
+ </div>
+ <gl-button
+ data-testid="sidebar-toggle"
+ icon="chevron-double-lg-left"
+ class="d-block d-sm-none gutter-toggle issuable-gutter-toggle"
+ :aria-label="__('Expand sidebar')"
+ @click="handleRightSidebarToggleClick"
+ />
+ </div>
+ <div
+ data-testid="header-actions"
+ class="detail-page-header-actions gl-display-flex gl-md-display-block"
+ >
+ <slot name="header-actions"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
new file mode 100644
index 00000000000..8849af2a52e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
@@ -0,0 +1,162 @@
+<script>
+import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue';
+
+import IssuableBody from './issuable_body.vue';
+import IssuableDiscussion from './issuable_discussion.vue';
+import IssuableHeader from './issuable_header.vue';
+
+export default {
+ components: {
+ IssuableSidebar,
+ IssuableHeader,
+ IssuableBody,
+ IssuableDiscussion,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ enableEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableAutosave: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableZenMode: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ enableTaskList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editFormVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showFieldTitle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ taskListUpdatePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ taskListLockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ methods: {
+ handleKeydownTitle(e, issuableMeta) {
+ this.$emit('keydown-title', e, issuableMeta);
+ },
+ handleKeydownDescription(e, issuableMeta) {
+ this.$emit('keydown-description', e, issuableMeta);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-show-container" data-qa-selector="issuable_show_container">
+ <issuable-header
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :blocked="issuable.blocked"
+ :confidential="issuable.confidential"
+ :created-at="issuable.createdAt"
+ :author="issuable.author"
+ :task-completion-status="taskCompletionStatus"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ <template #header-actions>
+ <slot name="header-actions"></slot>
+ </template>
+ </issuable-header>
+
+ <issuable-body
+ :issuable="issuable"
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :enable-edit="enableEdit"
+ :enable-autocomplete="enableAutocomplete"
+ :enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
+ :enable-task-list="enableTaskList"
+ :edit-form-visible="editFormVisible"
+ :show-field-title="showFieldTitle"
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ :task-list-update-path="taskListUpdatePath"
+ :task-list-lock-version="taskListLockVersion"
+ @edit-issuable="$emit('edit-issuable', $event)"
+ @task-list-update-success="$emit('task-list-update-success', $event)"
+ @task-list-update-failure="$emit('task-list-update-failure')"
+ @keydown-title="handleKeydownTitle"
+ @keydown-description="handleKeydownDescription"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ <template #edit-form-actions="actionsProps">
+ <slot name="edit-form-actions" v-bind="actionsProps"></slot>
+ </template>
+ </issuable-body>
+
+ <issuable-discussion>
+ <template #discussion>
+ <slot name="discussion"></slot>
+ </template>
+ </issuable-discussion>
+
+ <issuable-sidebar>
+ <template #right-sidebar-items="{ sidebarExpanded, toggleSidebar }">
+ <slot name="right-sidebar-items" v-bind="{ sidebarExpanded, toggleSidebar }"></slot>
+ </template>
+ </issuable-sidebar>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
new file mode 100644
index 00000000000..b96ce0c43f7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -0,0 +1,101 @@
+<script>
+import {
+ GlIcon,
+ GlButton,
+ GlIntersectionObserver,
+ GlTooltipDirective,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ editTitleAndDescription: __('Edit title and description'),
+ },
+ components: {
+ GlIcon,
+ GlButton,
+ GlIntersectionObserver,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: true,
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ enableEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ stickyTitleVisible: false,
+ };
+ },
+ methods: {
+ handleTitleAppear() {
+ this.stickyTitleVisible = false;
+ },
+ handleTitleDisappear() {
+ this.stickyTitleVisible = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="title-container">
+ <h2 v-safe-html="issuable.titleHtml || issuable.title" class="title qa-title" dir="auto"></h2>
+ <gl-button
+ v-if="enableEdit"
+ v-gl-tooltip.bottom
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
+ icon="pencil"
+ class="btn-edit js-issuable-edit qa-edit-button"
+ @click="$emit('edit-issuable', $event)"
+ />
+ </div>
+ <gl-intersection-observer @appear="handleTitleAppear" @disappear="handleTitleDisappear">
+ <transition name="issuable-header-slide">
+ <div
+ v-if="stickyTitleVisible"
+ class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
+ data-testid="header"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
+ >
+ <p
+ data-testid="status"
+ class="issuable-status-box status-box gl-my-0"
+ :class="statusBadgeClass"
+ >
+ <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
+ <span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span>
+ </p>
+ <p
+ class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
+ :title="issuable.title"
+ >
+ {{ issuable.title }}
+ </p>
+ </div>
+ </div>
+ </transition>
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/constants.js b/app/assets/javascripts/vue_shared/issuable/show/constants.js
new file mode 100644
index 00000000000..346f45c7d90
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/constants.js
@@ -0,0 +1,5 @@
+export const IssuableType = {
+ Issue: 'issue',
+ Incident: 'incident',
+ TestCase: 'test_case',
+};
diff --git a/app/assets/javascripts/vue_shared/issuable/show/event_hub.js b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
new file mode 100644
index 00000000000..99dcccd12ed
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue
@@ -0,0 +1,86 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import Cookies from 'js-cookie';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ data() {
+ const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE));
+
+ // We're deliberately keeping two different props for sidebar status;
+ // 1. userExpanded reflects value based on cookie `collapsed_gutter`.
+ // 2. isExpanded reflect actual sidebar state.
+ return {
+ userExpanded,
+ isExpanded: userExpanded ? bp.isDesktop() : userExpanded,
+ };
+ },
+ mounted() {
+ window.addEventListener('resize', this.handleWindowResize);
+ this.updatePageContainerClass();
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.handleWindowResize);
+ },
+ methods: {
+ updatePageContainerClass() {
+ const layoutPageEl = document.querySelector('.layout-page');
+
+ if (layoutPageEl) {
+ layoutPageEl.classList.toggle('right-sidebar-expanded', this.isExpanded);
+ layoutPageEl.classList.toggle('right-sidebar-collapsed', !this.isExpanded);
+ }
+ },
+ handleWindowResize() {
+ if (this.userExpanded) {
+ this.isExpanded = bp.isDesktop();
+ this.updatePageContainerClass();
+ }
+ },
+ toggleSidebar() {
+ this.isExpanded = !this.isExpanded;
+ this.userExpanded = this.isExpanded;
+
+ Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded);
+ this.updatePageContainerClass();
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="{ 'right-sidebar-expanded': isExpanded, 'right-sidebar-collapsed': !isExpanded }"
+ class="right-sidebar"
+ aria-live="polite"
+ >
+ <button
+ class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!"
+ data-testid="toggle-right-sidebar-button"
+ :title="__('Toggle sidebar')"
+ @click="toggleSidebar"
+ >
+ <span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{
+ __('Collapse sidebar')
+ }}</span>
+ <gl-icon v-show="isExpanded" data-testid="icon-collapse" name="chevron-double-lg-right" />
+ <gl-icon
+ v-show="!isExpanded"
+ data-testid="icon-expand"
+ name="chevron-double-lg-left"
+ class="gl-ml-2"
+ />
+ </button>
+ <div data-testid="sidebar-items" class="issuable-sidebar">
+ <slot
+ name="right-sidebar-items"
+ v-bind="{ sidebarExpanded: isExpanded, toggleSidebar }"
+ ></slot>
+ </div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js
new file mode 100644
index 00000000000..4f4b6341a1c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js
@@ -0,0 +1 @@
+export const USER_COLLAPSED_GUTTER_COOKIE = 'collapsed_gutter';