Commit b9592ccd authored by Coung Ngo's avatar Coung Ngo Committed by Jose Ivan Vargas

Add empty states and search to the issues list page refactor

parent 63c1ed12
<script> <script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { toNumber } from 'lodash'; import { toNumber } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
...@@ -14,7 +14,7 @@ import { ...@@ -14,7 +14,7 @@ import {
} from '~/issues_list/constants'; } from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale'; import { __, s__ } from '~/locale';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue'; import IssueCardTimeInfo from './issue_card_time_info.vue';
...@@ -26,13 +26,38 @@ export default { ...@@ -26,13 +26,38 @@ export default {
sortParams, sortParams,
i18n: { i18n: {
calendarLabel: __('Subscribe to calendar'), calendarLabel: __('Subscribe to calendar'),
jiraIntegrationMessage: s__(
'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.',
),
jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'),
jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'),
newIssueLabel: __('New issue'),
noClosedIssuesTitle: __('There are no closed issues'),
noOpenIssuesDescription: __('To keep this project going, create a new issue'),
noOpenIssuesTitle: __('There are no open issues'),
noIssuesSignedInDescription: __(
'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.',
),
noIssuesSignedInTitle: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project',
),
noIssuesSignedOutButtonText: __('Register / Sign In'),
noIssuesSignedOutDescription: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
),
noIssuesSignedOutTitle: __('There are no issues to show'),
noSearchResultsDescription: __('To widen your search, change or remove filters above'),
noSearchResultsTitle: __('Sorry, your filter produced no results'),
reorderError: __('An error occurred while reordering issues.'), reorderError: __('An error occurred while reordering issues.'),
rssLabel: __('Subscribe to RSS feed'), rssLabel: __('Subscribe to RSS feed'),
}, },
components: { components: {
CsvImportExportButtons, CsvImportExportButtons,
GlButton, GlButton,
GlEmptyState,
GlIcon, GlIcon,
GlLink,
GlSprintf,
IssuableList, IssuableList,
IssueCardTimeInfo, IssueCardTimeInfo,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'), BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
...@@ -47,6 +72,9 @@ export default { ...@@ -47,6 +72,9 @@ export default {
canBulkUpdate: { canBulkUpdate: {
default: false, default: false,
}, },
emptyStateSvgPath: {
default: '',
},
endpoint: { endpoint: {
default: '', default: '',
}, },
...@@ -56,9 +84,18 @@ export default { ...@@ -56,9 +84,18 @@ export default {
fullPath: { fullPath: {
default: '', default: '',
}, },
hasIssues: {
default: false,
},
isSignedIn: {
default: false,
},
issuesPath: { issuesPath: {
default: '', default: '',
}, },
jiraIntegrationPath: {
default: '',
},
newIssuePath: { newIssuePath: {
default: '', default: '',
}, },
...@@ -68,6 +105,9 @@ export default { ...@@ -68,6 +105,9 @@ export default {
showNewIssueLink: { showNewIssueLink: {
default: false, default: false,
}, },
signInPath: {
default: '',
},
}, },
data() { data() {
const orderBy = getParameterByName('order_by'); const orderBy = getParameterByName('order_by');
...@@ -76,9 +116,18 @@ export default { ...@@ -76,9 +116,18 @@ export default {
(key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort, (key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort,
); );
const search = getParameterByName('search') || '';
const tokens = search.split(' ').map((searchWord) => ({
type: 'filtered-search-term',
value: {
data: searchWord,
},
}));
return { return {
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filters: sortParams[sortKey] || {}, filters: sortParams[sortKey] || {},
filterTokens: tokens,
isLoading: false, isLoading: false,
issues: [], issues: [],
page: toNumber(getParameterByName('page')) || 1, page: toNumber(getParameterByName('page')) || 1,
...@@ -89,6 +138,23 @@ export default { ...@@ -89,6 +138,23 @@ export default {
}; };
}, },
computed: { computed: {
isManualOrdering() {
return this.sortKey === RELATIVE_POSITION_ASC;
},
isOpenTab() {
return this.state === IssuableStates.Opened;
},
searchQuery() {
return (
this.filterTokens
.map((searchTerm) => searchTerm.value.data)
.filter((searchWord) => Boolean(searchWord))
.join(' ') || undefined
);
},
showPaginationControls() {
return this.issues.length > 0;
},
tabCounts() { tabCounts() {
return Object.values(IssuableStates).reduce( return Object.values(IssuableStates).reduce(
(acc, state) => ({ (acc, state) => ({
...@@ -101,13 +167,11 @@ export default { ...@@ -101,13 +167,11 @@ export default {
urlParams() { urlParams() {
return { return {
page: this.page, page: this.page,
search: this.searchQuery,
state: this.state, state: this.state,
...this.filters, ...this.filters,
}; };
}, },
isManualOrdering() {
return this.sortKey === RELATIVE_POSITION_ASC;
},
}, },
mounted() { mounted() {
eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => { eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => {
...@@ -121,6 +185,10 @@ export default { ...@@ -121,6 +185,10 @@ export default {
}, },
methods: { methods: {
fetchIssues() { fetchIssues() {
if (!this.hasIssues) {
return undefined;
}
this.isLoading = true; this.isLoading = true;
return axios return axios
...@@ -128,6 +196,7 @@ export default { ...@@ -128,6 +196,7 @@ export default {
params: { params: {
page: this.page, page: this.page,
per_page: this.$options.PAGE_SIZE, per_page: this.$options.PAGE_SIZE,
search: this.searchQuery,
state: this.state, state: this.state,
with_labels_details: true, with_labels_details: true,
...this.filters, ...this.filters,
...@@ -166,6 +235,10 @@ export default { ...@@ -166,6 +235,10 @@ export default {
this.state = state; this.state = state;
this.fetchIssues(); this.fetchIssues();
}, },
handleFilter(filter) {
this.filterTokens = filter;
this.fetchIssues();
},
handlePageChange(page) { handlePageChange(page) {
this.page = page; this.page = page;
this.fetchIssues(); this.fetchIssues();
...@@ -214,10 +287,12 @@ export default { ...@@ -214,10 +287,12 @@ export default {
<template> <template>
<issuable-list <issuable-list
v-if="hasIssues"
:namespace="fullPath" :namespace="fullPath"
recent-searches-storage-key="issues" recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')" :search-input-placeholder="__('Search or filter results…')"
:search-tokens="[]" :search-tokens="[]"
:initial-filter-value="filterTokens"
:sort-options="$options.sortOptions" :sort-options="$options.sortOptions"
:initial-sort-by="sortKey" :initial-sort-by="sortKey"
:issuables="issues" :issuables="issues"
...@@ -227,13 +302,14 @@ export default { ...@@ -227,13 +302,14 @@ export default {
:issuables-loading="isLoading" :issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering" :is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar" :show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="true" :show-pagination-controls="showPaginationControls"
:total-items="totalIssues" :total-items="totalIssues"
:current-page="page" :current-page="page"
:previous-page="page - 1" :previous-page="page - 1"
:next-page="page + 1" :next-page="page + 1"
:url-params="urlParams" :url-params="urlParams"
@click-tab="handleClickTab" @click-tab="handleClickTab"
@filter="handleFilter"
@page-change="handlePageChange" @page-change="handlePageChange"
@reorder="handleReorder" @reorder="handleReorder"
@sort="handleSort" @sort="handleSort"
...@@ -267,7 +343,7 @@ export default { ...@@ -267,7 +343,7 @@ export default {
{{ __('Edit issues') }} {{ __('Edit issues') }}
</gl-button> </gl-button>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ __('New issue') }} {{ $options.i18n.newIssueLabel }}
</gl-button> </gl-button>
</template> </template>
...@@ -312,5 +388,81 @@ export default { ...@@ -312,5 +388,81 @@ export default {
:is-list-item="true" :is-list-item="true"
/> />
</template> </template>
<template #empty-state>
<gl-empty-state
v-if="searchQuery"
:description="$options.i18n.noSearchResultsDescription"
:title="$options.i18n.noSearchResultsTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<gl-empty-state
v-else-if="isOpenTab"
:description="$options.i18n.noOpenIssuesDescription"
:title="$options.i18n.noOpenIssuesTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<gl-empty-state
v-else
:title="$options.i18n.noClosedIssuesTitle"
:svg-path="emptyStateSvgPath"
/>
</template>
</issuable-list> </issuable-list>
<div v-else-if="isSignedIn">
<gl-empty-state
:description="$options.i18n.noIssuesSignedInDescription"
:title="$options.i18n.noIssuesSignedInTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
<csv-import-export-buttons
class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="totalIssues"
/>
</template>
</gl-empty-state>
<hr />
<p class="gl-text-center gl-font-weight-bold gl-mb-0">
{{ $options.i18n.jiraIntegrationTitle }}
</p>
<p class="gl-text-center gl-mb-0">
<gl-sprintf :message="$options.i18n.jiraIntegrationMessage">
<template #jiraDocsLink="{ content }">
<gl-link :href="jiraIntegrationPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<p class="gl-text-center gl-text-gray-500">
{{ $options.i18n.jiraIntegrationSecondaryMessage }}
</p>
</div>
<gl-empty-state
v-else
:description="$options.i18n.noIssuesSignedOutDescription"
:title="$options.i18n.noIssuesSignedOutTitle"
:svg-path="emptyStateSvgPath"
:primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
:primary-button-link="signInPath"
/>
</template> </template>
...@@ -76,20 +76,26 @@ export function initIssuesListApp() { ...@@ -76,20 +76,26 @@ export function initIssuesListApp() {
calendarPath, calendarPath,
canBulkUpdate, canBulkUpdate,
canEdit, canEdit,
canImportIssues,
email, email,
emptyStateSvgPath,
endpoint, endpoint,
exportCsvPath, exportCsvPath,
fullPath, fullPath,
hasBlockedIssuesFeature, hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature, hasIssuableHealthStatusFeature,
hasIssues,
hasIssueWeightsFeature, hasIssueWeightsFeature,
importCsvIssuesPath, importCsvIssuesPath,
isSignedIn,
issuesPath, issuesPath,
jiraIntegrationPath,
maxAttachmentSize, maxAttachmentSize,
newIssuePath, newIssuePath,
projectImportJiraPath, projectImportJiraPath,
rssPath, rssPath,
showNewIssueLink, showNewIssueLink,
signInPath,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
...@@ -100,15 +106,20 @@ export function initIssuesListApp() { ...@@ -100,15 +106,20 @@ export function initIssuesListApp() {
provide: { provide: {
calendarPath, calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate), canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
endpoint, endpoint,
fullPath, fullPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssues: parseBoolean(hasIssues),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
isSignedIn: parseBoolean(isSignedIn),
issuesPath, issuesPath,
jiraIntegrationPath,
newIssuePath, newIssuePath,
rssPath, rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink), showNewIssueLink: parseBoolean(showNewIssueLink),
signInPath,
// For CsvImportExportButtons component // For CsvImportExportButtons component
canEdit: parseBoolean(canEdit), canEdit: parseBoolean(canEdit),
email, email,
...@@ -116,8 +127,9 @@ export function initIssuesListApp() { ...@@ -116,8 +127,9 @@ export function initIssuesListApp() {
importCsvIssuesPath, importCsvIssuesPath,
maxAttachmentSize, maxAttachmentSize,
projectImportJiraPath, projectImportJiraPath,
showExportButton: true, showExportButton: parseBoolean(hasIssues),
showImportButton: true, showImportButton: parseBoolean(canImportIssues),
showLabel: !parseBoolean(hasIssues),
}, },
render: (createComponent) => createComponent(IssuesListApp), render: (createComponent) => createComponent(IssuesListApp),
}); });
......
...@@ -168,17 +168,23 @@ module IssuesHelper ...@@ -168,17 +168,23 @@ module IssuesHelper
calendar_path: url_for(safe_params.merge(calendar_url_options)), calendar_path: url_for(safe_params.merge(calendar_url_options)),
can_bulk_update: can?(current_user, :admin_issue, project).to_s, can_bulk_update: can?(current_user, :admin_issue, project).to_s,
can_edit: can?(current_user, :admin_project, project).to_s, can_edit: can?(current_user, :admin_project, project).to_s,
can_import_issues: can?(current_user, :import_issues, @project).to_s,
email: current_user&.notification_email, email: current_user&.notification_email,
empty_state_svg_path: image_path('illustrations/issues.svg'),
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project), export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path, full_path: project.full_path,
has_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path, import_csv_issues_path: import_csv_namespace_project_issues_path,
is_signed_in: current_user.present?.to_s,
issues_path: project_issues_path(project), issues_path: project_issues_path(project),
jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }), new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project), project_import_jira_path: project_import_jira_path(project),
rss_path: url_for(safe_params.merge(rss_url_options)), rss_path: url_for(safe_params.merge(rss_url_options)),
show_new_issue_link: show_new_issue_link?(project).to_s show_new_issue_link: show_new_issue_link?(project).to_s,
sign_in_path: new_user_session_path
} }
end end
......
...@@ -17666,6 +17666,9 @@ msgstr "" ...@@ -17666,6 +17666,9 @@ msgstr ""
msgid "JiraService| on branch %{branch_link}" msgid "JiraService| on branch %{branch_link}"
msgstr "" msgstr ""
msgid "JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab."
msgstr ""
msgid "JiraService|%{jira_docs_link_start}Enable the Jira integration%{jira_docs_link_end} to view your Jira issues in GitLab." msgid "JiraService|%{jira_docs_link_start}Enable the Jira integration%{jira_docs_link_end} to view your Jira issues in GitLab."
msgstr "" msgstr ""
......
import { GlButton } from '@gitlab/ui'; import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter'; import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -28,13 +28,24 @@ describe('IssuesListApp component', () => { ...@@ -28,13 +28,24 @@ describe('IssuesListApp component', () => {
let axiosMock; let axiosMock;
let wrapper; let wrapper;
const calendarPath = 'calendar/path'; const defaultProvide = {
const endpoint = 'api/endpoint'; calendarPath: 'calendar/path',
const exportCsvPath = 'export/csv/path'; canBulkUpdate: false,
const fullPath = 'path/to/project'; emptyStateSvgPath: 'empty-state.svg',
const issuesPath = `${fullPath}/-/issues`; endpoint: 'api/endpoint',
const newIssuePath = `new/issue/path`; exportCsvPath: 'export/csv/path',
const rssPath = 'rss/path'; fullPath: 'path/to/project',
hasIssues: true,
isSignedIn: false,
issuesPath: 'path/to/issues',
jiraIntegrationPath: 'jira/integration/path',
newIssuePath: 'new/issue/path',
rssPath: 'rss/path',
showImportButton: true,
showNewIssueLink: true,
signInPath: 'sign/in/path',
};
const state = 'opened'; const state = 'opened';
const xPage = 1; const xPage = 1;
const xTotal = 25; const xTotal = 25;
...@@ -51,27 +62,27 @@ describe('IssuesListApp component', () => { ...@@ -51,27 +62,27 @@ describe('IssuesListApp component', () => {
}, },
}; };
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findGlButton = () => wrapper.findComponent(GlButton);
const findGlButtons = () => wrapper.findAllComponents(GlButton); const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findGlButtonAt = (index) => findGlButtons().at(index); const findGlButtonAt = (index) => findGlButtons().at(index);
const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList); const findIssuableList = () => wrapper.findComponent(IssuableList);
const mountComponent = ({ provide = {} } = {}) => const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
shallowMount(IssuesListApp, { mountFn(IssuesListApp, {
provide: { provide: {
calendarPath, ...defaultProvide,
endpoint,
exportCsvPath,
fullPath,
issuesPath,
newIssuePath,
rssPath,
...provide, ...provide,
}, },
}); });
beforeEach(() => { beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios); axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers); axiosMock
.onGet(defaultProvide.endpoint)
.reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
}); });
afterEach(() => { afterEach(() => {
...@@ -88,7 +99,7 @@ describe('IssuesListApp component', () => { ...@@ -88,7 +99,7 @@ describe('IssuesListApp component', () => {
it('renders', () => { it('renders', () => {
expect(findIssuableList().props()).toMatchObject({ expect(findIssuableList().props()).toMatchObject({
namespace: fullPath, namespace: defaultProvide.fullPath,
recentSearchesStorageKey: 'issues', recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…', searchInputPlaceholder: 'Search or filter results…',
sortOptions, sortOptions,
...@@ -96,7 +107,7 @@ describe('IssuesListApp component', () => { ...@@ -96,7 +107,7 @@ describe('IssuesListApp component', () => {
tabs: IssuableListTabs, tabs: IssuableListTabs,
currentTab: IssuableStates.Opened, currentTab: IssuableStates.Opened,
tabCounts, tabCounts,
showPaginationControls: true, showPaginationControls: false,
issuables: [], issuables: [],
totalItems: xTotal, totalItems: xTotal,
currentPage: xPage, currentPage: xPage,
...@@ -112,7 +123,7 @@ describe('IssuesListApp component', () => { ...@@ -112,7 +123,7 @@ describe('IssuesListApp component', () => {
wrapper = mountComponent(); wrapper = mountComponent();
expect(findGlButtonAt(0).attributes()).toMatchObject({ expect(findGlButtonAt(0).attributes()).toMatchObject({
href: rssPath, href: defaultProvide.rssPath,
icon: 'rss', icon: 'rss',
'aria-label': IssuesListApp.i18n.rssLabel, 'aria-label': IssuesListApp.i18n.rssLabel,
}); });
...@@ -122,7 +133,7 @@ describe('IssuesListApp component', () => { ...@@ -122,7 +133,7 @@ describe('IssuesListApp component', () => {
wrapper = mountComponent(); wrapper = mountComponent();
expect(findGlButtonAt(1).attributes()).toMatchObject({ expect(findGlButtonAt(1).attributes()).toMatchObject({
href: calendarPath, href: defaultProvide.calendarPath,
icon: 'calendar', icon: 'calendar',
'aria-label': IssuesListApp.i18n.calendarLabel, 'aria-label': IssuesListApp.i18n.calendarLabel,
}); });
...@@ -140,8 +151,8 @@ describe('IssuesListApp component', () => { ...@@ -140,8 +151,8 @@ describe('IssuesListApp component', () => {
await waitForPromises(); await waitForPromises();
expect(wrapper.findComponent(CsvImportExportButtons).props()).toMatchObject({ expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: `${exportCsvPath}${search}`, exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
issuableCount: xTotal, issuableCount: xTotal,
}); });
}); });
...@@ -153,7 +164,7 @@ describe('IssuesListApp component', () => { ...@@ -153,7 +164,7 @@ describe('IssuesListApp component', () => {
expect(findGlButtonAt(2).text()).toBe('Edit issues'); expect(findGlButtonAt(2).text()).toBe('Edit issues');
}); });
it('does not render when user has permissions', () => { it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: false } }); wrapper = mountComponent({ provide: { canBulkUpdate: false } });
expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0); expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0);
...@@ -175,7 +186,7 @@ describe('IssuesListApp component', () => { ...@@ -175,7 +186,7 @@ describe('IssuesListApp component', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true } }); wrapper = mountComponent({ provide: { showNewIssueLink: true } });
expect(findGlButtonAt(2).text()).toBe('New issue'); expect(findGlButtonAt(2).text()).toBe('New issue');
expect(findGlButtonAt(2).attributes('href')).toBe(newIssuePath); expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath);
}); });
it('does not render when user does not have permissions', () => { it('does not render when user does not have permissions', () => {
...@@ -186,13 +197,27 @@ describe('IssuesListApp component', () => { ...@@ -186,13 +197,27 @@ describe('IssuesListApp component', () => {
}); });
}); });
describe('initial sort', () => { describe('initial url params', () => {
it.each(Object.keys(sortParams))('is set as %s when the url query matches', (sortKey) => { describe('page', () => {
it('is set from the url params', () => {
const page = 5;
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
writable: true, writable: true,
value: { value: { href: setUrlParams({ page }, TEST_HOST) },
href: setUrlParams(sortParams[sortKey], TEST_HOST), });
},
wrapper = mountComponent();
expect(findIssuableList().props('currentPage')).toBe(page);
});
});
describe('sort', () => {
it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams(sortParams[sortKey], TEST_HOST) },
}); });
wrapper = mountComponent(); wrapper = mountComponent();
...@@ -204,6 +229,22 @@ describe('IssuesListApp component', () => { ...@@ -204,6 +229,22 @@ describe('IssuesListApp component', () => {
}); });
}); });
describe('state', () => {
it('is set from the url params', () => {
const initialState = IssuableStates.All;
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams({ state: initialState }, TEST_HOST) },
});
wrapper = mountComponent();
expect(findIssuableList().props('currentTab')).toBe(initialState);
});
});
});
describe('bulk edit', () => { describe('bulk edit', () => {
describe.each([true, false])( describe.each([true, false])(
'when "issuables:toggleBulkEdit" event is received with payload `%s`', 'when "issuables:toggleBulkEdit" event is received with payload `%s`',
...@@ -221,9 +262,126 @@ describe('IssuesListApp component', () => { ...@@ -221,9 +262,126 @@ describe('IssuesListApp component', () => {
); );
}); });
describe('empty states', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
beforeEach(async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) },
});
wrapper = mountComponent({ provide: { hasIssues: true } });
await waitForPromises();
});
it('shows empty state', () => {
expect(findGlEmptyState().props()).toMatchObject({
description: IssuesListApp.i18n.noSearchResultsDescription,
title: IssuesListApp.i18n.noSearchResultsTitle,
svgPath: defaultProvide.emptyStateSvgPath,
});
});
});
describe('when "Open" tab has no issues', () => {
beforeEach(() => {
wrapper = mountComponent({ provide: { hasIssues: true } });
});
it('shows empty state', () => {
expect(findGlEmptyState().props()).toMatchObject({
description: IssuesListApp.i18n.noOpenIssuesDescription,
title: IssuesListApp.i18n.noOpenIssuesTitle,
svgPath: defaultProvide.emptyStateSvgPath,
});
});
});
describe('when "Closed" tab has no issues', () => {
beforeEach(async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) },
});
wrapper = mountComponent({ provide: { hasIssues: true } });
});
it('shows empty state', () => {
expect(findGlEmptyState().props()).toMatchObject({
title: IssuesListApp.i18n.noClosedIssuesTitle,
svgPath: defaultProvide.emptyStateSvgPath,
});
});
});
});
describe('when there are no issues', () => {
describe('when user is logged in', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasIssues: false, isSignedIn: true },
mountFn: mount,
});
});
it('shows empty state', () => {
expect(findGlEmptyState().props()).toMatchObject({
description: IssuesListApp.i18n.noIssuesSignedInDescription,
title: IssuesListApp.i18n.noIssuesSignedInTitle,
svgPath: defaultProvide.emptyStateSvgPath,
});
});
it('shows "New issue" and import/export buttons', () => {
expect(findGlButton().text()).toBe(IssuesListApp.i18n.newIssueLabel);
expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath);
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: defaultProvide.exportCsvPath,
issuableCount: 0,
});
});
it('shows Jira integration information', () => {
const paragraphs = wrapper.findAll('p');
expect(paragraphs.at(2).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle);
expect(paragraphs.at(3).text()).toContain(
'Enable the Jira integration to view your Jira issues in GitLab.',
);
expect(paragraphs.at(4).text()).toContain(
IssuesListApp.i18n.jiraIntegrationSecondaryMessage,
);
expect(findGlLink().text()).toBe('Enable the Jira integration');
expect(findGlLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath);
});
});
describe('when user is logged out', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasIssues: false, isSignedIn: false },
});
});
it('shows empty state', () => {
expect(findGlEmptyState().props()).toMatchObject({
description: IssuesListApp.i18n.noIssuesSignedOutDescription,
title: IssuesListApp.i18n.noIssuesSignedOutTitle,
svgPath: defaultProvide.emptyStateSvgPath,
primaryButtonText: IssuesListApp.i18n.noIssuesSignedOutButtonText,
primaryButtonLink: defaultProvide.signInPath,
});
});
});
});
});
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => { describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => { beforeEach(() => {
axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, { axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
'x-page': 2, 'x-page': 2,
'x-total': xTotal, 'x-total': xTotal,
}); });
...@@ -247,7 +405,7 @@ describe('IssuesListApp component', () => { ...@@ -247,7 +405,7 @@ describe('IssuesListApp component', () => {
const totalItems = 21; const totalItems = 21;
beforeEach(async () => { beforeEach(async () => {
axiosMock.onGet(endpoint).reply(200, data, { axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
'x-page': page, 'x-page': page,
'x-total': totalItems, 'x-total': totalItems,
}); });
...@@ -288,7 +446,7 @@ describe('IssuesListApp component', () => { ...@@ -288,7 +446,7 @@ describe('IssuesListApp component', () => {
const issues = [issueOne, issueTwo, issueThree, issueFour]; const issues = [issueOne, issueTwo, issueThree, issueFour];
beforeEach(async () => { beforeEach(async () => {
axiosMock.onGet(endpoint).reply(200, issues, fetchIssuesResponse.headers); axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
wrapper = mountComponent(); wrapper = mountComponent();
await waitForPromises(); await waitForPromises();
}); });
...@@ -309,7 +467,7 @@ describe('IssuesListApp component', () => { ...@@ -309,7 +467,7 @@ describe('IssuesListApp component', () => {
await waitForPromises(); await waitForPromises();
expect(axiosMock.history.put[0]).toMatchObject({ expect(axiosMock.history.put[0]).toMatchObject({
url: `${issuesPath}/${issueToMove.iid}/reorder`, url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`,
data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }), data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
}); });
}); });
...@@ -319,7 +477,7 @@ describe('IssuesListApp component', () => { ...@@ -319,7 +477,7 @@ describe('IssuesListApp component', () => {
describe('when unsuccessful', () => { describe('when unsuccessful', () => {
it('displays an error message', async () => { it('displays an error message', async () => {
axiosMock.onPut(`${issuesPath}/${issueOne.iid}/reorder`).reply(500); axiosMock.onPut(`${defaultProvide.issuesPath}/${issueOne.iid}/reorder`).reply(500);
findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
...@@ -332,7 +490,7 @@ describe('IssuesListApp component', () => { ...@@ -332,7 +490,7 @@ describe('IssuesListApp component', () => {
describe('when "sort" event is emitted by IssuableList', () => { describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(sortParams))( it.each(Object.keys(sortParams))(
'fetches issues with correct params for "sort" payload `%s`', 'fetches issues with correct params with payload `%s`',
async (sortKey) => { async (sortKey) => {
wrapper = mountComponent(); wrapper = mountComponent();
...@@ -365,4 +523,24 @@ describe('IssuesListApp component', () => { ...@@ -365,4 +523,24 @@ describe('IssuesListApp component', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
}); });
}); });
describe('when "filter" event is emitted by IssuableList', () => {
beforeEach(async () => {
wrapper = mountComponent();
const payload = [
{ type: 'filtered-search-term', value: { data: 'no' } },
{ type: 'filtered-search-term', value: { data: 'issues' } },
];
findIssuableList().vm.$emit('filter', payload);
await waitForPromises();
});
it('makes an API call to search for issues with the search term', () => {
expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' });
});
});
});
}); });
...@@ -288,24 +288,31 @@ RSpec.describe IssuesHelper do ...@@ -288,24 +288,31 @@ RSpec.describe IssuesHelper do
allow(helper).to receive(:current_user).and_return(current_user) allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:finder).and_return(finder) allow(helper).to receive(:finder).and_return(finder)
allow(helper).to receive(:can?).and_return(true) allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:url_for).and_return('#') allow(helper).to receive(:image_path).and_return('#')
allow(helper).to receive(:import_csv_namespace_project_issues_path).and_return('#') allow(helper).to receive(:import_csv_namespace_project_issues_path).and_return('#')
allow(helper).to receive(:url_for).and_return('#')
expected = { expected = {
calendar_path: '#', calendar_path: '#',
can_bulk_update: 'true', can_bulk_update: 'true',
can_edit: 'true', can_edit: 'true',
can_import_issues: 'true',
email: current_user&.notification_email, email: current_user&.notification_email,
empty_state_svg_path: '#',
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project), export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path, full_path: project.full_path,
has_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#', import_csv_issues_path: '#',
is_signed_in: current_user.present?.to_s,
issues_path: project_issues_path(project), issues_path: project_issues_path(project),
jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }), new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }),
project_import_jira_path: project_import_jira_path(project), project_import_jira_path: project_import_jira_path(project),
rss_path: '#', rss_path: '#',
show_new_issue_link: 'true' show_new_issue_link: 'true',
sign_in_path: new_user_session_path
} }
expect(helper.issues_list_data(project, current_user, finder)).to include(expected) expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
......
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