Commit b01ff4b4 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'kp-issuable-list-enhancements' into 'master'

Enhancements for `issuable_list` app

See merge request gitlab-org/gitlab!45897
parents b0961026 823c9460
<script>
import { GlLink, GlLabel, GlTooltipDirective } from '@gitlab/ui';
import { GlLink, GlIcon, GlLabel, 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,
IssuableAssignees,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -24,25 +29,33 @@ export default {
type: Object,
required: true,
},
enableLabelPermalinks: {
type: Boolean,
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 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 +66,33 @@ export default {
timeAgo: getTimeago().format(this.issuable.updatedAt),
});
},
issuableTitleProps() {
if (this.isIssuableUrlExternal) {
return {
target: '_blank',
};
}
return {};
},
},
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 +109,20 @@ export default {
</script>
<template>
<li class="issue">
<li class="issue px-3">
<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>
<gl-link :href="issuable.webUrl" v-bind="issuableTitleProps"
>{{ issuable.title }}<gl-icon v-if="isIssuableUrlExternal" name="external-link"
/></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">
......@@ -113,15 +151,30 @@ export default {
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="hasSlotContents('status') || issuable.assignees" class="controls">
<li v-if="hasSlotContents('status')" class="issuable-status">
<slot name="status"></slot>
</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"
......
<script>
import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
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';
......@@ -35,6 +36,11 @@ export default {
type: Array,
required: true,
},
urlParams: {
type: Object,
required: false,
default: () => ({}),
},
initialFilterValue: {
type: Array,
required: false,
......@@ -55,7 +61,8 @@ export default {
},
tabCounts: {
type: Object,
required: true,
required: false,
default: null,
},
currentTab: {
type: String,
......@@ -81,6 +88,11 @@ export default {
required: false,
default: 20,
},
totalPages: {
type: Number,
required: false,
default: 0,
},
currentPage: {
type: Number,
required: false,
......@@ -96,6 +108,26 @@ export default {
required: false,
default: 2,
},
enableLabelPermalinks: {
type: Boolean,
required: false,
default: true,
},
},
watch: {
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,
});
}
},
},
},
};
</script>
......@@ -135,12 +167,21 @@ export default {
:key="issuable.id"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
/>
:enable-label-permalinks="enableLabelPermalinks"
>
<template #reference>
<slot name="reference" :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="totalPages"
:value="currentPage"
:prev-page="previousPage"
:next-page="nextPage"
......
......@@ -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>
......
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;
......@@ -11517,6 +11517,9 @@ msgstr ""
msgid "Filter by issues that are currently closed."
msgstr ""
msgid "Filter by issues that are currently opened."
msgstr ""
msgid "Filter by label"
msgstr ""
......@@ -24258,6 +24261,9 @@ msgstr ""
msgid "Show all activity"
msgstr ""
msgid "Show all issues."
msgstr ""
msgid "Show all members"
msgstr ""
......
......@@ -2,28 +2,34 @@ import { shallowMount } from '@vue/test-utils';
import { GlLink, GlLabel } from '@gitlab/ui';
import IssuableItem from '~/issuable_list/components/issuable_item.vue';
import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data';
const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) =>
const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots = {} } = {}) =>
shallowMount(IssuableItem, {
propsData: {
issuableSymbol,
issuable,
enableLabelPermalinks: true,
},
slots,
});
describe('IssuableItem', () => {
const mockLabels = mockIssuable.labels.nodes;
const mockAuthor = mockIssuable.author;
const originalUrl = gon.gitlab_url;
let wrapper;
beforeEach(() => {
gon.gitlab_url = 'http://0.0.0.0:3000';
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
gon.gitlab_url = originalUrl;
});
describe('computed', () => {
......@@ -38,8 +44,8 @@ describe('IssuableItem', () => {
authorId | returnValue
${1} | ${1}
${'1'} | ${1}
${'gid://gitlab/User/1'} | ${'1'}
${'foo'} | ${''}
${'gid://gitlab/User/1'} | ${1}
${'foo'} | ${null}
`(
'returns $returnValue when value of `issuable.author.id` is $authorId',
async ({ authorId, returnValue }) => {
......@@ -60,6 +66,30 @@ describe('IssuableItem', () => {
);
});
describe('isIssuableUrlExternal', () => {
it.each`
issuableWebUrl | urlType | returnValue
${'/gitlab-org/gitlab-test/-/issues/2'} | ${'relative'} | ${false}
${'http://0.0.0.0:3000/gitlab-org/gitlab-test/-/issues/1'} | ${'absolute and internal'} | ${false}
${'http://jira.atlassian.net/browse/IG-1'} | ${'external'} | ${true}
${'https://github.com/gitlabhq/gitlabhq/issues/1'} | ${'external'} | ${true}
`(
'returns $returnValue when `issuable.webUrl` is $urlType',
async ({ issuableWebUrl, returnValue }) => {
wrapper.setProps({
issuable: {
...mockIssuable,
webUrl: issuableWebUrl,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.isIssuableUrlExternal).toBe(returnValue);
},
);
});
describe('labels', () => {
it('returns `issuable.labels.nodes` reference when it is available', () => {
expect(wrapper.vm.labels).toEqual(mockLabels);
......@@ -92,6 +122,12 @@ describe('IssuableItem', () => {
});
});
describe('assignees', () => {
it('returns `issuable.assignees` reference when it is available', () => {
expect(wrapper.vm.assignees).toBe(mockIssuable.assignees);
});
});
describe('createdAt', () => {
it('returns string containing timeago string based on `issuable.createdAt`', () => {
expect(wrapper.vm.createdAt).toContain('created');
......@@ -120,6 +156,34 @@ describe('IssuableItem', () => {
},
);
});
describe('labelTitle', () => {
it.each`
label | propWithTitle | returnValue
${{ title: 'foo' }} | ${'title'} | ${'foo'}
${{ name: 'foo' }} | ${'name'} | ${'foo'}
`('returns string value of `label.$propWithTitle`', ({ label, returnValue }) => {
expect(wrapper.vm.labelTitle(label)).toBe(returnValue);
});
});
describe('labelTarget', () => {
it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => {
expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe(
'?label_name%5B%5D=Documentation%20Update',
);
});
it('returns string "#" for a provided label param when `enableLabelPermalinks` is false', async () => {
wrapper.setProps({
enableLabelPermalinks: false,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe('#');
});
});
});
describe('template', () => {
......@@ -128,9 +192,28 @@ describe('IssuableItem', () => {
expect(titleEl.exists()).toBe(true);
expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl);
expect(titleEl.find(GlLink).attributes('target')).not.toBeDefined();
expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title);
});
it('renders issuable title with `target` set as "_blank" when issuable.webUrl is external', async () => {
wrapper.setProps({
issuable: {
...mockIssuable,
webUrl: 'http://jira.atlassian.net/browse/IG-1',
},
});
await wrapper.vm.$nextTick();
expect(
wrapper
.find('[data-testid="issuable-title"]')
.find(GlLink)
.attributes('target'),
).toBe('_blank');
});
it('renders issuable reference', () => {
const referenceEl = wrapper.find('[data-testid="issuable-reference"]');
......@@ -138,6 +221,24 @@ describe('IssuableItem', () => {
expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`);
});
it('renders issuable reference via slot', () => {
const wrapperWithRefSlot = createComponent({
issuableSymbol: '#',
issuable: mockIssuable,
slots: {
reference: `
<b class="js-reference">${mockIssuable.iid}</b>
`,
},
});
const referenceEl = wrapperWithRefSlot.find('.js-reference');
expect(referenceEl.exists()).toBe(true);
expect(referenceEl.text()).toBe(`${mockIssuable.iid}`);
wrapperWithRefSlot.destroy();
});
it('renders issuable createdAt info', () => {
const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]');
......@@ -151,7 +252,7 @@ describe('IssuableItem', () => {
expect(authorEl.exists()).toBe(true);
expect(authorEl.attributes()).toMatchObject({
'data-user-id': wrapper.vm.authorId,
'data-user-id': `${wrapper.vm.authorId}`,
'data-username': mockAuthor.username,
'data-name': mockAuthor.name,
'data-avatar-url': mockAuthor.avatarUrl,
......@@ -170,10 +271,40 @@ describe('IssuableItem', () => {
title: mockLabels[0].title,
description: mockLabels[0].description,
scoped: false,
target: wrapper.vm.labelTarget(mockLabels[0]),
size: 'sm',
});
});
it('renders issuable status via slot', () => {
const wrapperWithStatusSlot = createComponent({
issuableSymbol: '#',
issuable: mockIssuable,
slots: {
status: `
<b class="js-status">${mockIssuable.state}</b>
`,
},
});
const statusEl = wrapperWithStatusSlot.find('.js-status');
expect(statusEl.exists()).toBe(true);
expect(statusEl.text()).toBe(`${mockIssuable.state}`);
wrapperWithStatusSlot.destroy();
});
it('renders issuable-assignees component', () => {
const assigneesEl = wrapper.find(IssuableAssignees);
expect(assigneesEl.exists()).toBe(true);
expect(assigneesEl.props()).toMatchObject({
assignees: mockIssuable.assignees,
iconSize: 16,
maxVisible: 4,
});
});
it('renders issuable updatedAt info', () => {
const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]');
......
import { mount } from '@vue/test-utils';
import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
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';
......@@ -32,6 +34,29 @@ describe('IssuableListRoot', () => {
wrapper.destroy();
});
describe('watch', () => {
describe('urlParams', () => {
it('updates window URL reflecting props within `urlParams`', async () => {
const urlParams = {
state: 'closed',
sort: 'updated_asc',
page: 1,
search: 'foo',
};
wrapper.setProps({
urlParams,
});
await wrapper.vm.$nextTick();
expect(global.window.location.href).toBe(
`${TEST_HOST}/?state=${urlParams.state}&sort=${urlParams.sort}&page=${urlParams.page}&search=${urlParams.search}`,
);
});
});
});
describe('template', () => {
it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
......@@ -114,6 +139,7 @@ describe('IssuableListRoot', () => {
it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
wrapper.setProps({
showPaginationControls: true,
totalPages: 10,
});
await wrapper.vm.$nextTick();
......@@ -125,6 +151,7 @@ describe('IssuableListRoot', () => {
value: 1,
prevPage: 0,
nextPage: 2,
totalItems: 10,
align: 'center',
});
});
......
......@@ -51,6 +51,7 @@ export const mockIssuable = {
labels: {
nodes: mockLabels,
},
assignees: [mockAuthor],
};
export const mockIssuables = [
......
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