Commit 500693ac authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'export-requirements' into 'master'

Export requirements as a CSV

See merge request gitlab-org/gitlab!51434
parents b674bce0 0c4b7101
<script>
import { GlModal, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
i18n: {
exportRequirements: __('Export requirements'),
},
components: {
GlModal,
GlSprintf,
},
props: {
email: {
type: String,
required: true,
},
requirementCount: {
type: Number,
required: true,
},
},
methods: {
show() {
this.$refs.modal.show();
},
hide() {
this.$refs.modal.hide();
},
handleExport() {
this.$emit('export');
},
},
};
</script>
<template>
<gl-modal
ref="modal"
size="sm"
modal-id="export-requirements"
:title="$options.i18n.exportRequirements"
:ok-title="$options.i18n.exportRequirements"
ok-variant="success"
ok-only
@ok="handleExport"
>
<p>
<gl-sprintf
:message="
__(
'%{requirementCount} requirements have been selected for export. These will be sent to %{email} as an attachment once finished.',
)
"
>
<template #requirementCount>
<strong>{{ requirementCount }}</strong>
</template>
<template #email>
<strong>{{ email }}</strong>
</template>
</gl-sprintf>
</p>
</gl-modal>
</template>
...@@ -19,11 +19,13 @@ import RequirementsEmptyState from './requirements_empty_state.vue'; ...@@ -19,11 +19,13 @@ 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 ImportRequirementsModal from './import_requirements_modal.vue';
import ExportRequirementsModal from './export_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';
import createRequirement from '../queries/createRequirement.mutation.graphql'; import createRequirement from '../queries/createRequirement.mutation.graphql';
import updateRequirement from '../queries/updateRequirement.mutation.graphql'; import updateRequirement from '../queries/updateRequirement.mutation.graphql';
import exportRequirement from '../queries/exportRequirements.mutation.graphql';
import { import {
FilterState, FilterState,
...@@ -45,6 +47,7 @@ export default { ...@@ -45,6 +47,7 @@ export default {
RequirementCreateForm: RequirementForm, RequirementCreateForm: RequirementForm,
RequirementEditForm: RequirementForm, RequirementEditForm: RequirementForm,
ImportRequirementsModal, ImportRequirementsModal,
ExportRequirementsModal,
}, },
mixins: [glFeatureFlagsMixin(), Tracking.mixin()], mixins: [glFeatureFlagsMixin(), Tracking.mixin()],
props: { props: {
...@@ -108,6 +111,10 @@ export default { ...@@ -108,6 +111,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
currentUserEmail: {
type: String,
required: true,
},
}, },
apollo: { apollo: {
requirements: { requirements: {
...@@ -404,6 +411,27 @@ export default { ...@@ -404,6 +411,27 @@ export default {
createFlash({ message }); createFlash({ message });
}); });
}, },
exportCsv() {
return this.$apollo
.mutate({
mutation: exportRequirement,
variables: {
projectPath: this.projectPath,
state: this.filterBy,
authorUsername: this.authorUsernames,
search: this.textSearch,
sortBy: this.sortBy,
},
})
.catch((e) => {
createFlash({
message: __('Something went wrong while exporting requirements'),
captureError: true,
error: e,
});
throw e;
});
},
handleTabClick({ filterBy }) { handleTabClick({ filterBy }) {
this.filterBy = filterBy; this.filterBy = filterBy;
this.prevPageCursor = ''; this.prevPageCursor = '';
...@@ -612,6 +640,7 @@ export default { ...@@ -612,6 +640,7 @@ export default {
@click-tab="handleTabClick" @click-tab="handleTabClick"
@click-new-requirement="handleNewRequirementClick" @click-new-requirement="handleNewRequirementClick"
@click-import-requirements="handleImportRequirementsClick" @click-import-requirements="handleImportRequirementsClick"
@click-export-requirements="$refs.exportModal.show()"
/> />
<filtered-search-bar <filtered-search-bar
:namespace="projectPath" :namespace="projectPath"
...@@ -689,5 +718,12 @@ export default { ...@@ -689,5 +718,12 @@ export default {
:project-path="projectPath" :project-path="projectPath"
@import="importCsv" @import="importCsv"
/> />
<export-requirements-modal
v-if="glFeatures.importRequirementsCsv"
ref="exportModal"
:requirement-count="totalRequirementsForCurrentTab"
:email="currentUserEmail"
@export="exportCsv"
/>
</div> </div>
</template> </template>
<script> <script>
import { GlBadge, GlButton, GlTabs, GlTab } from '@gitlab/ui'; import { GlBadge, GlButton, GlButtonGroup, GlTabs, GlTab } from '@gitlab/ui';
import { FilterState } from '../constants'; import { FilterState } from '../constants';
...@@ -10,6 +10,7 @@ export default { ...@@ -10,6 +10,7 @@ export default {
GlButton, GlButton,
GlTabs, GlTabs,
GlTab, GlTab,
GlButtonGroup,
}, },
props: { props: {
filterBy: { filterBy: {
...@@ -85,15 +86,24 @@ export default { ...@@ -85,15 +86,24 @@ export default {
</gl-tab> </gl-tab>
</gl-tabs> </gl-tabs>
<div v-if="isOpenTab && canCreateRequirement" class="nav-controls"> <div v-if="isOpenTab && canCreateRequirement" class="nav-controls">
<gl-button-group>
<gl-button
v-if="showUploadCsv"
category="secondary"
:disabled="showCreateForm"
icon="export"
@click="$emit('click-export-requirements')"
/>
<gl-button <gl-button
v-if="showUploadCsv" v-if="showUploadCsv"
category="secondary" category="secondary"
variant="default"
class="js-import-requirements qa-import-requirements-button" class="js-import-requirements qa-import-requirements-button"
:disabled="showCreateForm" :disabled="showCreateForm"
icon="import" icon="import"
@click="$emit('click-import-requirements')" @click="$emit('click-import-requirements')"
/> />
</gl-button-group>
<gl-button <gl-button
category="primary" category="primary"
variant="success" variant="success"
......
mutation exportRequirements(
$projectPath: ID!
$state: RequirementState
$authorUsername: [String!] = []
$search: String = ""
$sortBy: Sort = CREATED_DESC
) {
exportRequirements(
input: {
projectPath: $projectPath
search: $search
authorUsername: $authorUsername
state: $state
sort: $sortBy
}
) {
errors
}
}
...@@ -59,6 +59,7 @@ export default () => { ...@@ -59,6 +59,7 @@ export default () => {
canCreateRequirement, canCreateRequirement,
requirementsWebUrl, requirementsWebUrl,
requirementsImportCsvPath: importCsvPath, requirementsImportCsvPath: importCsvPath,
currentUserEmail,
} = el.dataset; } = el.dataset;
const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened; const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened;
...@@ -84,6 +85,7 @@ export default () => { ...@@ -84,6 +85,7 @@ export default () => {
canCreateRequirement, canCreateRequirement,
requirementsWebUrl, requirementsWebUrl,
importCsvPath, importCsvPath,
currentUserEmail,
}; };
}, },
render(createElement) { render(createElement) {
...@@ -102,6 +104,7 @@ export default () => { ...@@ -102,6 +104,7 @@ export default () => {
canCreateRequirement: parseBoolean(this.canCreateRequirement), canCreateRequirement: parseBoolean(this.canCreateRequirement),
requirementsWebUrl: this.requirementsWebUrl, requirementsWebUrl: this.requirementsWebUrl,
importCsvPath: this.importCsvPath, importCsvPath: this.importCsvPath,
currentUserEmail: this.currentUserEmail,
}, },
}); });
}, },
......
...@@ -18,7 +18,7 @@ module RequirementsManagement ...@@ -18,7 +18,7 @@ module RequirementsManagement
'Title' => 'title', 'Title' => 'title',
'Description' => 'description', 'Description' => 'description',
'Author Username' => -> (requirement) { requirement.author&.username }, 'Author Username' => -> (requirement) { requirement.author&.username },
'Latest Test Report State' => -> (requirement) { requirement.last_test_report_state }, 'Latest Test Report State' => -> (requirement) { requirement.last_test_report_state&.capitalize },
'Latest Test Report Created At (UTC)' => -> (requirement) { latest_test_report_time(requirement) } 'Latest Test Report Created At (UTC)' => -> (requirement) { latest_test_report_time(requirement) }
} }
end end
......
...@@ -29,6 +29,7 @@ ...@@ -29,6 +29,7 @@
opened: requirements_count['opened'], opened: requirements_count['opened'],
archived: requirements_count['archived'], archived: requirements_count['archived'],
all: total_requirements, all: total_requirements,
current_user_email: current_user.email,
requirements_web_url: project_requirements_management_requirements_path(@project), requirements_web_url: project_requirements_management_requirements_path(@project),
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),
......
---
title: Export requirements as a CSV
merge_request: 51434
author:
type: added
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import ExportRequirementsModal from 'ee/requirements/components/export_requirements_modal.vue';
const createComponent = ({ requirementCount = 42, email = 'admin@example.com' } = {}) =>
shallowMount(ExportRequirementsModal, {
propsData: {
requirementCount,
email,
},
});
describe('ExportRequirementsModal', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('handleExport', () => {
it('emits `export` event', () => {
wrapper.vm.handleExport();
const emitted = wrapper.emitted('export');
expect(emitted).toBeDefined();
});
});
});
describe('template', () => {
it('GlModal open click emits export event', () => {
wrapper.find(GlModal).vm.$emit('ok');
const emitted = wrapper.emitted('export');
expect(emitted).toBeDefined();
});
});
});
...@@ -9,6 +9,7 @@ import RequirementsTabs from 'ee/requirements/components/requirements_tabs.vue'; ...@@ -9,6 +9,7 @@ import RequirementsTabs from 'ee/requirements/components/requirements_tabs.vue';
import createRequirement from 'ee/requirements/queries/createRequirement.mutation.graphql'; import createRequirement from 'ee/requirements/queries/createRequirement.mutation.graphql';
import updateRequirement from 'ee/requirements/queries/updateRequirement.mutation.graphql'; import updateRequirement from 'ee/requirements/queries/updateRequirement.mutation.graphql';
import exportRequirement from 'ee/requirements/queries/exportRequirements.mutation.graphql';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
...@@ -46,6 +47,7 @@ const createComponent = ({ ...@@ -46,6 +47,7 @@ const createComponent = ({
canCreateRequirement = true, canCreateRequirement = true,
requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements', requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements',
importCsvPath = '/gitlab-org/gitlab-shell/-/requirements/import_csv', importCsvPath = '/gitlab-org/gitlab-shell/-/requirements/import_csv',
currentUserEmail = 'admin@example.com',
} = {}) => } = {}) =>
shallowMount(RequirementsRoot, { shallowMount(RequirementsRoot, {
propsData: { propsData: {
...@@ -57,6 +59,7 @@ const createComponent = ({ ...@@ -57,6 +59,7 @@ const createComponent = ({
canCreateRequirement, canCreateRequirement,
requirementsWebUrl, requirementsWebUrl,
importCsvPath, importCsvPath,
currentUserEmail,
}, },
mocks: { mocks: {
$apollo: { $apollo: {
...@@ -257,6 +260,14 @@ describe('RequirementsRoot', () => { ...@@ -257,6 +260,14 @@ describe('RequirementsRoot', () => {
}, },
}; };
const mockExportRequirementsMutationResult = {
data: {
exportRequirements: {
errors: [],
},
},
};
describe('getFilteredSearchValue', () => { describe('getFilteredSearchValue', () => {
it('returns array containing applied filter search values', () => { it('returns array containing applied filter search values', () => {
wrapper.setData({ wrapper.setData({
...@@ -291,6 +302,42 @@ describe('RequirementsRoot', () => { ...@@ -291,6 +302,42 @@ describe('RequirementsRoot', () => {
}); });
}); });
describe('exportCsv', () => {
it('calls `$apollo.mutate` with `exportRequirement` mutation and variables', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue(mockExportRequirementsMutationResult);
wrapper.vm.exportCsv();
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: exportRequirement,
variables: {
projectPath: wrapper.vm.projectPath,
state: wrapper.vm.filterBy,
authorUsername: wrapper.vm.authorUsernames,
search: wrapper.vm.textSearch,
sortBy: wrapper.vm.sortBy,
},
}),
);
});
it('calls `createFlash` when request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(new Error({}));
return wrapper.vm.exportCsv().catch(() => {
expect(createFlash).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Something went wrong while exporting requirements',
captureError: true,
}),
);
});
});
});
describe('updateRequirement', () => { describe('updateRequirement', () => {
it('calls `$apollo.mutate` with `updateRequirement` mutation and variables containing `projectPath` & `iid`', () => { it('calls `$apollo.mutate` with `updateRequirement` mutation and variables containing `projectPath` & `iid`', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult);
......
...@@ -72,7 +72,7 @@ describe('RequirementsTabs', () => { ...@@ -72,7 +72,7 @@ describe('RequirementsTabs', () => {
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.findAll(GlButton).at(1); const buttonEl = wrapper.findAll(GlButton).at(2);
expect(buttonEl.exists()).toBe(true); expect(buttonEl.exists()).toBe(true);
expect(buttonEl.text()).toBe('New requirement'); expect(buttonEl.text()).toBe('New requirement');
...@@ -114,6 +114,7 @@ describe('RequirementsTabs', () => { ...@@ -114,6 +114,7 @@ describe('RequirementsTabs', () => {
expect(buttonEl.at(0).props('disabled')).toBe(true); expect(buttonEl.at(0).props('disabled')).toBe(true);
expect(buttonEl.at(1).props('disabled')).toBe(true); expect(buttonEl.at(1).props('disabled')).toBe(true);
expect(buttonEl.at(2).props('disabled')).toBe(true);
}); });
}); });
}); });
......
...@@ -71,7 +71,7 @@ RSpec.describe RequirementsManagement::ExportCsvService do ...@@ -71,7 +71,7 @@ RSpec.describe RequirementsManagement::ExportCsvService do
end end
specify 'latest test report state' do specify 'latest test report state' do
expect(csv[0]['Latest Test Report State']).to eq "passed" expect(csv[0]['Latest Test Report State']).to eq "Passed"
end end
specify 'latest test report created at' do specify 'latest test report created at' do
......
...@@ -724,6 +724,9 @@ msgstr "" ...@@ -724,6 +724,9 @@ msgstr ""
msgid "%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities." msgid "%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities."
msgstr "" msgstr ""
msgid "%{requirementCount} requirements have been selected for export. These will be sent to %{email} as an attachment once finished."
msgstr ""
msgid "%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}." msgid "%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}."
msgstr "" msgstr ""
...@@ -11799,6 +11802,9 @@ msgstr "" ...@@ -11799,6 +11802,9 @@ msgstr ""
msgid "Export project" msgid "Export project"
msgstr "" msgstr ""
msgid "Export requirements"
msgstr ""
msgid "Export this group with all related data to a new GitLab instance. Once complete, you can import the data file from the \"New Group\" page." msgid "Export this group with all related data to a new GitLab instance. Once complete, you can import the data file from the \"New Group\" page."
msgstr "" msgstr ""
...@@ -26368,6 +26374,9 @@ msgstr "" ...@@ -26368,6 +26374,9 @@ msgstr ""
msgid "Something went wrong while editing your comment. Please try again." msgid "Something went wrong while editing your comment. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while exporting requirements"
msgstr ""
msgid "Something went wrong while fetching %{listType} list" msgid "Something went wrong while fetching %{listType} list"
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