diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 13f74da..d461edf 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -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); diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index 65183cb..45621d5 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -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)) { diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index 940757f..cc5a8fa 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -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)) { diff --git a/src/Controller/MailingListsController.php b/src/Controller/MailingListsController.php index 0282287..a3ea108 100644 --- a/src/Controller/MailingListsController.php +++ b/src/Controller/MailingListsController.php @@ -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)) { diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index a45f5f3..7f2ce6d 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -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)) { diff --git a/src/Model/Table/AppTable.php b/src/Model/Table/AppTable.php index 71633b2..b9ce7d8 100644 --- a/src/Model/Table/AppTable.php +++ b/src/Model/Table/AppTable.php @@ -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')) { diff --git a/src/Model/Table/InstanceTable.php b/src/Model/Table/InstanceTable.php index b5fcced..562e642 100644 --- a/src/Model/Table/InstanceTable.php +++ b/src/Model/Table/InstanceTable.php @@ -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; } diff --git a/templates/element/charts/bar.php b/templates/element/charts/bar.php index 14fc2bf..5f20316 100644 --- a/templates/element/charts/bar.php +++ b/templates/element/charts/bar.php @@ -45,16 +45,6 @@ if (!empty($series)) { }, series: , tooltip: { - x: { - // show: false - }, - y: { - title: { - formatter: function formatter(val) { - return ''; - } - } - }, theme: 'dark' }, } @@ -64,7 +54,7 @@ if (!empty($series)) { \ No newline at end of file diff --git a/templates/element/charts/generic.php b/templates/element/charts/generic.php new file mode 100644 index 0000000..488577c --- /dev/null +++ b/templates/element/charts/generic.php @@ -0,0 +1,43 @@ + + +
+ + + + \ No newline at end of file diff --git a/templates/element/charts/pie.php b/templates/element/charts/pie.php new file mode 100644 index 0000000..6dd4475 --- /dev/null +++ b/templates/element/charts/pie.php @@ -0,0 +1,73 @@ + + +
+ + + + \ No newline at end of file diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php new file mode 100644 index 0000000..2b0e32d --- /dev/null +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php @@ -0,0 +1,116 @@ +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( + '%s%s', + 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( + '
%s%s
', + $titleHtml, + $pieChart + ); + $statPie = $this->Bootstrap->card([ + 'variant' => 'secondary', + 'bodyHTML' => $panelHtml, + 'bodyClass' => 'py-1 px-2', + 'class' => ['shadow-sm', 'h-100'] + ]); + $statisticsHtml .= sprintf('
%s
', $statPie); +} +?> + + + + \ No newline at end of file diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_scaffold.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_scaffold.php new file mode 100644 index 0000000..3c988f9 --- /dev/null +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_scaffold.php @@ -0,0 +1,32 @@ + 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('
%s
', $statisticsHtml); +echo sprintf('
%s
', $statisticsHtml); +?> diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php new file mode 100644 index 0000000..fc703c0 --- /dev/null +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php @@ -0,0 +1,126 @@ + $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( + '
+ %s %s%s +
', + $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( + '
+
%s %s
+
%s %s
+
', + __('{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('
%s
', $this->element('charts/bar', [ + 'series' => $series, + 'chartOptions' => array_merge( + [ + 'chart' => [ + 'height' => 60, + ], + 'stroke' => [ + 'width' => 2, + 'curve' => 'smooth', + ], + ], + !empty($chartOptions) ? $chartOptions : [] + ) +])); +$cardContent = sprintf( + '
+
%s
+
%s
+
', + $leftContent, + $rightContent +); + +$card = $this->Bootstrap->card([ + 'variant' => 'secondary', + 'bodyHTML' => $cardContent, + 'bodyClass' => 'py-1 px-2', + 'class' => ['shadow-sm', 'h-100'] +]); + +?> + +
+ + \ No newline at end of file diff --git a/templates/element/genericElements/IndexTable/index_table.php b/templates/element/genericElements/IndexTable/index_table.php index 4fc7a42..850be4b 100644 --- a/templates/element/genericElements/IndexTable/index_table.php +++ b/templates/element/genericElements/IndexTable/index_table.php @@ -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('
%s
', $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('
%s
', $statModified); - } - $statisticsHtml = sprintf('
%s
', $statisticsHtml); + echo $this->element('genericElements/IndexTable/Statistics/index_statistic_scaffold', [ + 'statistics' => $modelStatistics, + ]); } -echo sprintf('
%s
', $statisticsHtml); echo '
'; if (!empty($data['html'])) { @@ -212,8 +180,6 @@ echo '
'; var tooltipList = tooltipTriggerList.map(function(tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) }) - - }); diff --git a/templates/element/widgets/highlight-panel.php b/templates/element/widgets/highlight-panel.php index 062588f..f1b16fb 100644 --- a/templates/element/widgets/highlight-panel.php +++ b/templates/element/widgets/highlight-panel.php @@ -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 }