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:
Diffstat (limited to 'app/assets/javascripts/issuable_list')
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue35
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue124
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue153
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_tabs.vue5
-rw-r--r--app/assets/javascripts/issuable_list/constants.js51
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>
&nbsp;
<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;