Commit 1f8a5bab authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'import-requirements' into 'master'

Import requirements via CSV upload

See merge request gitlab-org/gitlab!47064
parents f9041604 df2c72d8
<script>
import { GlModal, GlFormGroup, GlSprintf } from '@gitlab/ui';
export default {
components: {
GlModal,
GlFormGroup,
GlSprintf,
},
props: {
projectPath: {
type: String,
required: true,
},
},
data() {
return {
file: null,
};
},
computed: {
importDisabled() {
return !this.file;
},
},
methods: {
show() {
this.$refs.modal.show();
},
hide() {
this.$refs.modal.hide();
},
handleCSVFile(e) {
const [file] = e.target.files;
this.file = file;
},
handleImport() {
const { projectPath, file } = this;
if (!file) {
return;
}
this.$emit('import', { file, projectPath });
},
},
};
</script>
<template>
<gl-modal
ref="modal"
size="sm"
modal-id="import-requirements"
:title="__('Import requirements')"
:ok-title="__('Import requirements')"
ok-variant="success"
:ok-disabled="importDisabled"
ok-only
@ok="handleImport"
>
<p>
{{
__(
"Your requirements will be imported in the background. Once it's finished, you'll get a confirmation email. ",
)
}}
</p>
<div>
<gl-form-group label="Upload CSV file" label-for="import-requirements-file-input">
<input
id="import-requirements-file-input"
ref="fileInput"
class="gl-mt-3 gl-mb-2 bv-no-focus-ring"
type="file"
accept=".csv,text/csv"
@change="handleCSVFile"
/>
</gl-form-group>
</div>
<p class="text-secondary">
<gl-sprintf
:message="
__(
'Your file must contain a column named %{codeStart}title%{codeEnd}. A %{codeStart}description%{codeEnd} column is optional. The maximum file size allowed is 10 MB.',
)
"
>
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</p>
</gl-modal>
</template>
......@@ -26,6 +26,11 @@ export default {
type: Boolean,
required: true,
},
showUploadCsv: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
emptyStateTitle() {
......@@ -56,6 +61,14 @@ export default {
<gl-button category="primary" variant="success" @click="$emit('click-new-requirement')">{{
__('New requirement')
}}</gl-button>
<gl-button
v-if="showUploadCsv"
category="secondary"
variant="default"
@click="$emit('click-import-requirements')"
>
{{ __('Import requirements') }}
</gl-button>
</template>
</gl-empty-state>
</div>
......
<script>
import { GlPagination } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import Api from '~/api';
import createFlash from '~/flash';
import createFlash, { FLASH_TYPES } from '~/flash';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { DEFAULT_LABEL_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RequirementsTabs from './requirements_tabs.vue';
import RequirementsLoading from './requirements_loading.vue';
import RequirementsEmptyState from './requirements_empty_state.vue';
import RequirementItem from './requirement_item.vue';
import RequirementForm from './requirement_form.vue';
import ImportRequirementsModal from './import_requirements_modal.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql';
import projectRequirementsCount from '../queries/projectRequirementsCount.query.graphql';
......@@ -40,7 +43,9 @@ export default {
RequirementItem,
RequirementCreateForm: RequirementForm,
RequirementEditForm: RequirementForm,
ImportRequirementsModal,
},
mixins: [glFeatureFlagsMixin()],
props: {
projectPath: {
type: String,
......@@ -98,6 +103,10 @@ export default {
type: String,
required: true,
},
importCsvPath: {
type: String,
required: true,
},
},
apollo: {
requirements: {
......@@ -377,6 +386,23 @@ export default {
throw e;
});
},
importCsv({ file }) {
const formData = new FormData();
formData.append('file', file);
return axios
.post(this.importCsvPath, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(({ data }) => {
createFlash({ message: data?.message, type: FLASH_TYPES.NOTICE });
})
.catch(err => {
const { data: { message = __('Something went wrong') } = {} } = err.response;
createFlash({ message });
});
},
handleTabClick({ filterBy }) {
this.filterBy = filterBy;
this.prevPageCursor = '';
......@@ -558,6 +584,9 @@ export default {
this.updateUrl();
},
handleImportRequirementsClick() {
this.$refs.modal.show();
},
},
};
</script>
......@@ -568,9 +597,11 @@ export default {
:filter-by="filterBy"
:requirements-count="requirementsCount"
:show-create-form="showRequirementCreateDrawer"
:show-upload-csv="glFeatures.importRequirementsCsv"
:can-create-requirement="canCreateRequirement"
@click-tab="handleTabClick"
@click-new-requirement="handleNewRequirementClick"
@click-import-requirements="handleImportRequirementsClick"
/>
<filtered-search-bar
:namespace="projectPath"
......@@ -606,7 +637,9 @@ export default {
:empty-state-path="emptyStatePath"
:requirements-count="requirementsCount"
:can-create-requirement="canCreateRequirement"
:show-upload-csv="glFeatures.importRequirementsCsv"
@click-new-requirement="handleNewRequirementClick"
@click-import-requirements="handleImportRequirementsClick"
/>
<requirements-loading
v-show="requirementsListLoading"
......@@ -640,5 +673,11 @@ export default {
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
<import-requirements-modal
v-if="glFeatures.importRequirementsCsv"
ref="modal"
:project-path="projectPath"
@import="importCsv"
/>
</div>
</template>
......@@ -27,6 +27,11 @@ export default {
type: Boolean,
required: false,
},
showUploadCsv: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isOpenTab() {
......@@ -80,6 +85,15 @@ export default {
</li>
</ul>
<div v-if="isOpenTab && canCreateRequirement" class="nav-controls">
<gl-button
v-if="showUploadCsv"
category="secondary"
variant="default"
class="js-import-requirements qa-import-requirements-button"
:disabled="showCreateForm"
icon="import"
@click="$emit('click-import-requirements')"
/>
<gl-button
category="primary"
variant="success"
......
......@@ -58,6 +58,7 @@ export default () => {
all,
canCreateRequirement,
requirementsWebUrl,
requirementsImportCsvPath: importCsvPath,
} = el.dataset;
const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened;
......@@ -82,6 +83,7 @@ export default () => {
projectPath,
canCreateRequirement,
requirementsWebUrl,
importCsvPath,
};
},
render(createElement) {
......@@ -99,6 +101,7 @@ export default () => {
emptyStatePath: this.emptyStatePath,
canCreateRequirement: parseBoolean(this.canCreateRequirement),
requirementsWebUrl: this.requirementsWebUrl,
importCsvPath: this.importCsvPath,
},
});
},
......
......@@ -20,19 +20,22 @@ class Projects::RequirementsManagement::RequirementsController < Projects::Appli
end
def import_csv
verify_valid_file!
if uploader = UploadService.new(project, params[:file]).execute
RequirementsManagement::ImportRequirementsCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
flash[:notice] = _("Your requirements are being imported. Once finished, you'll receive a confirmation email.")
else
flash[:alert] = _("File upload error.")
end
redirect_to project_requirements_management_requirements_path(project)
return render json: { message: invalid_file_message } unless file_is_valid?(params[:file])
uploader = UploadService.new(project, params[:file]).execute
message =
if uploader
RequirementsManagement::ImportRequirementsCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
_("Your requirements are being imported. Once finished, you'll receive a confirmation email.")
else
_("File upload error.")
end
render json: { message: message }
end
private
def authorize_import_access!
render_404 unless Feature.enabled?(:import_requirements_csv, project, default_enabled: false)
......@@ -45,13 +48,9 @@ class Projects::RequirementsManagement::RequirementsController < Projects::Appli
end
end
def verify_valid_file!
return if file_is_valid?(params[:file])
def invalid_file_message
supported_file_extensions = ".#{EXTENSION_WHITELIST.join(', .')}"
flash[:alert] = _("The uploaded file was invalid. Supported file extensions are %{extensions}.") % { extensions: supported_file_extensions }
redirect_to project_requirements_management_requirements_path(project)
_("The uploaded file was invalid. Supported file extensions are %{extensions}.") % { extensions: supported_file_extensions }
end
def uploader_class
......
......@@ -33,7 +33,8 @@
can_create_requirement: "#{can?(current_user, :create_requirement, @project)}",
description_preview_path: preview_markdown_path(@project),
description_help_path: help_page_path('user/markdown'),
empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } }
empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg'),
requirements_import_csv_path: import_csv_project_requirements_management_requirements_path(@project) } }
- if current_tab_count == 0
-# Show regular spinner only when there will be no
-# requirements to show for current tab.
......
---
title: Import requirements via CSV upload
merge_request: 47064
author:
type: added
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import ImportRequirementsModal from 'ee/requirements/components/import_requirements_modal.vue';
const createComponent = ({ projectPath = 'gitLabTest' } = {}) =>
shallowMount(ImportRequirementsModal, {
propsData: {
projectPath,
},
});
describe('ImportRequirementsModal', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('importDisabled', () => {
it('returns true when file is absent', () => {
expect(wrapper.vm.importDisabled).toBe(true);
});
it('returns false when file is present', () => {
wrapper.setData({ file: 'Some file' });
expect(wrapper.vm.importDisabled).toBe(false);
});
});
});
describe('methods', () => {
describe('handleCSVFile', () => {
it('sets the first file selected', () => {
const file = 'some file';
const event = {
target: {
files: [file],
},
};
wrapper.vm.handleCSVFile(event);
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.file).toBe(file);
});
});
});
});
describe('template', () => {
it('GlModal open click emits file and projectPath', () => {
const file = 'some file';
wrapper.setData({
file,
});
wrapper.find(GlModal).vm.$emit('ok');
const emitted = wrapper.emitted('import')[0][0];
expect(emitted).toExist();
expect(emitted.file).toBe(file);
expect(emitted.projectPath).toBe(wrapper.vm.projectPath);
});
});
});
......@@ -44,6 +44,7 @@ const createComponent = ({
loading = false,
canCreateRequirement = true,
requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements',
importCsvPath = '/gitlab-org/gitlab-shell/-/requirements/import_csv',
} = {}) =>
shallowMount(RequirementsRoot, {
propsData: {
......@@ -54,6 +55,7 @@ const createComponent = ({
emptyStatePath,
canCreateRequirement,
requirementsWebUrl,
importCsvPath,
},
mocks: {
$apollo: {
......
......@@ -11,6 +11,7 @@ const createComponent = ({
requirementsCount = mockRequirementsCount,
showCreateForm = false,
canCreateRequirement = true,
showUploadCsv = true,
} = {}) =>
shallowMount(RequirementsTabs, {
propsData: {
......@@ -18,6 +19,7 @@ const createComponent = ({
requirementsCount,
showCreateForm,
canCreateRequirement,
showUploadCsv,
},
});
......@@ -75,7 +77,7 @@ describe('RequirementsTabs', () => {
});
return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.find(GlButton);
const buttonEl = wrapper.findAll(GlButton).at(1);
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.text()).toBe('New requirement');
......@@ -113,9 +115,10 @@ describe('RequirementsTabs', () => {
});
return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.find(GlButton);
const buttonEl = wrapper.findAll(GlButton);
expect(buttonEl.props('disabled')).toBe(true);
expect(buttonEl.at(0).props('disabled')).toBe(true);
expect(buttonEl.at(1).props('disabled')).toBe(true);
});
});
});
......
......@@ -54,22 +54,21 @@ RSpec.describe Projects::RequirementsManagement::RequirementsController do
stub_licensed_features(requirements: true)
end
shared_examples 'response with 302 status' do
it 'returns 302 status and redirects to the correct path' do
shared_examples 'response with success status' do
it 'returns 200 status and success message' do
subject
expect(flash[:notice]).to eq(_("Your requirements are being imported. Once finished, you'll receive a confirmation email."))
expect(response).to redirect_to(project_requirements_management_requirements_path(project))
expect(response).to have_gitlab_http_status(:found)
expect(response).to have_gitlab_http_status(:success)
expect(json_response).to eq('message' => "Your requirements are being imported. Once finished, you'll receive a confirmation email.")
end
end
it_behaves_like 'response with 302 status'
it_behaves_like 'response with success status'
context 'when file extension is in upper case' do
let(:file) { fixture_file_upload('spec/fixtures/csv_uppercase.CSV') }
it_behaves_like 'response with 302 status'
it_behaves_like 'response with success status'
end
it 'shows error when upload fails' do
......@@ -79,8 +78,18 @@ RSpec.describe Projects::RequirementsManagement::RequirementsController do
subject
expect(flash[:alert]).to include(_('File upload error.'))
expect(response).to redirect_to(project_requirements_management_requirements_path(project))
expect(json_response).to eq('message' => 'File upload error.')
end
context 'when file extension is not csv' do
let(:file) { fixture_file_upload('spec/fixtures/sample_doc.md') }
it 'returns error message' do
subject
expect(response).to have_gitlab_http_status(:success)
expect(json_response).to eq('message' => "The uploaded file was invalid. Supported file extensions are .csv.")
end
end
end
......
......@@ -14488,6 +14488,9 @@ msgstr ""
msgid "Import repository"
msgstr ""
msgid "Import requirements"
msgstr ""
msgid "Import started by: %{importInitiator}"
msgstr ""
......@@ -25793,6 +25796,9 @@ msgstr ""
msgid "Someone edited this merge request at the same time you did. Please refresh the page to see changes."
msgstr ""
msgid "Something went wrong"
msgstr ""
msgid "Something went wrong on our end"
msgstr ""
......@@ -32188,6 +32194,9 @@ msgstr ""
msgid "Your device was successfully set up! Give it a name and register it with the GitLab server."
msgstr ""
msgid "Your file must contain a column named %{codeStart}title%{codeEnd}. A %{codeStart}description%{codeEnd} column is optional. The maximum file size allowed is 10 MB."
msgstr ""
msgid "Your first project"
msgstr ""
......@@ -32266,6 +32275,9 @@ msgstr ""
msgid "Your requirements are being imported. Once finished, you'll receive a confirmation email."
msgstr ""
msgid "Your requirements will be imported in the background. Once it's finished, you'll get a confirmation email. "
msgstr ""
msgid "Your response has been recorded."
msgstr ""
......
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