Commit 9bd14535 authored by wortschi's avatar wortschi

Replace CSV import and export modals with Vue component

- This replaces the HAML version of the
CSV import and export modals with
Vue components
parent bea43e50
<script>
import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
import { ISSUABLE_TYPE } from '../constants';
export default {
name: 'CsvExportModal',
components: {
GlButton,
GlModal,
GlSprintf,
GlIcon,
},
inject: {
issuableType: {
default: '',
},
issuableCount: {
default: 0,
},
email: {
default: '',
},
exportCsvPath: {
default: '',
},
},
props: {
modalId: {
type: String,
required: true,
},
},
data() {
return {
// eslint-disable-next-line @gitlab/require-i18n-strings
issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
};
},
issueableType: ISSUABLE_TYPE,
};
</script>
<template>
<gl-modal :modal-id="modalId" body-class="gl-p-0!">
<template #modal-title>
<gl-sprintf :message="__('Export %{name}')">
<template #name>{{ issuableName }}</template>
</gl-sprintf>
</template>
<div
v-if="issuableCount > -1"
data-testid="issuable-count-note"
class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50"
>
<gl-icon name="check" class="gl-color-green-400" />
<strong class="gl-m-3">
<gl-sprintf
v-if="issuableType === $options.issueableType.issues"
:message="n__('1 issue selected', '%d issues selected', issuableCount)"
>
<template #issuableCount>{{ issuableCount }}</template>
</gl-sprintf>
<gl-sprintf
v-else
:message="n__('1 merge request selected', '%d merge request selected', issuableCount)"
>
<template #issuableCount>{{ issuableCount }}</template>
</gl-sprintf>
</strong>
</div>
<div class="modal-text gl-px-4 gl-py-5">
<gl-sprintf
:message="
__(
`The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`,
)
"
>
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</div>
<template #modal-footer>
<gl-button
category="primary"
variant="confirm"
:href="exportCsvPath"
data-method="post"
:data-qa-selector="`export_${issuableType}_button`"
data-track-event="click_button"
:data-track-label="`export_${issuableType}_csv`"
>
<gl-sprintf :message="__('Export %{name}')">
<template #name>{{ issuableName }}</template>
</gl-sprintf>
</gl-button>
</template>
</gl-modal>
</template>
<script>
import {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownItem,
GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui';
import CsvExportModal from './csv_export_modal.vue';
import CsvImportModal from './csv_import_modal.vue';
export default {
name: 'CsvImportExportButtons',
components: {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownItem,
CsvExportModal,
CsvImportModal,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
inject: {
showExportButton: {
default: false,
},
showImportButton: {
default: false,
},
containerClass: {
default: '',
},
canEdit: {
default: false,
},
projectImportJiraPath: {
default: null,
},
},
computed: {
exportModalId() {
return `${this.issuableType}-export-modal`;
},
importModalId() {
return `${this.issuableType}-import-modal`;
},
},
};
</script>
<template>
<div :class="containerClass">
<gl-button-group>
<gl-button
v-if="showExportButton"
v-gl-tooltip.hover="__('Export as CSV')"
v-gl-modal="exportModalId"
icon="export"
data-testid="export-csv-button"
/>
<gl-dropdown
v-if="showImportButton"
v-gl-tooltip.hover="__('Import issues')"
data-testid="import-csv-dropdown"
icon="import"
>
<gl-dropdown-item v-gl-modal="importModalId" data-testid="import-csv-link">{{
__('Import CSV')
}}</gl-dropdown-item>
<gl-dropdown-item
v-if="canEdit"
:href="projectImportJiraPath"
data-qa-selector="import_from_jira_link"
data-testid="import-from-jira-link"
>{{ __('Import from Jira') }}</gl-dropdown-item
>
</gl-dropdown>
</gl-button-group>
<csv-export-modal v-if="showExportButton" :modal-id="exportModalId" />
<csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
</div>
</template>
<script>
import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { ISSUABLE_TYPE } from '../constants';
export default {
name: 'CsvImportModal',
components: {
GlModal,
GlSprintf,
GlFormGroup,
GlButton,
},
inject: {
issuableType: {
default: '',
},
exportCsvPath: {
default: '',
},
importCsvIssuesPath: {
default: '',
},
maxAttachmentSize: {
default: 0,
},
},
props: {
modalId: {
type: String,
required: true,
},
},
data() {
return {
// eslint-disable-next-line @gitlab/require-i18n-strings
issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
};
},
methods: {
submitForm() {
this.$refs.form.submit();
},
},
csrf,
};
</script>
<template>
<gl-modal :modal-id="modalId" :title="__('Import issues')">
<form
ref="form"
:action="importCsvIssuesPath"
enctype="multipart/form-data"
method="post"
data-testid="import-csv-form"
>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<p>
{{
__(
"Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
)
}}
</p>
<gl-form-group :label="__('Upload CSV file')" label-for="file">
<input id="file" type="file" name="file" accept=".csv,text/csv" />
</gl-form-group>
<p class="text-secondary">
{{
__(
'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
)
}}
<gl-sprintf :message="__('The maximum file size allowed is %{size}.')"
><template #size>{{ maxAttachmentSize }}</template></gl-sprintf
>
</p>
</form>
<template #modal-footer>
<gl-button category="primary" variant="confirm" @click="submitForm">{{
__('Import issues')
}}</gl-button>
</template>
</gl-modal>
</template>
export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change';
export const ISSUABLE_TYPE = {
issues: 'issues',
mergeRequests: 'merge-requests',
};
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ImportExportButtons from './components/csv_import_export_buttons.vue';
export default () => {
const el = document.querySelector('.js-csv-import-export-buttons');
if (!el) return null;
const {
showExportButton,
showImportButton,
issuableType,
issuableCount,
email,
exportCsvPath,
importCsvIssuesPath,
containerClass,
canEdit,
projectImportJiraPath,
maxAttachmentSize,
} = el.dataset;
return new Vue({
el,
provide: {
showExportButton: parseBoolean(showExportButton),
showImportButton: parseBoolean(showImportButton),
issuableType,
issuableCount,
email,
exportCsvPath,
importCsvIssuesPath,
containerClass,
canEdit: parseBoolean(canEdit),
projectImportJiraPath,
maxAttachmentSize,
},
render(h) {
return h(ImportExportButtons);
},
});
};
......@@ -2,6 +2,7 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
import initIssuablesList from '~/issues_list';
......@@ -26,3 +27,4 @@ new UsersSelect();
initManualOrdering();
initIssuablesList();
initIssuableByEmail();
initCsvImportExportButtons();
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
import { FILTERED_SEARCH } from '~/pages/constants';
......@@ -22,3 +23,4 @@ new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initIssuableByEmail();
initCsvImportExportButtons();
- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true)
- show_import_button = local_assigns.fetch(:show_import_button, true) && can?(current_user, :import_issues, @project)
- show_export_button = local_assigns.fetch(:show_export_button, true)
- issuable_type = 'issues'
- can_edit = can?(current_user, :admin_project, @project)
- notification_email = @current_user.present? ? @current_user.notification_email : nil
.nav-controls.issues-nav-controls
- if show_feed_buttons
= render 'shared/issuable/feed_buttons'
.btn-group
- if show_export_button
= render 'shared/issuable/csv_export/button', issuable_type: 'issues'
- if show_import_button
= render 'projects/issues/import_csv/button'
.js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project) } }
- if @can_bulk_update
= button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
......@@ -19,11 +17,6 @@
= link_to _("New issue"), new_project_issue_path(@project,
issue: { assignee_id: finder.assignee.try(:id),
milestone_id: finder.milestones.first.try(:id) }),
class: "gl-button btn btn-success",
class: "gl-button btn btn-confirm",
id: "new_issue_link"
- if show_export_button
= render 'shared/issuable/csv_export/modal', issuable_type: 'issues'
- if show_import_button
= render 'projects/issues/import_csv/modal'
.btn-group
= render 'shared/issuable/csv_export/button', issuable_type: 'merge-requests'
- issuable_type = 'merge-requests'
- notification_email = @current_user.present? ? @current_user.notification_email : nil
.js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } }
- if @can_bulk_update
= button_tag "Edit merge requests", class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
- if merge_project
= link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: "New merge request" do
New merge request
= render 'shared/issuable/csv_export/modal', issuable_type: 'merge_requests'
---
title: Replace import/export CSV modal with Vue component
merge_request: 54214
author:
type: other
......@@ -1163,6 +1163,16 @@ msgid_plural "%d hours"
msgstr[0] ""
msgstr[1] ""
msgid "1 issue selected"
msgid_plural "%d issues selected"
msgstr[0] ""
msgstr[1] ""
msgid "1 merge request selected"
msgid_plural "%d merge request selected"
msgstr[0] ""
msgstr[1] ""
msgid "1 merged merge request"
msgid_plural "%{merge_requests} merged merge requests"
msgstr[0] ""
......@@ -12392,6 +12402,9 @@ msgstr ""
msgid "Export"
msgstr ""
msgid "Export %{name}"
msgstr ""
msgid "Export %{requirementsCount} requirements?"
msgstr ""
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Issues csv' do
RSpec.describe 'Issues csv', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
......@@ -17,7 +17,7 @@ RSpec.describe 'Issues csv' do
def request_csv(params = {})
visit project_issues_path(project, params)
page.within('.nav-controls') do
click_on 'Export as CSV'
find('[data-testid="export-csv-button"]').click
end
click_on 'Export issues'
end
......
......@@ -14,11 +14,13 @@ RSpec.describe 'Merge Requests > Exports as CSV', :js do
subject { page.find('.nav-controls') }
it { is_expected.to have_button('Export as CSV') }
it { is_expected.to have_selector '[data-testid="export-csv-button"]' }
context 'button is clicked' do
before do
click_button('Export as CSV')
page.within('.nav-controls') do
find('[data-testid="export-csv-button"]').click
end
end
it 'shows a success message' do
......
import { GlModal, GlIcon, GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CsvExportModal from '~/issuable/components/csv_export_modal.vue';
describe('CsvExportModal', () => {
let wrapper;
function createComponent(options = {}) {
const { injectedProperties = {}, props = {} } = options;
return extendedWrapper(
mount(CsvExportModal, {
propsData: {
modalId: 'csv-export-modal',
...props,
},
provide: {
issuableType: 'issues',
...injectedProperties,
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
const findModal = () => wrapper.findComponent(GlModal);
const findIcon = () => wrapper.findComponent(GlIcon);
const findButton = () => wrapper.findComponent(GlButton);
describe('template', () => {
describe.each`
issuableType | modalTitle
${'issues'} | ${'Export issues'}
${'merge-requests'} | ${'Export merge requests'}
`('with the issuableType "$issuableType"', ({ issuableType, modalTitle }) => {
beforeEach(() => {
wrapper = createComponent({ injectedProperties: { issuableType } });
});
it('displays the modal title "$modalTitle"', () => {
expect(findModal().text()).toContain(modalTitle);
});
it('displays the button with title "$modalTitle"', () => {
expect(findButton().text()).toBe(modalTitle);
});
});
describe('issuable count info text', () => {
it('displays the info text when issuableCount is > -1', () => {
wrapper = createComponent({ injectedProperties: { 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 } });
expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false);
});
});
describe('email info text', () => {
it('displays the proper email', () => {
const email = 'admin@example.com';
wrapper = createComponent({ injectedProperties: { email } });
expect(findModal().text()).toContain(
`The CSV export will be created in the background. Once finished, it will be sent to ${email} in an attachment.`,
);
});
});
describe('primary button', () => {
it('passes the exportCsvPath to the button', () => {
const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv';
wrapper = createComponent({ injectedProperties: { exportCsvPath } });
expect(findButton().attributes('href')).toBe(exportCsvPath);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CsvExportModal from '~/issuable/components/csv_export_modal.vue';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import CsvImportModal from '~/issuable/components/csv_import_modal.vue';
describe('CsvImportExportButtons', () => {
let wrapper;
let glModalDirective;
function createComponent(injectedProperties = {}) {
glModalDirective = jest.fn();
return extendedWrapper(
shallowMount(CsvImportExportButtons, {
directives: {
GlTooltip: createMockDirective(),
glModal: {
bind(_, { value }) {
glModalDirective(value);
},
},
},
provide: {
...injectedProperties,
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
const findExportCsvButton = () => wrapper.findByTestId('export-csv-button');
const findImportDropdown = () => wrapper.findByTestId('import-csv-dropdown');
const findImportCsvButton = () => wrapper.findByTestId('import-csv-dropdown');
const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link');
const findExportCsvModal = () => wrapper.findComponent(CsvExportModal);
const findImportCsvModal = () => wrapper.findComponent(CsvImportModal);
describe('template', () => {
describe('when the showExportButton=true', () => {
beforeEach(() => {
wrapper = createComponent({ showExportButton: true });
});
it('displays the export button', () => {
expect(findExportCsvButton().exists()).toBe(true);
});
it('export button has a tooltip', () => {
const tooltip = getBinding(findExportCsvButton().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe('Export as CSV');
});
it('renders the export modal', () => {
expect(findExportCsvModal().exists()).toBe(true);
});
it('opens the export modal', () => {
findExportCsvButton().trigger('click');
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.exportModalId);
});
});
describe('when the showExportButton=false', () => {
beforeEach(() => {
wrapper = createComponent({ showExportButton: false });
});
it('does not display the export button', () => {
expect(findExportCsvButton().exists()).toBe(false);
});
it('does not render the export modal', () => {
expect(findExportCsvModal().exists()).toBe(false);
});
});
describe('when the showImportButton=true', () => {
beforeEach(() => {
wrapper = createComponent({ showImportButton: true });
});
it('displays the import dropdown', () => {
expect(findImportDropdown().exists()).toBe(true);
});
it('renders the import button', () => {
expect(findImportCsvButton().exists()).toBe(true);
});
it('import button has a tooltip', () => {
const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe('Import issues');
});
it('renders the import modal', () => {
expect(findImportCsvModal().exists()).toBe(true);
});
it('opens the import modal', () => {
findImportCsvButton().trigger('click');
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.importModalId);
});
describe('import from jira link', () => {
const projectImportJiraPath = 'gitlab-org/gitlab-test/-/import/jira';
beforeEach(() => {
wrapper = createComponent({
showImportButton: true,
canEdit: true,
projectImportJiraPath,
});
});
describe('when canEdit=true', () => {
it('renders the import dropdown item', () => {
expect(findImportFromJiraLink().exists()).toBe(true);
});
it('passes the proper path to the link', () => {
expect(findImportFromJiraLink().attributes('href')).toBe(projectImportJiraPath);
});
});
describe('when canEdit=false', () => {
beforeEach(() => {
wrapper = createComponent({ showImportButton: true, canEdit: false });
});
it('does not render the import dropdown item', () => {
expect(findImportFromJiraLink().exists()).toBe(false);
});
});
});
});
describe('when the showImportButton=false', () => {
beforeEach(() => {
wrapper = createComponent({ showImportButton: false });
});
it('does not display the import dropdown', () => {
expect(findImportDropdown().exists()).toBe(false);
});
it('does not render the import modal', () => {
expect(findImportCsvModal().exists()).toBe(false);
});
});
});
});
import { GlModal } from '@gitlab/ui';
import { getByRole, getByLabelText } from '@testing-library/dom';
import { mount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CsvImportModal from '~/issuable/components/csv_import_modal.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('CsvImportModal', () => {
let wrapper;
let formSubmitSpy;
function createComponent(options = {}) {
const { injectedProperties = {}, props = {} } = options;
return extendedWrapper(
mount(CsvImportModal, {
propsData: {
modalId: 'csv-import-modal',
...props,
},
provide: {
issuableType: 'issues',
...injectedProperties,
},
stubs: {
GlModal: stubComponent(GlModal, {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
}),
);
}
beforeEach(() => {
formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
});
const findModal = () => wrapper.findComponent(GlModal);
const findPrimaryButton = () => getByRole(wrapper.element, 'button', { name: 'Import issues' });
const findForm = () => wrapper.findByTestId('import-csv-form');
const findFileInput = () => getByLabelText(wrapper.element, 'Upload CSV file');
const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token');
describe('template', () => {
it('displays modal title', () => {
wrapper = createComponent();
expect(findModal().text()).toContain('Import issues');
});
it('displays a note about the maximum allowed file size', () => {
const maxAttachmentSize = 500;
wrapper = createComponent({ injectedProperties: { maxAttachmentSize } });
expect(findModal().text()).toContain(`The maximum file size allowed is ${maxAttachmentSize}`);
});
describe('form', () => {
const importCsvIssuesPath = 'gitlab-org/gitlab-test/-/issues/import_csv';
beforeEach(() => {
wrapper = createComponent({ injectedProperties: { importCsvIssuesPath } });
});
it('displays the form with the correct action and inputs', () => {
expect(findForm().exists()).toBe(true);
expect(findForm().attributes('action')).toBe(importCsvIssuesPath);
expect(findAuthenticityToken()).toBe('mock-csrf-token');
expect(findFileInput()).toExist();
});
it('displays the correct primary button action text', () => {
expect(findPrimaryButton()).toExist();
});
it('submits the form when the primary action is clicked', async () => {
findPrimaryButton().click();
expect(formSubmitSpy).toHaveBeenCalled();
});
});
});
});
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