Commit ec0090a1 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'export-selected-fields' into 'master'

Export only selected fields in requirements

See merge request gitlab-org/gitlab!53707
parents ad3678e3 c6475697
...@@ -262,6 +262,7 @@ For GitLab.com, it is set to 10 MB. ...@@ -262,6 +262,7 @@ For GitLab.com, it is set to 10 MB.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290813) in GitLab 13.8. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290813) in GitLab 13.8.
> - Revised CSV column headers [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299247) in GitLab 13.9. > - Revised CSV column headers [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299247) in GitLab 13.9.
> - Ability to select which fields to export [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290823) in GitLab 13.9.
You can export GitLab requirements to a You can export GitLab requirements to a
[CSV file](https://en.wikipedia.org/wiki/Comma-separated_values) sent to your default notification [CSV file](https://en.wikipedia.org/wiki/Comma-separated_values) sent to your default notification
...@@ -276,7 +277,14 @@ Users with Reporter or higher [permissions](../../permissions.md) can export req ...@@ -276,7 +277,14 @@ Users with Reporter or higher [permissions](../../permissions.md) can export req
To export requirements: To export requirements:
1. In a project, go to **Requirements**. 1. In a project, go to **Requirements**.
1. Select the **Export as CSV** icon (**{export}**) in the top right. A confirmation modal appears. 1. In the top right, select the **Export as CSV** icon (**{export}**).
A confirmation modal appears.
1. Under **Advanced export options**, select which fields to export.
All fields are selected by default. To exclude a field from being exported, clear the checkbox next to it.
1. Select **Export requirements**. The exported CSV file is sent to the email address associated with your user. 1. Select **Export requirements**. The exported CSV file is sent to the email address associated with your user.
### Exported CSV file format ### Exported CSV file format
......
<script> <script>
import { GlModal, GlSprintf } from '@gitlab/ui'; import { GlModal, GlSprintf, GlAlert, GlFormCheckbox } from '@gitlab/ui';
import { __ } from '~/locale'; import { uniq } from 'lodash';
import { __, sprintf } from '~/locale';
export default { export default {
i18n: { i18n: {
modalTitle: __('Export %{requirementsCount} requirements?'),
exportRequirements: __('Export requirements'), exportRequirements: __('Export requirements'),
}, },
components: { components: {
GlModal, GlModal,
GlSprintf, GlSprintf,
GlFormCheckbox,
GlAlert,
}, },
props: { props: {
email: { email: {
...@@ -20,17 +24,86 @@ export default { ...@@ -20,17 +24,86 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
selectedFields: this.$options.fields.map((f) => f.key),
};
},
computed: {
modalTitle() {
return sprintf(this.$options.i18n.modalTitle, { requirementsCount: this.requirementCount });
},
selectedOptionsProps() {
const selectedLength = this.selectedFields.length;
const totalLength = this.$options.fields.length;
let checked = false;
if (selectedLength === 0) {
checked = false;
} else if (selectedLength === totalLength) {
checked = true;
}
return {
indeterminate: selectedLength !== 0 && selectedLength !== totalLength,
checked,
};
},
},
methods: { methods: {
show() { show() {
this.selectedFields = this.$options.fields.map((f) => f.key);
this.$refs.modal.show(); this.$refs.modal.show();
}, },
hide() { hide() {
this.$refs.modal.hide(); this.$refs.modal.hide();
}, },
handleExport() { handleExport() {
this.$emit('export'); this.$emit('export', this.selectedFields);
},
toggleField(field) {
const index = this.selectedFields.indexOf(field);
if (index !== -1) {
const tmp = [...this.selectedFields];
tmp.splice(index, 1);
this.selectedFields = tmp;
} else {
this.selectedFields = [...this.selectedFields, field];
}
},
isFieldSelected(field) {
return this.selectedFields.includes(field);
},
toggleAllFields() {
const { indeterminate, checked } = this.selectedOptionsProps;
if (indeterminate) {
this.selectedFields = uniq([
...this.selectedFields,
...this.$options.fields.map((f) => f.key),
]);
return;
}
if (checked) {
this.selectedFields = [];
} else {
this.selectedFields = [...this.$options.fields.map((f) => f.key)];
}
}, },
}, },
/* eslint-disable @gitlab/require-i18n-strings */
fields: [
{ key: 'requirement id', value: 'Requirement ID' },
{ key: 'title', value: 'Title' },
{ key: 'description', value: 'Description' },
{ key: 'author', value: 'Author' },
{ key: 'author username', value: 'Author Username' },
{ key: 'created at (utc)', value: 'Created At (UTC)' },
{ key: 'state', value: 'State' },
{ key: 'state updated at (utc)', value: 'State Updated At (UTC)' },
],
}; };
</script> </script>
...@@ -39,27 +112,50 @@ export default { ...@@ -39,27 +112,50 @@ export default {
ref="modal" ref="modal"
size="sm" size="sm"
modal-id="export-requirements" modal-id="export-requirements"
:title="$options.i18n.exportRequirements" dialog-class="gl-mx-5"
:title="modalTitle"
:ok-title="$options.i18n.exportRequirements" :ok-title="$options.i18n.exportRequirements"
ok-variant="success" ok-variant="info"
ok-only ok-only
@ok="handleExport" @ok="handleExport"
> >
<p> <p>
<gl-sprintf <gl-alert :dismissible="false">
:message=" <gl-sprintf :message="__('These will be sent to %{email} in an attachment once finished.')">
__( <template #email>
'%{requirementCount} requirements have been selected for export. These will be sent to %{email} as an attachment once finished.', <strong>{{ email }}</strong>
) </template>
" </gl-sprintf>
> </gl-alert>
<template #requirementCount>
<strong>{{ requirementCount }}</strong>
</template>
<template #email>
<strong>{{ email }}</strong>
</template>
</gl-sprintf>
</p> </p>
<div>
<h5 class="gl-mb-0! gl-mt-5">{{ __('Advanced export options') }}</h5>
<p class="gl-mb-3">
{{ __('Please select what should be included in each exported requirement.') }}
</p>
<div class="scrollbox gl-mb-3">
<div class="scrollbox-header gl-p-5">
<gl-form-checkbox
v-bind="selectedOptionsProps"
class="gl-mb-0 gl-mr-0"
@change="toggleAllFields"
>{{ __('Select all') }}</gl-form-checkbox
>
</div>
<div class="gl-pt-5 gl-pb-4 scrollbox-body">
<gl-form-checkbox
v-for="field in $options.fields"
:key="field.key"
class="gl-mb-0 gl-mr-0 form-check gl-pb-5 gl-ml-5"
:checked="isFieldSelected(field.key)"
@change="() => toggleField(field.key)"
>{{ field.value }}</gl-form-checkbox
>
</div>
<div class="scrollbox-fade"></div>
</div>
</div>
</gl-modal> </gl-modal>
</template> </template>
...@@ -408,7 +408,7 @@ export default { ...@@ -408,7 +408,7 @@ export default {
createFlash({ message }); createFlash({ message });
}); });
}, },
exportCsv() { exportCsv(selectedFields) {
return this.$apollo return this.$apollo
.mutate({ .mutate({
mutation: exportRequirement, mutation: exportRequirement,
...@@ -418,6 +418,7 @@ export default { ...@@ -418,6 +418,7 @@ export default {
authorUsername: this.authorUsernames, authorUsername: this.authorUsernames,
search: this.textSearch, search: this.textSearch,
sortBy: this.sortBy, sortBy: this.sortBy,
selectedFields,
}, },
}) })
.catch((e) => { .catch((e) => {
......
...@@ -4,6 +4,7 @@ mutation exportRequirements( ...@@ -4,6 +4,7 @@ mutation exportRequirements(
$authorUsername: [String!] = [] $authorUsername: [String!] = []
$search: String = "" $search: String = ""
$sortBy: Sort = CREATED_DESC $sortBy: Sort = CREATED_DESC
$selectedFields: [String!] = []
) { ) {
exportRequirements( exportRequirements(
input: { input: {
...@@ -12,6 +13,7 @@ mutation exportRequirements( ...@@ -12,6 +13,7 @@ mutation exportRequirements(
authorUsername: $authorUsername authorUsername: $authorUsername
state: $state state: $state
sort: $sortBy sort: $sortBy
selectedFields: $selectedFields
} }
) { ) {
errors errors
......
...@@ -143,3 +143,41 @@ ...@@ -143,3 +143,41 @@
max-width: 100%; max-width: 100%;
} }
} }
#export-requirements {
.scrollbox {
border: 1px solid $gray-200;
border-radius: $border-radius-default;
position: relative;
.scrollbox-header {
border-bottom: 1px solid $gray-200;
}
.scrollbox-body {
max-height: 200px;
overflow: auto;
}
.scrollbox-fade {
position: absolute;
bottom: 0;
height: 20px;
width: 100%;
background-image: linear-gradient(180deg, transparent 0%, $white 100%);
z-index: 1;
border-radius: $border-radius-default;
}
}
.modal-content {
align-self: flex-start;
margin-top: $gl-spacing-scale-11;
}
@media (max-width: $breakpoint-sm) {
.modal-dialog {
margin-top: $gl-spacing-scale-11;
}
}
}
---
title: Export only selected fields in requirements
merge_request: 53707
author:
type: added
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
import ExportRequirementsModal from 'ee/requirements/components/export_requirements_modal.vue'; import ExportRequirementsModal from 'ee/requirements/components/export_requirements_modal.vue';
...@@ -32,6 +32,74 @@ describe('ExportRequirementsModal', () => { ...@@ -32,6 +32,74 @@ describe('ExportRequirementsModal', () => {
expect(emitted).toBeDefined(); expect(emitted).toBeDefined();
}); });
}); });
describe('toggleField', () => {
it("removes field if it's already selected", async () => {
const [field] = wrapper.vm.$options.fields;
wrapper.vm.toggleField(field.key);
expect(wrapper.vm.selectedFields.includes(field)).toBe(false);
});
it("adds field if it's not selected", async () => {
const [field] = wrapper.vm.$options.fields;
await wrapper.setData({
selectedFields: wrapper.vm.$options.fields.slice(1).map((f) => f.key),
});
wrapper.vm.toggleField(field.key);
expect(wrapper.vm.selectedFields.includes(field.key)).toBe(true);
});
});
describe('isFieldSelected', () => {
it('returns true when field is in selectedFields', () => {
const [field] = wrapper.vm.$options.fields;
expect(wrapper.vm.isFieldSelected(field.key)).toBe(true);
});
it('returns false when field is in selectedFields', async () => {
const [field] = wrapper.vm.$options.fields;
await wrapper.setData({
selectedFields: wrapper.vm.$options.fields.slice(1).map((f) => f.key),
});
expect(wrapper.vm.isFieldSelected(field.key)).toBe(false);
});
});
describe('toggleAllFields', () => {
it('selects all if few are selected', async () => {
await wrapper.setData({
selectedFields: wrapper.vm.$options.fields.slice(1).map((f) => f.key),
});
wrapper.vm.toggleAllFields();
expect(wrapper.vm.selectedFields).toHaveLength(wrapper.vm.$options.fields.length);
});
it('unchecks all if all are selected', () => {
wrapper.vm.toggleAllFields();
expect(wrapper.vm.selectedFields).toHaveLength(0);
});
it('selects all if none are selected', async () => {
await wrapper.setData({
selectedFields: [],
});
wrapper.vm.toggleAllFields();
expect(wrapper.vm.selectedFields).toHaveLength(wrapper.vm.$options.fields.length);
});
});
}); });
describe('template', () => { describe('template', () => {
...@@ -42,5 +110,17 @@ describe('ExportRequirementsModal', () => { ...@@ -42,5 +110,17 @@ describe('ExportRequirementsModal', () => {
expect(emitted).toBeDefined(); expect(emitted).toBeDefined();
}); });
it('renders checkboxes for advanced exporting', () => {
const checkboxes = wrapper.find('.scrollbox-body').findAll(GlFormCheckbox);
expect(checkboxes).toHaveLength(wrapper.vm.$options.fields.length);
});
it('renders Select all checkbox', () => {
const checkbox = wrapper.find('.scrollbox-header').findAll(GlFormCheckbox);
expect(checkbox).toHaveLength(1);
});
}); });
}); });
...@@ -738,9 +738,6 @@ msgstr "" ...@@ -738,9 +738,6 @@ 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 ""
...@@ -2552,6 +2549,9 @@ msgstr "" ...@@ -2552,6 +2549,9 @@ msgstr ""
msgid "Advanced Settings" msgid "Advanced Settings"
msgstr "" msgstr ""
msgid "Advanced export options"
msgstr ""
msgid "Advanced permissions, Large File Storage and Two-Factor authentication settings." msgid "Advanced permissions, Large File Storage and Two-Factor authentication settings."
msgstr "" msgstr ""
...@@ -12125,6 +12125,9 @@ msgstr "" ...@@ -12125,6 +12125,9 @@ msgstr ""
msgid "Export" msgid "Export"
msgstr "" msgstr ""
msgid "Export %{requirementsCount} requirements?"
msgstr ""
msgid "Export as CSV" msgid "Export as CSV"
msgstr "" msgstr ""
...@@ -22187,6 +22190,9 @@ msgstr "" ...@@ -22187,6 +22190,9 @@ msgstr ""
msgid "Please select at least one filter to see results" msgid "Please select at least one filter to see results"
msgstr "" msgstr ""
msgid "Please select what should be included in each exported requirement."
msgstr ""
msgid "Please set a new password before proceeding." msgid "Please set a new password before proceeding."
msgstr "" msgstr ""
...@@ -29805,6 +29811,9 @@ msgstr "" ...@@ -29805,6 +29811,9 @@ msgstr ""
msgid "These variables are inherited from the parent group." msgid "These variables are inherited from the parent group."
msgstr "" msgstr ""
msgid "These will be sent to %{email} in an attachment once finished."
msgstr ""
msgid "Third Party Advisory Link" msgid "Third Party Advisory Link"
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