Commit d703b898 authored by Coung Ngo's avatar Coung Ngo Committed by Douglas Barbosa Alexandre

Add tabs and header action buttons to issues list page refactor

Add Open/Closed/All tabs and header action buttons (RSS,
calendar, import/export CSV, bulk edit issues, and new issue
buttons) to issues list page refactor, behind `vue_issues_list`
feature flag defaulted to off.

https://gitlab.com/gitlab-org/gitlab/-/issues/322755
parent d0daba8c
......@@ -12,19 +12,23 @@ export default {
},
inject: {
issuableType: {
default: '',
},
issuableCount: {
default: 0,
default: ISSUABLE_TYPE.issues,
},
email: {
default: '',
},
},
props: {
exportCsvPath: {
type: String,
required: false,
default: '',
},
},
props: {
issuableCount: {
type: Number,
required: false,
default: 0,
},
modalId: {
type: String,
required: true,
......
......@@ -53,6 +53,18 @@ export default {
default: false,
},
},
props: {
exportCsvPath: {
type: String,
required: false,
default: '',
},
issuableCount: {
type: Number,
required: false,
default: undefined,
},
},
computed: {
exportModalId() {
return `${this.issuableType}-export-modal`;
......@@ -105,7 +117,12 @@ export default {
>
</gl-dropdown>
</gl-button-group>
<csv-export-modal v-if="showExportButton" :modal-id="exportModalId" />
<csv-export-modal
v-if="showExportButton"
:modal-id="exportModalId"
:export-csv-path="exportCsvPath"
:issuable-count="issuableCount"
/>
<csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
</div>
</template>
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ImportExportButtons from './components/csv_import_export_buttons.vue';
import CsvImportExportButtons from './components/csv_import_export_buttons.vue';
export default () => {
const el = document.querySelector('.js-csv-import-export-buttons');
......@@ -28,9 +28,7 @@ export default () => {
showExportButton: parseBoolean(showExportButton),
showImportButton: parseBoolean(showImportButton),
issuableType,
issuableCount,
email,
exportCsvPath,
importCsvIssuesPath,
containerClass,
canEdit: parseBoolean(canEdit),
......@@ -39,7 +37,12 @@ export default () => {
showLabel,
},
render(h) {
return h(ImportExportButtons);
return h(CsvImportExportButtons, {
props: {
exportCsvPath,
issuableCount: parseInt(issuableCount, 10),
},
});
},
});
};
......@@ -46,14 +46,11 @@ export default class IssuableBulkUpdateSidebar {
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
this.$checkAllContainer.on('click', () => this.updateFormState());
issueableEventHub.$on('issuables:updateBulkEdit', () => {
// Danger! Strong coupling ahead!
// The bulk update sidebar and its dropdowns look for checkboxes, and get data on which issue
// is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties
// explicitly, but this component is used in too many places right now to refactor straight away.
this.updateFormState();
});
// The event hub connects this bulk update logic with `issues_list_app.vue`.
// We can remove it once we've refactored the issues list page bulk edit sidebar to Vue.
// https://gitlab.com/gitlab-org/gitlab/-/issues/325874
issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true));
issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState());
}
initDropdowns() {
......@@ -110,7 +107,7 @@ export default class IssuableBulkUpdateSidebar {
}
toggleBulkEdit(e, enable) {
e.preventDefault();
e?.preventDefault();
issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
......
......@@ -26,6 +26,9 @@ export default {
isTabActive(tabName) {
return tabName === this.currentTab;
},
isTabCountNumeric(tab) {
return Number.isInteger(this.tabCounts[tab.name]);
},
},
};
</script>
......@@ -44,9 +47,13 @@ export default {
>
<template #title>
<span :title="tab.titleTooltip">{{ tab.title }}</span>
<gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-tab-counter-badge">{{
tabCounts[tab.name]
}}</gl-badge>
<gl-badge
v-if="isTabCountNumeric(tab)"
variant="neutral"
size="sm"
class="gl-tab-counter-badge"
>{{ tabCounts[tab.name] }}</gl-badge
>
</template>
</gl-tab>
</gl-tabs>
......
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableStatus } from '~/issue_show/constants';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
CREATED_DESC,
PAGE_SIZE,
......@@ -19,13 +20,18 @@ import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
CREATED_DESC,
IssuableListTabs,
PAGE_SIZE,
sortOptions,
sortParams,
i18n: {
calendarLabel: __('Subscribe to calendar'),
reorderError: __('An error occurred while reordering issues.'),
rssLabel: __('Subscribe to RSS feed'),
},
components: {
CsvImportExportButtons,
GlButton,
GlIcon,
IssuableList,
IssueCardTimeInfo,
......@@ -35,15 +41,33 @@ export default {
GlTooltip: GlTooltipDirective,
},
inject: {
calendarPath: {
default: '',
},
canBulkUpdate: {
default: false,
},
endpoint: {
default: '',
},
exportCsvPath: {
default: '',
},
fullPath: {
default: '',
},
issuesPath: {
default: '',
},
newIssuePath: {
default: '',
},
rssPath: {
default: '',
},
showNewIssueLink: {
default: false,
},
},
data() {
const orderBy = getParameterByName('order_by');
......@@ -53,20 +77,31 @@ export default {
);
return {
currentPage: toNumber(getParameterByName('page')) || 1,
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filters: sortParams[sortKey] || {},
isLoading: false,
issues: [],
page: toNumber(getParameterByName('page')) || 1,
showBulkEditSidebar: false,
sortKey: sortKey || CREATED_DESC,
state: getParameterByName('state') || IssuableStates.Opened,
totalIssues: 0,
};
},
computed: {
tabCounts() {
return Object.values(IssuableStates).reduce(
(acc, state) => ({
...acc,
[state]: this.state === state ? this.totalIssues : undefined,
}),
{},
);
},
urlParams() {
return {
page: this.currentPage,
state: IssuableStatus.Open,
page: this.page,
state: this.state,
...this.filters,
};
},
......@@ -85,23 +120,24 @@ export default {
eventHub.$off('issuables:toggleBulkEdit');
},
methods: {
fetchIssues(pageToFetch) {
fetchIssues() {
this.isLoading = true;
return axios
.get(this.endpoint, {
params: {
page: pageToFetch || this.currentPage,
page: this.page,
per_page: this.$options.PAGE_SIZE,
state: IssuableStatus.Open,
state: this.state,
with_labels_details: true,
...this.filters,
},
})
.then(({ data, headers }) => {
this.currentPage = Number(headers['x-page']);
this.page = Number(headers['x-page']);
this.totalIssues = Number(headers['x-total']);
this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
})
.catch(() => {
createFlash({ message: __('An error occurred while loading issues') });
......@@ -110,6 +146,9 @@ export default {
this.isLoading = false;
});
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
},
handleUpdateLegacyBulkEdit() {
// If "select all" checkbox was checked, wait for all checkboxes
// to be checked before updating IssuableBulkUpdateSidebar class
......@@ -117,8 +156,19 @@ export default {
eventHub.$emit('issuables:updateBulkEdit');
});
},
handleBulkUpdateClick() {
eventHub.$emit('issuables:enableBulkEdit');
},
handleClickTab(state) {
if (this.state !== state) {
this.page = 1;
}
this.state = state;
this.fetchIssues();
},
handlePageChange(page) {
this.fetchIssues(page);
this.page = page;
this.fetchIssues();
},
handleReorder({ newIndex, oldIndex }) {
const issueToMove = this.issues[oldIndex];
......@@ -171,25 +221,60 @@ export default {
:sort-options="$options.sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
:tabs="[]"
current-tab=""
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="true"
:total-items="totalIssues"
:current-page="currentPage"
:previous-page="currentPage - 1"
:next-page="currentPage + 1"
:current-page="page"
:previous-page="page - 1"
:next-page="page + 1"
:url-params="urlParams"
@click-tab="handleClickTab"
@page-change="handlePageChange"
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
>
<template #nav-actions>
<gl-button
v-gl-tooltip
:href="rssPath"
icon="rss"
:title="$options.i18n.rssLabel"
:aria-label="$options.i18n.rssLabel"
/>
<gl-button
v-gl-tooltip
:href="calendarPath"
icon="calendar"
:title="$options.i18n.calendarLabel"
:aria-label="$options.i18n.calendarLabel"
/>
<csv-import-export-buttons
class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="totalIssues"
/>
<gl-button
v-if="canBulkUpdate"
:disabled="showBulkEditSidebar"
@click="handleBulkUpdateClick"
>
{{ __('Edit issues') }}
</gl-button>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ __('New issue') }}
</gl-button>
</template>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
......
......@@ -73,12 +73,23 @@ export function initIssuesListApp() {
}
const {
calendarPath,
canBulkUpdate,
canEdit,
email,
endpoint,
exportCsvPath,
fullPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
importCsvIssuesPath,
issuesPath,
maxAttachmentSize,
newIssuePath,
projectImportJiraPath,
rssPath,
showNewIssueLink,
} = el.dataset;
return new Vue({
......@@ -87,12 +98,26 @@ export function initIssuesListApp() {
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
provide: {
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
endpoint,
fullPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
issuesPath,
newIssuePath,
rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink),
// For CsvImportExportButtons component
canEdit: parseBoolean(canEdit),
email,
exportCsvPath,
importCsvIssuesPath,
maxAttachmentSize,
projectImportJiraPath,
showExportButton: true,
showImportButton: true,
},
render: (createComponent) => createComponent(IssuesListApp),
});
......
......@@ -163,6 +163,25 @@ module IssuesHelper
}
end
def issues_list_data(project, current_user, finder)
{
calendar_path: url_for(safe_params.merge(calendar_url_options)),
can_bulk_update: can?(current_user, :admin_issue, project).to_s,
can_edit: can?(current_user, :admin_project, project).to_s,
email: current_user&.notification_email,
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path,
import_csv_issues_path: import_csv_namespace_project_issues_path,
issues_path: project_issues_path(project),
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) }),
project_import_jira_path: project_import_jira_path(project),
rss_path: url_for(safe_params.merge(rss_url_options)),
show_new_issue_link: show_new_issue_link?(project).to_s
}
end
# Overridden in EE
def scoped_labels_available?(parent)
false
......
......@@ -13,32 +13,24 @@
issues_path: project_issues_path(@project),
project_path: @project.full_path } }
- if project_issues(@project).exists?
- if Feature.enabled?(:vue_issues_list, @project)
.js-issues-list{ data: issues_list_data(@project, current_user, finder) }
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
- elsif project_issues(@project).exists?
.top-area
= render 'shared/issuable/nav', type: :issues
= render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
- if Feature.enabled?(:vue_issues_list, @project)
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
.js-issues-list{ data: { endpoint: data_endpoint,
full_path: @project.full_path,
has_blocked_issues_feature: Gitlab.ee? && @project.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: Gitlab.ee? && @project.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s,
issues_path: project_issues_path(@project) } }
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
- else
= render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
.issues-holder
= render 'issues'
- if new_issue_email
.issuable-footer.text-center
.js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
.issues-holder
= render 'issues'
- if new_issue_email
.issuable-footer.text-center
.js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
- new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
= render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true
......@@ -68,5 +68,14 @@ module EE
actions[:can_promote_to_epic] = issuable.can_be_promoted_to_epic?(current_user).to_s
actions
end
override :issues_list_data
def issues_list_data(project, current_user, finder)
super.merge!(
has_blocked_issues_feature: project.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: project.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: project.feature_available?(:issue_weights).to_s
)
end
end
end
......@@ -122,4 +122,25 @@ RSpec.describe EE::IssuesHelper do
it_behaves_like 'with license'
end
end
describe '#issues_list_data' do
it 'returns expected result' do
current_user = double.as_null_object
finder = double.as_null_object
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:finder).and_return(finder)
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:url_for).and_return('#')
allow(helper).to receive(:import_csv_namespace_project_issues_path).and_return('#')
allow(project).to receive(:feature_available?).and_return(true)
expected = {
has_blocked_issues_feature: 'true',
has_issuable_health_status_feature: 'true',
has_issue_weights_feature: 'true'
}
expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
end
end
end
......@@ -58,14 +58,14 @@ describe('CsvExportModal', () => {
describe('issuable count info text', () => {
it('displays the info text when issuableCount is > -1', () => {
wrapper = createComponent({ injectedProperties: { issuableCount: 10 } });
wrapper = createComponent({ props: { issuableCount: 10 } });
expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true);
expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected');
expect(findIcon().exists()).toBe(true);
});
it("doesn't display the info text when issuableCount is -1", () => {
wrapper = createComponent({ injectedProperties: { issuableCount: -1 } });
wrapper = createComponent({ props: { issuableCount: -1 } });
expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false);
});
});
......@@ -83,7 +83,7 @@ describe('CsvExportModal', () => {
describe('primary button', () => {
it('passes the exportCsvPath to the button', () => {
const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv';
wrapper = createComponent({ injectedProperties: { exportCsvPath } });
wrapper = createComponent({ props: { exportCsvPath } });
expect(findButton().attributes('href')).toBe(exportCsvPath);
});
});
......
......@@ -9,6 +9,9 @@ describe('CsvImportExportButtons', () => {
let wrapper;
let glModalDirective;
const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv';
const issuableCount = 10;
function createComponent(injectedProperties = {}) {
glModalDirective = jest.fn();
return extendedWrapper(
......@@ -24,6 +27,10 @@ describe('CsvImportExportButtons', () => {
provide: {
...injectedProperties,
},
propsData: {
exportCsvPath,
issuableCount,
},
}),
);
}
......@@ -57,7 +64,7 @@ describe('CsvImportExportButtons', () => {
});
it('renders the export modal', () => {
expect(findExportCsvModal().exists()).toBe(true);
expect(findExportCsvModal().props()).toMatchObject({ exportCsvPath, issuableCount });
});
it('opens the export modal', () => {
......
......@@ -34,6 +34,9 @@ describe('IssuableTabs', () => {
wrapper.destroy();
});
const findAllGlBadges = () => wrapper.findAllComponents(GlBadge);
const findAllGlTabs = () => wrapper.findAllComponents(GlTab);
describe('methods', () => {
describe('isTabActive', () => {
it.each`
......@@ -57,17 +60,19 @@ describe('IssuableTabs', () => {
describe('template', () => {
it('renders gl-tab for each tab within `tabs` array', () => {
const tabsEl = wrapper.findAll(GlTab);
const tabsEl = findAllGlTabs();
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);
const badges = findAllGlBadges();
expect(badgeEl.exists()).toBe(true);
expect(badgeEl.text()).toBe(`${mockIssuableListProps.tabCounts.opened}`);
// Does not render `All` badge since it has an undefined count
expect(badges).toHaveLength(2);
expect(badges.at(0).text()).toBe(`${mockIssuableListProps.tabCounts.opened}`);
expect(badges.at(1).text()).toBe(`${mockIssuableListProps.tabCounts.closed}`);
});
it('renders contents for slot "nav-actions"', () => {
......@@ -80,7 +85,7 @@ describe('IssuableTabs', () => {
describe('events', () => {
it('gl-tab component emits `click` event on `click` event', () => {
const tabEl = wrapper.findAll(GlTab).at(0);
const tabEl = findAllGlTabs().at(0);
tabEl.vm.$emit('click', 'opened');
......
......@@ -135,7 +135,7 @@ export const mockTabs = [
export const mockTabCounts = {
opened: 5,
closed: 0,
all: 5,
all: undefined,
};
export const mockIssuableListProps = {
......
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import {
CREATED_DESC,
PAGE_SIZE,
......@@ -24,12 +28,21 @@ describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
const fullPath = 'path/to/project';
const calendarPath = 'calendar/path';
const endpoint = 'api/endpoint';
const exportCsvPath = 'export/csv/path';
const fullPath = 'path/to/project';
const issuesPath = `${fullPath}/-/issues`;
const newIssuePath = `new/issue/path`;
const rssPath = 'rss/path';
const state = 'opened';
const xPage = 1;
const xTotal = 25;
const tabCounts = {
opened: xTotal,
closed: undefined,
all: undefined,
};
const fetchIssuesResponse = {
data: [],
headers: {
......@@ -38,14 +51,21 @@ describe('IssuesListApp component', () => {
},
};
const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findGlButtonAt = (index) => findGlButtons().at(index);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const mountComponent = () =>
const mountComponent = ({ provide = {} } = {}) =>
shallowMount(IssuesListApp, {
provide: {
calendarPath,
endpoint,
exportCsvPath,
fullPath,
issuesPath,
newIssuePath,
rssPath,
...provide,
},
});
......@@ -73,6 +93,9 @@ describe('IssuesListApp component', () => {
searchInputPlaceholder: 'Search or filter results…',
sortOptions,
initialSortBy: CREATED_DESC,
tabs: IssuableListTabs,
currentTab: IssuableStates.Opened,
tabCounts,
showPaginationControls: true,
issuables: [],
totalItems: xTotal,
......@@ -84,6 +107,85 @@ describe('IssuesListApp component', () => {
});
});
describe('header action buttons', () => {
it('renders rss button', () => {
wrapper = mountComponent();
expect(findGlButtonAt(0).attributes()).toMatchObject({
href: rssPath,
icon: 'rss',
'aria-label': IssuesListApp.i18n.rssLabel,
});
});
it('renders calendar button', () => {
wrapper = mountComponent();
expect(findGlButtonAt(1).attributes()).toMatchObject({
href: calendarPath,
icon: 'calendar',
'aria-label': IssuesListApp.i18n.calendarLabel,
});
});
it('renders csv import/export component', async () => {
const search = '?page=1&search=refactor';
Object.defineProperty(window, 'location', {
writable: true,
value: { search },
});
wrapper = mountComponent();
await waitForPromises();
expect(wrapper.findComponent(CsvImportExportButtons).props()).toMatchObject({
exportCsvPath: `${exportCsvPath}${search}`,
issuableCount: xTotal,
});
});
describe('bulk edit button', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true } });
expect(findGlButtonAt(2).text()).toBe('Edit issues');
});
it('does not render when user has permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: false } });
expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0);
});
it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true } });
jest.spyOn(eventHub, '$emit');
findGlButtonAt(2).vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit');
});
});
describe('new issue button', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true } });
expect(findGlButtonAt(2).text()).toBe('New issue');
expect(findGlButtonAt(2).attributes('href')).toBe(newIssuePath);
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: false } });
expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0);
});
});
});
describe('initial sort', () => {
it.each(Object.keys(sortParams))('is set as %s when the url query matches', (sortKey) => {
Object.defineProperty(window, 'location', {
......@@ -119,6 +221,26 @@ describe('IssuesListApp component', () => {
);
});
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, {
'x-page': 2,
'x-total': xTotal,
});
wrapper = mountComponent();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
it('makes API call to filter the list by the new state and resets the page to 1', () => {
expect(axiosMock.history.get[1].params).toMatchObject({
page: 1,
state: IssuableStates.Closed,
});
});
});
describe('when "page-change" event is emitted by IssuableList', () => {
const data = [{ id: 10, title: 'title', state }];
const page = 2;
......
......@@ -281,4 +281,48 @@ RSpec.describe IssuesHelper do
expect(helper.issue_header_actions_data(project, issue, current_user)).to include(expected)
end
end
shared_examples 'issues list data' do
it 'returns expected result' do
finder = double.as_null_object
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:finder).and_return(finder)
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:url_for).and_return('#')
allow(helper).to receive(:import_csv_namespace_project_issues_path).and_return('#')
expected = {
calendar_path: '#',
can_bulk_update: 'true',
can_edit: 'true',
email: current_user&.notification_email,
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path,
import_csv_issues_path: '#',
issues_path: project_issues_path(project),
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 }),
project_import_jira_path: project_import_jira_path(project),
rss_path: '#',
show_new_issue_link: 'true'
}
expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
end
end
describe '#issues_list_data' do
context 'when user is signed in' do
it_behaves_like 'issues list data' do
let(:current_user) { double.as_null_object }
end
end
context 'when user is anonymous' do
it_behaves_like 'issues list data' do
let(:current_user) { nil }
end
end
end
end
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