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>
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import AuditEventsFilter from './audit_events_filter.vue';
import DateRangeField from './date_range_field.vue';
import SortingField from './sorting_field.vue';
import AuditEventsTable from './audit_events_table.vue';
import AuditEventsExportButton from './audit_events_export_button.vue';
export default {
components: {
......@@ -11,6 +12,7 @@ export default {
DateRangeField,
SortingField,
AuditEventsTable,
AuditEventsExportButton,
},
props: {
events: {
......@@ -37,9 +39,21 @@ export default {
required: false,
default: undefined,
},
exportUrl: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState(['filterValue', 'startDate', 'endDate', 'sortBy']),
...mapGetters(['buildExportHref']),
exportHref() {
return this.buildExportHref(this.exportUrl);
},
hasExportUrl() {
return this.exportUrl.length;
},
},
methods: {
...mapActions(['setDateRange', 'setFilterValue', 'setSortBy', 'searchForAuditEvents']),
......@@ -49,6 +63,11 @@ export default {
<template>
<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="d-flex justify-content-between audit-controls row">
<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';
export default 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();
store.dispatch('initializeAuditEvents');
......@@ -25,6 +32,7 @@ export default selector => {
),
filterQaSelector,
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 Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
......@@ -10,6 +11,7 @@ export default () =>
new Vuex.Store({
namespaced: true,
actions,
getters,
mutations,
state,
});
......@@ -37,4 +37,8 @@ module AuditEventsHelper
"#{key} <strong>#{value}</strong>"
end
end
def export_url
Feature.enabled?(:audit_log_export_csv) ? admin_audit_log_reports_url(format: :csv) : ''
end
end
......@@ -5,4 +5,5 @@
is_last_page: @is_last_page.to_json,
filter_qa_selector: 'admin_audit_log_filter',
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 @@
exports[`AuditEventsApp when initialized matches the snapshot 1`] = `
<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
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';
import SortingField from 'ee/audit_events/components/sorting_field.vue';
import AuditEventsTable from 'ee/audit_events/components/audit_events_table.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 createStore from 'ee/audit_events/store';
......@@ -21,6 +22,7 @@ describe('AuditEventsApp', () => {
const filterTokenOptions = AVAILABLE_TOKEN_TYPES.map(type => ({ type }));
const filterQaSelector = 'filter_qa_selector';
const tableQaSelector = 'table_qa_selector';
const exportUrl = 'http://example.com/audit_log_reports.csv';
const initComponent = (props = {}) => {
wrapper = shallowMount(AuditEventsApp, {
......@@ -31,6 +33,7 @@ describe('AuditEventsApp', () => {
tableQaSelector,
filterTokenOptions,
events,
exportUrl,
...props,
},
stubs: {
......@@ -90,6 +93,13 @@ describe('AuditEventsApp', () => {
it('renders sorting field', () => {
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', () => {
......@@ -111,4 +121,14 @@ describe('AuditEventsApp', () => {
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 @@
require 'spec_helper'
RSpec.describe AuditEventsHelper do
using RSpec::Parameterized::TableSyntax
describe '#admin_audit_event_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)
end
end
......@@ -91,4 +93,24 @@ RSpec.describe AuditEventsHelper do
expect(select_keys('expiry_to', nil)).to eq 'expiry_to <strong>never expires</strong>'
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
......@@ -15355,6 +15355,9 @@ msgstr ""
msgid "Max seats used"
msgstr ""
msgid "Max size 15 MB"
msgstr ""
msgid "Maximum Users:"
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