diff --git a/INSTALL/cerebrate_nginx.conf b/INSTALL/cerebrate_nginx.conf index 098d397..b8936f5 100644 --- a/INSTALL/cerebrate_nginx.conf +++ b/INSTALL/cerebrate_nginx.conf @@ -28,7 +28,7 @@ server { location ~ \.php$ { try_files $uri =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; diff --git a/config/Migrations/20221130000000_MoreDataOnMetaFields.php b/config/Migrations/20221130000000_MoreDataOnMetaFields.php new file mode 100644 index 0000000..69598ad --- /dev/null +++ b/config/Migrations/20221130000000_MoreDataOnMetaFields.php @@ -0,0 +1,160 @@ +table('meta_fields'); + if (!$metaFieldTable->hasColumn('meta_template_directory_id')) { + $metaFieldTable + ->addColumn('meta_template_directory_id', 'integer', [ + 'default' => null, + 'null' => false, + 'signed' => false, + 'length' => 10 + ]) + ->addIndex('meta_template_directory_id') + ->update(); + } + + $exists = $this->hasTable('meta_template_name_directory'); + if (!$exists) { + $templateNameDirectoryTable = $this->table('meta_template_name_directory', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci' + ]); + $templateNameDirectoryTable + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('name', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + ]) + ->addColumn('namespace', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + ]) + ->addColumn('version', 'string', [ + 'null' => false, + 'limit' => 191, + 'collation' => 'utf8mb4_unicode_ci', + 'encoding' => 'utf8mb4', + ]) + ->addColumn('uuid', 'uuid', [ + 'null' => false, + 'default' => null, + ]); + + $templateNameDirectoryTable + ->addIndex(['uuid', 'version'], ['unique' => true]) + ->addIndex('name') + ->addIndex('namespace'); + + $templateNameDirectoryTable->create(); + + $allTemplates = $this->getAllTemplates(); + $this->populateTemplateDirectoryTable($allTemplates); + + $metaTemplateTable = $this->table('meta_templates'); + $metaTemplateTable + ->addColumn('meta_template_directory_id', 'integer', [ + 'default' => null, + 'null' => false, + 'signed' => false, + 'length' => 10 + ]) + ->update(); + $this->assignTemplateDirectory($allTemplates); + $metaTemplateTable + ->addForeignKey('meta_template_directory_id', 'meta_template_name_directory', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->save(); + + $metaFieldTable + ->dropForeignKey('meta_template_id') + ->dropForeignKey('meta_template_field_id') + ->addForeignKey('meta_template_directory_id', 'meta_template_name_directory', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->save(); + } + } + + private function populateTemplateDirectoryTable(array $allTemplates): void + { + $builder = $this->getQueryBuilder() + ->insert(['uuid', 'name', 'namespace', 'version']) + ->into('meta_template_name_directory'); + + if (!empty($allTemplates)) { + foreach ($allTemplates as $template) { + $builder->values([ + 'uuid' => $template['uuid'], + 'name' => $template['name'], + 'namespace' => $template['namespace'], + 'version' => $template['version'], + ]); + } + $builder->execute(); + } + } + + private function assignTemplateDirectory(array $allTemplates): void + { + foreach ($allTemplates as $template) { + $directory_template = $this->getDirectoryTemplate($template['uuid'], $template['version'])[0]; + $this->getQueryBuilder() + ->update('meta_templates') + ->set('meta_template_directory_id', $directory_template['id']) + ->where(['meta_template_id' => $template['id']]) + ->execute(); + $this->getQueryBuilder() + ->update('meta_fields') + ->set('meta_template_directory_id', $directory_template['id']) + ->where(['id' => $template['id']]) + ->execute(); + } + } + + private function getAllTemplates(): array + { + return $this->getQueryBuilder() + ->select(['id', 'uuid', 'name', 'namespace', 'version']) + ->from('meta_templates') + ->execute()->fetchAll('assoc'); + } + + private function getDirectoryTemplate(string $uuid, string $version): array + { + return $this->getQueryBuilder() + ->select(['id', 'uuid', 'version']) + ->from('meta_template_name_directory') + ->where([ + 'uuid' => $uuid, + 'version' => $version, + ]) + ->execute()->fetchAll('assoc'); + } +} diff --git a/libraries/default/InboxProcessors/templates/LocalTool/GenericRequest.php b/libraries/default/InboxProcessors/templates/LocalTool/GenericRequest.php index 15ea91b..6709c7e 100644 --- a/libraries/default/InboxProcessors/templates/LocalTool/GenericRequest.php +++ b/libraries/default/InboxProcessors/templates/LocalTool/GenericRequest.php @@ -50,28 +50,28 @@ $footerButtons[] = [ $table = $this->Bootstrap->table(['small' => true, 'bordered' => false, 'striped' => false, 'hover' => false], [ 'fields' => [ - ['key' => 'created', 'label' => __('Date'), 'formatter' => function($value, $row) { + ['path' => 'created', 'label' => __('Date'), 'formatter' => function($value, $row) { return $value->i18nFormat('yyyy-MM-dd HH:mm:ss'); }], - ['key' => 'connector', 'label' => __('Tool Name'), 'formatter' => function($connector, $row) { + ['path' => 'connector', 'label' => __('Tool Name'), 'formatter' => function($connector, $row) { return sprintf('%s', $this->Url->build(['controller' => 'localTools', 'action' => 'viewConnector', $connector['name']]), sprintf('%s (v%s)', h($connector['name']), h($connector['connector_version'])) ); }], - ['key' => 'brood', 'label' => __('Brood'), 'formatter' => function($brood, $row) { + ['path' => 'brood', 'label' => __('Brood'), 'formatter' => function($brood, $row) { return sprintf('%s', $this->Url->build(['controller' => 'broods', 'action' => 'view', $brood['id']]), h($brood['name']) ); }], - ['key' => 'individual', 'label' => __('Individual'), 'formatter' => function($individual, $row) { + ['path' => 'individual', 'label' => __('Individual'), 'formatter' => function($individual, $row) { return sprintf('%s', $this->Url->build(['controller' => 'users', 'action' => 'view', $individual['id']]), h($individual['email']) ); }], - ['key' => 'individual.alignments', 'label' => __('Alignment'), 'formatter' => function($alignments, $row) { + ['path' => 'individual.alignments', 'label' => __('Alignment'), 'formatter' => function($alignments, $row) { $html = ''; foreach ($alignments as $alignment) { $html .= sprintf('
%s
', json_encode($request['data'], JSON_PRETTY_PRINT))
diff --git a/libraries/default/InboxProcessors/templates/Notification/DataChange.php b/libraries/default/InboxProcessors/templates/Notification/DataChange.php
index 952fae1..d204d8f 100644
--- a/libraries/default/InboxProcessors/templates/Notification/DataChange.php
+++ b/libraries/default/InboxProcessors/templates/Notification/DataChange.php
@@ -1,4 +1,7 @@
element('genericElements/Form/genericForm', [
]
]);
+$properties = array_unique(array_merge(array_keys($data['original']), array_keys($data['changed'])));
+$tableData = [];
+foreach ($properties as $i => $property) {
+ $tableData[] = [
+ $property,
+ $data['original'][$property] ?? '',
+ $data['changed'][$property] ?? '',
+ ];
+}
+$emptyValueHTML = $this->Bootstrap->node('span', ['class' => ['text-muted', 'fw-light', 'fst-italic']], __('- empty -'));
+
+$diffTable = $this->Bootstrap->table(
+ [
+ 'hover' => false,
+ 'striped' => false,
+ 'bordered' => false,
+ ],
+ [
+ 'items' => $tableData,
+ 'fields' => [
+ [
+ 'label' => __('Property name'),
+ 'formatter' => function ($field, $row) {
+ return $this->Bootstrap->node('pre', [], h($field));
+ }
+ ],
+ [
+ 'label' => __('Old value'),
+ 'formatter' => function ($field, $row) use ($randomIdOld, $emptyValueHTML) {
+ $fieldText = is_array($field) ? json_encode($field, JSON_FORCE_OBJECT | JSON_PRETTY_PRINT) : $field;
+ $config = [
+ 'text' => $fieldText,
+ 'variant' => 'danger',
+ 'dismissible' => false,
+ 'class' => ['p-2', 'mb-0', !empty($fieldText) && is_array($field) ? "json_container_{$randomIdOld}" : ''],
+ ];
+ if (empty($fieldText)) {
+ $config['html'] = $emptyValueHTML;
+ } else {
+ $config['text'] = $fieldText;
+ }
+ return $this->Bootstrap->alert($config);
+ }
+ ],
+ [
+ 'label' => __('New value'),
+ 'formatter' => function ($field, $row) use ($randomIdNew, $emptyValueHTML) {
+ $fieldText = is_array($field) ? json_encode($field, JSON_FORCE_OBJECT | JSON_PRETTY_PRINT) : $field;
+ $config = [
+ 'text' => $fieldText,
+ 'variant' => 'success',
+ 'dismissible' => false,
+ 'class' => ['p-2', 'mb-0', !empty($fieldText) && is_array($field) ? "json_container_{$randomIdNew}" : ''],
+ ];
+ if (empty($fieldText)) {
+ $config['html'] = $emptyValueHTML;
+ } else {
+ $config['text'] = $fieldText;
+ }
+ return $this->Bootstrap->alert($config);
+ }
+ ],
+ ],
+ ]
+);
+
+
+$cards = sprintf(
+ '%s
-%s
%s%s', $form, $changedSummary, - $this->Bootstrap->card([ - 'headerText' => __('Original values'), - 'bodyHTML' => $this->element('genericElements/SingleViews/Fields/jsonField', ['field' => ['raw' => $data['original']]]) - ]), - $this->Bootstrap->card([ - 'headerText' => __('Changed values'), - 'bodyHTML' => $this->element('genericElements/SingleViews/Fields/jsonField', ['field' => ['raw' => $data['changed']]]) - ]) + $diffTable, + $collapse ), - 'confirmText' => __('Acknowledge & Discard'), - 'confirmIcon' => 'check', + 'confirmButton' => [ + 'text' => __('Acknowledge & Discard'), + 'icon' => 'check', + ] ]); ?> + + \ No newline at end of file diff --git a/libraries/default/OutboxProcessors/templates/Broods/ResendFailedMessage.php b/libraries/default/OutboxProcessors/templates/Broods/ResendFailedMessage.php index b9339fb..2d84f1e 100644 --- a/libraries/default/OutboxProcessors/templates/Broods/ResendFailedMessage.php +++ b/libraries/default/OutboxProcessors/templates/Broods/ResendFailedMessage.php @@ -40,22 +40,22 @@ $tools = sprintf( $table = $this->Bootstrap->table(['small' => true, 'bordered' => false, 'striped' => false, 'hover' => false], [ 'fields' => [ - ['key' => 'created', 'label' => __('Date'), 'formatter' => function($value, $row) { + ['path' => 'created', 'label' => __('Date'), 'formatter' => function($value, $row) { return $value->i18nFormat('yyyy-MM-dd HH:mm:ss'); }], - ['key' => 'brood', 'label' => __('Brood'), 'formatter' => function($brood, $row) { + ['path' => 'brood', 'label' => __('Brood'), 'formatter' => function($brood, $row) { return sprintf('%s', $this->Url->build(['controller' => 'broods', 'action' => 'view', $brood['id']]), h($brood['name']) ); }], - ['key' => 'individual', 'label' => __('Individual'), 'formatter' => function($individual, $row) { + ['path' => 'individual', 'label' => __('Individual'), 'formatter' => function($individual, $row) { return sprintf('%s', $this->Url->build(['controller' => 'users', 'action' => 'view', $individual['id']]), h($individual['email']) ); }], - ['key' => 'individual.alignments', 'label' => __('Alignment'), 'formatter' => function($alignments, $row) { + ['path' => 'individual.alignments', 'label' => __('Alignment'), 'formatter' => function($alignments, $row) { $html = ''; foreach ($alignments as $alignment) { $html .= sprintf('%s
', json_encode($request['data']['sent'], JSON_PRETTY_PRINT))
diff --git a/plugins/Tags/src/View/Helper/TagHelper.php b/plugins/Tags/src/View/Helper/TagHelper.php
index 71b48d7..405c513 100644
--- a/plugins/Tags/src/View/Helper/TagHelper.php
+++ b/plugins/Tags/src/View/Helper/TagHelper.php
@@ -60,9 +60,7 @@ class TagHelper extends Helper
'icon' => 'plus',
'variant' => 'secondary',
'class' => ['badge'],
- 'params' => [
- 'onclick' => 'createTagPicker(this)',
- ]
+ 'onclick' => 'createTagPicker(this)',
]);
} else {
$html .= '';
@@ -111,22 +109,20 @@ class TagHelper extends Helper
'class' => ['ms-1', 'border-0', "text-${textColour}"],
'variant' => 'text',
'title' => __('Delete tag'),
- 'params' => [
- 'onclick' => sprintf('deleteTag(\'%s\', \'%s\', this)',
- $this->Url->build([
- 'controller' => $this->getView()->getName(),
- 'action' => 'untag',
- $this->getView()->get('entity')['id']
- ]),
- h($tag['name'])
- ),
- ],
+ 'onclick' => sprintf('deleteTag(\'%s\', \'%s\', this)',
+ $this->Url->build([
+ 'controller' => $this->getView()->getName(),
+ 'action' => 'untag',
+ $this->getView()->get('entity')['id']
+ ]),
+ h($tag['name'])
+ ),
]);
} else {
$deleteButton = '';
}
- $html = $this->Bootstrap->genNode('span', [
+ $html = $this->Bootstrap->node('span', [
'class' => [
'tag',
'badge',
diff --git a/src/Application.php b/src/Application.php
index 0bf89c9..aad0322 100644
--- a/src/Application.php
+++ b/src/Application.php
@@ -120,8 +120,9 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
]));
\SocialConnect\JWX\JWT::$screw = Configure::check('keycloak.screw') ? Configure::read('keycloak.screw') : 0;
}
- $middlewareQueue->add(new AuthenticationMiddleware($this))
- ->add(new BodyParserMiddleware());
+ $middlewareQueue
+ ->add(new BodyParserMiddleware())
+ ->add(new AuthenticationMiddleware($this));
return $middlewareQueue;
}
diff --git a/src/Controller/BroodsController.php b/src/Controller/BroodsController.php
index 8a054a5..c8efcd3 100644
--- a/src/Controller/BroodsController.php
+++ b/src/Controller/BroodsController.php
@@ -90,24 +90,39 @@ class BroodsController extends AppController
public function testConnection($id)
{
+ $this->request->getSession()->close(); // close session to allow concurrent requests
$status = $this->Broods->queryStatus($id);
return $this->RestResponse->viewData($status, 'json');
}
public function previewIndex($id, $scope)
{
- if (!in_array($scope, ['organisations', 'individuals', 'sharingGroups'])) {
- throw new MethodNotAllowedException(__('Invalid scope. Valid options are: organisations, individuals, sharing_groups'));
+ $validScopes = array_keys($this->Broods->previewScopes);
+ if (!in_array($scope, $validScopes)) {
+ throw new MethodNotAllowedException(__('Invalid scope. Valid options are: {0}', implode(', ', $validScopes)));
}
- $filter = $this->request->getQuery('quickFilter');
- $data = $this->Broods->queryIndex($id, $scope, $filter);
+ $filtering = [
+ 'page' => $this->request->getQuery('page', 1),
+ 'limit' => $this->request->getQuery('limit', 20),
+ ];
+ if (!empty($this->request->getQuery('quickFilter'))) {
+ $filtering['quickFilter'] = $this->request->getQuery('quickFilter');
+ }
+ $data = $this->Broods->queryIndex($id, $scope, $filtering, true);
if (!is_array($data)) {
$data = [];
}
if ($this->ParamHandler->isRest()) {
return $this->RestResponse->viewData($data, 'json');
} else {
+ $data = $this->Broods->attachAllSyncStatus($data, $scope);
$data = $this->CustomPagination->paginate($data);
+ $optionFilters = ['quickFilter'];
+ $CRUDParams = $this->ParamHandler->harvestParams($optionFilters);
+ $CRUDOptions = [
+ 'quickFilters' => $this->Broods->previewScopes[$scope]['quickFilterFields'],
+ ];
+ $this->CRUD->setQuickFilterForView($CRUDParams, $CRUDOptions);
$this->set('data', $data);
$this->set('brood_id', $id);
if ($this->request->is('ajax')) {
@@ -120,23 +135,43 @@ class BroodsController extends AppController
public function downloadOrg($brood_id, $org_id)
{
- $result = $this->Broods->downloadOrg($brood_id, $org_id);
- $success = __('Organisation fetched from remote.');
- $fail = __('Could not save the remote organisation');
- if ($this->ParamHandler->isRest()) {
- if ($result) {
- return $this->RestResponse->saveSuccessResponse('Brood', 'downloadOrg', $brood_id, 'json', $success);
+ if ($this->request->is('post')) {
+ $result = $this->Broods->downloadOrg($brood_id, $org_id);
+ $success = __('Organisation fetched from remote.');
+ $fail = __('Could not save the remote organisation');
+ if ($this->ParamHandler->isRest()) {
+ if ($result) {
+ return $this->RestResponse->saveSuccessResponse('Brood', 'downloadOrg', $brood_id, 'json', $success);
+ } else {
+ return $this->RestResponse->saveFailResponse('Brood', 'downloadOrg', $brood_id, $fail, 'json');
+ }
} else {
- return $this->RestResponse->saveFailResponse('Brood', 'downloadOrg', $brood_id, $fail, 'json');
+ if ($result) {
+ $this->Flash->success($success);
+ } else {
+ $this->Flash->error($fail);
+ }
+ $this->redirect($this->referer());
}
- } else {
- if ($result) {
- $this->Flash->success($success);
- } else {
- $this->Flash->error($fail);
- }
- $this->redirect($this->referer());
}
+ if ($org_id === 'all') {
+ $question = __('All organisations from brood `{0}` will be downloaded. Continue?', h($brood_id));
+ $title = __('Download all organisations from this brood');
+ $actionName = __('Download all');
+ } else {
+ $question = __('The organisations `{0}` from brood `{1}` will be downloaded. Continue?', h($org_id), h($brood_id));
+ $title = __('Download organisation from this brood');
+ $actionName = __('Download organisation');
+ }
+ $this->set('title', $title);
+ $this->set('question', $question);
+ $this->set('modalOptions', [
+ 'confirmButton' => [
+ 'variant' => $org_id === 'all' ? 'warning' : 'primary',
+ 'text' => $actionName,
+ ],
+ ]);
+ $this->render('/genericTemplates/confirm');
}
public function downloadIndividual($brood_id, $individual_id)
diff --git a/src/Controller/Component/APIRearrangeComponent.php b/src/Controller/Component/APIRearrangeComponent.php
index 561b271..0de9a3e 100644
--- a/src/Controller/Component/APIRearrangeComponent.php
+++ b/src/Controller/Component/APIRearrangeComponent.php
@@ -16,17 +16,17 @@ use Cake\Collection\Collection;
class APIRearrangeComponent extends Component
{
- public function rearrangeForAPI(object $data)
+ public static function rearrangeForAPI(object $data, array $options = [])
{
if (is_subclass_of($data, 'Iterator')) {
$newData = [];
- $data->each(function ($value, $key) use (&$newData) {
- $value->rearrangeForAPI();
+ $data->each(function ($value, $key) use (&$newData, $options) {
+ $value->rearrangeForAPI($options);
$newData[] = $value;
});
return new Collection($newData);
} else {
- $data->rearrangeForAPI();
+ $data->rearrangeForAPI($options);
}
return $data;
}
diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php
index 5edb80d..39554d4 100644
--- a/src/Controller/Component/CRUDComponent.php
+++ b/src/Controller/Component/CRUDComponent.php
@@ -33,6 +33,10 @@ class CRUDComponent extends Component
public function index(array $options): void
{
+ $embedInModal = !empty($this->request->getQuery('embedInModal', false));
+ $excludeStats = !empty($this->request->getQuery('excludeStats', false));
+ $skipTableToolbar = !empty($this->request->getQuery('skipTableToolbar', false));
+
if (!empty($options['quickFilters'])) {
if (empty($options['filters'])) {
$options['filters'] = [];
@@ -46,7 +50,8 @@ class CRUDComponent extends Component
$options['filters'][] = 'filteringTags';
}
- $optionFilters = empty($options['filters']) ? [] : $options['filters'];
+ $optionFilters = [];
+ $optionFilters += empty($options['filters']) ? [] : $options['filters'];
foreach ($optionFilters as $i => $filter) {
$optionFilters[] = "{$filter} !=";
$optionFilters[] = "{$filter} >=";
@@ -79,11 +84,16 @@ class CRUDComponent extends Component
$this->Controller->paginate['order'] = $options['order'];
}
}
+ if ($this->metaFieldsSupported() && !$this->Controller->ParamHandler->isRest()) {
+ $query = $this->includeRequestedMetaFields($query);
+ }
+ if (!$this->Controller->ParamHandler->isRest()) {
+ $this->setRequestedEntryAmount();
+ }
+ $data = $this->Controller->paginate($query, $this->Controller->paginate ?? []);
+ $totalCount = $this->Controller->getRequest()->getAttribute('paging')[$this->TableAlias]['count'];
if ($this->Controller->ParamHandler->isRest()) {
- if ($this->metaFieldsSupported()) {
- $query = $this->includeRequestedMetaFields($query);
- }
- $data = $query->all();
+ $data = $this->Controller->paginate($query, $this->Controller->paginate ?? []);
if (isset($options['hidden'])) {
$data->each(function($value, $key) use ($options) {
$hidden = is_array($options['hidden']) ? $options['hidden'] : [$options['hidden']];
@@ -114,13 +124,11 @@ class CRUDComponent extends Component
return $this->attachMetaTemplatesIfNeeded($value, $metaTemplates);
});
}
- $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
+ $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json', false, false, false, [
+ 'X-Total-Count' => $totalCount,
+ ]);
} else {
- if ($this->metaFieldsSupported()) {
- $query = $this->includeRequestedMetaFields($query);
- }
- $this->setRequestedEntryAmount();
- $data = $this->Controller->paginate($query, $this->Controller->paginate ?? []);
+ $this->Controller->setResponse($this->Controller->getResponse()->withHeader('X-Total-Count', $totalCount));
if (isset($options['afterFind'])) {
$function = $options['afterFind'];
if (is_callable($function)) {
@@ -150,7 +158,7 @@ class CRUDComponent extends Component
return $template['enabled'];
}));
}
- if (true) { // check if stats are requested
+ if (empty($excludeStats)) { // check if stats are requested
$modelStatistics = [];
if ($this->Table->hasBehavior('Timestamp')) {
$modelStatistics = $this->Table->getActivityStatisticsForModel(
@@ -191,6 +199,8 @@ class CRUDComponent extends Component
}
$this->Controller->set('model', $this->Table);
$this->Controller->set('data', $data);
+ $this->Controller->set('embedInModal', $embedInModal);
+ $this->Controller->set('skipTableToolbar', $skipTableToolbar);
}
}
@@ -439,6 +449,7 @@ class CRUDComponent extends Component
'field' => $rawMetaTemplateField->field,
'meta_template_id' => $rawMetaTemplateField->meta_template_id,
'meta_template_field_id' => $rawMetaTemplateField->id,
+ 'meta_template_directory_id' => $allMetaTemplates[$template_id]->meta_template_directory_id,
'parent_id' => $entity->id,
'uuid' => Text::uuid(),
]);
@@ -469,6 +480,7 @@ class CRUDComponent extends Component
'field' => $rawMetaTemplateField->field,
'meta_template_id' => $rawMetaTemplateField->meta_template_id,
'meta_template_field_id' => $rawMetaTemplateField->id,
+ 'meta_template_directory_id' => $template->meta_template_directory_id,
'parent_id' => $entity->id,
'uuid' => Text::uuid(),
]);
@@ -1126,17 +1138,9 @@ class CRUDComponent extends Component
public function setQuickFilters(array $params, \Cake\ORM\Query $query, array $options): \Cake\ORM\Query
{
+ $this->setQuickFilterForView($params, $options);
$quickFilterFields = $options['quickFilters'];
- $queryConditions = [];
- $this->Controller->set('quickFilter', empty($quickFilterFields) ? [] : $quickFilterFields);
- if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) {
- $this->Controller->set('quickFilterForMetaField', [
- 'enabled' => $options['quickFilterForMetaField']['enabled'] ?? false,
- 'wildcard_search' => $options['quickFilterForMetaField']['enabled'] ?? false,
- ]);
- }
if (!empty($params['quickFilter']) && !empty($quickFilterFields)) {
- $this->Controller->set('quickFilterValue', $params['quickFilter']);
$queryConditions = $this->genQuickFilterConditions($params, $quickFilterFields);
if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) {
@@ -1146,10 +1150,25 @@ class CRUDComponent extends Component
}
$query->where(['OR' => $queryConditions]);
+ }
+ return $query;
+ }
+
+ public function setQuickFilterForView(array $params, array $options): void
+ {
+ $quickFilterFields = $options['quickFilters'];
+ $this->Controller->set('quickFilter', empty($quickFilterFields) ? [] : $quickFilterFields);
+ if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) {
+ $this->Controller->set('quickFilterForMetaField', [
+ 'enabled' => $options['quickFilterForMetaField']['enabled'] ?? false,
+ 'wildcard_search' => $options['quickFilterForMetaField']['enabled'] ?? false,
+ ]);
+ }
+ if (!empty($params['quickFilter']) && !empty($quickFilterFields)) {
+ $this->Controller->set('quickFilterValue', $params['quickFilter']);
} else {
$this->Controller->set('quickFilterValue', '');
}
- return $query;
}
public function genQuickFilterConditions(array $params, array $quickFilterFields): array
@@ -1561,8 +1580,11 @@ class CRUDComponent extends Component
private function renderViewInVariable($templateRelativeName, $data)
{
$builder = new ViewBuilder();
- $builder->disableAutoLayout()->setTemplate("{$this->TableAlias}/{$templateRelativeName}");
- $view = $builder->build($data);
+ $builder->disableAutoLayout()
+ ->setClassName('Monad')
+ ->setTemplate("{$this->TableAlias}/{$templateRelativeName}")
+ ->setVars($data);
+ $view = $builder->build();
return $view->render();
}
diff --git a/src/Controller/Component/Navigation/Inbox.php b/src/Controller/Component/Navigation/Inbox.php
index 78eba2e..05df932 100644
--- a/src/Controller/Component/Navigation/Inbox.php
+++ b/src/Controller/Component/Navigation/Inbox.php
@@ -14,12 +14,14 @@ class InboxNavigation extends BaseNavigation
'icon' => 'trash',
'url' => '/inbox/discard/{{id}}',
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
]);
$this->bcf->addRoute('Inbox', 'process', [
'label' => __('Process message'),
'icon' => 'cogs',
'url' => '/inbox/process/{{id}}',
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
]);
}
diff --git a/src/Controller/Component/Navigation/MetaTemplates.php b/src/Controller/Component/Navigation/MetaTemplates.php
index 769bc36..c9c6707 100644
--- a/src/Controller/Component/Navigation/MetaTemplates.php
+++ b/src/Controller/Component/Navigation/MetaTemplates.php
@@ -11,16 +11,18 @@ class MetaTemplatesNavigation extends BaseNavigation
$this->bcf->addRoute('MetaTemplates', 'index', $this->bcf->defaultCRUD('MetaTemplates', 'index'));
$this->bcf->addRoute('MetaTemplates', 'view', $this->bcf->defaultCRUD('MetaTemplates', 'view'));
$this->bcf->addRoute('MetaTemplates', 'enable', [
- 'label' => __('Enable'),
+ 'label' => __('Enable / Disable'),
'icon' => 'check-square',
- 'url' => '/metaTemplates/enable/{{id}}/enabled',
+ 'url' => '/metaTemplates/toggle/{{id}}/enabled',
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
]);
$this->bcf->addRoute('MetaTemplates', 'set_default', [
'label' => __('Set as default'),
'icon' => 'check-square',
'url' => '/metaTemplates/toggle/{{id}}/default',
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
]);
$totalUpdateCount = 0;
@@ -46,11 +48,13 @@ class MetaTemplatesNavigation extends BaseNavigation
'label' => __('Update template'),
'icon' => 'download',
'url' => '/metaTemplates/update',
+ 'isPOST' => true,
]);
$this->bcf->addRoute('MetaTemplates', 'prune_outdated_template', [
'label' => __('Prune outdated template'),
'icon' => 'trash',
'url' => '/metaTemplates/prune_outdated_template',
+ 'isPOST' => true,
]);
}
@@ -74,7 +78,7 @@ class MetaTemplatesNavigation extends BaseNavigation
$totalUpdateCount = $udpateCount + $newCount;
}
$updateAllActionConfig = [
- 'label' => __('Update template'),
+ 'label' => __('Update all template'),
'url' => '/metaTemplates/updateAllTemplates',
'url_vars' => ['id' => 'id'],
];
@@ -87,11 +91,11 @@ class MetaTemplatesNavigation extends BaseNavigation
}
$this->bcf->addAction('MetaTemplates', 'index', 'MetaTemplates', 'update_all_templates', $updateAllActionConfig);
$this->bcf->addAction('MetaTemplates', 'index', 'MetaTemplates', 'prune_outdated_template', [
- 'label' => __('Prune outdated template'),
+ 'label' => __('Prune outdated templates'),
'url' => '/metaTemplates/prune_outdated_template',
]);
- if (empty($this->viewVars['updateableTemplates']['up-to-date'])) {
+ if (empty($this->viewVars['templateStatus']['up-to-date'])) {
$this->bcf->addAction('MetaTemplates', 'view', 'MetaTemplates', 'update', [
'label' => __('Update template'),
'url' => '/metaTemplates/update/{{id}}',
diff --git a/src/Controller/Component/Navigation/Outbox.php b/src/Controller/Component/Navigation/Outbox.php
index 39d4717..1f344bc 100644
--- a/src/Controller/Component/Navigation/Outbox.php
+++ b/src/Controller/Component/Navigation/Outbox.php
@@ -14,12 +14,14 @@ class OutboxNavigation extends BaseNavigation
'icon' => 'trash',
'url' => '/outbox/discard/{{id}}',
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
]);
$this->bcf->addRoute('Outbox', 'process', [
'label' => __('Process message'),
'icon' => 'cogs',
'url' => '/outbox/process/{{id}}',
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
]);
}
diff --git a/src/Controller/Component/NavigationComponent.php b/src/Controller/Component/NavigationComponent.php
index 86da134..3c606e8 100644
--- a/src/Controller/Component/NavigationComponent.php
+++ b/src/Controller/Component/NavigationComponent.php
@@ -217,6 +217,7 @@ class BreadcrumbFactory
'label' => __('[new {0}]', $controller),
'icon' => 'plus',
'url' => "/{$controller}/add",
+ 'isPOST' => true,
]);
} else if ($action === 'edit') {
$item = $this->genRouteConfig($controller, $action, [
@@ -224,6 +225,7 @@ class BreadcrumbFactory
'icon' => 'edit',
'url' => "/{$controller}/edit/{{id}}",
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
'textGetter' => !empty($table->getDisplayField()) ? $table->getDisplayField() : 'id',
]);
} else if ($action === 'delete') {
@@ -232,6 +234,15 @@ class BreadcrumbFactory
'icon' => 'trash',
'url' => "/{$controller}/delete/{{id}}",
'url_vars' => ['id' => 'id'],
+ 'isPOST' => true,
+ 'textGetter' => !empty($table->getDisplayField()) ? $table->getDisplayField() : 'id',
+ ]);
+ } else if ($action === 'audit') {
+ $item = $this->genRouteConfig($controller, $action, [
+ 'label' => __('Audit changes'),
+ 'icon' => 'history',
+ 'url' => "/audit-logs?model={{model}}&model_id={{id}}&sort=created&direction=desc&embedInModal=1&excludeStats=1&skipTableToolbar=1",
+ 'url_vars' => ['id' => 'id', 'model' => ['raw' => $table->getAlias()]],
'textGetter' => !empty($table->getDisplayField()) ? $table->getDisplayField() : 'id',
]);
}
@@ -253,6 +264,7 @@ class BreadcrumbFactory
$routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'label');
$routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'textGetter');
$routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'badge');
+ $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'isPOST');
return $routeConfig;
}
@@ -279,6 +291,7 @@ class BreadcrumbFactory
$this->addRoute($controller, 'add', $this->defaultCRUD($controller, 'add'));
$this->addRoute($controller, 'edit', $this->defaultCRUD($controller, 'edit'));
$this->addRoute($controller, 'delete', $this->defaultCRUD($controller, 'delete'));
+ $this->addRoute($controller, 'audit', $this->defaultCRUD($controller, 'audit'));
$this->addParent($controller, 'view', $controller, 'index');
$this->addParent($controller, 'add', $controller, 'index');
@@ -292,8 +305,10 @@ class BreadcrumbFactory
$this->addAction($controller, 'view', $controller, 'add');
$this->addAction($controller, 'view', $controller, 'delete');
+ $this->addAction($controller, 'view', $controller, 'audit');
$this->addAction($controller, 'edit', $controller, 'add');
$this->addAction($controller, 'edit', $controller, 'delete');
+ $this->addAction($controller, 'edit', $controller, 'audit');
}
public function get($controller, $action)
diff --git a/src/Controller/Component/RestResponseComponent.php b/src/Controller/Component/RestResponseComponent.php
index db5072b..8da9128 100644
--- a/src/Controller/Component/RestResponseComponent.php
+++ b/src/Controller/Component/RestResponseComponent.php
@@ -283,6 +283,7 @@ class RestResponseComponent extends Component
private $__scopedFieldsConstraint = array();
public function initialize(array $config): void {
+ parent::initialize($config);
$this->__configureFieldConstraints();
$this->Controller = $this->getController();
}
@@ -559,7 +560,14 @@ class RestResponseComponent extends Component
$data['errors'] = $errors;
}
if (!$raw && is_object($data)) {
- $data = $this->APIRearrange->rearrangeForAPI($data);
+ $rearrangeOptions = [];
+ if (!empty($this->Controller->getRequest()->getQuery('includeMetatemplate', false))) {
+ $rearrangeOptions['includeMetatemplate'] = true;
+ }
+ if (!empty($this->Controller->getRequest()->getQuery('includeFullMetaFields', false))) {
+ $rearrangeOptions['includeFullMetaFields'] = true;
+ }
+ $data = $this->APIRearrange->rearrangeForAPI($data, $rearrangeOptions);
}
return $this->__sendResponse($data, 200, $format, $raw, $download, $headers);
}
diff --git a/src/Controller/MetaTemplatesController.php b/src/Controller/MetaTemplatesController.php
index 2595385..0c59120 100644
--- a/src/Controller/MetaTemplatesController.php
+++ b/src/Controller/MetaTemplatesController.php
@@ -226,13 +226,16 @@ class MetaTemplatesController extends AppController
foreach ($newestMetaTemplate->meta_template_fields as $i => $newMetaTemplateField) {
if ($metaTemplateField->field == $newMetaTemplateField->field && empty($newMetaTemplateField->metaFields)) {
$movedMetaTemplateFields[] = $metaTemplateField->id;
+ $nonEmptyMetaFields = array_filter($metaTemplateField->metaFields, function ($e) {
+ return $e->value !== '';
+ });
$copiedMetaFields = array_map(function ($e) use ($newMetaTemplateField) {
$e = $e->toArray();
$e['meta_template_id'] = $newMetaTemplateField->meta_template_id;
$e['meta_template_field_id'] = $newMetaTemplateField->id;
unset($e['id']);
return $e;
- }, $metaTemplateField->metaFields);
+ }, $nonEmptyMetaFields);
$newMetaTemplateField->metaFields = $this->MetaTemplates->MetaTemplateFields->MetaFields->newEntities($copiedMetaFields);
}
}
@@ -244,6 +247,55 @@ class MetaTemplatesController extends AppController
$this->set('movedMetaTemplateFields', $movedMetaTemplateFields);
}
+ public function migrateMetafieldsToNewestTemplate(int $template_id, $forceMigration=false)
+ {
+ $oldMetaTemplate = $this->MetaTemplates->find()->where([
+ 'id' => $template_id
+ ])->contain(['MetaTemplateFields'])->first();
+
+ if (empty($oldMetaTemplate)) {
+ throw new NotFoundException(__('Invalid {0} {1}.', $this->MetaTemplates->getAlias(), $template_id));
+ }
+ $newestMetaTemplate = $this->MetaTemplates->getNewestVersion($oldMetaTemplate, true);
+ if ($oldMetaTemplate->id == $newestMetaTemplate->id) {
+ throw new NotFoundException(__('Invalid {0} {1}. Template already the newest version', $this->MetaTemplates->getAlias(), $template_id));
+ }
+
+ if ($this->request->is('post')) {
+ $result = $this->MetaTemplates->migrateMetafieldsToNewestTemplate($oldMetaTemplate, $newestMetaTemplate, !empty($forceMigration));
+ if ($this->ParamHandler->isRest()) {
+ return $this->RestResponse->viewData($result, 'json');
+ } else {
+ if ($result['success']) {
+ $message = __('{0} entities updated. {1} entities could not be automatically migrated.', $result['migrated_count'], $result['conflicting_entities']);
+ } else {
+ $message = __('{0} entities updated. {1} entities could not be automatically migrated. {2} entities could not be updated due to errors', $result['migrated_count'], $result['conflicting_entities'], $result['migration_errors']);
+ }
+ $this->CRUD->setResponseForController('update', $result['success'], $message, $result, $result['migration_errors'], ['redirect' => $this->referer()]);
+ $responsePayload = $this->CRUD->getResponsePayload();
+ if (!empty($responsePayload)) {
+ return $responsePayload;
+ }
+ }
+ } else {
+ $entities = $this->MetaTemplates->getEntitiesHavingMetaFieldsFromTemplate($oldMetaTemplate->id, null);
+ $conflictingEntities = [];
+ foreach ($entities as $entity) {
+ $conflicts = $this->MetaTemplates->getMetaFieldsConflictsUnderTemplate($entity->meta_fields, $newestMetaTemplate);
+ if (!empty($conflicts)) {
+ $conflictingEntities[] = $entity;
+ }
+ }
+
+ if (!$this->ParamHandler->isRest()) {
+ $this->set('oldMetaTemplate', $oldMetaTemplate);
+ $this->set('newestMetaTemplate', $newestMetaTemplate);
+ $this->set('conflictingEntities', $conflictingEntities);
+ $this->set('entityCount', count($entities));
+ }
+ }
+ }
+
public function index()
{
$templatesUpdateStatus = $this->MetaTemplates->getUpdateStatusForTemplates();
diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php
index 93ddfb9..367df4f 100644
--- a/src/Controller/OrganisationsController.php
+++ b/src/Controller/OrganisationsController.php
@@ -65,6 +65,11 @@ class OrganisationsController extends AppController
]
];
}
+ $additionalContainFields = [];
+ if ($this->ParamHandler->isRest()) {
+ $additionalContainFields[] = 'MetaFields';
+ }
+ $containFields = array_merge($this->containFields, $additionalContainFields);
$this->CRUD->index([
'filters' => $this->filterFields,
@@ -73,7 +78,7 @@ class OrganisationsController extends AppController
'contextFilters' => [
'custom' => $customContextFilters,
],
- 'contain' => $this->containFields,
+ 'contain' => $containFields,
'statisticsFields' => $this->statisticsFields,
]);
$responsePayload = $this->CRUD->getResponsePayload();
diff --git a/src/Controller/SharingGroupsController.php b/src/Controller/SharingGroupsController.php
index 15d41e8..8afd6c6 100644
--- a/src/Controller/SharingGroupsController.php
+++ b/src/Controller/SharingGroupsController.php
@@ -160,7 +160,7 @@ class SharingGroupsController extends AppController
'organisation' => $this->SharingGroups->Organisations->find('list', [
'sort' => ['name' => 'asc'],
'conditions' => $conditions
- ])
+ ])->toArray()
];
if ($this->request->is('post')) {
$input = $this->request->getData();
@@ -280,7 +280,7 @@ class SharingGroupsController extends AppController
'conditions' => [
'id' => $user['organisation_id']
]
- ]);
+ ])->toArray();
}
return $organisations;
}
diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php
index a05882e..3bd715d 100644
--- a/src/Controller/UsersController.php
+++ b/src/Controller/UsersController.php
@@ -26,8 +26,13 @@ class UsersController extends AppController
if (!empty(Configure::read('keycloak.enabled'))) {
// $keycloakUsersParsed = $this->Users->getParsedKeycloakUser();
}
+ $additionalContainFields = [];
+ if ($this->ParamHandler->isRest()) {
+ $additionalContainFields[] = 'MetaFields';
+ }
+ $containFields = array_merge($this->containFields, $additionalContainFields);
$this->CRUD->index([
- 'contain' => $this->containFields,
+ 'contain' => $containFields,
'filters' => $this->filterFields,
'quickFilters' => $this->quickFilterFields,
'conditions' => $conditions,
@@ -154,7 +159,12 @@ class UsersController extends AppController
}
$keycloakUsersParsed = null;
if (!empty(Configure::read('keycloak.enabled'))) {
- $keycloakUsersParsed = $this->Users->getParsedKeycloakUser();
+ try {
+ $keycloakUsersParsed = $this->Users->getParsedKeycloakUser();
+ } catch (\Exception $e) {
+ $keycloakUsersParsed = [];
+ $this->Flash->error(__('Issue while connecting to keycloak. {0}', $e->getMessage()));
+ }
}
$this->CRUD->view($id, [
'contain' => ['Individuals' => ['Alignments' => 'Organisations'], 'Roles', 'Organisations'],
@@ -298,6 +308,9 @@ class UsersController extends AppController
}
$params = [
'beforeSave' => function($data) use ($currentUser, $validRoles) {
+ if (empty(Configure::read('user.allow-user-deletion'))) {
+ throw new MethodNotAllowedException(__('User deletion is disabled on this instance.'));
+ }
if (!$currentUser['role']['perm_admin']) {
if ($data['organisation_id'] !== $currentUser['organisation_id']) {
throw new MethodNotAllowedException(__('You do not have permission to delete the given user.'));
diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php
index 86c38c7..34ad57d 100644
--- a/src/Model/Behavior/AuthKeycloakBehavior.php
+++ b/src/Model/Behavior/AuthKeycloakBehavior.php
@@ -459,7 +459,7 @@ class AuthKeycloakBehavior extends Behavior
$requireUpdate = true;
$differences = [
'user' => [
- 'keycloak' => 'USER NOT FOUND',
+ 'keycloak' => __('ERROR or USER NOT FOUND'),
'cerebrate' => $user['username']
]
];
diff --git a/src/Model/Entity/AppModel.php b/src/Model/Entity/AppModel.php
index 3e70518..2d4a553 100644
--- a/src/Model/Entity/AppModel.php
+++ b/src/Model/Entity/AppModel.php
@@ -40,28 +40,47 @@ class AppModel extends Entity
return TableRegistry::get($this->getSource());
}
- public function rearrangeForAPI(): void
+ public function rearrangeForAPI(array $options = []): void
{
}
- public function rearrangeMetaFields(): void
+ public function rearrangeMetaFields(array $options = []): void
{
- $this->meta_fields = [];
- foreach ($this->MetaTemplates as $template) {
- foreach ($template['meta_template_fields'] as $field) {
- if ($field['counter'] > 0) {
- foreach ($field['metaFields'] as $metaField) {
- if (!empty($this->meta_fields[$template['name']][$field['field']])) {
- if (!is_array($this->meta_fields[$template['name']][$field['field']])) {
- $this->meta_fields[$template['name']][$field['field']] = [$this->meta_fields[$template['name']][$field['field']]];
+ if (!empty($options['includeFullMetaFields'])) {
+ $this->meta_fields = [];
+ foreach ($this->MetaTemplates as $template) {
+ foreach ($template['meta_template_fields'] as $field) {
+ if ($field['counter'] > 0) {
+ foreach ($field['metaFields'] as $metaField) {
+ if (!empty($this->meta_fields[$template['name']][$field['field']])) {
+ if (!is_array($this->meta_fields[$template['name']][$field['field']])) {
+ $this->meta_fields[$template['name']][$field['field']] = [$this->meta_fields[$template['name']][$field['field']]];
+ }
+ $this->meta_fields[$template['name']][$field['field']][] = $metaField['value'];
+ } else {
+ $this->meta_fields[$template['name']][$field['field']] = $metaField['value'];
}
- $this->meta_fields[$template['name']][$field['field']][] = $metaField['value'];
- } else {
- $this->meta_fields[$template['name']][$field['field']] = $metaField['value'];
}
}
}
}
+ } elseif (!empty($this->meta_fields)) {
+ $templateDirectoryTable = TableRegistry::get('MetaTemplateNameDirectory');
+ $templates = [];
+ foreach ($this->meta_fields as $i => $metafield) {
+ $templateDirectoryId = $metafield['meta_template_directory_id'];
+ if (empty($templates[$templateDirectoryId])) {
+ $templates[$templateDirectoryId] = $templateDirectoryTable->find()->where(['id' => $templateDirectoryId])->first();
+ }
+ $this->meta_fields[$i]['template_uuid'] = $templates[$templateDirectoryId]['uuid'];
+ $this->meta_fields[$i]['template_version'] = $templates[$templateDirectoryId]['version'];
+ $this->meta_fields[$i]['template_name'] = $templates[$templateDirectoryId]['name'];
+ $this->meta_fields[$i]['template_namespace'] = $templates[$templateDirectoryId]['namespace'];
+ }
+ }
+ // if ((!isset($options['includeMetatemplate']) || empty($options['includeMetatemplate'])) && !empty($this->MetaTemplates)) {
+ if ((!isset($options['includeMetatemplate']) || empty($options['includeMetatemplate']))) {
+ unset($this->MetaTemplates);
}
}
diff --git a/src/Model/Entity/AuditLog.php b/src/Model/Entity/AuditLog.php
index 4f1a2fe..eaaac47 100644
--- a/src/Model/Entity/AuditLog.php
+++ b/src/Model/Entity/AuditLog.php
@@ -66,7 +66,7 @@ class AuditLog extends AppModel
return $title;
}
- public function rearrangeForAPI(): void
+ public function rearrangeForAPI(array $options = []): void
{
if (!empty($this->user)) {
$this->user = $this->user->toArray();
diff --git a/src/Model/Entity/EncryptionKey.php b/src/Model/Entity/EncryptionKey.php
index a9b2f67..e735e15 100644
--- a/src/Model/Entity/EncryptionKey.php
+++ b/src/Model/Entity/EncryptionKey.php
@@ -8,7 +8,7 @@ use Cake\ORM\Entity;
class EncryptionKey extends AppModel
{
- public function rearrangeForAPI(): void
+ public function rearrangeForAPI(array $options = []): void
{
$this->rearrangeSimplify(['organisation', 'individual']);
}
diff --git a/src/Model/Entity/Individual.php b/src/Model/Entity/Individual.php
index 1afd30c..14863b5 100644
--- a/src/Model/Entity/Individual.php
+++ b/src/Model/Entity/Individual.php
@@ -42,7 +42,7 @@ class Individual extends AppModel
return $emails;
}
- public function rearrangeForAPI(): void
+ public function rearrangeForAPI(array $options = []): void
{
if (!empty($this->tags)) {
$this->tags = $this->rearrangeTags($this->tags);
@@ -51,10 +51,7 @@ class Individual extends AppModel
$this->alignments = $this->rearrangeAlignments($this->alignments);
}
if (!empty($this->meta_fields)) {
- $this->rearrangeMetaFields();
- }
- if (!empty($this->MetaTemplates)) {
- unset($this->MetaTemplates);
+ $this->rearrangeMetaFields($options);
}
}
}
diff --git a/src/Model/Entity/MetaTemplateNameDirectory.php b/src/Model/Entity/MetaTemplateNameDirectory.php
new file mode 100644
index 0000000..84c9696
--- /dev/null
+++ b/src/Model/Entity/MetaTemplateNameDirectory.php
@@ -0,0 +1,11 @@
+ true
];
- public function rearrangeForAPI(): void
+ public function rearrangeForAPI(array $options = []): void
{
if (!empty($this->tags)) {
$this->tags = $this->rearrangeTags($this->tags);
@@ -25,11 +25,9 @@ class Organisation extends AppModel
if (!empty($this->alignments)) {
$this->alignments = $this->rearrangeAlignments($this->alignments);
}
- if (!empty($this->meta_fields)) {
- $this->rearrangeMetaFields();
- }
- if (!empty($this->MetaTemplates)) {
- unset($this->MetaTemplates);
+ if (!empty($this->meta_fields) || !empty($this->MetaTemplates)) {
+ $this->rearrangeMetaFields($options);
}
}
+ // MetaTemplate object property is not unset!!
}
diff --git a/src/Model/Entity/User.php b/src/Model/Entity/User.php
index 5f39720..bcb7e38 100644
--- a/src/Model/Entity/User.php
+++ b/src/Model/Entity/User.php
@@ -49,16 +49,13 @@ class User extends AppModel
}
}
- public function rearrangeForAPI(): void
+ public function rearrangeForAPI(array $options = []): void
{
if (!empty($this->tags)) {
$this->tags = $this->rearrangeTags($this->tags);
}
- if (!empty($this->meta_fields)) {
- $this->rearrangeMetaFields();
- }
- if (!empty($this->MetaTemplates)) {
- unset($this->MetaTemplates);
+ if (!empty($this->meta_fields) || !empty($this->MetaTemplates)) {
+ $this->rearrangeMetaFields($options);
}
if (!empty($this->user_settings_by_name)) {
$this->rearrangeUserSettings();
diff --git a/src/Model/Table/BroodsTable.php b/src/Model/Table/BroodsTable.php
index 0708c18..851fc89 100644
--- a/src/Model/Table/BroodsTable.php
+++ b/src/Model/Table/BroodsTable.php
@@ -2,10 +2,15 @@
namespace App\Model\Table;
+require_once APP . DS . 'Utility/Utils.php';
use App\Model\Table\AppTable;
+use function App\Utility\Utils\array_diff_recursive;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\Core\Configure;
+use Cake\Utility\Inflector;
+use Cake\Utility\Hash;
+use Cake\I18n\FrozenTime;
use Cake\Http\Client;
use Cake\Http\Client\Response;
use Cake\Http\Exception\NotFoundException;
@@ -15,6 +20,27 @@ use Cake\Error\Debugger;
class BroodsTable extends AppTable
{
+
+ public $previewScopes = [
+ 'organisations' => [
+ 'quickFilterFields' => ['uuid', ['name' => true],],
+ 'contain' => ['MetaFields' => ['MetaTemplateNameDirectory'], 'Tags'],
+ 'compareFields' => ['name', 'url', 'nationality', 'sector', 'type', 'contacts', 'modified', 'tags', 'meta_fields',],
+ ],
+ 'individuals' => [
+ 'quickFilterFields' => ['uuid', ['email' => true], ['first_name' => true], ['last_name' => true],],
+ 'contain' => ['MetaFields'],
+ 'compareFields' => ['email', 'first_name', 'last_name', 'position', 'modified', 'meta_fields', 'tags',],
+ ],
+ 'sharingGroups' => [
+ 'quickFilterFields' => ['uuid', ['name' => true],],
+ 'contain' => ['SharingGroupOrgs', 'Organisations'],
+ 'compareFields' => ['name', 'releasability', 'description', 'organisation_id', 'user_id', 'active', 'local', 'modified', 'organisation', 'sharing_group_orgs',],
+ ],
+ ];
+
+ private $metaFieldCompareFields = ['modified', 'value'];
+
public function initialize(array $config): void
{
parent::initialize($config);
@@ -119,14 +145,16 @@ class BroodsTable extends AppTable
return $result;
}
- public function queryIndex($id, $scope, $filter)
+ public function queryIndex($id, $scope, $filter, $full = false)
{
$brood = $this->find()->where(['id' => $id])->first();
if (empty($brood)) {
throw new NotFoundException(__('Brood not found'));
}
- $filterQuery = empty($filter) ? '' : '?quickFilter=' . urlencode($filter);
- $response = $this->HTTPClientGET(sprintf('/%s/index.json%s', $scope, $filterQuery), $brood);
+ if (!empty($full)) {
+ $filter['full'] = 1;
+ }
+ $response = $this->HTTPClientGET(sprintf('/%s/index.json?%s', $scope, http_build_query($filter)), $brood);
if ($response->isOk()) {
return $response->getJson();
} else {
@@ -371,4 +399,124 @@ class BroodsTable extends AppTable
$connector = $params['connector'][$params['remote_tool']['connector']];
$connector->remoteToolConnectionStatus($params, constant(get_class($connector) . '::' . $status));
}
+
+ public function attachAllSyncStatus(array $data, string $scope): array
+ {
+ $options = $this->previewScopes[$scope];
+ foreach ($data as $i => $entry) {
+ $data[$i] = $this->__attachSyncStatus($scope, $entry, $options);
+ }
+ return $data;
+ }
+
+ private function __attachSyncStatus(string $scope, array $entry, array $options = []): array
+ {
+ $table = TableRegistry::getTableLocator()->get(Inflector::camelize($scope));
+ $localEntry = $table
+ ->find()
+ ->where(['uuid' => $entry['uuid']])
+ ->first();
+ if (is_null($localEntry)) {
+ $entry['status'] = $this->__statusNotLocal();
+ } else {
+ if (!empty($options['contain'])) {
+ $localEntry = $table->loadInto($localEntry, $options['contain']);
+ }
+ $localEntry = json_decode(json_encode($localEntry), true);
+ $entry['status'] = $this->__statusLocal($entry, $localEntry, $options);
+ }
+
+ return $entry;
+ }
+
+ private function __statusNotLocal(): array
+ {
+ return self::__getStatus(false);
+ }
+
+ private function __statusLocal(array $remoteEntry, $localEntry, array $options = []): array
+ {
+ $isLocalNewer = (new FrozenTime($localEntry['modified']))->toUnixString() >= (new FrozenTime($remoteEntry['modified']))->toUnixString();
+ $compareFields = $options['compareFields'];
+ $fieldDifference = [];
+ $fieldDifference = array_diff_recursive($remoteEntry, $localEntry);
+ // if (in_array('meta_fields', $options['compareFields']) && !empty($fieldDifference['meta_fields'])) {
+ // $fieldDifference['meta_fields'] = $this->_compareMetaFields($remoteEntry, $localEntry, $options);
+ // }
+ $fieldDifference = array_filter($fieldDifference, function($value, $field) use ($compareFields) {
+ return in_array($field, $compareFields);
+ }, ARRAY_FILTER_USE_BOTH);
+ foreach ($fieldDifference as $fieldName => $value) {
+ $fieldDifference[$fieldName] = [
+ 'local' => $localEntry[$fieldName],
+ 'remote' => $value,
+ ];
+ }
+ if (in_array('meta_fields', $options['compareFields']) && !empty($fieldDifference['meta_fields'])) {
+ $fieldDifference['meta_fields'] = $this->_compareMetaFields($remoteEntry, $localEntry, $options);
+ }
+
+ return self::__getStatus(true, $isLocalNewer, $fieldDifference);
+ }
+
+ private static function __getStatus($local=true, $updateToDate=false, array $data = []): array
+ {
+ $status = [
+ 'local' => $local,
+ 'up_to_date' => $updateToDate,
+ 'data' => $data,
+ ];
+ if ($status['local'] && $status['up_to_date']) {
+ $status['title'] = __('This entity is up-to-date');
+ } else if ($status['local'] && !$status['up_to_date']) {
+ $status['title'] = __('This entity is known but differs with the remote');
+ } else {
+ $status['title'] = __('This entity is not known locally');
+ }
+ return $status;
+ }
+
+ private function _compareMetaFields($remoteEntry, $localEntry): array
+ {
+ $compareFields = $this->metaFieldCompareFields;
+ $indexedRemoteMF = [];
+ $indexedLocalMF = [];
+ foreach ($remoteEntry['meta_fields'] as $metafields) {
+ $indexedRemoteMF[$metafields['uuid']] = array_intersect_key($metafields, array_flip($compareFields));
+ }
+ foreach ($localEntry['meta_fields'] as $metafields) {
+ $indexedLocalMF[$metafields['uuid']] = array_intersect_key($metafields, array_flip($compareFields));
+ }
+ $fieldDifference = [];
+ foreach ($remoteEntry['meta_fields'] as $remoteMetafield) {
+ $uuid = $remoteMetafield['uuid'];
+ $metafieldName = $remoteMetafield['field'];
+ // $metafieldName = sprintf('%s(v%s) :: %s', $remoteMetafield['template_name'], $remoteMetafield['template_version'], $remoteMetafield['field']);
+ if (empty($fieldDifference[$metafieldName])) {
+ $fieldDifference[$metafieldName] = [
+ 'meta_template' => [
+ 'name' => $remoteMetafield['template_name'],
+ 'version' => $remoteMetafield['template_version'],
+ 'uuid' => $remoteMetafield['template_uuid']
+ ],
+ 'delta' => [],
+ ];
+ }
+ if (empty($indexedLocalMF[$uuid])) {
+ $fieldDifference[$metafieldName]['delta'][] = [
+ 'local' => null,
+ 'remote' => $indexedRemoteMF[$uuid],
+ ];
+ } else {
+ $fieldDifferenceTmp = array_diff_recursive($indexedRemoteMF[$uuid], $indexedLocalMF[$uuid]);
+ if (!empty($fieldDifferenceTmp)) {
+ $fieldDifference[$metafieldName]['delta'][] = [
+ 'local' => $indexedLocalMF[$uuid],
+ 'remote' => $indexedRemoteMF[$uuid],
+ ];
+ }
+ }
+ }
+ return $fieldDifference;
+ }
}
diff --git a/src/Model/Table/MetaFieldsTable.php b/src/Model/Table/MetaFieldsTable.php
index 8c5a6d0..4b0338b 100644
--- a/src/Model/Table/MetaFieldsTable.php
+++ b/src/Model/Table/MetaFieldsTable.php
@@ -5,7 +5,9 @@ namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\ORM\Table;
use Cake\Validation\Validator;
+use Cake\Event\EventInterface;
use Cake\ORM\RulesChecker;
+use ArrayObject;
class MetaFieldsTable extends AppTable
{
@@ -22,6 +24,8 @@ class MetaFieldsTable extends AppTable
$this->addBehavior('Timestamp');
$this->belongsTo('MetaTemplates');
$this->belongsTo('MetaTemplateFields');
+ $this->belongsTo('MetaTemplateNameDirectory')
+ ->setForeignKey('meta_template_directory_id');
$this->setDisplayField('field');
}
@@ -35,7 +39,8 @@ class MetaFieldsTable extends AppTable
->notEmptyString('value')
->notEmptyString('meta_template_id')
->notEmptyString('meta_template_field_id')
- ->requirePresence(['scope', 'field', 'value', 'uuid', 'meta_template_id', 'meta_template_field_id'], 'create');
+ ->notEmptyString('meta_template_directory_id')
+ ->requirePresence(['scope', 'field', 'value', 'uuid', 'meta_template_directory_id', ], 'create');
$validator->add('value', 'validMetaField', [
'rule' => 'isValidMetaField',
@@ -46,10 +51,28 @@ class MetaFieldsTable extends AppTable
return $validator;
}
+ public function afterMarshal(EventInterface $event, \App\Model\Entity\MetaField $entity, ArrayObject $data, ArrayObject $options) {
+ if (!isset($entity->meta_template_directory_id)) {
+ $entity->set('meta_template_directory_id', $this->getTemplateDirectoryIdFromMetaTemplate($entity->meta_template_id));
+ }
+ }
+
+ public function getTemplateDirectoryIdFromMetaTemplate($metaTemplateId): int
+ {
+ return $this->MetaTemplates->find()
+ ->select('meta_template_directory_id')
+ ->where(['id' => $metaTemplateId])
+ ->first()
+ ->meta_template_directory_id;
+ }
+
public function isValidMetaField($value, array $context)
{
$metaFieldsTable = $context['providers']['table'];
$entityData = $context['data'];
+ if (empty($entityData['meta_template_field_id'])) {
+ return true;
+ }
$metaTemplateField = $metaFieldsTable->MetaTemplateFields->get($entityData['meta_template_field_id']);
return $this->isValidMetaFieldForMetaTemplateField($value, $metaTemplateField);
}
@@ -63,14 +86,14 @@ class MetaFieldsTable extends AppTable
if (!empty($metaTemplateField['regex'])) {
return $this->isValidRegex($value, $metaTemplateField);
}
+ if (!empty($metaTemplateField['values_list'])) {
+ return $this->isValidValuesList($value, $metaTemplateField);
+ }
return true;
}
public function isValidType($value, $metaTemplateField)
{
- if (empty($value)) {
- return __('Metafield value cannot be empty.');
- }
$typeHandler = $this->MetaTemplateFields->getTypeHandler($metaTemplateField['type']);
if (!empty($typeHandler)) {
$success = $typeHandler->validate($value);
@@ -92,4 +115,11 @@ class MetaFieldsTable extends AppTable
}
return true;
}
+
+ public function isValidValuesList($value, $metaTemplateField)
+ {
+
+ $valuesList = $metaTemplateField['values_list'];
+ return in_array($value, $valuesList);
+ }
}
diff --git a/src/Model/Table/MetaTemplateNameDirectoryTable.php b/src/Model/Table/MetaTemplateNameDirectoryTable.php
new file mode 100644
index 0000000..32e0869
--- /dev/null
+++ b/src/Model/Table/MetaTemplateNameDirectoryTable.php
@@ -0,0 +1,65 @@
+hasMany(
+ 'MetaFields',
+ [
+ 'foreignKey' => 'meta_template_directory_id',
+ ]
+ );
+ $this->setDisplayField('name');
+ }
+
+ public function validationDefault(Validator $validator): Validator
+ {
+ $validator
+ ->notEmptyString('name')
+ ->notEmptyString('namespace')
+ ->notEmptyString('uuid')
+ ->notEmptyString('version')
+ ->requirePresence(['version', 'uuid', 'name', 'namespace'], 'create');
+ return $validator;
+ }
+
+ public function buildRules(RulesChecker $rules): RulesChecker
+ {
+ $rules->add($rules->isUnique(
+ ['uuid', 'version'],
+ __('This meta-template already exists.')
+ ));
+ return $rules;
+ }
+
+ public function createFromMetaTemplate(MetaTemplate $metaTemplate): MetaTemplateNameDirectory
+ {
+ $metaTemplateDirectory = $this->newEntity([
+ 'name' => $metaTemplate['name'],
+ 'namespace' => $metaTemplate['namespace'],
+ 'uuid' => $metaTemplate['uuid'],
+ 'version' => $metaTemplate['version'],
+ ]);
+ $existingTemplate = $this->find()
+ ->where([
+ 'uuid' => $metaTemplate['uuid'],
+ 'version' => $metaTemplate['version'],
+ ])->first();
+ if (!empty($existingTemplate)) {
+ return $existingTemplate;
+ }
+ $this->save($metaTemplateDirectory);
+ return $metaTemplateDirectory;
+ }
+}
diff --git a/src/Model/Table/MetaTemplatesTable.php b/src/Model/Table/MetaTemplatesTable.php
index 77df33d..57925a6 100644
--- a/src/Model/Table/MetaTemplatesTable.php
+++ b/src/Model/Table/MetaTemplatesTable.php
@@ -22,10 +22,15 @@ class MetaTemplatesTable extends AppTable
public const UPDATE_STRATEGY_CREATE_NEW = 'create_new';
public const UPDATE_STRATEGY_UPDATE_EXISTING = 'update_existing';
public const UPDATE_STRATEGY_KEEP_BOTH = 'keep_both';
- public const UPDATE_STRATEGY_DELETE = 'delete_all';
+ public const UPDATE_STRATEGY_DELETE_ALL = 'delete_all';
- public const DEFAULT_STRATEGY = MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW;
- public const ALLOWED_STRATEGIES = [MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW];
+ public const DEFAULT_STRATEGY = MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING;
+ public const ALLOWED_STRATEGIES = [
+ MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW,
+ MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING,
+ MetaTemplatesTable::UPDATE_STRATEGY_KEEP_BOTH,
+ MetaTemplatesTable::UPDATE_STRATEGY_DELETE_ALL,
+ ];
private $templatesOnDisk = null;
@@ -42,6 +47,9 @@ class MetaTemplatesTable extends AppTable
'cascadeCallbacks' => true,
]
);
+ $this->hasOne('MetaTemplateNameDirectory')
+ ->setForeignKey('meta_template_directory_id');
+
$this->setDisplayField('name');
}
@@ -54,7 +62,7 @@ class MetaTemplatesTable extends AppTable
->notEmptyString('uuid')
->notEmptyString('version')
->notEmptyString('source')
- ->requirePresence(['scope', 'source', 'version', 'uuid', 'name', 'namespace'], 'create');
+ ->requirePresence(['scope', 'source', 'version', 'uuid', 'name', 'namespace', 'meta_template_directory_id'], 'create');
return $validator;
}
@@ -70,6 +78,41 @@ class MetaTemplatesTable extends AppTable
* @return array The update result containing potential errors and the successes
*/
public function updateAllTemplates(): array
+ {
+ // Create new template found on the disk
+ $newTemplateResult = $this->createAllNewTemplates();
+ $files_processed = $newTemplateResult['files_processed'];
+ $updatesErrors = $newTemplateResult['update_errors'];
+
+ $templatesUpdateStatus = $this->getUpdateStatusForTemplates();
+
+ // Update all existing templates
+ foreach ($templatesUpdateStatus as $uuid => $templateUpdateStatus) {
+ if (!empty($templateUpdateStatus['existing_template'])) {
+ $metaTemplate = $templateUpdateStatus['existing_template'];
+ $result = $this->update($metaTemplate, null);
+ if ($result['success']) {
+ $files_processed[] = $metaTemplate->uuid;
+ }
+ if (!empty($result['errors'])) {
+ $updatesErrors[] = $result['errors'];
+ }
+ }
+ }
+ $results = [
+ 'update_errors' => $updatesErrors,
+ 'files_processed' => $files_processed,
+ 'success' => !empty($files_processed),
+ ];
+ return $results;
+ }
+
+ /**
+ * Load the templates stored on the disk update and create them in the database without touching at the existing ones
+ *
+ * @return array The update result containing potential errors and the successes
+ */
+ public function createAllNewTemplates(): array
{
$updatesErrors = [];
$files_processed = [];
@@ -103,7 +146,7 @@ class MetaTemplatesTable extends AppTable
* @param string|null $strategy The strategy to be used when updating templates with conflicts
* @return array The update result containing potential errors and the successes
*/
- public function update($metaTemplate, $strategy = null): array
+ public function update(\App\Model\Entity\MetaTemplate $metaTemplate, $strategy = null): array
{
$files_processed = [];
$updatesErrors = [];
@@ -111,6 +154,25 @@ class MetaTemplatesTable extends AppTable
$templateStatus = $this->getStatusForMetaTemplate($templateOnDisk, $metaTemplate);
$updateStatus = $this->computeFullUpdateStatusForMetaTemplate($templateStatus, $metaTemplate);
$errors = [];
+
+ $result = $this->doUpdate($updateStatus, $templateOnDisk, $metaTemplate, $strategy);
+ if ($result['success']) {
+ $files_processed[] = $templateOnDisk['uuid'];
+ }
+ if (!empty($result['errors'])) {
+ $updatesErrors[] = implode(', ', Hash::extract($result['errors'], '{n}.message'));
+ }
+ $results = [
+ 'update_errors' => $updatesErrors,
+ 'files_processed' => $files_processed,
+ 'success' => !empty($files_processed),
+ ];
+ return $results;
+ }
+
+ private function doUpdate(array $updateStatus, array $templateOnDisk, \App\Model\Entity\MetaTemplate $metaTemplate, string $strategy = null): array
+ {
+ $errors = [];
$success = false;
if ($updateStatus['up-to-date']) {
$errors['message'] = __('Meta-template already up-to-date');
@@ -123,22 +185,17 @@ class MetaTemplatesTable extends AppTable
$errors['message'] = __('Cannot update meta-template, update strategy not allowed');
} else if (!$updateStatus['up-to-date']) {
$strategy = is_null($strategy) ? MetaTemplatesTable::DEFAULT_STRATEGY : $strategy;
+ if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING && !$updateStatus['automatically-updateable']) {
+ $strategy = MetaTemplatesTable::UPDATE_STRATEGY_KEEP_BOTH;
+ }
$success = $this->updateMetaTemplateWithStrategyRouter($metaTemplate, $templateOnDisk, $strategy, $errors);
} else {
$errors['message'] = __('Could not update. Something went wrong.');
}
- if ($success) {
- $files_processed[] = $templateOnDisk['uuid'];
- }
- if (!empty($errors)) {
- $updatesErrors[] = $errors;
- }
- $results = [
- 'update_errors' => $updatesErrors,
- 'files_processed' => $files_processed,
- 'success' => !empty($files_processed),
+ return [
+ 'success' => $success,
+ 'errors' => $errors,
];
- return $results;
}
/**
@@ -731,6 +788,8 @@ class MetaTemplatesTable extends AppTable
$metaTemplate = $this->newEntity($template, [
'associated' => ['MetaTemplateFields']
]);
+ $metaTemplateDirectory = $this->MetaTemplateNameDirectory->createFromMetaTemplate($metaTemplate);
+ $metaTemplate->meta_template_directory_id = $metaTemplateDirectory->id;
$tmp = $this->save($metaTemplate, [
'associated' => ['MetaTemplateFields']
]);
@@ -759,13 +818,27 @@ class MetaTemplatesTable extends AppTable
$errors[] = new UpdateError(false, $metaTemplate);
return false;
}
- $metaTemplate = $this->patchEntity($metaTemplate, $template, [
- 'associated' => ['MetaTemplateFields']
- ]);
+ $metaTemplate = $this->patchEntity($metaTemplate, $template);
+ foreach ($template['metaFields'] as $newMetaField) {
+ $newMetaField['__patched'] = true;
+ foreach($metaTemplate->meta_template_fields as $i => $oldMetaField) {
+ if ($oldMetaField->field == $newMetaField['field']) {
+ $metaTemplate->meta_template_fields[$i] = $this->MetaTemplateFields->patchEntity($oldMetaField, $newMetaField);
+ continue 2;
+ }
+ }
+ $metaTemplate->meta_template_fields[] = $this->MetaTemplateFields->newEntity($newMetaField);
+ }
+ $metaTemplate->setDirty('meta_template_fields', true);
$metaTemplate = $this->save($metaTemplate, [
'associated' => ['MetaTemplateFields']
]);
- if (!empty($metaTemplate)) {
+ foreach ($metaTemplate->meta_template_fields as $savedMetafield) {
+ if (empty($savedMetafield['__patched'])) {
+ $this->MetaTemplateFields->delete($savedMetafield);
+ }
+ }
+ if (empty($metaTemplate)) {
$errors[] = new UpdateError(false, __('Could not save the template.'), $metaTemplate->getErrors());
return false;
}
@@ -792,10 +865,12 @@ class MetaTemplatesTable extends AppTable
}
if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_KEEP_BOTH) {
$result = $this->executeStrategyKeep($template, $metaTemplate);
- } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_DELETE) {
- $result = $this->executeStrategyDeleteAll($template, $metaTemplate);
+ } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING) {
+ $result = $this->updateMetaTemplate($metaTemplate, $template);
} else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) {
$result = $this->executeStrategyCreateNew($template, $metaTemplate);
+ } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_DELETE_ALL) {
+ $result = $this->executeStrategyDeleteAll($template, $metaTemplate);
} else {
$errors[] = new UpdateError(false, __('Invalid strategy {0}', $strategy));
return false;
@@ -825,6 +900,7 @@ class MetaTemplatesTable extends AppTable
$errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']);
}
$conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template);
+ $conflictingEntities = Hash::combine($conflicts, '{s}.conflictingEntities.{n}.parent_id', '{s}.conflictingEntities.{n}.parent_id');
$blockingConflict = Hash::extract($conflicts, '{s}.conflicts');
$errors = [];
if (empty($blockingConflict)) { // No conflict, everything can be updated without special care
@@ -833,7 +909,6 @@ class MetaTemplatesTable extends AppTable
}
$entities = $this->getEntitiesHavingMetaFieldsFromTemplate($metaTemplate->id, null);
- $conflictingEntities = [];
foreach ($entities as $entity) {
$conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template);
if (!empty($conflicts)) {
@@ -844,8 +919,9 @@ class MetaTemplatesTable extends AppTable
$this->updateMetaTemplate($metaTemplate, $template, $errors);
return !empty($errors) ? $errors[0] : true;
}
- $template['is_default'] = $metaTemplate['is_default'];
- $template['enabled'] = $metaTemplate['enabled'];
+ $template['is_default'] = $metaTemplate->is_default;
+ $template['enabled'] = $metaTemplate->enabled;
+ $metaTemplate->set('enabled', false);
if ($metaTemplate->is_default) {
$metaTemplate->set('is_default', false);
$this->save($metaTemplate);
@@ -853,13 +929,9 @@ class MetaTemplatesTable extends AppTable
$savedMetaTemplate = null;
$this->saveNewMetaTemplate($template, $errors, $savedMetaTemplate);
if (!empty($savedMetaTemplate)) {
- $savedMetaTemplateFieldByName = Hash::combine($savedMetaTemplate['meta_template_fields'], '{n}.field', '{n}');
foreach ($entities as $entity) {
if (empty($conflictingEntities[$entity->id])) { // conflicting entities remain untouched
- foreach ($entity['meta_fields'] as $metaField) {
- $savedMetaTemplateField = $savedMetaTemplateFieldByName[$metaField->field];
- $this->supersedeMetaFieldWithMetaTemplateField($metaField, $savedMetaTemplateField);
- }
+ $this->supersedeMetaFieldsWithMetaTemplateField($entity['meta_fields'], $savedMetaTemplate);
}
}
} else {
@@ -868,6 +940,56 @@ class MetaTemplatesTable extends AppTable
return true;
}
+ public function migrateMetafieldsToNewestTemplate(\App\Model\Entity\MetaTemplate $oldMetaTemplate, \App\Model\Entity\MetaTemplate $newestMetaTemplate, bool $forceMigration): array
+ {
+ $result = [
+ 'success' => true,
+ 'migrated_count' => 0,
+ 'conflicting_entities' => 0,
+ 'migration_errors' => 0,
+ ];
+
+ $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($oldMetaTemplate->id, null);
+ if (empty($entities)) {
+ return $result;
+ }
+
+ $successfullyMigratedEntities = 0;
+ $migrationErrors = 0;
+ $conflictingEntities = [];
+ foreach ($entities as $entity) {
+ $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity->meta_fields, $newestMetaTemplate);
+ if (!empty($conflicts)) {
+ $conflictingEntities[] = $entity->id;
+ if (!$forceMigration) {
+ continue;
+ } else {
+ $conflictingMetafieldIDs = Hash::extract($conflicts, '{n}.id');
+ $metaFieldsToDelete = [];
+ foreach ($entity->meta_fields as $i => $metaField) {
+ if (in_array($metaField->id, $conflictingMetafieldIDs)) {
+ $metaFieldsToDelete[] = $metaField;
+ unset($entity->meta_fields[$i]);
+ }
+ }
+ $this->MetaTemplateFields->MetaFields->unlink($entity, $metaFieldsToDelete);
+ }
+ }
+ $success = $this->supersedeMetaFieldsWithMetaTemplateField($entity->meta_fields, $newestMetaTemplate);
+ if ($success) {
+ $successfullyMigratedEntities += 1;
+ } else {
+ $migrationErrors += 1;
+ }
+ }
+
+ $result['success'] = $migrationErrors == 0;
+ $result['migrated_count'] = $successfullyMigratedEntities;
+ $result['conflicting_entities'] = count($conflictingEntities);
+ $result['migration_errors'] = $migrationErrors;
+ return $result;
+ }
+
/**
* Execute the `delete_all` update strategy by updating the meta-template and deleting all conflicting meta-fields.
* Strategy:
@@ -894,9 +1016,7 @@ class MetaTemplatesTable extends AppTable
foreach ($entities as $entity) {
$conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template);
- $deletedCount = $this->MetaTemplateFields->MetaFields->deleteAll([
- 'id IN' => $conflicts
- ]);
+ $this->MetaTemplateFields->MetaFields->unlink($entity, $conflicts);
}
$this->updateMetaTemplate($metaTemplate, $template, $errors);
return !empty($errors) ? $errors[0] : true;
@@ -936,16 +1056,20 @@ class MetaTemplatesTable extends AppTable
/**
* Supersede a meta-fields's meta-template-field with the provided one.
*
- * @param \App\Model\Entity\MetaField $metaField
+ * @param array $metaFields
* @param \App\Model\Entity\MetaTemplateField $savedMetaTemplateField
* @return bool True if the replacement was a success, False otherwise
*/
- public function supersedeMetaFieldWithMetaTemplateField(\App\Model\Entity\MetaField $metaField, \App\Model\Entity\MetaTemplateField $savedMetaTemplateField): bool
+ public function supersedeMetaFieldsWithMetaTemplateField(array $metaFields, \App\Model\Entity\MetaTemplate $savedMetaTemplate): bool
{
- $metaField->set('meta_template_id', $savedMetaTemplateField->meta_template_id);
- $metaField->set('meta_template_field_id', $savedMetaTemplateField->id);
- $metaField = $this->MetaTemplateFields->MetaFields->save($metaField);
- return !empty($metaField);
+ $savedMetaTemplateFieldByName = Hash::combine($savedMetaTemplate['meta_template_fields'], '{n}.field', '{n}');
+ foreach ($metaFields as $i => $metaField) {
+ $savedMetaTemplateField = $savedMetaTemplateFieldByName[$metaField->field];
+ $metaField->set('meta_template_id', $savedMetaTemplateField->meta_template_id);
+ $metaField->set('meta_template_field_id', $savedMetaTemplateField->id);
+ }
+ $entities = $this->MetaTemplateFields->MetaFields->saveMany($metaFields);
+ return !empty($entities);
}
/**
@@ -972,13 +1096,14 @@ class MetaTemplatesTable extends AppTable
$metaTemplateFieldByName[$metaTemplateField['field']] = $this->MetaTemplateFields->newEntity($metaTemplateField);
}
foreach ($metaFields as $metaField) {
- if ($existingMetaTemplate && $metaField->meta_template_id != $template->id) {
- continue;
+ if (empty($metaTemplateFieldByName[$metaField->field])) { // Meta-field was removed from the template
+ $isValid = false;
+ } else {
+ $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField(
+ $metaField->value,
+ $metaTemplateFieldByName[$metaField->field]
+ );
}
- $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField(
- $metaField->value,
- $metaTemplateFieldByName[$metaField->field]
- );
if ($isValid !== true) {
$conflicting[] = $metaField;
}
@@ -1022,27 +1147,12 @@ class MetaTemplatesTable extends AppTable
$result['conflictingEntities'] = Hash::extract($conflictingStatus, '{n}.parent_id');
}
}
- if (!empty($templateField['regex']) && $templateField['regex'] != $metaTemplateField->regex) {
- $entitiesWithMetaFieldQuery = $this->MetaTemplateFields->MetaFields->find();
- $entitiesWithMetaFieldQuery
- ->enableHydration(false)
- ->select([
- 'parent_id',
- ])
- ->where([
- 'meta_template_field_id' => $metaTemplateField->id,
- ]);
- $entitiesTable = $this->getTableForMetaTemplateScope($scope);
- $entities = $entitiesTable->find()
- ->where(['id IN' => $entitiesWithMetaFieldQuery])
- ->contain([
- 'MetaFields' => [
- 'conditions' => [
- 'MetaFields.meta_template_field_id' => $metaTemplateField->id
- ]
- ]
- ])
- ->all()->toList();
+
+ if (
+ (!empty($templateField['regex']) && $templateField['regex'] != $metaTemplateField->regex) ||
+ !empty($templateField['values_list'])
+ ) {
+ $entities = $this->getEntitiesForMetaTemplateField($scope, $metaTemplateField->id, true);
$conflictingEntities = [];
foreach ($entities as $entity) {
foreach ($entity['meta_fields'] as $metaField) {
@@ -1051,7 +1161,10 @@ class MetaTemplatesTable extends AppTable
$templateField
);
if ($isValid !== true) {
- $conflictingEntities[] = $entity->id;
+ $conflictingEntities[] = [
+ 'parent_id' => $entity->id,
+ 'meta_template_field_id' => $metaTemplateField->id,
+ ];
break;
}
}
@@ -1059,13 +1172,50 @@ class MetaTemplatesTable extends AppTable
if (!empty($conflictingEntities)) {
$result['automatically-updateable'] = $result['automatically-updateable'] && false;
- $result['conflicts'][] = __('This field is instantiated with values not passing the validation anymore');
+ $result['conflicts'][] = __('This field is instantiated with values not passing the validation anymore.');
$result['conflictingEntities'] = array_merge($result['conflictingEntities'], $conflictingEntities);
}
}
return $result;
}
+ /**
+ * Return all entities having the meta-fields using the provided meta-template-field.
+ *
+ * @param string $scope
+ * @param integer $metaTemplateFieldID The ID of the matching meta-template-field
+ * @param boolean $includeMatchingMetafields Should the entities also include the matching meta-fields
+ * @return array
+ */
+ private function getEntitiesForMetaTemplateField(string $scope, int $metaTemplateFieldID, bool $includeMatchingMetafields=true): array
+ {
+ $entitiesTable = $this->getTableForMetaTemplateScope($scope);
+ $entitiesWithMetaFieldQuery = $this->MetaTemplateFields->MetaFields->find();
+ $entitiesWithMetaFieldQuery
+ ->enableHydration(false)
+ ->select([
+ 'parent_id',
+ ])
+ ->where([
+ 'meta_template_field_id' => $metaTemplateFieldID,
+ ]);
+
+ $entitiesQuery = $entitiesTable->find()
+ ->where(['id IN' => $entitiesWithMetaFieldQuery]);
+
+ if ($includeMatchingMetafields) {
+ $entitiesQuery->contain([
+ 'MetaFields' => [
+ 'conditions' => [
+ 'MetaFields.meta_template_field_id' => $metaTemplateFieldID,
+ ]
+ ]
+ ]);
+ }
+
+ return $entitiesQuery->all()->toList();
+ }
+
/**
* Check the conflict that would be introduced if the metaTemplate would be updated to the provided template
*
@@ -1082,7 +1232,7 @@ class MetaTemplatesTable extends AppTable
$templateMetaFields = $template['metaFields'];
}
$conflicts = [];
- $existingMetaTemplateFields = Hash::combine($metaTemplate->toArray(), 'meta_template_fields.{n}.field');
+ $existingMetaTemplateFields = Hash::combine($metaTemplate->toArray(), 'meta_template_fields.{n}.field', 'meta_template_fields.{n}');
foreach ($templateMetaFields as $newMetaField) {
foreach ($metaTemplate->meta_template_fields as $metaField) {
if ($newMetaField['field'] == $metaField->field) {
@@ -1098,10 +1248,23 @@ class MetaTemplatesTable extends AppTable
}
}
if (!empty($existingMetaTemplateFields)) {
- foreach ($existingMetaTemplateFields as $field => $tmp) {
- $conflicts[$field] = [
+ foreach ($existingMetaTemplateFields as $metaTemplateField) {
+ $query = $this->MetaTemplateFields->MetaFields->find();
+ $query
+ ->enableHydration(false)
+ ->select([
+ 'parent_id',
+ 'meta_template_field_id',
+ ])
+ ->where([
+ 'meta_template_field_id' => $metaTemplateField['id'],
+ ])
+ ->group(['parent_id']);
+ $entityWithMetafieldToBeRemoved = $query->all()->toList();
+ $conflicts[$metaTemplateField['field']] = [
'automatically-updateable' => false,
'conflicts' => [__('This field is intended to be removed')],
+ 'conflictingEntities' => $entityWithMetafieldToBeRemoved,
];
}
}
@@ -1154,9 +1317,9 @@ class MetaTemplatesTable extends AppTable
$updateStatus['current_version'] = $metaTemplate->version;
$updateStatus['next_version'] = $template['version'];
$updateStatus['new'] = false;
- if ($metaTemplate->version >= $template['version']) {
+ $updateStatus['automatically-updateable'] = false;
+ if (intval($metaTemplate->version) >= intval($template['version'])) {
$updateStatus['up-to-date'] = true;
- $updateStatus['automatically-updateable'] = false;
$updateStatus['conflicts'][] = __('Could not update the template. Local version is equal or newer.');
return $updateStatus;
}
@@ -1164,6 +1327,17 @@ class MetaTemplatesTable extends AppTable
$conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template);
if (!empty($conflicts)) {
$updateStatus['conflicts'] = $conflicts;
+ $updateStatus['automatically-updateable'] = false;
+ $emptySum = 0;
+ foreach ($conflicts as $fieldname => $fieldStatus) {
+ if (!empty($fieldStatus['conflictingEntities'])) {
+ break;
+ }
+ $emptySum += 1;
+ }
+ if ($emptySum == count($conflicts)) {
+ $updateStatus['automatically-updateable'] = true;
+ }
} else {
$updateStatus['automatically-updateable'] = true;
}
diff --git a/src/Model/Table/PermissionLimitationsTable.php b/src/Model/Table/PermissionLimitationsTable.php
index 6300823..e40cf96 100644
--- a/src/Model/Table/PermissionLimitationsTable.php
+++ b/src/Model/Table/PermissionLimitationsTable.php
@@ -91,10 +91,6 @@ class PermissionLimitationsTable extends AppTable
foreach ($metaTemplate['meta_template_fields'] as &$meta_template_field) {
$boolean = $meta_template_field['type'] === 'boolean';
foreach ($meta_template_field['metaFields'] as &$metaField) {
- if ($boolean) {
- $metaField['value'] = '';
- $metaField['no_escaping'] = true;
- }
if (isset($permissionLimitations[$metaField['field']])) {
foreach ($permissionLimitations[$metaField['field']] as $scope => $value) {
$messageType = 'warning';
diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php
index fb2faa0..3f9bb65 100644
--- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php
+++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php
@@ -342,7 +342,13 @@ class CerebrateSettingsProvider extends BaseSettingsProvider
'type' => 'boolean',
'description' => __('This setting will enforce that usernames conform to basic requirements of e-mail addresses.'),
'default' => false
- ]
+ ],
+ 'user.allow-user-deletion' => [
+ 'name' => __('Allow user deletion'),
+ 'type' => 'boolean',
+ 'description' => __('This setting will allow the deletion of users by authorized users.'),
+ 'default' => false
+ ],
]
]
]
diff --git a/src/Utility/Utils.php b/src/Utility/Utils.php
new file mode 100644
index 0000000..9f63c24
--- /dev/null
+++ b/src/Utility/Utils.php
@@ -0,0 +1,32 @@
+ $value) {
+ //if the key exists in the second array, recursively call this function
+ //if it is an array, otherwise check if the value is in arr2
+ if (array_key_exists($key, $arr2)) {
+ if (is_array($value)) {
+ $recursiveDiff = array_diff_recursive($value, $arr2[$key]);
+
+ if (count($recursiveDiff)) {
+ $outputDiff[$key] = $recursiveDiff;
+ }
+ } else if (!in_array($value, $arr2)) {
+ $outputDiff[$key] = $value;
+ }
+ }
+ //if the key is not in the second array, check if the value is in
+ //the second array (this is a quirk of how array_diff works)
+ else if (!in_array($value, $arr2)) {
+ $outputDiff[$key] = $value;
+ }
+ }
+
+ return $outputDiff;
+}
diff --git a/src/View/Helper/BootstrapElements/BootstrapAccordion.php b/src/View/Helper/BootstrapElements/BootstrapAccordion.php
new file mode 100644
index 0000000..6f0688c
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapAccordion.php
@@ -0,0 +1,155 @@
+Bootstrap->accordion(
+ * [
+ * 'stayOpen' => true,
+ * ],
+ * [
+ * [
+ * 'open' => true,
+ * 'header' => [
+ * 'variant' => 'danger',
+ * 'text' => 'nav 1',
+ * ],
+ * 'body' => 'body',
+ * ],
+ * [
+ * 'class' => ['opacity-50'],
+ * 'variant' => 'success',
+ * 'header' => [
+ * 'html' => 'nav 1',
+ * ],
+ * 'body' => 'body',
+ * ],
+ * ]
+ * );
+ */
+class BootstrapAccordion extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'stayOpen' => false,
+ 'class' => [],
+ ];
+
+ function __construct(array $options, array $content, BootstrapHelper $btHelper)
+ {
+ $this->allowedOptionValues = [];
+ $this->content = $content;
+ $this->btHelper = $btHelper;
+ $this->processOptions($options);
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->checkOptionValidity();
+ $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']);
+ $this->seed = 'acc-' . mt_rand();
+ $this->contentSeeds = [];
+ foreach ($this->content as $accordionItem) {
+ $this->contentSeeds[] = mt_rand();
+ }
+
+ foreach ($this->content as $i => $item) {
+ $this->content[$i]['class'] = $this->convertToArrayIfNeeded($item['class'] ?? []);
+ $this->content[$i]['header']['class'] = $this->convertToArrayIfNeeded($item['header']['class'] ?? []);
+ }
+ }
+
+ public function accordion(): string
+ {
+ return $this->genAccordion();
+ }
+
+ private function genHeader(array $accordionItem, int $i): string
+ {
+ $html = $this->nodeOpen('h2', [
+ 'class' => ['accordion-header'],
+ 'id' => 'head-' . $this->contentSeeds[$i]
+ ]);
+ $content = $accordionItem['header']['html'] ?? h($accordionItem['header']['text']);
+ $buttonOptions = [
+ 'class' => array_merge(
+ [
+ 'accordion-button',
+ empty($accordionItem['open']) ? 'collapsed' : '',
+ self::getBGAndTextClassForVariant($accordionItem['header']['variant'] ?? ''),
+ ],
+ $accordionItem['header']['class'],
+ ),
+ 'type' => 'button',
+ 'data-bs-toggle' => 'collapse',
+ 'data-bs-target' => '#body-' . $this->contentSeeds[$i],
+ 'aria-expanded' => 'false',
+ 'aria-controls' => 'body-' . $this->contentSeeds[$i],
+ ];
+ $html .= $this->node('button', $buttonOptions, $content);
+ $html .= $this->nodeClose(('h2'));
+ return $html;
+ }
+
+ private function genBody(array $accordionItem, int $i): string
+ {
+ $content = $this->node('div', [
+ 'class' => ['accordion-body']
+ ], $accordionItem['body']);
+ $divOptions = [
+ 'class' => array_merge(
+ [
+ 'accordion-collapse collapse',
+ empty($accordionItem['open']) ? '' : 'show',
+ self::getBGAndTextClassForVariant($accordionItem['variant'] ?? ''),
+ ],
+ $accordionItem['class'],
+ ),
+ 'id' => 'body-' . $this->contentSeeds[$i],
+ 'aria-labelledby' => 'head-' . $this->contentSeeds[$i],
+ ];
+ if (empty($this->options['stayOpen'])) {
+ $divOptions['data-bs-parent'] = '#' . $this->seed;
+ }
+ $html = $this->node('div', $divOptions, $content);
+ return $html;
+ }
+
+ private function genAccordion(): string
+ {
+ $html = $this->nodeOpen('div', [
+ 'class' => array_merge(['accordion'], $this->options['class']),
+ 'id' => $this->seed
+ ]);
+ foreach ($this->content as $i => $accordionItem) {
+ $html .= $this->nodeOpen('div', [
+ 'class' => array_merge(['accordion-item'])
+ ]);
+ $html .= $this->genHeader($accordionItem, $i);
+ $html .= $this->genBody($accordionItem, $i);
+ $html .= $this->nodeClose('div');
+ }
+ $html .= $this->nodeClose('div');
+ return $html;
+ }
+}
\ No newline at end of file
diff --git a/src/View/Helper/BootstrapElements/BootstrapAlert.php b/src/View/Helper/BootstrapElements/BootstrapAlert.php
new file mode 100644
index 0000000..8bff3c1
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapAlert.php
@@ -0,0 +1,83 @@
+Bootstrap->alert([
+ * 'text' => 'This is an alert',
+ * 'dismissible' => false,
+ * 'variant' => 'warning',
+ * 'fade' => false,
+ * ]);
+ */
+class BootstrapAlert extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'text' => '',
+ 'html' => null,
+ 'dismissible' => true,
+ 'variant' => 'primary',
+ 'fade' => true,
+ 'class' => [],
+ ];
+
+ function __construct(array $options)
+ {
+ $this->allowedOptionValues = [
+ 'variant' => BootstrapGeneric::$variants,
+ ];
+ $this->processOptions($options);
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']);
+ $this->checkOptionValidity();
+ }
+
+ public function alert(): string
+ {
+ return $this->genAlert();
+ }
+
+ private function genAlert(): string
+ {
+ $html = $this->nodeOpen('div', [
+ 'class' => array_merge([
+ 'alert',
+ "alert-{$this->options['variant']}",
+ $this->options['dismissible'] ? 'alert-dismissible' : '',
+ $this->options['fade'] ? 'fade show' : '',
+ ], $this->options['class']),
+ 'role' => "alert"
+ ]);
+
+ $html .= $this->options['html'] ?? h($this->options['text']);
+ $html .= $this->genCloseButton();
+ $html .= $this->nodeClose('div');
+ return $html;
+ }
+
+ private function genCloseButton(): string
+ {
+ $html = '';
+ if ($this->options['dismissible']) {
+ $html .= $this->genericCloseButton('alert');
+ }
+ return $html;
+ }
+}
diff --git a/src/View/Helper/BootstrapElements/BootstrapBadge.php b/src/View/Helper/BootstrapElements/BootstrapBadge.php
new file mode 100644
index 0000000..3fe745b
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapBadge.php
@@ -0,0 +1,88 @@
+Bootstrap->badge([
+ * 'text' => 'text',
+ * 'variant' => 'success',
+ * 'pill' => false,
+ * ]);
+ */
+class BootstrapBadge extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'id' => '',
+ 'text' => '',
+ 'html' => null,
+ 'variant' => 'primary',
+ 'pill' => false,
+ 'icon' => false,
+ 'title' => '',
+ 'class' => [],
+ ];
+
+ function __construct(array $options)
+ {
+ $this->allowedOptionValues = [
+ 'variant' => BootstrapGeneric::$variants,
+ ];
+ $this->processOptions($options);
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']);
+ $this->checkOptionValidity();
+ }
+
+ public function badge(): string
+ {
+ return $this->genBadge();
+ }
+
+ private function genBadge(): string
+ {
+ $html = $this->node('span', [
+ 'class' => array_merge($this->options['class'], [
+ 'ms-1',
+ 'badge',
+ self::getBGAndTextClassForVariant($this->options['variant']),
+ $this->options['pill'] ? 'rounded-pill' : '',
+ ]),
+ 'title' => $this->options['title'],
+ 'id' => $this->options['id'] ?? '',
+ ], [
+ $this->genIcon(),
+ $this->options['html'] ?? h($this->options['text'])
+ ]);
+ return $html;
+ }
+
+ private function genIcon(): string
+ {
+ if (!empty($this->options['icon'])) {
+ $bsIcon = new BootstrapIcon($this->options['icon'], [
+ 'class' => [(!empty($this->options['text']) ? 'me-1' : '')]
+ ]);
+ return $bsIcon->icon();
+ }
+ return '';
+ }
+}
diff --git a/src/View/Helper/BootstrapElements/BootstrapButton.php b/src/View/Helper/BootstrapElements/BootstrapButton.php
new file mode 100644
index 0000000..458680c
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapButton.php
@@ -0,0 +1,143 @@
+Bootstrap->button([
+ * 'text' => 'Press me!',
+ * 'variant' => 'warning',
+ * 'icon' => 'exclamation-triangle',
+ * 'onclick' => 'alert(1)',
+ * ]);
+ */
+class BootstrapButton extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'id' => '',
+ 'text' => '',
+ 'html' => null,
+ 'variant' => 'primary',
+ 'outline' => false,
+ 'size' => '',
+ 'icon' => null,
+ 'image' => null,
+ 'class' => [],
+ 'type' => 'button',
+ 'nodeType' => 'button',
+ 'title' => '',
+ 'badge' => false,
+ 'onclick' => false,
+ 'attrs' => [],
+ ];
+
+ private $bsClasses = [];
+
+ function __construct(array $options)
+ {
+ $this->allowedOptionValues = [
+ 'variant' => array_merge(BootstrapGeneric::$variants, ['link', 'text']),
+ 'size' => ['', 'xs', 'sm', 'lg'],
+ 'type' => ['button', 'submit', 'reset']
+ ];
+ $this->processOptions($options);
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']);
+ $this->checkOptionValidity();
+
+ if (!empty($this->options['id'])) {
+ $this->options['attrs']['id'] = $this->options['id'];
+ }
+
+ $this->bsClasses[] = 'btn';
+ if ($this->options['outline']) {
+ $this->bsClasses[] = "btn-outline-{$this->options['variant']}";
+ } else {
+ $this->bsClasses[] = "btn-{$this->options['variant']}";
+ }
+ if (!empty($this->options['size'])) {
+ $this->bsClasses[] = "btn-{$this->options['size']}";
+ }
+ if ($this->options['variant'] == 'text') {
+ $this->bsClasses[] = 'p-0';
+ $this->bsClasses[] = 'lh-1';
+ }
+ if (!empty($this->options['onclick'])) {
+ $this->options['attrs']['onclick'] = $this->options['onclick'];
+ }
+ }
+
+ public function button(): string
+ {
+ return $this->genButton();
+ }
+
+ private function genButton(): string
+ {
+ $html = $this->nodeOpen($this->options['nodeType'], array_merge($this->options['attrs'], [
+ 'class' => array_merge($this->options['class'], $this->bsClasses),
+ 'role' => "alert",
+ 'type' => $this->options['type'],
+ 'title' => h($this->options['title']),
+ ]));
+
+ $html .= $this->genIcon();
+ $html .= $this->genImage();
+ $html .= $this->options['html'] ?? h($this->options['text']);
+ if (!empty($this->options['badge'])) {
+ $bsBadge = new BootstrapBadge($this->options['badge']);
+ $html .= $bsBadge->badge();
+ }
+ $html .= $this->nodeClose($this->options['nodeType']);
+ return $html;
+ }
+
+ private function genIcon(): string
+ {
+ if (!empty($this->options['icon'])) {
+ $bsIcon = new BootstrapIcon($this->options['icon'], [
+ 'class' => [(!empty($this->options['text']) ? 'me-1' : '')]
+ ]);
+ return $bsIcon->icon();
+ }
+ return '';
+ }
+
+ private function genImage(): string
+ {
+ if (!empty($this->options['image'])) {
+ return $this->node('img', [
+ 'src' => $this->options['image']['path'] ?? '',
+ 'class' => ['img-fluid', 'me-1'],
+ 'width' => '26',
+ 'height' => '26',
+ 'alt' => $this->options['image']['alt'] ?? ''
+ ]);
+ }
+ return '';
+ }
+}
diff --git a/src/View/Helper/BootstrapElements/BootstrapCard.php b/src/View/Helper/BootstrapElements/BootstrapCard.php
new file mode 100644
index 0000000..47ecd68
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapCard.php
@@ -0,0 +1,135 @@
+Bootstrap->card([
+ * 'headerText' => 'header',
+ * 'bodyHTML' => 'body',
+ * 'footerText' => 'footer',
+ * 'headerVariant' => 'warning',
+ * 'footerVariant' => 'dark',
+ * );
+ */
+class BootstrapCard extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'headerText' => '',
+ 'bodyText' => '',
+ 'footerText' => '',
+ 'headerHTML' => null,
+ 'bodyHTML' => null,
+ 'footerHTML' => null,
+ 'class' => [],
+ 'headerVariant' => '',
+ 'bodyVariant' => '',
+ 'footerVariant' => '',
+ 'headerClass' => '',
+ 'bodyClass' => '',
+ 'footerClass' => '',
+ ];
+
+ public function __construct(array $options)
+ {
+ $this->allowedOptionValues = [
+ 'headerVariant' => array_merge(BootstrapGeneric::$variants, ['']),
+ 'bodyVariant' => array_merge(BootstrapGeneric::$variants, ['']),
+ 'footerVariant' => array_merge(BootstrapGeneric::$variants, ['']),
+ ];
+ $this->processOptions($options);
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->options['headerClass'] = $this->convertToArrayIfNeeded($this->options['headerClass']);
+ $this->options['bodyClass'] = $this->convertToArrayIfNeeded($this->options['bodyClass']);
+ $this->options['footerClass'] = $this->convertToArrayIfNeeded($this->options['footerClass']);
+ $this->checkOptionValidity();
+ $this->options['borderVariant'] = !empty($this->options['headerVariant']) ? "border-{$this->options['headerVariant']}" : '';
+ }
+
+ public function card(): string
+ {
+ return $this->genCard();
+ }
+
+ private function genCard(): string
+ {
+ $card = $this->node('div', [
+ 'class' => array_merge(
+ [
+ 'card',
+ $this->options['borderVariant'],
+ ],
+ $this->options['class']
+ ),
+ ], implode('', [$this->genHeader(), $this->genBody(), $this->genFooter()]));
+ return $card;
+ }
+
+ private function genHeader(): string
+ {
+ if (empty($this->options['headerHTML']) && empty($this->options['headerText'])) {
+ return '';
+ }
+ $content = $this->options['headerHTML'] ?? h($this->options['headerText']);
+ $header = $this->node('div', [
+ 'class' => array_merge(
+ [
+ 'card-header',
+ self::getBGAndTextClassForVariant($this->options['headerVariant']),
+ ],
+ $this->options['headerClass']
+ ),
+ ], $content);
+ return $header;
+ }
+
+ private function genBody(): string
+ {
+ if (empty($this->options['bodyHTML']) && empty($this->options['bodyText'])) {
+ return '';
+ }
+ $content = $this->options['bodyHTML'] ?? h($this->options['bodyText']);
+ $body = $this->node('div', [
+ 'class' => array_merge(
+ [
+ 'card-body',
+ self::getBGAndTextClassForVariant($this->options['bodyVariant']),
+ ],
+ $this->options['bodyClass']
+ )
+ ], $content);
+ return $body;
+ }
+
+ private function genFooter(): string
+ {
+ if (empty($this->options['footerHTML']) && empty($this->options['footerText'])) {
+ return '';
+ }
+ $content = $this->options['footerHTML'] ?? h($this->options['footerText']);
+ $footer = $this->node('div', [
+ 'class' => array_merge([
+ 'card-footer',
+ self::getBGAndTextClassForVariant($this->options['footerVariant']),
+ ],
+ $this->options['footerClass']
+ )
+ ], $content);
+ return $footer;
+ }
+}
\ No newline at end of file
diff --git a/src/View/Helper/BootstrapElements/BootstrapCollapse.php b/src/View/Helper/BootstrapElements/BootstrapCollapse.php
new file mode 100644
index 0000000..472cfc1
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapCollapse.php
@@ -0,0 +1,125 @@
+Bootstrap->collapse([
+ * 'button' => [
+ * 'text' => 'Open sesame',
+ * 'variant' => 'success',
+ * ],
+ * 'card' => [
+ * 'bodyClass' => 'p-2 rounded-3',
+ * 'bodyVariant' => 'secondary',
+ * ]
+ * ], 'content');
+ */
+
+class BootstrapCollapse extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'text' => '',
+ 'html' => null,
+ 'open' => false,
+ 'horizontal' => false,
+ 'class' => [],
+ 'button' => [],
+ 'card' => false,
+ 'attrs' => [],
+ ];
+
+ function __construct(array $options, string $content, BootstrapHelper $btHelper)
+ {
+ $this->allowedOptionValues = [];
+ $this->processOptions($options);
+ $this->content = $content;
+ $this->btHelper = $btHelper;
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']);
+ $this->options['class'][] = 'collapse';
+ if (!empty($this->options['horizontal'])) {
+ $this->options['class'][] = 'collapse-horizontal';
+ }
+ if ($this->options['open']) {
+ $this->options['class'][] = 'show';
+ }
+ if ($this->options['card'] !== false && empty($this->options['card']['bodyClass'])) {
+ $this->options['card']['bodyClass'] = ['p-0'];
+ }
+ if (empty($this->options['id'])) {
+ $this->options['id'] = 'c-' . Security::randomString(8);
+ }
+ $this->checkOptionValidity();
+ }
+
+ public function collapse(): string
+ {
+ return $this->genCollapse();
+ }
+
+ private function genControl(): string
+ {
+ $attrsConfig = [
+ 'data-bs-toggle' => 'collapse',
+ 'role' => 'button',
+ 'aria-expanded' => 'false',
+ 'aria-controls' => $this->options['id'],
+ 'href' => '#' . $this->options['id'],
+ ];
+ $html = '';
+ if (!empty($this->options['button'])) {
+ $btnConfig = array_merge($this->options['button'], ['attrs' => $attrsConfig]);
+ $html = $this->btHelper->button($btnConfig);
+ } else {
+ $nodeConfig = [
+ 'class' => ['text-decoration-none'],
+ ];
+ $nodeConfig = array_merge($nodeConfig, $attrsConfig);
+ $html = $this->node('a', $nodeConfig, $this->options['html'] ?? h($this->options['text']));
+ }
+ return $html;
+ }
+
+ private function genContent(): string
+ {
+ if (!empty($this->options['card'])) {
+ $cardConfig = $this->options['card'];
+ $cardConfig['bodyHTML'] = $this->content;
+ $content = $this->btHelper->card($cardConfig);
+ } else {
+ $content = $this->content;
+ }
+ $container = $this->node('div', [
+ 'class' => $this->options['class'],
+ 'id' => $this->options['id'],
+ ], $content);
+ return $container;
+ }
+
+ private function genCollapse(): string
+ {
+ return $this->genControl() . $this->genContent();
+ }
+}
\ No newline at end of file
diff --git a/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php b/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php
new file mode 100644
index 0000000..58110d1
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapDropdownMenu.php
@@ -0,0 +1,214 @@
+ element
+ * - attrs: Additional HTML attributes to be applied on the entry
+ *
+ * # Usage:
+ * $this->Bootstrap->dropdownMenu([
+ * 'dropdown-class' => 'ms-1',
+ * 'alignment' => 'end',
+ * 'direction' => 'down',
+ * 'button' => [
+ * 'icon' => 'sliders-h',
+ * 'variant' => 'primary',
+ * ],
+ * 'submenu_alignment' => 'end',
+ * 'submenu_direction' => 'end',
+ * 'attrs' => [],
+ * 'menu' => [
+ * [
+ * 'text' => __('Eye'),
+ * 'icon' => 'eye-slash',
+ * 'keepOpen' => true,
+ * 'menu' => [
+ * ['header' => true, 'text' => 'nested menu'],
+ * ['text' => 'item 1'],
+ * ['text' => 'item 2', 'sup' => 'v1'],
+ * ],
+ * ],
+ * [
+ * 'html' => 'html item',
+ * ],
+ * ]
+ * ]);
+ */
+
+class BootstrapDropdownMenu extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'dropdown-class' => [],
+ 'alignment' => 'start',
+ 'direction' => 'end',
+ 'button' => [],
+ 'menu' => [],
+ 'submenu_direction' => 'end',
+ 'submenu_classes' => [],
+ 'attrs' => [],
+ ];
+
+ function __construct(array $options, BootstrapHelper $btHelper)
+ {
+ $this->allowedOptionValues = [
+ 'direction' => ['start', 'end', 'up', 'down'],
+ 'alignment' => ['start', 'end'],
+ 'submenu_direction' => ['start', 'end', 'up', 'down'],
+ ];
+ $this->processOptions($options);
+ $this->menu = $this->options['menu'];
+ $this->btHelper = $btHelper;
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->options['dropdown-class'] = $this->convertToArrayIfNeeded($this->options['dropdown-class']);
+ $this->checkOptionValidity();
+ }
+
+ public function dropdownMenu(): string
+ {
+ return $this->fullDropdown();
+ }
+
+ public function fullDropdown(): string
+ {
+ return $this->genDropdownWrapper($this->genDropdownToggleButton(), $this->genDropdownMenu($this->menu));
+ }
+
+ public function genDropdownWrapper(string $toggle = '', string $menu = '', $direction = null, $classes = null): string
+ {
+ $classes = !is_null($classes) ? $classes : $this->options['dropdown-class'];
+ $direction = !is_null($direction) ? $direction : $this->options['direction'];
+ $content = $toggle . $menu;
+ $html = $this->node('div', array_merge(
+ $this->options['attrs'],
+ [
+ 'class' => array_merge(
+ $classes,
+ [
+ 'dropdown',
+ "drop{$direction}"
+ ]
+ )
+ ]
+ ), $content);
+ return $html;
+ }
+
+ public function genDropdownToggleButton(): string
+ {
+ $defaultOptions = [
+ 'class' => ['dropdown-toggle'],
+ 'attrs' => [
+ 'data-bs-toggle' => 'dropdown',
+ 'aria-expanded' => 'false',
+ ]
+ ];
+ $options = array_merge_recursive($this->options['button'], $defaultOptions);
+ return $this->btHelper->button($options);
+ }
+
+ private function genDropdownMenu(array $entries, $alignment = null): string
+ {
+ $alignment = !is_null($alignment) ? $alignment : $this->options['alignment'];
+ $html = $this->node('div', [
+ 'class' => ['dropdown-menu', "dropdown-menu-{$alignment}"],
+ ], $this->genEntries($entries));
+ return $html;
+ }
+
+ private function genEntries(array $entries): string
+ {
+ $html = '';
+ foreach ($entries as $entry) {
+ $link = $this->genEntry($entry);
+ if (!empty($entry['menu'])) {
+ $html .= $this->genDropdownWrapper($link, $this->genDropdownMenu($entry['menu']), $this->options['submenu_direction'], $this->options['submenu_classes']);
+ } else {
+ $html .= $link;
+ }
+ }
+ return $html;
+ }
+
+ private function genEntry(array $entry): string
+ {
+ if (!empty($entry['html'])) {
+ return $entry['html'];
+ }
+ $classes = [];
+ $icon = '';
+ if (!empty($entry['icon'])) {
+ $icon = $this->btHelper->icon($entry['icon'], ['class' => 'me-2']);
+ }
+ $badge = '';
+ if (!empty($entry['badge'])) {
+ $bsBadge = new BootstrapBadge(array_merge(
+ ['class' => ['ms-auto']],
+ $entry['badge']
+ ));
+ $badge = $bsBadge->badge();
+ }
+
+ if (!empty($entry['header'])) {
+ return $this->node('h6', [
+ 'class' => ['dropdown-header',],
+ ], $icon . h($entry['text']) . $badge);
+ }
+
+ $classes = ['dropdown-item'];
+ if (!empty($entry['class'])) {
+ if (!is_array($entry['class'])) {
+ $entry['class'] = [$entry['class']];
+ }
+ $classes = array_merge($classes, $entry['class']);
+ }
+ $params = $entry['attrs'] ?? [];
+ $params['href'] = '#';
+
+ if (!empty($entry['menu'])) {
+ $classes[] = 'dropdown-toggle';
+ $classes[] = 'd-flex align-items-center';
+ $params['data-bs-toggle'] = 'dropdown';
+ $params['aria-haspopup'] = 'true';
+ $params['aria-expanded'] = 'false';
+ if (!empty($entry['keepOpen'])) {
+ $classes[] = 'open-form';
+ }
+ $params['data-open-form-id'] = mt_rand();
+ }
+
+ $labelContent = sprintf(
+ '%s%s',
+ h($entry['text']),
+ !empty($entry['sup']) ? $this->node('sup', ['class' => 'ms-1 text-muted'], $entry['sup']) : ''
+ );
+ $label = $this->node('span', ['class' => 'mx-1'], $labelContent);
+ $content = $icon . $label . $badge;
+
+ return $this->node('a', array_merge([
+ 'class' => $classes,
+ ], $params), $content);
+ }
+}
diff --git a/src/View/Helper/BootstrapElements/BootstrapIcon.php b/src/View/Helper/BootstrapElements/BootstrapIcon.php
new file mode 100644
index 0000000..71c0c8a
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapIcon.php
@@ -0,0 +1,61 @@
+Bootstrap->icon('eye-slash', [
+ * 'class' => 'm-3',
+ * ]);
+ */
+class BootstrapIcon extends BootstrapGeneric
+{
+ private $icon = '';
+ private $defaultOptions = [
+ 'class' => [],
+ 'title' => '',
+ 'attrs' => [],
+ ];
+
+ function __construct(string $icon, array $options = [])
+ {
+ $this->icon = $icon;
+ $this->processOptions($options);
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->checkOptionValidity();
+ $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']);
+ }
+
+ public function icon(): string
+ {
+ return $this->genIcon();
+ }
+
+ private function genIcon(): string
+ {
+ $html = $this->node('span', array_merge(
+ [
+ 'class' => array_merge(
+ $this->options['class'],
+ ["fa fa-{$this->icon}"]
+ ),
+ 'title' => h($this->options['title'])
+ ],
+ $this->options['attrs']
+ ));
+ return $html;
+ }
+}
\ No newline at end of file
diff --git a/src/View/Helper/BootstrapElements/BootstrapListGroup.php b/src/View/Helper/BootstrapElements/BootstrapListGroup.php
new file mode 100644
index 0000000..5804a3f
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapListGroup.php
@@ -0,0 +1,119 @@
+Bootstrap->listGroup(
+ * [
+ * [
+ * 'text' => 'test',
+ * 'badge' => [
+ * 'text' => 'test',
+ * 'variant' => 'warning'
+ * ],
+ * 'attrs' => [
+ * 'data-test' => 'tes'
+ * ]
+ * ],
+ * [
+ * 'html' => 'test2',
+ * ],
+ * ],
+ * [
+ * 'class' => 'container-class'
+ * ]
+ * );
+ */
+class BootstrapListGroup extends BootstrapGeneric
+{
+ private $defaultOptions = [
+ 'class' => [],
+ 'attrs' => [],
+ ];
+
+ private $defaultItemOptions = [
+ 'href' => '#',
+ 'text' => '',
+ 'html' => null,
+ 'badge' => '',
+ 'class' => [],
+ 'attrs' => [],
+ ];
+
+ private static $defaultClasses = ['list-group',];
+ private static $defaultItemClasses = ['list-group-item', 'list-group-item-action', 'd-flex', 'align-items-start', 'justify-content-between'];
+
+ function __construct(array $items, array $options, \App\View\BootstrapHelper $btHelper)
+ {
+ $this->items = $items;
+ $this->processOptions($options);
+ $this->btHelper = $btHelper;
+ }
+
+ private function processOptions(array $options): void
+ {
+ $this->options = array_merge($this->defaultOptions, $options);
+ $this->options['class'] = $this->convertToArrayIfNeeded($this->options['class']);
+ }
+
+ public function listGroup()
+ {
+ return $this->genListGroup();
+ }
+
+ private function genListGroup()
+ {
+ $html = $this->nodeOpen('div', array_merge([
+ 'class' => array_merge(self::$defaultClasses, $this->options['class']),
+ ], $this->options['attrs']));
+ foreach ($this->items as $item) {
+ $html .= $this->genItem($item);
+ }
+ $html .= $this->nodeClose('div');
+ return $html;
+ }
+
+ private function genItem(array $item): string
+ {
+ $item['class'] = !is_array($item['class']) ? [$item['class']] : $item['class'];
+ $itemOptions = array_merge($this->defaultItemOptions, $item);
+ $itemOptions['class'] = array_merge(self::$defaultItemClasses, $itemOptions['class']);
+
+ $html = $this->node('a',
+ array_merge([
+ 'class' => array_merge(self::$defaultItemClasses, $itemOptions['class']),
+ 'href' => '#',
+ ], $itemOptions['attrs']),
+ [
+ !is_null($itemOptions['html']) ? $this->node('div', ['class' => 'w-100'], $itemOptions['html']) : h($itemOptions['text']),
+ $this->genBadge($itemOptions['badge'])
+ ],
+ );
+ return $html;
+ }
+
+ private function genBadge(array $badge): string
+ {
+ if (empty($badge)) {
+ return '';
+ }
+ return $this->btHelper->badge($badge);
+ }
+}
\ No newline at end of file
diff --git a/src/View/Helper/BootstrapElements/BootstrapListTable.php b/src/View/Helper/BootstrapElements/BootstrapListTable.php
new file mode 100644
index 0000000..a2ba806
--- /dev/null
+++ b/src/View/Helper/BootstrapElements/BootstrapListTable.php
@@ -0,0 +1,223 @@
+ or array