Skip to content

Commit

Permalink
Administration "Objects history" and "User accesses (#1031)
Browse files Browse the repository at this point in the history
* feat: objects history + user accesses

* feat: objects history more data

* feat: styles and icons

* fix: pagination + UI

* test: add unit tests

* fix: sort desc
  • Loading branch information
didoda authored Jul 20, 2023
1 parent d59f88a commit 446c3e4
Show file tree
Hide file tree
Showing 14 changed files with 700 additions and 2 deletions.
2 changes: 1 addition & 1 deletion config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
// Admin.
$routes->prefix('admin', ['_namePrefix' => 'admin:'], function (RouteBuilder $routes) {

foreach (['appearance', 'applications', 'async_jobs', 'config', 'endpoints', 'roles', 'roles_modules', 'endpoint_permissions'] as $controller) {
foreach (['appearance', 'applications', 'async_jobs', 'config', 'endpoints', 'roles', 'roles_modules', 'endpoint_permissions', 'objects_history', 'user_accesses'] as $controller) {
// Routes connected here are prefixed with '/admin'
$name = Inflector::camelize($controller);
$routes->get(
Expand Down
3 changes: 3 additions & 0 deletions resources/js/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const _vueInstance = new Vue({
Thumbnail:() => import(/* webpackChunkName: "thumbnail" */'app/components/thumbnail/thumbnail'),
Permission:() => import(/* webpackChunkName: "permission" */'app/components/permission/permission'),
Permissions:() => import(/* webpackChunkName: "permissions" */'app/components/permissions/permissions'),
PaginationNavigation:() => import(/* webpackChunkName: "pagination-navigation" */'app/components/pagination-navigation/pagination-navigation'),
ObjectsHistory:() => import(/* webpackChunkName: "objects-history" */'app/components/objects-history/objects-history'),
UserAccesses:() => import(/* webpackChunkName: "user-accesses" */'app/components/user-accesses/user-accesses'),
Icon,
},

Expand Down
217 changes: 217 additions & 0 deletions resources/js/app/components/objects-history/objects-history.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<template>
<div class="objectsHistory">
<div v-if="pagination" class="filterContainer">
<div class="filterDate">
<label class="mr-05">
{{ msgStartingDate }}
</label>
<input type="date" v-model="filterDate" @change="changeDate" />
</div>
<div class="paginationContainer ml-2">
<PaginationNavigation
:pagination="pagination"
:resource="msgHistory"
@change-page-size="changePageSize"
@change-page="changePage">
</PaginationNavigation>
</div>
</div>
<span v-if="loading" class="is-loading-spinner"></span>
<div v-if="!loading && items.length > 0" class="grid">
<span class="column-header">
<Icon icon="carbon:calendar"></Icon>
<i class="ml-05">{{ msgDate }}</i>
</span>
<span class="column-header">
<Icon icon="carbon:information"></Icon>
<i class="ml-05">{{ msgInfo }}</i>
</span>
<span class="column-header">
<Icon icon="carbon:content-view"></Icon>
<i class="ml-05">{{ msgChange }}</i>
</span>
<template v-for="item,key in items">
<span>{{ formatDate(item?.meta?.created) }}</span>
<span>
{{ msgAction }}
<b class="action">{{ item?.meta?.user_action || '' }}</b>
{{ msgByUser }}
<a class="tag has-background-module-users" :href="`/users/view/${item?.meta?.user_id}`">{{ truncate(item?.meta?.user || '', 50) }}</a>
{{ msgWithApplication }}
<b class="application">{{ truncate(item?.meta?.application || '', 50) }}</b>
{{ msgOnResource }}
<a :class="`tag has-background-module-${item?.meta?.resource_type || ''}`" :href="`/view/${item.meta.resource_id}`">{{ truncate(item?.meta?.resource || '', 50) }}</a>
</span>
<span>
<json-editor
:options="jsonEditorOptions"
:target="`item-container-${item.id}`"
:text="JSON.stringify(item?.meta?.changed)">
</json-editor>
<div :id="`item-container-${item.id}`"></div>
</span>
</template>
</div>
</div>
</template>
<script>
import { t } from 'ttag';

export default {
name: 'ObjectsHistory',
components: {
JsonEditor: () => import(/* webpackChunkName: "json-editor" */'app/components/json-editor/json-editor'),
PaginationNavigation:() => import(/* webpackChunkName: "pagination-navigation" */'app/components/pagination-navigation/pagination-navigation'),
},
props: {
applications: {
type: Array,
default: () => [],
},
},
data() {
return {
applicationsMap: {},
filterDate: '',
jsonEditorOptions: {
mainMenuBar: false,
navigationBar: false,
statusBar: false,
},
items: [],
loading: false,
msgAction: t`Action`,
msgByUser: t`by user`,
msgChange: t`Change`,
msgDate: t`Date`,
msgHistory: t`History items`,
msgInfo: t`Info`,
msgOnResource: t`on resource`,
msgResource: t`Resource`,
msgStartingDate: t`Starting date`,
msgWithApplication: t`with application`,
pagination: {},
resourcesMap: {},
resourcesTypesMap: {},
}
},
mounted() {
this.$nextTick(() => {
this.filterDate = this.$helpers.getLastWeeksDate().toISOString().split('T')[0];
this.loadHistory();
this.applicationsMap = this.applications.reduce((map, obj) => {
map[obj?.id] = obj.attributes.name;
return map;
}, {});
});
},
methods: {
changeDate() {
this.loadHistory(this.pagination.page_size, this.pagination.page);
},
changePage(page) {
this.loadHistory(this.pagination.page_size, page);
},
changePageSize(pageSize) {
this.loadHistory(pageSize);
},
formatDate(d) {
return d ? new Date(d).toLocaleDateString() + ' ' + new Date(d).toLocaleTimeString() : '';
},
async getHistory(pageSize = 20, page = 1) {
const filterDate = this.filterDate ? new Date(this.filterDate).toISOString() : '';

return fetch(`/api/history?page_size=${pageSize}&page=${page}&filter[created][gt]=${filterDate}&sort=-created`).then((r) => r.json());
},
async getObjects(ids) {
return fetch(`/api/objects?filter[id]=${ids.join(',')}`).then((r) => r.json());
},
loadHistory(pageSize = 20, page = 1) {
this.loading = true;
this.getHistory(pageSize, page)
.catch(_error => { this.loading = false; return false;})
.then(response => {
this.loading = false;
this.items = response.data || [];
this.pagination = response.meta?.pagination || {};
const userIds = this.items.map(item => item.meta.user_id).filter((v, i, a) => a.indexOf(v) === i).map(i=>Number(i));
const objectIds = this.items.map(item => item.meta.resource_id).filter((v, i, a) => a.indexOf(v) === i).map(i=>Number(i));
const ids = [...userIds, ...objectIds];
this.getObjects(ids)
.catch(_error => { return false;})
.then(response => {
const items = response.data || [];
const resourcesMap = items.reduce((map, obj) => {
if (obj.type === 'users') {
map[obj?.id] = obj?.attributes?.title || obj?.attributes?.username || obj?.id;
} else {
map[obj?.id] = obj?.attributes?.title || obj?.id;
}
return map;
}, {});
this.resourcesMap = {...this.resourcesMap, ...resourcesMap};
const resourcesTypesMap = items.reduce((map, obj) => {
map[obj?.id] = obj?.type;
return map;
}, {});
this.resourcesTypesMap = {...this.resourcesTypesMap, ...resourcesTypesMap};
for (const item of this.items) {
item.meta.user = this.resourcesMap[item.meta.user_id] || item.meta.user_id;
item.meta.resource = this.resourcesMap[item.meta.resource_id] || item.meta.resource_id;
item.meta.application = this.applicationsMap[item.meta.application_id] || item.meta.application_id;
item.meta.resource_type = this.resourcesTypesMap[item.meta.resource_id] || item.meta.resource_type;
}
this.$forceUpdate();
});
}
);
},
msgActionBy(item) {
const action = item?.meta?.user_action || '';

return t`Action ${action} performed by`;
},
truncate(str, len) {
return this.$helpers.truncate(str, len);
},
}
}
</script>
<style>
.objectsHistory {
min-width: 500px;
max-width: 1200px;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
}
.objectsHistory > .filterContainer {
display: inline-flex;
padding-bottom: 1rem;
}
.objectsHistory > .grid {
display: grid;
grid-template-columns: 200px repeat(2, 1fr);
border-top: 1px dotted black;
border-right: 1px dotted black;
}
.objectsHistory > .grid > span {
padding: 4px 8px;
border-left: 1px dotted black;
border-bottom: 1px dotted black;
white-space: normal;
}
.objectsHistory > .grid > span.column-header {
text-align: left;
}
.objectsHistory > .grid > span > a:hover {
text-decoration: underline;
}
span.column-header {
color: yellow;
text-align: center;
}
b.application, b.action {
color: white;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<template>
<nav class="pagination has-text-size-smallest">
<div class="count-items">
<span class="has-font-weight-bold">{{ pagination?.count }}</span>
<span>{{ resource }}</span>
</div>
<div v-if="show()" class="page-size">
<span>{{ msgSize }}</span>
<select
v-model="pageSize"
form="_pagination"
class="page-size-selector has-background-gray-700 has-border-gray-700 has-font-weight-light has-text-gray-200 has-text-size-smallest"
@change="changePageSize">
<option v-for="size in pageSizes" :key="size" :value="size">{{ size }}</option>
</select>
</div>
<div v-if="show()" class="pagination-buttons">
<div>
<!-- first page -->
<button v-if="pagination.page > 1" :class="pageButton" @click.prevent="changePage($event, 1)">1</button>
<!-- delimiter -->
<span v-if="pagination.page > 3" class="pages-delimiter"></span>
<!-- prev page -->
<button v-if="pagination.page > 2" :class="pageButton" @click.prevent="changePage($event, pagination.page - 1)">{{ pagination.page - 1 }}</button>
<!-- current page -->
<input size="2" class="ml-05" :class="pagination.page === 1 ? 'mr-05' : 'ml-05'" :value="pagination.page" @change="changePageNumber($event)" @keydown="pageKeydown($event)"/>
<!-- next page -->
<button v-if="pagination.page < pagination.page_count-1" :class="pageButton" @click.prevent="changePage($event, pagination.page + 1)">{{ pagination.page + 1 }}</button>
<!-- delimiter -->
<span v-if="pagination.page < pagination.page_count-2" class="pages-delimiter"></span>
<!-- last page -->
<button v-if="pagination.page < pagination.page_count" :class="pageButton" @click.prevent="changePage($event, pagination.page_count)">{{ pagination.page_count }}</button>
</div>
</div>
</nav>
</template>
<script>
import { t } from 'ttag';
export default {
name: 'PaginationNavigation',
props: {
pagination: {
type: Object,
required: true,
},
resource: {
type: String,
required: true,
},
},
data() {
return {
msgSize: t`Size`,
pageButton: 'has-text-size-smallest button is-width-auto button-outlined',
pageSize: 20,
pageSizes: [10, 20, 50, 100],
}
},
mounted() {
this.$nextTick(() => {
this.pageSize = this.pagination.page_size || 20;
});
},
methods: {
changePage(e, page) {
if (!page) {
return;
}
this.$emit('change-page', page);
},
changePageSize() {
this.$emit('change-page-size', this.pageSize);
},
changePageNumber(e) {
let val = e.target.value;
val = val.trim();
if (!val) {
return;
}
val = parseFloat(val);
if (!val || val > this.pagination.page_count) {
e.target.value = '';

return;
}
this.changePage(e, val);
},
pageKeydown(e) {
if (e.key !== 'Enter' && e.keyCode !== 13) {
return;
}
e.preventDefault();
e.stopPropagation();
this.changePageNumber(e);

return false;
},
show() {
return this.pagination.count > this.pagination.page_size;
},
}
}
</script>
<style>
div.count-items {
margin: auto;
padding: 6px;
}
</style>
Loading

0 comments on commit 446c3e4

Please sign in to comment.