Commit df2c72d8 authored by Rajat Jain's avatar Rajat Jain

Import requirements via CSV upload

Like Issue, allow users to upload requirements via CSV
parent 9eae6b36
<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 { ...@@ -26,6 +26,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
showUploadCsv: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
emptyStateTitle() { emptyStateTitle() {
...@@ -56,6 +61,14 @@ export default { ...@@ -56,6 +61,14 @@ export default {
<gl-button category="primary" variant="success" @click="$emit('click-new-requirement')">{{ <gl-button category="primary" variant="success" @click="$emit('click-new-requirement')">{{
__('New requirement') __('New requirement')
}}</gl-button> }}</gl-button>
<gl-button
v-if="showUploadCsv"
category="secondary"
variant="default"
@click="$emit('click-import-requirements')"
>
{{ __('Import requirements') }}
</gl-button>
</template> </template>
</gl-empty-state> </gl-empty-state>
</div> </div>
......
<script> <script>
import { GlPagination } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import Api from '~/api'; import Api from '~/api';
import createFlash from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; 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 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 { 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 RequirementsTabs from './requirements_tabs.vue';
import RequirementsLoading from './requirements_loading.vue'; import RequirementsLoading from './requirements_loading.vue';
import RequirementsEmptyState from './requirements_empty_state.vue'; import RequirementsEmptyState from './requirements_empty_state.vue';
import RequirementItem from './requirement_item.vue'; import RequirementItem from './requirement_item.vue';
import RequirementForm from './requirement_form.vue'; import RequirementForm from './requirement_form.vue';
import ImportRequirementsModal from './import_requirements_modal.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql'; import projectRequirements from '../queries/projectRequirements.query.graphql';
import projectRequirementsCount from '../queries/projectRequirementsCount.query.graphql'; import projectRequirementsCount from '../queries/projectRequirementsCount.query.graphql';
...@@ -40,7 +43,9 @@ export default { ...@@ -40,7 +43,9 @@ export default {
RequirementItem, RequirementItem,
RequirementCreateForm: RequirementForm, RequirementCreateForm: RequirementForm,
RequirementEditForm: RequirementForm, RequirementEditForm: RequirementForm,
ImportRequirementsModal,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
projectPath: { projectPath: {
type: String, type: String,
...@@ -98,6 +103,10 @@ export default { ...@@ -98,6 +103,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
importCsvPath: {
type: String,
required: true,
},
}, },
apollo: { apollo: {
requirements: { requirements: {
...@@ -377,6 +386,23 @@ export default { ...@@ -377,6 +386,23 @@ export default {
throw e; 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 }) { handleTabClick({ filterBy }) {
this.filterBy = filterBy; this.filterBy = filterBy;
this.prevPageCursor = ''; this.prevPageCursor = '';
...@@ -558,6 +584,9 @@ export default { ...@@ -558,6 +584,9 @@ export default {
this.updateUrl(); this.updateUrl();
}, },
handleImportRequirementsClick() {
this.$refs.modal.show();
},
}, },
}; };
</script> </script>
...@@ -568,9 +597,11 @@ export default { ...@@ -568,9 +597,11 @@ export default {
:filter-by="filterBy" :filter-by="filterBy"
:requirements-count="requirementsCount" :requirements-count="requirementsCount"
:show-create-form="showRequirementCreateDrawer" :show-create-form="showRequirementCreateDrawer"
:show-upload-csv="glFeatures.importRequirementsCsv"
:can-create-requirement="canCreateRequirement" :can-create-requirement="canCreateRequirement"
@click-tab="handleTabClick" @click-tab="handleTabClick"
@click-new-requirement="handleNewRequirementClick" @click-new-requirement="handleNewRequirementClick"
@click-import-requirements="handleImportRequirementsClick"
/> />
<filtered-search-bar <filtered-search-bar
:namespace="projectPath" :namespace="projectPath"
...@@ -606,7 +637,9 @@ export default { ...@@ -606,7 +637,9 @@ export default {
:empty-state-path="emptyStatePath" :empty-state-path="emptyStatePath"
:requirements-count="requirementsCount" :requirements-count="requirementsCount"
:can-create-requirement="canCreateRequirement" :can-create-requirement="canCreateRequirement"
:show-upload-csv="glFeatures.importRequirementsCsv"
@click-new-requirement="handleNewRequirementClick" @click-new-requirement="handleNewRequirementClick"
@click-import-requirements="handleImportRequirementsClick"
/> />
<requirements-loading <requirements-loading
v-show="requirementsListLoading" v-show="requirementsListLoading"
...@@ -640,5 +673,11 @@ export default { ...@@ -640,5 +673,11 @@ export default {
class="gl-pagination gl-mt-3" class="gl-pagination gl-mt-3"
@input="handlePageChange" @input="handlePageChange"
/> />
<import-requirements-modal
v-if="glFeatures.importRequirementsCsv"
ref="modal"
:project-path="projectPath"
@import="importCsv"
/>
</div> </div>
</template> </template>
...@@ -27,6 +27,11 @@ export default { ...@@ -27,6 +27,11 @@ export default {
type: Boolean, type: Boolean,
required: false, required: false,
}, },
showUploadCsv: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
isOpenTab() { isOpenTab() {
...@@ -80,6 +85,15 @@ export default { ...@@ -80,6 +85,15 @@ export default {
</li> </li>
</ul> </ul>
<div v-if="isOpenTab && canCreateRequirement" class="nav-controls"> <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 <gl-button
category="primary" category="primary"
variant="success" variant="success"
......
...@@ -58,6 +58,7 @@ export default () => { ...@@ -58,6 +58,7 @@ export default () => {
all, all,
canCreateRequirement, canCreateRequirement,
requirementsWebUrl, requirementsWebUrl,
requirementsImportCsvPath: importCsvPath,
} = el.dataset; } = el.dataset;
const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened; const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened;
...@@ -82,6 +83,7 @@ export default () => { ...@@ -82,6 +83,7 @@ export default () => {
projectPath, projectPath,
canCreateRequirement, canCreateRequirement,
requirementsWebUrl, requirementsWebUrl,
importCsvPath,
}; };
}, },
render(createElement) { render(createElement) {
...@@ -99,6 +101,7 @@ export default () => { ...@@ -99,6 +101,7 @@ export default () => {
emptyStatePath: this.emptyStatePath, emptyStatePath: this.emptyStatePath,
canCreateRequirement: parseBoolean(this.canCreateRequirement), canCreateRequirement: parseBoolean(this.canCreateRequirement),
requirementsWebUrl: this.requirementsWebUrl, requirementsWebUrl: this.requirementsWebUrl,
importCsvPath: this.importCsvPath,
}, },
}); });
}, },
......
...@@ -20,19 +20,22 @@ class Projects::RequirementsManagement::RequirementsController < Projects::Appli ...@@ -20,19 +20,22 @@ class Projects::RequirementsManagement::RequirementsController < Projects::Appli
end end
def import_csv def import_csv
verify_valid_file! return render json: { message: invalid_file_message } unless file_is_valid?(params[:file])
if uploader = UploadService.new(project, params[:file]).execute uploader = UploadService.new(project, params[:file]).execute
RequirementsManagement::ImportRequirementsCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker message =
if uploader
flash[:notice] = _("Your requirements are being imported. Once finished, you'll receive a confirmation email.") RequirementsManagement::ImportRequirementsCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
else _("Your requirements are being imported. Once finished, you'll receive a confirmation email.")
flash[:alert] = _("File upload error.") else
end _("File upload error.")
end
redirect_to project_requirements_management_requirements_path(project)
render json: { message: message }
end end
private
def authorize_import_access! def authorize_import_access!
render_404 unless Feature.enabled?(:import_requirements_csv, project, default_enabled: false) render_404 unless Feature.enabled?(:import_requirements_csv, project, default_enabled: false)
...@@ -45,13 +48,9 @@ class Projects::RequirementsManagement::RequirementsController < Projects::Appli ...@@ -45,13 +48,9 @@ class Projects::RequirementsManagement::RequirementsController < Projects::Appli
end end
end end
def verify_valid_file! def invalid_file_message
return if file_is_valid?(params[:file])
supported_file_extensions = ".#{EXTENSION_WHITELIST.join(', .')}" supported_file_extensions = ".#{EXTENSION_WHITELIST.join(', .')}"
flash[:alert] = _("The uploaded file was invalid. Supported file extensions are %{extensions}.") % { extensions: supported_file_extensions } _("The uploaded file was invalid. Supported file extensions are %{extensions}.") % { extensions: supported_file_extensions }
redirect_to project_requirements_management_requirements_path(project)
end end
def uploader_class def uploader_class
......
...@@ -33,7 +33,8 @@ ...@@ -33,7 +33,8 @@
can_create_requirement: "#{can?(current_user, :create_requirement, @project)}", can_create_requirement: "#{can?(current_user, :create_requirement, @project)}",
description_preview_path: preview_markdown_path(@project), description_preview_path: preview_markdown_path(@project),
description_help_path: help_page_path('user/markdown'), 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 - if current_tab_count == 0
-# Show regular spinner only when there will be no -# Show regular spinner only when there will be no
-# requirements to show for current tab. -# 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 = ({ ...@@ -44,6 +44,7 @@ const createComponent = ({
loading = false, loading = false,
canCreateRequirement = true, canCreateRequirement = true,
requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements', requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements',
importCsvPath = '/gitlab-org/gitlab-shell/-/requirements/import_csv',
} = {}) => } = {}) =>
shallowMount(RequirementsRoot, { shallowMount(RequirementsRoot, {
propsData: { propsData: {
...@@ -54,6 +55,7 @@ const createComponent = ({ ...@@ -54,6 +55,7 @@ const createComponent = ({
emptyStatePath, emptyStatePath,
canCreateRequirement, canCreateRequirement,
requirementsWebUrl, requirementsWebUrl,
importCsvPath,
}, },
mocks: { mocks: {
$apollo: { $apollo: {
......
...@@ -11,6 +11,7 @@ const createComponent = ({ ...@@ -11,6 +11,7 @@ const createComponent = ({
requirementsCount = mockRequirementsCount, requirementsCount = mockRequirementsCount,
showCreateForm = false, showCreateForm = false,
canCreateRequirement = true, canCreateRequirement = true,
showUploadCsv = true,
} = {}) => } = {}) =>
shallowMount(RequirementsTabs, { shallowMount(RequirementsTabs, {
propsData: { propsData: {
...@@ -18,6 +19,7 @@ const createComponent = ({ ...@@ -18,6 +19,7 @@ const createComponent = ({
requirementsCount, requirementsCount,
showCreateForm, showCreateForm,
canCreateRequirement, canCreateRequirement,
showUploadCsv,
}, },
}); });
...@@ -75,7 +77,7 @@ describe('RequirementsTabs', () => { ...@@ -75,7 +77,7 @@ describe('RequirementsTabs', () => {
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.find(GlButton); const buttonEl = wrapper.findAll(GlButton).at(1);
expect(buttonEl.exists()).toBe(true); expect(buttonEl.exists()).toBe(true);
expect(buttonEl.text()).toBe('New requirement'); expect(buttonEl.text()).toBe('New requirement');
...@@ -113,9 +115,10 @@ describe('RequirementsTabs', () => { ...@@ -113,9 +115,10 @@ describe('RequirementsTabs', () => {
}); });
return wrapper.vm.$nextTick(() => { 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 ...@@ -54,22 +54,21 @@ RSpec.describe Projects::RequirementsManagement::RequirementsController do
stub_licensed_features(requirements: true) stub_licensed_features(requirements: true)
end end
shared_examples 'response with 302 status' do shared_examples 'response with success status' do
it 'returns 302 status and redirects to the correct path' do it 'returns 200 status and success message' do
subject subject
expect(flash[:notice]).to eq(_("Your requirements are being imported. Once finished, you'll receive a confirmation email.")) expect(response).to have_gitlab_http_status(:success)
expect(response).to redirect_to(project_requirements_management_requirements_path(project)) expect(json_response).to eq('message' => "Your requirements are being imported. Once finished, you'll receive a confirmation email.")
expect(response).to have_gitlab_http_status(:found)
end end
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 context 'when file extension is in upper case' do
let(:file) { fixture_file_upload('spec/fixtures/csv_uppercase.CSV') } 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 end
it 'shows error when upload fails' do it 'shows error when upload fails' do
...@@ -79,8 +78,18 @@ RSpec.describe Projects::RequirementsManagement::RequirementsController do ...@@ -79,8 +78,18 @@ RSpec.describe Projects::RequirementsManagement::RequirementsController do
subject subject
expect(flash[:alert]).to include(_('File upload error.')) expect(json_response).to eq('message' => 'File upload error.')
expect(response).to redirect_to(project_requirements_management_requirements_path(project)) 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
end end
......
...@@ -14488,6 +14488,9 @@ msgstr "" ...@@ -14488,6 +14488,9 @@ msgstr ""
msgid "Import repository" msgid "Import repository"
msgstr "" msgstr ""
msgid "Import requirements"
msgstr ""
msgid "Import started by: %{importInitiator}" msgid "Import started by: %{importInitiator}"
msgstr "" msgstr ""
...@@ -25793,6 +25796,9 @@ 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." msgid "Someone edited this merge request at the same time you did. Please refresh the page to see changes."
msgstr "" msgstr ""
msgid "Something went wrong"
msgstr ""
msgid "Something went wrong on our end" msgid "Something went wrong on our end"
msgstr "" msgstr ""
...@@ -32188,6 +32194,9 @@ msgstr "" ...@@ -32188,6 +32194,9 @@ msgstr ""
msgid "Your device was successfully set up! Give it a name and register it with the GitLab server." msgid "Your device was successfully set up! Give it a name and register it with the GitLab server."
msgstr "" 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" msgid "Your first project"
msgstr "" msgstr ""
...@@ -32266,6 +32275,9 @@ msgstr "" ...@@ -32266,6 +32275,9 @@ msgstr ""
msgid "Your requirements are being imported. Once finished, you'll receive a confirmation email." msgid "Your requirements are being imported. Once finished, you'll receive a confirmation email."
msgstr "" 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." msgid "Your response has been recorded."
msgstr "" 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