diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/issuable')
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">·</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> + + <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'; |