From 7215e6a87ac18f0938f98382be12c92d9837f0fc Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Sat, 24 Sep 2022 16:54:55 -0400 Subject: [PATCH] Initial implementation of Global Search (CFM-109) --- app/config/bootstrap.php | 7 +- app/config/schema/schema.json | 34 ++-- app/resources/locales/en_US/field.po | 39 ++-- app/resources/locales/en_US/information.po | 3 + app/resources/locales/en_US/result.po | 22 ++- app/src/Command/TransmogrifyCommand.php | 6 +- app/src/Controller/DashboardsController.php | 187 ++++++++++++++++++ app/src/Controller/MVEAController.php | 2 +- app/src/Controller/NamesController.php | 2 +- app/src/Controller/PeopleController.php | 2 +- app/src/Lib/Traits/PrimaryLinkTrait.php | 6 +- app/src/Model/Entity/CoSetting.php | 8 +- app/src/Model/Entity/Identifier.php | 2 +- app/src/Model/Table/AddressesTable.php | 42 ++++ app/src/Model/Table/CoSettingsTable.php | 163 ++++++++------- app/src/Model/Table/DashboardsTable.php | 7 +- app/src/Model/Table/EmailAddressesTable.php | 23 ++- app/src/Model/Table/GroupsTable.php | 36 ++++ app/src/Model/Table/IdentifiersTable.php | 24 +++ app/src/Model/Table/NamesTable.php | 63 ++++++ app/src/Model/Table/PersonRolesTable.php | 38 ++++ app/src/Model/Table/TelephoneNumbersTable.php | 23 +++ app/src/Model/Table/TypesTable.php | 2 +- app/src/Model/Table/UrlsTable.php | 18 ++ app/templates/CoSettings/fields.inc | 24 ++- app/templates/Dashboards/search.php | 71 +++++++ app/templates/element/flash/information.php | 9 + app/templates/element/searchGlobal.php | 23 +-- app/webroot/css/co-base.css | 5 + 29 files changed, 738 insertions(+), 153 deletions(-) create mode 100644 app/templates/Dashboards/search.php create mode 100644 app/templates/element/flash/information.php diff --git a/app/config/bootstrap.php b/app/config/bootstrap.php index da1c9efd0..e2ed59f7d 100644 --- a/app/config/bootstrap.php +++ b/app/config/bootstrap.php @@ -232,4 +232,9 @@ Inflector::rules('irregular', ['co_terms_and_condition' => 'co_terms_and_conditions']); Inflector::rules('irregular', ['cou' => 'cous']); -Inflector::rules('irregular', ['meta' => 'meta']); \ No newline at end of file +Inflector::rules('irregular', ['meta' => 'meta']); + +/* + * Define some constants + */ +define('DEF_GLOBAL_SEARCH_LIMIT', 500); \ No newline at end of file diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 3abdb34b2..015d5528c 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -74,16 +74,18 @@ "columns": { "id": {}, "co_id": {}, - "address_required_fields": { "type": "string", "size": 160 }, - "address_default_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "email_address_default_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "identifier_default_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "name_default_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "name_permitted_fields": { "type": "string", "size": 160 }, - "name_required_fields": { "type": "string", "size": 160 }, - "telephone_number_default_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "telephone_number_permitted_fields": { "type": "string", "size": 160 }, - "url_default_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } } + "default_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "default_url_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "permitted_fields_name": { "type": "string", "size": 160 }, + "permitted_fields_telephone_number": { "type": "string", "size": 160 }, + "required_fields_address": { "type": "string", "size": 160 }, + "required_fields_name": { "type": "string", "size": 160 }, + "search_global_limit": { "type": "integer" }, + "search_global_limited_models": { "type": "boolean" } }, "indexes": { "co_settings_i1": { "columns": [ "co_id" ]}, @@ -92,13 +94,13 @@ "We don't really need an index, but DBAL will create one for all foreign keys if none exists", "typeIsDefault will make queries using these columns, but rarely and won't usually have enough rows to need the index" ], - "columns": [ "name_default_type_id" ] + "columns": [ "default_name_type_id" ] }, - "co_settings_i3": { "columns": [ "email_address_default_type_id" ] }, - "co_settings_i4": { "columns": [ "identifier_default_type_id" ] }, - "co_settings_i5": { "columns": [ "address_default_type_id" ] }, - "co_settings_i6": { "columns": [ "telephone_number_default_type_id" ] }, - "co_settings_i7": { "columns": [ "url_default_type_id" ] } + "co_settings_i3": { "columns": [ "default_email_address_type_id" ] }, + "co_settings_i4": { "columns": [ "default_identifier_type_id" ] }, + "co_settings_i5": { "columns": [ "default_address_type_id" ] }, + "co_settings_i6": { "columns": [ "default_telephone_number_type_id" ] }, + "co_settings_i7": { "columns": [ "default_url_type_id" ] } } }, diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 28c608e54..336d42e27 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -56,35 +56,44 @@ msgstr "Comment" msgid "Cos.member.not" msgstr "{0} (Not a Member)" -msgid "CoSettings.address_default_type_id" +msgid "CoSettings.default_address_type_id" msgstr "Default Address Type" -msgid "CoSettings.address_required_fields" -msgstr "Address Required Fields" - -msgid "CoSettings.email_address_default_type_id" +msgid "CoSettings.default_email_address_type_id" msgstr "Default Email Address Type" -msgid "CoSettings.identifier_default_type_id" +msgid "CoSettings.default_identifier_type_id" msgstr "Default Identifier Type" -msgid "CoSettings.name_default_type_id" +msgid "CoSettings.default_name_type_id" msgstr "Default Name Type" -msgid "CoSettings.name_permitted_fields" +msgid "CoSettings.default_telephone_number_type_id" +msgstr "Default Telephone Number Type" + +msgid "CoSettings.default_url_type_id" +msgstr "Default URL Type" + +msgid "CoSettings.permitted_fields_name" msgstr "Name Permitted Fields" -msgid "CoSettings.name_required_fields" +msgid "CoSettings.permitted_fields_telephone_number" +msgstr "Telephone Number Permitted Fields" + +msgid "CoSettings.required_fields_address" +msgstr "Address Required Fields" + +msgid "CoSettings.required_fields_name" msgstr "Name Required Fields" -msgid "CoSettings.telephone_number_default_type_id" -msgstr "Default Telephone Number Type" +msgid "CoSettings.search_global_limit" +msgstr "Global Search Limit" -msgid "CoSettings.telephone_number_permitted_fields" -msgstr "Telephone Number Permitted Fields" +msgid "CoSettings.search_global_limited_models" +msgstr "Limit Global Search Scope" -msgid "CoSettings.url_default_type_id" -msgstr "Default URL Type" +msgid "CoSettings.search_global_limited_models.desc" +msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." msgid "country" msgstr "Country" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 8713d0b20..dba961a1a 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -42,6 +42,9 @@ msgstr "Notice: " msgid "flash.error" msgstr "Error: " +msgid "flash.information" +msgstr "Information: " + msgid "flash.success" msgstr "Success: " diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index a707e6f14..5399338f8 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -72,8 +72,26 @@ msgstr "Added {0} as an owner of group {1}" msgid "GroupOwners.deleted" msgstr "Removed {0} as an owner of group {1}" +msgid "Names.primary_name" +msgstr "Primary Name Updated" + msgid "saved" msgstr "Saved" -msgid "Names.primary_name" -msgstr "Primary Name Updated" \ No newline at end of file +msgid "search.exact" +msgstr "Exact match for \"{0}\" found ({1})" + +msgid "search.limit" +msgstr "Search limit reached" + +msgid "search.none" +msgstr "No results found" + +msgid "search.result.id" +msgstr "({0})" + +msgid "search.result.related" +msgstr "({0}, {1}: {2})" + +msgid "search.results" +msgstr "Search Results" \ No newline at end of file diff --git a/app/src/Command/TransmogrifyCommand.php b/app/src/Command/TransmogrifyCommand.php index c5cefc731..d89dced23 100644 --- a/app/src/Command/TransmogrifyCommand.php +++ b/app/src/Command/TransmogrifyCommand.php @@ -74,9 +74,8 @@ class TransmogrifyCommand extends Command { 'postTable' => 'insertDefaultSettings', 'cache' => [ 'co_id' ], 'fieldMap' => [ - 'permitted_fields_name' => 'name_permitted_fields', - 'required_fields_addr' => 'address_required_fields', - 'required_fields_name' => 'name_required_fields', + 'global_search_limit' => 'search_global_limit', + 'required_fields_addr' => 'required_fields_address', 'telephone_number_permitted_fields' => '&populate_co_settings_phone', // XXX CFM-80 these fields are not yet migrated // be sure to add appropriate fields to 'booleans' @@ -96,7 +95,6 @@ class TransmogrifyCommand extends Command { 'elect_strategy_primary_name' => null, 'co_dashboard_id' => null, 'co_theme_id' => null, - 'global_search_limit' => null, 'person_picker_email_type' => null, 'person_picker_identifier_type' => null, 'person_picker_display_types' => null diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index e33b58bd2..93b2bb27c 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -31,6 +31,9 @@ // XXX not doing anything with Log yet use Cake\Log\Log; +use Cake\ORM\TableRegistry; +use Cake\Utility\Hash; +use Cake\Utility\Inflector; //use \App\Lib\Enum\PermissionEnum; class DashboardsController extends StandardController { @@ -114,4 +117,188 @@ public function configuration() { public function dashboard(?int $id=null) { // XXX placeholder } + + /** + * Perform a cross model search. + * + * @since COmanage Registry v5.0.0 + */ + + public function search() { + /* To add a new backend to search: + * (1) Implement $model->search($id, $q, $limit) + * (2) Add the model to $models here, and define which roles can query it + * (3) Update documentation at https://spaces.at.internet2.edu/pages/viewpage.action?pageId=243078053 + */ + + $models = [ + 'Addresses' => [ + 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'street', + 'searchLimited' => false + ], + 'EmailAddresses' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'mail', + 'searchLimited' => true + ], + 'Groups' => [ + 'parent' => ['Cos' => 'co_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'name', + 'searchLimited' => false + ], + 'Identifiers' => [ + 'parent' => ['Groups' => 'group_id', 'People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'identifier', + 'searchLimited' => true + ], + 'Names' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'full_name', + 'searchLimited' => true + ], + 'PersonRoles' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'title', + 'searchLimited' => false + ], + 'TelephoneNumbers' => [ + 'parent' => ['People' => 'person_id', 'PersonRoles' => 'person_role_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'number', + 'searchLimited' => false + ], + 'Urls' => [ + 'parent' => ['People' => 'person_id'], + 'roles' => ['platformAdmin', 'coAdmin'], + 'displayField' => 'url', + 'searchLimited' => false + ] + ]; + + $this->set('vv_supported_models', $models); + + // XXX inject plugins here + + // $results tracks the per-model backend results + $results = [ + 'Cos' => [], + 'Groups' => [], + 'People' => [] + ]; + + // XXX Still need to implement this (see also CFM-126) + $roles = []; + + if(!empty($this->request->getData('q')) + // Only process the request if there are non-space characters + && !ctype_space($this->request->getData('q'))) { + // Trim leading and trailing whitespace + $q = trim($this->request->getData('q')); + + // Pull our search configuration + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $searchLimit = $settings->search_global_limit; + + foreach(array_keys($models) as $m) { + // If we're in limited search mode, we don't search all models + if($settings->search_global_limited_models + && !$models[$m]['searchLimited']) { + continue; + } + + $authorized = true; // XXX dynamically calculate this + + $table = $this->getTableLocator()->get($m); + + $searchResults = $table->search(coId: $this->getCOID(), + q: $q, + limit: $searchLimit); + + // For models with a parent other than Co, we aggregate the results to the parent + // model, but track what the matching model was. We key on the foreign key to the parent + // to also unique-ify the results while we're here. + + foreach($searchResults as $r) { + // Some tables support multiple parent models (eg: Identifiers), so we walk through + // the possibilities to see which one matched + foreach($models[$m]['parent'] as $pmodel => $pkey) { + if(!empty($r->$pkey)) { + if($m == 'Groups') { + // We special case Groups since (unlike People) they can match on both the + // primary model (Groups::name) or associated models (Identifiers::identifier). + // We force any Groups matches into the parent key format. + $results['Groups'][$r->id]['Groups'] = $r; + } elseif($pmodel == 'Cos') { + // This will look something like $results['Cos']['Departments'][] = $entity + $results[$pmodel][$m][] = $r; + } elseif($pmodel == 'PersonRoles') { + // Although we matched on a PersonRole we're really interested in the Person + $results['People'][$r->person_role->person_id][$m] = $r->person_role; + } else { + // Note we're also keying on the matched model, so this will look something like + // $results['People'][123]['Names'] = $entity + $results[$pmodel][$r->$pkey][$m] = $r; + } + } + } + } + } + + if(count($results['Cos']) + count($results['Groups']) + count($results['People']) >= $searchLimit) { + $this->Flash->information(__d('result', 'search.limit')); + } + } + + // It's a single match if there is a single person or person role result, + // or if there is a single result overall, redirect to that result. + if((count($results['Cos']) == 0 + && (count($results['People']) + count($results['Groups'])) == 1) + || + (count($results['Cos']) == 1 + && (count($results['People']) + count($results['Groups'])) == 0)) { + // Figure out which model matched, as well as the target model to redirect to + $matchClass = null; + $targetClass = null; + $targetRecordId = null; + + foreach(['Cos', 'Groups', 'People'] as $m) { + if(!empty($results[$m])) { + $targetClass = $m; + $targetRecordId = array_key_first($results[$m]); + $matchClass = array_key_first($results[$m][$targetRecordId]); + } + } + + $this->Flash->information(__d('result', + 'search.exact', + [filter_var($this->request->getData('q'), FILTER_SANITIZE_SPECIAL_CHARS), + __d('controller', $matchClass, [1])])); + + // Redirect to the matchClass controller + return $this->redirect([ + 'controller' => Inflector::dasherize($targetClass), + 'action' => 'edit', + $targetRecordId + ]); + + // XXX handle plugins + } elseif(count($results['Cos']) + + count($results['People']) + + count($results['Groups']) == 0) { + $this->Flash->information(__d('result', 'search.none')); + } + + $this->set('vv_results', $results); + $this->set('vv_title', __d('result', 'search.results')); + } } \ No newline at end of file diff --git a/app/src/Controller/MVEAController.php b/app/src/Controller/MVEAController.php index 443814142..2f2323f9e 100644 --- a/app/src/Controller/MVEAController.php +++ b/app/src/Controller/MVEAController.php @@ -106,7 +106,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { // If there is a default type setting for this model, pass it to the view if($this->$modelsName->getSchema()->hasColumn('type_id')) { - $defaultTypeField = $fieldName . "_default_type_id"; + $defaultTypeField = "default_" . $fieldName . "_type_id"; $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); diff --git a/app/src/Controller/NamesController.php b/app/src/Controller/NamesController.php index fb3401b1e..bcac8f285 100644 --- a/app/src/Controller/NamesController.php +++ b/app/src/Controller/NamesController.php @@ -60,7 +60,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); $this->set('vv_permitted_fields', $settings->name_permitted_fields_array()); - $this->set('vv_default_type', $settings->name_default_type_id); + $this->set('vv_default_type', $settings->default_name_type_id); } return parent::beforeRender($event); diff --git a/app/src/Controller/PeopleController.php b/app/src/Controller/PeopleController.php index 74232cbe7..d87f8aa6e 100644 --- a/app/src/Controller/PeopleController.php +++ b/app/src/Controller/PeopleController.php @@ -70,7 +70,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $this->set('vv_permitted_name_fields', $settings->name_permitted_fields_array()); $this->set('vv_required_name_fields', $settings->name_required_fields_array()); - $this->set('vv_default_name_type', $settings->name_default_type_id); + $this->set('vv_default_name_type', $settings->default_name_type_id); } return parent::beforeRender($event); diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index 64edd65ed..579961177 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -330,11 +330,13 @@ public function lookupPersonId($entity): ?int { if($entity->getSource() == 'People') { return $entity->id; - } elseif(array_key_exists('person_id', $a)) { + } elseif(array_key_exists('person_id', $a) + // MVEAs can have multiple parent keys, but not all of them may be set + && !empty($entity->person_id)) { return $entity->person_id; } else { $linkEntity = $this->findPrimaryLinkEntity($entity); - + if(!empty($linkEntity->person_id)) { return $linkEntity->person_id; } else { diff --git a/app/src/Model/Entity/CoSetting.php b/app/src/Model/Entity/CoSetting.php index a59e1434a..7b1d08a05 100644 --- a/app/src/Model/Entity/CoSetting.php +++ b/app/src/Model/Entity/CoSetting.php @@ -51,7 +51,7 @@ class CoSetting extends Entity { */ public function address_required_fields_array(): array { - return explode(",", $this->address_required_fields); + return explode(",", $this->required_fields_address); } /** @@ -62,7 +62,7 @@ public function address_required_fields_array(): array { */ public function name_permitted_fields_array(): array { - return explode(",", $this->name_permitted_fields); + return explode(",", $this->permitted_fields_name); } /** @@ -73,7 +73,7 @@ public function name_permitted_fields_array(): array { */ public function name_required_fields_array(): array { - return explode(",", $this->name_required_fields); + return explode(",", $this->required_fields_name); } /** @@ -84,6 +84,6 @@ public function name_required_fields_array(): array { */ public function telephone_number_permitted_fields_array(): array { - return explode(",", $this->telephone_number_permitted_fields); + return explode(",", $this->permitted_fields_telephone_number); } } \ No newline at end of file diff --git a/app/src/Model/Entity/Identifier.php b/app/src/Model/Entity/Identifier.php index da736bf40..a86ba05d7 100644 --- a/app/src/Model/Entity/Identifier.php +++ b/app/src/Model/Entity/Identifier.php @@ -49,6 +49,6 @@ class Identifier extends Entity { */ public function isLogin(): bool { - return $this->login; + return $this->login ?? false; } } \ No newline at end of file diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index aeb3f830a..293fb6d98 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -129,6 +129,48 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause['AND'][] = [ + 'OR' => [ + 'LOWER(Addresses.street) LIKE' => '%' . strtolower($t) . '%' + ] + ]; + } + + return $this->find() + ->where($whereClause) + ->andWhere(['People.co_id' => $coId]) + ->order(['Addresses.street']) + ->limit($limit) + ->contain([ + 'People' => 'PrimaryName', + 'PersonRoles' => [ + 'People' => 'PrimaryName' + ] + ]) + ->all(); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php index eba140fd3..6102b7a7c 100644 --- a/app/src/Model/Table/CoSettingsTable.php +++ b/app/src/Model/Table/CoSettingsTable.php @@ -73,32 +73,32 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); - $this->belongsTo('AddressDefaultTypes') + $this->belongsTo('DefaultAddressTypes') ->setClassName('Types') - ->setForeignKey('address_default_type_id') + ->setForeignKey('default_address_type_id') // Property is set so ruleValidateCO can find it. We don't use the // _id suffix to match Cake's default pattern. - ->setProperty('address_default_type'); - $this->belongsTo('EmailAddressDefaultTypes') + ->setProperty('default_address_type'); + $this->belongsTo('DefaultEmailAddressTypes') ->setClassName('Types') - ->setForeignKey('email_address_default_type_id') - ->setProperty('email_address_default_type'); - $this->belongsTo('IdentifierDefaultTypes') + ->setForeignKey('default_email_address_type_id') + ->setProperty('default_email_address_type'); + $this->belongsTo('DefaultIdentifierTypes') ->setClassName('Types') - ->setForeignKey('identifier_default_type_id') - ->setProperty('identifier_default_type'); - $this->belongsTo('NameDefaultTypes') + ->setForeignKey('default_identifier_type_id') + ->setProperty('default_identifier_type'); + $this->belongsTo('DefaultNameTypes') ->setClassName('Types') - ->setForeignKey('name_default_type_id') - ->setProperty('name_default_type'); - $this->belongsTo('TelephoneNumberDefaultTypes') + ->setForeignKey('default_name_type_id') + ->setProperty('default_name_type'); + $this->belongsTo('DefaultTelephoneNumberTypes') ->setClassName('Types') - ->setForeignKey('telephone_number_default_type_id') - ->setProperty('telephone_number_default_type'); - $this->belongsTo('UrlDefaultTypes') + ->setForeignKey('default_telephone_number_type_id') + ->setProperty('default_telephone_number_type'); + $this->belongsTo('DefaultUrlTypes') ->setClassName('Types') - ->setForeignKey('url_default_type_id') - ->setProperty('url_default_type'); + ->setForeignKey('default_url_type_id') + ->setProperty('default_url_type'); $this->setDisplayField('co_id'); @@ -108,45 +108,45 @@ public function initialize(array $config): void { $this->setRedirectGoal('self'); $this->setAutoViewVars([ - 'addressDefaultTypes' => [ + 'defaultAddressTypes' => [ 'type' => 'type', 'attribute' => 'Addresses.type' ], - 'addressRequiredFields' => [ - 'type' => 'enum', - 'class' => 'RequiredAddressFieldsEnum' - ], - 'emailAddressDefaultTypes' => [ + 'defaultEmailAddressTypes' => [ 'type' => 'type', 'attribute' => 'EmailAddresses.type' ], - 'identifierDefaultTypes' => [ + 'defaultIdentifierTypes' => [ 'type' => 'type', 'attribute' => 'Identifiers.type' ], - 'nameDefaultTypes' => [ + 'defaultNameTypes' => [ 'type' => 'type', 'attribute' => 'Names.type' ], - 'namePermittedFields' => [ + 'defaultTelephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ], + 'defaultUrlTypes' => [ + 'type' => 'type', + 'attribute' => 'Urls.type' + ], + 'permittedFieldsNames' => [ 'type' => 'enum', 'class' => 'PermittedNameFieldsEnum' ], - 'nameRequiredFields' => [ + 'permittedFieldsTelephoneNumbers' => [ 'type' => 'enum', - 'class' => 'RequiredNameFieldsEnum' - ], - 'telephoneNumberDefaultTypes' => [ - 'type' => 'type', - 'attribute' => 'TelephoneNumbers.type' + 'class' => 'PermittedTelephoneNumberFieldsEnum' ], - 'telephoneNumberPermittedFields' => [ + 'requiredFieldsAddresses' => [ 'type' => 'enum', - 'class' => 'PermittedTelephoneNumberFieldsEnum' + 'class' => 'RequiredAddressFieldsEnum' ], - 'urlDefaultTypes' => [ - 'type' => 'type', - 'attribute' => 'Urls.type' + 'requiredFieldsNames' => [ + 'type' => 'enum', + 'class' => 'RequiredNameFieldsEnum' ] ]); @@ -181,16 +181,18 @@ public function addDefaults(int $coId): int { $defaultSettings = [ 'co_id' => $coId, - 'address_default_type_id' => null, - 'address_required_fields' => RequiredAddressFieldsEnum::Street, - 'email_address_default_type_id' => null, - 'identifier_default_type_id' => null, - 'name_default_type_id' => null, - 'name_permitted_fields' => PermittedNameFieldsEnum::HGMFS, - 'name_required_fields' => RequiredNameFieldsEnum::Given, - 'telephone_number_default_type_id' => null, - 'telephone_number_permitted_fields' => PermittedTelephoneNumberFieldsEnum::CANE, - 'url_default_type_id' => null + 'default_address_type_id' => null, + 'default_email_address_type_id' => null, + 'default_identifier_type_id' => null, + 'default_name_type_id' => null, + 'default_telephone_number_type_id' => null, + 'default_url_type_id' => null, + 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, + 'permitted_fields_telephone_number' => PermittedTelephoneNumberFieldsEnum::CANE, + 'required_fields_address' => RequiredAddressFieldsEnum::Street, + 'required_fields_name' => RequiredNameFieldsEnum::Given, + 'search_global_limit' => DEF_GLOBAL_SEARCH_LIMIT, + 'search_limited_models' => false // XXX to add new settings, set a default here, then add a validation rule below // also update data model documentation // 'disable_expiration' => false, @@ -209,7 +211,6 @@ public function addDefaults(int $coId): int { // 'enable_empty_cou' => false, // 'theme_stacking' => SuspendableStatusEnum::Suspended, // 'co_theme_id' => null, - // 'global_search_limit' => DEF_GLOBAL_SEARCH_LIMIT ]; // Check if we already have Settings for this CO @@ -254,7 +255,7 @@ public function typeIsDefault(int $id): bool { $orclause = []; foreach($this->getSchema()->columns() as $col) { - if(preg_match('/_default_type_id$/', $col)) { + if(preg_match('/^default_[a-z]+_type_id$/', $col)) { $orclause[] = [$col => $id]; } } @@ -273,56 +274,66 @@ public function typeIsDefault(int $id): bool { */ public function validationDefault(Validator $validator): Validator { - $validator->add('address_default_type_id', [ + $validator->add('default_address_type_id', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->allowEmptyString('address_default_type_id'); + $validator->allowEmptyString('default_address_type_id'); - $validator->add('address_required_fields', [ - 'content' => ['rule' => ['inList', RequiredAddressFieldsEnum::getConstValues()]] + $validator->add('default_email_address_type_id', [ + 'content' => ['rule' => 'isInteger'] ]); - $validator->notEmptyString('address_required_fields'); + $validator->allowEmptyString('default_email_address_type_id'); - $validator->add('email_address_default_type_id', [ + $validator->add('default_identifier_type_id', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->allowEmptyString('email_address_default_type_id'); + $validator->allowEmptyString('default_identifier_type_id'); - $validator->add('identifier_default_type_id', [ + $validator->add('default_name_type_id', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->allowEmptyString('identifier_default_type_id'); + $validator->allowEmptyString('default_name_type_id'); - $validator->add('name_default_type_id', [ + $validator->add('default_telephone_number_type_id', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->allowEmptyString('name_default_type_id'); + $validator->allowEmptyString('default_telephone_number_type_id'); - $validator->add('name_permitted_fields', [ - 'content' => ['rule' => ['inList', PermittedNameFieldsEnum::getConstValues()]] + $validator->add('default_url_type_id', [ + 'content' => ['rule' => 'isInteger'] ]); - $validator->notEmptyString('name_permitted_fields'); + $validator->allowEmptyString('default_url_type_id'); - $validator->add('name_required_fields', [ - 'content' => ['rule' => ['inList', RequiredNameFieldsEnum::getConstValues()]] + $validator->add('permitted_name_fields', [ + 'content' => ['rule' => ['inList', PermittedNameFieldsEnum::getConstValues()]] ]); - $validator->notEmptyString('name_required_fields'); + $validator->notEmptyString('permitted_name_fields'); - $validator->add('telephone_number_default_type_id', [ - 'content' => ['rule' => 'isInteger'] + $validator->add('permitted_telephone_number_fields', [ + 'content' => ['rule' => ['inList', PermittedTelephoneNumberFieldsEnum::getConstValues()]] ]); - $validator->allowEmptyString('telephone_number_default_type_id'); + $validator->notEmptyString('permitted_fields_telephone_number'); + + $validator->add('required_fields_address', [ + 'content' => ['rule' => ['inList', RequiredAddressFieldsEnum::getConstValues()]] + ]); + $validator->notEmptyString('required_fields_address'); - $validator->add('telephone_number_permitted_fields', [ - 'content' => ['rule' => ['inList', PermittedTelephoneNumberFieldsEnum::getConstValues()]] + $validator->add('required_fields_name', [ + 'content' => ['rule' => ['inList', RequiredNameFieldsEnum::getConstValues()]] ]); - $validator->notEmptyString('telephone_number_permitted_fields'); + $validator->notEmptyString('required_name_fields'); - $validator->add('url_default_type_id', [ - 'content' => ['rule' => 'isInteger'] + $validator->add('search_global_limited_models', [ + 'content' => ['rule' => ['boolean']] ]); - $validator->allowEmptyString('url_default_type_id'); + $validator->allowEmptyString('search_global_limited_models'); + $validator->add('search_global_limit', [ + 'content' => ['rule' => ['comparison', '>', 0]] + ]); + $validator->notEmptyString('search_global_limit'); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/DashboardsTable.php b/app/src/Model/Table/DashboardsTable.php index 58a9cac41..c29c2b954 100644 --- a/app/src/Model/Table/DashboardsTable.php +++ b/app/src/Model/Table/DashboardsTable.php @@ -51,7 +51,7 @@ public function initialize(array $config): void { $this->addBehavior('Timezone'); // Dashboards are configuration - $this->setIsConfigurationTable(true); + $this->setIsConfigurationTable(false); // Define associations $this->belongsTo('Cos'); @@ -60,7 +60,7 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); - $this->setAllowUnkeyedPrimaryCO(['configuration', 'dashboard']); + $this->setAllowUnkeyedPrimaryCO(['configuration', 'dashboard', 'search']); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) @@ -73,7 +73,8 @@ public function initialize(array $config): void { // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'configuration' => ['platformAdmin', 'coAdmin'], - 'dashboard' => ['coMember'] + 'dashboard' => ['coMember'], + 'search' => ['platformAdmin', 'coAdmin'] /* 'add' => ['platformAdmin', 'coAdmin'], 'index' => ['platformAdmin', 'coAdmin']*/ ] diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index bb8155764..bd296ff18 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -123,7 +123,28 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } - + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where([ + 'LOWER(EmailAddresses.mail)' => strtolower($q), + 'People.co_id' => $coId + ]) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index 4673bfd32..0ae30ef97 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -555,6 +555,42 @@ public function ruleIsNested($entity, $options) { return true; } + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause['AND'][] = [ + 'OR' => [ + 'LOWER(Groups.name) LIKE' => '%' . strtolower($t) . '%' + ] + ]; + } + + return $this->find() + ->where($whereClause) + ->andWhere(['Groups.co_id' => $coId]) + ->order(['Groups.name']) + ->limit($limit) + ->all(); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index be73002d9..a788d3556 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -144,6 +144,30 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where([ + 'Identifiers.identifier' => $q, + 'OR' => [ + 'People.co_id' => $coId, + 'Groups.co_id' => $coId + ] + ]) + ->limit($limit) + ->contain(['People' => 'PrimaryName', 'Groups']) + ->all(); + } /** * Set validation rules. diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 46219e4f5..764b3f5d2 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -243,6 +243,69 @@ public function rulePrimaryNameDelete($entity, $options) { return true; } + + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + $ret = array(); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause[1]['AND'][] = [ + 'OR' => [ + 'LOWER(Names.given) LIKE' => strtolower($t) . '%', + 'LOWER(Names.middle) LIKE' => strtolower($t) . '%', + 'LOWER(Names.family) LIKE' => strtolower($t) . '%' + ] + ]; + + $whereClause[2]['AND'][] = [ + 'OR' => [ + 'LOWER(Names.given) LIKE' => '%' . strtolower($t) . '%', + 'LOWER(Names.middle) LIKE' => '%' . strtolower($t) . '%', + 'LOWER(Names.family) LIKE' => '%' . strtolower($t) . '%' + ] + ]; + } + + $results = $this->find() + ->where($whereClause[1]) + ->andWhere(['People.co_id' => $coId]) + ->order(['Names.family', 'Names.given', 'Names.middle']) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + + if($results->count() < $limit) { + $results2 = $this->find() + ->where($whereClause[2]) + ->andWhere(['People.co_id' => $coId]) + ->order(['Names.family', 'Names.given', 'Names.middle']) + ->limit($limit - $results->count()) + ->contain(['People' => 'PrimaryName']) + ->all(); + + $results = $results->append($results2); + } + + return $results; + } /** * Set validation rules. diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index c3df8dcbf..06054310c 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -380,6 +380,44 @@ public function reconcileCouMembersGroupMemberships(\Cake\Datasource\EntityInter } } + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + // Tokenize $q on spaces + $tokens = explode(" ", $q); + + // We take two loops through, the first time we only do a prefix search + // (foo%). If that doesn't reach the search limit, we'll do an infix search + // the second time around. + + $whereClause = []; + + foreach($tokens as $t) { + $whereClause['AND'][] = [ + 'OR' => [ + 'LOWER(PersonRoles.title) LIKE' => '%' . strtolower($tokens[0]) . '%', + 'LOWER(PersonRoles.organization) LIKE' => '%' . strtolower($tokens[0]) . '%', + 'LOWER(PersonRoles.department) LIKE' => '%' . strtolower($tokens[0]) . '%' + ] + ]; + } + + return $this->find() + ->where($whereClause) + ->andWhere(['People.co_id' => $coId]) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index 65808fcfa..73e7ac8dd 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -125,6 +125,29 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where(['TelephoneNumbers.number' => $q]) + ->limit($limit) + ->contain([ + 'People' => 'PrimaryName', + 'PersonRoles' => [ + 'People' => 'PrimaryName' + ] + ]) + ->all(); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index cacb8ed38..0caa24963 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -84,7 +84,7 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); $this->hasMany('CoSettings') - ->setForeignKey('name_default_type_id'); + ->setForeignKey('default_name_type_id'); $this->hasMany('Addresses'); $this->hasMany('EmailAddresses'); $this->hasMany('Identifiers'); diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index 2539464f8..2fdbbcdd3 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -118,6 +118,24 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + /** + * Perform a keyword search. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID to constrain search to + * @param string $q String to search for + * @param int $limit Search limit + * @return Array Array of search results, as from find('all) + */ + + public function search(int $coId, string $q, int $limit) { + return $this->find() + ->where(['Urls.url' => $q]) + ->limit($limit) + ->contain(['People' => 'PrimaryName']) + ->all(); + } + /** * Set validation rules. * diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc index ddc22f67f..a7dc234fc 100644 --- a/app/templates/CoSettings/fields.inc +++ b/app/templates/CoSettings/fields.inc @@ -28,23 +28,27 @@ Field->control('address_required_fields', ['suppressBlank' => true]); + print $this->Field->control('required_fields_address', ['suppressBlank' => true]); - print $this->Field->control('address_default_type_id'); + print $this->Field->control('default_address_type_id'); - print $this->Field->control('email_address_default_type_id'); + print $this->Field->control('default_email_address_type_id'); - print $this->Field->control('identifier_default_type_id'); + print $this->Field->control('default_identifier_type_id'); - print $this->Field->control('name_default_type_id'); + print $this->Field->control('default_name_type_id'); - print $this->Field->control('name_permitted_fields', ['suppressBlank' => true]); + print $this->Field->control('permitted_fields_name', ['suppressBlank' => true]); - print $this->Field->control('name_required_fields', ['suppressBlank' => true]); + print $this->Field->control('required_fields_name', ['suppressBlank' => true]); - print $this->Field->control('telephone_number_default_type_id'); + print $this->Field->control('default_telephone_number_type_id'); - print $this->Field->control('telephone_number_permitted_fields', ['suppressBlank' => true]); + print $this->Field->control('permitted_fields_telephone_number', ['suppressBlank' => true]); - print $this->Field->control('url_default_type_id'); + print $this->Field->control('default_url_type_id'); + + print $this->Field->control('search_global_limit'); + + print $this->Field->control('search_global_limited_models'); } diff --git a/app/templates/Dashboards/search.php b/app/templates/Dashboards/search.php new file mode 100644 index 000000000..5428514ab --- /dev/null +++ b/app/templates/Dashboards/search.php @@ -0,0 +1,71 @@ +" . __d('controller', $pm, 2) . "\n"; + print "\n"; + } +} diff --git a/app/templates/element/flash/information.php b/app/templates/element/flash/information.php new file mode 100644 index 000000000..7211ef457 --- /dev/null +++ b/app/templates/element/flash/information.php @@ -0,0 +1,9 @@ + + + + Alert->alert(h($message), 'information', true, __d('information','flash.information')) ?> + \ No newline at end of file diff --git a/app/templates/element/searchGlobal.php b/app/templates/element/searchGlobal.php index 219630f52..a0d3c1fea 100644 --- a/app/templates/element/searchGlobal.php +++ b/app/templates/element/searchGlobal.php @@ -25,22 +25,17 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -$options = array( - // XXX Re-enable and adjust the following when we are ready to implement the search feature - /*'type' => 'get', - 'url' => array( - 'plugin' => null, - 'action' => 'search' - )*/ - // XXX For now, note that this feature is unimplemented - 'type' => 'get', - 'url' => array( - 'plugin' => null - ), - 'onsubmit' => 'alert("unimplemented"); return false;' -); +$options = [ + 'type' => 'post', + 'url' => [ + 'plugin' => null, + 'controller' => 'dashboards', + 'action' => 'search' + ] +]; print $this->Form->create(null, $options); +print $this->Form->hidden('co_id', ['default' => $vv_cur_co->id]); print $this->Form->label('q', __d('field','search.placeholder'), ['class' => 'visually-hidden']); print $this->Form->input('q',['id' => 'q','placeholder' => __d('field','search.placeholder')]); print $this->Form->button( diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index 0d2e3862c..1fd55cfd9 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -360,6 +360,11 @@ body.logged-in #top-menu { color: var(--cmg-color-red-006); border-color: var(--cmg-color-red-007); } +.co-alert.alert-information { + background-color: var(--cmg-color-blue-003); + color: var(--cmg-color-blue-002); + border-color: var(--cmg-color-blue-001); +} .co-alert .alert-icon { margin-right: 0.1rem; }