Commit 9bc4c7d9 authored by Dylan Griffith's avatar Dylan Griffith

Merge branch 'admin-audit-log-table-redesign' into 'master'

Admin audit log table redesign

Closes #216126

See merge request gitlab-org/gitlab!27578
parents 7096acb4 dd0e0c92
<script>
import { GlPagination, GlTable } from '@gitlab/ui';
import { s__ } from '~/locale';
import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility';
import UrlTableCell from './url_table_cell.vue';
const TABLE_HEADER_CLASSES = 'bg-transparent border-bottom p-3';
export default {
name: 'LogsTable',
components: {
GlTable,
GlPagination,
UrlTableCell,
},
props: {
events: {
type: Array,
required: false,
default: () => [],
},
isLastPage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
page: parseInt(getParameterValues('page')[0], 10) || 1,
};
},
computed: {
displayPagination() {
return this.events.length > 0;
},
prevPage() {
return this.page > 1 ? this.page - 1 : null;
},
nextPage() {
return !this.isLastPage ? this.page + 1 : null;
},
},
methods: {
generateLink(page) {
return setUrlParams({ page });
},
},
fields: [
{
key: 'author',
label: s__('AuditLogs|Author'),
thClass: TABLE_HEADER_CLASSES,
},
{
key: 'object',
label: s__('AuditLogs|Object'),
thClass: TABLE_HEADER_CLASSES,
},
{
key: 'action',
label: s__('AuditLogs|Action'),
thClass: TABLE_HEADER_CLASSES,
},
{
key: 'target',
label: s__('AuditLogs|Target'),
thClass: TABLE_HEADER_CLASSES,
},
{
key: 'ip_address',
label: s__('AuditLogs|IP Address'),
thClass: TABLE_HEADER_CLASSES,
},
{
key: 'date',
label: s__('AuditLogs|Date'),
thClass: TABLE_HEADER_CLASSES,
},
],
};
</script>
<template>
<div class="audit-log-table" data-qa-selector="admin_audit_log_table">
<gl-table class="mt-3" :fields="$options.fields" :items="events" show-empty>
<template #cell(author)="{ value: { url, name } }">
<url-table-cell :url="url" :name="name" />
</template>
<template #cell(object)="{ value: { url, name } }">
<url-table-cell :url="url" :name="name" />
</template>
</gl-table>
<gl-pagination
v-if="displayPagination"
v-model="page"
:prev-page="prevPage"
:next-page="nextPage"
:link-gen="generateLink"
align="center"
class="w-100"
/>
</div>
</template>
<script>
import { s__ } from '~/locale';
const REMOVED_TEXT = s__('AuditLogs|(removed)');
export default {
props: {
url: {
type: String,
required: false,
default: '',
},
name: {
type: String,
required: true,
},
},
REMOVED_TEXT,
};
</script>
<template>
<a v-if="url" :href="url">{{ name }}</a>
<span v-else>
{{ name }} <small>{{ $options.REMOVED_TEXT }}</small>
</span>
</template>
import Vue from 'vue'; import Vue from 'vue';
import DateRangeField from './components/date_range_field.vue'; import { parseBoolean } from '~/lib/utils/common_utils';
import DateRangeField from 'ee/audit_logs/components/date_range_field.vue';
import LogsTable from 'ee/audit_logs/components/logs_table.vue';
import AuditLogs from './audit_logs'; import AuditLogs from './audit_logs';
// Merge these when working on https://gitlab.com/gitlab-org/gitlab/-/issues/215363
document.addEventListener('DOMContentLoaded', () => new AuditLogs()); document.addEventListener('DOMContentLoaded', () => new AuditLogs());
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#js-audit-logs-date-range-app'); const el = document.querySelector('#js-audit-logs-date-range-app');
...@@ -21,3 +26,19 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -21,3 +26,19 @@ document.addEventListener('DOMContentLoaded', () => {
}), }),
}); });
}); });
document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#js-audit-logs-table-app');
// eslint-disable-next-line no-new
new Vue({
el,
name: 'AuditLogsTableApp',
render: createElement =>
createElement(LogsTable, {
props: {
events: JSON.parse(el.dataset.events),
isLastPage: parseBoolean(el.dataset.isLastPage),
},
}),
});
});
// TODO: Remove this once https://gitlab.com/gitlab-org/gitlab/-/issues/213324 is resolved
.audit-log-table .gl-table {
th {
font-weight: 600;
line-height: 1rem;
}
th,
td {
@include gl-text-gray-700;
}
}
...@@ -11,6 +11,7 @@ class Admin::AuditLogsController < Admin::ApplicationController ...@@ -11,6 +11,7 @@ class Admin::AuditLogsController < Admin::ApplicationController
def index def index
@events = audit_log_events @events = audit_log_events
@table_events = AuditEventSerializer.new.represent(@events)
@entity = case audit_logs_params[:entity_type] @entity = case audit_logs_params[:entity_type]
when 'User' when 'User'
......
...@@ -4,11 +4,13 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple ...@@ -4,11 +4,13 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple
presents :audit_event presents :audit_event
def author_name def author_name
author = audit_event.lazy_author author&.name
end
return author.name if author.is_a?(Gitlab::Audit::NullAuthor) def author_url
return if author.is_a?(Gitlab::Audit::NullAuthor)
link_to(author.name, user_path(author)) url_for(user_path(author))
end end
def target def target
...@@ -24,11 +26,15 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple ...@@ -24,11 +26,15 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple
end end
def object def object
entity = audit_event.lazy_entity return if entity.is_a?(Gitlab::Audit::NullEntity)
details[:entity_path] || entity.name
end
def object_url
return if entity.is_a?(Gitlab::Audit::NullEntity) return if entity.is_a?(Gitlab::Audit::NullEntity)
link_to(details[:entity_path] || entity.name, entity).html_safe url_for(entity)
end end
def date def date
...@@ -41,11 +47,11 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple ...@@ -41,11 +47,11 @@ class AuditEventPresenter < Gitlab::View::Presenter::Simple
private private
# The class can't include ActionView::Helpers::UrlHelper because it overwrites def author
# the method url_for. In this helper, that implementation of that method @author ||= audit_event.lazy_author
# doesn't accept objects to resolve their route. That's why here we call the end
# native url_for to get the route of the object and then call the link_to with it
def link_to(name, object) def entity
ActionController::Base.helpers.link_to(name, url_for(object)) @entity ||= audit_event.lazy_entity
end end
end end
# frozen_string_literal: true
class AuditEventEntity < Grape::Entity
alias_method :audit_event, :object
expose :id
expose :author do |_|
{
name: presenter.author_name,
url: presenter.author_url
}
end
expose :action do |_|
presenter.action
end
expose :date do |_|
presenter.date
end
expose :ip_address do |_|
presenter.ip_address
end
expose :object do |_|
{
name: presenter.object,
url: presenter.object_url
}
end
expose :target do |_|
presenter.target
end
private
def presenter
@presenter ||= audit_event.present
end
end
# frozen_string_literal: true
class AuditEventSerializer < BaseSerializer
entity AuditEventEntity
end
...@@ -49,31 +49,5 @@ ...@@ -49,31 +49,5 @@
#js-audit-logs-date-range-app #js-audit-logs-date-range-app
= render 'shared/audit_events/event_sort' = render 'shared/audit_events/event_sort'
- if @events.present? - if @table_events.present?
%table#events-table.table #js-audit-logs-table-app{ data: { events: @table_events.to_json, is_last_page: @events.last_page?.to_json, qa_selector: 'admin_audit_log_table' } }
%thead
%tr
%th Author
%th Object
%th Action
%th Target
%th IP Address
%th Date
%tbody
- @events.map(&:present).each do |event|
%tr{ data: { qa_selector: 'admin_audit_log_row_content' } }
%td
- if (author_link = event.author_name)
= author_link
- else
#{event.details[:author_name]} <small>(removed)</small>
%td
- if (object_link = event.object)
= object_link
- else
#{event.details[:entity_path]} <small>(removed)</small>
%td= sanitize(event.action, tags: %w(strong))
%td= event.target
%td= event.ip_address
%td= event.date
= paginate_without_count @events
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
= _('Author') = _('Author')
.table-mobile-content .table-mobile-content
- if event.author_name - if event.author_name
= event.author_name %a{ href: event.author_url }= event.author_name
- else - else
= s_('AuditEvents|(removed)') = s_('AuditEvents|(removed)')
.table-section.section-50.section-wrap.audit-action.js-audit-action .table-section.section-50.section-wrap.audit-action.js-audit-action
......
...@@ -92,7 +92,7 @@ describe 'Admin::AuditLogs', :js do ...@@ -92,7 +92,7 @@ describe 'Admin::AuditLogs', :js do
wait_for_requests wait_for_requests
find('.select2-results').click find('.select2-results').click
find('#events-table td', match: :first) find('.audit-log-table td', match: :first)
expect(page).to have_content('Added user access as Owner') expect(page).to have_content('Added user access as Owner')
end end
...@@ -116,13 +116,13 @@ describe 'Admin::AuditLogs', :js do ...@@ -116,13 +116,13 @@ describe 'Admin::AuditLogs', :js do
wait_for_requests wait_for_requests
find('.select2-results').click find('.select2-results').click
find('#events-table td', match: :first) find('.audit-log-table td', match: :first)
expect(page).to have_content('Removed user access') expect(page).to have_content('Removed user access')
end end
end end
describe 'filter by date', js: false do describe 'filter by date' do
let_it_be(:audit_event_1) { create(:user_audit_event, created_at: 5.days.ago) } let_it_be(:audit_event_1) { create(:user_audit_event, created_at: 5.days.ago) }
let_it_be(:audit_event_2) { create(:user_audit_event, created_at: 3.days.ago) } let_it_be(:audit_event_2) { create(:user_audit_event, created_at: 3.days.ago) }
let_it_be(:audit_event_3) { create(:user_audit_event, created_at: 1.day.ago) } let_it_be(:audit_event_3) { create(:user_audit_event, created_at: 1.day.ago) }
...@@ -130,17 +130,21 @@ describe 'Admin::AuditLogs', :js do ...@@ -130,17 +130,21 @@ describe 'Admin::AuditLogs', :js do
it 'shows only 2 days old events' do it 'shows only 2 days old events' do
visit admin_audit_logs_path(created_after: 4.days.ago.to_date, created_before: 2.days.ago.to_date) visit admin_audit_logs_path(created_after: 4.days.ago.to_date, created_before: 2.days.ago.to_date)
expect(page).to have_content(audit_event_2.present.date) find('.audit-log-table td', match: :first)
expect(page).not_to have_content(audit_event_1.present.date) expect(page).not_to have_content(audit_event_1.present.date)
expect(page).to have_content(audit_event_2.present.date)
expect(page).not_to have_content(audit_event_3.present.date) expect(page).not_to have_content(audit_event_3.present.date)
end end
it 'shows only yesterday events' do it 'shows only yesterday events' do
visit admin_audit_logs_path(created_after: 2.days.ago.to_date) visit admin_audit_logs_path(created_after: 2.days.ago.to_date)
expect(page).to have_content(audit_event_3.present.date) find('.audit-log-table td', match: :first)
expect(page).not_to have_content(audit_event_1.present.date) expect(page).not_to have_content(audit_event_1.present.date)
expect(page).not_to have_content(audit_event_2.present.date) expect(page).not_to have_content(audit_event_2.present.date)
expect(page).to have_content(audit_event_3.present.date)
end end
it 'shows a message if provided date is invalid' do it 'shows a message if provided date is invalid' do
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlDaterangePicker } from '@gitlab/ui'; import { GlDaterangePicker } from '@gitlab/ui';
import DateRangeField from 'ee/pages/admin/audit_logs/components/date_range_field.vue'; import DateRangeField from 'ee/audit_logs/components/date_range_field.vue';
import { parsePikadayDate } from '~/lib/utils/datetime_utility'; import { parsePikadayDate } from '~/lib/utils/datetime_utility';
describe('DateRangeField component', () => { describe('DateRangeField component', () => {
......
import { mount } from '@vue/test-utils';
import { GlPagination, GlTable } from '@gitlab/ui';
import LogsTable from 'ee/audit_logs/components/logs_table.vue';
import createEvents from '../mock_data';
const EVENTS = createEvents();
describe('LogsTable component', () => {
let wrapper;
const createComponent = (props = {}) => {
return mount(LogsTable, {
propsData: {
events: EVENTS,
isLastPage: false,
...props,
},
});
};
const getCell = (trIdx, tdIdx) => {
return wrapper
.find(GlTable)
.find('tbody')
.findAll('tr')
.at(trIdx)
.findAll('td')
.at(tdIdx);
};
beforeEach(() => {
delete window.location;
window.location = new URL('https://localhost');
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('Table behaviour', () => {
it('should show', () => {
expect(getCell(0, 1).text()).toBe('User');
});
it('should show the empty state if there is no data', () => {
wrapper.setProps({ events: [] });
wrapper.vm.$nextTick(() => {
expect(getCell(0, 0).text()).toBe('There are no records to show');
});
});
});
describe('Pagination behaviour', () => {
it('should show', () => {
expect(wrapper.find(GlPagination).exists()).toBe(true);
});
it('should hide if there is no data', () => {
wrapper.setProps({ events: [] });
wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlPagination).exists()).toBe(false);
});
});
it('should get the page number from the URL', () => {
window.location.search = '?page=2';
wrapper = createComponent();
expect(wrapper.find(GlPagination).props().value).toBe(2);
});
it('should not have a prevPage if the page is 1', () => {
window.location.search = '?page=1';
wrapper = createComponent();
expect(wrapper.find(GlPagination).props().prevPage).toBe(null);
});
it('should set the prevPage to 1 if the page is 2', () => {
window.location.search = '?page=2';
wrapper = createComponent();
expect(wrapper.find(GlPagination).props().prevPage).toBe(1);
});
it('should not have a nextPage if isLastPage is true', () => {
wrapper.setProps({ isLastPage: true });
wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlPagination).props().nextPage).toBe(null);
});
});
it('should set the nextPage to 2 if the page is 1', () => {
window.location.search = '?page=1';
wrapper = createComponent();
expect(wrapper.find(GlPagination).props().nextPage).toBe(2);
});
it('should set the nextPage to 2 if the page is not set', () => {
expect(wrapper.find(GlPagination).props().nextPage).toBe(2);
});
});
});
import { shallowMount } from '@vue/test-utils';
import UrlTableCell from 'ee/audit_logs/components/url_table_cell.vue';
describe('UrlTableCell component', () => {
it('should show the link if the URL is provided', () => {
const wrapper = shallowMount(UrlTableCell, { propsData: { url: '/user-1', name: 'User 1' } });
const name = wrapper.find('a');
expect(name.exists()).toBe(true);
expect(name.attributes().href).toBe('/user-1');
expect(name.text()).toBe('User 1');
});
it('should show the removed text if no URL is provided', () => {
const wrapper = shallowMount(UrlTableCell, { propsData: { url: '', name: 'User 1' } });
const name = wrapper.find('span');
expect(name.exists()).toBe(true);
expect(name.text()).toBe('User 1 (removed)');
});
});
import { slugify } from '~/lib/utils/text_utility';
const DEFAULT_EVENT = {
action: 'Signed in with STANDARD authentication',
date: '2020-03-18 12:04:23',
ip_address: '127.0.0.1',
};
const populateEvent = (user, hasAuthorUrl = true, hasObjectUrl = true) => {
const author = { name: user, url: null };
const object = { name: user, url: null };
const userSlug = slugify(user);
if (hasAuthorUrl) {
author.url = `/${userSlug}`;
}
if (hasObjectUrl) {
object.url = `http://127.0.0.1:3000/${userSlug}`;
}
return {
...DEFAULT_EVENT,
author,
object,
target: user,
};
};
export default () => [
populateEvent('User'),
populateEvent('User 2', false),
populateEvent('User 3', true, false),
populateEvent('User 4', false, false),
];
...@@ -23,9 +23,13 @@ describe AuditEventPresenter do ...@@ -23,9 +23,13 @@ describe AuditEventPresenter do
end end
context 'exposes the author' do context 'exposes the author' do
it 'gets the event author name' do
expect(presenter.author_name).to eq(audit_event.user.name)
end
context 'event authored by a user that exists' do context 'event authored by a user that exists' do
it 'shows a link' do it 'returns a url' do
expect(presenter.author_name).to eq("<a href=\"#{user_path(audit_event.user)}\">#{audit_event.user.name}</a>") expect(presenter.author_url).to eq(url_for(user_path(audit_event.user)))
end end
end end
...@@ -50,8 +54,8 @@ describe AuditEventPresenter do ...@@ -50,8 +54,8 @@ describe AuditEventPresenter do
} }
end end
it 'shows a blank author name' do it 'does not return a url' do
expect(presenter.author_name).to be_blank expect(presenter.author_url).to be_blank
end end
end end
...@@ -101,15 +105,25 @@ describe AuditEventPresenter do ...@@ -101,15 +105,25 @@ describe AuditEventPresenter do
end end
context 'exposes the object' do context 'exposes the object' do
it 'link if it exists' do it 'returns the object path if it exists' do
expect(presenter.object).to eq("<a href=\"#{url_for(audit_event.entity)}\">#{details[:entity_path]}</a>") expect(presenter.object).to eq(details[:entity_path])
end end
it 'stored name if it has been deleted' do it 'returns the stored name if it has been deleted' do
audit_event.entity_id = nil audit_event.entity_id = nil
expect(presenter.object).to be_blank expect(presenter.object).to be_blank
end end
it 'returns the object url if it exists' do
expect(presenter.object_url).to eq(url_for(audit_event.entity))
end
it 'returns no object url if it has been deleted' do
audit_event.entity_id = nil
expect(presenter.object_url).to be_blank
end
end end
it 'exposes the date' do it 'exposes the date' do
......
# frozen_string_literal: true
require 'spec_helper'
describe AuditEventEntity do
let(:event) { create(:audit_event) }
subject { described_class.new(event) }
describe '.as_json' do
it 'includes audit event attributes' do
expect(subject.as_json.keys.sort).to eq([:action, :author, :date, :id, :ip_address, :object, :target])
end
end
describe '@presenter' do
it 'is only set once' do
expect(AuditEventPresenter).to receive(:new)
.with(event)
.and_call_original
.once
subject.as_json
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe AuditEventSerializer do
describe '.represent' do
it 'returns an empty array when there are no audit events' do
result = described_class.new.represent([])
expect(result).to eq([])
end
it 'includes audit event attributes' do
audit_event = create(:audit_event)
audit_events = [audit_event]
result = described_class.new.represent(audit_events)
expect(result.first.keys.sort).to eq([:action, :author, :date, :id, :ip_address, :object, :target])
end
end
end
...@@ -2759,6 +2759,27 @@ msgstr "" ...@@ -2759,6 +2759,27 @@ msgstr ""
msgid "AuditEvents|Target" msgid "AuditEvents|Target"
msgstr "" msgstr ""
msgid "AuditLogs|(removed)"
msgstr ""
msgid "AuditLogs|Action"
msgstr ""
msgid "AuditLogs|Author"
msgstr ""
msgid "AuditLogs|Date"
msgstr ""
msgid "AuditLogs|IP Address"
msgstr ""
msgid "AuditLogs|Object"
msgstr ""
msgid "AuditLogs|Target"
msgstr ""
msgid "Aug" msgid "Aug"
msgstr "" msgstr ""
......
...@@ -7,16 +7,16 @@ module QA ...@@ -7,16 +7,16 @@ module QA
module Monitoring module Monitoring
class AuditLog < QA::Page::Base class AuditLog < QA::Page::Base
view 'ee/app/views/admin/audit_logs/index.html.haml' do view 'ee/app/views/admin/audit_logs/index.html.haml' do
element :admin_audit_log_row_content element :admin_audit_log_table
end end
def has_audit_log_row?(text) def has_audit_log_table_with_text?(text)
# Sometimes the audit logs are not displayed in the UI # Sometimes the audit logs are not displayed in the UI
# right away so a refresh may be needed. # right away so a refresh may be needed.
# https://gitlab.com/gitlab-org/gitlab/issues/119203 # https://gitlab.com/gitlab-org/gitlab/issues/119203
# TODO: https://gitlab.com/gitlab-org/gitlab/issues/195424 # TODO: https://gitlab.com/gitlab-org/gitlab/issues/195424
wait_until(reload: true) do wait_until(reload: true) do
has_element?(:admin_audit_log_row_content, text: text) has_element?(:admin_audit_log_table, text: text)
end end
end end
end end
......
...@@ -11,7 +11,7 @@ module QA ...@@ -11,7 +11,7 @@ module QA
QA::Page::Admin::Menu.perform(&:go_to_monitoring_audit_logs) QA::Page::Admin::Menu.perform(&:go_to_monitoring_audit_logs)
EE::Page::Admin::Monitoring::AuditLog.perform do |audit_log_page| EE::Page::Admin::Monitoring::AuditLog.perform do |audit_log_page|
expected_events.each do |expected_event| expected_events.each do |expected_event|
expect(audit_log_page).to have_audit_log_row(expected_event) expect(audit_log_page).to have_audit_log_table_with_text(expected_event)
end end
end end
end end
......
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