Commit 9569eaa9 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'kp-add-issuable-list-app' into 'master'

Add re-usable Issuable List app

See merge request gitlab-org/gitlab!42357
parents f4762056 ed0e5836
<script>
import { GlLink, GlLabel, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { isScopedLabel } from '~/lib/utils/common_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
GlLink,
GlLabel,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
issuableSymbol: {
type: String,
required: true,
},
issuable: {
type: Object,
required: true,
},
},
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 id;
},
labels() {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
createdAt() {
return sprintf(__('created %{timeAgo}'), {
timeAgo: getTimeago().format(this.issuable.createdAt),
});
},
updatedAt() {
return sprintf(__('updated %{timeAgo}'), {
timeAgo: getTimeago().format(this.issuable.updatedAt),
});
},
},
methods: {
scopedLabel(label) {
return isScopedLabel(label);
},
/**
* 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 class="issue">
<div class="issue-box">
<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>
</span>
</div>
<div class="issuable-info">
<span data-testid="issuable-reference" class="issuable-reference"
>{{ issuableSymbol }}{{ issuable.iid }}</span
>
<span class="issuable-authored d-none d-sm-inline-block">
&middot;
<span
v-gl-tooltip:tooltipcontainer.bottom
data-testid="issuable-created-at"
:title="tooltipTitle(issuable.createdAt)"
>{{ createdAt }}</span
>
{{ __('by') }}
<gl-link
: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>
</span>
&nbsp;
<gl-label
v-for="(label, index) in labels"
:key="index"
:background-color="label.color"
:title="label.title"
:description="label.description"
:scoped="scopedLabel(label)"
:class="{ 'gl-ml-2': index }"
size="sm"
/>
</div>
</div>
<div class="issuable-meta">
<div
data-testid="issuable-updated-at"
class="float-right issuable-updated-at d-none d-sm-inline-block"
>
<span
v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(issuable.updatedAt)"
class="issuable-updated-at"
>{{ updatedAt }}</span
>
</div>
</div>
</div>
</div>
</li>
</template>
<script>
import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
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';
export default {
components: {
GlLoadingIcon,
IssuableTabs,
FilteredSearchBar,
IssuableItem,
GlPagination,
},
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,
},
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: true,
},
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,
},
defaultPageSize: {
type: Number,
required: false,
default: 20,
},
currentPage: {
type: Number,
required: false,
default: 1,
},
previousPage: {
type: Number,
required: false,
default: 0,
},
nextPage: {
type: Number,
required: false,
default: 2,
},
},
};
</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"
class="gl-flex-grow-1 row-content-block"
@onFilter="$emit('filter', $event)"
@onSort="$emit('sort', $event)"
/>
<div class="issuables-holder">
<gl-loading-icon v-if="issuablesLoading" size="md" class="gl-mt-5" />
<ul
v-if="!issuablesLoading && issuables.length"
class="content-list issuable-list issues-list"
>
<issuable-item
v-for="issuable in issuables"
:key="issuable.id"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
/>
</ul>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
<gl-pagination
v-if="showPaginationControls"
:per-page="defaultPageSize"
:value="currentPage"
:prev-page="previousPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="$emit('page-change', $event)"
/>
</div>
</div>
</template>
<script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
export default {
components: {
GlTabs,
GlTab,
GlBadge,
},
props: {
tabs: {
type: Array,
required: true,
},
tabCounts: {
type: Object,
required: true,
},
currentTab: {
type: String,
required: true,
},
},
methods: {
isTabActive(tabName) {
return tabName === this.currentTab;
},
},
};
</script>
<template>
<div class="top-area">
<gl-tabs class="nav-links mobile-separator issuable-state-filters">
<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">{{ tab.title }}</span>
<gl-badge variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{
tabCounts[tab.name]
}}</gl-badge>
</template>
</gl-tab>
</gl-tabs>
<div class="nav-controls">
<slot name="nav-actions"></slot>
</div>
</div>
</template>
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlLabel } from '@gitlab/ui';
import IssuableItem from '~/issuable_list/components/issuable_item.vue';
import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data';
const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) =>
shallowMount(IssuableItem, {
propsData: {
issuableSymbol,
issuable,
},
});
describe('IssuableItem', () => {
const mockLabels = mockIssuable.labels.nodes;
const mockAuthor = mockIssuable.author;
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('author', () => {
it('returns `issuable.author` reference', () => {
expect(wrapper.vm.author).toEqual(mockIssuable.author);
});
});
describe('authorId', () => {
it.each`
authorId | returnValue
${1} | ${1}
${'1'} | ${1}
${'gid://gitlab/User/1'} | ${'1'}
${'foo'} | ${''}
`(
'returns $returnValue when value of `issuable.author.id` is $authorId',
async ({ authorId, returnValue }) => {
wrapper.setProps({
issuable: {
...mockIssuable,
author: {
...mockAuthor,
id: authorId,
},
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.authorId).toBe(returnValue);
},
);
});
describe('labels', () => {
it('returns `issuable.labels.nodes` reference when it is available', () => {
expect(wrapper.vm.labels).toEqual(mockLabels);
});
it('returns `issuable.labels` reference when it is available', async () => {
wrapper.setProps({
issuable: {
...mockIssuable,
labels: mockLabels,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.labels).toEqual(mockLabels);
});
it('returns empty array when none of `issuable.labels.nodes` or `issuable.labels` are available', async () => {
wrapper.setProps({
issuable: {
...mockIssuable,
labels: null,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.labels).toEqual([]);
});
});
describe('createdAt', () => {
it('returns string containing timeago string based on `issuable.createdAt`', () => {
expect(wrapper.vm.createdAt).toContain('created');
expect(wrapper.vm.createdAt).toContain('ago');
});
});
describe('updatedAt', () => {
it('returns string containing timeago string based on `issuable.updatedAt`', () => {
expect(wrapper.vm.updatedAt).toContain('updated');
expect(wrapper.vm.updatedAt).toContain('ago');
});
});
});
describe('methods', () => {
describe('scopedLabel', () => {
it.each`
label | labelType | returnValue
${mockRegularLabel} | ${'regular'} | ${false}
${mockScopedLabel} | ${'scoped'} | ${true}
`(
'return $returnValue when provided label param is a $labelType label',
({ label, returnValue }) => {
expect(wrapper.vm.scopedLabel(label)).toBe(returnValue);
},
);
});
});
describe('template', () => {
it('renders issuable title', () => {
const titleEl = wrapper.find('[data-testid="issuable-title"]');
expect(titleEl.exists()).toBe(true);
expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl);
expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title);
});
it('renders issuable reference', () => {
const referenceEl = wrapper.find('[data-testid="issuable-reference"]');
expect(referenceEl.exists()).toBe(true);
expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`);
});
it('renders issuable createdAt info', () => {
const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]');
expect(createdAtEl.exists()).toBe(true);
expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm GMT+0000');
expect(createdAtEl.text()).toBe(wrapper.vm.createdAt);
});
it('renders issuable author info', () => {
const authorEl = wrapper.find('[data-testid="issuable-author"]');
expect(authorEl.exists()).toBe(true);
expect(authorEl.attributes()).toMatchObject({
'data-user-id': wrapper.vm.authorId,
'data-username': mockAuthor.username,
'data-name': mockAuthor.name,
'data-avatar-url': mockAuthor.avatarUrl,
href: mockAuthor.webUrl,
});
expect(authorEl.text()).toBe(mockAuthor.name);
});
it('renders gl-label component for each label present within `issuable` prop', () => {
const labelsEl = wrapper.findAll(GlLabel);
expect(labelsEl.exists()).toBe(true);
expect(labelsEl).toHaveLength(mockLabels.length);
expect(labelsEl.at(0).props()).toMatchObject({
backgroundColor: mockLabels[0].color,
title: mockLabels[0].title,
description: mockLabels[0].description,
scoped: false,
size: 'sm',
});
});
it('renders issuable updatedAt info', () => {
const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]');
expect(updatedAtEl.exists()).toBe(true);
expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am GMT+0000');
expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt);
});
});
});
import { mount } from '@vue/test-utils';
import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue';
import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
import IssuableItem from '~/issuable_list/components/issuable_item.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { mockIssuableListProps } from '../mock_data';
const createComponent = (propsData = mockIssuableListProps) =>
mount(IssuableListRoot, {
propsData,
slots: {
'nav-actions': `
<button class="js-new-issuable">New issuable</button>
`,
'empty-state': `
<p class="js-issuable-empty-state">Issuable empty state</p>
`,
},
});
describe('IssuableListRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
});
it('renders issuable-tabs component', () => {
const tabsEl = wrapper.find(IssuableTabs);
expect(tabsEl.exists()).toBe(true);
expect(tabsEl.props()).toMatchObject({
tabs: wrapper.vm.tabs,
tabCounts: wrapper.vm.tabCounts,
currentTab: wrapper.vm.currentTab,
});
});
it('renders contents for slot "nav-actions" within issuable-tab component', () => {
const buttonEl = wrapper.find(IssuableTabs).find('button.js-new-issuable');
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.text()).toBe('New issuable');
});
it('renders filtered-search-bar component', () => {
const searchEl = wrapper.find(FilteredSearchBar);
const {
namespace,
recentSearchesStorageKey,
searchInputPlaceholder,
searchTokens,
sortOptions,
initialFilterValue,
initialSortBy,
} = wrapper.vm;
expect(searchEl.exists()).toBe(true);
expect(searchEl.props()).toMatchObject({
namespace,
recentSearchesStorageKey,
searchInputPlaceholder,
tokens: searchTokens,
sortOptions,
initialFilterValue,
initialSortBy,
});
});
it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => {
wrapper.setProps({
issuablesLoading: true,
});
await wrapper.vm.$nextTick();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders issuable-item component for each item within `issuables` array', () => {
const itemsEl = wrapper.findAll(IssuableItem);
const mockIssuable = mockIssuableListProps.issuables[0];
expect(itemsEl).toHaveLength(mockIssuableListProps.issuables.length);
expect(itemsEl.at(0).props()).toMatchObject({
issuableSymbol: wrapper.vm.issuableSymbol,
issuable: mockIssuable,
});
});
it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => {
wrapper.setProps({
issuables: [],
});
await wrapper.vm.$nextTick();
expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true);
expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state');
});
it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
wrapper.setProps({
showPaginationControls: true,
});
await wrapper.vm.$nextTick();
const paginationEl = wrapper.find(GlPagination);
expect(paginationEl.exists()).toBe(true);
expect(paginationEl.props()).toMatchObject({
perPage: 20,
value: 1,
prevPage: 0,
nextPage: 2,
align: 'center',
});
});
});
describe('events', () => {
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
wrapper.find(IssuableTabs).vm.$emit('click');
expect(wrapper.emitted('click-tab')).toBeTruthy();
});
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
const searchEl = wrapper.find(FilteredSearchBar);
searchEl.vm.$emit('onFilter');
expect(wrapper.emitted('filter')).toBeTruthy();
searchEl.vm.$emit('onSort');
expect(wrapper.emitted('sort')).toBeTruthy();
});
it('gl-pagination component emits `page-change` event on `input` event', async () => {
wrapper.setProps({
showPaginationControls: true,
});
await wrapper.vm.$nextTick();
wrapper.find(GlPagination).vm.$emit('input');
expect(wrapper.emitted('page-change')).toBeTruthy();
});
});
});
import { mount } from '@vue/test-utils';
import { GlTab, GlBadge } from '@gitlab/ui';
import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
import { mockIssuableListProps } from '../mock_data';
const createComponent = ({
tabs = mockIssuableListProps.tabs,
tabCounts = mockIssuableListProps.tabCounts,
currentTab = mockIssuableListProps.currentTab,
} = {}) =>
mount(IssuableTabs, {
propsData: {
tabs,
tabCounts,
currentTab,
},
slots: {
'nav-actions': `
<button class="js-new-issuable">New issuable</button>
`,
},
});
describe('IssuableTabs', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('isTabActive', () => {
it.each`
tabName | currentTab | returnValue
${'opened'} | ${'opened'} | ${true}
${'opened'} | ${'closed'} | ${false}
`(
'returns $returnValue when tab name is "$tabName" is current tab is "$currentTab"',
async ({ tabName, currentTab, returnValue }) => {
wrapper.setProps({
currentTab,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.isTabActive(tabName)).toBe(returnValue);
},
);
});
});
describe('template', () => {
it('renders gl-tab for each tab within `tabs` array', () => {
const tabsEl = wrapper.findAll(GlTab);
expect(tabsEl.exists()).toBe(true);
expect(tabsEl).toHaveLength(mockIssuableListProps.tabs.length);
});
it('renders gl-badge component within a tab', () => {
const badgeEl = wrapper.findAll(GlBadge).at(0);
expect(badgeEl.exists()).toBe(true);
expect(badgeEl.text()).toBe(`${mockIssuableListProps.tabCounts.opened}`);
});
it('renders contents for slot "nav-actions"', () => {
const buttonEl = wrapper.find('button.js-new-issuable');
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.text()).toBe('New issuable');
});
});
describe('events', () => {
it('gl-tab component emits `click` event on `click` event', () => {
const tabEl = wrapper.findAll(GlTab).at(0);
tabEl.vm.$emit('click', 'opened');
expect(wrapper.emitted('click')).toBeTruthy();
expect(wrapper.emitted('click')[0]).toEqual(['opened']);
});
});
});
import {
mockAuthorToken,
mockLabelToken,
mockSortOptions,
} from 'jest/vue_shared/components/filtered_search_bar/mock_data';
export const mockAuthor = {
id: 'gid://gitlab/User/1',
avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
name: 'Administrator',
username: 'root',
webUrl: 'http://0.0.0.0:3000/root',
};
export const mockRegularLabel = {
id: 'gid://gitlab/GroupLabel/2048',
title: 'Documentation Update',
description: null,
color: '#F0AD4E',
textColor: '#FFFFFF',
};
export const mockScopedLabel = {
id: 'gid://gitlab/ProjectLabel/2049',
title: 'status::confirmed',
description: null,
color: '#D9534F',
textColor: '#FFFFFF',
};
export const mockLabels = [mockRegularLabel, mockScopedLabel];
export const mockIssuable = {
iid: '30',
title: 'Dismiss Cipher with no integrity',
description: null,
createdAt: '2020-06-29T13:52:56Z',
updatedAt: '2020-09-10T11:41:13Z',
webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/30',
author: mockAuthor,
labels: {
nodes: mockLabels,
},
};
export const mockIssuables = [
mockIssuable,
{
iid: '28',
title: 'Dismiss Cipher with no integrity',
description: null,
createdAt: '2020-06-29T13:52:56Z',
updatedAt: '2020-06-29T13:52:56Z',
webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/28',
author: mockAuthor,
labels: {
nodes: [],
},
},
{
iid: '7',
title: 'Temporibus in veritatis labore explicabo velit molestiae sed.',
description: 'Quo consequatur rem aliquid laborum quibusdam molestiae saepe.',
createdAt: '2020-06-25T13:50:14Z',
updatedAt: '2020-08-25T06:09:27Z',
webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/7',
author: mockAuthor,
labels: {
nodes: mockLabels,
},
},
{
iid: '17',
title: 'Vel voluptatem quaerat est hic incidunt qui ut aliquid sit exercitationem.',
description: 'Incidunt accusamus perspiciatis aut excepturi.',
createdAt: '2020-06-19T13:51:36Z',
updatedAt: '2020-08-11T13:36:35Z',
webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/17',
author: mockAuthor,
labels: {
nodes: [],
},
},
{
iid: '16',
title: 'Vero qui quo labore libero omnis quisquam et cumque.',
description: 'Ipsa ipsum magni nostrum alias aut exercitationem.',
createdAt: '2020-06-19T13:51:36Z',
updatedAt: '2020-06-19T13:51:36Z',
webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/16',
author: mockAuthor,
labels: {
nodes: [],
},
},
];
export const mockTabs = [
{
id: 'state-opened',
name: 'opened',
title: 'Open',
titleTooltip: 'Filter by issuables that are currently opened.',
},
{
id: 'state-archived',
name: 'closed',
title: 'Closed',
titleTooltip: 'Filter by issuables that are currently archived.',
},
{
id: 'state-all',
name: 'all',
title: 'All',
titleTooltip: 'Show all issuables.',
},
];
export const mockTabCounts = {
opened: 5,
closed: 0,
all: 5,
};
export const mockIssuableListProps = {
namespace: 'gitlab-org/gitlab-test',
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search issues',
searchTokens: [mockAuthorToken, mockLabelToken],
sortOptions: mockSortOptions,
issuables: mockIssuables,
tabs: mockTabs,
tabCounts: mockTabCounts,
currentTab: 'opened',
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment