diff options
Diffstat (limited to 'app/assets/javascripts/issuable_list')
5 files changed, 347 insertions, 21 deletions
diff --git a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue new file mode 100644 index 00000000000..5ca9e50d854 --- /dev/null +++ b/app/assets/javascripts/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/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index d8cb1ab07cd..1ee794ab208 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -1,15 +1,21 @@ <script> -import { GlLink, GlLabel, GlTooltipDirective } from '@gitlab/ui'; +import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { isScopedLabel } from '~/lib/utils/common_utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + export default { components: { GlLink, + GlIcon, GlLabel, + GlFormCheckbox, + IssuableAssignees, }, directives: { GlTooltip: GlTooltipDirective, @@ -24,25 +30,42 @@ export default { type: Object, required: true, }, + enableLabelPermalinks: { + type: Boolean, + required: true, + }, + showCheckbox: { + type: Boolean, + required: true, + }, + checked: { + type: Boolean, + required: false, + default: false, + }, }, computed: { author() { return this.issuable.author; }, authorId() { - const id = parseInt(this.author.id, 10); - - if (Number.isNaN(id)) { - return this.author.id.includes('gid') - ? this.author.id.split('gid://gitlab/User/').pop() - : ''; + return getIdFromGraphQLId(`${this.author.id}`); + }, + isIssuableUrlExternal() { + // Check if URL is relative, which means it is internal. + if (!/^https?:\/\//g.test(this.issuable.webUrl)) { + return false; } - - return id; + // In case URL is absolute, it may or may not be internal, + // hence use `gon.gitlab_url` which is current instance domain. + return !this.issuable.webUrl.includes(gon.gitlab_url); }, labels() { return this.issuable.labels?.nodes || this.issuable.labels || []; }, + assignees() { + return this.issuable.assignees || []; + }, createdAt() { return sprintf(__('created %{timeAgo}'), { timeAgo: getTimeago().format(this.issuable.createdAt), @@ -53,11 +76,41 @@ export default { timeAgo: getTimeago().format(this.issuable.updatedAt), }); }, + issuableTitleProps() { + if (this.isIssuableUrlExternal) { + return { + target: '_blank', + }; + } + return {}; + }, + showDiscussions() { + return typeof this.issuable.userDiscussionsCount === 'number'; + }, + showIssuableMeta() { + return Boolean( + this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees, + ); + }, }, 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 key = encodeURIComponent('label_name[]'); + const value = encodeURIComponent(this.labelTitle(label)); + return `?${key}=${value}`; + } + return '#'; + }, /** * This is needed as an independent method since * when user changes current page, `$refs.authorLink` @@ -74,17 +127,28 @@ export default { </script> <template> - <li class="issue"> + <li class="issue gl-px-5!"> <div class="issue-box"> + <div v-if="showCheckbox" class="issue-check"> + <gl-form-checkbox + class="gl-mr-0" + :checked="checked" + @input="$emit('checked-input', $event)" + /> + </div> <div class="issuable-info-container"> <div class="issuable-main-info"> <div data-testid="issuable-title" class="issue-title title"> <span class="issue-title-text" dir="auto"> - <gl-link :href="issuable.webUrl">{{ issuable.title }}</gl-link> + <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps" + >{{ issuable.title + }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" + /></gl-link> </span> </div> <div class="issuable-info"> - <span data-testid="issuable-reference" class="issuable-reference" + <slot v-if="hasSlotContents('reference')" name="reference"></slot> + <span v-else data-testid="issuable-reference" class="issuable-reference" >{{ issuableSymbol }}{{ issuable.iid }}</span > <span class="issuable-authored d-none d-sm-inline-block"> @@ -96,7 +160,9 @@ export default { >{{ createdAt }}</span > {{ __('by') }} + <slot v-if="hasSlotContents('author')" name="author"></slot> <gl-link + v-else :data-user-id="authorId" :data-username="author.username" :data-name="author.name" @@ -108,20 +174,52 @@ export default { <span class="author">{{ author.name }}</span> </gl-link> </span> + <slot name="timeframe"></slot> <gl-label v-for="(label, index) in labels" :key="index" :background-color="label.color" - :title="label.title" + :title="labelTitle(label)" :description="label.description" :scoped="scopedLabel(label)" + :target="labelTarget(label)" :class="{ 'gl-ml-2': index }" size="sm" /> </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="showDiscussions" + data-testid="issuable-discussions" + class="issuable-comments gl-display-none gl-display-sm-block" + > + <gl-link + v-gl-tooltip:tooltipcontainer.top + :title="__('Comments')" + :href="`${issuable.webUrl}#notes`" + :class="{ 'no-comments': !issuable.userDiscussionsCount }" + class="gl-reset-color!" + > + <gl-icon name="comments" /> + {{ issuable.userDiscussionsCount }} + </gl-link> + </li> + <li v-if="assignees.length" class="gl-display-flex"> + <issuable-assignees + :assignees="issuable.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> + </ul> <div data-testid="issuable-updated-at" class="float-right issuable-updated-at d-none d-sm-inline-block" diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index 7535203dea1..b2312c55f01 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -1,17 +1,23 @@ <script> -import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import IssuableTabs from './issuable_tabs.vue'; import IssuableItem from './issuable_item.vue'; +import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; + +import { DEFAULT_SKELETON_COUNT } from '../constants'; export default { components: { - GlLoadingIcon, + GlSkeletonLoading, IssuableTabs, FilteredSearchBar, IssuableItem, + IssuableBulkEditSidebar, GlPagination, }, props: { @@ -35,6 +41,11 @@ export default { type: Array, required: true, }, + urlParams: { + type: Object, + required: false, + default: () => ({}), + }, initialFilterValue: { type: Array, required: false, @@ -55,7 +66,8 @@ export default { }, tabCounts: { type: Object, - required: true, + required: false, + default: null, }, currentTab: { type: String, @@ -76,11 +88,21 @@ export default { 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, @@ -96,6 +118,92 @@ export default { required: false, default: 2, }, + enableLabelPermalinks: { + type: Boolean, + required: false, + default: true, + }, + }, + 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; + }, []); + }, + }, + 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), + title: document.title, + replace: true, + }); + } + }, + }, + }, + methods: { + issuableId(issuable) { + return issuable.id || issuable.iid || uniqueId(); + }, + issuableChecked(issuable) { + return this.checkedIssuables[this.issuableId(issuable)]?.checked; + }, + handleIssuableCheckedInput(issuable, value) { + this.checkedIssuables[this.issuableId(issuable)].checked = value; + }, + handleAllIssuablesCheckedInput(value) { + Object.keys(this.checkedIssuables).forEach(issuableId => { + this.checkedIssuables[issuableId].checked = value; + }); + }, }, }; </script> @@ -120,27 +228,60 @@ export default { :sort-options="sortOptions" :initial-filter-value="initialFilterValue" :initial-sort-by="initialSortBy" + :show-checkbox="showBulkEditSidebar" + :checkbox-checked="allIssuablesChecked" class="gl-flex-grow-1 row-content-block" + @checked-input="handleAllIssuablesCheckedInput" @onFilter="$emit('filter', $event)" @onSort="$emit('sort', $event)" /> + <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> <div class="issuables-holder"> - <gl-loading-icon v-if="issuablesLoading" size="md" class="gl-mt-5" /> + <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> <ul v-if="!issuablesLoading && issuables.length" class="content-list issuable-list issues-list" > <issuable-item v-for="issuable in issuables" - :key="issuable.id" + :key="issuableId(issuable)" :issuable-symbol="issuableSymbol" :issuable="issuable" - /> + :enable-label-permalinks="enableLabelPermalinks" + :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> + </issuable-item> </ul> <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot> <gl-pagination v-if="showPaginationControls" :per-page="defaultPageSize" + :total-items="totalItems" :value="currentPage" :prev-page="previousPage" :next-page="nextPage" diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue index df544ce69e7..d9aab004077 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -14,7 +14,8 @@ export default { }, tabCounts: { type: Object, - required: true, + required: false, + default: null, }, currentTab: { type: String, @@ -40,7 +41,7 @@ export default { > <template #title> <span :title="tab.titleTooltip">{{ tab.title }}</span> - <gl-badge variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{ + <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{ tabCounts[tab.name] }}</gl-badge> </template> diff --git a/app/assets/javascripts/issuable_list/constants.js b/app/assets/javascripts/issuable_list/constants.js new file mode 100644 index 00000000000..773ad0f8e93 --- /dev/null +++ b/app/assets/javascripts/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; |