Commit 8668b83e authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '1449-export-audit-events-to-csv-ui' into 'master'

Add CSV export button to Admin Audit Log

See merge request gitlab-org/gitlab!42203
parents a90559bd 8462b4a7
<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
...@@ -15385,6 +15385,9 @@ msgstr "" ...@@ -15385,6 +15385,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