diff --git a/.gitignore b/.gitignore index ce8fde9..4c169ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ logs tmp vendor webroot/theme/node_modules +webroot/scss/*.css .vscode docker/run/ .phpunit.result.cache diff --git a/composer.json b/composer.json index 6d48735..1b826a2 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,10 @@ "cakephp/bake": "^2.0.3", "cakephp/cakephp-codesniffer": "~4.0.0", "cakephp/debug_kit": "^4.0", + "cebe/php-openapi": "^1.6", "fzaninotto/faker": "^1.9", "josegonzalez/dotenv": "^3.2", - "league/openapi-psr7-validator": "^0.16.4", + "league/openapi-psr7-validator": "^0.17", "phpunit/phpunit": "^8.5", "psy/psysh": "@stable", "wiremock-php/wiremock-php": "^2.32" @@ -58,6 +59,11 @@ "nohup sh ./tests/Helper/wiremock/start.sh >/dev/null 2>&1 &", "phpunit", "sh ./tests/Helper/wiremock/stop.sh" + ], + "migrate": [ + "./bin/cake migrations migrate", + "./bin/cake migrations migrate -p tags", + "./bin/cake migrations migrate -p ADmad/SocialAuth" ] }, "prefer-stable": true, diff --git a/config/Migrations/20211025100313_MailingLists.php b/config/Migrations/20211025100313_MailingLists.php new file mode 100644 index 0000000..cabc846 --- /dev/null +++ b/config/Migrations/20211025100313_MailingLists.php @@ -0,0 +1,154 @@ +table('mailing_lists', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci', + ]); + $mailinglists + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('uuid', 'uuid', [ + 'default' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => null, + 'null' => false, + 'limit' => 191, + 'comment' => 'The name of the mailing list', + ]) + ->addColumn('recipients', 'string', [ + 'default' => null, + 'null' => true, + 'limit' => 191, + 'comment' => 'Human-readable description of who the intended recipients.', + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'null' => true, + 'comment' => 'Additional description of the mailing list' + ]) + ->addColumn('user_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('active', 'boolean', [ + 'default' => 0, + 'null' => false, + ]) + ->addColumn('deleted', 'boolean', [ + 'default' => 0, + 'null' => false, + ]) + ->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]) + ->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + + + $mailinglists->addForeignKey('user_id', 'users', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']); + + $mailinglists->addIndex(['uuid'], ['unique' => true]) + ->addIndex('name') + ->addIndex('recipients') + ->addIndex('user_id') + ->addIndex('active') + ->addIndex('deleted') + ->addIndex('created') + ->addIndex('modified'); + + $mailinglists->create(); + + + $mailinglists_individuals = $this->table('mailing_lists_individuals', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $mailinglists_individuals + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('mailing_list_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('individual_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('include_primary_email', 'boolean', [ + 'default' => 1, + 'null' => false, + 'comment' => 'Should the primary email address by included in the mailing list' + ]) + ->addForeignKey('mailing_list_id', 'mailing_lists', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addForeignKey('individual_id', 'individuals', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']); + + $mailinglists_individuals->addIndex(['mailing_list_id', 'individual_id'], ['unique' => true]); + + $mailinglists_individuals->create(); + + + $mailinglists_metafields = $this->table('mailing_lists_meta_fields', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $mailinglists_metafields + ->addColumn('mailing_list_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('meta_field_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addPrimaryKey(['mailing_list_id', 'meta_field_id']) + ->addForeignKey('mailing_list_id', 'mailing_lists', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE']) + ->addForeignKey('meta_field_id', 'meta_fields', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE']); + + $mailinglists_metafields->create(); + } +} + diff --git a/config/Migrations/20211104072514_MoreMetaFieldColumns.php b/config/Migrations/20211104072514_MoreMetaFieldColumns.php new file mode 100644 index 0000000..336c273 --- /dev/null +++ b/config/Migrations/20211104072514_MoreMetaFieldColumns.php @@ -0,0 +1,44 @@ +table('meta_fields'); + + $metaFieldsTable + ->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]) + ->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => false, + ]) + ->update(); + + $metaFieldsTable + ->addIndex('created') + ->addIndex('modified'); + + $metaTemplateFieldsTable = $this->table('meta_template_fields') + ->addColumn('counter', 'integer', [ + 'default' => 0, + 'length' => 11, + 'null' => false, + 'signed' => false, + 'comment' => 'Field used by the CounterCache behaviour to count the occurence of meta_template_fields' + ]) + ->addForeignKey('meta_template_id', 'meta_templates', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->update(); + + $metaTemplate = $this->table('meta_templates') + ->removeIndex(['uuid']) + ->addIndex(['uuid', 'version']) + ->update(); + } +} \ No newline at end of file diff --git a/libraries/default/InboxProcessors/LocalToolInboxProcessor.php b/libraries/default/InboxProcessors/LocalToolInboxProcessor.php index 8852f9e..2dacab8 100644 --- a/libraries/default/InboxProcessors/LocalToolInboxProcessor.php +++ b/libraries/default/InboxProcessors/LocalToolInboxProcessor.php @@ -159,9 +159,9 @@ class LocalToolInboxProcessor extends GenericInboxProcessor { return $validator ->requirePresence('connectorName') - ->notEmpty('connectorName', 'The connector name must be provided') + ->notEmptyString('connectorName', 'The connector name must be provided') ->requirePresence('cerebrateURL') - ->notEmpty('cerebrateURL', 'A url must be provided') + ->notEmptyString('cerebrateURL', 'A url must be provided') ->requirePresence('local_tool_id') ->numeric('local_tool_id', 'A local_tool_id must be provided') ->requirePresence('remote_tool_id') diff --git a/libraries/default/InboxProcessors/UserInboxProcessor.php b/libraries/default/InboxProcessors/UserInboxProcessor.php index 53312d5..abb9550 100644 --- a/libraries/default/InboxProcessors/UserInboxProcessor.php +++ b/libraries/default/InboxProcessors/UserInboxProcessor.php @@ -37,13 +37,13 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr protected function addValidatorRules($validator) { return $validator - ->notEmpty('username', 'A username must be provided.') + ->notEmptyString('username', 'A username must be provided.') ->add('email', 'validFormat', [ 'rule' => 'email', 'message' => 'E-mail must be valid' ]) - ->notEmpty('first_name', 'A first name must be provided') - ->notEmpty('last_name', 'A last name must be provided') + ->notEmptyString('first_name', 'A first name must be provided') + ->notEmptyString('last_name', 'A last name must be provided') ->add('password', 'password_complexity', [ 'rule' => function($value, $context) { if (!preg_match('/^((?=.*\d)|(?=.*\W+))(?![\n])(?=.*[A-Z])(?=.*[a-z]).*$|.{16,}/s', $value) || strlen($value) < 12) { diff --git a/libraries/default/meta_fields/cerebrate_csirt_constituency.json b/libraries/default/meta_fields/cerebrate_csirt_constituency.json new file mode 100644 index 0000000..317e939 --- /dev/null +++ b/libraries/default/meta_fields/cerebrate_csirt_constituency.json @@ -0,0 +1,46 @@ +{ + "name": "CSIRT Constituency", + "namespace": "cerebrate", + "description": "Template meant to collect data about the constituency of a CSIRT", + "version": 1, + "scope": "organisation", + "uuid": "faca6acc-23e0-4585-8fd8-4379e3a6250d", + "source": "Cerebrate", + "metaFields": [ + { + "field": "IPv4 address", + "type": "ipv4", + "multiple": true + }, + { + "field": "IPv6 address", + "type": "ipv6", + "multiple": true + }, + { + "field": "AS Number", + "type": "text", + "multiple": true + }, + { + "field": "Domain", + "type": "text", + "multiple": true + }, + { + "field": "Country", + "type": "text", + "multiple": true + }, + { + "field": "Country Code", + "type": "text", + "multiple": true + }, + { + "field": "Sector", + "type": "text", + "multiple": true + } + ] +} diff --git a/libraries/default/meta_fields/cerebrate_individual_extended.json b/libraries/default/meta_fields/cerebrate_individual_extended.json new file mode 100644 index 0000000..e3999c1 --- /dev/null +++ b/libraries/default/meta_fields/cerebrate_individual_extended.json @@ -0,0 +1,21 @@ +{ + "name": "Cerebrate Individuals extended", + "namespace": "cerebrate", + "description": "Template to extend fields of individuals", + "version": 2, + "scope": "individual", + "uuid": "3bc374c8-3cdd-4900-823e-cc9100ad5179", + "source": "Cerebrate", + "metaFields": [ + { + "field": "alternate_email", + "type": "text", + "multiple": true + }, + { + "field": "mobile_phone", + "type": "text", + "multiple": true + } + ] +} diff --git a/libraries/default/meta_fields/enisa-csirt-inventory.json b/libraries/default/meta_fields/enisa-csirt-inventory.json index 9171a5c..cb89aa9 100644 --- a/libraries/default/meta_fields/enisa-csirt-inventory.json +++ b/libraries/default/meta_fields/enisa-csirt-inventory.json @@ -9,7 +9,7 @@ { "field": "website", "type": "text", - "regex": "(http(s)?:\\\/\\\/.)?(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&\/\/=]*)" + "regex": "https?:\\\/\\\/.+" }, { "field": "enisa-geo-group", @@ -50,7 +50,7 @@ { "field": "email", "type": "text", - "regex": "(?:[a-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])" + "regex": "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+" }, { "field": "country-name", @@ -70,5 +70,5 @@ "scope": "organisation", "source": "enisa.europa.eu/topics/csirts-in-europe/csirt-inventory/certs-by-country-interactive-map", "uuid": "089c68c7-d97e-4f21-a798-159cd10f7864", - "version": 1 -} \ No newline at end of file + "version": 2 +} diff --git a/libraries/default/meta_fields/it_infrastructure_and_services.json b/libraries/default/meta_fields/it_infrastructure_and_services.json new file mode 100644 index 0000000..56e5867 --- /dev/null +++ b/libraries/default/meta_fields/it_infrastructure_and_services.json @@ -0,0 +1,36 @@ +{ + "name": "IT infrastructure and services", + "namespace": "cerebrate", + "description": "Offers the possiblity to register the IP of part of the infrastructure or services.", + "version": 2, + "scope": "organisation", + "uuid": "a7674718-57c8-40e7-969e-d26ca911cb4a", + "source": "Cerebrate", + "metaFields": [ + { + "field": "Microsoft Exchange Server IP", + "type": "text", + "multiple": true + }, + { + "field": "Microsfot Office 365 IP", + "type": "text", + "multiple": true + }, + { + "field": "Microsoft SharePoint IP", + "type": "text", + "multiple": true + }, + { + "field": "Microsoft Active Directory IP", + "type": "text", + "multiple": true + }, + { + "field": "Proxy IP", + "type": "text", + "multiple": true + } + ] +} diff --git a/plugins/Tags/src/View/Helper/TagHelper.php b/plugins/Tags/src/View/Helper/TagHelper.php index ab9122e..71b48d7 100644 --- a/plugins/Tags/src/View/Helper/TagHelper.php +++ b/plugins/Tags/src/View/Helper/TagHelper.php @@ -33,7 +33,7 @@ class TagHelper extends Helper 'data-text-colour' => h($tag['text_colour']), ]; }, $options['allTags']) : []; - $classes = ['tag-input', 'flex-grow-1']; + $classes = ['select2-input', 'flex-grow-1']; $url = ''; if (!empty($this->getConfig('editable'))) { $url = $this->Url->build([ diff --git a/plugins/Tags/templates/Tags/add.php b/plugins/Tags/templates/Tags/add.php index 89eb5e9..c8ecfe8 100644 --- a/plugins/Tags/templates/Tags/add.php +++ b/plugins/Tags/templates/Tags/add.php @@ -12,7 +12,6 @@ 'type' => 'color', ), ), - 'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates, 'submit' => array( 'action' => $this->request->getParam('action') ) diff --git a/plugins/Tags/templates/Tags/index.php b/plugins/Tags/templates/Tags/index.php index 4f1a041..5f2fded 100644 --- a/plugins/Tags/templates/Tags/index.php +++ b/plugins/Tags/templates/Tags/index.php @@ -20,7 +20,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/plugins/Tags/webroot/js/tagging.js b/plugins/Tags/webroot/js/tagging.js index bcd9c3d..4f32b46 100644 --- a/plugins/Tags/webroot/js/tagging.js +++ b/plugins/Tags/webroot/js/tagging.js @@ -23,7 +23,7 @@ function createTagPicker(clicked) { const $clicked = $(clicked) const $container = $clicked.closest('.tag-container') - const $select = $container.parent().find('select.tag-input').removeClass('d-none') + const $select = $container.parent().find('select.select2-input').removeClass('d-none') closePicker($select, $container) const $pickerContainer = $('
').addClass(['picker-container', 'd-flex']) @@ -90,7 +90,7 @@ function refreshTagList(apiResult, $container) { } function initSelect2Pickers() { - $('select.tag-input').each(function() { + $('select.select2-input').each(function() { if (!$(this).hasClass("select2-hidden-accessible")) { initSelect2Picker($(this)) } diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 2438735..dc5b81f 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -79,6 +79,9 @@ class AppController extends Controller $this->loadComponent('Navigation', [ 'request' => $this->request, ]); + $this->loadComponent('Notification', [ + 'request' => $this->request, + ]); if (Configure::read('debug')) { Configure::write('DebugKit.panels', ['DebugKit.Packages' => true]); Configure::write('DebugKit.forceEnable', true); @@ -112,7 +115,6 @@ class AppController extends Controller } unset($user['password']); $this->ACL->setUser($user); - $this->Navigation->genBreadcrumbs($user); $this->request->getSession()->write('authUser', $user); $this->isAdmin = $user['role']['perm_admin']; if (!$this->ParamHandler->isRest()) { @@ -135,7 +137,6 @@ class AppController extends Controller $this->ACL->checkAccess(); if (!$this->ParamHandler->isRest()) { - $this->set('breadcrumb', $this->Navigation->getBreadcrumb()); $this->set('ajax', $this->request->is('ajax')); $this->request->getParam('prefix'); $this->set('baseurl', Configure::read('App.fullBaseUrl')); @@ -154,6 +155,16 @@ class AppController extends Controller } } + public function beforeRender(EventInterface $event) + { + if (!empty($this->request->getAttribute('identity'))) { + if (!$this->ParamHandler->isRest()) { + $this->set('breadcrumb', $this->Navigation->getBreadcrumb()); + $this->set('notifications', $this->Notification->getNotifications()); + } + } + } + private function authApiUser(): void { if (!empty($_SERVER['HTTP_AUTHORIZATION']) && strlen($_SERVER['HTTP_AUTHORIZATION'])) { diff --git a/src/Controller/AuditLogsController.php b/src/Controller/AuditLogsController.php index 27bee73..e72327d 100644 --- a/src/Controller/AuditLogsController.php +++ b/src/Controller/AuditLogsController.php @@ -11,8 +11,8 @@ use Cake\Core\Configure; class AuditLogsController extends AppController { - public $filterFields = ['model_id', 'model', 'request_action', 'user_id', 'title']; - public $quickFilterFields = ['model', 'request_action', 'title']; + public $filterFields = ['model_id', 'model', 'request_action', 'user_id', 'model_title']; + public $quickFilterFields = ['model', 'request_action', 'model_title']; public $containFields = ['Users']; public function index() @@ -31,6 +31,11 @@ class AuditLogsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Administration'); } + + public function filtering() + { + $this->CRUD->filtering(); + } + } diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 57180c2..f296030 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -47,7 +47,8 @@ class ACLComponent extends Component 'view' => ['*'] ], 'AuditLogs' => [ - 'index' => ['perm_admin'] + 'filtering' => ['perm_admin'], + 'index' => ['perm_admin'], ], 'AuthKeys' => [ 'add' => ['*'], @@ -119,16 +120,31 @@ class ACLComponent extends Component 'view' => ['perm_admin'], 'viewConnector' => ['perm_admin'] ], + 'MailingLists' => [ + "add" => ['perm_org_admin'], + "addIndividual" => ['perm_org_admin'], + "delete" => ['perm_org_admin'], + "edit" => ['perm_org_admin'], + "index" => ['*'], + "listIndividuals" => ['perm_org_admin'], + "removeIndividual" => ['perm_org_admin'], + "view" => ['*'], + ], 'MetaTemplateFields' => [ 'index' => ['perm_admin'] ], 'MetaTemplates' => [ + 'createNewTemplate' => ['perm_admin'], + 'delete' => ['perm_admin'], 'disable' => ['perm_admin'], 'enable' => ['perm_admin'], + 'getMetaFieldsToUpdate' => ['perm_admin'], 'index' => ['perm_admin'], + 'migrateOldMetaTemplateToNewestVersionForEntity' => ['perm_admin'], 'update' => ['perm_admin'], + 'updateAllTemplates' => ['perm_admin'], 'toggle' => ['perm_admin'], - 'view' => ['perm_admin'] + 'view' => ['perm_admin'], ], 'Organisations' => [ 'add' => ['perm_admin'], @@ -559,7 +575,6 @@ class ACLComponent extends Component continue; } } - $menu[$group][$subMenuElementName]['children'] = array_values($menu[$group][$subMenuElementName]['children']); if (empty($menu[$group][$subMenuElementName]['children'])) { unset($subMenu[$subMenuElementName]); } diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 41fe9d1..a09040c 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -6,10 +6,14 @@ use Cake\Controller\Component; use Cake\Error\Debugger; use Cake\Utility\Hash; use Cake\Utility\Inflector; +use Cake\Utility\Text; use Cake\View\ViewBuilder; use Cake\ORM\TableRegistry; +use Cake\Routing\Router; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Http\Exception\NotFoundException; +use Cake\Collection\Collection; +use App\Utility\UI\IndexSetting; class CRUDComponent extends Component { @@ -33,6 +37,8 @@ class CRUDComponent extends Component $options['filters'] = []; } $options['filters'][] = 'quickFilter'; + } else { + $options['quickFilters'] = []; } $options['filters'][] = 'filteringLabel'; if ($this->taggingSupported()) { @@ -44,12 +50,13 @@ class CRUDComponent extends Component $optionFilters[] = "{$filter} !="; } $params = $this->Controller->ParamHandler->harvestParams($optionFilters); + $params = $this->fakeContextFilter($options, $params); $query = $this->Table->find(); if (!empty($options['filterFunction'])) { $query = $options['filterFunction']($query); } $query = $this->setFilters($params, $query, $options); - $query = $this->setQuickFilters($params, $query, empty($options['quickFilters']) ? [] : $options['quickFilters']); + $query = $this->setQuickFilters($params, $query, $options); if (!empty($options['conditions'])) { $query->where($options['conditions']); } @@ -73,37 +80,85 @@ class CRUDComponent extends Component } if (isset($options['afterFind'])) { $function = $options['afterFind']; - if (is_callable($options['afterFind'])) { - $function = $options['afterFind']; - $data->each(function($value, $key) use ($function) { + if (is_callable($function)) { + $data = $data->map(function($value, $key) use ($function) { return $function($value); + })->filter(function ($value) { + return $value !== false; }); } else { $t = $this->Table; - $data->each(function($value, $key) use ($t, $function) { + $data = $data->map(function($value, $key) use ($t, $function) { return $t->$function($value); + })->filter(function ($value) { + return $value !== false; }); } } $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); } else { + if ($this->metaFieldsSupported()) { + $query = $this->includeRequestedMetaFields($query); + } $this->Controller->loadComponent('Paginator'); $data = $this->Controller->Paginator->paginate($query, $this->Controller->paginate ?? []); if (isset($options['afterFind'])) { $function = $options['afterFind']; - if (is_callable($options['afterFind'])) { - $function = $options['afterFind']; - $data->each(function($value, $key) use ($function) { + if (is_callable($function)) { + $data = $data->map(function($value, $key) use ($function) { return $function($value); + })->filter(function($value) { + return $value !== false; }); } else { $t = $this->Table; - $data->each(function($value, $key) use ($t, $function) { + $data = $data->map(function($value, $key) use ($t, $function) { return $t->$function($value); + })->filter(function ($value) { + return $value !== false; }); } } $this->setFilteringContext($options['contextFilters'] ?? [], $params); + if ($this->metaFieldsSupported()) { + $data = $data->toArray(); + $metaTemplates = $this->getMetaTemplates()->toArray(); + foreach ($data as $i => $row) { + $data[$i] = $this->attachMetaTemplatesIfNeeded($row, $metaTemplates); + } + $this->Controller->set('meta_templates', $metaTemplates); + } + if (true) { // check if stats are requested + $modelStatistics = []; + if ($this->Table->hasBehavior('Timestamp')) { + $modelStatistics = $this->Table->getActivityStatisticsForModel( + $this->Table, + !is_numeric($this->request->getQuery('statistics_days')) ? 7 : $this->request->getQuery('statistics_days') + ); + } + if (!empty($options['statisticsFields'])) { + $statIncludeRemaining = $this->request->getQuery('statistics_include_remainging', true); + if (is_string($statIncludeRemaining)) { + $statIncludeRemaining = $statIncludeRemaining == 'true' ? true : false; + } + $statIgnoreNull = $this->request->getQuery('statistics_ignore_null', true); + if (is_string($statIgnoreNull)) { + $statIgnoreNull = $statIgnoreNull == 'true' ? true : false; + } + $statsOptions = [ + 'limit' => !is_numeric($this->request->getQuery('statistics_entry_amount')) ? 5 : $this->request->getQuery('statistics_entry_amount'), + 'includeOthers' => $statIncludeRemaining, + 'ignoreNull' => $statIgnoreNull, + ]; + $modelStatistics['usage'] = $this->Table->getStatisticsUsageForModel( + $this->Table, + $options['statisticsFields'], + $statsOptions + ); + } + $this->Controller->set('modelStatistics', $modelStatistics); + } + $this->Controller->set('model', $this->Table); $this->Controller->set('data', $data); } } @@ -113,8 +168,23 @@ class CRUDComponent extends Component if ($this->taggingSupported()) { $this->Controller->set('taggingEnabled', true); $this->setAllTags(); + } else { + $this->Controller->set('taggingEnabled', false); + } + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates()->toArray(); + $this->Controller->set('metaFieldsEnabled', true); + $this->Controller->set('metaTemplates', $metaTemplates); + } else { + $this->Controller->set('metaFieldsEnabled', false); } $filters = !empty($this->Controller->filterFields) ? $this->Controller->filterFields : []; + $typeHandlers = $this->Table->getBehavior('MetaFields')->getTypeHandlers(); + $typeHandlersOperators = []; + foreach ($typeHandlers as $type => $handler) { + $typeHandlersOperators[$type] = $handler::OPERATORS; + } + $this->Controller->set('typeHandlersOperators', $typeHandlersOperators); $this->Controller->set('filters', $filters); $this->Controller->viewBuilder()->setLayout('ajax'); $this->Controller->render('/genericTemplates/filters'); @@ -135,28 +205,40 @@ class CRUDComponent extends Component return false; } - private function getMetaTemplates() + private function getMetaTemplates(array $metaTemplateConditions=[]) { $metaTemplates = []; - if (!empty($this->Table->metaFields)) { - $metaQuery = $this->MetaTemplates->find(); - $metaQuery - ->order(['is_default' => 'DESC']) - ->where([ - 'scope' => $this->Table->metaFields, - 'enabled' => 1 - ]); - $metaQuery->contain(['MetaTemplateFields']); - $metaTemplates = $metaQuery->all(); + if (!$this->metaFieldsSupported()) { + throw new \Exception(__("Table {$this->TableAlias} does not support meta_fields")); } - $this->Controller->set('metaTemplates', $metaTemplates); - return true; + $metaFieldsBehavior = $this->Table->getBehavior('MetaFields'); + $metaQuery = $this->MetaTemplates->find(); + $metaQuery + ->order(['is_default' => 'DESC']) + ->where(array_merge( + $metaTemplateConditions, + ['scope' => $metaFieldsBehavior->getScope(), ] + )) + ->contain('MetaTemplateFields') + ->formatResults(function (\Cake\Collection\CollectionInterface $metaTemplates) { // Set meta-template && meta-template-fields indexed by their ID + return $metaTemplates + ->map(function ($metaTemplate) { + $metaTemplate->meta_template_fields = Hash::combine($metaTemplate->meta_template_fields, '{n}.id', '{n}'); + return $metaTemplate; + }) + ->indexBy('id'); + }); + $metaTemplates = $metaQuery->all(); + return $metaTemplates; } public function add(array $params = []): void { - $this->getMetaTemplates(); $data = $this->Table->newEmptyEntity(); + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates(); + $data = $this->attachMetaTemplatesIfNeeded($data, $metaTemplates->toArray()); + } if ($this->request->is('post')) { $patchEntityParams = [ 'associated' => [], @@ -175,6 +257,11 @@ class CRUDComponent extends Component throw new NotFoundException(__('Could not save {0} due to the marshaling failing. Your input is bad and you should feel bad.', $this->ObjectAlias)); } } + if ($this->metaFieldsSupported()) { + $massagedData = $this->massageMetaFields($data, $input, $metaTemplates); + unset($input['MetaTemplates']); // Avoid MetaTemplates to be overriden when patching entity + $data = $massagedData['entity']; + } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); @@ -188,9 +275,6 @@ class CRUDComponent extends Component $params['afterSave']($data); } $message = __('{0} added.', $this->ObjectAlias); - if (!empty($input['metaFields'])) { - $this->saveMetaFields($data->id, $input); - } if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($savedData, 'json'); } else if ($this->Controller->ParamHandler->isAjax()) { @@ -215,7 +299,7 @@ class CRUDComponent extends Component $message = __( '{0} could not be added.{1}', $this->ObjectAlias, - empty($validationMessage) ? '' : PHP_EOL . __('Reason:{0}', $validationMessage) + empty($validationMessage) ? '' : PHP_EOL . __('Reason: {0}', $validationMessage) ); if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($message, 'json'); @@ -255,9 +339,20 @@ class CRUDComponent extends Component foreach ($data->getErrors() as $field => $errorData) { $errorMessages = []; foreach ($errorData as $key => $value) { - $errorMessages[] = $value; + if (is_array($value)) { + $extracted = Hash::extract($value, "{s}.{s}"); + if (!empty($extracted)) { + $errorMessages[] = implode('& ', $extracted); + } + } else { + if (!empty($value)) { + $errorMessages[] = $value; + } + } + } + if (!empty($errorMessages)) { + $validationMessage .= __('{0}: {1}', $field, implode(',', $errorMessages)); } - $validationMessage .= __('{0}: {1}', $field, implode(',', $errorMessages)); } } return $validationMessage; @@ -268,6 +363,93 @@ class CRUDComponent extends Component $this->Table->saveMetaFields($id, $input, $this->Table); } + // prune empty values and marshall fields + public function massageMetaFields($entity, $input, $allMetaTemplates=[]) + { + if (empty($input['MetaTemplates']) || !$this->metaFieldsSupported()) { + return ['entity' => $entity, 'metafields_to_delete' => []]; + } + + $metaFieldsTable = TableRegistry::getTableLocator()->get('MetaFields'); + $metaFieldsIndex = []; + if (empty($metaTemplates)) { + $allMetaTemplates = $this->getMetaTemplates()->toArray(); + } + if (!empty($entity->meta_fields)) { + foreach ($entity->meta_fields as $i => $metaField) { + $metaFieldsIndex[$metaField->id] = $i; + } + } else { + $entity->meta_fields = []; + } + + $metaFieldsToDelete = []; + foreach ($input['MetaTemplates'] as $template_id => $template) { + foreach ($template['meta_template_fields'] as $meta_template_field_id => $meta_template_field) { + $rawMetaTemplateField = $allMetaTemplates[$template_id]['meta_template_fields'][$meta_template_field_id]; + foreach ($meta_template_field['metaFields'] as $meta_field_id => $meta_field) { + if ($meta_field_id == 'new') { // create new meta_field + $new_meta_fields = $meta_field; + foreach ($new_meta_fields as $new_value) { + if (!empty($new_value)) { + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $this->Table->getBehavior('MetaFields')->getScope(), + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } + } else { + $new_value = $meta_field['value']; + if (!empty($new_value)) { // update meta_field and attach validation errors + if (isset($metaFieldsIndex[$meta_field_id])) { + $index = $metaFieldsIndex[$meta_field_id]; + if ($entity->meta_fields[$index]->value != $new_value) { // nothing to do, value hasn't changed + $metaFieldsTable->patchEntity($entity->meta_fields[$index], [ + 'value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id + ], ['value']); + $metaFieldsTable->patchEntity( + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id], + ['value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id], + ['value'] + ); + } + } else { // metafield comes from a second post where the temporary entity has already been created + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $this->Table->getBehavior('MetaFields')->getScope(), // get scope from behavior + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } else { // Metafield value is empty, indicating the field should be removed + $index = $metaFieldsIndex[$meta_field_id]; + $metaFieldsToDelete[] = $entity->meta_fields[$index]; + unset($entity->meta_fields[$index]); + unset($entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id]); + } + } + } + } + } + + $entity->setDirty('meta_fields', true); + return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete]; + } + private function __massageInput($params) { $input = $this->request->getData(); @@ -291,23 +473,38 @@ class CRUDComponent extends Component if (empty($id)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - $this->getMetaTemplates(); if ($this->taggingSupported()) { $params['contain'][] = 'Tags'; $this->setAllTags(); } - $data = $this->Table->find()->where(['id' => $id]); - if (!empty($params['conditions'])) { - $data->where($params['conditions']); + if ($this->metaFieldsSupported()) { + if (empty($params['contain'])) { + $params['contain'] = []; + } + if (is_array($params['contain'])) { + $params['contain'][] = 'MetaFields'; + } else { + $params['contain'] = [$params['contain'], 'MetaFields']; + } } - $data = $data->first(); + $query = $this->Table->find()->where(['id' => $id]); + if (!empty($params['contain'])) { + $query->contain($params['contain']); + } + if (!empty($params['conditions'])) { + $query->where($params['conditions']); + } + $data = $query->first(); if (isset($params['afterFind'])) { $data = $params['afterFind']($data, $params); } if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - $data = $this->getMetaFields($id, $data); + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates(); + $data = $this->attachMetaTemplatesIfNeeded($data, $metaTemplates->toArray()); + } if ($this->request->is(['post', 'put'])) { $patchEntityParams = [ 'associated' => [] @@ -322,6 +519,12 @@ class CRUDComponent extends Component throw new NotFoundException(__('Could not save {0} due to the marshaling failing. Your input is bad and you should feel bad.', $this->ObjectAlias)); } } + if ($this->metaFieldsSupported()) { + $massagedData = $this->massageMetaFields($data, $input, $metaTemplates); + unset($input['MetaTemplates']); // Avoid MetaTemplates to be overriden when patching entity + $data = $massagedData['entity']; + $metaFieldsToDelete = $massagedData['metafields_to_delete']; + } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); @@ -331,14 +534,13 @@ class CRUDComponent extends Component } $savedData = $this->Table->save($data); if ($savedData !== false) { + if ($this->metaFieldsSupported() && !empty($metaFieldsToDelete)) { + $this->Table->MetaFields->unlink($savedData, $metaFieldsToDelete); + } if (isset($params['afterSave'])) { $params['afterSave']($data); } $message = __('{0} `{1}` updated.', $this->ObjectAlias, $savedData->{$this->Table->getDisplayField()}); - if (!empty($input['metaFields'])) { - $this->MetaFields->deleteAll(['scope' => $this->Table->metaFields, 'parent_id' => $savedData->id]); - $this->saveMetaFields($savedData->id, $input); - } if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($savedData, 'json'); } else if ($this->Controller->ParamHandler->isAjax()) { @@ -353,11 +555,11 @@ class CRUDComponent extends Component } } else { $validationErrors = $data->getErrors(); - $validationMessage = $this->prepareValidationMessage($validationErrors); + $validationMessage = $this->prepareValidationError($data); $message = __( '{0} could not be modified.{1}', $this->ObjectAlias, - empty($validationMessage) ? '' : PHP_EOL . __('Reason:{0}', $validationMessage) + empty($validationMessage) ? '' : PHP_EOL . __('Reason: {0}', $validationMessage) ); if ($this->Controller->ParamHandler->isRest()) { } else if ($this->Controller->ParamHandler->isAjax()) { @@ -376,10 +578,10 @@ class CRUDComponent extends Component public function attachMetaData($id, $data) { - if (empty($this->Table->metaFields)) { - return $data; + if (!$this->metaFieldsSupported()) { + throw new \Exception(__("Table {$this->TableAlias} does not support meta_fields")); } - $metaFieldScope = $this->Table->metaFields; + $metaFieldScope = $this->Table->getBehavior('MetaFields')->getScope(); $query = $this->MetaTemplates->find()->where(['MetaTemplates.scope' => $metaFieldScope]); $query->contain(['MetaTemplateFields.MetaFields' => function ($q) use ($id, $metaFieldScope) { return $q->where(['MetaFields.scope' => $metaFieldScope, 'MetaFields.parent_id' => $id]); @@ -408,21 +610,80 @@ class CRUDComponent extends Component return $metaTemplates; } - public function getMetaFields($id, $data) + public function getMetaFields($id) { - if (empty($this->Table->metaFields)) { - return $data; + if (!$this->metaFieldsSupported()) { + throw new \Exception(__("Table {$this->TableAlias} does not support meta_fields")); } $query = $this->MetaFields->find(); - $query->where(['MetaFields.scope' => $this->Table->metaFields, 'MetaFields.parent_id' => $id]); + $query->where(['MetaFields.scope' => $this->Table->getBehavior('MetaFields')->getScope(), 'MetaFields.parent_id' => $id]); $metaFields = $query->all(); - $data['metaFields'] = []; - foreach($metaFields as $metaField) { - $data['metaFields'][$metaField->meta_template_id][$metaField->field] = $metaField->value; + $data = []; + foreach ($metaFields as $metaField) { + if (empty($data[$metaField->meta_template_id][$metaField->meta_template_field_id])) { + $data[$metaField->meta_template_id][$metaField->meta_template_field_id] = []; + } + $data[$metaField->meta_template_id][$metaField->meta_template_field_id][$metaField->id] = $metaField; } return $data; } + public function attachMetaTemplates($data, $metaTemplates, $pruneEmptyDisabled=true) + { + $this->MetaTemplates = TableRegistry::getTableLocator()->get('MetaTemplates'); + $metaFields = []; + if (!empty($data->id)) { + $metaFields = $this->getMetaFields($data->id); + } + foreach ($metaTemplates as $i => $metaTemplate) { + if (isset($metaFields[$metaTemplate->id])) { + foreach ($metaTemplate->meta_template_fields as $j => $meta_template_field) { + if (isset($metaFields[$metaTemplate->id][$meta_template_field->id])) { + $metaTemplates[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = $metaFields[$metaTemplate->id][$meta_template_field->id]; + } else { + $metaTemplates[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = []; + } + } + } else { + if (!empty($pruneEmptyDisabled) && !$metaTemplate->enabled) { + unset($metaTemplates[$i]); + } + } + $newestTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate); + if (!empty($newestTemplate) && !empty($metaTemplates[$i])) { + $metaTemplates[$i]['hasNewerVersion'] = $newestTemplate; + } + } + $data['MetaTemplates'] = $metaTemplates; + return $data; + } + + protected function includeRequestedMetaFields($query) + { + $user = $this->Controller->ACL->getUser(); + $tableSettings = IndexSetting::getTableSetting($user, $this->Table); + if (empty($tableSettings['visible_meta_column'])) { + return $query; + } + $containConditions = ['OR' => []]; + $requestedMetaFields = []; + foreach ($tableSettings['visible_meta_column'] as $template_id => $fields) { + $containConditions['OR'][] = [ + 'meta_template_id' => $template_id, + 'meta_template_field_id IN' => array_map('intval', $fields), + ]; + foreach ($fields as $field) { + $requestedMetaFields[] = ['template_id' => $template_id, 'meta_template_field_id' => intval($field)]; + } + } + $this->Controller->set('requestedMetaFields', $requestedMetaFields); + return $query->contain([ + 'MetaFields' => [ + 'conditions' => $containConditions + ] + ]); + } + public function view(int $id, array $params = []): void { if (empty($id)) { @@ -433,12 +694,24 @@ class CRUDComponent extends Component $params['contain'][] = 'Tags'; $this->setAllTags(); } + if ($this->metaFieldsSupported()) { + if (!empty($this->request->getQuery('full'))) { + $params['contain']['MetaFields'] = ['MetaTemplateFields' => 'MetaTemplates']; + } else { + $params['contain'][] = 'MetaFields'; + } + } $data = $this->Table->get($id, $params); if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - $data = $this->attachMetaData($id, $data); + if ($this->metaFieldsSupported() && !empty($data['meta_fields'])) { + $usedMetaTemplateIDs = array_values(array_unique(Hash::extract($data['meta_fields'], '{n}.meta_template_id'))); + $data = $this->attachMetaTemplatesIfNeeded($data, null, [ + 'id IN' => $usedMetaTemplateIDs + ]); + } if (isset($params['afterFind'])) { $data = $params['afterFind']($data); } @@ -451,18 +724,38 @@ class CRUDComponent extends Component $this->Controller->set('entity', $data); } + public function attachMetaTemplatesIfNeeded($data, array $metaTemplates = null, array $metaTemplateConditions=[]) + { + if (!$this->metaFieldsSupported()) { + return $data; + } + if (!is_null($metaTemplates)) { + // We might be in the case where $metaTemplates gets re-used in a while loop + // We deep copy the meta-template so that the data attached is not preserved for the next iteration + $metaTemplates = array_map(function ($metaTemplate) { + $tmpEntity = $this->MetaTemplates->newEntity($metaTemplate->toArray()); + $tmpEntity['meta_template_fields'] = Hash::combine($tmpEntity['meta_template_fields'], '{n}.id', '{n}'); // newEntity resets array indexing, see https://github.com/cakephp/cakephp/blob/32e3c532fea8abe2db8b697f07dfddf4dfc134ca/src/ORM/Marshaller.php#L369 + return $tmpEntity; + }, $metaTemplates); + } else { + $metaTemplates = $this->getMetaTemplates($metaTemplateConditions)->toArray(); + } + $data = $this->attachMetaTemplates($data, $metaTemplates); + return $data; + } + public function delete($id=false, $params=[]): void { if ($this->request->is('get')) { if(!empty($id)) { - $data = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); + $query = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); if (!empty($params['conditions'])) { - $data->where($params['conditions']); + $query->where($params['conditions']); } if (!empty($params['contain'])) { - $data->contain($params['contain']); + $query->contain($params['contain']); } - $data = $data->first(); + $data = $query->first(); if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } @@ -483,18 +776,20 @@ class CRUDComponent extends Component $isBulk = count($ids) > 1; $bulkSuccesses = 0; foreach ($ids as $id) { - $skipExecution = false; - $data = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); + $query = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); if (!empty($params['conditions'])) { - $data->where($params['conditions']); + $query->where($params['conditions']); } if (!empty($params['contain'])) { - $data->contain($params['contain']); + $query->contain($params['contain']); } - $data = $data->first(); + $data = $query->first(); if (isset($params['beforeSave'])) { try { $data = $params['beforeSave']($data); + if ($data === false) { + throw new NotFoundException(__('Could not save {0} due to the input failing to meet expectations. Your input is bad and you should feel bad.', $this->ObjectAlias)); + } } catch (Exception $e) { $data = false; } @@ -515,28 +810,32 @@ class CRUDComponent extends Component __('{0} deleted.', $this->ObjectAlias), __('All {0} have been deleted.', Inflector::pluralize($this->ObjectAlias)), __('Could not delete {0}.', $this->ObjectAlias), - __('{0} / {1} {2} have been deleted.', + __( + '{0} / {1} {2} have been deleted.', $bulkSuccesses, count($ids), Inflector::pluralize($this->ObjectAlias) ) ); - $this->setResponseForController('delete', $bulkSuccesses, $message, $data); + $additionalData = []; + if ($bulkSuccesses > 0) { + $additionalData['redirect'] = Router::url(['controller' => $this->Controller->getName(), 'action' => 'index']); + } + $this->setResponseForController('delete', $bulkSuccesses, $message, $data, null, $additionalData); } - $this->Controller->set('metaGroup', 'ContactDB'); $this->Controller->set('scope', 'users'); $this->Controller->viewBuilder()->setLayout('ajax'); $this->Controller->render('/genericTemplates/delete'); } - public function tag($id=false): void + public function tag($id = false): void { if (!$this->taggingSupported()) { throw new Exception("Table {$this->TableAlias} does not support tagging"); } if ($this->request->is('get')) { $this->setAllTags(); - if(!empty($id)) { + if (!empty($id)) { $params = [ 'contain' => 'Tags', ]; @@ -576,7 +875,8 @@ class CRUDComponent extends Component __('{0} tagged with `{1}`.', $this->ObjectAlias, $input['tag_list']), __('All {0} have been tagged.', Inflector::pluralize($this->ObjectAlias)), __('Could not tag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']), - __('{0} / {1} {2} have been tagged.', + __( + '{0} / {1} {2} have been tagged.', $bulkSuccesses, count($ids), Inflector::pluralize($this->ObjectAlias) @@ -588,14 +888,14 @@ class CRUDComponent extends Component $this->Controller->render('/genericTemplates/tagForm'); } - public function untag($id=false): void + public function untag($id = false): void { if (!$this->taggingSupported()) { throw new Exception("Table {$this->TableAlias} does not support tagging"); } if ($this->request->is('get')) { $this->setAllTags(); - if(!empty($id)) { + if (!empty($id)) { $params = [ 'contain' => 'Tags', ]; @@ -637,7 +937,8 @@ class CRUDComponent extends Component __('{0} untagged with `{1}`.', $this->ObjectAlias, implode(', ', $tagsToRemove)), __('All {0} have been untagged.', Inflector::pluralize($this->ObjectAlias)), __('Could not untag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']), - __('{0} / {1} {2} have been untagged.', + __( + '{0} / {1} {2} have been untagged.', $bulkSuccesses, count($ids), Inflector::pluralize($this->ObjectAlias) @@ -672,13 +973,16 @@ class CRUDComponent extends Component $this->Controller->render('/genericTemplates/tag'); } - public function setResponseForController($action, $success, $message, $data=[], $errors=null) + public function setResponseForController($action, $success, $message, $data = [], $errors = null, $additionalData = []) { if ($success) { if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); } elseif ($this->Controller->ParamHandler->isAjax()) { - $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($this->ObjectAlias, $action, $data, $message); + if (!empty($additionalData['redirect'])) { // If a redirection occurs, we need to make sure the flash message gets displayed + $this->Controller->Flash->success($message); + } + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($this->ObjectAlias, $action, $data, $message, $additionalData); } else { $this->Controller->Flash->success($message); $this->Controller->redirect($this->Controller->referer()); @@ -687,6 +991,9 @@ class CRUDComponent extends Component if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); } elseif ($this->Controller->ParamHandler->isAjax()) { + if (!empty($additionalData['redirect'])) { // If a redirection occurs, we need to make sure the flash message gets displayed + $this->Controller->Flash->error($message); + } $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($this->ObjectAlias, $action, $data, $message, !is_null($errors) ? $errors : $data->getErrors()); } else { $this->Controller->Flash->error($message); @@ -712,7 +1019,7 @@ class CRUDComponent extends Component * @return Array The ID converted to a list or the list of provided IDs from the request * @throws NotFoundException when no ID could be found */ - public function getIdsOrFail($id=false): Array + public function getIdsOrFail($id = false): array { $params = $this->Controller->ParamHandler->harvestParams(['ids']); if (!empty($params['ids'])) { @@ -763,22 +1070,27 @@ class CRUDComponent extends Component return $massagedFilters; } - public function setQuickFilters(array $params, \Cake\ORM\Query $query, array $quickFilterFields): \Cake\ORM\Query + public function setQuickFilters(array $params, \Cake\ORM\Query $query, array $options): \Cake\ORM\Query { + $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']); - foreach ($quickFilterFields as $filterField) { - $likeCondition = false; - if (is_array($filterField)) { - $likeCondition = reset($filterField); - $filterFieldName = array_key_first($filterField); - $queryConditions[$filterFieldName . ' LIKE'] = '%' . $params['quickFilter'] .'%'; - } else { - $queryConditions[$filterField] = $params['quickFilter']; - } + $queryConditions = $this->genQuickFilterConditions($params, $quickFilterFields); + + if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) { + $searchValue = !empty($options['quickFilterForMetaField']['wildcard_search']) ? "%{$params['quickFilter']}%" : $params['quickFilter']; + $metaFieldConditions = $this->Table->buildMetaFieldQuerySnippetForMatchingParent(['value' => $searchValue]); + $queryConditions[] = $metaFieldConditions; } + $query->where(['OR' => $queryConditions]); } else { $this->Controller->set('quickFilterValue', ''); @@ -786,6 +1098,25 @@ class CRUDComponent extends Component return $query; } + public function genQuickFilterConditions(array $params, array $quickFilterFields): array + { + $queryConditions = []; + foreach ($quickFilterFields as $filterField) { + if (is_array($filterField)) { + reset($filterField); + $filterFieldName = array_key_first($filterField); + if (!empty($filterField[$filterFieldName])) { + $queryConditions[$filterFieldName . ' LIKE'] = '%' . $params['quickFilter'] . '%'; + } else { + $queryConditions[$filterField] = $params['quickFilter']; + } + } else { + $queryConditions[$filterField] = $params['quickFilter']; + } + } + return $queryConditions; + } + protected function setFilters($params, \Cake\ORM\Query $query, array $options): \Cake\ORM\Query { $filteringLabel = !empty($params['filteringLabel']) ? $params['filteringLabel'] : ''; @@ -793,7 +1124,7 @@ class CRUDComponent extends Component $filteringTags = !empty($params['filteringTags']) && $this->taggingSupported() ? $params['filteringTags'] : ''; unset($params['filteringTags']); $customFilteringFunction = ''; - $chosenFilter = ''; + $chosenFilter = []; if (!empty($options['contextFilters']['custom'])) { foreach ($options['contextFilters']['custom'] as $filter) { if ($filter['label'] == $filteringLabel) { @@ -816,10 +1147,10 @@ class CRUDComponent extends Component } if (!empty($params['simpleFilters'])) { foreach ($params['simpleFilters'] as $filter => $filterValue) { - $activeFilters[$filter] = $filterValue; if ($filter === 'quickFilter') { continue; } + $activeFilters[$filter] = $filterValue; if (is_array($filterValue)) { $query->where([($filter . ' IN') => $filterValue]); } else { @@ -841,20 +1172,39 @@ class CRUDComponent extends Component $query = $this->setTagFilters($query, $filteringTags); } + if ($this->metaFieldsSupported()) { + $filteringMetaFields = $this->getMetaFieldFiltersFromQuery(); + if (!empty($filteringMetaFields)) { + $activeFilters['filteringMetaFields'] = $filteringMetaFields; + } + $query = $this->setMetaFieldFilters($query, $filteringMetaFields); + } + $this->Controller->set('activeFilters', $activeFilters); return $query; } + protected function setMetaFieldFilters($query, $metaFieldFilters) + { + $metaFieldConditions = $this->Table->buildMetaFieldQuerySnippetForMatchingParent($metaFieldFilters); + $query->where($metaFieldConditions); + + return $query; + } + protected function setTagFilters($query, $tags) { $modelAlias = $this->Table->getAlias(); $subQuery = $this->Table->find('tagged', [ 'name' => $tags, - 'forceAnd' => true + 'OperatorAND' => true ])->select($modelAlias . '.id'); return $query->where([$modelAlias . '.id IN' => $subQuery]); } + // FIXME: Adding related condition with association having `through` setup might include duplicate in the result set + // We should probably rely on `innerJoinWith` and perform deduplication via `distinct` + // Or grouping by primary key for the main model (however this is not optimal/efficient/clean) protected function setNestedRelatedCondition($query, $filterParts, $filterValue) { $modelName = $filterParts[0]; @@ -863,7 +1213,7 @@ class CRUDComponent extends Component $query = $this->setRelatedCondition($query, $modelName, $fieldName, $filterValue); } else { $filterParts = array_slice($filterParts, 1); - $query = $query->matching($modelName, function(\Cake\ORM\Query $q) use ($filterParts, $filterValue) { + $query = $query->matching($modelName, function (\Cake\ORM\Query $q) use ($filterParts, $filterValue) { return $this->setNestedRelatedCondition($q, $filterParts, $filterValue); }); } @@ -872,7 +1222,7 @@ class CRUDComponent extends Component protected function setRelatedCondition($query, $modelName, $fieldName, $filterValue) { - return $query->matching($modelName, function(\Cake\ORM\Query $q) use ($fieldName, $filterValue) { + return $query->matching($modelName, function (\Cake\ORM\Query $q) use ($fieldName, $filterValue) { return $this->setValueCondition($q, $fieldName, $filterValue); }); } @@ -891,8 +1241,14 @@ class CRUDComponent extends Component protected function setFilteringContext($contextFilters, $params) { $filteringContexts = []; - if (!isset($contextFilters['allow_all']) || $contextFilters['allow_all']) { - $filteringContexts[] = ['label' => __('All')]; + if ( + !isset($contextFilters['_all']) || + !isset($contextFilters['_all']['enabled']) || + !empty($contextFilters['_all']['enabled']) + ) { + $filteringContexts[] = [ + 'label' => !empty($contextFilters['_all']['text']) ? h($contextFilters['_all']['text']) : __('All') + ]; } if (!empty($contextFilters['fields'])) { foreach ($contextFilters['fields'] as $field) { @@ -918,6 +1274,28 @@ class CRUDComponent extends Component $this->Controller->set('filteringContexts', $filteringContexts); } + /** + * Create a fake filtering label set to the filter to be used by default if the request does not supply one + * This fake filtering label will then be used to set approriate filters on the query + * + * @param array $options CRUD options + * @param array $params Collected params from the request + * @return array + */ + protected function fakeContextFilter($options, $params): array + { + if (empty($params['filteringLabel']) && !empty($options['contextFilters']['custom'])) { + foreach ($options['contextFilters']['custom'] as $contextFilter) { + if (!empty($contextFilter['default'])) { + $params['filteringLabel'] = $contextFilter['label']; + $this->Controller->set('fakeFilteringLabel', $contextFilter['label']); + break; + } + } + } + return $params; + } + public function setParentConditionsForMetaFields($query, array $metaConditions) { $metaTemplates = $this->MetaFields->MetaTemplates->find('list', [ @@ -942,7 +1320,7 @@ class CRUDComponent extends Component throw new Exception('Invalid passed conditions'); } foreach ($metaANDConditions as $i => $conditions) { - $metaANDConditions[$i]['scope'] = $this->Table->metaFields; + $metaANDConditions[$i]['scope'] = $this->Table->getBehavior('MetaFields')->getScope(); } $firstCondition = $this->prefixConditions('MetaFields', $metaANDConditions[0]); $conditionsToJoin = array_slice($metaANDConditions, 1); @@ -974,6 +1352,11 @@ class CRUDComponent extends Component return $this->Table->behaviors()->has('Tag'); } + public function metaFieldsSupported() + { + return $this->Table->hasBehavior('MetaFields'); + } + public function setAllTags() { $this->Tags = TableRegistry::getTableLocator()->get('Tags.Tags'); @@ -999,7 +1382,8 @@ class CRUDComponent extends Component } $savedData = $this->Table->save($data); if ($savedData !== false) { - $message = __('{0} field {1}. (ID: {2} {3})', + $message = __( + '{0} field {1}. (ID: {2} {3})', $fieldName, $data->{$fieldName} ? __('enabled') : __('disabled'), Inflector::humanize($this->ObjectAlias), @@ -1023,7 +1407,7 @@ class CRUDComponent extends Component $message = __( '{0} could not be modified.{1}', $this->ObjectAlias, - empty($validationMessage) ? '' : ' ' . __('Reason:{0}', $validationMessage) + empty($validationMessage) ? '' : ' ' . __('Reason: {0}', $validationMessage) ); if ($this->Controller->ParamHandler->isRest()) { } else if ($this->Controller->ParamHandler->isAjax()) { @@ -1081,9 +1465,9 @@ class CRUDComponent extends Component [$this->Table->getAlias() => $this->Table->getTable()], [sprintf('%s.id = %s.%s', $this->Table->getAlias(), $associatedTable->getAlias(), $association->getForeignKey())] ) - ->where([ - ["${field} IS NOT" => NULL] - ]); + ->where([ + ["${field} IS NOT" => NULL] + ]); } else if ($associationType == 'manyToOne') { $fieldToExtract = sprintf('%s.%s', Inflector::singularize(strtolower($model)), $subField); $query = $this->Table->find()->contain($model); @@ -1100,6 +1484,25 @@ class CRUDComponent extends Component ->toList(); } + private function getMetaFieldFiltersFromQuery(): array + { + $filters = []; + foreach ($this->request->getQueryParams() as $filterName => $value) { + $prefix = '_metafield'; + if (substr($filterName, 0, strlen($prefix)) === $prefix) { + $dissected = explode('_', substr($filterName, strlen($prefix))); + if (count($dissected) == 3) { // Skip if template_id or template_field_id not provided + $filters[] = [ + 'meta_template_id' => intval($dissected[1]), + 'meta_template_field_id' => intval($dissected[2]), + 'value' => $value, + ]; + } + } + } + return $filters; + } + private function renderViewInVariable($templateRelativeName, $data) { $builder = new ViewBuilder(); diff --git a/src/Controller/Component/Navigation/Api.php b/src/Controller/Component/Navigation/Api.php new file mode 100644 index 0000000..022f9fc --- /dev/null +++ b/src/Controller/Component/Navigation/Api.php @@ -0,0 +1,16 @@ +bcf->addRoute('Api', 'index', [ + 'label' => __('API'), + 'url' => '/api/index', + 'icon' => 'code' + ]); + } +} diff --git a/src/Controller/Component/Navigation/MetaTemplates.php b/src/Controller/Component/Navigation/MetaTemplates.php index 91ba4eb..ca83ef9 100644 --- a/src/Controller/Component/Navigation/MetaTemplates.php +++ b/src/Controller/Component/Navigation/MetaTemplates.php @@ -12,21 +12,52 @@ class MetaTemplatesNavigation extends BaseNavigation $this->bcf->addRoute('MetaTemplates', 'view', $this->bcf->defaultCRUD('MetaTemplates', 'view')); $this->bcf->addRoute('MetaTemplates', 'enable', [ 'label' => __('Enable'), - 'icon' => 'check', + 'icon' => 'check-square', 'url' => '/metaTemplates/enable/{{id}}/enabled', 'url_vars' => ['id' => 'id'], ]); $this->bcf->addRoute('MetaTemplates', 'set_default', [ 'label' => __('Set as default'), - 'icon' => 'check', + 'icon' => 'check-square', 'url' => '/metaTemplates/toggle/{{id}}/default', 'url_vars' => ['id' => 'id'], ]); + + $totalUpdateCount = 0; + if (!empty($this->viewVars['updateableTemplates']['automatically-updateable']) && !empty($this->viewVars['updateableTemplates']['new'])) { + $udpateCount = count($this->viewVars['updateableTemplates']['automatically-updateable']) ?? 0; + $newCount = count($this->viewVars['updateableTemplates']['new']) ?? 0; + $totalUpdateCount = $udpateCount + $newCount; + } + $updateRouteConfig = [ + 'label' => __('Update all templates'), + 'icon' => 'download', + 'url' => '/metaTemplates/updateAllTemplates', + ]; + if ($totalUpdateCount > 0) { + $updateRouteConfig['badge'] = [ + 'text' => h($totalUpdateCount), + 'variant' => 'warning', + 'title' => __('There are {0} new meta-template(s) and {1} update(s) available', h($newCount), h($udpateCount)), + ]; + } + $this->bcf->addRoute('MetaTemplates', 'update_all_templates', $updateRouteConfig); + $this->bcf->addRoute('MetaTemplates', 'update', [ + 'label' => __('Update template'), + 'icon' => 'download', + 'url' => '/metaTemplates/update', + ]); + $this->bcf->addRoute('MetaTemplates', 'prune_outdated_template', [ + 'label' => __('Prune outdated template'), + 'icon' => 'trash', + 'url' => '/metaTemplates/prune_outdated_template', + ]); } public function addParents() { $this->bcf->addParent('MetaTemplates', 'view', 'MetaTemplates', 'index'); + $this->bcf->addParent('MetaTemplates', 'update', 'MetaTemplates', 'index'); } public function addLinks() @@ -36,6 +67,42 @@ class MetaTemplatesNavigation extends BaseNavigation public function addActions() { + $totalUpdateCount = 0; + if (!empty($this->viewVars['updateableTemplates']['not-up-to-date']) && !empty($this->viewVars['updateableTemplates']['new'])) { + $udpateCount = count($this->viewVars['updateableTemplates']['not-up-to-date']) ?? 0; + $newCount = count($this->viewVars['updateableTemplates']['new']) ?? 0; + $totalUpdateCount = $udpateCount + $newCount; + } + $updateAllActionConfig = [ + 'label' => __('Update template'), + 'url' => '/metaTemplates/updateAllTemplates', + 'url_vars' => ['id' => 'id'], + ]; + if ($totalUpdateCount > 0) { + $updateAllActionConfig['badge'] = [ + 'text' => h($totalUpdateCount), + 'variant' => 'warning', + 'title' => __('There are {0} new meta-template(s) and {1} update(s) available', h($newCount), h($udpateCount)), + ]; + } + $this->bcf->addAction('MetaTemplates', 'index', 'MetaTemplates', 'update_all_templates', $updateAllActionConfig); + $this->bcf->addAction('MetaTemplates', 'index', 'MetaTemplates', 'prune_outdated_template', [ + 'label' => __('Prune outdated template'), + 'url' => '/metaTemplates/prune_outdated_template', + ]); + + if (empty($this->viewVars['updateableTemplates']['up-to-date'])) { + $this->bcf->addAction('MetaTemplates', 'view', 'MetaTemplates', 'update', [ + 'label' => __('Update template'), + 'url' => '/metaTemplates/update/{{id}}', + 'url_vars' => ['id' => 'id'], + 'variant' => 'warning', + 'badge' => [ + 'variant' => 'warning', + 'title' => __('Update available') + ] + ]); + } $this->bcf->addAction('MetaTemplates', 'view', 'MetaTemplates', 'enable'); $this->bcf->addAction('MetaTemplates', 'view', 'MetaTemplates', 'set_default'); } diff --git a/src/Controller/Component/Navigation/base.php b/src/Controller/Component/Navigation/base.php index b426ab5..30f904a 100644 --- a/src/Controller/Component/Navigation/base.php +++ b/src/Controller/Component/Navigation/base.php @@ -5,12 +5,14 @@ class BaseNavigation { protected $bcf; protected $request; + protected $viewVars; public $currentUser; - public function __construct($bcf, $request) + public function __construct($bcf, $request, $viewVars) { $this->bcf = $bcf; $this->request = $request; + $this->viewVars = $viewVars; } public function setCurrentUser($currentUser) diff --git a/src/Controller/Component/Navigation/sidemenu.php b/src/Controller/Component/Navigation/sidemenu.php index 56a0154..e9b9281 100644 --- a/src/Controller/Component/Navigation/sidemenu.php +++ b/src/Controller/Component/Navigation/sidemenu.php @@ -38,6 +38,11 @@ class Sidemenu { 'label' => __('Sharing Groups'), 'icon' => $this->iconTable['SharingGroups'], 'url' => '/sharingGroups/index', + ], + 'MailingLists' => [ + 'label' => __('Mailing Lists'), + 'icon' => $this->iconTable['MailingLists'], + 'url' => '/mailingLists/index', ] ], __('Synchronisation') => [ @@ -46,11 +51,6 @@ class Sidemenu { 'icon' => $this->iconTable['Broods'], 'url' => '/broods/index', ], - 'API' => [ - 'label' => __('API'), - 'icon' => $this->iconTable['API'], - 'url' => '/api/index', - ], ], __('Administration') => [ 'Roles' => [ @@ -78,13 +78,13 @@ class Sidemenu { 'icon' => $this->iconTable['Inbox'], 'url' => '/inbox/index', 'children' => [ - 'index' => [ + 'inbox' => [ 'url' => '/inbox/index', - 'label' => __('Inbox') + 'label' => __('Inbox'), ], 'outbox' => [ 'url' => '/outbox/index', - 'label' => __('Outbox') + 'label' => __('Outbox'), ], ] ], @@ -125,6 +125,11 @@ class Sidemenu { ], ] ], + 'API' => [ + 'label' => __('API'), + 'icon' => $this->iconTable['API'], + 'url' => '/api/index', + ], ], 'Open' => [ 'Organisations' => [ diff --git a/src/Controller/Component/NavigationComponent.php b/src/Controller/Component/NavigationComponent.php index 67fb39c..9df48be 100644 --- a/src/Controller/Component/NavigationComponent.php +++ b/src/Controller/Component/NavigationComponent.php @@ -25,6 +25,7 @@ class NavigationComponent extends Component 'Organisations' => 'building', 'EncryptionKeys' => 'key', 'SharingGroups' => 'user-friends', + 'MailingLists' => 'mail-bulk', 'Broods' => 'network-wired', 'Roles' => 'id-badge', 'Users' => 'users', @@ -43,10 +44,9 @@ class NavigationComponent extends Component $this->request = $config['request']; } - public function genBreadcrumbs(\App\Model\Entity\User $user) + public function beforeRender($event) { - $this->currentUser = $user; - $this->breadcrumb = $this->fullBreadcrumb = $this->genBreadcrumb(); + $this->fullBreadcrumb = $this->genBreadcrumb(); } public function getSideMenu(): array @@ -141,7 +141,8 @@ class NavigationComponent extends Component $navigationClassname = str_replace('.php', '', $navigationFile); require_once(APP . 'Controller' . DS . 'Component' . DS . 'Navigation' . DS . $navigationFile); $reflection = new \ReflectionClass("BreadcrumbNavigation\\{$navigationClassname}Navigation"); - $navigationClasses[$navigationClassname] = $reflection->newInstance($bcf, $request); + $viewVars = $this->_registry->getController()->viewBuilder()->getVars(); + $navigationClasses[$navigationClassname] = $reflection->newInstance($bcf, $request, $viewVars); $navigationClasses[$navigationClassname]->setCurrentUser($this->currentUser); } return $navigationClasses; @@ -161,6 +162,7 @@ class NavigationComponent extends Component 'Tags', 'LocalTools', 'UserSettings', + 'MailingLists', ]; foreach ($CRUDControllers as $controller) { $bcf->setDefaultCRUDForModel($controller); @@ -250,6 +252,7 @@ class BreadcrumbFactory $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'icon'); $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'label'); $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'textGetter'); + $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'badge'); return $routeConfig; } diff --git a/src/Controller/Component/NotificationComponent.php b/src/Controller/Component/NotificationComponent.php new file mode 100644 index 0000000..33c460b --- /dev/null +++ b/src/Controller/Component/NotificationComponent.php @@ -0,0 +1,48 @@ +request = $config['request']; + $this->Controller = $this->getController(); + } + + public function getNotifications(): array + { + $notifications = []; + $notifications = $this->collectNotificationsFromTables(); + return $notifications; + } + + private function collectNotificationsFromTables(): array + { + $notifications = []; + foreach ($this->tables as $tableName) { + $table = TableRegistry::getTableLocator()->get($tableName); + $tableNotifications = $this->collectNotificationFromTable($table); + $notifications = array_merge($notifications, $tableNotifications); + } + return $notifications; + } + + private function collectNotificationFromTable($table): array + { + $notifications = []; + if (method_exists($table, 'collectNotifications')) { + $notifications = $table->collectNotifications($this->Controller->ACL->getUser()); + } + return $notifications; + } +} diff --git a/src/Controller/Component/ParamHandlerComponent.php b/src/Controller/Component/ParamHandlerComponent.php index 92260fd..bb004fa 100644 --- a/src/Controller/Component/ParamHandlerComponent.php +++ b/src/Controller/Component/ParamHandlerComponent.php @@ -27,15 +27,15 @@ class ParamHandlerComponent extends Component $queryString = str_replace('.', '_', $filter); $queryString = str_replace(' ', '_', $queryString); if ($this->request->getQuery($queryString) !== null) { - $parsedParams[$filter] = $this->request->getQuery($queryString); - continue; - } - if (($this->request->getQuery($filter)) !== null) { - $parsedParams[$filter] = $this->request->getQuery($filter); + if (is_array($this->request->getQuery($queryString))) { + $parsedParams[$filter] = array_map('trim', $this->request->getQuery($queryString)); + } else { + $parsedParams[$filter] = trim($this->request->getQuery($queryString)); + } continue; } if (($this->request->is('post') || $this->request->is('put')) && $this->request->getData($filter) !== null) { - $parsedParams[$filter] = $this->request->getData($filter); + $parsedParams[$filter] = trim($this->request->getData($filter)); } } return $parsedParams; diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index 803f180..d0376db 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -17,6 +17,7 @@ class EncryptionKeysController extends AppController public $filterFields = ['owner_model', 'owner_id', 'encryption_key']; public $quickFilterFields = ['encryption_key']; public $containFields = ['Individuals', 'Organisations']; + public $statisticsFields = ['type']; public function index() { @@ -28,7 +29,8 @@ class EncryptionKeysController extends AppController 'type' ] ], - 'contain' => $this->containFields + 'contain' => $this->containFields, + 'statisticsFields' => $this->statisticsFields, ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index 996a886..cc5a8fa 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -16,25 +16,22 @@ class IndividualsController extends AppController public $quickFilterFields = ['uuid', ['email' => true], ['first_name' => true], ['last_name' => true], 'position']; public $filterFields = ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type']; public $containFields = ['Alignments' => 'Organisations']; + public $statisticsFields = ['position']; public function index() { $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, - 'contextFilters' => [ - 'fields' => [ - 'Alignments.type' - ] - ], - 'contain' => $this->containFields + 'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true], + 'contain' => $this->containFields, + 'statisticsFields' => $this->statisticsFields, ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; } $this->set('alignmentScope', 'individuals'); - $this->set('metaGroup', 'ContactDB'); } public function filtering() @@ -49,7 +46,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); } public function view($id) @@ -59,7 +55,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); } public function edit($id) @@ -69,7 +64,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); $this->render('add'); } @@ -80,7 +74,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); } public function tag($id) diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index f136251..5caf6ee 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -21,7 +21,6 @@ class InstanceController extends AppController public function home() { - // $this->set('md', file_get_contents(ROOT . '/README.md')); $statistics = $this->Instance->getStatistics(); $this->set('statistics', $statistics); } @@ -44,7 +43,7 @@ class InstanceController extends AppController } $data = []; if (!empty($searchValue)) { - $data = $this->Instance->searchAll($searchValue, $limit, $model); + $data = $this->Instance->searchAll($searchValue, $this->ACL->getUser(), $limit, $model); } if ($this->ParamHandler->isRest()) { return $this->RestResponse->viewData($data, 'json'); diff --git a/src/Controller/MailingListsController.php b/src/Controller/MailingListsController.php new file mode 100644 index 0000000..e8e6a7c --- /dev/null +++ b/src/Controller/MailingListsController.php @@ -0,0 +1,358 @@ + true], ['description' => true], ['releasability' => true]]; + public $containFields = ['Users', 'Individuals', 'MetaFields']; + public $statisticsFields = ['active']; + + public function index() + { + $currentUser = $this->ACL->getUser(); + $this->CRUD->index([ + 'contain' => $this->containFields, + 'filters' => $this->filterFields, + 'quickFilters' => $this->quickFilterFields, + 'statisticsFields' => $this->statisticsFields, + 'afterFind' => function ($row) use ($currentUser) { + if (empty($currentUser['role']['perm_admin']) || $row['user_id'] != $currentUser['id']) { + if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $row)) { + $row = false; + } + } + return $row; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function add() + { + $currentUser = $this->ACL->getUser(); + $this->CRUD->add([ + 'override' => [ + 'user_id' => $currentUser['id'] + ], + 'beforeSave' => function ($data) use ($currentUser) { + return $data; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function view($id) + { + $currentUser = $this->ACL->getUser(); + $this->CRUD->view($id, [ + 'contain' => $this->containFields, + 'afterFind' => function($data) use ($currentUser) { + if (empty($currentUser['role']['perm_admin']) || $data['user_id'] != $currentUser['id']) { + if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $data)) { + $data = []; + } + } + return $data; + }, + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function edit($id = false) + { + $currentUser = $this->ACL->getUser(); + $params = []; + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $this->CRUD->edit($id, $params); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->render('add'); + } + + public function delete($id) + { + $currentUser = $this->ACL->getUser(); + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $this->CRUD->delete($id, $params); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function listIndividuals($mailinglist_id) + { + $currentUser = $this->ACL->getUser(); + $quickFilter = [ + 'uuid', + ['first_name' => true], + ['last_name' => true], + ]; + $quickFilterUI = array_merge($quickFilter, [ + ['Registered emails' => true], + ]); + $filters = ['uuid', 'first_name', 'last_name', 'quickFilter']; + $queryParams = $this->ParamHandler->harvestParams($filters); + $activeFilters = $queryParams['quickFilter'] ?? []; + + $mailingList = $this->MailingLists->find() + ->where(['MailingLists.id' => $mailinglist_id]) + ->contain(['MetaFields', 'Individuals']) + ->first(); + + if (is_null($mailingList)) { + throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->getAlias()))); + } + if (empty($currentUser['role']['perm_admin']) || $mailingList['user_id'] != $currentUser['id']) { + if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $mailingList)) { + throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->getAlias()))); + } + } + + $filteringActive = !empty($queryParams['quickFilter']); + $matchingMetaFieldParentIDs = []; + if ($filteringActive) { + // Collect individuals having a matching meta_field for the requested search value + foreach ($mailingList->meta_fields as $metaField) { + if ( + empty($queryParams['quickFilter']) || + ( + str_contains($metaField->field, 'email') && + str_contains($metaField->value, $queryParams['quickFilter']) + ) + ) { + $matchingMetaFieldParentIDs[$metaField->parent_id] = true; + } + } + } + $matchingMetaFieldParentIDs = array_keys($matchingMetaFieldParentIDs); + unset($mailingList['individuals']); // reset loaded individuals for the filtering to take effect + // Perform filtering based on the searched values (supports emails from metafield or individual) + $mailingList = $this->MailingLists->loadInto($mailingList, [ + 'Individuals' => function (Query $q) use ($queryParams, $quickFilter, $filteringActive, $matchingMetaFieldParentIDs) { + $conditions = []; + if (!empty($queryParams)) { + $conditions = $this->CRUD->genQuickFilterConditions($queryParams, $quickFilter); + } + if ($filteringActive && !empty($matchingMetaFieldParentIDs)) { + $conditions[] = function (QueryExpression $exp) use ($matchingMetaFieldParentIDs) { + return $exp->in('Individuals.id', $matchingMetaFieldParentIDs); + }; + } + if ($filteringActive && !empty($queryParams['quickFilter'])) { + $conditions[] = [ + 'MailingListsIndividuals.include_primary_email' => true, + 'Individuals.email LIKE' => "%{$queryParams['quickFilter']}%" + ]; + } + $q->where([ + 'OR' => $conditions + ]); + return $q; + } + ]); + $mailingList->injectRegisteredEmailsIntoIndividuals(); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData($mailingList->individuals, 'json'); + } + $individuals = $this->CustomPagination->paginate($mailingList->individuals); + $this->set('mailing_list_id', $mailinglist_id); + $this->set('quickFilter', $quickFilterUI); + $this->set('activeFilters', $activeFilters); + $this->set('quickFilterValue', $queryParams['quickFilter'] ?? ''); + $this->set('individuals', $individuals); + } + + public function addIndividual($mailinglist_id) + { + $currentUser = $this->ACL->getUser(); + $params = [ + 'contain' => ['Individuals', 'MetaFields'] + ]; + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $mailingList = $this->MailingLists->get($mailinglist_id, $params); + $linkedIndividualsIDs = Hash::extract($mailingList, 'individuals.{n}.id'); + $conditions = []; + if (!empty($linkedIndividualsIDs)) { + $conditions = [ + 'id NOT IN' => $linkedIndividualsIDs + ]; + } + $dropdownData = [ + 'individuals' => $this->MailingLists->Individuals->getTarget()->find() + ->order(['first_name' => 'asc']) + ->where($conditions) + ->all() + ->combine('id', 'full_name') + ->toArray() + ]; + if ($this->request->is('post') || $this->request->is('put')) { + $memberIDs = $this->request->getData()['individuals']; + $chosen_emails = $this->request->getData()['chosen_emails']; + if (!empty($chosen_emails)) { + $chosen_emails = json_decode($chosen_emails, true); + $chosen_emails = !is_null($chosen_emails) ? $chosen_emails : []; + } else { + $chosen_emails = []; + } + $members = $this->MailingLists->Individuals->getTarget()->find()->where([ + 'id IN' => $memberIDs + ])->all()->toArray(); + $memberToLink = []; + foreach ($members as $i => $member) { + $includePrimary = in_array('primary', $chosen_emails[$member->id]); + $chosen_emails[$member->id] = array_filter($chosen_emails[$member->id], function($entry) { + return $entry != 'primary'; + }); + $members[$i]->_joinData = new Entity(['include_primary_email' => $includePrimary]); + if (!in_array($member->id, $linkedIndividualsIDs)) { // individual are not already in the list + $memberToLink[] = $members[$i]; + } + } + + // save new individuals + if (!empty($memberToLink)) { + $success = (bool)$this->MailingLists->Individuals->link($mailingList, $memberToLink); + if ($success && !empty($chosen_emails[$member->id])) { // Include any remaining emails from the metaFields + $emailsFromMetaFields = $this->MailingLists->MetaFields->find()->where([ + 'id IN' => $chosen_emails[$member->id] + ])->all()->toArray(); + $success = (bool)$this->MailingLists->MetaFields->link($mailingList, $emailsFromMetaFields); + } + } + + if ($success) { + $message = __n('{0} individual added to the mailing list.', '{0} Individuals added to the mailing list.', count($members), count($members)); + $mailingList = $this->MailingLists->get($mailingList->id); + } else { + $message = __n('The individual could not be added to the mailing list.', 'The Individuals could not be added to the mailing list.', count($members)); + } + $this->CRUD->setResponseForController('add_individuals', $success, $message, $mailingList, $mailingList->getErrors()); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $this->set(compact('dropdownData')); + $this->set('mailinglist_id', $mailinglist_id); + $this->set('mailingList', $mailingList); + } + + public function removeIndividual($mailinglist_id, $individual_id=null) + { + $currentUser = $this->ACL->getUser(); + $params = [ + 'contain' => ['Individuals', 'MetaFields'] + ]; + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $mailingList = $this->MailingLists->get($mailinglist_id, $params); + $individual = []; + if (!is_null($individual_id)) { + $individual = $this->MailingLists->Individuals->get($individual_id); + } + if ($this->request->is('post') || $this->request->is('delete')) { + $success = false; + if (!is_null($individual_id)) { + $individualToRemove = $this->MailingLists->Individuals->get($individual_id); + $metaFieldsIDsToRemove = Hash::extract($mailingList, 'meta_fields.{n}.id'); + if (!empty($metaFieldsIDsToRemove)) { + $metaFieldsToRemove = $this->MailingLists->MetaFields->find()->where([ + 'id IN' => $metaFieldsIDsToRemove, + 'parent_id' => $individual_id, + ])->all()->toArray(); + } + $success = (bool)$this->MailingLists->Individuals->unlink($mailingList, [$individualToRemove]); + if ($success && !empty($metaFieldsToRemove)) { + $success = (bool)$this->MailingLists->MetaFields->unlink($mailingList, $metaFieldsToRemove); + } + if ($success) { + $message = __('{0} removed from the mailing list.', $individualToRemove->full_name); + $mailingList = $this->MailingLists->get($mailingList->id); + } else { + $message = __n('{0} could not be removed from the mailing list.', $individual->full_name); + } + $this->CRUD->setResponseForController('remove_individuals', $success, $message, $mailingList, $mailingList->getErrors()); + } else { + $params = $this->ParamHandler->harvestParams(['ids']); + if (!empty($params['ids'])) { + $params['ids'] = json_decode($params['ids']); + } + if (empty($params['ids'])) { + throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->Individuals->getAlias()))); + } + $individualsToRemove = $this->MailingLists->Individuals->find()->where([ + 'id IN' => array_map('intval', $params['ids']) + ])->all()->toArray(); + $metaFieldsIDsToRemove = Hash::extract($mailingList, 'meta_fields.{n}.id'); + if (!empty($metaFieldsIDsToRemove)) { + $metaFieldsToRemove = $this->MailingLists->MetaFields->find()->where([ + 'id IN' => $metaFieldsIDsToRemove, + ])->all()->toArray(); + } + $unlinkSuccesses = 0; + foreach ($individualsToRemove as $individualToRemove) { + $success = (bool)$this->MailingLists->Individuals->unlink($mailingList, [$individualToRemove]); + $results[] = $success; + if ($success) { + $unlinkSuccesses++; + } + } + $mailingList = $this->MailingLists->get($mailingList->id); + $success = $unlinkSuccesses == count($individualsToRemove); + $message = __( + '{0} {1} have been removed.', + $unlinkSuccesses == count($individualsToRemove) ? __('All') : sprintf('%s / %s', $unlinkSuccesses, count($individualsToRemove)), + Inflector::singularize($this->MailingLists->Individuals->getAlias()) + ); + $this->CRUD->setResponseForController('remove_individuals', $success, $message, $mailingList, []); + } + + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $this->set('mailinglist_id', $mailinglist_id); + $this->set('mailingList', $mailingList); + if (!empty($individual)) { + $this->set('deletionText', __('Are you sure you want to remove `{0} ({1})` from the mailing list?', $individual->full_name, $individual->email)); + } else { + $this->set('deletionText', __('Are you sure you want to remove multiples individuals from the mailing list?')); + } + $this->set('postLinkParameters', ['action' => 'removeIndividual', $mailinglist_id, $individual_id]); + $this->viewBuilder()->setLayout('ajax'); + $this->render('/genericTemplates/delete'); + } +} diff --git a/src/Controller/MetaTemplatesController.php b/src/Controller/MetaTemplatesController.php index 904ac8d..0a863e0 100644 --- a/src/Controller/MetaTemplatesController.php +++ b/src/Controller/MetaTemplatesController.php @@ -5,62 +5,285 @@ namespace App\Controller; use App\Controller\AppController; use Cake\Utility\Hash; use Cake\Utility\Text; +use Cake\Utility\Inflector; +use Cake\ORM\TableRegistry; use \Cake\Database\Expression\QueryExpression; +use Cake\Http\Exception\NotFoundException; +use Cake\Http\Exception\MethodNotAllowedException; +use Cake\Routing\Router; + class MetaTemplatesController extends AppController { - public $quickFilterFields = ['name', 'uuid', 'scope']; + public $quickFilterFields = [['name' => true], 'uuid', ['scope' => true]]; public $filterFields = ['name', 'uuid', 'scope', 'namespace']; public $containFields = ['MetaTemplateFields']; - public function update() + public function updateAllTemplates() { if ($this->request->is('post')) { - $result = $this->MetaTemplates->update(); + $result = $this->MetaTemplates->updateAllTemplates(); if ($this->ParamHandler->isRest()) { return $this->RestResponse->viewData($result, 'json'); } else { - $this->Flash->success(__('{0} templates updated.', count($result))); - $this->redirect($this->referer()); + if ($result['success']) { + $message = __n('{0} templates updated.', 'The template has been updated.', empty($template_id), $result['files_processed']); + } else { + $message = __n('{0} templates could not be updated.', 'The template could not be updated.', empty($template_id), $result['files_processed']); + } + $this->CRUD->setResponseForController('updateAllTemplate', $result['success'], $message, $result['files_processed'], $result['update_errors'], ['redirect' => $this->referer()]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } } } else { if (!$this->ParamHandler->isRest()) { - $this->set('title', __('Update Meta Templates')); - $this->set('question', __('Are you sure you wish to update the Meta Template definitions?')); - $this->set('actionName', __('Update')); - $this->set('path', ['controller' => 'metaTemplates', 'action' => 'update']); + $this->set('title', __('Update All Meta Templates')); + $this->set('question', __('Are you sure you wish to update all the Meta Template definitions')); + $templatesUpdateStatus = $this->MetaTemplates->getUpdateStatusForTemplates(); + $this->set('templatesUpdateStatus', $templatesUpdateStatus); + $this->render('updateAll'); + } + } + } + + /** + * Update the provided template or all templates + * + * @param int|null $template_id + */ + public function update($template_id=null) + { + $metaTemplate = false; + if (!is_null($template_id)) { + if (!is_numeric($template_id)) { + throw new NotFoundException(__('Invalid {0} for provided ID.', $this->MetaTemplates->getAlias(), $template_id)); + } + $metaTemplate = $this->MetaTemplates->find()->where([ + 'id' => $template_id + ])->contain(['MetaTemplateFields'])->first(); + + if (empty($metaTemplate)) { + throw new NotFoundException(__('Invalid {0} {1}.', $this->MetaTemplates->getAlias(), $template_id)); + } + } + if ($this->request->is('post')) { + $params = $this->ParamHandler->harvestParams(['update_strategy']); + $updateStrategy = $params['update_strategy'] ?? null; + $result = $this->MetaTemplates->update($metaTemplate, $updateStrategy); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData($result, 'json'); + } else { + if ($result['success']) { + $message = __n('{0} templates updated.', 'The template has been updated.', empty($template_id), $result['files_processed']); + } else { + $message = __n('{0} templates could not be updated.', 'The template could not be updated.', empty($template_id), $result['files_processed']); + } + $this->CRUD->setResponseForController('update', $result['success'], $message, $result['files_processed'], $result['update_errors'], ['redirect' => $this->referer()]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + } else { + if (!$this->ParamHandler->isRest()) { + if (!empty($metaTemplate)) { + $this->set('metaTemplate', $metaTemplate); + $statuses = $this->setUpdateStatus($metaTemplate->id); + $this->set('updateStatus', $this->MetaTemplates->computeFullUpdateStatusForMetaTemplate($statuses['templateStatus'], $metaTemplate)); + } else { + $this->set('title', __('Update All Meta Templates')); + $this->set('question', __('Are you sure you wish to update all the Meta Template definitions')); + $templatesUpdateStatus = $this->MetaTemplates->getUpdateStatusForTemplates(); + $this->set('templatesUpdateStatus', $templatesUpdateStatus); + $this->render('updateAll'); + } + } + } + } + + /** + * Create a new template by loading the template on the disk having the provided UUID. + * + * @param string $uuid + */ + public function createNewTemplate(string $uuid) + { + if ($this->request->is('post')) { + $result = $this->MetaTemplates->createNewTemplate($uuid); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData($result, 'json'); + } else { + if ($result['success']) { + $message = __('The template {0} has been created.', $uuid); + } else { + $message = __('The template {0} could not be created.', $uuid); + } + $this->CRUD->setResponseForController('createNewTemplate', $result['success'], $message, $result['files_processed'], $result['update_errors']); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + } else { + if (!$this->ParamHandler->isRest()) { + $this->set('title', __('Create Meta Template')); + $this->set('question', __('Are you sure you wish to load the meta template with UUID: {0} in the database', h($uuid))); + $this->set('actionName', __('Create template')); + $this->set('path', ['controller' => 'meta-templates', 'action' => 'create_new_template', $uuid]); $this->render('/genericTemplates/confirm'); } } } + public function getMetaFieldsToUpdate($template_id) + { + $metaTemplate = $this->MetaTemplates->get($template_id); + $newestMetaTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate); + $amountOfEntitiesToUpdate = 0; + $entities = $this->MetaTemplates->getEntitiesHavingMetaFieldsFromTemplate($template_id, 10, $amountOfEntitiesToUpdate); + $this->set('metaTemplate', $metaTemplate); + $this->set('newestMetaTemplate', $newestMetaTemplate); + $this->set('entities', $entities); + $this->set('amountOfEntitiesToUpdate', $amountOfEntitiesToUpdate); + } + + public function migrateOldMetaTemplateToNewestVersionForEntity($template_id, $entity_id) + { + $metaTemplate = $this->MetaTemplates->get($template_id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $newestMetaTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate, true); + $entity = $this->MetaTemplates->getEntity($metaTemplate, $entity_id); + $conditions = [ + 'MetaFields.meta_template_id IN' => [$metaTemplate->id, $newestMetaTemplate->id], + 'MetaFields.scope' => $metaTemplate->scope, + ]; + $keyedMetaFields = $this->MetaTemplates->getKeyedMetaFieldsForEntity($entity_id, $conditions); + if (empty($keyedMetaFields[$metaTemplate->id])) { + throw new NotFoundException(__('Invalid {0}. This entities does not have meta-fields to be moved to a newer template.', $this->MetaTemplates->getAlias())); + } + $mergedMetaFields = $this->MetaTemplates->insertMetaFieldsInMetaTemplates($keyedMetaFields, [$metaTemplate, $newestMetaTemplate]); + $entity['MetaTemplates'] = $mergedMetaFields; + if ($this->request->is('post') || $this->request->is('put')) { + $className = Inflector::camelize(Inflector::pluralize($newestMetaTemplate->scope)); + $entityTable = TableRegistry::getTableLocator()->get($className); + $inputData = $this->request->getData(); + $massagedData = $this->MetaTemplates->massageMetaFieldsBeforeSave($entity, $inputData, $newestMetaTemplate); + unset($inputData['MetaTemplates']); // Avoid MetaTemplates to be overriden when patching entity + $data = $massagedData['entity']; + $metaFieldsToDelete = $massagedData['metafields_to_delete']; + foreach ($entity->meta_fields as $i => $metaField) { + if ($metaField->meta_template_id == $template_id) { + $metaFieldsToDelete[] = $entity->meta_fields[$i]; + } + } + $data = $entityTable->patchEntity($data, $inputData); + $savedData = $entityTable->save($data); + if ($savedData !== false) { + if (!empty($metaFieldsToDelete)) { + $entityTable->MetaFields->unlink($savedData, $metaFieldsToDelete); + } + $message = __('Data on old meta-template has been migrated to newest meta-template'); + } else { + $message = __('Could not migrate data to newest meta-template'); + } + $this->CRUD->setResponseForController( + 'migrateOldMetaTemplateToNewestVersionForEntity', + $savedData !== false, + $message, + $savedData, + [], + [ + 'redirect' => [ + 'controller' => $className, + 'action' => 'view', $entity_id, + 'url' => Router::url(['controller' => $className, 'action' => 'view', $entity_id]) + ] + ] + ); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $conflicts = $this->MetaTemplates->getMetaFieldsConflictsUnderTemplate($entity->meta_fields, $newestMetaTemplate); + foreach ($conflicts as $conflict) { + if (!empty($conflict['existing_meta_template_field'])) { + $existingMetaTemplateField = $conflict['existing_meta_template_field']; + foreach ($existingMetaTemplateField->metaFields as $metaField) { + $metaField->setError('value', implode(', ', $existingMetaTemplateField->conflicts)); + } + } + } + // automatically convert non-conflicting fields to new meta-template + $movedMetaTemplateFields = []; + foreach ($metaTemplate->meta_template_fields as $metaTemplateField) { + if (!empty($conflicts[$metaTemplateField->field]['conflicts'])) { + continue; + } + foreach ($newestMetaTemplate->meta_template_fields as $i => $newMetaTemplateField) { + if ($metaTemplateField->field == $newMetaTemplateField->field && empty($newMetaTemplateField->metaFields)) { + $movedMetaTemplateFields[] = $metaTemplateField->id; + $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); + $newMetaTemplateField->metaFields = $this->MetaTemplates->MetaTemplateFields->MetaFields->newEntities($copiedMetaFields); + } + } + } + $this->set('oldMetaTemplate', $metaTemplate); + $this->set('newMetaTemplate', $newestMetaTemplate); + $this->set('entity', $entity); + $this->set('conflicts', $conflicts); + $this->set('movedMetaTemplateFields', $movedMetaTemplateFields); + } + public function index() { + $templatesUpdateStatus = $this->MetaTemplates->getUpdateStatusForTemplates(); $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, 'contextFilters' => [ - 'fields' => ['scope'], 'custom' => [ [ - 'label' => __('Contact DB'), - 'filterCondition' => ['scope' => ['individual', 'organisation']] - ], - [ - 'label' => __('Namespace CNW'), - 'filterCondition' => ['namespace' => 'cnw'] + 'default' => true, + 'label' => __('Latest Templates'), + 'filterConditionFunction' => function ($query) { + return $query->where([ + 'id IN' => $this->MetaTemplates->genQueryForAllNewestVersionIDs() + ]); + } ], ] ], - 'contain' => $this->containFields + 'contain' => $this->containFields, + 'afterFind' => function($metaTemplate) use ($templatesUpdateStatus) { + if (!empty($templatesUpdateStatus[$metaTemplate->uuid])) { + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templatesUpdateStatus[$metaTemplate->uuid]['template'], $metaTemplate); + $metaTemplate->set('updateStatus', $this->MetaTemplates->computeFullUpdateStatusForMetaTemplate($templateStatus, $metaTemplate)); + } + return $metaTemplate; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; } + $updateableTemplates = [ + 'not-up-to-date' => $this->MetaTemplates->getNotUpToDateTemplates(), + 'can-be-removed' => $this->MetaTemplates->getCanBeRemovedTemplates(), + 'new' => $this->MetaTemplates->getNewTemplates(), + ]; $this->set('defaultTemplatePerScope', $this->MetaTemplates->getDefaultTemplatePerScope()); $this->set('alignmentScope', 'individuals'); - $this->set('metaGroup', 'Administration'); + $this->set('updateableTemplates', $updateableTemplates); } public function view($id) @@ -72,7 +295,25 @@ class MetaTemplatesController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Administration'); + $this->setUpdateStatus($id); + } + + public function delete($id) + { + $metaTemplate = $this->MetaTemplates->get($id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $templateOnDisk = $this->MetaTemplates->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + if (empty($templateStatus['can-be-removed'])) { + throw new MethodNotAllowedException(__('This meta-template cannot be removed')); + } + $this->set('deletionText', __('The meta-template "{0}" has no meta-field and can be safely removed.', h($templateStatus['existing_template']->name))); + $this->CRUD->delete($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } } public function toggle($id, $fieldName = 'enabled') @@ -89,4 +330,35 @@ class MetaTemplatesController extends AppController return $responsePayload; } } + + private function getUpdateStatus($id): array + { + $metaTemplate = $this->MetaTemplates->get($id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $templateOnDisk = $this->MetaTemplates->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + return $templateStatus; + } + + /** + * Retreive the template stored on disk and compute the status for the provided template id. + * + * @param [type] $id + * @return array + */ + private function setUpdateStatus($template_id): array + { + $metaTemplate = $this->MetaTemplates->get($template_id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $templateOnDisk = $this->MetaTemplates->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + $this->set('templateOnDisk', $templateOnDisk); + $this->set('templateStatus', $templateStatus); + return [ + 'templateOnDisk' => $templateOnDisk, + 'templateStatus' => $templateStatus, + ]; + } } diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index a45f5f3..3591a68 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -16,12 +16,14 @@ class OrganisationsController extends AppController public $quickFilterFields = [['name' => true], 'uuid', 'nationality', 'sector', 'type', 'url']; public $filterFields = ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id', 'MetaFields.field', 'MetaFields.value', 'MetaFields.MetaTemplates.name']; public $containFields = ['Alignments' => 'Individuals']; + public $statisticsFields = ['nationality', 'sector']; public function index() { $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, + 'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true], 'contextFilters' => [ 'custom' => [ [ @@ -59,7 +61,8 @@ class OrganisationsController extends AppController ] ], ], - 'contain' => $this->containFields + 'contain' => $this->containFields, + 'statisticsFields' => $this->statisticsFields, ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Controller/SharingGroupsController.php b/src/Controller/SharingGroupsController.php index e03aee5..15d41e8 100644 --- a/src/Controller/SharingGroupsController.php +++ b/src/Controller/SharingGroupsController.php @@ -19,20 +19,32 @@ class SharingGroupsController extends AppController { $currentUser = $this->ACL->getUser(); $conditions = []; - if (empty($currentUser['role']['perm_admin'])) { - $conditions['SharingGroups.organisation_id'] = $currentUser['organisation_id']; - } $this->CRUD->index([ 'contain' => $this->containFields, 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, - 'conditions' => $conditions + 'conditions' => $conditions, + 'afterFind' => function ($row) use ($currentUser) { + if (empty($currentUser['role']['perm_admin'])) { + $orgFound = false; + if (!empty($row['sharing_group_orgs'])) { + foreach ($row['sharing_group_orgs'] as $org) { + if ($org['id'] === $currentUser['organisation_id']) { + $orgFound = true; + } + } + } + if ($row['organisation_id'] !== $currentUser['organisation_id'] && !$orgFound) { + return false; + } + } + return $row; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Trust Circles'); } public function add() @@ -57,7 +69,6 @@ class SharingGroupsController extends AppController return $responsePayload; } $this->set(compact('dropdownData')); - $this->set('metaGroup', 'Trust Circles'); } public function view($id) @@ -67,7 +78,7 @@ class SharingGroupsController extends AppController 'contain' => ['SharingGroupOrgs', 'Organisations', 'Users' => ['fields' => ['id', 'username']]], 'afterFind' => function($data) use ($currentUser) { if (empty($currentUser['role']['perm_admin'])) { - $orgFround = false; + $orgFound = false; if (!empty($data['sharing_group_orgs'])) { foreach ($data['sharing_group_orgs'] as $org) { if ($org['id'] === $currentUser['organisation_id']) { @@ -75,7 +86,7 @@ class SharingGroupsController extends AppController } } } - if ($data['organisation_id'] !== $currentUser['organisation_id'] && !$orgFround) { + if ($data['organisation_id'] !== $currentUser['organisation_id'] && !$orgFound) { return null; } } @@ -86,7 +97,6 @@ class SharingGroupsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Trust Circles'); } public function edit($id = false) @@ -106,13 +116,13 @@ class SharingGroupsController extends AppController 'organisation' => $this->getAvailableOrgForSg($this->ACL->getUser()) ]; $this->set(compact('dropdownData')); - $this->set('metaGroup', 'Trust Circles'); $this->render('add'); } public function delete($id) { $currentUser = $this->ACL->getUser(); + $params = []; if (empty($currentUser['role']['perm_admin'])) { $params['conditions'] = ['organisation_id' => $currentUser['organisation_id']]; } @@ -121,7 +131,6 @@ class SharingGroupsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Trust Circles'); } public function addOrg($id) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 27687ba..800dbd8 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -2,10 +2,7 @@ namespace App\Controller; use App\Controller\AppController; -use Cake\Utility\Hash; -use Cake\Utility\Text; use Cake\ORM\TableRegistry; -use \Cake\Database\Expression\QueryExpression; use Cake\Http\Exception\UnauthorizedException; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Core\Configure; @@ -163,11 +160,6 @@ class UsersController extends AppController } $params = [ - 'get' => [ - 'fields' => [ - 'id', 'individual_id', 'role_id', 'disabled', 'username' - ] - ], 'removeEmpty' => [ 'password' ], @@ -175,12 +167,15 @@ class UsersController extends AppController 'password', 'confirm_password' ] ]; - if (!empty($this->ACL->getUser()['role']['perm_admin'])) { + if ($this->request->is(['get'])) { + $params['fields'] = array_merge($params['fields'], ['individual_id', 'role_id', 'disabled', 'username']); + } + if ($this->request->is(['post', 'put']) && !empty($this->ACL->getUser()['role']['perm_admin'])) { $params['fields'][] = 'individual_id'; $params['fields'][] = 'role_id'; $params['fields'][] = 'organisation_id'; $params['fields'][] = 'disabled'; - } else if (!empty($this->ACL->getUser()['role']['perm_org_admin'])) { + } else if ($this->request->is(['post', 'put']) && !empty($this->ACL->getUser()['role']['perm_org_admin'])) { $params['fields'][] = 'role_id'; $params['fields'][] = 'disabled'; if (!$currentUser['role']['perm_admin']) { diff --git a/src/Lib/Tools/CidrTool.php b/src/Lib/Tools/CidrTool.php new file mode 100644 index 0000000..dda7f87 --- /dev/null +++ b/src/Lib/Tools/CidrTool.php @@ -0,0 +1,147 @@ +filterInputList($list); + } + + /** + * @param string $value IPv4 or IPv6 address or range + * @return false|string + */ + public function contains($value) + { + $valueMask = null; + if (strpos($value, '/') !== false) { + list($value, $valueMask) = explode('/', $value); + } + + $match = false; + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + // This code converts IP address to all possible CIDRs that can contains given IP address + // and then check if given hash table contains that CIDR. + $ip = ip2long($value); + // Start from 1, because doesn't make sense to check 0.0.0.0/0 match + for ($bits = $this->minimumIpv4Mask; $bits <= 32; $bits++) { + $mask = -1 << (32 - $bits); + $needle = long2ip($ip & $mask) . "/$bits"; + if (isset($this->ipv4[$needle])) { + $match = $needle; + break; + } + } + } elseif (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $value = unpack('n*', inet_pton($value)); + foreach ($this->ipv6 as $netmask => $lv) { + foreach ($lv as $l) { + if ($this->ipv6InCidr($value, $l, $netmask)) { + $match = inet_ntop($l) . "/$netmask"; + break; + } + } + } + } + + if ($match && $valueMask) { + $matchMask = explode('/', $match)[1]; + if ($valueMask < $matchMask) { + return false; + } + } + + return $match; + } + + /** + * @param string $cidr + * @return bool + */ + public static function validate($cidr) + { + $parts = explode('/', $cidr, 2); + $ipBytes = inet_pton($parts[0]); + if ($ipBytes === false) { + return false; + } + + $maximumNetmask = strlen($ipBytes) === 4 ? 32 : 128; + if (isset($parts[1]) && ($parts[1] > $maximumNetmask || $parts[1] < 0)) { + return false; // Netmask part of CIDR is invalid + } + + return true; + } + + /** + * Using solution from https://github.com/symfony/symfony/blob/master/src/Symfony/Component/HttpFoundation/IpUtils.php + * + * @param array $ip + * @param string $cidr + * @param int $netmask + * @return bool + */ + private function ipv6InCidr($ip, $cidr, $netmask) + { + $bytesAddr = unpack('n*', $cidr); + for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) { + $left = $netmask - 16 * ($i - 1); + $left = ($left <= 16) ? $left : 16; + $mask = ~(0xffff >> $left) & 0xffff; + if (($bytesAddr[$i] & $mask) != ($ip[$i] & $mask)) { + return false; + } + } + + return true; + } + + /** + * Filter out invalid IPv4 or IPv4 CIDR and append maximum netmask if no netmask is given. + * @param array $list + */ + private function filterInputList(array $list) + { + foreach ($list as $v) { + $parts = explode('/', $v, 2); + $ipBytes = inet_pton($parts[0]); + if ($ipBytes === false) { + continue; // IP address part of CIDR is invalid + } + $maximumNetmask = strlen($ipBytes) === 4 ? 32 : 128; + + if (isset($parts[1]) && ($parts[1] > $maximumNetmask || $parts[1] < 0)) { + // Netmask part of CIDR is invalid + continue; + } + + $mask = isset($parts[1]) ? $parts[1] : $maximumNetmask; + if ($maximumNetmask === 32) { + if ($mask < $this->minimumIpv4Mask) { + $this->minimumIpv4Mask = (int)$mask; + } + if (!isset($parts[1])) { + $v = "$v/$maximumNetmask"; // If CIDR doesnt contains '/', we will consider CIDR as /32 + } + $this->ipv4[$v] = true; + } else { + $this->ipv6[$mask][] = $ipBytes; + } + } + } +} diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index 66f5024..a5adb59 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -305,70 +305,71 @@ class MispConnector extends CommonConnectorTools $response = $this->getData('/servers/serverSettings', $params); $data = $response->getJson(); if (!empty($data['finalSettings'])) { - $finalSettings = [ - 'type' => 'index', - 'data' => [ - 'data' => $data['finalSettings'], - 'skip_pagination' => 1, - 'top_bar' => [ - 'children' => [ + $finalSettings = [ + 'type' => 'index', + 'data' => [ + 'data' => $data['finalSettings'], + 'skip_pagination' => 1, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'search', + 'button' => __('Search'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value', + 'additionalUrlParams' => $urlParams + ] + ] + ], + 'fields' => [ [ - 'type' => 'search', - 'button' => __('Filter'), - 'placeholder' => __('Enter value to search'), - 'data' => '', - 'searchKey' => 'value', - 'additionalUrlParams' => $urlParams + 'name' => 'Setting', + 'sort' => 'setting', + 'data_path' => 'setting', + ], + [ + 'name' => 'Criticality', + 'sort' => 'level', + 'data_path' => 'level', + 'arrayData' => [ + 0 => 'Critical', + 1 => 'Recommended', + 2 => 'Optional' + ], + 'element' => 'array_lookup_field' + ], + [ + 'name' => __('Value'), + 'sort' => 'value', + 'data_path' => 'value', + 'options' => 'options' + ], + [ + 'name' => __('Type'), + 'sort' => 'type', + 'data_path' => 'type', + ], + [ + 'name' => __('Error message'), + 'sort' => 'errorMessage', + 'data_path' => 'errorMessage', + ] + ], + 'title' => false, + 'description' => false, + 'pull' => 'right', + 'actions' => [ + [ + 'open_modal' => '/localTools/action/' . h($params['connection']['id']) . '/modifySettingAction?setting={{0}}', + 'modal_params_data_path' => ['setting'], + 'icon' => 'download', + 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/ServerSettingsAction' ] ] ], - 'fields' => [ - [ - 'name' => 'Setting', - 'sort' => 'setting', - 'data_path' => 'setting', - ], - [ - 'name' => 'Criticality', - 'sort' => 'level', - 'data_path' => 'level', - 'arrayData' => [ - 0 => 'Critical', - 1 => 'Recommended', - 2 => 'Optional' - ], - 'element' => 'array_lookup_field' - ], - [ - 'name' => __('Value'), - 'sort' => 'value', - 'data_path' => 'value', - 'options' => 'options' - ], - [ - 'name' => __('Type'), - 'sort' => 'type', - 'data_path' => 'type', - ], - [ - 'name' => __('Error message'), - 'sort' => 'errorMessage', - 'data_path' => 'errorMessage', - ] - ], - 'title' => false, - 'description' => false, - 'pull' => 'right', - 'actions' => [ - [ - 'open_modal' => '/localTools/action/' . h($params['connection']['id']) . '/modifySettingAction?setting={{0}}', - 'modal_params_data_path' => ['setting'], - 'icon' => 'download', - 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/ServerSettingsAction' - ] - ] - ] - ]; + ]; + if (!empty($params['quickFilter'])) { $needle = strtolower($params['quickFilter']); foreach ($finalSettings['data']['data'] as $k => $v) { @@ -404,7 +405,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', @@ -494,7 +495,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', @@ -572,7 +573,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', @@ -640,7 +641,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php b/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php index 362c514..eefbd7a 100644 --- a/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php +++ b/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php @@ -103,7 +103,7 @@ class SkeletonConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/src/Lib/default/meta_fields_types/IPv4Type.php b/src/Lib/default/meta_fields_types/IPv4Type.php new file mode 100644 index 0000000..c25acd7 --- /dev/null +++ b/src/Lib/default/meta_fields_types/IPv4Type.php @@ -0,0 +1,99 @@ +_isValidIP($value) || $this->_isValidIP(explode('/', $value)[0]); + } + + public function setQueryExpression(QueryExpression $exp, string $searchValue, \App\Model\Entity\MetaTemplateField $metaTemplateField): QueryExpression + { + if (strpos($searchValue, '%') !== false) { + $textHandler = new TextType(); // we are wildcard filtering, use text filter instead + return $textHandler->setQueryExpression($exp, $searchValue, $metaTemplateField); + } + $allMetaValues = $this->fetchAllValuesForThisType([], $metaTemplateField); + $isNegation = false; + if (substr($searchValue, 0, 1) == '!') { + $searchValue = substr($searchValue, 1); + $isNegation = true; + } + + foreach ($allMetaValues as $fieldID => $ip) { + $cidrTool = new CidrTool([$ip]); + if ($cidrTool->contains($searchValue) === false) { + if (!$isNegation) { + unset($allMetaValues[$fieldID]); + } + } else if ($isNegation) { + unset($allMetaValues[$fieldID]); + } + } + $matchingIDs = array_keys($allMetaValues); + if (!empty($matchingIDs)) { + $exp->in('MetaFields.id', $matchingIDs); + } else { + $exp->eq('MetaFields.id', -1); // No matching meta-fields, generate an impossible condition to return nothing + } + return $exp; + } + + protected function fetchAllMetatemplateFieldsIdForThisType(\App\Model\Entity\MetaTemplateField $metaTemplateField = null): Query + { + $this->MetaTemplateFields = TableRegistry::getTableLocator()->get('MetaTemplateFields'); + $conditions = []; + if (!is_null($metaTemplateField)) { + $conditions['id'] = $metaTemplateField->id; + } else { + $conditions['type'] = $this::TYPE; + } + $query = $this->MetaTemplateFields->find()->select(['id']) + ->distinct() + ->where($conditions); + return $query; + } + + protected function fetchAllValuesForThisType(array $conditions=[], \App\Model\Entity\MetaTemplateField $metaTemplateField=null): array + { + $metaTemplateFieldsIDs = $this->fetchAllMetatemplateFieldsIdForThisType($metaTemplateField); + if (empty($metaTemplateFieldsIDs)) { + return []; + } + $conditions = array_merge($conditions, ['meta_template_field_id IN' => $metaTemplateFieldsIDs]); + $allMetaValues = $this->MetaFields->find('list', [ + 'keyField' => 'id', + 'valueField' => 'value' + ])->where($conditions)->toArray(); + return $allMetaValues; + } + + protected function _isValidIP(string $value): bool + { + return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + } +} diff --git a/src/Lib/default/meta_fields_types/IPv6Type.php b/src/Lib/default/meta_fields_types/IPv6Type.php new file mode 100644 index 0000000..f5d181b --- /dev/null +++ b/src/Lib/default/meta_fields_types/IPv6Type.php @@ -0,0 +1,21 @@ +MetaFields = TableRegistry::getTableLocator()->get('MetaFields'); + } + + public function validate(string $value): bool + { + return is_string($value); + } + + public function setQueryExpression(QueryExpression $exp, string $searchValue, \App\Model\Entity\MetaTemplateField $metaTemplateField): QueryExpression + { + $field = 'MetaFields.value'; + if (substr($searchValue, 0, 1) == '!') { + $searchValue = substr($searchValue, 1); + $exp->notEq($field, $searchValue); + } else if (strpos($searchValue, '%') !== false) { + $exp->like($field, $searchValue); + } else { + $exp->eq($field, $searchValue); + } + return $exp; + } +} diff --git a/src/Model/Behavior/MetaFieldsBehavior.php b/src/Model/Behavior/MetaFieldsBehavior.php new file mode 100644 index 0000000..6f3a46e --- /dev/null +++ b/src/Model/Behavior/MetaFieldsBehavior.php @@ -0,0 +1,259 @@ + [ + 'className' => 'MetaFields', + 'foreignKey' => 'parent_id', + 'bindingKey' => 'id', + 'dependent' => true, + 'cascadeCallbacks' => true, + 'saveStrategy' => 'append', + 'propertyName' => 'meta_fields', + ], + 'modelAssoc' => [ + 'foreignKey' => 'parent_id', + 'bindingKey' => 'id', + ], + 'metaTemplateFieldCounter' => ['counter'], + + 'implementedEvents' => [ + 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.beforeFind' => 'beforeFind', + 'Model.beforeSave' => 'beforeSave', + ], + 'implementedMethods' => [ + 'normalizeMetafields' => 'normalizeMetafields', + 'buildMetaFieldQuerySnippetForMatchingParent' => 'buildQuerySnippetForMatchingParent', + ], + 'implementedFinders' => [ + 'metafield' => 'findMetafield', + ], + ]; + + private $aliasScope = null; + private $typeHandlers = []; + + public function initialize(array $config): void + { + $this->bindAssociations(); + $this->_metaTemplateFieldTable = $this->_table->MetaFields->MetaTemplateFields; + $this->_metaTemplateTable = $this->_table->MetaFields->MetaTemplates; + $this->loadTypeHandlers(); + } + + private function loadTypeHandlers() + { + if (empty($this->typeHandlers)) { + $this->typeHandlers = $this->_metaTemplateFieldTable->getTypeHandlers(); + } + } + + public function getTypeHandlers(): array + { + return $this->typeHandlers; + } + + public function getScope() + { + if (is_null($this->aliasScope)) { + $this->aliasScope = Inflector::underscore(Inflector::singularize($this->_table->getAlias())); + } + return $this->aliasScope; + + } + + public function bindAssociations() + { + $config = $this->getConfig(); + $metaFieldsAssoc = $config['metaFieldsAssoc']; + $modelAssoc = $config['modelAssoc']; + + $table = $this->_table; + $tableAlias = $this->_table->getAlias(); + + $assocConditions = [ + 'MetaFields.scope' => $this->getScope() + ]; + if (!$table->hasAssociation('MetaFields')) { + $table->hasMany('MetaFields', array_merge( + $metaFieldsAssoc, + [ + 'conditions' => $assocConditions + ] + )); + } + + if (!$table->MetaFields->hasAssociation($tableAlias)) { + $table->MetaFields->belongsTo($tableAlias, array_merge( + $modelAssoc, + [ + 'className' => get_class($table), + ] + )); + } + } + + public function beforeMarshal($event, $data, $options) + { + $property = $this->getConfig('metaFieldsAssoc.propertyName'); + $options['accessibleFields'][$property] = true; + $options['associated']['MetaFields']['accessibleFields']['id'] = true; + + if (isset($data[$property])) { + if (!empty($data[$property])) { + $data[$property] = $this->normalizeMetafields($data[$property]); + } + } + } + + public function beforeSave($event, $entity, $options) + { + if (empty($entity->metaFields)) { + return; + } + } + + public function normalizeMetafields($metaFields) + { + return $metaFields; + } + + /** + * Usage: + * $this->{$model}->find('metaField', [ + * ['meta_template_id' => 1, 'field' => 'email', 'value' => '%@domain.test'], + * ['meta_template_id' => 1, 'field' => 'country_code', 'value' => '!LU'], + * ['meta_template_id' => 1, 'field' => 'time_zone', 'value' => 'UTC+2'], + * ]) + * $this->{$model}->find('metaField', [ + * 'AND' => [ + * ['meta_template_id' => 1, 'field' => 'email', 'value' => '%@domain.test'], + * 'OR' => [ + * ['meta_template_id' => 1, 'field' => 'time_zone', 'value' => 'UTC+1'], + * ['meta_template_id' => 1, 'field' => 'time_zone', 'value' => 'UTC+2'], + * ], + * ], + * ]) + */ + public function findMetafield(Query $query, array $filters) + { + $conditions = $this->buildQuerySnippetForMatchingParent($filters); + $query->where($conditions); + return $query; + } + + public function buildQuerySnippetForMatchingParent(array $filters): array + { + if (empty($filters)) { + return []; + } + if (count(array_filter(array_keys($filters), 'is_string'))) { + $filters = [$filters]; + } + $conjugatedFilters = $this->buildConjugatedFilters($filters); + $conditions = $this->buildConjugatedQuerySnippet($conjugatedFilters); + return $conditions; + } + + protected function buildConjugatedFilters(array $filters): array + { + $conjugatedFilters = []; + foreach ($filters as $operator => $subFilters) { + if (is_numeric($operator)) { + $conjugatedFilters[] = $subFilters; + } else { + if (!empty($subFilters)) { + $conjugatedFilters[$operator] = $this->buildConjugatedFilters($subFilters); + } + } + } + return $conjugatedFilters; + } + + protected function buildConjugatedQuerySnippet(array $conjugatedFilters, string $parentOperator='AND'): array + { + $conditions = []; + if (empty($conjugatedFilters['AND']) && empty($conjugatedFilters['OR'])) { + if (count(array_filter(array_keys($conjugatedFilters), 'is_string')) > 0) { + $conditions = $this->buildComposedQuerySnippet([$conjugatedFilters]); + } else { + $conditions = $this->buildComposedQuerySnippet($conjugatedFilters, $parentOperator); + } + } else { + foreach ($conjugatedFilters as $subOperator => $subFilter) { + $conditions[$subOperator] = $this->buildConjugatedQuerySnippet($subFilter, $subOperator); + } + } + return $conditions; + } + + protected function buildComposedQuerySnippet(array $filters, string $operator='AND'): array + { + $conditions = []; + foreach ($filters as $filterOperator => $filter) { + $subQuery = $this->buildQuerySnippet($filter, true); + $modelAlias = $this->_table->getAlias(); + $conditions[$operator][] = [$modelAlias . '.id IN' => $subQuery]; + } + return $conditions; + } + + + protected function setQueryExpressionForTextField(QueryExpression $exp, string $field, string $value): QueryExpression + { + if (substr($value, 0, 1) == '!') { + $value = substr($value, 1); + $exp->notEq($field, $value); + } else if (strpos($value, '%') !== false) { + $exp->like($field, $value); + } else { + $exp->eq($field, $value); + } + return $exp; + } + + protected function buildQuerySnippet(array $filter): Query + { + $metaTemplateField = !empty($filter['meta_template_field_id']) ? $this->_metaTemplateFieldTable->get($filter['meta_template_field_id']) : null; + $whereClosure = function (QueryExpression $exp) use ($filter, $metaTemplateField) { + foreach ($filter as $column => $value) { + $keyedColumn = 'MetaFields.' . $column; + if ($column == 'value') { + $this->setQueryExpressionForField($exp, $keyedColumn, $value, $metaTemplateField); + } else { + $this->setQueryExpressionForTextField($exp, $keyedColumn, $value); + } + } + return $exp; + }; + + $foreignKey = $this->getConfig('modelAssoc.foreignKey'); + $query = $this->_table->MetaFields->find() + ->select('MetaFields.' . $foreignKey) + ->where($whereClosure); + return $query; + } + + protected function setQueryExpressionForField(QueryExpression $exp, string $field, string $value, \App\Model\Entity\MetaTemplateField $metaTemplateField=null): QueryExpression + { + if (!is_null($metaTemplateField) && isset($this->typeHandlers[$metaTemplateField->type])) { + $exp = $this->typeHandlers[$metaTemplateField->type]->setQueryExpression($exp, $value, $metaTemplateField); + } else { + $exp = $this->setQueryExpressionForTextField($exp, $field, $value); + } + return $exp; + } +} diff --git a/src/Model/Entity/Individual.php b/src/Model/Entity/Individual.php index dcf932f..cd1cc9d 100644 --- a/src/Model/Entity/Individual.php +++ b/src/Model/Entity/Individual.php @@ -19,7 +19,7 @@ class Individual extends AppModel 'created' => true, ]; - protected $_virtual = ['full_name']; + protected $_virtual = ['full_name', 'alternate_emails']; protected function _getFullName() { @@ -28,4 +28,17 @@ class Individual extends AppModel } return sprintf("%s %s", $this->first_name, $this->last_name); } + + protected function _getAlternateEmails() + { + $emails = []; + if (!empty($this->meta_fields)) { + foreach ($this->meta_fields as $metaField) { + if (str_contains($metaField->field, 'email')) { + $emails[] = $metaField; + } + } + } + return $emails; + } } diff --git a/src/Model/Entity/MailingList.php b/src/Model/Entity/MailingList.php new file mode 100644 index 0000000..02ff121 --- /dev/null +++ b/src/Model/Entity/MailingList.php @@ -0,0 +1,51 @@ + true, + 'id' => false, + 'uuid' => false, + 'user_id' => false, + ]; + + protected $_accessibleOnNew = [ + 'uuid' => true, + 'user_id' => true, + ]; + + private $metaFieldsByParentId = []; + + public function injectRegisteredEmailsIntoIndividuals() + { + if (empty($this->individuals)) { + return; + } + if (!empty($this->meta_fields)) { + foreach ($this->meta_fields as $meta_field) { + $this->metaFieldsByParentId[$meta_field->parent_id][] = $meta_field; + } + } + foreach ($this->individuals as $i => $individual) { + $this->individuals[$i]->mailinglist_emails = $this->collectEmailsForMailingList($individual); + } + } + + protected function collectEmailsForMailingList($individual) + { + $emails = []; + if (!empty($individual['_joinData']) && !empty($individual['_joinData']['include_primary_email'])) { + $emails[] = $individual->email; + } + if (!empty($this->metaFieldsByParentId[$individual->id])) { + foreach ($this->metaFieldsByParentId[$individual->id] as $metaField) { + $emails[] = $metaField->value; + } + } + return $emails; + } +} diff --git a/src/Model/Entity/MetaTemplateField.php b/src/Model/Entity/MetaTemplateField.php new file mode 100644 index 0000000..7baf30a --- /dev/null +++ b/src/Model/Entity/MetaTemplateField.php @@ -0,0 +1,11 @@ + 5, + 'includeOthers' => true, + 'ignoreNull' => true, + ]; + $options = $this->getOptions($defaultOptions, $options); + $stats = []; + foreach ($scopes as $scope) { + $queryTopUsage = $table->find(); + $queryTopUsage + ->select([ + $scope, + 'count' => $queryTopUsage->func()->count('id'), + ]); + if ($queryTopUsage->getDefaultTypes()[$scope] != 'boolean') { + $queryTopUsage->where(function (QueryExpression $exp) use ($scope) { + return $exp + ->isNotNull($scope) + ->notEq($scope, ''); + }); + } + $queryTopUsage + ->group($scope) + ->order(['count' => 'DESC']) + ->limit($options['limit']) + ->page(1) + ->enableHydration(false); + $topUsage = $queryTopUsage->toList(); + $stats[$scope] = $topUsage; + if ( + !empty($options['includeOthers']) && !empty($topUsage) && + $queryTopUsage->getDefaultTypes()[$scope] != 'boolean' // No need to get others as we only have 2 possibilities already considered + ) { + $queryOthersUsage = $table->find(); + $queryOthersUsage + ->select([ + 'count' => $queryOthersUsage->func()->count('id'), + ]) + ->where(function (QueryExpression $exp, Query $query) use ($topUsage, $scope, $options) { + if (!empty($options['ignoreNull'])) { + return $exp + ->isNotNull($scope) + ->notEq($scope, '') + ->notIn($scope, Hash::extract($topUsage, "{n}.{$scope}")); + } else { + return $exp->or([ + $query->newExpr()->isNull($scope), + $query->newExpr()->eq($scope, ''), + $query->newExpr()->notIn($scope, Hash::extract($topUsage, "{n}.{$scope}")), + ]); + } + }) + ->enableHydration(false); + $othersUsage = $queryOthersUsage->toList(); + if (!empty($othersUsage)) { + $stats[$scope][] = [ + $scope => __('Others'), + 'count' => $othersUsage[0]['count'], + ]; + } + } + } + return $stats; + } + + private function getOptions($defaults=[], $options=[]): array + { + return array_merge($defaults, $options); + } + + // Move this into a tool + public function getActivityStatisticsForModel(Object $table, int $days = 30): array + { + $statistics = []; + if ($table->hasBehavior('Timestamp')) { + if ($table->getSchema()->getColumnType('created') == 'datetime') { + $statistics['created'] = $this->getActivityStatistic($table, $days, 'created'); + } + if ($table->getSchema()->getColumnType('modified') == 'datetime') { + $statistics['modified'] = $this->getActivityStatistic($table, $days, 'modified'); + } + } + return $statistics; + } + + public function getActivityStatistic(Object $table, int $days = 30, string $field = 'modified', bool $includeTimeline = true): array + { + $statistics = []; + $statistics['days'] = $days; + $statistics['amount'] = $table->find()->all()->count(); + if ($table->behaviors()->has('Timestamp') && $includeTimeline) { + $statistics['timeline'] = $this->buildTimeline($table, $days, $field); + $statistics['variation'] = $table->find()->where(["{$field} >" => FrozenTime::now()->subDays($days)])->all()->count(); + } else { + $statistics['timeline'] = []; + $statistics['variation'] = 0; + } + return $statistics; + } + + public function buildTimeline(Object $table, int $days = 30, string $field = 'modified'): array + { + $timeline = []; + $authorizedFields = ['modified', 'created']; + if ($table->behaviors()->has('Timestamp')) { + if (!in_array($field, $authorizedFields)) { + throw new MethodNotAllowedException(__('Cannot construct timeline for field `{0}`', $field)); + } + $days = $days - 1; + $query = $table->find(); + $query->select([ + 'count' => $query->func()->count('id'), + 'date' => "DATE({$field})", + ]) + ->where(["{$field} >" => FrozenTime::now()->subDays($days)]) + ->group(['date']) + ->order(['date']); + $data = $query->all()->toArray(); + $interval = new \DateInterval('P1D'); + $period = new \DatePeriod(FrozenTime::now()->subDays($days), $interval, FrozenTime::now()->addDays(1)); + foreach ($period as $date) { + $timeline[$date->format("Y-m-d")] = [ + 'time' => $date->format("Y-m-d"), + 'count' => 0 + ]; + } + foreach ($data as $entry) { + $timeline[$entry->date]['count'] = $entry->count; + } + $timeline = array_values($timeline); + } + return $timeline; + } + public function saveMetaFields($id, $input) { $this->MetaFields = TableRegistry::getTableLocator()->get('MetaFields'); diff --git a/src/Model/Table/AuditLogsTable.php b/src/Model/Table/AuditLogsTable.php index 7987af6..e6f6f95 100644 --- a/src/Model/Table/AuditLogsTable.php +++ b/src/Model/Table/AuditLogsTable.php @@ -43,14 +43,8 @@ class AuditLogsTable extends AppTable public function initialize(array $config): void { parent::initialize($config); - $this->addBehavior('UUID'); - $this->addBehavior('Timestamp', [ - 'Model.beoreSave' => [ - 'created_at' => 'new' - ] - ]); + $this->addBehavior('Timestamp'); $this->belongsTo('Users'); - $this->setDisplayField('info'); $this->compressionEnabled = Configure::read('Cerebrate.log_new_audit_compress') && function_exists('brotli_compress'); } diff --git a/src/Model/Table/InboxTable.php b/src/Model/Table/InboxTable.php index a2c0e2c..0617cea 100644 --- a/src/Model/Table/InboxTable.php +++ b/src/Model/Table/InboxTable.php @@ -9,6 +9,8 @@ use Cake\ORM\RulesChecker; use Cake\Validation\Validator; use Cake\Http\Exception\NotFoundException; +use App\Utility\UI\Notification; + Type::map('json', 'Cake\Database\Type\JsonType'); class InboxTable extends AppTable @@ -89,4 +91,44 @@ class InboxTable extends AppTable $savedEntry = $this->save($entryData); return $savedEntry; } + + public function collectNotifications(\App\Model\Entity\User $user): array + { + $allNotifications = []; + $inboxNotifications = $this->getNotificationsForUser($user); + foreach ($inboxNotifications as $notification) { + $title = __('New message'); + $details = $notification->title; + $router = [ + 'controller' => 'inbox', + 'action' => 'process', + 'plugin' => null, + $notification->id + ]; + $allNotifications[] = (new Notification($title, $router, [ + 'icon' => 'envelope', + 'details' => $details, + 'datetime' => $notification->created, + 'variant' => 'warning', + '_useModal' => true, + '_sidebarId' => 'inbox', + ]))->get(); + } + return $allNotifications; + } + + public function getNotificationsForUser(\App\Model\Entity\User $user): array + { + $query = $this->find(); + $conditions = []; + if ($user['role']['perm_admin']) { + // Admin will not see notifications if it doesn't belong to them. They can see process the message from the inbox + $conditions['Inbox.user_id IS'] = null; + } else { + $conditions['Inbox.user_id'] = $user->id; + } + $query->where($conditions); + $notifications = $query->all()->toArray(); + return $notifications; + } } diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index de5b8e8..6ee0fd7 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -10,15 +10,15 @@ use Cake\ORM\Query; class IndividualsTable extends AppTable { - public $metaFields = 'individual'; - public function initialize(array $config): void { parent::initialize($config); $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); $this->addBehavior('Tags.Tag'); + $this->addBehavior('MetaFields'); $this->addBehavior('AuditLog'); + $this->hasMany( 'Alignments', [ @@ -39,6 +39,10 @@ class IndividualsTable extends AppTable $this->belongsToMany('Organisations', [ 'through' => 'Alignments', ]); + $this->belongsToMany('MailingLists', [ + 'through' => 'mailing_lists_individuals', + ]); + $this->setDisplayField('email'); } diff --git a/src/Model/Table/InstanceTable.php b/src/Model/Table/InstanceTable.php index 299536b..624fe7a 100644 --- a/src/Model/Table/InstanceTable.php +++ b/src/Model/Table/InstanceTable.php @@ -9,18 +9,18 @@ use Cake\Validation\Validator; use Migrations\Migrations; use Cake\Filesystem\Folder; use Cake\Http\Exception\MethodNotAllowedException; -use Cake\I18n\FrozenTime; class InstanceTable extends AppTable { protected $activePlugins = ['Tags', 'ADmad/SocialAuth']; - public $seachAllTables = ['Broods', 'Individuals', 'Organisations', 'SharingGroups', 'Users', 'EncryptionKeys', ]; + public $seachAllTables = []; public function initialize(array $config): void { parent::initialize($config); $this->addBehavior('AuditLog'); $this->setDisplayField('name'); + $this->setSearchAllTables(); } public function validationDefault(Validator $validator): Validator @@ -28,88 +28,121 @@ class InstanceTable extends AppTable return $validator; } - public function getStatistics($days=30): array + public function setSearchAllTables(): void + { + $this->seachAllTables = [ + 'Broods' => ['conditions' => false, 'afterFind' => false], + 'Individuals' => ['conditions' => false, 'afterFind' => false], + 'Organisations' => ['conditions' => false, 'afterFind' => false], + 'SharingGroups' => [ + 'conditions' => false, + 'afterFind' => function($result, $user) { + foreach ($result as $i => $row) { + if (empty($user['role']['perm_admin'])) { + $orgFound = false; + if (!empty($row['sharing_group_orgs'])) { + foreach ($row['sharing_group_orgs'] as $org) { + if ($org['id'] === $user['organisation_id']) { + $orgFound = true; + } + } + } + if ($row['organisation_id'] !== $user['organisation_id'] && !$orgFound) { + unset($result[$i]); + } + } + } + return $result; + }, + ], + 'Users' => [ + 'conditions' => function($user) { + $conditions = []; + if (empty($user['role']['perm_admin'])) { + $conditions['Users.organisation_id'] = $user['organisation_id']; + } + return $conditions; + }, + 'afterFind' => function ($result, $user) { + return $result; + }, + ], + 'EncryptionKeys' => ['conditions' => false, 'afterFind' => false], + ]; + } + + public function getStatistics(int $days=30): array { $models = ['Individuals', 'Organisations', 'Alignments', 'EncryptionKeys', 'SharingGroups', 'Users', 'Broods', 'Tags.Tags']; foreach ($models as $model) { $table = TableRegistry::getTableLocator()->get($model); - $statistics[$model]['amount'] = $table->find()->all()->count(); - if ($table->behaviors()->has('Timestamp')) { - $query = $table->find(); - $query->select([ - 'count' => $query->func()->count('id'), - 'date' => 'DATE(modified)', - ]) - ->where(['modified >' => FrozenTime::now()->subDays($days)]) - ->group(['date']) - ->order(['date']); - $data = $query->toArray(); - $interval = new \DateInterval('P1D'); - $period = new \DatePeriod(FrozenTime::now()->subDays($days), $interval, FrozenTime::now()); - $timeline = []; - foreach ($period as $date) { - $timeline[$date->format("Y-m-d")] = [ - 'time' => $date->format("Y-m-d"), - 'count' => 0 - ]; - } - foreach ($data as $entry) { - $timeline[$entry->date]['count'] = $entry->count; - } - $statistics[$model]['timeline'] = array_values($timeline); - - $startCount = $table->find()->where(['modified <' => FrozenTime::now()->subDays($days)])->all()->count(); - $endCount = $statistics[$model]['amount']; - $statistics[$model]['variation'] = $endCount - $startCount; - } else { - $statistics[$model]['timeline'] = []; - $statistics[$model]['variation'] = 0; - } + $statistics[$model] = $this->getActivityStatisticsForModel($table, $days); } return $statistics; } - public function searchAll($value, $limit=5, $model=null) + public function searchAll($value, $user, $limit=5, $model=null) { $results = []; - - // search in metafields. FIXME: To be replaced by the meta-template system - $metaFieldTable = TableRegistry::get('MetaFields'); - $query = $metaFieldTable->find()->where([ - 'value LIKE' => '%' . $value . '%' - ]); - $results['MetaFields']['amount'] = $query->count(); - $result = $query->limit($limit)->all()->toList(); - if (!empty($result)) { - $results['MetaFields']['entries'] = $result; - } - $models = $this->seachAllTables; if (!is_null($model)) { - if (in_array($model, $this->seachAllTables)) { - $models = [$model]; + if (in_array($model, array_keys($this->seachAllTables))) { + $models = [$model => $this->seachAllTables[$model]]; } else { return $results; // Cannot search in this model } } - foreach ($models as $tableName) { + + // search in metafields. FIXME?: Use meta-fields type handler to search for meta-field values + if (is_null($model)) { + $metaFieldTable = TableRegistry::get('MetaFields'); + $query = $metaFieldTable->find()->where([ + 'value LIKE' => '%' . $value . '%' + ]); + $results['MetaFields']['amount'] = $query->count(); + $result = $query->limit($limit)->all()->toList(); + if (!empty($result)) { + $results['MetaFields']['entries'] = $result; + } + } + + foreach ($models as $tableName => $tableConfig) { $controller = $this->getController($tableName); $table = TableRegistry::get($tableName); $query = $table->find(); - $quickFilterOptions = $this->getQuickFiltersFieldsFromController($controller); + $quickFilters = $this->getQuickFiltersFieldsFromController($controller); $containFields = $this->getContainFieldsFromController($controller); - if (empty($quickFilterOptions)) { + if (empty($quickFilters)) { continue; // make sure we are filtering on something } $params = ['quickFilter' => $value]; + $quickFilterOptions = ['quickFilters' => $quickFilters]; $query = $controller->CRUD->setQuickFilters($params, $query, $quickFilterOptions); + if (!empty($tableConfig['conditions'])) { + $whereClause = []; + if (is_callable($tableConfig['conditions'])) { + $whereClause = $tableConfig['conditions']($user); + } else { + $whereClause = $tableConfig['conditions']; + } + $query->where($whereClause); + } if (!empty($containFields)) { $query->contain($containFields); } - $results[$tableName]['amount'] = $query->count(); + if (!empty($tableConfig['contain'])) { + $query->contain($tableConfig['contain']); + } + if (empty($tableConfig['afterFind'])) { + $results[$tableName]['amount'] = $query->count(); + } $result = $query->limit($limit)->all()->toList(); if (!empty($result)) { + if (!empty($tableConfig['afterFind'])) { + $result = $tableConfig['afterFind']($result, $user); + } $results[$tableName]['entries'] = $result; + $results[$tableName]['amount'] = count($result); } } return $results; @@ -119,7 +152,7 @@ class InstanceTable extends AppTable { $controllerName = "\\App\\Controller\\{$name}Controller"; if (!class_exists($controllerName)) { - throw new MethodNotAllowedException(__('Model `{0}` does not exists', $model)); + throw new MethodNotAllowedException(__('Model `{0}` does not exists', $name)); } $controller = new $controllerName; return $controller; diff --git a/src/Model/Table/MailingListsTable.php b/src/Model/Table/MailingListsTable.php new file mode 100644 index 0000000..7b0a239 --- /dev/null +++ b/src/Model/Table/MailingListsTable.php @@ -0,0 +1,60 @@ +addBehavior('UUID'); + $this->addBehavior('Timestamp'); + $this->belongsTo( + 'Users' + ); + + $this->belongsToMany('Individuals', [ + 'joinTable' => 'mailing_lists_individuals', + ]); + // Change to HasMany? + $this->belongsToMany('MetaFields'); + + $this->setDisplayField('name'); + } + + public function validationDefault(Validator $validator): Validator + { + $validator + ->requirePresence(['name', 'releasability'], 'create'); + return $validator; + } + + public function buildRules(RulesChecker $rules): RulesChecker + { + return $rules; + } + + public function isIndividualListed($individual, \App\Model\Entity\MailingList $mailinglist): bool + { + $found = false; + if (empty($mailinglist['individuals'])) { + return false; + } + $individual_id_to_find = $individual; + if (is_object($individual)) { + $individual_id_to_find = $individual['id']; + } + foreach ($mailinglist['individuals'] as $individual) { + if ($individual['id'] == $individual_id_to_find) { + return true; + } + } + return $found; + } +} \ No newline at end of file diff --git a/src/Model/Table/MetaFieldsTable.php b/src/Model/Table/MetaFieldsTable.php index 0148ffd..8c5a6d0 100644 --- a/src/Model/Table/MetaFieldsTable.php +++ b/src/Model/Table/MetaFieldsTable.php @@ -5,6 +5,7 @@ namespace App\Model\Table; use App\Model\Table\AppTable; use Cake\ORM\Table; use Cake\Validation\Validator; +use Cake\ORM\RulesChecker; class MetaFieldsTable extends AppTable { @@ -12,11 +13,17 @@ class MetaFieldsTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->addBehavior('Timestamp'); + $this->addBehavior('CounterCache', [ + 'MetaTemplateFields' => ['counter'] + ]); + $this->addBehavior('AuditLog'); $this->addBehavior('Timestamp'); - $this->setDisplayField('field'); $this->belongsTo('MetaTemplates'); $this->belongsTo('MetaTemplateFields'); + + $this->setDisplayField('field'); } public function validationDefault(Validator $validator): Validator @@ -30,7 +37,59 @@ class MetaFieldsTable extends AppTable ->notEmptyString('meta_template_field_id') ->requirePresence(['scope', 'field', 'value', 'uuid', 'meta_template_id', 'meta_template_field_id'], 'create'); - // add validation regex + $validator->add('value', 'validMetaField', [ + 'rule' => 'isValidMetaField', + 'message' => __('The provided value doesn\'t pass the validation check for its meta-template.'), + 'provider' => 'table', + ]); + return $validator; } + + public function isValidMetaField($value, array $context) + { + $metaFieldsTable = $context['providers']['table']; + $entityData = $context['data']; + $metaTemplateField = $metaFieldsTable->MetaTemplateFields->get($entityData['meta_template_field_id']); + return $this->isValidMetaFieldForMetaTemplateField($value, $metaTemplateField); + } + + public function isValidMetaFieldForMetaTemplateField($value, $metaTemplateField) + { + $typeValid = $this->isValidType($value, $metaTemplateField); + if ($typeValid !== true) { + return $typeValid; + } + if (!empty($metaTemplateField['regex'])) { + return $this->isValidRegex($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); + return $success ? true : __('Metafields value `{0}` for `{1}` doesn\'t pass type validation.', $value, $metaTemplateField['field']); + } + /* + We should not end-up in this case. But if someone creates a new type without his handler, + we consider its type to be a valid text. + */ + return true; + } + + public function isValidRegex($value, $metaTemplateField) + { + + $re = $metaTemplateField['regex']; + if (!preg_match("/^$re$/m", $value)) { + return __('Metafield value `{0}` for `{1}` doesn\'t pass regex validation.', $value, $metaTemplateField['field']); + } + return true; + } } diff --git a/src/Model/Table/MetaTemplateFieldsTable.php b/src/Model/Table/MetaTemplateFieldsTable.php index 453690a..626a96c 100644 --- a/src/Model/Table/MetaTemplateFieldsTable.php +++ b/src/Model/Table/MetaTemplateFieldsTable.php @@ -6,16 +6,37 @@ use App\Model\Table\AppTable; use Cake\ORM\Table; use Cake\Validation\Validator; +use MetaFieldsTypes\TextType; +use MetaFieldsTypes\IPv4Type; +use MetaFieldsTypes\IPv6Type; +require_once(APP . 'Lib' . DS . 'default' . DS . 'meta_fields_types' . DS . 'TextType.php'); +require_once(APP . 'Lib' . DS . 'default' . DS . 'meta_fields_types' . DS . 'IPv4Type.php'); +require_once(APP . 'Lib' . DS . 'default' . DS . 'meta_fields_types' . DS . 'IPv6Type.php'); + class MetaTemplateFieldsTable extends AppTable { + private $typeHandlers = []; + public function initialize(array $config): void { parent::initialize($config); + $this->BelongsTo( 'MetaTemplates' ); $this->hasMany('MetaFields'); + $this->setDisplayField('field'); + $this->loadTypeHandlers(); + } + + public function beforeSave($event, $entity, $options) + { + if (empty($entity->meta_template_id)) { + $event->stopPropagation(); + $event->setResult(false); + return; + } } public function validationDefault(Validator $validator): Validator @@ -23,9 +44,31 @@ class MetaTemplateFieldsTable extends AppTable $validator ->notEmptyString('field') ->notEmptyString('type') - ->numeric('meta_template_id') - ->notBlank('meta_template_id') - ->requirePresence(['meta_template_id', 'field', 'type'], 'create'); + ->requirePresence(['field', 'type'], 'create'); return $validator; } + + public function loadTypeHandlers(): void + { + if (empty($this->typeHandlers)) { + $typeHandlers = [ + new TextType(), + new IPv4Type(), + new IPv6Type(), + ]; + foreach ($typeHandlers as $handler) { + $this->typeHandlers[$handler::TYPE] = $handler; + } + } + } + + public function getTypeHandlers(): array + { + return $this->typeHandlers; + } + + public function getTypeHandler($type) + { + return $this->typeHandlers[$type] ?? false; + } } diff --git a/src/Model/Table/MetaTemplatesTable.php b/src/Model/Table/MetaTemplatesTable.php index 9e01ccd..77df33d 100644 --- a/src/Model/Table/MetaTemplatesTable.php +++ b/src/Model/Table/MetaTemplatesTable.php @@ -4,11 +4,30 @@ namespace App\Model\Table; use App\Model\Table\AppTable; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; use Cake\Validation\Validator; +use Cake\Utility\Hash; +use Cake\Utility\Inflector; +use Cake\Utility\Text; +use Cake\Filesystem\File; +use Cake\Filesystem\Folder; class MetaTemplatesTable extends AppTable { - public $metaFields = true; + public const TEMPLATE_PATH = [ + ROOT . '/libraries/default/meta_fields/', + ROOT . '/libraries/custom/meta_fields/' + ]; + + 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 DEFAULT_STRATEGY = MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW; + public const ALLOWED_STRATEGIES = [MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW]; + + private $templatesOnDisk = null; public function initialize(array $config): void { @@ -17,7 +36,10 @@ class MetaTemplatesTable extends AppTable $this->hasMany( 'MetaTemplateFields', [ - 'foreignKey' => 'meta_template_id' + 'foreignKey' => 'meta_template_id', + 'saveStrategy' => 'replace', + 'dependent' => true, + 'cascadeCallbacks' => true, ] ); $this->setDisplayField('name'); @@ -36,40 +58,619 @@ class MetaTemplatesTable extends AppTable return $validator; } - public function update() + public function isStrategyAllowed(string $strategy): bool { - $paths = [ - ROOT . '/libraries/default/meta_fields/', - ROOT . '/libraries/custom/meta_fields/' - ]; + return in_array($strategy, MetaTemplatesTable::ALLOWED_STRATEGIES); + } + + /** + * Load the templates stored on the disk update and create them in the database without touching at the existing ones + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array The update result containing potential errors and the successes + */ + public function updateAllTemplates(): array + { + $updatesErrors = []; $files_processed = []; - foreach ($paths as $path) { + $templatesOnDisk = $this->readTemplatesFromDisk(); + $templatesUpdateStatus = $this->getUpdateStatusForTemplates(); + foreach ($templatesOnDisk as $template) { + $errors = []; + $success = false; + $updateStatus = $templatesUpdateStatus[$template['uuid']]; + if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) && $updateStatus['new']) { + $success = $this->saveNewMetaTemplate($template, $errors); + } + if ($success) { + $files_processed[] = $template['uuid']; + } else { + $updatesErrors[] = $errors; + } + } + $results = [ + 'update_errors' => $updatesErrors, + 'files_processed' => $files_processed, + 'success' => !empty($files_processed), + ]; + return $results; + } + + /** + * Load the template stored on the disk for the provided meta-template and update it using the optional strategy. + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @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 + { + $files_processed = []; + $updatesErrors = []; + $templateOnDisk = $this->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + $updateStatus = $this->computeFullUpdateStatusForMetaTemplate($templateStatus, $metaTemplate); + $errors = []; + $success = false; + if ($updateStatus['up-to-date']) { + $errors['message'] = __('Meta-template already up-to-date'); + $success = true; + } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) && $updateStatus['is-new']) { + $success = $this->saveNewMetaTemplate($templateOnDisk, $errors); + } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING) && $updateStatus['automatically-updateable']) { + $success = $this->updateMetaTemplate($metaTemplate, $templateOnDisk, $errors); + } else if (!$updateStatus['up-to-date'] && (!is_null($strategy) && !$this->isStrategyAllowed($strategy))) { + $errors['message'] = __('Cannot update meta-template, update strategy not allowed'); + } else if (!$updateStatus['up-to-date']) { + $strategy = is_null($strategy) ? MetaTemplatesTable::DEFAULT_STRATEGY : $strategy; + $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 $results; + } + + /** + * Load the templates stored on the disk update and create the one having the provided UUID in the database + * Will do nothing if the UUID is already known + * + * @param string $uuid + * @return array The update result containing potential errors and the successes + */ + public function createNewTemplate(string $uuid): array + { + $templateOnDisk = $this->readTemplateFromDisk($uuid); + $templateStatus = $this->getUpdateStatusForTemplate($templateOnDisk); + $errors = []; + $updatesErrors = []; + $files_processed = []; + $savedMetaTemplate = null; + $success = false; + if (empty($templateStatus['new'])) { + $error['message'] = __('Template UUID already exists'); + $success = true; + } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW)) { + $success = $this->saveNewMetaTemplate($templateOnDisk, $errors, $savedMetaTemplate); + } else { + $errors['message'] = __('Could not create template. 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 $results; + } + + /** + * Load the templates stored on the disk and compute their update status. + * Only compute the result if an UUID is provided + * + * @param string|null $template_uuid + * @return array + */ + public function getUpdateStatusForTemplates(): array + { + $errors = []; + $templateUpdatesStatus = []; + $templates = $this->readTemplatesFromDisk($errors); + foreach ($templates as $template) { + $templateUpdatesStatus[$template['uuid']] = $this->getUpdateStatusForTemplate($template); + } + return $templateUpdatesStatus; + } + + + /** + * Checks if the template is update-to-date from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function isUpToDate(array $updateStatus): bool + { + return !empty($updateStatus['up-to-date']) || !empty($updateStatus['new']); + } + + /** + * Checks if the template is updateable automatically from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function isAutomaticallyUpdateable(array $updateStatus): bool + { + return !empty($updateStatus['automatically-updateable']); + } + + /** + * Checks if the template is new (and not loaded in the database yet) from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function isNew(array $updateStatus): bool + { + return $updateStatus['new']; + } + + /** + * Checks if the template has no conflicts that would prevent an automatic update from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function hasNoConflict(array $updateStatus): bool + { + return $this->hasConflict($updateStatus); + } + + /** + * Checks if the template has conflict preventing an automatic update from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function hasConflict(array $updateStatus): bool + { + return empty($updateStatus['automatically-updateable']) && empty($updateStatus['up-to-date']) && empty($updateStatus['new']); + } + + /** + * Checks if the metaTemplate can be updated to a newer version loaded in the database + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return boolean + */ + public function isUpdateableToExistingMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate): bool + { + $newestTemplate = $this->getNewestVersion($metaTemplate); + return !empty($newestTemplate); + } + + /** + * Checks if the template can be removed from the database for the provided update status. + * A template can be removed if a newer version is already loaded in the database and no meta-fields are using it. + * + * @param array $updateStatus + * @return boolean + */ + public function isRemovable(array $updateStatus): bool + { + return !empty($updateStatus['can-be-removed']); + } + + /** + * Compute the state from the provided update status and metaTemplate + * + * @param array $updateStatus + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array + */ + public function computeFullUpdateStatusForMetaTemplate(array $updateStatus, \App\Model\Entity\MetaTemplate $metaTemplate): array + { + return [ + 'up-to-date' => $this->isUpToDate($updateStatus), + 'automatically-updateable' => $this->isAutomaticallyUpdateable($updateStatus), + 'is-new' => $this->isNew($updateStatus), + 'has-conflict' => $this->hasConflict($updateStatus), + 'to-existing' => $this->isUpdateableToExistingMetaTemplate($metaTemplate), + 'can-be-removed' => $this->isRemovable($updateStatus), + ]; + } + + /** + * Get the update status of meta-templates that are up-to-date in regards to the template stored on the disk. + * + * @param array|null $updateStatus + * @return array The list of update status for up-to-date templates + */ + public function getUpToDateTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->isUpToDate($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that are not up-to-date in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for non up-to-date templates + */ + public function getNotUpToDateTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if ($this->isUpToDate($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that are automatically updateable in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for non up-to-date templates + */ + public function getAutomaticallyUpdateableTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->isAutomaticallyUpdateable($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that are new in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for new templates + */ + public function getNewTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->isNew($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that have conflict preventing an automatic update in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for new templates + */ + public function getConflictTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->hasConflict($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the latest (having the higher version) meta-template loaded in the database for the provided meta-template + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @param boolean $full + * @return \App\Model\Entity\MetaTemplate|null + */ + public function getNewestVersion(\App\Model\Entity\MetaTemplate $metaTemplate, bool $full = false) + { + $query = $this->find()->where([ + 'uuid' => $metaTemplate->uuid, + 'id !=' => $metaTemplate->id, + 'version >=' => $metaTemplate->version, + ]) + ->order(['version' => 'DESC']); + if ($full) { + $query->contain(['MetaTemplateFields']); + } + $newestTemplate = $query->first(); + return $newestTemplate; + } + + /** + * Generate and return a query (to be used as a subquery) resolving to the IDs of the latest version of a saved meta-template + * + * @return \Cake\ORM\Query + */ + public function genQueryForAllNewestVersionIDs(): \Cake\ORM\Query + { + /** + * SELECT a.id FROM meta_templates a INNER JOIN ( + * SELECT uuid, MAX(version) maxVersion FROM meta_templates GROUP BY uuid + * ) b on a.uuid = b.uuid AND a.version = b.maxVersion; + */ + $query = $this->find() + ->select([ + 'id' + ]) + ->join([ + 't' => [ + 'table' => '(SELECT uuid, MAX(version) AS maxVersion FROM meta_templates GROUP BY uuid)', + 'type' => 'INNER', + 'conditions' => [ + 't.uuid = MetaTemplates.uuid', + 't.maxVersion = MetaTemplates.version' + ], + ], + ]); + return $query; + } + + /** + * Get the update status of meta-templates that can be removed. + * + * @param array|null $updateResult + * @return array The list of update status for new templates + */ + public function getCanBeRemovedTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $i => $updateStatus) { + if (!$this->isRemovable($updateStatus)) { + unset($updatesStatus[$i]); + } + } + return $updatesStatus; + } + + /** + * Reads all template stored on the disk and parse them + * + * @param array|null $errors Contains errors while parsing the meta-templates + * @return array The parsed meta-templates stored on the disk + */ + public function readTemplatesFromDisk(&$errors = []): array + { + if (!is_null($this->templatesOnDisk)) { + return $this->templatesOnDisk; + } + $templates = []; + $errors = []; + foreach (self::TEMPLATE_PATH as $path) { if (is_dir($path)) { $files = scandir($path); foreach ($files as $k => $file) { if (substr($file, -5) === '.json') { - if ($this->loadMetaFile($path . $file) === true) { - $files_processed[] = $file; + $errorMessage = ''; + $template = $this->decodeTemplateFromDisk($path . $file, $errorMessage); + if (!empty($template)) { + $templates[] = $template; + } else { + $errors[] = $errorMessage; } } } } } - return $files_processed; + $this->templatesOnDisk = $templates; + return $templates; } - public function getTemplate($id) + /** + * Read and parse the meta-template stored on disk having the provided UUID + * + * @param string $uuid + * @param string $error Contains the error while parsing the meta-template + * @return array|null The meta-template or null if not templates matche the provided UUID + */ + public function readTemplateFromDisk(string $uuid, &$error = ''): ?array { - $query = $this->find(); - $query->where(['id' => $id]); - $template = $query->first(); - if (empty($template)) { - throw new NotFoundException(__('Invalid template ID specified.')); + foreach (self::TEMPLATE_PATH as $path) { + if (is_dir($path)) { + $files = scandir($path); + foreach ($files as $k => $file) { + if (substr($file, -5) === '.json') { + $errorMessage = ''; + $template = $this->decodeTemplateFromDisk($path . $file, $errorMessage); + if (!empty($template) && $template['uuid'] == $uuid) { + return $template; + } + } + } + } } - return $template; + $error = __('Could not find meta-template with UUID {0}', $uuid); + return null; } - public function getDefaultTemplatePerScope(String $scope = '') + /** + * Read and decode the meta-template located at the provided path + * + * @param string $filePath + * @param string $errorMessage + * @return array|null The meta-template or null if there was an error while trying to decode + */ + public function decodeTemplateFromDisk(string $filePath, &$errorMessage = ''): ?array + { + $file = new File($filePath, false); + if ($file->exists()) { + $filename = $file->name(); + $content = $file->read(); + if (empty($content)) { + $errorMessage = __('Could not read template file `{0}`.', $filename); + return null; + } + $metaTemplate = json_decode($content, true); + if (empty($metaTemplate)) { + $errorMessage = __('Could not load template file `{0}`. Error while decoding the template\'s JSON', $filename); + return null; + } + if (empty($metaTemplate['uuid']) || empty($metaTemplate['version'])) { + $errorMessage = __('Could not load template file. Invalid template file. Missing template UUID or version'); + return null; + } + return $metaTemplate; + } + $errorMessage = __('File does not exists'); + return null; + } + + /** + * Collect all enties having meta-fields belonging to the provided template + * + * @param integer $template_id + * @param integer|bool $limit The limit of entities to be returned. Pass null to be ignore the limit + * @return array List of entities + */ + public function getEntitiesHavingMetaFieldsFromTemplate(int $metaTemplateId, $limit=10, int &$totalAmount=0): array + { + $metaTemplate = $this->get($metaTemplateId); + $queryParentEntities = $this->MetaTemplateFields->MetaFields->find(); + $queryParentEntities + ->select(['parent_id']) + ->where([ + 'meta_template_id' => $metaTemplateId + ]) + ->group(['parent_id']); + + $entitiesTable = $this->getTableForMetaTemplateScope($metaTemplate); + $entityQuery = $entitiesTable->find() + ->where(['id IN' => $queryParentEntities]) + ->contain([ + 'MetaFields' => [ + 'conditions' => [ + 'meta_template_id' => $metaTemplateId + ] + ] + ]); + if (!is_null($limit)) { + $totalAmount = $entityQuery->all()->count(); + $entityQuery->limit($limit); + } + $entities = $entityQuery->all()->toList(); + return $entities; + } + + /** + * Get the table linked to the meta-template + * + * @param \App\Model\Entity\MetaTemplate|string $metaTemplate + * @return \App\Model\Table\AppTable + */ + private function getTableForMetaTemplateScope($metaTemplateOrScope): \App\Model\Table\AppTable + { + if (is_string($metaTemplateOrScope)) { + $scope = $metaTemplateOrScope; + } else { + $scope = $metaTemplateOrScope->scope; + } + $entitiesClassName = Inflector::camelize(Inflector::pluralize($scope)); + $entitiesTable = TableRegistry::getTableLocator()->get($entitiesClassName); + return $entitiesTable; + } + + /** + * Get the meta-field keyed by their template_id and meta_template_id belonging to the provided entity + * + * @param integer $entity_id The entity for which the meta-fields belongs to + * @param array $conditions Additional conditions to be passed to the meta-fields query + * @return array The associated array containing the meta-fields keyed by their meta-template and meta-template-field IDs + */ + public function getKeyedMetaFieldsForEntity(int $entity_id, array $conditions = []): array + { + $query = $this->MetaTemplateFields->MetaFields->find(); + $query->where(array_merge( + $conditions, + [ + 'MetaFields.parent_id' => $entity_id + ] + )); + $metaFields = $query->all(); + $keyedMetaFields = []; + foreach ($metaFields as $metaField) { + if (empty($keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id])) { + $keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id] = []; + } + $keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id][$metaField->id] = $metaField; + } + return $keyedMetaFields; + } + + /** + * Insert the keyed meta-fields into the provided meta-templates + * + * @param array $keyedMetaFields An associative array containing the meta-fields keyed by their meta-template and meta-template-field IDs + * @param array $metaTemplates List of meta-templates + * @return array The list of meta-template with the meta-fields inserted + */ + public function insertMetaFieldsInMetaTemplates(array $keyedMetaFields, array $metaTemplates): array + { + $merged = []; + foreach ($metaTemplates as $metaTemplate) { + $metaTemplate['meta_template_fields'] = Hash::combine($metaTemplate['meta_template_fields'], '{n}.id', '{n}'); + $merged[$metaTemplate->id] = $metaTemplate; + if (isset($keyedMetaFields[$metaTemplate->id])) { + foreach ($metaTemplate->meta_template_fields as $j => $meta_template_field) { + if (isset($keyedMetaFields[$metaTemplate->id][$meta_template_field->id])) { + $merged[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = $keyedMetaFields[$metaTemplate->id][$meta_template_field->id]; + } else { + $merged[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = []; + } + } + } + } + return $merged; + } + + /** + * Retreive the entity associated for the provided meta-template and id + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @param integer $entity_id + * @return \App\Model\Entity\AppModel + */ + public function getEntity(\App\Model\Entity\MetaTemplate $metaTemplate, int $entity_id): \App\Model\Entity\AppModel + { + $entitiesTable = $this->getTableForMetaTemplateScope($metaTemplate); + $entity = $entitiesTable->get($entity_id, [ + 'contain' => 'MetaFields' + ]); + return $entity; + } + + /** + * Collect the unique default template for each scope + * + * @param string|null $scope + * @return array The list of default template + */ + public function getDefaultTemplatePerScope($scope = null): array { $query = $this->find('list', [ 'keyField' => 'scope', @@ -83,54 +684,595 @@ class MetaTemplatesTable extends AppTable return $query->all()->toArray(); } - public function removeDefaultFlag(String $scope) + /** + * Remove the default flag for all meta-templates belonging to the provided scope + * + * @param string $scope + * @return int the number of updated rows + */ + public function removeDefaultFlag(string $scope): int { - $this->updateAll( + return $this->updateAll( ['is_default' => false], ['scope' => $scope] ); } - public function loadMetaFile(String $filePath) + /** + * Check if the provided template can be saved in the database without creating duplicate template in regards to the UUID and version + * + * @param array $template + * @return boolean + */ + public function canBeSavedWithoutDuplicates(array $template): bool { - if (file_exists($filePath)) { - $contents = file_get_contents($filePath); - $metaTemplate = json_decode($contents, true); - if (!empty($metaTemplate) && !empty($metaTemplate['uuid']) && !empty($metaTemplate['version'])) { - $query = $this->find(); - $query->where(['uuid' => $metaTemplate['uuid']]); - $template = $query->first(); - if (empty($template)) { - $template = $this->newEntity($metaTemplate); - $result = $this->save($template); - if (!$result) { - return __('Something went wrong, could not create the template.'); - } - } else { - if ($template->version >= $metaTemplate['version']) { - return false; - } - foreach (['version', 'source', 'name', 'namespace', 'scope', 'description'] as $field) { - $template->{$field} = $metaTemplate[$field]; - } - $result = $this->save($template); - if (!$result) { - return __('Something went wrong, could not update the template.'); - return false; - } - } - if ($result) { - $this->MetaTemplateFields->deleteAll(['meta_template_id' => $template->id]); - foreach ($metaTemplate['metaFields'] as $metaField) { - $metaField['meta_template_id'] = $template->id; - $metaField = $this->MetaTemplateFields->newEntity($metaField); - $this->MetaTemplateFields->save($metaField); - } + $query = $this->find()->where([ + 'uuid' => $template['uuid'], + 'version' => $template['version'], + ]); + return $query->count() == 0; + } + /** + * Create and save the provided template in the database + * + * @param array $template The template to be saved + * @param array $errors The list of errors that occured during the save process + * @param \App\Model\Entity\MetaTemplate $savedMetaTemplate The metaTemplate entity that has just been saved + * @return boolean True if the save was successful, False otherwise + */ + public function saveNewMetaTemplate(array $template, array &$errors = [], \App\Model\Entity\MetaTemplate &$savedMetaTemplate = null): bool + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $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']); + } + $template['meta_template_fields'] = $template['metaFields']; + unset($template['metaFields']); + $metaTemplate = $this->newEntity($template, [ + 'associated' => ['MetaTemplateFields'] + ]); + $tmp = $this->save($metaTemplate, [ + 'associated' => ['MetaTemplateFields'] + ]); + if ($tmp === false) { + $errors[] = new UpdateError(false, __('Could not save the template.'), $metaTemplate->getErrors()); + return false; + } + $savedMetaTemplate = $tmp; + return true; + } + + /** + * Update an existing meta-template and save it in the database + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate The meta-template to update + * @param array $template The template to use to update the existing meta-template + * @param array $errors + * @return boolean True if the save was successful, False otherwise + */ + public function updateMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate, array $template, array &$errors = []): bool + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $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']); + } + if (is_string($metaTemplate)) { + $errors[] = new UpdateError(false, $metaTemplate); + return false; + } + $metaTemplate = $this->patchEntity($metaTemplate, $template, [ + 'associated' => ['MetaTemplateFields'] + ]); + $metaTemplate = $this->save($metaTemplate, [ + 'associated' => ['MetaTemplateFields'] + ]); + if (!empty($metaTemplate)) { + $errors[] = new UpdateError(false, __('Could not save the template.'), $metaTemplate->getErrors()); + return false; + } + return true; + } + + /** + * Update an existing meta-template with the provided strategy and save it in the database + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate The meta-template to update + * @param array $template The template to use to update the existing meta-template + * @param string $strategy The strategy to use when handling update conflicts + * @param array $errors + * @return boolean True if the save was successful, False otherwise + */ + public function updateMetaTemplateWithStrategyRouter(\App\Model\Entity\MetaTemplate $metaTemplate, array $template, string $strategy, array &$errors = []): bool + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $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']); + } + if (is_string($metaTemplate)) { + $errors[] = new UpdateError(false, $metaTemplate); + return false; + } + 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_CREATE_NEW) { + $result = $this->executeStrategyCreateNew($template, $metaTemplate); + } else { + $errors[] = new UpdateError(false, __('Invalid strategy {0}', $strategy)); + return false; + } + if (is_string($result)) { + $errors[] = new UpdateError(false, $result); + return false; + } + return true; + } + + /** + * Execute the `keep_both` update strategy by creating a new meta-template and moving non-conflicting entities to this one. + * Strategy: + * - Old template remains untouched + * - Create new template + * - Migrate all non-conflicting meta-fields for one entity to the new template + * - Keep all the conflicting meta-fields for one entity on the old template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return bool|string If the new template could be saved or the error message + */ + public function executeStrategyKeep(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $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); + $blockingConflict = Hash::extract($conflicts, '{s}.conflicts'); + $errors = []; + if (empty($blockingConflict)) { // No conflict, everything can be updated without special care + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($metaTemplate->id, null); + + $conflictingEntities = []; + foreach ($entities as $entity) { + $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template); + if (!empty($conflicts)) { + $conflictingEntities[$entity->id] = $entity->id; + } + } + if (empty($conflictingEntities)) { + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + $template['is_default'] = $metaTemplate['is_default']; + $template['enabled'] = $metaTemplate['enabled']; + if ($metaTemplate->is_default) { + $metaTemplate->set('is_default', false); + $this->save($metaTemplate); + } + $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); + } } } - return true; + } else { + return $errors[0]->message; + } + return true; + } + + /** + * Execute the `delete_all` update strategy by updating the meta-template and deleting all conflicting meta-fields. + * Strategy: + * - Delete conflicting meta-fields + * - Update template to the new version + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return bool|string If the new template could be saved or the error message + */ + public function executeStrategyDeleteAll(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $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']); + } + $errors = []; + $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); + $blockingConflict = Hash::extract($conflicts, '{s}.conflicts'); + if (empty($blockingConflict)) { // No conflict, everything can be updated without special care + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($metaTemplate->id, null); + + foreach ($entities as $entity) { + $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template); + $deletedCount = $this->MetaTemplateFields->MetaFields->deleteAll([ + 'id IN' => $conflicts + ]); + } + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + + /** + * Execute the `create_new` update strategy by creating a new meta-template + * Strategy: + * - Create a new meta-template + * - Make the new meta-template `default` and `enabled` if previous template had these states + * - Turn of these states on the old meta-template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return bool|string If the new template could be saved or the error message + */ + public function executeStrategyCreateNew(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $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']); + } + $errors = []; + $template['is_default'] = $metaTemplate->is_default; + $template['enabled'] = $metaTemplate->enabled; + $savedMetaTemplate = null; + $success = $this->saveNewMetaTemplate($template, $errors, $savedMetaTemplate); + if ($success) { + if ($metaTemplate->is_default) { + $metaTemplate->set('is_default', false); + $metaTemplate->set('enabled', false); + $this->save($metaTemplate); + } + } + return !empty($errors) ? $errors[0] : true; + } + + /** + * Supersede a meta-fields's meta-template-field with the provided one. + * + * @param \App\Model\Entity\MetaField $metaField + * @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 + { + $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); + } + + /** + * Compute the validity of the provided meta-fields under the provided meta-template + * + * @param \App\Model\Entity\MetaField[] $metaFields + * @param array|\App\Model\Entity\MetaTemplate $template + * @return \App\Model\Entity\MetaField[] The list of conflicting meta-fields under the provided template + */ + public function getMetaFieldsConflictsUnderTemplate(array $metaFields, $template): array + { + if (!is_array($template) && get_class($template) == 'App\Model\Entity\MetaTemplate') { + $metaTemplateFields = $template->meta_template_fields; + $existingMetaTemplate = true; + } else { + $metaTemplateFields = $template['metaFields']; + } + $conflicting = []; + $metaTemplateFieldByName = []; + foreach ($metaTemplateFields as $metaTemplateField) { + if (!is_array($template)) { + $metaTemplateField = $metaTemplateField->toArray(); + } + $metaTemplateFieldByName[$metaTemplateField['field']] = $this->MetaTemplateFields->newEntity($metaTemplateField); + } + foreach ($metaFields as $metaField) { + if ($existingMetaTemplate && $metaField->meta_template_id != $template->id) { + continue; + } + $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField( + $metaField->value, + $metaTemplateFieldByName[$metaField->field] + ); + if ($isValid !== true) { + $conflicting[] = $metaField; + } + } + return $conflicting; + } + + /** + * Compute the potential conflict that would be introduced by updating an existing meta-template-field with the provided one. + * This will go through all instanciation of the existing meta-template-field and checking their validity against the provided one. + * + * @param \App\Model\Entity\MetaTemplateField $metaTemplateField + * @param array $templateField + * @return array + */ + public function computeExistingMetaTemplateFieldConflictForMetaTemplateField(\App\Model\Entity\MetaTemplateField $metaTemplateField, array $templateField, string $scope): array + { + $result = [ + 'automatically-updateable' => true, + 'conflicts' => [], + 'conflictingEntities' => [], + ]; + if ($metaTemplateField->multiple && $templateField['multiple'] == false) { // Field is no longer multiple + $query = $this->MetaTemplateFields->MetaFields->find(); + $query + ->enableHydration(false) + ->select([ + 'parent_id', + 'meta_template_field_id', + 'count' => $query->func()->count('meta_template_field_id'), + ]) + ->where([ + 'meta_template_field_id' => $metaTemplateField->id, + ]) + ->group(['parent_id']) + ->having(['count >' => 1]); + $conflictingStatus = $query->all()->toList(); + if (!empty($conflictingStatus)) { + $result['automatically-updateable'] = false; + $result['conflicts'][] = __('This field is no longer multiple and is being that way'); + $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(); + $conflictingEntities = []; + foreach ($entities as $entity) { + foreach ($entity['meta_fields'] as $metaField) { + $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField( + $metaField->value, + $templateField + ); + if ($isValid !== true) { + $conflictingEntities[] = $entity->id; + break; + } + } + } + + if (!empty($conflictingEntities)) { + $result['automatically-updateable'] = $result['automatically-updateable'] && false; + $result['conflicts'][] = __('This field is instantiated with values not passing the validation anymore'); + $result['conflictingEntities'] = array_merge($result['conflictingEntities'], $conflictingEntities); + } + } + return $result; + } + + /** + * Check the conflict that would be introduced if the metaTemplate would be updated to the provided template + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @param \App\Model\Entity\MetaTemplate|array $template + * @return array + */ + public function getMetaTemplateConflictsForMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate, $template): array + { + $templateMetaFields = []; + if (!is_array($template) && get_class($template) == 'App\Model\Entity\MetaTemplate') { + $templateMetaFields = $template->meta_template_fields; + } else { + $templateMetaFields = $template['metaFields']; + } + $conflicts = []; + $existingMetaTemplateFields = Hash::combine($metaTemplate->toArray(), 'meta_template_fields.{n}.field'); + foreach ($templateMetaFields as $newMetaField) { + foreach ($metaTemplate->meta_template_fields as $metaField) { + if ($newMetaField['field'] == $metaField->field) { + unset($existingMetaTemplateFields[$metaField->field]); + $metaFieldArray = !is_array($newMetaField) && get_class($newMetaField) == 'App\Model\Entity\MetaTemplateField' ? $newMetaField->toArray() : $newMetaField; + $templateConflictsForMetaField = $this->computeExistingMetaTemplateFieldConflictForMetaTemplateField($metaField, $metaFieldArray, $metaTemplate->scope); + if (!$templateConflictsForMetaField['automatically-updateable']) { + $conflicts[$metaField->field] = $templateConflictsForMetaField; + $conflicts[$metaField->field]['existing_meta_template_field'] = $metaField; + $conflicts[$metaField->field]['existing_meta_template_field']['conflicts'] = $templateConflictsForMetaField['conflicts']; + } + } + } + } + if (!empty($existingMetaTemplateFields)) { + foreach ($existingMetaTemplateFields as $field => $tmp) { + $conflicts[$field] = [ + 'automatically-updateable' => false, + 'conflicts' => [__('This field is intended to be removed')], + ]; + } + } + return $conflicts; + } + + /** + * Get update status for the latest meta-template in the database for the provided template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate $metaTemplate + * @return array + */ + public function getUpdateStatusForTemplate(array $template): array + { + $updateStatus = [ + 'new' => true, + 'up-to-date' => false, + 'automatically-updateable' => false, + 'conflicts' => [], + 'template' => $template, + ]; + $query = $this->find() + ->contain('MetaTemplateFields') + ->where([ + 'uuid' => $template['uuid'], + ]) + ->order(['version' => 'DESC']); + $metaTemplate = $query->first(); + if (!empty($metaTemplate)) { + $updateStatus = array_merge( + $updateStatus, + $this->getStatusForMetaTemplate($template, $metaTemplate) + ); + } + return $updateStatus; + } + + /** + * Get update status for the meta-template stored in the database and the provided template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array + */ + public function getStatusForMetaTemplate(array $template, \App\Model\Entity\MetaTemplate $metaTemplate): array + { + $updateStatus = []; + $updateStatus['existing_template'] = $metaTemplate; + $updateStatus['current_version'] = $metaTemplate->version; + $updateStatus['next_version'] = $template['version']; + $updateStatus['new'] = false; + if ($metaTemplate->version >= $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; } + $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); + if (!empty($conflicts)) { + $updateStatus['conflicts'] = $conflicts; + } else { + $updateStatus['automatically-updateable'] = true; + } + $updateStatus['meta_field_amount'] = $this->MetaTemplateFields->MetaFields->find()->where(['meta_template_id' => $metaTemplate->id])->count(); + $updateStatus['can-be-removed'] = empty($updateStatus['meta_field_amount']) && empty($updateStatus['to-existing']); + return $updateStatus; + } + + /** + * Massages the meta-fields of an entity based on the input + * - If the keyed ID of the input meta-field is new, a new meta-field entity is created + * - If the input meta-field's value is empty for an existing meta-field, the existing meta-field is marked as to be deleted + * - If the input meta-field already exists, patch the entity and attach the validation errors + * + * @param \App\Model\Entity\AppModel $entity + * @param array $input + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array An array containing the entity with its massaged meta-fields and the meta-fields that should be deleted + */ + public function massageMetaFieldsBeforeSave(\App\Model\Entity\AppModel $entity, array $input, \App\Model\Entity\MetaTemplate $metaTemplate): array + { + $metaFieldsTable = $this->MetaTemplateFields->MetaFields; + $className = Inflector::camelize(Inflector::pluralize($metaTemplate->scope)); + $entityTable = TableRegistry::getTableLocator()->get($className); + $metaFieldsIndex = []; + if (!empty($entity->meta_fields)) { + foreach ($entity->meta_fields as $i => $metaField) { + $metaFieldsIndex[$metaField->id] = $i; + } + } else { + $entity->meta_fields = []; + } + + $metaFieldsToDelete = []; + foreach ($input['MetaTemplates'] as $template_id => $template) { + foreach ($template['meta_template_fields'] as $meta_template_field_id => $meta_template_field) { + $rawMetaTemplateField = $metaTemplate->meta_template_fields[$meta_template_field_id]; + foreach ($meta_template_field['metaFields'] as $meta_field_id => $meta_field) { + if ($meta_field_id == 'new') { // create new meta_field + $new_meta_fields = $meta_field; + foreach ($new_meta_fields as $new_value) { + if (!empty($new_value)) { + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $entityTable->getBehavior('MetaFields')->getScope(), + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } + } else { + $new_value = $meta_field['value']; + if (!empty($new_value)) { // update meta_field and attach validation errors + if (!empty($metaFieldsIndex[$meta_field_id])) { + $index = $metaFieldsIndex[$meta_field_id]; + $metaFieldsTable->patchEntity($entity->meta_fields[$index], [ + 'value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id + ], ['value']); + $metaFieldsTable->patchEntity( + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id], + ['value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id], + ['value'] + ); + } else { // metafield comes from a second POST where the temporary entity has already been created + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $entityTable->getBehavior('MetaFields')->getScope(), // get scope from behavior + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } else { // Metafield value is empty, indicating the field should be removed + $index = $metaFieldsIndex[$meta_field_id]; + $metaFieldsToDelete[] = $entity->meta_fields[$index]; + unset($entity->meta_fields[$index]); + unset($entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id]); + } + } + } + } + } + + $entity->setDirty('meta_fields', true); + return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete]; + } +} + +class UpdateError +{ + public $success; + public $message = ''; + public $errors = []; + + public function __construct($success = false, $message = '', $errors = []) + { + $this->success = $success; + $this->message = $message; + $this->errors = $errors; } } diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index 8cd7dec..723b46a 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -9,8 +9,6 @@ use Cake\Error\Debugger; class OrganisationsTable extends AppTable { - public $metaFields = 'organisation'; - public function initialize(array $config): void { parent::initialize($config); @@ -33,14 +31,7 @@ class OrganisationsTable extends AppTable 'conditions' => ['owner_model' => 'organisation'] ] ); - $this->hasMany( - 'MetaFields', - [ - 'dependent' => true, - 'foreignKey' => 'parent_id', - 'conditions' => ['MetaFields.scope' => 'organisation'] - ] - ); + $this->addBehavior('MetaFields'); $this->setDisplayField('name'); } diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index 9cf755d..801885c 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -153,7 +153,7 @@ class CerebrateSettingsProvider extends BaseSettingsProvider 'Providers' => [ 'PasswordAuth' => [ 'password_auth.enabled' => [ - 'name' => 'Disable password authentication', + 'name' => 'Enable password authentication', 'type' => 'boolean', 'severity' => 'warning', 'description' => __('Enable username/password authentication.'), diff --git a/src/Utility/UI/IndexSetting.php b/src/Utility/UI/IndexSetting.php new file mode 100644 index 0000000..ac3560e --- /dev/null +++ b/src/Utility/UI/IndexSetting.php @@ -0,0 +1,30 @@ +user_settings_by_name['ui.table_setting']['value']) ? json_decode($user->user_settings_by_name['ui.table_setting']['value'], true) : []; + return $rawSetting; + } + + public static function getTableSetting($user, $tableId): array + { + $rawSetting = IndexSetting::getAllSetting($user); + if (is_object($tableId)) { + $tableId = IndexSetting::getIDFromTable($tableId); + } + $tableSettings = !empty($rawSetting[$tableId]) ? $rawSetting[$tableId] : []; + return $tableSettings; + } + + public static function getIDFromTable(Object $table): string + { + return sprintf('%s_index', Inflector::variable(Inflector::singularize(($table->getAlias())))); + } +} \ No newline at end of file diff --git a/src/Utility/UI/Notification.php b/src/Utility/UI/Notification.php new file mode 100644 index 0000000..a6bf05b --- /dev/null +++ b/src/Utility/UI/Notification.php @@ -0,0 +1,49 @@ +text = $text; + $this->router = $router; + foreach ($options as $key => $value) { + $this->{$key} = $value; + } + $this->validate(); + } + + public function get(): array + { + if (empty($errors)) { + return (array) $this; + } + return null; + } + + private function validate() + { + $validator = new Validator(); + + $validator + ->requirePresence('title', 'create') + ->notEmptyString('title'); + + return $validator->validate((array) $this); + } +} diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 1b1db20..3ff283a 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -1,4 +1,5 @@ button(); } - public function icon($icon, $options=[]) + public function icon($icon, $options = []) { $bsIcon = new BoostrapIcon($icon, $options); return $bsIcon->icon(); @@ -98,7 +99,7 @@ class BootstrapHelper extends Helper $bsModal = new BoostrapModal($options); return $bsModal->modal(); } - + public function card($options) { $bsCard = new BoostrapCard($options); @@ -117,6 +118,12 @@ class BootstrapHelper extends Helper return $bsCollapse->collapse(); } + public function accordion($options, $content) + { + $bsAccordion = new BoostrapAccordion($options, $content, $this); + return $bsAccordion->accordion(); + } + public function progressTimeline($options) { $bsProgressTimeline = new BoostrapProgressTimeline($options, $this); @@ -129,7 +136,7 @@ class BootstrapHelper extends Helper return $bsListGroup->listGroup(); } - public function genNode($node, $params=[], $content='') + public function genNode($node, $params = [], $content = '') { return BootstrapGeneric::genNode($node, $params, $content); } @@ -140,6 +147,12 @@ class BootstrapHelper extends Helper return $bsSwitch->switch(); } + public function notificationBubble($options) + { + $bsNotificationBubble = new BoostrapNotificationBuble($options, $this); + return $bsNotificationBubble->notificationBubble(); + } + public function dropdownMenu($options) { $bsDropdownMenu = new BoostrapDropdownMenu($options, $this); @@ -177,12 +190,12 @@ class BootstrapGeneric } } - public static function genNode($node, $params=[], $content="") + public static function genNode($node, $params = [], $content = "") { return sprintf('<%s %s>%s', $node, BootstrapGeneric::genHTMLParams($params), $content, $node); } - protected static function openNode($node, $params=[]) + protected static function openNode($node, $params = []) { return sprintf('<%s %s>', $node, BootstrapGeneric::genHTMLParams($params)); } @@ -249,7 +262,8 @@ class BootstrapTabs extends BootstrapGeneric ]; private $bsClasses = null; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'justify' => [false, 'center', 'end'], 'body-variant' => array_merge(BootstrapGeneric::$variants, ['']), @@ -284,7 +298,7 @@ class BootstrapTabs extends BootstrapGeneric $this->options['pills'] = true; $this->options['card'] = true; } - + if ($this->options['pills']) { $this->bsClasses['nav'][] = 'nav-pills'; if ($this->options['vertical']) { @@ -307,7 +321,7 @@ class BootstrapTabs extends BootstrapGeneric $this->bsClasses['nav'][] = 'nav-justify'; } - $activeTab = 0; + $activeTab = array_key_first($this->data['navs']); foreach ($this->data['navs'] as $i => $nav) { if (!is_array($nav)) { $this->data['navs'][$i] = ['text' => $nav]; @@ -386,29 +400,31 @@ class BootstrapTabs extends BootstrapGeneric "border-{$this->options['header-border-variant']}" ] )]); - $html .= $this->openNode('div', ['class' => array_merge( - [ - ($this->options['vertical-size'] != 'auto' ? 'col-' . $this->options['vertical-size'] : ''), - ($this->options['card'] ? 'card-header border-end' : '') - ], - [ - "bg-{$this->options['header-variant']}", - "text-{$this->options['header-text-variant']}", - "border-{$this->options['header-border-variant']}" - ])]); - $html .= $this->genNav(); - $html .= $this->closeNode('div'); - $html .= $this->openNode('div', ['class' => array_merge( - [ - ($this->options['vertical-size'] != 'auto' ? 'col-' . (12 - $this->options['vertical-size']) : ''), - ($this->options['card'] ? 'card-body2' : '') - ], - [ - "bg-{$this->options['body-variant']}", - "text-{$this->options['body-text-variant']}" - ])]); - $html .= $this->genContent(); - $html .= $this->closeNode('div'); + $html .= $this->openNode('div', ['class' => array_merge( + [ + ($this->options['vertical-size'] != 'auto' ? 'col-' . $this->options['vertical-size'] : ''), + ($this->options['card'] ? 'card-header border-end' : '') + ], + [ + "bg-{$this->options['header-variant']}", + "text-{$this->options['header-text-variant']}", + "border-{$this->options['header-border-variant']}" + ] + )]); + $html .= $this->genNav(); + $html .= $this->closeNode('div'); + $html .= $this->openNode('div', ['class' => array_merge( + [ + ($this->options['vertical-size'] != 'auto' ? 'col-' . (12 - $this->options['vertical-size']) : ''), + ($this->options['card'] ? 'card-body2' : '') + ], + [ + "bg-{$this->options['body-variant']}", + "text-{$this->options['body-text-variant']}" + ] + )]); + $html .= $this->genContent(); + $html .= $this->closeNode('div'); $html .= $this->closeNode('div'); return $html; } @@ -479,7 +495,8 @@ class BootstrapTabs extends BootstrapGeneric } } -class BoostrapAlert extends BootstrapGeneric { +class BoostrapAlert extends BootstrapGeneric +{ private $defaultOptions = [ 'text' => '', 'html' => null, @@ -490,7 +507,8 @@ class BoostrapAlert extends BootstrapGeneric { private $bsClasses = null; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -537,11 +555,12 @@ class BoostrapAlert extends BootstrapGeneric { private function genContent() { - return !is_null($this->options['html']) ? $this->options['html'] : $this->options['text']; + return !is_null($this->options['html']) ? $this->options['html'] : h($this->options['text']); } } -class BoostrapTable extends BootstrapGeneric { +class BoostrapTable extends BootstrapGeneric +{ private $defaultOptions = [ 'striped' => true, 'bordered' => true, @@ -556,7 +575,8 @@ class BoostrapTable extends BootstrapGeneric { private $bsClasses = null; - function __construct($options, $data, $btHelper) { + function __construct($options, $data, $btHelper) + { $this->allowedOptionValues = [ 'variant' => array_merge(BootstrapGeneric::$variants, ['']) ]; @@ -597,7 +617,7 @@ class BoostrapTable extends BootstrapGeneric { $html .= $this->genCaption(); $html .= $this->genHeader(); $html .= $this->genBody(); - + $html .= $this->closeNode('table'); return $html; } @@ -645,7 +665,7 @@ class BoostrapTable extends BootstrapGeneric { private function genRow($row, $rowIndex) { - $html = $this->openNode('tr',[ + $html = $this->openNode('tr', [ 'class' => [ !empty($row['_rowVariant']) ? "table-{$row['_rowVariant']}" : '' ] @@ -694,7 +714,8 @@ class BoostrapTable extends BootstrapGeneric { } } -class BoostrapListTable extends BootstrapGeneric { +class BoostrapListTable extends BootstrapGeneric +{ private $defaultOptions = [ 'striped' => true, 'bordered' => false, @@ -708,7 +729,8 @@ class BoostrapListTable extends BootstrapGeneric { private $bsClasses = null; - function __construct($options, $data, $btHelper) { + function __construct($options, $data, $btHelper) + { $this->allowedOptionValues = [ 'variant' => array_merge(BootstrapGeneric::$variants, ['']) ]; @@ -749,7 +771,7 @@ class BoostrapListTable extends BootstrapGeneric { $html .= $this->genCaption(); $html .= $this->genBody(); - + $html .= $this->closeNode('table'); return $html; } @@ -773,11 +795,11 @@ class BoostrapListTable extends BootstrapGeneric { $rowValue = $this->genCell($field); $rowKey = $this->genNode('th', [ 'class' => [ - 'col-sm-2' + 'col-4 col-sm-2' ], 'scope' => 'row' ], h($field['key'])); - $row = $this->genNode('tr',[ + $row = $this->genNode('tr', [ 'class' => [ 'd-flex', !empty($field['_rowVariant']) ? "table-{$field['_rowVariant']}" : '' @@ -786,7 +808,7 @@ class BoostrapListTable extends BootstrapGeneric { return $row; } - private function genCell($field=[]) + private function genCell($field = []) { if (isset($field['raw'])) { $cellContent = h($field['raw']); @@ -802,7 +824,7 @@ class BoostrapListTable extends BootstrapGeneric { } return $this->genNode('td', [ 'class' => [ - 'col-sm-10', + 'col-8 col-sm-10', !empty($field['_cellVariant']) ? "bg-{$field['_cellVariant']}" : '' ] ], $cellContent); @@ -821,7 +843,8 @@ class BoostrapListTable extends BootstrapGeneric { private function getElementPath($type) { - return sprintf('%s%sField', + return sprintf( + '%s%sField', $this->options['elementsRootPath'] ?? '', $type ); @@ -833,7 +856,8 @@ class BoostrapListTable extends BootstrapGeneric { } } -class BoostrapButton extends BootstrapGeneric { +class BoostrapButton extends BootstrapGeneric +{ private $defaultOptions = [ 'id' => '', 'text' => '', @@ -853,10 +877,11 @@ class BoostrapButton extends BootstrapGeneric { private $bsClasses = []; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => array_merge(BootstrapGeneric::$variants, ['link', 'text']), - 'size' => ['', 'sm', 'lg'], + 'size' => ['', 'xs', 'sm', 'lg'], 'type' => ['button', 'submit', 'reset'] ]; if (empty($options['class'])) { @@ -871,6 +896,10 @@ class BoostrapButton extends BootstrapGeneric { $this->options = array_merge($this->defaultOptions, $options); $this->checkOptionValidity(); + if (!empty($this->options['id'])) { + $this->options['params']['id'] = $this->options['id']; + } + $this->bsClasses[] = 'btn'; if ($this->options['outline']) { $this->bsClasses[] = "btn-outline-{$this->options['variant']}"; @@ -942,7 +971,8 @@ class BoostrapButton extends BootstrapGeneric { } } -class BoostrapBadge extends BootstrapGeneric { +class BoostrapBadge extends BootstrapGeneric +{ private $defaultOptions = [ 'text' => '', 'variant' => 'primary', @@ -951,7 +981,8 @@ class BoostrapBadge extends BootstrapGeneric { 'class' => [], ]; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -961,6 +992,7 @@ class BoostrapBadge extends BootstrapGeneric { private function processOptions($options) { $this->options = array_merge($this->defaultOptions, $options); + $this->options['class'] = is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']]; $this->checkOptionValidity(); } @@ -985,13 +1017,17 @@ class BoostrapBadge extends BootstrapGeneric { } } -class BoostrapIcon extends BootstrapGeneric { +class BoostrapIcon extends BootstrapGeneric +{ private $icon = ''; private $defaultOptions = [ 'class' => [], + 'title' => '', + 'params' => [], ]; - function __construct($icon, $options=[]) { + function __construct($icon, $options = []) + { $this->icon = $icon; $this->processOptions($options); } @@ -1009,17 +1045,19 @@ class BoostrapIcon extends BootstrapGeneric { private function genIcon() { - $html = $this->genNode('span', [ + $html = $this->genNode('span', array_merge([ 'class' => array_merge( is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']], ["fa fa-{$this->icon}"] ), - ]); + 'title' => h($this->options['title']) + ], $this->options['params'])); return $html; } } -class BoostrapModal extends BootstrapGeneric { +class BoostrapModal extends BootstrapGeneric +{ private $defaultOptions = [ 'size' => '', 'centered' => true, @@ -1044,10 +1082,11 @@ class BoostrapModal extends BootstrapGeneric { private $bsClasses = null; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'size' => ['sm', 'lg', 'xl', ''], - 'type' => ['ok-only','confirm','confirm-success','confirm-warning','confirm-danger', 'custom'], + 'type' => ['ok-only', 'confirm', 'confirm-success', 'confirm-warning', 'confirm-danger', 'custom'], 'variant' => array_merge(BootstrapGeneric::$variants, ['']), ]; $this->processOptions($options); @@ -1066,6 +1105,7 @@ class BoostrapModal extends BootstrapGeneric { private function genModal() { + $this->options['modalClass'] = !empty($this->options['modalClass']) && !is_array($this->options['modalClass']) ? [$this->options['modalClass']] : $this->options['modalClass']; $dialog = $this->openNode('div', [ 'class' => array_merge( ['modal-dialog', (!empty($this->options['size'])) ? "modal-{$this->options['size']}" : ''], @@ -1126,7 +1166,8 @@ class BoostrapModal extends BootstrapGeneric { return $footer; } - private function getFooterBasedOnType() { + private function getFooterBasedOnType() + { if ($this->options['type'] == 'ok-only') { return $this->getFooterOkOnly(); } else if (str_contains($this->options['type'], 'confirm')) { @@ -1238,7 +1279,7 @@ class BoostrapCard extends BootstrapGeneric 'card', !empty($this->options['variant']) ? "bg-{$this->options['variant']}" : '', !empty($this->options['variant']) ? $this->getTextClassForVariant($this->options['variant']) : '', - h($this->options['class']), + h(is_array($this->options['class']) ? implode(' ', $this->options['class']) : $this->options['class']), ], ], implode('', [$this->genHeader(), $this->genBody(), $this->genFooter()])); return $card; @@ -1290,7 +1331,8 @@ class BoostrapCard extends BootstrapGeneric } } -class BoostrapSwitch extends BootstrapGeneric { +class BoostrapSwitch extends BootstrapGeneric +{ private $defaultOptions = [ 'label' => '', 'variant' => 'primary', @@ -1301,7 +1343,8 @@ class BoostrapSwitch extends BootstrapGeneric { 'attrs' => [], ]; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -1344,7 +1387,69 @@ class BoostrapSwitch extends BootstrapGeneric { } } -class BoostrapProgress extends BootstrapGeneric { +class BoostrapNotificationBuble extends BootstrapGeneric +{ + private $defaultOptions = [ + 'label' => '', + 'variant' => 'warning', + 'borderVariant' => 'ligth', + 'title' => 'Notification', + 'class' => [], + 'attrs' => [], + ]; + + function __construct($options) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + ]; + $this->defaultOptions['label'] = __('New notifications'); + $this->processOptions($options); + } + + private function processOptions($options) + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + if (!empty($this->options['attrs']['style'])) { + $this->options['attrs']['style'] += 'box-shadow: 0 0.125rem 0.25rem #00000050;'; + } else { + $this->options['attrs']['style'] = 'box-shadow: 0 0.125rem 0.25rem #00000050;'; + } + } + + public function notificationBubble() + { + return $this->genNotificationBubble(); + } + + private function genNotificationBubble() + { + $tmpId = 'tmp-' . mt_rand(); + $html = $this->genNode('span', [ + 'id' => $tmpId, + 'class' => [ + 'position-absolute', + 'top-0', + 'start-100', + 'translate-middle', + 'p-1', + 'border border-2 rounded-circle', + "border-{$this->options['borderVariant']}", + "bg-{$this->options['variant']}", + ], + 'title' => $this->options['title'] + ], $this->genNode('span', [ + 'class' => [ + ], + $this->options['label'] + ])); + return $html; + } +} + +class BoostrapProgress extends BootstrapGeneric +{ private $defaultOptions = [ 'value' => 0, 'total' => 100, @@ -1357,7 +1462,8 @@ class BoostrapProgress extends BootstrapGeneric { 'label' => true ]; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -1389,7 +1495,7 @@ class BoostrapProgress extends BootstrapGeneric { $this->options['animated'] ? 'progress-bar-animated' : '', ], 'role' => "progressbar", - 'aria-valuemin' => "0", 'aria-valuemax' => "100",'aria-valuenow' => $percentage, + 'aria-valuemin' => "0", 'aria-valuemax' => "100", 'aria-valuenow' => $percentage, 'style' => "${widthStyle}", 'title' => $this->options['title'] ], $label); @@ -1404,13 +1510,15 @@ class BoostrapProgress extends BootstrapGeneric { } } -class BoostrapCollapse extends BootstrapGeneric { +class BoostrapCollapse extends BootstrapGeneric +{ private $defaultOptions = [ - 'text' => '', + 'title' => '', 'open' => false, ]; - function __construct($options, $content, $btHelper) { + function __construct($options, $content, $btHelper) + { $this->allowedOptionValues = []; $this->processOptions($options); $this->content = $content; @@ -1461,7 +1569,98 @@ class BoostrapCollapse extends BootstrapGeneric { } } -class BoostrapProgressTimeline extends BootstrapGeneric { +class BoostrapAccordion extends BootstrapGeneric +{ + private $defaultOptions = [ + 'stayOpen' => true, + 'class' => [], + ]; + + function __construct($options, $content, $btHelper) + { + $this->allowedOptionValues = []; + $this->content = $content; + $this->btHelper = $btHelper; + $this->processOptions($options); + } + + private function processOptions($options) + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + if (!is_array($this->options['class']) && !empty($this->options['class'])) { + $this->options['class'] = [$this->options['class']]; + } + $this->seed = 'acc-' . mt_rand(); + $this->contentSeeds = []; + foreach ($this->content as $accordionItem) { + $this->contentSeeds[] = mt_rand(); + } + } + + public function accordion() + { + return $this->genAccordion(); + } + + private function genHeader($accordionItem, $i) + { + $html = $this->openNode('h2', [ + 'class' => ['accordion-header'], + 'id' => 'head-' . $this->contentSeeds[$i] + ]); + $content = !empty($accordionItem['header']['html']) ? $accordionItem['header']['html'] : h($accordionItem['header']['title'] ?? '- no title -'); + $buttonOptions = [ + 'class' => array_merge(['accordion-button', empty($accordionItem['_open']) ? 'collapsed' : ''], $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->genNode('button', $buttonOptions, $content); + $html .= $this->closeNode(('h2')); + return $html; + } + + private function genBody($accordionItem, $i) + { + $content = $this->genNode('div', [ + 'class' => ['accordion-body'] + ], $accordionItem['body']); + $divOptions = [ + 'class' => array_merge(['accordion-collapse collapse', empty($accordionItem['_open']) ? '' : 'show'], $accordionItem['body']['__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->genNode('div', $divOptions, $content); + return $html; + } + + private function genAccordion() + { + $html = $this->openNode('div', [ + 'class' => array_merge(['accordion'], $this->options['class']), + 'id' => $this->seed + ]); + foreach ($this->content as $i => $accordionItem) { + $html .= $this->openNode('div', [ + 'class' => array_merge(['accordion-item'], $accordionItem['__class'] ?? []) + ]); + $html .= $this->genHeader($accordionItem, $i); + $html .= $this->genBody($accordionItem, $i); + $html .= $this->closeNode('div'); + } + $html .= $this->closeNode('div'); + return $html; + } +} + +class BoostrapProgressTimeline extends BootstrapGeneric +{ private $defaultOptions = [ 'steps' => [], 'selected' => 0, @@ -1469,7 +1668,8 @@ class BoostrapProgressTimeline extends BootstrapGeneric { 'variantInactive' => 'secondary', ]; - function __construct($options, $btHelper) { + function __construct($options, $btHelper) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, 'variantInactive' => BootstrapGeneric::$variants, @@ -1496,7 +1696,7 @@ class BoostrapProgressTimeline extends BootstrapGeneric { !empty($step['icon']) ? h($this->btHelper->FontAwesome->getClass($step['icon'])) : '', $this->getTextClassForVariant($this->options['variant']) ], - ], empty($step['icon']) ? h($i+1) : ''); + ], empty($step['icon']) ? h($i + 1) : ''); $iconContainer = $this->genNode('span', [ 'class' => [ 'd-flex', 'align-items-center', 'justify-content-center', @@ -1518,7 +1718,7 @@ class BoostrapProgressTimeline extends BootstrapGeneric { private function getHorizontalLine($i, $nodeActive, $lineActive) { $stepCount = count($this->options['steps']); - if ($i == $stepCount-1) { + if ($i == $stepCount - 1) { return ''; } $progressBar = (new BoostrapProgress([ @@ -1583,7 +1783,8 @@ class BootstrapListGroup extends BootstrapGeneric private $bsClasses = null; - function __construct($options, $data, $btHelper) { + function __construct($options, $data, $btHelper) + { $this->data = $data; $this->processOptions($options); $this->btHelper = $btHelper; @@ -1685,7 +1886,8 @@ class BoostrapDropdownMenu extends BootstrapGeneric 'submenu_classes' => [], ]; - function __construct($options, $btHelper) { + function __construct($options, $btHelper) + { $this->allowedOptionValues = [ 'direction' => ['start', 'end', 'up', 'down'], 'alignment' => ['start', 'end'], @@ -1714,7 +1916,7 @@ class BoostrapDropdownMenu extends BootstrapGeneric return $this->genDropdownWrapper($this->genDropdownToggleButton(), $this->genDropdownMenu($this->menu)); } - public function genDropdownWrapper($toggle='', $menu='', $direction=null, $classes=null) + public function genDropdownWrapper($toggle = '', $menu = '', $direction = null, $classes = null) { $classes = !is_null($classes) ? $classes : $this->options['dropdown-class']; $direction = !is_null($direction) ? $direction : $this->options['direction']; @@ -1747,7 +1949,7 @@ class BoostrapDropdownMenu extends BootstrapGeneric return $this->btHelper->button($options); } - private function genDropdownMenu($entries, $alignment=null) + private function genDropdownMenu($entries, $alignment = null) { $alignment = !is_null($alignment) ? $alignment : $this->options['alignment']; $html = $this->genNode('div', [ @@ -1775,16 +1977,32 @@ class BoostrapDropdownMenu extends BootstrapGeneric 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 BoostrapBadge(array_merge( + ['class' => ['ms-auto']], + $entry['badge'] + )); + $badge = $bsBadge->badge(); + } + + if (!empty($entry['header'])) { + return $this->genNode('h6', [ + 'class' => ['dropdown-header',], + ], $icon . h($entry['text']) . $badge); + } $classes = ['dropdown-item']; $params = ['href' => '#']; - $icon = ''; - if (!empty($entry['icon'])) { - $icon = $this->btHelper->icon($entry['icon']); - } 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'; @@ -1794,13 +2012,11 @@ class BoostrapDropdownMenu extends BootstrapGeneric $params['data-open-form-id'] = mt_rand(); } - $label = $this->genNode('span', [ - 'class' => ['ms-2',], - ], h($entry['text'])); - $content = $icon . $label; + $label = $this->genNode('span', ['class' => 'mx-1'], h($entry['text'])); + $content = $icon . $label . $badge; return $this->genNode('a', array_merge([ 'class' => $classes, ], $params), $content); } -} \ No newline at end of file +} diff --git a/src/View/Helper/ValueGetterHelper.php b/src/View/Helper/ValueGetterHelper.php new file mode 100644 index 0000000..d64bb2f --- /dev/null +++ b/src/View/Helper/ValueGetterHelper.php @@ -0,0 +1,24 @@ +eval($target, $args); + } else { + $value = h($target); + } + return $value; + } + + private function eval($fun, $args=[]) + { + return $fun($args); + } +} \ No newline at end of file diff --git a/templates/Api/index.php b/templates/Api/index.php index 96be4b8..6d6208a 100644 --- a/templates/Api/index.php +++ b/templates/Api/index.php @@ -1,2 +1,2 @@ - \ No newline at end of file +Html->script('redoc.standalone.js') ?> \ No newline at end of file diff --git a/templates/AuditLogs/index.php b/templates/AuditLogs/index.php index 8b7b70e..e46892c 100644 --- a/templates/AuditLogs/index.php +++ b/templates/AuditLogs/index.php @@ -6,10 +6,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', - 'searchKey' => 'value' + 'searchKey' => 'value', + 'allowFilering' => true + ], + [ + 'type' => 'table_action', ] ] ], diff --git a/templates/AuthKeys/index.php b/templates/AuthKeys/index.php index 4f42c1b..1b5599a 100644 --- a/templates/AuthKeys/index.php +++ b/templates/AuthKeys/index.php @@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/Broods/index.php b/templates/Broods/index.php index 43d454a..a78cf5a 100644 --- a/templates/Broods/index.php +++ b/templates/Broods/index.php @@ -20,7 +20,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/Broods/preview_individuals.php b/templates/Broods/preview_individuals.php index 85a565d..73e2199 100644 --- a/templates/Broods/preview_individuals.php +++ b/templates/Broods/preview_individuals.php @@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/templates/Broods/preview_organisations.php b/templates/Broods/preview_organisations.php index 5bded24..40b09c3 100644 --- a/templates/Broods/preview_organisations.php +++ b/templates/Broods/preview_organisations.php @@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/templates/Broods/preview_sharing_groups.php b/templates/Broods/preview_sharing_groups.php index 1348182..5a4cc58 100644 --- a/templates/Broods/preview_sharing_groups.php +++ b/templates/Broods/preview_sharing_groups.php @@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/templates/EncryptionKeys/index.php b/templates/EncryptionKeys/index.php index 5017319..413e2d8 100644 --- a/templates/EncryptionKeys/index.php +++ b/templates/EncryptionKeys/index.php @@ -24,7 +24,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/Inbox/index.php b/templates/Inbox/index.php index f70bd90..6576ec3 100644 --- a/templates/Inbox/index.php +++ b/templates/Inbox/index.php @@ -29,7 +29,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/templates/Inbox/list_processors.php b/templates/Inbox/list_processors.php index fd93199..3c2a7c7 100644 --- a/templates/Inbox/list_processors.php +++ b/templates/Inbox/list_processors.php @@ -11,7 +11,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/templates/Individuals/add.php b/templates/Individuals/add.php index 19bcf36..436d6fa 100644 --- a/templates/Individuals/add.php +++ b/templates/Individuals/add.php @@ -27,7 +27,6 @@ 'requirements' => $this->request->getParam('action') === 'edit' ), ), - 'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates, 'submit' => array( 'action' => $this->request->getParam('action') ) diff --git a/templates/Individuals/index.php b/templates/Individuals/index.php index 84ed353..5867f19 100644 --- a/templates/Individuals/index.php +++ b/templates/Individuals/index.php @@ -20,7 +20,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', @@ -28,7 +28,6 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'table_action', - 'table_setting_id' => 'individual_index', ] ] ], @@ -73,7 +72,6 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], 'title' => __('ContactDB Individuals Index'), 'description' => __('A list of individuals known by your Cerebrate instance. This list can get populated either directly, by adding new individuals or by fetching them from trusted remote sources. Additionally, users created for the platform will always have an individual identity.'), - 'pull' => 'right', 'actions' => [ [ 'url' => '/individuals/view', diff --git a/templates/Instance/home.php b/templates/Instance/home.php index e5803c2..f3c6604 100644 --- a/templates/Instance/home.php +++ b/templates/Instance/home.php @@ -42,8 +42,8 @@ $this->userSettingsTable = TableRegistry::getTableLocator()->get('UserSettings')
- $statistics) : ?> -
+ $statisticForModel) : ?> +
userSettingsTable = TableRegistry::getTableLocator()->get('UserSettings') ); echo $this->element('widgets/highlight-panel', [ 'titleHtml' => $panelTitle, - 'number' => $statistics['amount'], - 'variation' => $statistics['variation'] ?? '', - 'chartData' => $statistics['timeline'] ?? [] + 'number' => $statisticForModel['created']['amount'], + 'variation' => $statisticForModel['created']['variation'] ?? null, + 'timeline' => $statisticForModel ?? [] ]); ?>
diff --git a/templates/LocalTools/brood_tools.php b/templates/LocalTools/brood_tools.php index 87a8508..6f46f9a 100644 --- a/templates/LocalTools/brood_tools.php +++ b/templates/LocalTools/brood_tools.php @@ -6,7 +6,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/LocalTools/connector_index.php b/templates/LocalTools/connector_index.php index f199d30..c896b17 100644 --- a/templates/LocalTools/connector_index.php +++ b/templates/LocalTools/connector_index.php @@ -36,7 +36,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/LocalTools/index.php b/templates/LocalTools/index.php index 91903a2..5c27c56 100644 --- a/templates/LocalTools/index.php +++ b/templates/LocalTools/index.php @@ -6,7 +6,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/MailingLists/add.php b/templates/MailingLists/add.php new file mode 100644 index 0000000..63a1db4 --- /dev/null +++ b/templates/MailingLists/add.php @@ -0,0 +1,35 @@ +element('genericElements/Form/genericForm', [ + 'data' => [ + 'description' => __('Mailing list are email distribution lists containing individuals.'), + 'model' => 'MailingLists', + 'fields' => [ + [ + 'field' => 'name' + ], + [ + 'field' => 'uuid', + 'label' => 'UUID', + 'type' => 'uuid' + ], + [ + 'field' => 'releasability', + 'type' => 'textarea' + ], + [ + 'field' => 'description', + 'type' => 'textarea' + ], + [ + 'field' => 'active', + 'type' => 'checkbox', + 'default' => 1 + ], + ], + 'submit' => [ + 'action' => $this->request->getParam('action') + ] + ] + ]); +?> +
diff --git a/templates/MailingLists/add_individual.php b/templates/MailingLists/add_individual.php new file mode 100644 index 0000000..0455d22 --- /dev/null +++ b/templates/MailingLists/add_individual.php @@ -0,0 +1,121 @@ +element('genericElements/Form/genericForm', [ + 'data' => [ + 'title' => __('Add members to `{0}` [{1}]', h($mailingList->name), h($mailingList->id)), + 'model' => 'MailingLists', + 'fields' => [ + [ + 'field' => 'individuals', + 'type' => 'dropdown', + 'multiple' => true, + 'select2' => true, + 'label' => __('Members'), + 'class' => 'select2-input', + 'options' => $dropdownData['individuals'] + ], + [ + 'field' => 'chosen_emails', + 'type' => 'text', + 'templates' => ['inputContainer' => '
{{content}}
'], + ], + '
' + ], + 'submit' => [ + 'action' => $this->request->getParam('action') + ], + ], +]); +?> +
+ + \ No newline at end of file diff --git a/templates/MailingLists/index.php b/templates/MailingLists/index.php new file mode 100644 index 0000000..dbcfae0 --- /dev/null +++ b/templates/MailingLists/index.php @@ -0,0 +1,100 @@ +element('genericElements/IndexTable/index_table', [ + 'data' => [ + 'data' => $data, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'simple', + 'children' => [ + 'data' => [ + 'type' => 'simple', + 'text' => __('Add mailing list'), + 'popover_url' => '/MailingLists/add' + ] + ] + ], + [ + 'type' => 'search', + 'button' => __('Search'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value' + ], + [ + 'type' => 'table_action', + 'table_setting_id' => 'mailinglist_index', + ] + ] + ], + 'fields' => [ + [ + 'name' => '#', + 'sort' => 'id', + 'class' => 'short', + 'data_path' => 'id', + ], + [ + 'name' => __('Name'), + 'sort' => 'name', + 'data_path' => 'name', + ], + [ + 'name' => __('Owner'), + 'data_path' => 'user_id', + 'element' => 'user' + ], + [ + 'name' => __('UUID'), + 'sort' => 'uuid', + 'data_path' => 'uuid', + ], + [ + 'name' => __('Members'), + 'data_path' => 'individuals', + 'element' => 'count_summary', + ], + [ + 'name' => __('Intended recipients'), + 'data_path' => 'recipients', + ], + [ + 'name' => __('Description'), + 'data_path' => 'description', + ], + [ + 'name' => __('Active'), + 'data_path' => 'active', + 'sort' => 'active', + 'element' => 'boolean', + ], + [ + 'name' => __('Deleted'), + 'data_path' => 'deleted', + 'sort' => 'deleted', + 'element' => 'boolean', + ], + ], + 'title' => __('Mailing Lists Index'), + 'description' => __('Mailing list are email distribution lists containing individuals.'), + 'actions' => [ + [ + 'url' => '/mailingLists/view', + 'url_params_data_paths' => ['id'], + 'icon' => 'eye' + ], + [ + 'open_modal' => '/mailingLists/edit/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'icon' => 'edit' + ], + [ + 'open_modal' => '/mailingLists/delete/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'icon' => 'trash' + ], + ] + ] +]); +?> diff --git a/templates/MailingLists/list_individuals.php b/templates/MailingLists/list_individuals.php new file mode 100644 index 0000000..c1b37fc --- /dev/null +++ b/templates/MailingLists/list_individuals.php @@ -0,0 +1,142 @@ +element('genericElements/IndexTable/index_table', [ + 'data' => [ + 'data' => $individuals, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'multi_select_actions', + 'children' => [ + [ + 'text' => __('Remove members'), + 'variant' => 'danger', + 'onclick' => 'removeMembers', + ] + ], + 'data' => [ + 'id' => [ + 'value_path' => 'id' + ] + ] + ], + [ + 'type' => 'simple', + 'children' => [ + 'data' => [ + 'type' => 'simple', + 'text' => __('Add member'), + 'popover_url' => '/mailingLists/addIndividual/' . h($mailing_list_id), + 'reload_url' => '/mailingLists/listIndividuals/' . h($mailing_list_id) + ] + ] + ], + [ + 'type' => 'search', + 'button' => __('Search'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value', + 'additionalUrlParams' => h($mailing_list_id) + ], + ] + ], + 'fields' => [ + [ + 'name' => '#', + 'sort' => 'id', + 'data_path' => 'id', + 'url' => '/individuals/view/{{0}}', + 'url_vars' => ['id'], + ], + [ + 'name' => __('First name'), + 'data_path' => 'first_name', + 'url' => '/individuals/view/{{0}}', + 'url_vars' => ['id'], + ], + [ + 'name' => __('Last name'), + 'data_path' => 'last_name', + 'url' => '/individuals/view/{{0}}', + 'url_vars' => ['id'], + ], + [ + 'name' => __('Registered Email'), + 'data_path' => 'mailinglist_emails', + 'element' => 'list', + ], + [ + 'name' => __('UUID'), + 'sort' => 'uuid', + 'data_path' => 'uuid', + ] + ], + 'actions' => [ + [ + 'open_modal' => '/mailingLists/removeIndividual/' . h($mailing_list_id) . '/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'reload_url' => '/mailingLists/listIndividuals/' . h($mailing_list_id), + 'icon' => 'trash' + ], + ] + ] +]); +?> + + \ No newline at end of file diff --git a/templates/MailingLists/view.php b/templates/MailingLists/view.php new file mode 100644 index 0000000..5983017 --- /dev/null +++ b/templates/MailingLists/view.php @@ -0,0 +1,53 @@ +element( + '/genericElements/SingleViews/single_view', + [ + 'data' => $entity, + 'fields' => [ + [ + 'key' => __('ID'), + 'path' => 'id' + ], + [ + 'key' => __('UUID'), + 'path' => 'uuid' + ], + [ + 'key' => __('Name'), + 'path' => 'name' + ], + [ + 'key' => __('Owner'), + 'path' => 'user_id', + 'url' => '/users/view/{{0}}', + 'url_vars' => 'user_id' + ], + [ + 'key' => __('Releasability'), + 'path' => 'releasability' + ], + [ + 'key' => __('Description'), + 'path' => 'description' + ], + [ + 'key' => __('Active'), + 'path' => 'active', + 'type' => 'boolean' + ], + [ + 'key' => __('Deleted'), + 'path' => 'deleted', + 'type' => 'boolean' + ] + ], + 'children' => [ + [ + 'url' => '/mailingLists/listIndividuals/{{0}}', + 'url_params' => ['id'], + 'title' => __('Individuals'), + 'collapsed' => 'show', + ] + ] + ] +); diff --git a/templates/MetaTemplateFields/index.php b/templates/MetaTemplateFields/index.php index 6cadf15..4071d13 100644 --- a/templates/MetaTemplateFields/index.php +++ b/templates/MetaTemplateFields/index.php @@ -6,7 +6,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' @@ -34,7 +34,12 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'name' => __('Validation regex'), 'sort' => 'regex', 'data_path' => 'regex' - ] + ], + [ + 'name' => __('Field Usage'), + 'sort' => 'counter', + 'data_path' => 'counter', + ], ], 'title' => __('Meta Template Fields'), 'description' => __('The various fields that the given template contans. When a meta template is enabled, the fields are automatically appended to the appropriate object.'), diff --git a/templates/MetaTemplates/get_meta_fields_to_update.php b/templates/MetaTemplates/get_meta_fields_to_update.php new file mode 100644 index 0000000..46d9dfa --- /dev/null +++ b/templates/MetaTemplates/get_meta_fields_to_update.php @@ -0,0 +1,42 @@ + + 'metaTemplates', + 'action' => 'view', + $newestMetaTemplate->id +]); + +$bodyHtml = ''; +$bodyHtml .= sprintf('
%s: %s
', __('Current version'), h($metaTemplate->version)); +$bodyHtml .= sprintf('
%s: %s
', __('Newest version'), $urlNewestMetaTemplate, h($newestMetaTemplate->version)); +$bodyHtml .= sprintf('

%s

', __('Entities with meta-fields to be updated:')); + +$bodyHtml .= ''; + +echo $this->Bootstrap->modal([ + 'titleHtml' => __('{0} has a new meta-template and meta-fields to be updated', sprintf('%s', h($metaTemplate->name))), + 'bodyHtml' => $bodyHtml, + 'size' => 'lg', + 'type' => 'ok-only', +]); +?> \ No newline at end of file diff --git a/templates/MetaTemplates/index.php b/templates/MetaTemplates/index.php index 4d1d9c0..799eedc 100644 --- a/templates/MetaTemplates/index.php +++ b/templates/MetaTemplates/index.php @@ -1,5 +1,34 @@ %s %s', + __('New meta-templates available!'), + __n('There is one new template on disk that can be loaded in the database', 'There are {0} new templates on disk that can be loaded in the database:', count($updateableTemplates['new']), count($updateableTemplates['new'])) + ); + $alertList = []; + $alertList = Hash::extract($updateableTemplates['new'], '{s}.template'); + $alertList = array_map(function($entry) { + return sprintf('%s:%s %s', + h($entry['namespace']), + h($entry['name']), + $this->Bootstrap->button([ + 'variant' => 'link', + 'size' => 'sm', + 'icon' => 'download', + 'title' => __('Create this template'), + 'params' => [ + 'onclick' => "UI.submissionModalForIndex('/metaTemplates/createNewTemplate/{$entry['uuid']}', '/meta-templates')" + ] + ]) + ); + }, $alertList); + $alertHtml .= $this->Html->nestedList($alertList); +} + echo $this->element('genericElements/IndexTable/index_table', [ + 'notice' => !empty($alertHtml) ? ['html' => $alertHtml, 'variant' => 'warning',] : false, 'data' => [ 'data' => $data, 'top_bar' => [ @@ -10,7 +39,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' @@ -129,21 +158,60 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'sort' => 'namespace', 'data_path' => 'namespace', ], + [ + 'name' => __('Version'), + 'sort' => 'version', + 'data_path' => 'version', + ], [ 'name' => __('UUID'), 'sort' => 'uuid', 'data_path' => 'uuid' - ] + ], ], 'title' => __('Meta Field Templates'), 'description' => __('The various templates used to enrich certain objects by a set of standardised fields.'), - 'pull' => 'right', 'actions' => [ [ 'url' => '/metaTemplates/view', 'url_params_data_paths' => ['id'], 'icon' => 'eye' ], + [ + 'open_modal' => '/metaTemplates/update/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'title' => __('Update Meta-Template'), + 'icon' => 'download', + 'complex_requirement' => [ + 'function' => function ($row, $options) { + return empty($row['updateStatus']['up-to-date']) && empty($row['updateStatus']['to-existing']); + } + ] + ], + [ + 'open_modal' => '/metaTemplates/getMetaFieldsToUpdate/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'title' => __('Get meta-fields that should be moved to the newest version of this meta-template'), + 'icon' => 'exclamation-triangle', + 'variant' => 'warning', + 'complex_requirement' => [ + 'function' => function ($row, $options) { + return !empty($row['updateStatus']['to-existing']) && empty($row['updateStatus']['can-be-removed']); + } + ] + ], + [ + 'open_modal' => '/metaTemplates/delete/[onclick_params_data_path]', + 'modal_params_data_path' => 'id', + 'title' => __('Get meta-fields that should be moved to the newest version of this meta-template'), + 'icon' => 'trash', + 'variant' => 'success', + 'complex_requirement' => [ + 'function' => function ($row, $options) { + return !empty($row['updateStatus']['to-existing']) && !empty($row['updateStatus']['can-be-removed']); + } + ] + ], ] ] ]); diff --git a/templates/MetaTemplates/migrate_old_meta_template_to_newest_version_for_entity.php b/templates/MetaTemplates/migrate_old_meta_template_to_newest_version_for_entity.php new file mode 100644 index 0000000..8a7ee11 --- /dev/null +++ b/templates/MetaTemplates/migrate_old_meta_template_to_newest_version_for_entity.php @@ -0,0 +1,109 @@ + +

name) ?>

+
+
+
+
+

+ 'view', + $oldMetaTemplate->id + ]); + ?> + version)) ?> + Bootstrap->badge([ + 'text' => __('Data to be migrated over'), + 'variant' => 'danger', + 'class' => 'fs-7' + ]) + ?> +

+
+ element('MetaTemplates/migrationToNewVersionForm', [ + 'metaTemplate' => $oldMetaTemplate, + 'entity' => $entity, + ]) + ?> +
+
+
+
+ Bootstrap->icon('arrow-alt-circle-right') ?> +
+
+
+

+ 'view', + $newMetaTemplate->id + ]); + ?> + version)) ?> + Bootstrap->badge([ + 'text' => __('Data to be saved'), + 'variant' => 'success', + 'class' => 'fs-7' + ]) + ?> +

+
+ element('MetaTemplates/migrationToNewVersionForm', [ + 'metaTemplate' => $newMetaTemplate, + 'entity' => $entity, + ]) + ?> +
+
+
+
+
+ Bootstrap->button([ + 'text' => __('Update to version {0}', h($newMetaTemplate->version)), + 'variant' => 'success', + 'params' => [ + 'onclick' => 'submitMigration()' + ] + ]) + ?> +
+
+ +Html->scriptBlock(sprintf( + 'var csrfToken = %s;', + json_encode($this->request->getAttribute('csrfToken')) +)); +?> + + \ No newline at end of file diff --git a/templates/MetaTemplates/update.php b/templates/MetaTemplates/update.php new file mode 100644 index 0000000..411e477 --- /dev/null +++ b/templates/MetaTemplates/update.php @@ -0,0 +1,72 @@ +Bootstrap->alert([ + 'variant' => 'success', + 'text' => __('This meta-template is already up-to-date!'), + 'dismissible' => false, + ]); + $modalType = 'ok-only'; +} else { + if ($updateStatus['automatically-updateable']) { + $bodyHtml .= $this->Bootstrap->alert([ + 'variant' => 'success', + 'html' => __('This meta-template can be updated to version {0} (current: {1}).', sprintf('%s', h($templateOnDisk['version'])), h($metaTemplate->version)), + 'dismissible' => false, + ]); + $form = $this->element('genericElements/Form/genericForm', [ + 'entity' => null, + 'ajax' => false, + 'raw' => true, + 'data' => [ + 'model' => 'MetaTemplate', + 'fields' => [ + [ + 'field' => 'update_strategy', + 'type' => 'checkbox', + 'value' => MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW, + 'checked' => true, + ] + ], + 'submit' => [ + 'action' => $this->request->getParam('action') + ], + ] + ]); + $bodyHtml .= sprintf('
%s
', $form); + } else { + $modalSize = 'xl'; + $bodyHtml .= $this->Bootstrap->alert([ + 'variant' => 'warning', + 'text' => __('Updating to version {0} cannot be done automatically as it introduces some conflicts.', h($templateOnDisk['version'])), + 'dismissible' => false, + ]); + $conflictTable = $this->element('MetaTemplates/conflictTable', [ + 'templateStatus' => $templateStatus, + 'metaTemplate' => $metaTemplate, + 'templateOnDisk' => $templateOnDisk, + ]); + $bodyHtml .= $this->Bootstrap->collapse([ + 'title' => __('View conflicts'), + 'open' => false + ], $conflictTable); + $bodyHtml .= $this->element('MetaTemplates/conflictResolution', [ + 'templateStatus' => $templateStatus, + 'metaTemplate' => $metaTemplate, + 'templateOnDisk' => $templateOnDisk, + ]); + } +} + +echo $this->Bootstrap->modal([ + 'title' => __('Update Meta Templates #{0} ?', h($metaTemplate->id)), + 'bodyHtml' => $bodyHtml, + 'size' => $modalSize, + 'type' => $modalType, + 'confirmText' => __('Update meta-templates'), +]); +?> diff --git a/templates/MetaTemplates/update_all.php b/templates/MetaTemplates/update_all.php new file mode 100644 index 0000000..1e592cc --- /dev/null +++ b/templates/MetaTemplates/update_all.php @@ -0,0 +1,119 @@ +'; +$tableHtml .= sprintf('%s', __('ID')); +$tableHtml .= sprintf('%s', __('Template')); +$tableHtml .= sprintf('%s', __('Version')); +$tableHtml .= sprintf('%s', __('New Template')); +$tableHtml .= sprintf('%s', __('Update available')); +$tableHtml .= sprintf('%s', __('Has Conflicts')); +$tableHtml .= sprintf('%s', __('Will be updated')); +$tableHtml .= ''; +$numberOfUpdates = 0; +$numberOfSkippedUpdates = 0; +foreach ($templatesUpdateStatus as $uuid => $status) { + $tableHtml .= ''; + if (!empty($status['new'])) { + $tableHtml .= sprintf('%s', __('N/A')); + } else { + $tableHtml .= sprintf('%s', + Router::url(['controller' => 'MetaTemplates', 'action' => 'view', 'plugin' => null, h($status['existing_template']->id)]), + h($status['existing_template']->id) + ); + } + if (!empty($status['new'])) { + $tableHtml .= sprintf('%s', h($uuid)); + } else { + $tableHtml .= sprintf('%s', + Router::url(['controller' => 'MetaTemplates', 'action' => 'view', 'plugin' => null, h($status['existing_template']->id)]), + h($status['existing_template']->name) + ); + } + if (!empty($status['new'])) { + $tableHtml .= sprintf('%s', __('N/A')); + } else { + $tableHtml .= sprintf('%s %s %s', + h($status['current_version']), + $this->Bootstrap->icon('arrow-right', ['class' => 'fs-8']), + h($status['next_version']) + ); + } + if (!empty($status['new'])) { + $numberOfUpdates += 1; + $tableHtml .= sprintf('%s', $this->Bootstrap->icon('check')); + } else { + $tableHtml .= sprintf('%s', $this->Bootstrap->icon('times')); + } + if (!empty($status['new'])) { + $tableHtml .= sprintf('%s', __('N/A')); + } else { + $tableHtml .= sprintf('%s', empty($status['up-to-date']) ? $this->Bootstrap->icon('check') : $this->Bootstrap->icon('times')); + } + if (!empty($status['new'])) { + $tableHtml .= sprintf('%s', __('N/A')); + } else { + $tableHtml .= sprintf('%s', !empty($status['conflicts']) ? $this->Bootstrap->icon('check') : $this->Bootstrap->icon('times')); + } + if (!empty($status['new'])) { + $tableHtml .= sprintf('%s', $this->Bootstrap->icon('check', ['class' => 'text-success'])); + } else { + // Depends on the strategy used by the update_all function. Right now, every update create a brand new template + // leaving existing data untouched. So regardless of the conflict, the new template will be created + if (!empty($status['new']) || empty($status['up-to-date'])) { + $numberOfUpdates += 1; + $tableHtml .= sprintf('%s', $this->Bootstrap->icon('check', ['class' => 'text-success'])); + } else { + $numberOfSkippedUpdates += 1; + $tableHtml .= sprintf('%s', $this->Bootstrap->icon('times', ['class' => 'text-danger'])); + } + } + $tableHtml .= ''; +} +$tableHtml .= ''; + +if (empty($numberOfSkippedUpdates) && empty($numberOfUpdates)) { + $bodyHtml .= $this->Bootstrap->alert([ + 'variant' => 'success', + 'text' => __('All meta-templates are already up-to-date!'), + 'dismissible' => false, + ]); + $modalType = 'ok-only'; +} elseif ($numberOfSkippedUpdates == 0) { + $bodyHtml .= $this->Bootstrap->alert([ + 'variant' => 'success', + 'text' => __('All {0} meta-templates can be updated', $numberOfUpdates), + 'dismissible' => false, + ]); +} else { + $modalSize = 'xl'; + $alertHtml = ''; + if (!empty($numberOfUpdates)) { + $alertHtml .= sprintf('
%s
', __('{0} meta-templates can be updated.', sprintf('%s', $numberOfUpdates))); + } + if (!empty($numberOfSkippedUpdates)) { + $alertHtml .= sprintf('
%s
', __('{0} meta-templates will be skipped.', sprintf('%s', $numberOfSkippedUpdates))); + $alertHtml .= sprintf('
%s
', __('You can still choose the update strategy when updating each conflicting template manually.')); + } + $bodyHtml .= $this->Bootstrap->alert([ + 'variant' => 'warning', + 'html' => $alertHtml, + 'dismissible' => false, + ]); +} + +$bodyHtml .= $tableHtml; + +echo $this->Bootstrap->modal([ + 'title' => h($title), + 'bodyHtml' => $bodyHtml, + 'size' => $modalSize, + 'type' => $modalType, + 'confirmText' => __('Update meta-templates'), + 'confirmFunction' => 'updateMetaTemplate', +]); +?> diff --git a/templates/MetaTemplates/view.php b/templates/MetaTemplates/view.php index 902949b..fbb19e5 100644 --- a/templates/MetaTemplates/view.php +++ b/templates/MetaTemplates/view.php @@ -43,7 +43,8 @@ echo $this->element( [ 'url' => '/MetaTemplateFields/index?meta_template_id={{0}}', 'url_params' => ['id'], - 'title' => __('Fields') + 'title' => __('Fields'), + 'collapsed' => 'show', ] ] ] diff --git a/templates/Open/Individuals/index.php b/templates/Open/Individuals/index.php index 411f331..8eac5f6 100644 --- a/templates/Open/Individuals/index.php +++ b/templates/Open/Individuals/index.php @@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/Open/Organisations/index.php b/templates/Open/Organisations/index.php index e74a5f2..fa6fcab 100644 --- a/templates/Open/Organisations/index.php +++ b/templates/Open/Organisations/index.php @@ -18,7 +18,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/Organisations/add.php b/templates/Organisations/add.php index 6278ff8..72b9a9e 100644 --- a/templates/Organisations/add.php +++ b/templates/Organisations/add.php @@ -25,7 +25,6 @@ 'field' => 'type' ) ), - 'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates, 'submit' => array( 'action' => $this->request->getParam('action') ) diff --git a/templates/Organisations/index.php b/templates/Organisations/index.php index 4b34aab..34e4a10 100644 --- a/templates/Organisations/index.php +++ b/templates/Organisations/index.php @@ -44,6 +44,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'name' => __('Name'), 'class' => 'short', 'data_path' => 'name', + 'sort' => 'name', ], [ 'name' => __('UUID'), @@ -67,14 +68,17 @@ echo $this->element('genericElements/IndexTable/index_table', [ [ 'name' => __('Nationality'), 'data_path' => 'nationality', + 'sort' => 'nationality', ], [ 'name' => __('Sector'), 'data_path' => 'sector', + 'sort' => 'sector', ], [ 'name' => __('Type'), 'data_path' => 'type', + 'sort' => 'type', ], [ 'name' => __('Tags'), @@ -84,7 +88,6 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], 'title' => __('ContactDB Organisation Index'), 'description' => __('A list of organisations known by your Cerebrate instance. This list can get populated either directly, by adding new organisations or by fetching them from trusted remote sources.'), - 'pull' => 'right', 'actions' => [ [ 'url' => '/organisations/view', diff --git a/templates/Organisations/view.php b/templates/Organisations/view.php index 415795c..8ffbef8 100644 --- a/templates/Organisations/view.php +++ b/templates/Organisations/view.php @@ -48,8 +48,7 @@ echo $this->element( 'scope' => 'organisations' ] ], - 'metaTemplates' => empty($metaFields) ? [] : $metaFields, - 'combinedFieldsView' => true, + 'combinedFieldsView' => false, 'children' => [] ] ); diff --git a/templates/Outbox/index.php b/templates/Outbox/index.php index 2685171..d00cb3c 100644 --- a/templates/Outbox/index.php +++ b/templates/Outbox/index.php @@ -29,7 +29,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/templates/Outbox/list_processors.php b/templates/Outbox/list_processors.php index 38c87f2..416a7c7 100644 --- a/templates/Outbox/list_processors.php +++ b/templates/Outbox/list_processors.php @@ -11,7 +11,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/templates/Roles/index.php b/templates/Roles/index.php index e710fd6..d846934 100644 --- a/templates/Roles/index.php +++ b/templates/Roles/index.php @@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/SharingGroups/index.php b/templates/SharingGroups/index.php index c930bed..0162c23 100644 --- a/templates/SharingGroups/index.php +++ b/templates/SharingGroups/index.php @@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/SharingGroups/list_orgs.php b/templates/SharingGroups/list_orgs.php index 1c0658f..267677f 100644 --- a/templates/SharingGroups/list_orgs.php +++ b/templates/SharingGroups/list_orgs.php @@ -18,7 +18,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/UserSettings/index.php b/templates/UserSettings/index.php index 5740047..42da55b 100644 --- a/templates/UserSettings/index.php +++ b/templates/UserSettings/index.php @@ -22,7 +22,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/Users/add.php b/templates/Users/add.php index 1f68ae5..c6999dc 100644 --- a/templates/Users/add.php +++ b/templates/Users/add.php @@ -1,12 +1,16 @@ request->getParam('action') === 'add') { $dropdownData['individual'] = ['new' => __('New individual')] + $dropdownData['individual']; if (!Configure::check('password_auth.enabled') || Configure::read('password_auth.enabled')) { $passwordRequired = 'required'; } } + if (!Configure::check('password_auth.enabled') || Configure::read('password_auth.enabled')) { + $showPasswordField = true; + } echo $this->element('genericElements/Form/genericForm', [ 'data' => [ 'description' => __('Roles define global rules for a set of users, including first and foremost access controls to certain functionalities.'), @@ -62,7 +66,7 @@ 'required' => $passwordRequired, 'autocomplete' => 'new-password', 'value' => '', - 'requirements' => (bool)$passwordRequired + 'requirements' => $showPasswordField, ], [ 'field' => 'confirm_password', @@ -70,7 +74,7 @@ 'type' => 'password', 'required' => $passwordRequired, 'autocomplete' => 'off', - 'requirements' => (bool)$passwordRequired + 'requirements' => $showPasswordField, ], [ 'field' => 'role_id', diff --git a/templates/Users/index.php b/templates/Users/index.php index 21afdc5..dd287c8 100644 --- a/templates/Users/index.php +++ b/templates/Users/index.php @@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/templates/element/MetaTemplates/conflictResolution.php b/templates/element/MetaTemplates/conflictResolution.php new file mode 100644 index 0000000..a0d1adc --- /dev/null +++ b/templates/element/MetaTemplates/conflictResolution.php @@ -0,0 +1,107 @@ +element('genericElements/Form/genericForm', [ + 'entity' => null, + 'ajax' => false, + 'raw' => true, + 'data' => [ + 'model' => 'MetaTemplate', + 'fields' => [ + [ + 'field' => 'update_strategy', + 'type' => 'radio', + 'options' => [ + ['value' => 'create_new', 'text' => 'create_new', 'id' => 'radio_create_new'], + ['value' => 'keep_both', 'text' => 'keep_both', 'id' => 'radio_keep_both'], + ['value' => 'delete', 'text' => 'delete', 'id' => 'radio_delete'], + ], + ] + ], + 'submit' => [ + 'action' => $this->request->getParam('action') + ], + ] +]); +?> + +
+
+
+ + + + + + + + + + + + + + +
+
+
+ +
+ +
+ + \ No newline at end of file diff --git a/templates/element/MetaTemplates/conflictTable.php b/templates/element/MetaTemplates/conflictTable.php new file mode 100644 index 0000000..82b314e --- /dev/null +++ b/templates/element/MetaTemplates/conflictTable.php @@ -0,0 +1,45 @@ + + + + + + + + + + + + $fieldConflict) : ?> + + + + + + + + + +
+ + + $id) { + if ($i > 0) { + echo ', '; + } + if ($i > 10) { + echo sprintf('%s', __('{0} more', count($fieldConflict['conflictingEntities'])-$i)); + break; + } + $url = Router::url([ + 'controller' => Inflector::pluralize($templateStatus['existing_template']->scope), + 'action' => 'view', + $id + ]); + echo sprintf('%s', $url, __('{0} #{1}', h(Inflector::humanize($templateStatus['existing_template']->scope)), h($id))); + } + ?> +
\ No newline at end of file diff --git a/templates/element/MetaTemplates/migrationToNewVersionForm.php b/templates/element/MetaTemplates/migrationToNewVersionForm.php new file mode 100644 index 0000000..7bbbfaa --- /dev/null +++ b/templates/element/MetaTemplates/migrationToNewVersionForm.php @@ -0,0 +1,12 @@ +Form->create($entity, ['id' => 'form-' . $formRandomValue]); +echo $this->element( + 'genericElements/Form/metaTemplateForm', + [ + 'metaTemplate' => $metaTemplate, + ] +); +echo $this->Form->end(); +?> \ No newline at end of file diff --git a/templates/element/charts/bar.php b/templates/element/charts/bar.php index 573a61b..5f20316 100644 --- a/templates/element/charts/bar.php +++ b/templates/element/charts/bar.php @@ -1,14 +1,22 @@ $entry) { - $data[] = $entry['count']; +$chartData = $chartData ?? []; +$chartSeries = []; +if (!empty($series)) { + $chartSeries = $series; +} else { + // Transform the chart data into the expected format + $data = []; + foreach ($chartData as $i => $entry) { + $data[] = $entry['count']; + } + $chartSeries = [ + ['data' => $data] + ]; } ?> @@ -20,7 +28,7 @@ foreach ($chartData as $i => $entry) { const defaultOptions = { chart: { id: '', - type: 'bar', + type: 'line', sparkline: { enabled: true }, @@ -35,31 +43,18 @@ foreach ($chartData as $i => $entry) { enabled: false }, }, - series: [{ - data: , - }], - colors: ['var(--bs-light)'], + series: , tooltip: { - x: { - show: false - }, - y: { - title: { - formatter: function formatter(val) { - return ''; - } - } - }, - theme: '' + theme: 'dark' }, } - const chartOptions = Object.assign({}, defaultOptions, passedOptions) + const chartOptions = mergeDeep({}, defaultOptions, passedOptions) new ApexCharts(document.querySelector('#'), chartOptions).render(); }) \ No newline at end of file diff --git a/templates/element/charts/generic.php b/templates/element/charts/generic.php new file mode 100644 index 0000000..488577c --- /dev/null +++ b/templates/element/charts/generic.php @@ -0,0 +1,43 @@ + + +
+ + + + \ No newline at end of file diff --git a/templates/element/charts/pie.php b/templates/element/charts/pie.php new file mode 100644 index 0000000..6dd4475 --- /dev/null +++ b/templates/element/charts/pie.php @@ -0,0 +1,73 @@ + + +
+ + + + \ No newline at end of file diff --git a/templates/element/flash/success.php b/templates/element/flash/success.php index 1e866d3..6d6bf96 100644 --- a/templates/element/flash/success.php +++ b/templates/element/flash/success.php @@ -24,4 +24,4 @@ if (!isset($params['escape']) || $params['escape'] !== false) { - + \ No newline at end of file diff --git a/templates/element/genericElements/Form/Fields/dropdownField.php b/templates/element/genericElements/Form/Fields/dropdownField.php index 364fd9e..26e8e88 100644 --- a/templates/element/genericElements/Form/Fields/dropdownField.php +++ b/templates/element/genericElements/Form/Fields/dropdownField.php @@ -8,4 +8,7 @@ 'class' => ($fieldData['class'] ?? '') . ' formDropdown form-select', 'default' => ($fieldData['default'] ?? null) ]; + if (!empty($fieldData['label'])) { + $controlParams['label'] = $fieldData['label']; + } echo $this->FormFieldMassage->prepareFormElement($this->Form, $controlParams, $fieldData); diff --git a/templates/element/genericElements/Form/Fields/genericField.php b/templates/element/genericElements/Form/Fields/genericField.php index 38f05a4..4174c5b 100644 --- a/templates/element/genericElements/Form/Fields/genericField.php +++ b/templates/element/genericElements/Form/Fields/genericField.php @@ -1,5 +1,7 @@ FormFieldMassage->prepareFormElement($this->Form, $params, $fieldData); ?> diff --git a/templates/element/genericElements/Form/fieldScaffold.php b/templates/element/genericElements/Form/fieldScaffold.php index 4258ce0..32b339b 100644 --- a/templates/element/genericElements/Form/fieldScaffold.php +++ b/templates/element/genericElements/Form/fieldScaffold.php @@ -8,7 +8,9 @@ $fieldTemplate = $fieldData['type'] . 'Field'; } if (empty($fieldData['label'])) { - $fieldData['label'] = \Cake\Utility\Inflector::humanize($fieldData['field']); + if (!isset($fieldData['label']) || $fieldData['label'] !== false) { + $fieldData['label'] = \Cake\Utility\Inflector::humanize($fieldData['field']); + } } if (!empty($fieldDesc[$fieldData['field']])) { $fieldData['label'] .= $this->element( @@ -30,10 +32,9 @@ } else { $params['class'] = ''; } - if (empty($fieldData['type']) || $fieldData['type'] !== 'checkbox' ) { + if (empty($fieldData['type']) || ($fieldData['type'] !== 'checkbox' && $fieldData['type'] !== 'radio')) { $params['class'] .= ' form-control'; } - //$params['class'] = sprintf('form-control %s', $params['class']); foreach ($fieldData as $k => $fd) { if (!isset($simpleFieldWhitelist) || in_array($k, $simpleFieldWhitelist) || strpos($k, 'data-') === 0) { $params[$k] = $fd; @@ -47,7 +48,6 @@ $temp = ''; } echo $temp; - // $fieldsArrayForPersistence []= $modelForForm . \Cake\Utility\Inflector::camelize($fieldData['field']); } else { echo $fieldData; } diff --git a/templates/element/genericElements/Form/formLayouts/formDefault.php b/templates/element/genericElements/Form/formLayouts/formDefault.php new file mode 100644 index 0000000..56a439a --- /dev/null +++ b/templates/element/genericElements/Form/formLayouts/formDefault.php @@ -0,0 +1,38 @@ +
+

+ +

+ + + +
+ +
+ +
+ +
+ + +
+ Bootstrap->accordion( + [ + 'class' => 'mb-3' + ], + [ + [ + '_open' => true, + 'header' => [ + 'title' => __('Meta fields') + ], + 'body' => $metaTemplateString, + ], + ] + ); + ?> +
+ + element('genericElements/Form/submitButton', $submitButtonData); ?> + +
\ No newline at end of file diff --git a/templates/element/genericElements/Form/formLayouts/formRaw.php b/templates/element/genericElements/Form/formLayouts/formRaw.php new file mode 100644 index 0000000..1e445f7 --- /dev/null +++ b/templates/element/genericElements/Form/formLayouts/formRaw.php @@ -0,0 +1,28 @@ + +
+ +
+ + + + + + + Bootstrap->accordion( + [ + 'class' => 'mb-3' + ], + [ + [ + '_open' => true, + 'header' => [ + 'title' => __('Meta fields') + ], + 'body' => $metaTemplateString, + ], + ] + ); + ?> + + \ No newline at end of file diff --git a/templates/element/genericElements/Form/genericForm.php b/templates/element/genericElements/Form/genericForm.php index 7d7552d..1a2bc58 100644 --- a/templates/element/genericElements/Form/genericForm.php +++ b/templates/element/genericElements/Form/genericForm.php @@ -1,5 +1,5 @@ $this->request->getParam('controller'), "action" => $this->request->getParam('url')]; } $formRandomValue = Cake\Utility\Security::randomString(8); + $initSelect2 = false; $formCreate = $this->Form->create($entity, ['id' => 'form-' . $formRandomValue]); $default_template = [ 'inputContainer' => '
{{content}}
', @@ -36,7 +36,9 @@ 'select' => '', 'checkbox' => '', 'checkboxFormGroup' => '{{label}}', - 'formGroup' => '
{{label}}
{{input}}{{error}}
', + 'radio' => '', + 'radioWrapper' => '{{label}}', + 'formGroup' => '
{{input}}{{error}}
', 'nestingLabel' => '{{hidden}}
{{text}}
{{input}}
', 'option' => '', 'optgroup' => '{{content}}', @@ -52,6 +54,7 @@ continue; } } + $initSelect2 = $initSelect2 || (!empty($fieldData['type']) && $fieldData['type'] == 'dropdown' && !empty($fieldData['select2'])); $formTemplate = $default_template; if (!empty($fieldData['floating-label'])) { $formTemplate['inputContainer'] = '
{{content}}
'; @@ -59,6 +62,9 @@ $formTemplate['formGroup'] = '{{input}}{{label}}'; $fieldData['placeholder'] = !empty($fieldData['label']) ? $fieldData['label'] : h($fieldData['field']); } + if (!empty($data['templates'])) { + $formTemplate = array_merge($formTemplate, $data['templates']); + } // we reset the template each iteration as individual fields might override the defaults. $this->Form->setConfig($formTemplate); $this->Form->setTemplates($formTemplate); @@ -66,7 +72,8 @@ continue; } $fieldsString .= $this->element( - 'genericElements/Form/fieldScaffold', [ + 'genericElements/Form/fieldScaffold', + [ 'fieldData' => $fieldData, 'form' => $this->Form, 'simpleFieldWhitelist' => $simpleFieldWhitelist @@ -74,10 +81,11 @@ ); } } - if (!empty($data['metaTemplates']) && $data['metaTemplates']->count() > 0) { + $metaTemplateString = ''; + if (!empty($entity['MetaTemplates']) && count($entity['MetaTemplates']) > 0) { $metaTemplateString = $this->element( - 'genericElements/Form/metaTemplateScaffold', [ - 'metaTemplatesData' => $data['metaTemplates'], + 'genericElements/Form/metaTemplateScaffold', + [ 'form' => $this->Form, ] ); @@ -100,82 +108,42 @@ $actionName = h(\Cake\Utility\Inflector::humanize($this->request->getParam('action'))); $modelName = h(\Cake\Utility\Inflector::humanize(\Cake\Utility\Inflector::singularize($this->request->getParam('controller')))); if (!empty($ajax)) { - echo $this->element('genericElements/genericModal', [ + $seedModal = 'mseed-' . mt_rand(); + echo $this->Bootstrap->modal([ 'title' => empty($data['title']) ? sprintf('%s %s', $actionName, $modelName) : h($data['title']), - 'body' => sprintf( - '%s%s%s%s%s%s', - empty($data['description']) ? '' : sprintf( - '
%s
', - h($data['description']) - ), - $ajaxFlashMessage, - $formCreate, - $fieldsString, - empty($metaTemplateString) ? '' : $this->element( - 'genericElements/accordion_scaffold', [ - 'children' => [ - [ - 'body' => $metaTemplateString, - 'title' => 'Meta fields' - ] - ] - ] - ), - $formEnd - ), - 'actionButton' => $this->element('genericElements/Form/submitButton', $submitButtonData), - 'class' => 'modal-lg' + 'bodyHtml' => $this->element('genericElements/Form/formLayouts/formRaw', [ + 'formCreate' => $formCreate, + 'ajaxFlashMessage' => $ajaxFlashMessage, + 'fieldsString' => $fieldsString, + 'formEnd' => $formEnd, + 'metaTemplateString' => $metaTemplateString, + ]), + 'size' => !empty($fieldsString) ? 'xl' : 'lg', + 'type' => 'confirm', + 'modalClass' => $seedModal, ]); } else if (!empty($raw)) { - echo sprintf( - '%s%s%s%s%s%s', - empty($data['description']) ? '' : sprintf( - '
%s
', - h($data['description']) - ), - $ajaxFlashMessage, - $formCreate, - $fieldsString, - empty($metaTemplateString) ? '' : $this->element( - 'genericElements/accordion_scaffold', [ - 'children' => [ - [ - 'body' => $metaTemplateString, - 'title' => 'Meta fields' - ] - ] - ] - ), - $formEnd - ); + echo $this->element('genericElements/Form/formLayouts/formDefault', [ + 'actionName' => $actionName, + 'modelName' => $modelName, + 'submitButtonData' => $submitButtonData, + 'formCreate' => $formCreate, + 'ajaxFlashMessage' => $ajaxFlashMessage, + 'fieldsString' => $fieldsString, + 'formEnd' => $formEnd, + 'metaTemplateString' => $metaTemplateString, + ]); } else { - echo sprintf( - '%s

%s

%s%s%s%s%s%s%s%s%s', - empty($ajax) ? '
' : '', - empty($data['title']) ? sprintf('%s %s', $actionName, $modelName) : h($data['title']), - $formCreate, - $ajaxFlashMessage, - empty($data['description']) ? '' : sprintf( - '
%s
', - h($data['description']) - ), - sprintf('
%s
', $fieldsString), - empty($metaTemplateString) ? '' : $this->element( - 'genericElements/accordion_scaffold', [ - 'children' => [ - [ - 'body' => $metaTemplateString, - 'title' => 'Meta fields', - ] - ], - 'class' => 'mb-2' - ] - ), - $this->element('genericElements/Form/submitButton', $submitButtonData), - $formEnd, - '

', - empty($ajax) ? '
' : '' - ); + echo $this->element('genericElements/Form/formLayouts/formDefault', [ + 'actionName' => $actionName, + 'modelName' => $modelName, + 'submitButtonData' => $submitButtonData, + 'formCreate' => $formCreate, + 'ajaxFlashMessage' => $ajaxFlashMessage, + 'fieldsString' => $fieldsString, + 'formEnd' => $formEnd, + 'metaTemplateString' => $metaTemplateString, + ]); } ?> + \ No newline at end of file diff --git a/templates/element/genericElements/Form/metaTemplateForm.php b/templates/element/genericElements/Form/metaTemplateForm.php new file mode 100644 index 0000000..0a4d934 --- /dev/null +++ b/templates/element/genericElements/Form/metaTemplateForm.php @@ -0,0 +1,77 @@ + '
{{content}}
', + 'inputContainerError' => '
{{content}}
', + 'formGroup' => '
{{input}}{{error}}
', + 'error' => '
{{content}}
', + 'errorList' => '', + 'errorItem' => '
  • {{text}}
  • ', +]; +$this->Form->setTemplates($default_template); +$backupTemplates = $this->Form->getTemplates(); + +$fieldsHtml = ''; +foreach ($metaTemplate->meta_template_fields as $metaTemplateField) { + $metaTemplateField->label = Inflector::humanize($metaTemplateField->field); + if (!empty($metaTemplateField->metaFields)) { + if (!empty($metaTemplateField->multiple)) { + $fieldsHtml .= $this->element( + 'genericElements/Form/multiFieldScaffold', + [ + 'metaFieldsEntities' => $metaTemplateField->metaFields, + 'metaTemplateField' => $metaTemplateField, + 'multiple' => !empty($metaTemplateField->multiple), + 'form' => $this->Form, + ] + ); + } else { + $metaField = reset($metaTemplateField->metaFields); + $fieldData = [ + 'label' => $metaTemplateField->label, + ]; + if (isset($metaField->id)) { + $fieldData['field'] = sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.%s.value', $metaField->meta_template_id, $metaField->meta_template_field_id, $metaField->id); + } else { + $fieldData['field'] = sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.%s.value', $metaField->meta_template_id, $metaField->meta_template_field_id, array_key_first($metaTemplateField->metaFields)); + } + $this->Form->setTemplates($backupTemplates); + $fieldsHtml .= $this->element( + 'genericElements/Form/fieldScaffold', + [ + 'fieldData' => $fieldData, + 'metaTemplateField' => $metaTemplateField, + 'form' => $this->Form + ] + ); + } + } else { + if (!empty($metaTemplateField->multiple)) { + $fieldsHtml .= $this->element( + 'genericElements/Form/multiFieldScaffold', + [ + 'metaFieldsEntities' => [], + 'metaTemplateField' => $metaTemplateField, + 'multiple' => !empty($metaTemplateField->multiple), + 'form' => $this->Form, + ] + ); + } else { + $this->Form->setTemplates($backupTemplates); + $fieldData = [ + 'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.new.0', $metaTemplateField->meta_template_id, $metaTemplateField->id), + 'label' => $metaTemplateField->label, + ]; + $fieldsHtml .= $this->element( + 'genericElements/Form/fieldScaffold', + [ + 'fieldData' => $fieldData, + 'form' => $this->Form + ] + ); + } + } +} +echo $fieldsHtml; \ No newline at end of file diff --git a/templates/element/genericElements/Form/metaTemplateScaffold.php b/templates/element/genericElements/Form/metaTemplateScaffold.php index 614dc2a..00dc9be 100644 --- a/templates/element/genericElements/Form/metaTemplateScaffold.php +++ b/templates/element/genericElements/Form/metaTemplateScaffold.php @@ -1,32 +1,24 @@ Form->getTemplates(); $tabData = []; -foreach($metaTemplatesData as $i => $metaTemplate) { - if ($metaTemplate->is_default) { - $tabData['navs'][$i] = [ - 'html' => $this->element('/genericElements/MetaTemplates/metaTemplateNav', ['metaTemplate' => $metaTemplate]) - ]; - } else { - $tabData['navs'][$i] = [ - 'text' => $metaTemplate->name - ]; - } +foreach ($entity->MetaTemplates as $i => $metaTemplate) { + $tabData['navs'][$i] = [ + 'html' => $this->element('/genericElements/MetaTemplates/metaTemplateNav', ['metaTemplate' => $metaTemplate]) + ]; $fieldsHtml = ''; - foreach ($metaTemplate->meta_template_fields as $metaField) { - $metaField->label = Inflector::humanize($metaField->field); - $metaField->field = sprintf('%s.%s.%s', 'metaFields', $metaField->meta_template_id, $metaField->field); - $fieldsHtml .= $this->element( - 'genericElements/Form/fieldScaffold', [ - 'fieldData' => $metaField->toArray(), - 'form' => $this->Form - ] - ); - } + $fieldsHtml .= $this->element( + 'genericElements/Form/metaTemplateForm', + [ + 'metaTemplate' => $metaTemplate, + ] + ); $tabData['content'][$i] = $fieldsHtml; } +$this->Form->setTemplates($backupTemplates); echo $this->Bootstrap->Tabs([ 'pills' => true, 'data' => $tabData, - 'nav-class' => ['pb-1'] -]); \ No newline at end of file + 'nav-class' => ['shadow mb-3 p-2 rounded'], + 'content-class' => ['pt-2 px-3'] +]); diff --git a/templates/element/genericElements/Form/multiFieldButton.php b/templates/element/genericElements/Form/multiFieldButton.php new file mode 100644 index 0000000..c7c778b --- /dev/null +++ b/templates/element/genericElements/Form/multiFieldButton.php @@ -0,0 +1,71 @@ + +
    + %s%s', $this->Bootstrap->icon('plus'), __('Add another {0}', h($metaTemplateFieldName))); + $content = sprintf( + '%s%s', + $this->Bootstrap->button([ + 'nodeType' => 'a', + 'icon' => 'plus', + 'variant' => 'secondary', + 'size' => 'xs', + ]), + $this->Bootstrap->button([ + 'nodeType' => 'a', + 'text' => __('Add another {0}', h($metaTemplateFieldName)), + 'variant' => 'link', + 'class' => ['link-secondary'], + 'size' => 'xs', + ]) + ); + ?> + Bootstrap->button([ + 'id' => $seed, + 'html' => $content, + 'variant' => 'link', + 'size' => 'xs', + ]); + ?> +
    + + \ No newline at end of file diff --git a/templates/element/genericElements/Form/multiFieldMetaFieldTemplate.php b/templates/element/genericElements/Form/multiFieldMetaFieldTemplate.php new file mode 100644 index 0000000..162b894 --- /dev/null +++ b/templates/element/genericElements/Form/multiFieldMetaFieldTemplate.php @@ -0,0 +1,15 @@ + false, + 'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.{count}', $metaTemplateField['meta_template_id'], $metaTemplateField['id']), + 'class' => 'metafield-template', + ]; + echo $this->element( + 'genericElements/Form/fieldScaffold', + [ + 'fieldData' => $fieldData, + 'form' => $form + ] + ); +} diff --git a/templates/element/genericElements/Form/multiFieldScaffold.php b/templates/element/genericElements/Form/multiFieldScaffold.php new file mode 100644 index 0000000..3a73125 --- /dev/null +++ b/templates/element/genericElements/Form/multiFieldScaffold.php @@ -0,0 +1,92 @@ + '
    {{content}}
    ', + 'inputContainerError' => '
    {{content}}
    ', + 'formGroup' => '
    {{input}}{{error}}
    ', +]; +$form->setTemplates($default_template); + +$fieldsHtml = ''; +$labelPrintedOnce = false; +if (!empty($metaFieldsEntities)) { + foreach ($metaFieldsEntities as $i => $metaFieldsEntity) { + $metaFieldsEntity->label = Inflector::humanize($metaFieldsEntity->field); + $fieldData = [ + 'label' => $metaFieldsEntity->label, + 'field' => sprintf( + 'MetaTemplates.%s.meta_template_fields.%s.metaFields.%s.value', + $metaFieldsEntity->meta_template_id, + $metaFieldsEntity->meta_template_field_id, + $metaFieldsEntity->id + ), + ]; + if($metaFieldsEntity->isNew()) { + $fieldData['field'] = sprintf( + 'MetaTemplates.%s.meta_template_fields.%s.metaFields.%s.value', + $metaFieldsEntity->meta_template_id, + $metaFieldsEntity->meta_template_field_id, + $i + ); + $fieldData['class'] = 'new-metafield'; + } + if ($labelPrintedOnce) { // Only the first input can have a label + $fieldData['label'] = false; + } + $labelPrintedOnce = true; + $fieldsHtml .= $this->element( + 'genericElements/Form/fieldScaffold', + [ + 'fieldData' => $fieldData, + 'form' => $form + ] + ); + } +} +if (!empty($metaTemplateField) && !empty($multiple)) { // Add multiple field button + $metaTemplateField->label = Inflector::humanize($metaTemplateField->field); + $emptyMetaFieldInput = ''; + if (empty($metaFieldsEntities)) { // Include editable field for meta-template not containing a meta-field + $emptyMetaFieldInput = $this->element( + 'genericElements/Form/fieldScaffold', + [ + 'fieldData' => [ + 'label' => $metaTemplateField->label, + 'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.new.0', $metaTemplateField->meta_template_id, $metaTemplateField->id), + 'class' => 'new-metafield', + ], + 'form' => $form, + ] + ); + } + $emptyInputForSecurityComponent = $this->element( + 'genericElements/Form/fieldScaffold', + [ + 'fieldData' => [ + 'label' => false, + 'field' => sprintf('MetaTemplates.%s.meta_template_fields.%s.metaFields.new[]', $metaTemplateField->meta_template_id, $metaTemplateField->id), + 'value' => '', + ], + 'form' => $form, + ] + ); + $multiFieldButtonHtml = sprintf( + '
    %s
    ', + $this->element( + 'genericElements/Form/multiFieldButton', + [ + 'metaTemplateFieldName' => $metaTemplateField->field, + ] + ) + ); + $fieldsHtml .= $emptyMetaFieldInput; + $fieldsHtml .= sprintf('
    %s
    ', $emptyInputForSecurityComponent); + $fieldsHtml .= $multiFieldButtonHtml; +} +?> + +
    + +
    \ No newline at end of file diff --git a/templates/element/genericElements/Form/submitButton.php b/templates/element/genericElements/Form/submitButton.php index 55062dd..be9352e 100644 --- a/templates/element/genericElements/Form/submitButton.php +++ b/templates/element/genericElements/Form/submitButton.php @@ -11,7 +11,8 @@ } else { echo $this->Form->button(empty($text) ? __('Submit') : h($text), [ 'class' => 'btn btn-' . (empty($type) ? 'primary' : h($type)), - 'type' => 'submit' + 'type' => 'submit', + 'data-form-id' => '#form-' . h($formRandomValue) ]); } ?> diff --git a/templates/element/genericElements/IndexTable/Fields/actions.php b/templates/element/genericElements/IndexTable/Fields/actions.php index 8a215c4..379fb58 100644 --- a/templates/element/genericElements/IndexTable/Fields/actions.php +++ b/templates/element/genericElements/IndexTable/Fields/actions.php @@ -101,12 +101,13 @@ $action['onclick'] = sprintf('UI.submissionModalForIndex(\'%s\', \'%s\', \'%s\')', h($modal_url), h($reload_url), h($tableRandomValue)); } echo sprintf( - ' ', + ' ', $url, empty($action['title']) ? '' : h($action['title']), empty($action['title']) ? '' : h($action['title']), empty($action['dbclickAction']) ? '' : 'class="dblclickActionElement"', empty($action['onclick']) ? '' : sprintf('onClick="%s"', $action['onclick']), + empty($action['variant']) ? 'outline-dark' : h($action['variant']), $this->FontAwesome->getClass($action['icon']) ); } diff --git a/templates/element/genericElements/IndexTable/Fields/generic_field.php b/templates/element/genericElements/IndexTable/Fields/generic_field.php index abc8eca..b3c150e 100644 --- a/templates/element/genericElements/IndexTable/Fields/generic_field.php +++ b/templates/element/genericElements/IndexTable/Fields/generic_field.php @@ -2,14 +2,16 @@ $data = $this->Hash->extract($row, $field['data_path']); if (is_array($data)) { if (count($data) > 1) { - $data = implode(', ', $data); + $data = implode('
    ', array_map('h', $data)); } else { if (count($data) > 0) { - $data = $data[0]; + $data = h($data[0]); } else { $data = ''; } } + } else { + $data = h($data); } if (is_bool($data)) { $data = sprintf( @@ -17,7 +19,6 @@ $data ? 'check' : 'times' ); } else { - $data = h($data); if (!empty($field['options'])) { $options = $this->Hash->extract($row, $field['options']); if (!empty($options)) { diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php new file mode 100644 index 0000000..1f92f0e --- /dev/null +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_field_amount.php @@ -0,0 +1,141 @@ +request->getQuery('statistics_entry_amount', 5); +$statistics_pie_include_remaining = $this->request->getQuery('statistics_include_remainging', true); +if (is_string($statistics_pie_include_remaining)) { + $statistics_pie_include_remaining = $statistics_pie_include_remaining == 'true' ? true : false; +} +$statistics_pie_ignore_null = $this->request->getQuery('statistics_ignore_null', true); +if (is_string($statistics_pie_ignore_null)) { + $statistics_pie_ignore_null = $statistics_pie_ignore_null == 'true' ? true : false; +} + +$seedPiechart = 's-' . mt_rand(); +foreach ($statistics['usage'] as $scope => $graphData) { + $pieChart = $this->element('charts/pie', [ + 'data' => $graphData, + 'chartOptions' => [ + 'chart' => [ + 'height' => '80px', + 'sparkline' => [ + 'enabled' => true, + ] + ], + 'plotOptions' => [ + 'pie' => [ + 'customScale' => 0.9, + ] + ], + ], + ]); + $titleHtml = sprintf( + '%s%s', + Inflector::Pluralize(Inflector::Humanize(h($scope))), + $this->Bootstrap->button([ + 'variant' => 'link', + 'icon' => 'cog', + 'size' => 'xs', + 'nodeType' => 'a', + 'onclick' => '', + 'class' => ['btn-statistics-pie-configurator-' . $seedPiechart], + 'params' => [ + 'data-bs-toggle' => 'popover', + ] + ]) + ); + $panelHtml = sprintf( + '
    %s%s
    ', + $titleHtml, + $pieChart + ); + $statPie = $this->Bootstrap->card([ + 'variant' => 'secondary', + 'bodyHTML' => $panelHtml, + 'bodyClass' => 'py-1 px-2', + 'class' => ['shadow-sm', 'h-100'] + ]); + $statisticsHtml .= sprintf('
    %s
    ', $statPie); +} +?> + + + + \ No newline at end of file diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_scaffold.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_scaffold.php new file mode 100644 index 0000000..251994b --- /dev/null +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_scaffold.php @@ -0,0 +1,16 @@ +element('genericElements/IndexTable/Statistics/index_statistic_timestamp', [ + 'timeline' => $statistics, + ]); +} +if (!empty($statistics['usage'])) { + $statisticsHtml .= $this->element('genericElements/IndexTable/Statistics/index_statistic_field_amount', [ + 'statistics' => $statistics, + ]); +} +$statisticsHtml = sprintf('
    %s
    ', $statisticsHtml); +echo sprintf('
    %s
    ', $statisticsHtml); +?> diff --git a/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php b/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php new file mode 100644 index 0000000..c202890 --- /dev/null +++ b/templates/element/genericElements/IndexTable/Statistics/index_statistic_timestamp.php @@ -0,0 +1,140 @@ + $entry['time'], 'y' => $entry['count']]; + } +} +if (!empty($timeline['modified']['timeline'])) { + $series[1]['name'] = __('Modified'); + foreach ($timeline['modified']['timeline'] as $entry) { + $series[1]['data'][] = ['x' => $entry['time'], 'y' => $entry['count']]; + } +} + +$panelControlHtml = sprintf( + '
    + %s %s%s +
    ', + $title, + $subTitle, + $this->Bootstrap->button([ + 'variant' => 'link', + 'icon' => 'cog', + 'size' => 'xs', + 'nodeType' => 'a', + 'onclick' => '', + 'class' => ['btn-statistics-days-configurator-' . $seed,], + 'params' => [ + 'data-bs-toggle' => 'popover', + ] + ]) +); +$createdNumber = empty($timeline['created']) ? '' : sprintf( + '
    %s %s
    ', + __('{0} Created', $timeline['created']['variation']), + $this->Bootstrap->icon('plus', ['class' => ['fa-fw'], 'params' => ['style' => 'font-size: 60%;']]), + $timeline['created']['variation'] +); +$modifiedNumber = empty($timeline['modified']) ? '' : sprintf( + '
    %s %s
    ', + __('{0} Modified', $timeline['modified']['variation']), + $this->Bootstrap->icon('edit', ['class' => ['fa-fw'], 'params' => ['style' => 'font-size: 60%;']]), + $timeline['modified']['variation'] +); +$activityNumbers = sprintf('
    %s%s
    ', $createdNumber, $modifiedNumber); + +$leftContent = sprintf( + '%s%s', + $panelControlHtml, + $activityNumbers +); +$rightContent = sprintf('
    %s
    ', $this->element('charts/bar', [ + 'series' => $series, + 'chartOptions' => array_merge( + [ + 'chart' => [ + 'height' => 60, + ], + 'stroke' => [ + 'width' => 2, + 'curve' => 'smooth', + ], + ], + !empty($chartOptions) ? $chartOptions : [] + ) +])); +$cardContent = sprintf( + '
    +
    %s
    +
    %s
    +
    ', + $leftContent, + $rightContent +); + +$card = $this->Bootstrap->card([ + 'variant' => 'secondary', + 'bodyHTML' => $cardContent, + 'bodyClass' => 'py-1 px-2', + 'class' => ['shadow-sm', 'h-100'] +]); + +?> + +
    + + \ No newline at end of file diff --git a/templates/element/genericElements/IndexTable/headers.php b/templates/element/genericElements/IndexTable/headers.php index b949d12..ddacc54 100644 --- a/templates/element/genericElements/IndexTable/headers.php +++ b/templates/element/genericElements/IndexTable/headers.php @@ -3,11 +3,27 @@ foreach ($fields as $k => $header) { if (!isset($header['requirement']) || $header['requirement']) { $header_data = ''; + $icon_html = ''; + if (!empty($header['icon'])) { + $icon_html = $this->Bootstrap->icon($header['icon'], ['class' => ['d-inline me-1']]); + } if (!empty($header['sort'])) { if (!empty($header['name'])) { - $header_data = $paginator->sort($header['sort'], $header['name']); + $header_data = $paginator->sort( + $header['sort'], + sprintf('%s%s', $icon_html, h($header['name'])), + ['escape' => false] + ); } else { - $header_data = $paginator->sort($header['sort']); + if (empty($icon_html)) { + $header_data = $paginator->sort($header['sort']); + } else { + $header_data = $paginator->sort( + $header['sort'], + $icon_html, + ['escape' => false] + ); + } } } else { if (!empty($header['element']) && $header['element'] === 'selector') { diff --git a/templates/element/genericElements/IndexTable/index_table.php b/templates/element/genericElements/IndexTable/index_table.php index a893b90..dc7453c 100644 --- a/templates/element/genericElements/IndexTable/index_table.php +++ b/templates/element/genericElements/IndexTable/index_table.php @@ -1,5 +1,7 @@ element('/genericElements/IndexTable/index_table', [ * 'top_bar' => ( * // search/filter bar information compliant with ListTopBar @@ -12,118 +14,155 @@ * ), * 'title' => optional title, * 'description' => optional description, + * 'index_statistics' => optional statistics to be displayed for the index, * 'primary_id_path' => path to each primary ID (extracted and passed as $primary to fields) * )); * */ - $tableRandomValue = Cake\Utility\Security::randomString(8); - echo '
    '; - if (!empty($data['title'])) { - echo sprintf('

    %s

    ', h($data['title'])); - } - if (!empty($data['description'])) { - echo sprintf( - '
    %s
    ', - empty($data['description']) ? '' : h($data['description']) - ); - } - echo '
    '; - if (!empty($data['html'])) { - echo sprintf('
    %s
    ', $data['html']); - } - $skipPagination = isset($data['skip_pagination']) ? $data['skip_pagination'] : 0; - if (!$skipPagination) { - $paginationData = !empty($data['paginatorOptions']) ? $data['paginatorOptions'] : []; - echo $this->element( - '/genericElements/IndexTable/pagination', - [ - 'paginationOptions' => $paginationData, - 'tableRandomValue' => $tableRandomValue - ] - ); - echo $this->element( - '/genericElements/IndexTable/pagination_links' - ); - } - $multiSelectData = getMultiSelectData($data['top_bar']); - if (!empty($multiSelectData)) { - $multiSelectField = [ - 'element' => 'selector', - 'class' => 'short', - 'data' => $multiSelectData['data'] + +$newMetaFields = []; +if (!empty($requestedMetaFields)) { // Create mapping for new index table fields on the fly + foreach ($requestedMetaFields as $requestedMetaField) { + $template_id = $requestedMetaField['template_id']; + $meta_template_field_id = $requestedMetaField['meta_template_field_id']; + $newMetaFields[] = [ + 'name' => $meta_templates[$template_id]['meta_template_fields'][$meta_template_field_id]['field'], + 'data_path' => "MetaTemplates.{$template_id}.meta_template_fields.{$meta_template_field_id}.metaFields.{n}.value", + 'element' => 'generic_field', + '_metafield' => true, + '_automatic_field' => true, ]; - array_unshift($data['fields'], $multiSelectField); } - if (!empty($data['top_bar'])) { - echo $this->element( - '/genericElements/ListTopBar/scaffold', - [ - 'data' => $data['top_bar'], - 'table_data' => $data, - 'tableRandomValue' => $tableRandomValue - ] - ); - } - $rows = ''; - $row_element = isset($data['row_element']) ? $data['row_element'] : 'row'; - $options = isset($data['options']) ? $data['options'] : []; - $actions = isset($data['actions']) ? $data['actions'] : []; - if ($this->request->getParam('prefix') === 'Open') { - $actions = []; - } - $dblclickActionArray = !empty($actions) ? $this->Hash->extract($actions, '{n}[dbclickAction]') : []; - $dbclickAction = ''; - foreach ($data['data'] as $k => $data_row) { - $primary = null; - if (!empty($data['primary_id_path'])) { - $primary = $this->Hash->extract($data_row, $data['primary_id_path'])[0]; - } - if (!empty($dblclickActionArray)) { - $dbclickAction = sprintf("changeLocationFromIndexDblclick(%s)", $k); - } - $rows .= sprintf( - '%s', - h($k), - empty($dbclickAction) ? '' : 'ondblclick="' . $dbclickAction . '"', - empty($primary) ? '' : 'data-primary-id="' . $primary . '"', - empty($data['row_modifier']) ? '' : h($data['row_modifier']($data_row)), - empty($data['class']) ? '' : h($data['row_class']), - $this->element( - '/genericElements/IndexTable/' . $row_element, - [ - 'k' => $k, - 'row' => $data_row, - 'fields' => $data['fields'], - 'options' => $options, - 'actions' => $actions, - 'primary' => $primary, - 'tableRandomValue' => $tableRandomValue +} +$data['fields'] = array_merge($data['fields'], $newMetaFields); + +$tableRandomValue = Cake\Utility\Security::randomString(8); +echo '
    '; +if (!empty($data['title'])) { + echo Text::insert( + '

    :title :help

    ', + [ + 'title' => $this->ValueGetter->get($data['title']), + 'help' => $this->Bootstrap->icon('info', [ + 'class' => ['fs-6', 'align-text-top',], + 'title' => empty($data['description']) ? '' : h($data['description']), + 'params' => [ + 'data-bs-toggle' => 'tooltip', ] - ) - ); + ]), + ] + ); +} + +if(!empty($notice)) { + echo $this->Bootstrap->alert($notice); +} + +if (!empty($modelStatistics)) { + echo $this->element('genericElements/IndexTable/Statistics/index_statistic_scaffold', [ + 'statistics' => $modelStatistics, + ]); +} + + +echo '
    '; +if (!empty($data['html'])) { + echo sprintf('
    %s
    ', $data['html']); +} +$skipPagination = isset($data['skip_pagination']) ? $data['skip_pagination'] : 0; +if (!$skipPagination) { + $paginationData = !empty($data['paginatorOptions']) ? $data['paginatorOptions'] : []; + echo $this->element( + '/genericElements/IndexTable/pagination', + [ + 'paginationOptions' => $paginationData, + 'tableRandomValue' => $tableRandomValue + ] + ); + echo $this->element( + '/genericElements/IndexTable/pagination_links' + ); +} +$multiSelectData = getMultiSelectData($data['top_bar']); +if (!empty($multiSelectData)) { + $multiSelectField = [ + 'element' => 'selector', + 'class' => 'short', + 'data' => $multiSelectData['data'] + ]; + array_unshift($data['fields'], $multiSelectField); +} +if (!empty($data['top_bar'])) { + echo $this->element( + '/genericElements/ListTopBar/scaffold', + [ + 'data' => $data['top_bar'], + 'table_data' => $data, + 'tableRandomValue' => $tableRandomValue + ] + ); +} +$rows = ''; +$row_element = isset($data['row_element']) ? $data['row_element'] : 'row'; +$options = isset($data['options']) ? $data['options'] : []; +$actions = isset($data['actions']) ? $data['actions'] : []; +if ($this->request->getParam('prefix') === 'Open') { + $actions = []; +} +$dblclickActionArray = !empty($actions) ? $this->Hash->extract($actions, '{n}[dbclickAction]') : []; +$dbclickAction = ''; +foreach ($data['data'] as $k => $data_row) { + $primary = null; + if (!empty($data['primary_id_path'])) { + $primary = $this->Hash->extract($data_row, $data['primary_id_path'])[0]; } - $tbody = '' . $rows . ''; - echo sprintf( - '%s%s
    ', - $tableRandomValue, - $tableRandomValue, + if (!empty($dblclickActionArray)) { + $dbclickAction = sprintf("changeLocationFromIndexDblclick(%s)", $k); + } + $rows .= sprintf( + '%s', + h($k), + empty($dbclickAction) ? '' : 'ondblclick="' . $dbclickAction . '"', + empty($primary) ? '' : 'data-primary-id="' . $primary . '"', + empty($data['row_modifier']) ? '' : h($data['row_modifier']($data_row)), + empty($data['class']) ? '' : h($data['row_class']), $this->element( - '/genericElements/IndexTable/headers', + '/genericElements/IndexTable/' . $row_element, [ + 'k' => $k, + 'row' => $data_row, 'fields' => $data['fields'], - 'paginator' => $this->Paginator, - 'actions' => (empty($actions) ? false : true), + 'options' => $options, + 'actions' => $actions, + 'primary' => $primary, 'tableRandomValue' => $tableRandomValue ] - ), - $tbody + ) ); - if (!$skipPagination) { - echo $this->element('/genericElements/IndexTable/pagination_counter', $paginationData); - echo $this->element('/genericElements/IndexTable/pagination_links'); - } - echo '
    '; - echo '
    '; +} +$tbody = '' . $rows . ''; +echo sprintf( + '%s%s
    ', + $tableRandomValue, + $tableRandomValue, + h($this->Url->build(['action' => $this->request->getParam('action'),])), + $this->element( + '/genericElements/IndexTable/headers', + [ + 'fields' => $data['fields'], + 'paginator' => $this->Paginator, + 'actions' => (empty($actions) ? false : true), + 'tableRandomValue' => $tableRandomValue + ] + ), + $tbody +); +if (!$skipPagination) { + echo $this->element('/genericElements/IndexTable/pagination_counter', $paginationData); + echo $this->element('/genericElements/IndexTable/pagination_links'); +} +echo '
    '; +echo '
    '; ?> $metaTemplate) { + foreach ($metaTemplate['meta_template_fields'] as $metaTemplateField) { + $filteringItems[h($metaTemplate->name)][] = [ + 'id' => h($metaTemplateField->id), + 'name' => h($metaTemplateField->field), + 'template_id' => h($template_id), + 'type' => h($metaTemplateField->type), + ]; + } +} + +$filteringForm = $this->Bootstrap->table( + [ + 'small' => true, + 'striped' => false, + 'hover' => false, + 'tableClass' => ['indexMetaFieldsFilteringTable'], + ], + [ + 'fields' => [ + __('Meta Field'), + __('Operator'), + [ + 'labelHtml' => sprintf( + '%s %s', + __('Value'), + sprintf('', __('Supports strict matches and LIKE matches with the `%` character. Example: `%.com`')) + ) + ], + __('Action') + ], + 'items' => [] + ] +); +?> + + + \ No newline at end of file diff --git a/templates/element/genericElements/IndexTable/pagination.php b/templates/element/genericElements/IndexTable/pagination.php index b321962..890093d 100644 --- a/templates/element/genericElements/IndexTable/pagination.php +++ b/templates/element/genericElements/IndexTable/pagination.php @@ -25,11 +25,11 @@ 'number' => '
  • {{text}}
  • ', 'current' => '
  • {{text}}
  • ', 'ellipsis' => '
  • ', - 'sort' => '{{text}}', - 'sortAsc' => '{{text}} ', - 'sortDesc' => '{{text}} ', - 'sortAscLocked' => '{{text}}', - 'sortDescLocked' => '{{text}}' + 'sort' => '{{text}}', + 'sortAsc' => '{{text}} ', + 'sortDesc' => '{{text}} ', + 'sortAscLocked' => '{{text}}', + 'sortDescLocked' => '{{text}}' ] ); echo $this->Paginator->options($options); diff --git a/templates/element/genericElements/ListTopBar/group_context_filters.php b/templates/element/genericElements/ListTopBar/group_context_filters.php index c7cc3ed..e441515 100644 --- a/templates/element/genericElements/ListTopBar/group_context_filters.php +++ b/templates/element/genericElements/ListTopBar/group_context_filters.php @@ -9,6 +9,7 @@ ]; $currentQuery = $this->request->getQuery(); $filteringLabel = !empty($currentQuery['filteringLabel']) ? $currentQuery['filteringLabel'] : ''; + $fakeFilteringLabel = !empty($fakeFilteringLabel) ? $fakeFilteringLabel : false; unset($currentQuery['page'], $currentQuery['limit'], $currentQuery['sort'], $currentQuery['filteringLabel']); if (!empty($filteringContext['filterCondition'])) { // PHP replaces `.` by `_` when fetching the request parameter $currentFilteringContext = []; @@ -25,9 +26,11 @@ ( $currentQuery == $currentFilteringContext && // query conditions match !isset($filteringContext['filterConditionFunction']) && // not a custom filtering - empty($filteringLabel) // do not check `All` by default + empty($filteringLabel) && // do not check `All` by default + empty($fakeFilteringLabel) // no custom filter is a default filter ) || - $filteringContext['label'] == $filteringLabel // labels should not be duplicated + $filteringContext['label'] == $filteringLabel || // labels should not be duplicated + $filteringContext['label'] == $fakeFilteringLabel // use the default filter ), 'isFilter' => true, 'onClick' => 'changeIndexContext', diff --git a/templates/element/genericElements/ListTopBar/group_search.php b/templates/element/genericElements/ListTopBar/group_search.php index db94351..c2743f1 100644 --- a/templates/element/genericElements/ListTopBar/group_search.php +++ b/templates/element/genericElements/ListTopBar/group_search.php @@ -15,12 +15,22 @@ if (!empty($data['quickFilter'])) { $quickFilter = $data['quickFilter']; } + if (!empty($quickFilterForMetaField['enabled'])) { + $quickFilter[] = [ + 'MetaFields.value' => !empty($quickFilterForMetaField['wildcard_search']) + ]; + } $filterEffective = !empty($quickFilter); // No filters will be picked up, thus rendering the filtering useless $filteringButton = ''; if (!empty($data['allowFilering'])) { $activeFilters = !empty($activeFilters) ? $activeFilters : []; + $numberActiveFilters = count($activeFilters); + if (!empty($activeFilters['filteringMetaFields'])) { + $numberActiveFilters += count($activeFilters['filteringMetaFields']) - 1; + } $buttonConfig = [ 'icon' => 'filter', + 'variant' => $numberActiveFilters > 0 ? 'warning' : 'primary', 'params' => [ 'title' => __('Filter index'), 'id' => sprintf('toggleFilterButton-%s', h($tableRandomValue)) @@ -29,8 +39,8 @@ if (count($activeFilters) > 0) { $buttonConfig['badge'] = [ 'variant' => 'light', - 'text' => count($activeFilters), - 'title' => __n('There is {0} active filter', 'There are {0} active filters', count($activeFilters), count($activeFilters)) + 'text' => $numberActiveFilters, + 'title' => __n('There is {0} active filter', 'There are {0} active filters', $numberActiveFilters, $numberActiveFilters) ]; } $filteringButton = $this->Bootstrap->button($buttonConfig); @@ -58,7 +68,8 @@ $filterEffective ? '' : 'disabled="disabled"' ); echo sprintf( - '
    %s%s
    ', + '
    %s%s
    ', + $filterEffective ? '' : 'd-none', h($tableRandomValue), $input, $button diff --git a/templates/element/genericElements/ListTopBar/group_table_action.php b/templates/element/genericElements/ListTopBar/group_table_action.php index 64d3cb2..e9de073 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action.php +++ b/templates/element/genericElements/ListTopBar/group_table_action.php @@ -1,9 +1,12 @@ user_settings_by_name['ui.table_setting']['value']) ? json_decode($loggedUser->user_settings_by_name['ui.table_setting']['value'], true) : []; -$tableSettings = !empty($tableSettings[$data['table_setting_id']]) ? $tableSettings[$data['table_setting_id']] : []; +$data['table_setting_id'] = !empty($data['table_setting_id']) ? $data['table_setting_id'] : IndexSetting::getIDFromTable($model); +$tableSettings = IndexSetting::getTableSetting($loggedUser, $data['table_setting_id']); $compactDisplay = !empty($tableSettings['compact_display']); $availableColumnsHtml = $this->element('/genericElements/ListTopBar/group_table_action/hiddenColumns', [ @@ -11,6 +14,38 @@ $availableColumnsHtml = $this->element('/genericElements/ListTopBar/group_table_ 'tableSettings' => $tableSettings, 'table_setting_id' => $data['table_setting_id'], ]); + +$metaTemplateColumnMenu = []; +if (!empty($meta_templates)) { + $metaTemplateColumnMenu[] = ['header' => true, 'text' => __('Meta Templates'), 'icon' => 'object-group',]; + foreach ($meta_templates as $meta_template) { + $numberActiveMetaField = !empty($tableSettings['visible_meta_column'][$meta_template->id]) ? count($tableSettings['visible_meta_column'][$meta_template->id]) : 0; + $metaTemplateColumnMenu[] = [ + 'text' => $meta_template->name, + 'badge' => [ + 'text' => $numberActiveMetaField, + 'variant' => 'secondary', + 'title' => __n('{0} meta-field active for this meta-template', '{0} meta-fields active for this meta-template', $numberActiveMetaField, $numberActiveMetaField), + ], + 'keepOpen' => true, + 'menu' => [ + [ + 'html' => $this->element('/genericElements/ListTopBar/group_table_action/hiddenMetaColumns', [ + 'tableSettings' => $tableSettings, + 'table_setting_id' => $data['table_setting_id'], + 'meta_template' => $meta_template, + ]) + ] + ], + ]; + } +} +$indexColumnMenu = array_merge( + [['header' => true, 'text' => sprintf('%s\'s fields', $this->request->getParam('controller'))]], + [['html' => $availableColumnsHtml]], + $metaTemplateColumnMenu +); + $compactDisplayHtml = $this->element('/genericElements/ListTopBar/group_table_action/compactDisplay', [ 'table_data' => $table_data, 'tableSettings' => $tableSettings, @@ -35,24 +70,11 @@ $compactDisplayHtml = $this->element('/genericElements/ListTopBar/group_table_ac 'data-table_setting_id' => $data['table_setting_id'], ], 'menu' => [ - // [ - // 'text' => __('Group by'), - // 'icon' => 'layer-group', - // 'menu' => [ - // [ - // 'text' => 'fields to be grouped by', TODO:implement - // ] - // ], - // ], [ 'text' => __('Show/hide columns'), 'icon' => 'eye-slash', 'keepOpen' => true, - 'menu' => [ - [ - 'html' => $availableColumnsHtml, - ] - ], + 'menu' => $indexColumnMenu, ], [ 'html' => $compactDisplayHtml, diff --git a/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php b/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php index 9714be4..3a95b82 100644 --- a/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php +++ b/templates/element/genericElements/ListTopBar/group_table_action/hiddenColumns.php @@ -4,7 +4,10 @@ $tableSettings['hidden_column'] = $tableSettings['hidden_column'] ?? []; $availableColumnsHtml = ''; $availableColumns = []; foreach ($table_data['fields'] as $field) { - if (!empty($field['element']) && $field['element'] === 'selector') { + if ( + (!empty($field['element']) && $field['element'] === 'selector') || + !empty($field['_automatic_field']) + ) { continue; } $fieldName = !empty($field['name']) ? $field['name'] : \Cake\Utility\Inflector::humanize($field['data_path']); @@ -13,7 +16,7 @@ foreach ($table_data['fields'] as $field) { $availableColumnsHtml .= sprintf( '
    -
    ', @@ -34,17 +37,41 @@ echo $availableColumnsHtml; \ No newline at end of file diff --git a/templates/element/genericElements/ListTopBar/group_table_action/hiddenMetaColumns.php b/templates/element/genericElements/ListTopBar/group_table_action/hiddenMetaColumns.php new file mode 100644 index 0000000..8415dce --- /dev/null +++ b/templates/element/genericElements/ListTopBar/group_table_action/hiddenMetaColumns.php @@ -0,0 +1,33 @@ +meta_template_fields as $j => $meta_template_field) { + $fieldName = $meta_template_field['field']; + $fieldId = "metatemplate-{$meta_template_field->meta_template_id}-{$meta_template_field->id}"; + $isVisible = false; + if (!empty($tableSettings['visible_meta_column']) && !empty($tableSettings['visible_meta_column'][$meta_template_field->meta_template_id])) { + $isVisible = in_array($meta_template_field->id, $tableSettings['visible_meta_column'][$meta_template_field->meta_template_id]); + } + $availableMetaColumnsHtml .= sprintf( + '
    + + +
    ', + h($fieldId), + h($fieldId), + $isVisible ? 'checked' : '', + h($fieldId), + h($fieldName) + ); + } +} + +$availableMetaColumnsHtml = $this->Bootstrap->genNode('form', [ + 'class' => ['visible-meta-column-form', 'px-2 py-1'], +], $availableMetaColumnsHtml); +echo $availableMetaColumnsHtml; +?> diff --git a/templates/element/genericElements/MetaTemplates/metaTemplateNav.php b/templates/element/genericElements/MetaTemplates/metaTemplateNav.php index 9f9e36d..11c16c1 100644 --- a/templates/element/genericElements/MetaTemplates/metaTemplateNav.php +++ b/templates/element/genericElements/MetaTemplates/metaTemplateNav.php @@ -1,4 +1,12 @@ name) ?> - + Bootstrap->badge([ + 'variant' => !empty($metaTemplate['hasNewerVersion']) ? 'warning' : 'primary', + 'text' => sprintf('v%s', h($metaTemplate->version)) + ]) + ?> + is_default)): ?> + + \ No newline at end of file diff --git a/templates/element/genericElements/SingleViews/metafields_panel.php b/templates/element/genericElements/SingleViews/metafields_panel.php index ed718b1..e426679 100644 --- a/templates/element/genericElements/SingleViews/metafields_panel.php +++ b/templates/element/genericElements/SingleViews/metafields_panel.php @@ -1,34 +1,63 @@ [], 'content' => [] ]; -foreach($data['metaTemplates'] as $metaTemplate) { +foreach($data['MetaTemplates'] as $metaTemplate) { if (!empty($metaTemplate->meta_template_fields)) { - if ($metaTemplate->is_default) { - $tabData['navs'][] = [ - 'html' => $this->element('/genericElements/MetaTemplates/metaTemplateNav', ['metaTemplate' => $metaTemplate]) - ]; - } else { - $tabData['navs'][] = [ - 'text' => $metaTemplate->name - ]; - } + $tabData['navs'][] = [ + 'html' => $this->element('/genericElements/MetaTemplates/metaTemplateNav', ['metaTemplate' => $metaTemplate]) + ]; $fields = []; foreach ($metaTemplate->meta_template_fields as $metaTemplateField) { - $metaField = $metaTemplateField->meta_fields[0]; - $fields[] = [ - 'key' => $metaField->field, - 'raw' => $metaField->value - ]; + $labelPrintedOnce = false; + if (!empty($metaTemplateField->metaFields)) { + foreach ($metaTemplateField->metaFields as $metaField) { + $fields[] = [ + 'key' => !$labelPrintedOnce ? $metaField->field : '', + 'raw' => $metaField->value + ]; + $labelPrintedOnce = true; + } + } } $listTable = $this->Bootstrap->listTable([ 'hover' => false, 'elementsRootPath' => '/genericElements/SingleViews/Fields/' ],[ 'item' => false, - 'fields' => $fields + 'fields' => $fields, + 'caption' => __n( + 'This meta-template contains {0} meta-field', + 'This meta-template contains {0} meta-fields', + count($fields), + count($fields) + ) ]); + if (!empty($metaTemplate['hasNewerVersion']) && !empty($fields)) { + $listTable = $this->Bootstrap->alert([ + 'html' => sprintf( + '
    %s
    %s
    ', + __('These meta-fields are registered under an outdated template. Newest template is {0}, current is {1}.', $metaTemplate['hasNewerVersion']->version, $metaTemplate->version), + $this->Bootstrap->button([ + 'text' => __('Migrate to version {0}', $metaTemplate['hasNewerVersion']->version), + 'variant' => 'success', + 'nodeType' => 'a', + 'params' => [ + 'href' => Router::url([ + 'controller' => 'metaTemplates', + 'action' => 'migrateOldMetaTemplateToNewestVersionForEntity', + $metaTemplate->id, + $data->id, + ]) + ] + ]) + ), + 'variant' => 'warning', + ]) . $listTable; + } $tabData['content'][] = $listTable; } } diff --git a/templates/element/genericElements/SingleViews/single_view.php b/templates/element/genericElements/SingleViews/single_view.php index 90bcbc1..f1812b0 100644 --- a/templates/element/genericElements/SingleViews/single_view.php +++ b/templates/element/genericElements/SingleViews/single_view.php @@ -36,7 +36,7 @@ 'tableClass' => 'col-sm-8', 'elementsRootPath' => '/genericElements/SingleViews/Fields/' ]; - if (!empty($data['metaTemplates']) && (empty($skip_meta_templates)) && !empty($combinedFieldsView)) { + if (!empty($data['MetaTemplates']) && (empty($skip_meta_templates)) && !empty($combinedFieldsView)) { $listTableOptions['tableClass'] = ''; } $listTable = $this->Bootstrap->listTable($listTableOptions,[ @@ -45,7 +45,7 @@ ]); $metafieldsPanel = ''; - if (!empty($data['metaTemplates']) && (empty($skip_meta_templates))) { + if (!empty($data['MetaTemplates']) && (empty($skip_meta_templates))) { $metaFieldsData = [ 'data' => $data, ]; @@ -78,11 +78,11 @@ __('{0} view', \Cake\Utility\Inflector::singularize(\Cake\Utility\Inflector::humanize($this->request->getParam('controller')))) : $title; echo sprintf( - "
    + "

    %s

    %s%s -
    %s
    -
    %s
    +
    %s
    +
    %s
    %s
    ", $tableRandomValue, diff --git a/templates/element/layouts/header/header-breadcrumb.php b/templates/element/layouts/header/header-breadcrumb.php index bd72eba..9ac92fa 100644 --- a/templates/element/layouts/header/header-breadcrumb.php +++ b/templates/element/layouts/header/header-breadcrumb.php @@ -66,16 +66,22 @@ if (!empty($breadcrumb)) { ); } } + $badgeNumber = 0; if (!empty($lastCrumb['actions'])) { foreach ($lastCrumb['actions'] as $i => $actionEntry) { if (!empty($actionEntry['url_vars'])) { $actionEntry['url'] = $this->DataFromPath->buildStringFromDataPath($actionEntry['url'], $entity, $actionEntry['url_vars']); } + if (!empty($actionEntry['badge'])) { + $badgeNumber += 1; + } $breadcrumbAction .= sprintf( - '%s', - Router::url($actionEntry['url']), + '%s%s', + !empty($actionEntry['variant']) ? sprintf('dropdown-item-%s', $actionEntry['variant']) : '', + sprintf('UI.overlayUntilResolve(this, UI.submissionModalAutoGuess(\'%s\'))', h(Router::url($actionEntry['url']))), !empty($actionEntry['icon']) ? $this->FontAwesome->getClass(h($actionEntry['icon'])) : '', - h($actionEntry['label']) + h($actionEntry['label']), + !empty($actionEntry['badge']) ? $this->Bootstrap->badge($actionEntry['badge']) : '' ); } } @@ -92,22 +98,24 @@ echo $this->Breadcrumbs->render( \ No newline at end of file diff --git a/templates/element/layouts/header/header-notification-item.php b/templates/element/layouts/header/header-notification-item.php new file mode 100644 index 0000000..59a3df8 --- /dev/null +++ b/templates/element/layouts/header/header-notification-item.php @@ -0,0 +1,40 @@ + + + href="" + + onclick="UI.submissionModal('', {closeOnSuccess: false})" + + title="ValueGetter->get($notification['text']), $this->ValueGetter->get($notification['details'])) ?>" +> +
    + + position-relative"> + Bootstrap->icon($notification['icon'], ['class' => ['fa-fw', 'position-absolute top-50 start-50 translate-middle']]) ?> + + + +
    + ValueGetter->get($notification['text']) ?> + + format('Y-m-d\TH:i:s')) ?> + +
    + + + ValueGetter->get($notification['details']) ?> + + +
    +
    +
    + \ No newline at end of file diff --git a/templates/element/layouts/header/header-notifications.php b/templates/element/layouts/header/header-notifications.php index d5ab345..e18b619 100644 --- a/templates/element/layouts/header/header-notifications.php +++ b/templates/element/layouts/header/header-notifications.php @@ -1,13 +1,47 @@ -1, + 'info' => 0, + 'warning' => 1, + 'danger' => 2, +]; +$maxSeverity = -1; +$hasNotification = !empty($notifications); +$notificationVariants = Hash::extract($notifications, '{n}.variant'); +foreach ($notificationVariants as $notifVariant) { + $maxSeverity = max($maxSeverity, $severity[$notifVariant] ?? 0); +} +$variant = array_flip($severity)[$maxSeverity]; ?>
    - +
    \ No newline at end of file diff --git a/templates/element/layouts/sidebar.php b/templates/element/layouts/sidebar.php index 3c9d849..16732ad 100644 --- a/templates/element/layouts/sidebar.php +++ b/templates/element/layouts/sidebar.php @@ -18,6 +18,7 @@ element('layouts/sidebar/category', ['label' => $category]) ?> $parent) : ?> element('layouts/sidebar/entry', [ + 'parentName' => $parentName, 'parent' => $parent, ]) ?> diff --git a/templates/element/layouts/sidebar/entry.php b/templates/element/layouts/sidebar/entry.php index 59da1ee..81949a1 100644 --- a/templates/element/layouts/sidebar/entry.php +++ b/templates/element/layouts/sidebar/entry.php @@ -8,7 +8,7 @@ if (!empty($children)) { $url = "#{$seed}"; } else { - $url = $parent['url'] ?? '#'; + $url = $parent['url'] ?? false; } $controller = \Cake\Utility\Inflector::variable($this->request->getParam('controller')); @@ -26,24 +26,73 @@ $hasActiveChild = true; } } + + $severity = [ + 'primary' => -1, + 'info' => 0, + 'warning' => 1, + 'danger' => 2, + ]; + + $hasNotification = false; + $childHasNotification = false; + $maxSeverity = -1; + $childMaxSeverity = -1; + $notificationAmount = 0; + foreach ($children as $childName => $child) { // children notification + foreach ($notifications as $notification) { + if (!empty($notification['_sidebarId']) && $notification['_sidebarId'] == $childName) { + $childHasNotification = true; + $childMaxSeverity = max($childMaxSeverity, $severity[$notification['variant']] ?? 0); + } + } + } + foreach ($notifications as $notification) { // leaf notification + if (!empty($notification['_sidebarId']) && $notification['_sidebarId'] == $parentName) { + $hasNotification = true; + $maxSeverity = max($maxSeverity, $severity[$notification['variant']] ?? 0); + $notificationAmount += 1; + } + } + $notificationVariant = array_flip($severity)[$maxSeverity]; + $childNotificationVariant = array_flip($severity)[$childMaxSeverity]; ?>
  • - - - > - - - - - element('layouts/sidebar/sub-menu', [ - 'seed' => $seed, - 'children' => $children, - 'open' => $hasActiveChild - ]); - ?> + + + + > + + Bootstrap->notificationBubble([ + 'variant' => $childHasNotification ? $childNotificationVariant : $notificationVariant, + ]); + } + ?> + + + Bootstrap->badge([ + 'text' => $notificationAmount, + 'class' => 'ms-auto', + 'variant' => $notificationVariant, + ]); + } + ?> + + + element('layouts/sidebar/sub-menu', [ + 'seed' => $seed, + 'children' => $children, + 'open' => $hasActiveChild, + ]); + ?> +
  • diff --git a/templates/element/layouts/sidebar/sub-menu.php b/templates/element/layouts/sidebar/sub-menu.php index 5e3031a..5b96b62 100644 --- a/templates/element/layouts/sidebar/sub-menu.php +++ b/templates/element/layouts/sidebar/sub-menu.php @@ -3,8 +3,9 @@ ?>