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