Commit 8462b4a7 authored by Tan Le's avatar Tan Le

Add CSV export button to Admin Audit Log

Administrator can export audit events to CSV from Admin Audit Log
page. This will use the current filter criteria for exporting. Due to
the current constraint of Rails `ActiveRecord:Batches`, we are unable to
set custom sort order by non-id fields (e.g. created_at).
parent e8094a0f
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import AuditEventsFilter from './audit_events_filter.vue'; import AuditEventsFilter from './audit_events_filter.vue';
import DateRangeField from './date_range_field.vue'; import DateRangeField from './date_range_field.vue';
import SortingField from './sorting_field.vue'; import SortingField from './sorting_field.vue';
import AuditEventsTable from './audit_events_table.vue'; import AuditEventsTable from './audit_events_table.vue';
import AuditEventsExportButton from './audit_events_export_button.vue';
export default { export default {
components: { components: {
...@@ -11,6 +12,7 @@ export default { ...@@ -11,6 +12,7 @@ export default {
DateRangeField, DateRangeField,
SortingField, SortingField,
AuditEventsTable, AuditEventsTable,
AuditEventsExportButton,
}, },
props: { props: {
events: { events: {
...@@ -37,9 +39,21 @@ export default { ...@@ -37,9 +39,21 @@ export default {
required: false, required: false,
default: undefined, default: undefined,
}, },
exportUrl: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
...mapState(['filterValue', 'startDate', 'endDate', 'sortBy']), ...mapState(['filterValue', 'startDate', 'endDate', 'sortBy']),
...mapGetters(['buildExportHref']),
exportHref() {
return this.buildExportHref(this.exportUrl);
},
hasExportUrl() {
return this.exportUrl.length;
},
}, },
methods: { methods: {
...mapActions(['setDateRange', 'setFilterValue', 'setSortBy', 'searchForAuditEvents']), ...mapActions(['setDateRange', 'setFilterValue', 'setSortBy', 'searchForAuditEvents']),
...@@ -49,6 +63,11 @@ export default { ...@@ -49,6 +63,11 @@ export default {
<template> <template>
<div> <div>
<header>
<div class="gl-my-5 gl-display-flex gl-flex-direction-row gl-justify-content-end">
<audit-events-export-button v-if="hasExportUrl" :export-href="exportHref" />
</div>
</header>
<div class="row-content-block second-block pb-0"> <div class="row-content-block second-block pb-0">
<div class="d-flex justify-content-between audit-controls row"> <div class="d-flex justify-content-between audit-controls row">
<div class="col-lg-auto flex-fill form-group align-items-lg-center pr-lg-8"> <div class="col-lg-auto flex-fill form-group align-items-lg-center pr-lg-8">
......
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
exportHref: {
required: true,
type: String,
},
},
strings: {
buttonText: __('Export as CSV'),
tooltipText: __('Max size 15 MB'),
},
};
</script>
<template>
<gl-button
v-gl-tooltip.hover
:href="exportHref"
icon="export"
:title="$options.strings.tooltipText"
>
{{ $options.strings.buttonText }}
</gl-button>
</template>
...@@ -7,7 +7,14 @@ import createStore from './store'; ...@@ -7,7 +7,14 @@ import createStore from './store';
export default selector => { export default selector => {
const el = document.querySelector(selector); const el = document.querySelector(selector);
const { events, isLastPage, filterTokenOptions, filterQaSelector, tableQaSelector } = el.dataset; const {
events,
isLastPage,
filterTokenOptions,
filterQaSelector,
tableQaSelector,
exportUrl,
} = el.dataset;
const store = createStore(); const store = createStore();
store.dispatch('initializeAuditEvents'); store.dispatch('initializeAuditEvents');
...@@ -25,6 +32,7 @@ export default selector => { ...@@ -25,6 +32,7 @@ export default selector => {
), ),
filterQaSelector, filterQaSelector,
tableQaSelector, tableQaSelector,
exportUrl,
}, },
}), }),
}); });
......
import { setUrlParams } from '~/lib/utils/url_utility';
import { createAuditEventSearchQuery } from '../utils';
/**
* Returns the CSV export href for given base path and search filters
* @param {string} exportUrl
* @returns {string}
*/
export const buildExportHref = state => exportUrl => {
return setUrlParams(
createAuditEventSearchQuery({
filterValue: state.filterValue,
startDate: state.startDate,
endDate: state.endDate,
}),
exportUrl,
);
};
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
...@@ -10,6 +11,7 @@ export default () => ...@@ -10,6 +11,7 @@ export default () =>
new Vuex.Store({ new Vuex.Store({
namespaced: true, namespaced: true,
actions, actions,
getters,
mutations, mutations,
state, state,
}); });
...@@ -37,4 +37,8 @@ module AuditEventsHelper ...@@ -37,4 +37,8 @@ module AuditEventsHelper
"#{key} <strong>#{value}</strong>" "#{key} <strong>#{value}</strong>"
end end
end end
def export_url
Feature.enabled?(:audit_log_export_csv) ? admin_audit_log_reports_url(format: :csv) : ''
end
end end
...@@ -5,4 +5,5 @@ ...@@ -5,4 +5,5 @@
is_last_page: @is_last_page.to_json, is_last_page: @is_last_page.to_json,
filter_qa_selector: 'admin_audit_log_filter', filter_qa_selector: 'admin_audit_log_filter',
table_qa_selector: 'admin_audit_log_table', table_qa_selector: 'admin_audit_log_table',
filter_token_options: admin_audit_event_tokens.to_json } } filter_token_options: admin_audit_event_tokens.to_json,
export_url: export_url } }
...@@ -2,6 +2,16 @@ ...@@ -2,6 +2,16 @@
exports[`AuditEventsApp when initialized matches the snapshot 1`] = ` exports[`AuditEventsApp when initialized matches the snapshot 1`] = `
<div> <div>
<header>
<div
class="gl-my-5 gl-display-flex gl-flex-direction-row gl-justify-content-end"
>
<audit-events-export-button-stub
exporthref="http://example.com/audit_log_reports.csv?created_after=2020-01-01&created_before=2020-02-02"
/>
</div>
</header>
<div <div
class="row-content-block second-block pb-0" class="row-content-block second-block pb-0"
> >
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuditEventsExportButton component Audit Events CSV export button matches the snapshot 1`] = `
<gl-button-stub
category="primary"
href="http://example.com/audit_log_reports.csv?created_after=2020-12-12"
icon="export"
size="medium"
title="Max size 15 MB"
variant="default"
>
Export as CSV
</gl-button-stub>
`;
...@@ -5,6 +5,7 @@ import DateRangeField from 'ee/audit_events/components/date_range_field.vue'; ...@@ -5,6 +5,7 @@ import DateRangeField from 'ee/audit_events/components/date_range_field.vue';
import SortingField from 'ee/audit_events/components/sorting_field.vue'; import SortingField from 'ee/audit_events/components/sorting_field.vue';
import AuditEventsTable from 'ee/audit_events/components/audit_events_table.vue'; import AuditEventsTable from 'ee/audit_events/components/audit_events_table.vue';
import AuditEventsFilter from 'ee/audit_events/components/audit_events_filter.vue'; import AuditEventsFilter from 'ee/audit_events/components/audit_events_filter.vue';
import AuditEventsExportButton from 'ee/audit_events/components/audit_events_export_button.vue';
import { AVAILABLE_TOKEN_TYPES } from 'ee/audit_events/constants'; import { AVAILABLE_TOKEN_TYPES } from 'ee/audit_events/constants';
import createStore from 'ee/audit_events/store'; import createStore from 'ee/audit_events/store';
...@@ -21,6 +22,7 @@ describe('AuditEventsApp', () => { ...@@ -21,6 +22,7 @@ describe('AuditEventsApp', () => {
const filterTokenOptions = AVAILABLE_TOKEN_TYPES.map(type => ({ type })); const filterTokenOptions = AVAILABLE_TOKEN_TYPES.map(type => ({ type }));
const filterQaSelector = 'filter_qa_selector'; const filterQaSelector = 'filter_qa_selector';
const tableQaSelector = 'table_qa_selector'; const tableQaSelector = 'table_qa_selector';
const exportUrl = 'http://example.com/audit_log_reports.csv';
const initComponent = (props = {}) => { const initComponent = (props = {}) => {
wrapper = shallowMount(AuditEventsApp, { wrapper = shallowMount(AuditEventsApp, {
...@@ -31,6 +33,7 @@ describe('AuditEventsApp', () => { ...@@ -31,6 +33,7 @@ describe('AuditEventsApp', () => {
tableQaSelector, tableQaSelector,
filterTokenOptions, filterTokenOptions,
events, events,
exportUrl,
...props, ...props,
}, },
stubs: { stubs: {
...@@ -90,6 +93,13 @@ describe('AuditEventsApp', () => { ...@@ -90,6 +93,13 @@ describe('AuditEventsApp', () => {
it('renders sorting field', () => { it('renders sorting field', () => {
expect(wrapper.find(SortingField).props()).toEqual({ sortBy: TEST_SORT_BY }); expect(wrapper.find(SortingField).props()).toEqual({ sortBy: TEST_SORT_BY });
}); });
it('renders the audit events export button', () => {
expect(wrapper.find(AuditEventsExportButton).props()).toEqual({
exportHref:
'http://example.com/audit_log_reports.csv?created_after=2020-01-01&created_before=2020-02-02',
});
});
}); });
describe('when a field is selected', () => { describe('when a field is selected', () => {
...@@ -111,4 +121,14 @@ describe('AuditEventsApp', () => { ...@@ -111,4 +121,14 @@ describe('AuditEventsApp', () => {
expect(store.dispatch).toHaveBeenCalledWith(action, payload); expect(store.dispatch).toHaveBeenCalledWith(action, payload);
}); });
}); });
describe('when the audit events export link is not present', () => {
beforeEach(() => {
initComponent({ exportUrl: '' });
});
it('does not render the audit events export button', () => {
expect(wrapper.find(AuditEventsExportButton).exists()).toBe(false);
});
});
}); });
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import AuditEventsExportButton from 'ee/audit_events/components/audit_events_export_button.vue';
const EXPORT_HREF = 'http://example.com/audit_log_reports.csv?created_after=2020-12-12';
describe('AuditEventsExportButton component', () => {
let wrapper;
const findExportButton = () => wrapper.find(GlButton);
const createComponent = (props = {}) => {
return shallowMount(AuditEventsExportButton, {
propsData: {
exportHref: EXPORT_HREF,
...props,
},
});
};
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('Audit Events CSV export button', () => {
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the Audit Events CSV export button', () => {
expect(findExportButton().exists()).toBe(true);
});
it('renders the export icon', () => {
expect(findExportButton().props('icon')).toBe('export');
});
it('links to the CSV download path', () => {
expect(findExportButton().attributes('href')).toEqual(EXPORT_HREF);
});
});
});
import * as getters from 'ee/audit_events/store/getters';
import createState from 'ee/audit_events/store/state';
describe('Audit Events getters', () => {
describe('buildExportHref', () => {
const exportUrl = 'https://example.com/audit_reports.csv';
describe('with empty state', () => {
it('returns the export href', () => {
const state = createState();
expect(getters.buildExportHref(state)(exportUrl)).toEqual(
'https://example.com/audit_reports.csv',
);
});
});
describe('with filters and dates', () => {
it('returns the export url', () => {
const filterValue = [{ type: 'user', value: { data: 1, operator: '=' } }];
const startDate = new Date(2020, 1, 2);
const endDate = new Date(2020, 1, 30);
const state = { ...createState, ...{ filterValue, startDate, endDate } };
expect(getters.buildExportHref(state)(exportUrl)).toEqual(
'https://example.com/audit_reports.csv?' +
'created_after=2020-02-02&created_before=2020-03-01' +
'&entity_id=1&entity_type=User',
);
});
});
});
});
...@@ -3,11 +3,13 @@ ...@@ -3,11 +3,13 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe AuditEventsHelper do RSpec.describe AuditEventsHelper do
using RSpec::Parameterized::TableSyntax
describe '#admin_audit_event_tokens' do describe '#admin_audit_event_tokens' do
it 'returns the available tokens' do it 'returns the available tokens' do
available_tokens = [{ type: AuditEventsHelper::FILTER_TOKEN_TYPES[:user] }, { type: AuditEventsHelper::FILTER_TOKEN_TYPES[:group] }, { type: AuditEventsHelper::FILTER_TOKEN_TYPES[:project] }] available_tokens = [
{ type: AuditEventsHelper::FILTER_TOKEN_TYPES[:user] },
{ type: AuditEventsHelper::FILTER_TOKEN_TYPES[:group] },
{ type: AuditEventsHelper::FILTER_TOKEN_TYPES[:project] }
]
expect(admin_audit_event_tokens).to eq(available_tokens) expect(admin_audit_event_tokens).to eq(available_tokens)
end end
end end
...@@ -91,4 +93,24 @@ RSpec.describe AuditEventsHelper do ...@@ -91,4 +93,24 @@ RSpec.describe AuditEventsHelper do
expect(select_keys('expiry_to', nil)).to eq 'expiry_to <strong>never expires</strong>' expect(select_keys('expiry_to', nil)).to eq 'expiry_to <strong>never expires</strong>'
end end
end end
describe '#export_url' do
subject { export_url }
context 'feature is enabled' do
before do
stub_feature_flags(audit_log_export_csv: true)
end
it { is_expected.to eq('http://test.host/admin/audit_log_reports.csv') }
end
context 'feature is disabled' do
before do
stub_feature_flags(audit_log_export_csv: false)
end
it { is_expected.to be_empty }
end
end
end end
...@@ -15355,6 +15355,9 @@ msgstr "" ...@@ -15355,6 +15355,9 @@ msgstr ""
msgid "Max seats used" msgid "Max seats used"
msgstr "" msgstr ""
msgid "Max size 15 MB"
msgstr ""
msgid "Maximum Users:" msgid "Maximum Users:"
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