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';
import RequirementItem from './requirement_item.vue';
import RequirementForm from './requirement_form.vue';
import ImportRequirementsModal from './import_requirements_modal.vue';
import ExportRequirementsModal from './export_requirements_modal.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql';
import projectRequirementsCount from '../queries/projectRequirementsCount.query.graphql';
import createRequirement from '../queries/createRequirement.mutation.graphql';
import updateRequirement from '../queries/updateRequirement.mutation.graphql';
import exportRequirement from '../queries/exportRequirements.mutation.graphql';
import {
FilterState,
......@@ -45,6 +47,7 @@ export default {
RequirementCreateForm: RequirementForm,
RequirementEditForm: RequirementForm,
ImportRequirementsModal,
ExportRequirementsModal,
},
mixins: [glFeatureFlagsMixin(), Tracking.mixin()],
props: {
......@@ -108,6 +111,10 @@ export default {
type: String,
required: true,
},
currentUserEmail: {
type: String,
required: true,
},
},
apollo: {
requirements: {
......@@ -404,6 +411,27 @@ export default {
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 }) {
this.filterBy = filterBy;
this.prevPageCursor = '';
......@@ -612,6 +640,7 @@ export default {
@click-tab="handleTabClick"
@click-new-requirement="handleNewRequirementClick"
@click-import-requirements="handleImportRequirementsClick"
@click-export-requirements="$refs.exportModal.show()"
/>
<filtered-search-bar
:namespace="projectPath"
......@@ -689,5 +718,12 @@ export default {
:project-path="projectPath"
@import="importCsv"
/>
<export-requirements-modal
v-if="glFeatures.importRequirementsCsv"
ref="exportModal"
:requirement-count="totalRequirementsForCurrentTab"
:email="currentUserEmail"
@export="exportCsv"
/>
</div>
</template>
<script>
import { GlBadge, GlButton, GlTabs, GlTab } from '@gitlab/ui';
import { GlBadge, GlButton, GlButtonGroup, GlTabs, GlTab } from '@gitlab/ui';
import { FilterState } from '../constants';
......@@ -10,6 +10,7 @@ export default {
GlButton,
GlTabs,
GlTab,
GlButtonGroup,
},
props: {
filterBy: {
......@@ -85,15 +86,24 @@ export default {
</gl-tab>
</gl-tabs>
<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
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-group>
<gl-button
category="primary"
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 () => {
canCreateRequirement,
requirementsWebUrl,
requirementsImportCsvPath: importCsvPath,
currentUserEmail,
} = el.dataset;
const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened;
......@@ -84,6 +85,7 @@ export default () => {
canCreateRequirement,
requirementsWebUrl,
importCsvPath,
currentUserEmail,
};
},
render(createElement) {
......@@ -102,6 +104,7 @@ export default () => {
canCreateRequirement: parseBoolean(this.canCreateRequirement),
requirementsWebUrl: this.requirementsWebUrl,
importCsvPath: this.importCsvPath,
currentUserEmail: this.currentUserEmail,
},
});
},
......
......@@ -18,7 +18,7 @@ module RequirementsManagement
'Title' => 'title',
'Description' => 'description',
'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) }
}
end
......
......@@ -29,6 +29,7 @@
opened: requirements_count['opened'],
archived: requirements_count['archived'],
all: total_requirements,
current_user_email: current_user.email,
requirements_web_url: project_requirements_management_requirements_path(@project),
can_create_requirement: "#{can?(current_user, :create_requirement, @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';
import createRequirement from 'ee/requirements/queries/createRequirement.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 { mockTracking, unmockTracking } from 'helpers/tracking_helper';
......@@ -46,6 +47,7 @@ const createComponent = ({
canCreateRequirement = true,
requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements',
importCsvPath = '/gitlab-org/gitlab-shell/-/requirements/import_csv',
currentUserEmail = 'admin@example.com',
} = {}) =>
shallowMount(RequirementsRoot, {
propsData: {
......@@ -57,6 +59,7 @@ const createComponent = ({
canCreateRequirement,
requirementsWebUrl,
importCsvPath,
currentUserEmail,
},
mocks: {
$apollo: {
......@@ -257,6 +260,14 @@ describe('RequirementsRoot', () => {
},
};
const mockExportRequirementsMutationResult = {
data: {
exportRequirements: {
errors: [],
},
},
};
describe('getFilteredSearchValue', () => {
it('returns array containing applied filter search values', () => {
wrapper.setData({
......@@ -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', () => {
it('calls `$apollo.mutate` with `updateRequirement` mutation and variables containing `projectPath` & `iid`', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult);
......
......@@ -72,7 +72,7 @@ describe('RequirementsTabs', () => {
});
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.text()).toBe('New requirement');
......@@ -114,6 +114,7 @@ describe('RequirementsTabs', () => {
expect(buttonEl.at(0).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
end
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
specify 'latest test report created at' do
......
......@@ -724,6 +724,9 @@ msgstr ""
msgid "%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities."
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}."
msgstr ""
......@@ -11799,6 +11802,9 @@ msgstr ""
msgid "Export project"
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."
msgstr ""
......@@ -26368,6 +26374,9 @@ msgstr ""
msgid "Something went wrong while editing your comment. Please try again."
msgstr ""
msgid "Something went wrong while exporting requirements"
msgstr ""
msgid "Something went wrong while fetching %{listType} list"
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