Skip to content

Commit

Permalink
Initial implementation of Global Search (CFM-109)
Browse files Browse the repository at this point in the history
  • Loading branch information
Benn Oshrin committed Sep 24, 2022
1 parent da76874 commit 7215e6a
Show file tree
Hide file tree
Showing 29 changed files with 738 additions and 153 deletions.
7 changes: 6 additions & 1 deletion app/config/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Inflector::rules('irregular', ['meta' => 'meta']);

/*
* Define some constants
*/
define('DEF_GLOBAL_SEARCH_LIMIT', 500);
34 changes: 18 additions & 16 deletions app/config/schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]},
Expand All @@ -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" ] }
}
},

Expand Down
39 changes: 24 additions & 15 deletions app/resources/locales/en_US/field.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions app/resources/locales/en_US/information.po
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ msgstr "Notice: "
msgid "flash.error"
msgstr "Error: "

msgid "flash.information"
msgstr "Information: "

msgid "flash.success"
msgstr "Success: "

Expand Down
22 changes: 20 additions & 2 deletions app/resources/locales/en_US/result.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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"
6 changes: 2 additions & 4 deletions app/src/Command/TransmogrifyCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
187 changes: 187 additions & 0 deletions app/src/Controller/DashboardsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'));
}
}
2 changes: 1 addition & 1 deletion app/src/Controller/MVEAController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Loading

0 comments on commit 7215e6a

Please sign in to comment.