chg: [genericElements:index_table] Continuation of stats for current view - WiP

pull/93/head
Sami Mokaddem 2021-11-17 17:04:39 +01:00
parent 25f0f07251
commit ef91cfcee3
No known key found for this signature in database
GPG Key ID: 164C473F627A06FA
15 changed files with 518 additions and 61 deletions

View File

@ -97,8 +97,34 @@ class CRUDComponent extends Component
}
$this->Controller->set('meta_templates', $metaTemplates);
}
if ($this->Table->hasBehavior('Timestamp')) {
$modelStatistics = $this->Table->getStatisticsForModel($this->Table, !is_numeric($this->request->getQuery('statistics_days')) ? 7 : $this->request->getQuery('statistics_days'));
if (true) { // check if stats are requested
$modelStatistics = [];
if ($this->Table->hasBehavior('Timestamp')) {
$modelStatistics = $this->Table->getActivityStatisticsForModel(
$this->Table,
!is_numeric($this->request->getQuery('statistics_days')) ? 7 : $this->request->getQuery('statistics_days')
);
}
if (!empty($options['statisticsFields'])) {
$statIncludeRemaining = $this->request->getQuery('statistics_include_remainging', true);
if (is_string($statIncludeRemaining)) {
$statIncludeRemaining = $statIncludeRemaining == 'true' ? true : false;
}
$statIgnoreNull = $this->request->getQuery('statistics_ignore_null', true);
if (is_string($statIgnoreNull)) {
$statIgnoreNull = $statIgnoreNull == 'true' ? true : false;
}
$statsOptions = [
'limit' => !is_numeric($this->request->getQuery('statistics_entry_amount')) ? 5 : $this->request->getQuery('statistics_entry_amount'),
'includeOthers' => $statIncludeRemaining,
'ignoreNull' => $statIgnoreNull,
];
$modelStatistics['usage'] = $this->Table->getStatisticsUsageForModel(
$this->Table,
$options['statisticsFields'],
$statsOptions
);
}
$this->Controller->set('modelStatistics', $modelStatistics);
}
$this->Controller->set('model', $this->Table);

View File

@ -17,6 +17,7 @@ class EncryptionKeysController extends AppController
public $filterFields = ['owner_model', 'organisation_id', 'individual_id', 'encryption_key'];
public $quickFilterFields = ['encryption_key'];
public $containFields = ['Individuals', 'Organisations'];
public $statisticsFields = ['type'];
public function index()
{
@ -28,7 +29,8 @@ class EncryptionKeysController extends AppController
'type'
]
],
'contain' => $this->containFields
'contain' => $this->containFields,
'statisticsFields' => $this->statisticsFields,
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -16,6 +16,7 @@ class IndividualsController extends AppController
public $quickFilterFields = ['uuid', ['email' => true], ['first_name' => true], ['last_name' => true], 'position'];
public $filterFields = ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type'];
public $containFields = ['Alignments' => 'Organisations'];
public $statisticsFields = ['position'];
public function index()
{
@ -23,7 +24,8 @@ class IndividualsController extends AppController
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true],
'contain' => $this->containFields
'contain' => $this->containFields,
'statisticsFields' => $this->statisticsFields,
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -16,13 +16,15 @@ class MailingListsController extends AppController
public $filterFields = ['MailingLists.uuid', 'MailingLists.name', 'description', 'releasability'];
public $quickFilterFields = ['MailingLists.uuid', ['MailingLists.name' => true], ['description' => true], ['releasability' => true]];
public $containFields = ['Users', 'Individuals', 'MetaFields'];
public $statisticsFields = ['active'];
public function index()
{
$this->CRUD->index([
'contain' => $this->containFields,
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields
'quickFilters' => $this->quickFilterFields,
'statisticsFields' => $this->statisticsFields,
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -16,6 +16,7 @@ class OrganisationsController extends AppController
public $quickFilterFields = [['name' => true], 'uuid', 'nationality', 'sector', 'type', 'url'];
public $filterFields = ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id', 'MetaFields.field', 'MetaFields.value', 'MetaFields.MetaTemplates.name'];
public $containFields = ['Alignments' => 'Individuals'];
public $statisticsFields = ['nationality', 'sector'];
public function index()
{
@ -59,7 +60,8 @@ class OrganisationsController extends AppController
]
],
],
'contain' => $this->containFields
'contain' => $this->containFields,
'statisticsFields' => $this->statisticsFields,
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {

View File

@ -7,6 +7,9 @@ use Cake\Validation\Validator;
use Cake\Core\Configure;
use Cake\Core\Configure\Engine\PhpConfig;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
use Cake\Database\Expression\QueryExpression;
use Cake\ORM\Query;
class AppTable extends Table
{
@ -14,8 +17,80 @@ class AppTable extends Table
{
}
public function getStatisticsUsageForModel(Object $table, array $scopes, array $options=[]): array
{
$defaultOptions = [
'limit' => 5,
'includeOthers' => true,
'ignoreNull' => true,
];
$options = $this->getOptions($defaultOptions, $options);
$stats = [];
foreach ($scopes as $scope) {
$queryTopUsage = $table->find();
$queryTopUsage
->select([
$scope,
'count' => $queryTopUsage->func()->count('id'),
]);
if ($queryTopUsage->getDefaultTypes()[$scope] != 'boolean') {
$queryTopUsage->where(function (QueryExpression $exp) use ($scope) {
return $exp
->isNotNull($scope)
->notEq($scope, '');
});
}
$queryTopUsage
->group($scope)
->order(['count' => 'DESC'])
->limit($options['limit'])
->page(1)
->enableHydration(false);
$topUsage = $queryTopUsage->toList();
$stats[$scope] = $topUsage;
if (
!empty($options['includeOthers']) && !empty($topUsage) &&
$queryTopUsage->getDefaultTypes()[$scope] != 'boolean' // No need to get others as we only have 2 possibilities already considered
) {
$queryOthersUsage = $table->find();
$queryOthersUsage
->select([
'count' => $queryOthersUsage->func()->count('id'),
])
->where(function (QueryExpression $exp, Query $query) use ($topUsage, $scope, $options) {
if (!empty($options['ignoreNull'])) {
return $exp
->isNotNull($scope)
->notEq($scope, '')
->notIn($scope, Hash::extract($topUsage, "{n}.{$scope}"));
} else {
return $exp->or([
$query->newExpr()->isNull($scope),
$query->newExpr()->eq($scope, ''),
$query->newExpr()->notIn($scope, Hash::extract($topUsage, "{n}.{$scope}")),
]);
}
})
->enableHydration(false);
$othersUsage = $queryOthersUsage->toList();
if (!empty($othersUsage)) {
$stats[$scope][] = [
$scope => __('Others'),
'count' => $othersUsage[0]['count'],
];
}
}
}
return $stats;
}
private function getOptions($defaults=[], $options=[]): array
{
return array_merge($defaults, $options);
}
// Move this into a tool
public function getStatisticsForModel(Object $table, int $days = 30): array
public function getActivityStatisticsForModel(Object $table, int $days = 30): array
{
$statistics = [];
if ($table->hasBehavior('Timestamp')) {

View File

@ -30,7 +30,7 @@ class InstanceTable extends AppTable
$models = ['Individuals', 'Organisations', 'Alignments', 'EncryptionKeys', 'SharingGroups', 'Users', 'Broods', 'Tags.Tags'];
foreach ($models as $model) {
$table = TableRegistry::getTableLocator()->get($model);
$statistics[$model] = $this->getStatisticsForModel($table, $days);
$statistics[$model] = $this->getActivityStatisticsForModel($table, $days);
}
return $statistics;
}

View File

@ -45,16 +45,6 @@ if (!empty($series)) {
},
series: <?= json_encode($chartSeries) ?>,
tooltip: {
x: {
// show: false
},
y: {
title: {
formatter: function formatter(val) {
return '';
}
}
},
theme: 'dark'
},
}
@ -64,7 +54,7 @@ if (!empty($series)) {
</script>
<style>
.apexcharts-tooltip.apexcharts-theme-light {
color: black !important
#<?= $chartId ?> .apexcharts-tooltip-y-group {
padding: 1px;
}
</style>

View File

@ -0,0 +1,43 @@
<?php
$chartOptions = $chartOptions ?? [];
$seed = mt_rand();
$chartId = "chart-{$seed}";
$chartData = $chartData ?? [];
$chartSeries = [];
if (!empty($series)) {
$chartSeries = $series;
}
?>
<div id="<?= $chartId ?>"></div>
<script>
$(document).ready(function() {
const passedOptions = <?= json_encode($chartOptions) ?>;
const defaultOptions = {
chart: {
dropShadow: {
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.2,
},
animations: {
enabled: false
},
}
series: <?= json_encode($chartSeries) ?>,
}
const chartOptions = mergeDeep({}, defaultOptions, passedOptions)
new ApexCharts(document.querySelector('#<?= $chartId ?>'), chartOptions).render();
})
</script>
<style>
#<?= $chartId ?> .apexcharts-tooltip-y-group {
padding: 1px;
}
</style>

View File

@ -0,0 +1,73 @@
<?php
$chartOptions = $chartOptions ?? [];
$seed = mt_rand();
$chartId = "chart-{$seed}";
$data = $data ?? [];
$series = [];
$labels = [];
$totalValue = 0;
foreach ($data as $combined) {
$combinedValues = array_values($combined);
$label = $combinedValues[0];
$value = $combinedValues[1];
$labels[] = $label;
$series[] = $value;
$totalValue += $value;
}
?>
<div id="<?= $chartId ?>"></div>
<script>
$(document).ready(function() {
const totalValue = <?= $totalValue ?>;
const passedOptions = <?= json_encode($chartOptions) ?>;
const defaultOptions = {
chart: {
id: '<?= $chartId ?>',
type: 'pie',
dropShadow: {
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.2,
},
animations: {
enabled: false
},
},
series: <?= json_encode($series) ?>,
labels: <?= json_encode($labels) ?>,
tooltip: {
y: {
formatter: function(value, {
series,
seriesIndex,
dataPointIndex,
w
}) {
return value + " (" + (value / totalValue * 100).toFixed(2) + "%)"
}
}
},
noData: {
text: '<?= __('No data') ?>',
verticalAlign: 'bottom',
style: {
fontFamily: 'var(--bs-body-font-family)'
}
}
}
const chartOptions = mergeDeep({}, defaultOptions, passedOptions)
new ApexCharts(document.querySelector('#<?= $chartId ?>'), chartOptions).render();
})
</script>
<style>
#<?= $chartId ?>.apexcharts-tooltip-y-group {
padding: 1px;
}
</style>

View File

@ -0,0 +1,116 @@
<?php
use Cake\Utility\Inflector;
$statisticsHtml = '';
$statistics_pie_amount = $this->request->getQuery('statistics_entry_amount', 5);
$statistics_pie_include_remaining = $this->request->getQuery('statistics_include_remainging', true);
if (is_string($statistics_pie_include_remaining)) {
$statistics_pie_include_remaining = $statistics_pie_include_remaining == 'true' ? true : false;
}
$statistics_pie_ignore_null = $this->request->getQuery('statistics_ignore_null', true);
if (is_string($statistics_pie_ignore_null)) {
$statistics_pie_ignore_null = $statistics_pie_ignore_null == 'true' ? true : false;
}
$seedPiechart = 's-' . mt_rand();
foreach ($statistics['usage'] as $scope => $graphData) {
$pieChart = $this->element('charts/pie', [
'data' => $graphData,
'chartOptions' => [
'chart' => [
'height' => '80px',
'sparkline' => [
'enabled' => true,
]
],
'plotOptions' => [
'pie' => [
'customScale' => 0.9,
]
],
],
]);
$titleHtml = sprintf(
'<span class="text-nowrap">%s%s</span>',
Inflector::Pluralize(Inflector::Humanize(h($scope))),
$this->Bootstrap->button([
'variant' => 'link',
'icon' => 'cog',
'size' => 'xs',
'nodeType' => 'a',
'onclick' => '',
'class' => ['btn-statistics-pie-configurator-' . $seedPiechart],
'params' => [
'data-bs-toggle' => 'popover',
'data-bs-title' => __('Configure chart'),
]
])
);
$panelHtml = sprintf(
'<div class="d-flex flex-row">%s%s</div>',
$titleHtml,
$pieChart
);
$statPie = $this->Bootstrap->card([
'variant' => 'secondary',
'bodyHTML' => $panelHtml,
'bodyClass' => 'py-1 px-2',
'class' => ['shadow-sm', 'h-100']
]);
$statisticsHtml .= sprintf('<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1" style="height: 90px;">%s</div>', $statPie);
}
?>
<?= $statisticsHtml ?>
<script>
$(document).ready(function() {
var popoverTriggerList = [].slice.call(document.querySelectorAll('.btn-statistics-pie-configurator-<?= $seedPiechart ?>'))
var popoverList = popoverTriggerList.map(function(popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl, {
container: 'body',
html: true,
sanitize: false,
content: () => {
return '<div class="popover-form-container"> \
<div class="input-group flex-nowrap"> \
<span class="input-group-text"><?= __('Amount') ?></span> \
<input type="number" min="1" class="form-control entry-amount" placeholder="7" aria-label="<?= __('Days') ?>" value="<?= h($statistics_pie_amount) ?>"> \
</div> \
<div class="form-check"> \
<input class="form-check-input cb-include-remaining" type="checkbox" value="" id="checkbox-include-remaining" <?= $statistics_pie_include_remaining ? 'checked' : '' ?>> \
<label class="form-check-label" for="checkbox-include-remaining"> \
<?= __('Merge skipped entries') ?> \
</label> \
</div> \
<div class="form-check"> \
<input class="form-check-input cb-ignore-null" type="checkbox" value="" id="checkbox-ignore-null" <?= $statistics_pie_ignore_null ? 'checked' : '' ?>> \
<label class="form-check-label" for="checkbox-ignore-null"> \
<?= __('Ignore NULL values') ?> \
</label> \
</div> \
<button class="btn btn-primary" type="button" onclick="statisticsPieConfigurationRedirect(this)"><?= __('Update chart') ?> </button> \
</div>'
}
})
})
})
function statisticsPieConfigurationRedirect(clicked) {
const endpoint = window.location.pathname
const search = window.location.search
let entryAmount = $(clicked).closest('.popover-form-container').find('input.entry-amount').val()
let includeRemaining = $(clicked).closest('.popover-form-container').find('input.cb-include-remaining').prop('checked')
let ignoreNull = $(clicked).closest('.popover-form-container').find('input.cb-ignore-null').prop('checked')
entryAmount = entryAmount !== undefined ? entryAmount : 5
includeRemaining = includeRemaining !== undefined ? includeRemaining : true
ignoreNull = ignoreNull !== undefined ? ignoreNull : true
const searchParams = new URLSearchParams(window.location.search)
searchParams.set('statistics_entry_amount', entryAmount);
searchParams.set('statistics_include_remainging', includeRemaining);
searchParams.set('statistics_ignore_null', ignoreNull);
const url = endpoint + '?' + searchParams
window.location = url
}
</script>

View File

@ -0,0 +1,32 @@
<?php
$statisticsHtml = '';
$panelOptions = [
'condensed' => true,
'panelNoGrow' => true,
'allowConfiguration' => true,
'chartType' => 'line',
'chartOptions' => [
'chart' => [
'height' => '60px',
],
'stroke' => [
'width' => 2,
'curve' => 'smooth',
],
]
];
if (!empty($statistics['created'])) {
$statisticsHtml .= $this->element('genericElements/IndexTable/Statistics/index_statistic_timestamp', [
'timeline' => $statistics,
]);
}
if (!empty($statistics['usage'])) {
$statisticsHtml .= $this->element('genericElements/IndexTable/Statistics/index_statistic_field_amount', [
'statistics' => $statistics,
]);
}
$statisticsHtml = sprintf('<div class="container-fluid"><div class="row gx-2">%s</div></div>', $statisticsHtml);
echo sprintf('<div class="index-statistic-container">%s</div>', $statisticsHtml);
?>

View File

@ -0,0 +1,126 @@
<?php
$statisticsHtml = '';
if (empty($timeline['created']) && empty($timeline['modified'])) {
return $statisticsHtml;
}
$seed = 'timeline-' . mt_rand();
$title = __('Activity');
$statistics_day_number = $timeline['created']['days'];
$subTitle = __('Past {0} days', $statistics_day_number);
$series = [];
if (!empty($timeline['created']['timeline'])) {
$series[0]['name'] = __('Created');
foreach ($timeline['created']['timeline'] as $entry) {
$series[0]['data'][] = ['x' => $entry['time'], 'y' => $entry['count']];
}
}
if (!empty($timeline['modified']['timeline'])) {
$series[1]['name'] = __('Modified');
foreach ($timeline['modified']['timeline'] as $entry) {
$series[1]['data'][] = ['x' => $entry['time'], 'y' => $entry['count']];
}
}
$panelControlHtml = sprintf(
'<div class="text-nowrap">
%s <span class="fs-8 fw-light">%s</span>%s
</div>',
$title,
$subTitle,
$this->Bootstrap->button([
'variant' => 'link',
'icon' => 'cog',
'size' => 'xs',
'nodeType' => 'a',
'onclick' => '',
'class' => ['btn-statistics-days-configurator-' . $seed,],
'params' => [
'data-bs-toggle' => 'popover',
'data-bs-title' => __('Set statistics spanning days'),
]
])
);
$activityNumbers = sprintf(
'<div class="my-1 fs-5">
<div class="lh-1" title="%s">%s<span class=""> %s</span></div>
<div class="ms-2 lh-1" title="%s">%s<span class=""> %s</span></div>
</div>',
__('{0} Created', $timeline['created']['variation']),
$this->Bootstrap->icon('plus-square', ['class' => ['ms-2 fa-fw'], 'params' => ['style' => 'font-size: 60%;']]),
$timeline['created']['variation'],
__('{0} Modified', $timeline['modified']['variation']),
$this->Bootstrap->icon('edit', ['class' => ['fa-fw'], 'params' => ['style' => 'font-size: 60%;']]),
$timeline['modified']['variation']
);
$leftContent = sprintf(
'%s%s',
$panelControlHtml,
$activityNumbers
);
$rightContent = sprintf('<div class="">%s</div>', $this->element('charts/bar', [
'series' => $series,
'chartOptions' => array_merge(
[
'chart' => [
'height' => 60,
],
'stroke' => [
'width' => 2,
'curve' => 'smooth',
],
],
!empty($chartOptions) ? $chartOptions : []
)
]));
$cardContent = sprintf(
'<div class="highlight-panel-container d-flex align-items-center justify-content-between" style="max-height: 100px">
<div class="number-container">%s</div>
<div class="chart-container p-2" style="width: 60%%;">%s</div>
</div>',
$leftContent,
$rightContent
);
$card = $this->Bootstrap->card([
'variant' => 'secondary',
'bodyHTML' => $cardContent,
'bodyClass' => 'py-1 px-2',
'class' => ['shadow-sm', 'h-100']
]);
?>
<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1" style="height: 90px;"><?= $card ?></div>
<script>
$(document).ready(function() {
let popovers = new bootstrap.Popover(document.querySelector('.btn-statistics-days-configurator-<?= $seed ?>'), {
container: 'body',
html: true,
sanitize: false,
content: () => {
return '<div class="input-group flex-nowrap"> \
<span class="input-group-text" id="addon-wrapping-<?= $seed ?>"><?= __('Days') ?></span> \
<input type="number" min="1" class="form-control" placeholder="7" aria-label="<?= __('Days') ?>" aria-describedby="addon-wrapping-<?= $seed ?>" value="<?= h($statistics_day_number) ?>"> \
<button class="btn btn-primary" type="button" onclick="statisticsDaysRedirect(this)"><?= __('Get statistics') ?> </button> \
</div>'
}
})
})
function statisticsDaysRedirect(clicked) {
const endpoint = window.location.pathname
const search = window.location.search
let days = $(clicked).closest('.input-group').find('input').val()
days = days !== undefined ? days : 7
const searchParams = new URLSearchParams(window.location.search)
searchParams.set('statistics_days', days);
const url = endpoint + '?' + searchParams
window.location = url
}
</script>

View File

@ -54,44 +54,12 @@ if (!empty($data['title'])) {
);
}
$statisticsHtml = '';
if (!empty($modelStatistics)) {
$panelOptions = [
'condensed' => true,
'panelNoGrow' => true,
'allowConfiguration' => true,
'chartType' => 'line',
'chartOptions' => [
'chart' => [
'height' => '60px',
],
'stroke' => [
'width' => 2,
'curve' => 'smooth',
],
'colors' => ['#0fd291'],
]
];
if (!empty($modelStatistics['created'])) {
$statCreated = $this->element('widgets/highlight-panel', array_merge($panelOptions, [
'titleHtml' => __('New {0}', $model->getAlias()),
'number' => $modelStatistics['created']['variation'] ?? '',
'timeline' => ['created' => $modelStatistics['created']],
]));
$statisticsHtml .= sprintf('<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1">%s</div>', $statCreated);
}
if (!empty($modelStatistics['modified'])) {
$statModified = $this->element('widgets/highlight-panel', array_merge($panelOptions, [
'titleHtml' => __('Updated {0}', $model->getAlias()),
'number' => $modelStatistics['modified']['variation'] ?? '',
'timeline' => ['modified' => $modelStatistics['modified']]
]));
$statisticsHtml .= sprintf('<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1">%s</div>', $statModified);
}
$statisticsHtml = sprintf('<div class="row gx-2">%s</div>', $statisticsHtml);
echo $this->element('genericElements/IndexTable/Statistics/index_statistic_scaffold', [
'statistics' => $modelStatistics,
]);
}
echo sprintf('<div class="index-statistic-container">%s</div>', $statisticsHtml);
echo '<div class="panel">';
if (!empty($data['html'])) {
@ -212,8 +180,6 @@ echo '</div>';
var tooltipList = tooltipTriggerList.map(function(tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
});
</script>

View File

@ -113,9 +113,11 @@ echo $this->Bootstrap->card([
function statisticsDaysRedirect(clicked) {
const endpoint = window.location.pathname
const searchParams = new URLSearchParams({
statistics_days: $(clicked).closest('.input-group').find('input').val()
});
const search = window.location.search
let days = $(clicked).closest('.input-group').find('input').val()
days = days !== undefined ? days : 7
const searchParams = new URLSearchParams(window.location.search)
searchParams.set('statistics_days', days);
const url = endpoint + '?' + searchParams
window.location = url
}