From ea72b9f09df072526a0a743254f698d8a1427161 Mon Sep 17 00:00:00 2001 From: Arlen Johnson Date: Thu, 29 Aug 2024 09:28:08 -0400 Subject: [PATCH] CFM-411 - Autocomplete Updates (#212) * Improve People Picker URL structure * Remove extended query parameters from picker URL * Remove viewConfigParameter:for. Left join both group members and non group members. * add middle name as a filtering option * Add middle name po entry * Fix CoSettings picker configuration * Allow picker email/identifier all or 1 type * UI updates to People Picker (CFM-411) * update autocomplete library. Fix pagination. Improve isMember code changes. * disable li autocomplete option * push .disabled css class * fix keyboard accessibility for pagination * Picker mode just not require Identifiers or EmailAddresses to be linked to a Person * class for the last option element * Further UI updates to People Picker (CFM-411) * Accessibility contrast improvement for People Picker (CFM-411) --------- Co-authored-by: Ioannis Igoumenos --- app/config/routes.php | 3 + app/config/schema/schema.json | 4 +- app/resources/locales/en_US/field.po | 3 + app/resources/locales/en_US/operation.po | 3 + app/src/Controller/ApiV2Controller.php | 94 +++++++++----- app/src/Controller/AppController.php | 3 +- app/src/Lib/Traits/IndexQueryTrait.php | 58 +++++++-- app/src/Lib/Traits/QueryModificationTrait.php | 29 ++++- app/src/Lib/Traits/SearchFilterTrait.php | 2 +- app/src/Model/Table/CoSettingsTable.php | 46 +++---- app/src/Model/Table/GroupsTable.php | 2 +- app/src/Model/Table/PeopleTable.php | 17 ++- app/src/View/Helper/FieldHelper.php | 22 +++- app/templates/CoSettings/fields.inc | 7 +- app/templates/GroupMembers/columns.inc | 2 - app/templates/Standard/api/v2/json/index.php | 2 +- .../element/form/infoDiv/grouped.php | 8 +- app/templates/element/peopleAutocomplete.php | 3 +- app/templates/layout/default.php | 4 +- app/templates/layout/iframe.php | 4 +- app/webroot/css/co-base.css | 57 ++++++--- .../autocomplete/cm-autocomplete-people.js | 118 +++++++++++++++--- .../components/autocomplete/item-with-type.js | 8 +- ...js => primevue-3.53.0.autocomplete.min.js} | 2 +- ...ore.min.js => primevue-3.53.0.core.min.js} | 20 +-- 25 files changed, 393 insertions(+), 128 deletions(-) rename app/webroot/js/vue/{primevue-3.52.0.autocomplete.min.js => primevue-3.53.0.autocomplete.min.js} (76%) rename app/webroot/js/vue/{primevue-3.52.0.core.min.js => primevue-3.53.0.core.min.js} (82%) diff --git a/app/config/routes.php b/app/config/routes.php index cde13ebb2..89664c075 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -114,6 +114,9 @@ function (RouteBuilder $builder) { ['controller' => 'ApiV2', 'action' => 'generateApiKey', 'model' => 'api_users']) ->setPass(['id']) ->setPatterns(['id' => '[0-9]+']); + $builder->get( + '/people/pick', + ['controller' => 'ApiV2', 'action' => 'pick', 'model' => 'people']); // These establish the usual CRUD options on all models: $builder->delete( '/{model}/{id}', ['controller' => 'ApiV2', 'action' => 'delete']) diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 190826b33..da2844120 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -127,8 +127,8 @@ "required_fields_name": { "type": "string", "size": 160 }, "search_global_limit": { "type": "integer" }, "search_global_limited_models": { "type": "boolean" }, - "person_picker_email_type": { "type": "integer" }, - "person_picker_identifier_type": { "type": "integer" }, + "person_picker_email_address_type_id": { "type": "integer" }, + "person_picker_identifier_type_id": { "type": "integer" }, "person_picker_display_types": { "type": "boolean" } }, "indexes": { diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 2a00ffeca..d2ceb620f 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -125,6 +125,9 @@ msgstr "Item" msgid "family" msgstr "Family Name" +msgid "middle" +msgstr "Middle Name" + msgid "given" msgstr "Given Name" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 13e737791..ac1743b10 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -66,6 +66,9 @@ msgstr "Apply Database Schema" msgid "assign" msgstr "Assign" +msgid "all" +msgstr "All" + msgid "any" msgstr "Any" diff --git a/app/src/Controller/ApiV2Controller.php b/app/src/Controller/ApiV2Controller.php index 5386c46e4..add5bbb5a 100644 --- a/app/src/Controller/ApiV2Controller.php +++ b/app/src/Controller/ApiV2Controller.php @@ -136,6 +136,25 @@ public function beforeRender(\Cake\Event\EventInterface $event) { return parent::beforeRender($event); } + + /** + * Calculate the CO ID associated with the request. + * + * @since COmanage Registry v5.0.0 + * @return int CO ID, or null if no CO contextwas found + */ + + public function calculateRequestedCOID(): ?int { + if($this->request->getQuery('group_id') !== null) { + $groupId = $this->request->getQuery('group_id'); + $Group = TableRegistry::getTableLocator()->get('Groups'); + + $groupRecord = $Group->get($groupId); + return $groupRecord->co_id; + } + + return null; + } /** * Handle a delete action for a Standard object. @@ -181,7 +200,42 @@ public function delete($id) { throw new BadRequestException($this->exceptionToError($e)); } } - + + protected function dispatchIndex(string $mode = 'default') { + // There are use cases where we will pass co_id and another model_id as a query parameter. The co_id might be + // required for the primary link calculations while the foreign key for filtering. Since we are using the + // most constrained identifier to calculate the co_id, we then check if the two parameters match. If not, + // the request should fail, so as to prevent any security holes. + if($this->request->getQuery('co_id') !== null + && $this->getCOID() !== null + && (int)$this->getCOID() !== (int)$this->request->getQuery('co_id')) { + $this->llog('error', 'CO Id calculated from Group ID does not match CO Id query parameter'); + // Mask this with a generic UnauthorizedException + throw new UnauthorizedException(__d('error', 'perm')); + } + + // $modelsName = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + $reqParameters = [...$this->request->getQuery()]; + $pickerMode = ($mode === 'picker'); + + // Construct the Query + $query = $this->getIndexQuery($pickerMode, $reqParameters); + + if(method_exists($table, 'findIndexed')) { + $query = $table->findIndexed($query); + } + // This magically makes REST calls paginated... can use eg direction=, + // sort=, limit=, page= + $this->set($this->tableName, $this->paginate($query)); + + // Let the view render + $this->render('/Standard/api/v2/json/index'); + } + /** * Handle an edit action for a Standard object. * @@ -300,33 +354,7 @@ public function generateApiKey(string $id) { */ public function index() { - // $modelsName = Models - $modelsName = $this->name; - // $table = the actual table object - $table = $this->$modelsName; - - $reqParameters = []; - $pickerMode = false; - if($this->request->is('ajax')) { - $reqParameters = [...$this->request->getQuery()]; - if($this->request->getQuery('picker') !== null) { - $pickerMode = filter_var($this->request->getQuery('picker'), FILTER_VALIDATE_BOOLEAN); - } - } - - - // Construct the Query - $query = $this->getIndexQuery($pickerMode, $reqParameters); - - if(method_exists($table, 'findIndexed')) { - $query = $table->findIndexed($query); - } - // This magically makes REST calls paginated... can use eg direction=, - // sort=, limit=, page= - $this->set($this->tableName, $this->paginate($query)); - - // Let the view render - $this->render('/Standard/api/v2/json/index'); + $this->dispatchIndex(); } /** @@ -354,4 +382,14 @@ public function view($id = null) { // Let the view render $this->render('/Standard/api/v2/json/index'); } + + /** + * Pick a set of Standard Objects. + * + * @since COmanage Registry v5.0.0 + */ + + public function pick() { + $this->dispatchIndex(mode: 'picker'); + } } \ No newline at end of file diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 01fd2ab98..e190ff1ab 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -346,7 +346,8 @@ public function getPrimaryLink(bool $lookup=false) { } } - if(empty($this->cur_pl->value) && !$this->$modelsName->allowEmptyPrimaryLink()) { + if(empty($this->cur_pl->value) + && !$this->$modelsName->allowEmptyPrimaryLink($this->request->getParam('action'))) { throw new \RuntimeException(__d('error', 'primary_link')); } } diff --git a/app/src/Lib/Traits/IndexQueryTrait.php b/app/src/Lib/Traits/IndexQueryTrait.php index 8a6eb8f64..0a3e9fb76 100644 --- a/app/src/Lib/Traits/IndexQueryTrait.php +++ b/app/src/Lib/Traits/IndexQueryTrait.php @@ -57,13 +57,38 @@ public function constructGetIndexContains(Query $query): object { $containClause = $table->getIndexContains(); } - if($this->request->is('restful')|| $this->request->is('ajax')) { + if($this->request->is('restful') || $this->request->is('ajax')) { $containClause = $this->containClauseFromQueryParams(); } return empty($containClause) ? $query : $query->contain($containClause); } + /** + * Construct the Picker Contain array + * + * @param Query $query + * + * @return object Cake ORM Query object + * @since COmanage Registry v5.0.0 + */ + public function constructGetPickerContains(Query $query): object { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // Initialize the containClause + $containClause = []; + + // Get whatever the table configuration has + if(method_exists($table, 'getPickerContains') + && $table->getPickerContains()) { + $containClause = $table->getPickerContains(); + } + + return empty($containClause) ? $query : $query->contain($containClause); + } + /** * Construct the Contain Clause from the query parameters of an AJAX or REST call @@ -151,7 +176,7 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [ } // Get Associated Model Data - $query = $this->constructGetIndexContains($query); + $query = $pickerMode ? $this->constructGetPickerContains($query) : $this->constructGetIndexContains($query); // Attributes to search for if(method_exists($table, 'getSearchableAttributes')) { @@ -170,8 +195,25 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [ // Here we iterate over the attributes, and we add a new where clause for each one foreach($searchableAttributes as $attribute => $options) { + $jointype = 'INNER'; + if ( + $pickerMode + && !empty($options['model']) + && \in_array($options['model'], ['Identifiers', 'EmailAddresses'], true) + ) { + // XXX People picker is different than people filtering. A people picker has the following requirements: + // - Name is required + // - Identifiers, EmailAddresses are optional + // Having that said, we LEFT JOIN the Identifiers and EmailAddresses models instead of INNER JOIN them. + $jointype = 'LEFT'; + } // Add the Join Clauses - $query = $table->addJoins($query, $attribute, $this->request); + $query = $table->addJoins( + $query, + $attribute, + $this->request, + $jointype + ); // Construct and apply the where Clause if(!empty($this->request->getQuery($attribute))) { @@ -199,11 +241,13 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [ $query = $query->where(fn(QueryExpression $exp, Query $query) => $exp->in($table->getAlias().'.status', [StatusEnum::Active, StatusEnum::GracePeriod])); // Specific expressions per view - $query = match($requestParams['for'] ?? '') { + $query = match(true) { // GroupMembers Add view: We need to filter the active members - 'GroupMembers' => $query->leftJoinWith('GroupMembers', fn($q) => $q->where(['GroupMembers.group_id' => (int)($requestParams['groupid'] ?? -1)])) - ->where($this->getTableLocator()->get('GroupMembers')->checkValidity($query)) - ->where(fn(QueryExpression $exp, Query $query) => $exp->isNull('GroupMembers.' . StringUtilities::classNameToForeignKey($table->getAlias()))), + (isset($requestParams['group_id']) && $modelsName === 'People') => $query + ->leftJoinWith('GroupMembers', fn($q) => $q->where(['GroupMembers.group_id' => (int)($requestParams['group_id'] ?? -1)])), +// XXX We want to get both members and not members. The frontend will handle the rest +// ->where($this->getTableLocator()->get('GroupMembers')->checkValidity($query)) +// ->where(fn(QueryExpression $exp, Query $query) => $exp->isNull('GroupMembers.' . StringUtilities::classNameToForeignKey($table->getAlias()))), // Just return the query default => $query }; diff --git a/app/src/Lib/Traits/QueryModificationTrait.php b/app/src/Lib/Traits/QueryModificationTrait.php index 1aa061bcd..6670ef894 100644 --- a/app/src/Lib/Traits/QueryModificationTrait.php +++ b/app/src/Lib/Traits/QueryModificationTrait.php @@ -52,6 +52,9 @@ trait QueryModificationTrait { // Array of associated models to pull during a view private $viewContains = false; + // Array of associated models to pull during a pick action + private $pickerContains = false; + /** * Construct the checkValidity for the fields valid_from and valid_through @@ -121,7 +124,18 @@ public function getIndexContains() { public function getPatchAssociated() { return $this->patchAssociated; } - + + /** + * Obtain the set of associated models to pull during a pick. + * + * @since COmanage Registry v5.0.0 + * @return array Array of associated models + */ + + public function getPickerContains() { + return $this->pickerContains; + } + /** * Obtain the set of associated models to pull during a view. * @@ -187,7 +201,18 @@ public function setIndexFilter(array|\Closure $filter) { public function setPatchAssociated(array $a) { $this->patchAssociated = $a; } - + + /** + * Set the associated models to pull during a pick. + * + * @since COmanage Registry v5.0.0 + * @param array $c Array of associated models + */ + + public function setPickerContains(array $c) { + $this->pickerContains = $c; + } + /** * Set the associated models to pull during a view. * diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 6b266a0b8..eeaec4665 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -124,7 +124,7 @@ public function addJoins(Query $query, string $attribute, ServerRequest $request return $query->join($joinAssociations); - // XXX We can not use the inenerJoinWith since it applies EagerLoading and includes all the fields which + // XXX We can not use the innerJoinWith since it applies EagerLoading and includes all the fields which // causes problems // return $query->innerJoinWith($this->searchFilters[$attribute]['model']); } diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php index 40d93824a..33884a2f5 100644 --- a/app/src/Model/Table/CoSettingsTable.php +++ b/app/src/Model/Table/CoSettingsTable.php @@ -222,25 +222,25 @@ public function addDefaults(int $coId): int { // Default values for each setting $defaultSettings = [ - 'co_id' => $coId, - 'default_address_type_id' => null, - 'default_email_address_type_id' => null, - 'default_identifier_type_id' => null, - 'default_name_type_id' => null, - 'default_pronoun_type_id' => null, - 'default_telephone_number_type_id' => null, - 'default_url_type_id' => null, - 'email_smtp_server_id' => null, - 'email_delivery_address_type_id' => null, - 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, - 'permitted_fields_telephone_number' => PermittedTelephoneNumberFieldsEnum::CANE, - 'person_picker_email_type' => null, - 'person_picker_identifier_type' => null, - 'person_picker_display_types' => true, - 'required_fields_address' => RequiredAddressFieldsEnum::Street, - 'required_fields_name' => RequiredNameFieldsEnum::Given, - 'search_global_limit' => DEF_GLOBAL_SEARCH_LIMIT, - 'search_limited_models' => false + 'co_id' => $coId, + 'default_address_type_id' => null, + 'default_email_address_type_id' => null, + 'default_identifier_type_id' => null, + 'default_name_type_id' => null, + 'default_pronoun_type_id' => null, + 'default_telephone_number_type_id' => null, + 'default_url_type_id' => null, + 'email_smtp_server_id' => null, + 'email_delivery_address_type_id' => null, + 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, + 'permitted_fields_telephone_number' => PermittedTelephoneNumberFieldsEnum::CANE, + 'person_picker_email_address_type_id' => null, + 'person_picker_identifier_type_id' => null, + 'person_picker_display_types' => true, + '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, @@ -416,15 +416,15 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('person_picker_display_types'); - $validator->add('person_picker_email_type', [ + $validator->add('person_picker_email_address_type_id', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->allowEmptyString('person_picker_email_type'); + $validator->allowEmptyString('person_picker_email_address_type_id'); - $validator->add('person_picker_identifier_type', [ + $validator->add('person_picker_identifier_type_id', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->allowEmptyString('person_picker_identifier_type'); + $validator->allowEmptyString('person_picker_identifier_type_id'); $validator->add('required_fields_address', [ 'content' => ['rule' => ['inList', RequiredAddressFieldsEnum::getConstValues()]] diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index 3350ab7a1..f42470ee1 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -176,7 +176,7 @@ public function initialize(array $config): void { // For the Groups Filtering block we want to // pick/GET from the entire CO pool of people 'action' => 'GET', - // The co configuration will fall throught the default configuration + // The co configuration will fall through the default configuration 'for' => 'co' ] ] diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 21c449f69..aee5d57e3 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -148,6 +148,7 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setRedirectGoal('self'); $this->setAllowLookupPrimaryLink(['provision']); + $this->setAllowUnkeyedPrimaryLink(['pick']); // XXX does some of this stuff really belong in the controller? $this->setEditContains([ @@ -164,6 +165,11 @@ public function initialize(array $config): void { ]); $this->setIndexContains(['PrimaryName']); $this->setViewContains(['PrimaryName']); + $this->setPickerContains([ + 'EmailAddresses', + 'Identifiers', + 'PrimaryName', + ]); $this->setAutoViewVars([ 'statuses' => [ @@ -179,6 +185,12 @@ public function initialize(array $config): void { // XXX expand/revise this as needed to work best with looking up the related models $this->setFilterConfig([ 'family' => [ + 'type' => 'string', + 'model' => 'Names', + 'active' => true, + 'order' => 3 + ], + 'middle' => [ 'type' => 'string', 'model' => 'Names', 'active' => true, @@ -194,7 +206,7 @@ public function initialize(array $config): void { 'type' => 'string', 'model' => 'EmailAddresses', 'active' => true, - 'order' => 3 + 'order' => 5 ], 'identifier' => [ 'type' => 'string', @@ -222,7 +234,8 @@ public function initialize(array $config): void { // Actions that operate over a table (ie: do not require an $id) 'table' => [ 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'] + 'index' => ['platformAdmin', 'coAdmin'], + 'pick' => ['platformAdmin', 'coAdmin'], ], // Related models whose permissions we'll need, typically for table views 'related' => [ diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index e4095ca17..ca2fa9431 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -335,7 +335,27 @@ public function formField(string $fieldName, // if the field is required. This makes it clear when a value needs to be set. // Note this will be ignored for non-select controls. $fieldArgs['empty'] = !\in_array($fieldName, ['status', 'sync_status_on_delete'], true) - || (isset($fieldOptions['empty']) && $fieldOptions['empty']); + || (isset($fieldOptions['empty']) && !empty($fieldOptions['empty'])); + + // Check if the empty option comes with a value + if($fieldArgs['empty'] + && !empty($fieldOptions['empty']) + && \is_string($fieldOptions['empty'])) { + $fieldArgs['empty'] = $fieldOptions['empty']; + } + + if(!empty($fieldOptions['all'])) { + $optionName = lcfirst(StringUtilities::foreignKeyToClassName($fieldName)); + $optionValues = $this->getView()->get($optionName); + $optionValues = [ + '-1' => $fieldOptions['all'], + ...$optionValues + ]; + $this->getView()->set($optionName, $optionValues); + } + + // Is this a multiple select + $fieldArgs['multiple'] = !empty($fieldOptions['multiple']); // Manipulate the vv_object for the hasPrefix use case $this->handlePrefix($fieldPrefix, $fieldName); diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc index b7ff8281e..d94aa9049 100644 --- a/app/templates/CoSettings/fields.inc +++ b/app/templates/CoSettings/fields.inc @@ -105,15 +105,20 @@ if($vv_action == 'edit') { 'person_picker_email_address_type_id' => [ 'fieldOptions' => [ 'label' => __d('field', 'mail'), + 'empty' => '(' . __d('operation', 'all') . ')', +// 'all' => '(' . __d('operation', 'all') . ')' ], ], 'person_picker_identifier_type_id' => [ 'fieldOptions' => [ 'label' => __d('field', 'identifier'), + 'empty' => '(' . __d('operation', 'all') . ')', +// 'all' => '(' . __d('operation', 'all') . ')', ], ], 'person_picker_display_types' => [ - 'singleRowItem' => true + 'singleRowItem' => true, + 'fieldLabel' => __d('field', 'CoSettings.person_picker_display_types') ] ], ]]); diff --git a/app/templates/GroupMembers/columns.inc b/app/templates/GroupMembers/columns.inc index 8cef9bbc8..abd2f1c7f 100644 --- a/app/templates/GroupMembers/columns.inc +++ b/app/templates/GroupMembers/columns.inc @@ -100,8 +100,6 @@ $subnav = [ $peoplePicker = [ 'label' => __d('operation','add.member'), 'viewConfigParameters' => [ - 'for' => 'GroupMembers', - 'action' => $vv_action, 'groupId' => $this->getRequest()?->getQuery('group_id') ], 'actionUrl' => [ diff --git a/app/templates/Standard/api/v2/json/index.php b/app/templates/Standard/api/v2/json/index.php index ed6b61990..e1e1a76e9 100644 --- a/app/templates/Standard/api/v2/json/index.php +++ b/app/templates/Standard/api/v2/json/index.php @@ -32,7 +32,7 @@ 'version' => '2' ]; -if($this->request->getParam('action') == 'index') { +if(in_array($this->request->getParam('action'), ['index', 'pick'])) { $responseMeta['totalResults'] = $this->Paginator->counter('{{count}}'); $responseMeta['startIndex'] = $this->Paginator->counter('{{start}}'); $responseMeta['itemsPerPage'] = $this->Paginator->counter('{{current}}'); // confusingly this is different than ->current() diff --git a/app/templates/element/form/infoDiv/grouped.php b/app/templates/element/form/infoDiv/grouped.php index c0a89aaf7..c1114d2c9 100644 --- a/app/templates/element/form/infoDiv/grouped.php +++ b/app/templates/element/form/infoDiv/grouped.php @@ -42,10 +42,10 @@ unset($fieldArguments['singleRowItem']); } ?> -
-
- Field->formField($fieldName, ...$fieldArguments) ?> +
+
+ Field->formField($fieldName, ...$fieldArguments) ?> +
-
\ No newline at end of file diff --git a/app/templates/element/peopleAutocomplete.php b/app/templates/element/peopleAutocomplete.php index 73e690f97..7787bbf36 100644 --- a/app/templates/element/peopleAutocomplete.php +++ b/app/templates/element/peopleAutocomplete.php @@ -67,7 +67,8 @@ api: { viewConfigParameters: , webroot: 'request->getAttribute('webroot') ?>', - searchPeople: `request->getAttribute('webroot') ?>api/ajax/v2/people?co_id=id ?>&picker=on&for=` + // co_id query parameter is required since it is the People's primary link + searchPeople: `request->getAttribute('webroot') ?>api/ajax/v2/people/pick?co_id=id ?>` } } diff --git a/app/templates/layout/default.php b/app/templates/layout/default.php index fe70da0bf..3d0fc247f 100644 --- a/app/templates/layout/default.php +++ b/app/templates/layout/default.php @@ -72,8 +72,8 @@ 'bootstrap/bootstrap.bundle.min.js', 'jquery/jquery.min.js', 'vue/vue-3.2.31.global.prod.js', - 'vue/primevue-3.52.0.core.min.js', - 'vue/primevue-3.52.0.autocomplete.min.js', + 'vue/primevue-3.53.0.core.min.js', + 'vue/primevue-3.53.0.autocomplete.min.js', 'datatables/datatables-2.0.7.net.min.js', ]) . PHP_EOL ?> diff --git a/app/templates/layout/iframe.php b/app/templates/layout/iframe.php index 01967245c..cfc37ce81 100644 --- a/app/templates/layout/iframe.php +++ b/app/templates/layout/iframe.php @@ -72,8 +72,8 @@ 'bootstrap/bootstrap.bundle.min.js', 'jquery/jquery.min.js', 'vue/vue-3.2.31.global.prod.js', - 'vue/primevue-3.52.0.core.min.js', - 'vue/primevue-3.52.0.autocomplete.min.js', + 'vue/primevue-3.53.0.core.min.js', + 'vue/primevue-3.53.0.autocomplete.min.js', 'datatables/datatables-2.0.7.net.min.js', ]) . PHP_EOL ?> diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index 1660796cf..3865970c9 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -1633,37 +1633,63 @@ ul.form-list .cm-time-picker-vals li { } /* Autocomplete */ .cm-autocomplete-panel { - overflow-y: scroll; + overflow-y: auto; max-height: 50vh !important; + max-width: 800px; + border: 1px solid var(--cmg-color-bg-008); + background-color: var(--cmg-color-body-bg); + line-height: 1.75em; } .cm-autocomplete-panel ul { padding: 0; margin: 0; - background-color: var(--cmg-color-body-bg); - border: 1px solid var(--cmg-color-bg-008); } -.cm-autocomplete-panel li { +.cm-autocomplete-panel .cm-ac-item { padding: 1em; +} +.cm-autocomplete-panel li { list-style: none; border-collapse: collapse; border-bottom: 1px solid var(--cmg-color-bg-006); } -.cm-autocomplete-panel li[data-p-focus=true] { - background-color: var(--cmg-color-bg-001); - box-shadow: inset 0 0 0.5em var(--cmg-color-btn-bg-001); +.cm-autocomplete-panel li[data-p-focus="true"] { + background-color: var(--cmg-color-highlight-002); + box-shadow: inset 0 0 0.1em var(--cmg-color-bg-001); + cursor: pointer; +} +.cm-autocomplete-panel li[data-p-focus="true"] > .cm-ac-item-is-member { + background-color: var(--cmg-color-body-bg); + box-shadow: inset 0 0 0.1em var(--cmg-color-highlight-007); + cursor: default; } .cm-autocomplete-panel .cm-ac-subitems { font-size: 0.9em; margin-left: 1em; } +.cm-autocomplete-panel .disabled { + pointer-events:none; + opacity:0.65; + box-shadow: inset 0 0 0.2em var(--cmg-color-highlight-007); +} +.cm-ac-item-primary { + display: flex; + justify-content: space-between; +} +.cm-ac-name { + display: flex; + align-items: center; + gap: 1em; +} +.cm-ac-name-value { + font-weight: bold; +} +.cm-ac-item-id { + font-size: 0.9em; +} .cm-ac-subitem { display: grid; grid-template-columns: 1fr 8fr; - padding: 0.25em 0 0; -} -.cm-ac-subitem.cm-ac-email { - border-bottom: 1px solid var(--cmg-color-bg-006); - padding-bottom: 2px; + gap: 0.5em; } .cm-ac-pager a { display: block; @@ -1673,12 +1699,12 @@ ul.form-list .cm-time-picker-vals li { .cm-ac-pager a:focus, .cm-ac-pager a:hover { text-decoration: underline; + background-color: var(--cmg-color-bg-002); } .item-with-type { display: grid; grid-template-columns: 1fr 1fr; - padding: 0.1em; - border-bottom: 1px solid var(--cmg-color-bg-006); + gap: 0.5em; } .item-with-type:last-child { border-bottom: none; @@ -1694,6 +1720,9 @@ ul.form-list .cm-time-picker-vals li { background-color: var(--cmg-color-highlight-018); color: black; } +li[data-pc-section="emptymessage"] { + padding: 0.1em 0.5em; +} /* PEOPLE PICKER */ #cm-people-picker { display: flex; diff --git a/app/webroot/js/comanage/components/autocomplete/cm-autocomplete-people.js b/app/webroot/js/comanage/components/autocomplete/cm-autocomplete-people.js index e5bb29cab..990378c2e 100644 --- a/app/webroot/js/comanage/components/autocomplete/cm-autocomplete-people.js +++ b/app/webroot/js/comanage/components/autocomplete/cm-autocomplete-people.js @@ -49,7 +49,9 @@ export default { loading: false, page: 1, limit: 7, - query: null + query: null, + liItemLast: {}, + listLastPos: -1 } }, methods: { @@ -63,16 +65,13 @@ export default { const url = new URL(urlString); let queryParams = url.searchParams; // Query parameters - queryParams.append('extended', 'PrimaryName,Identifiers,EmailAddresses') queryParams.append('identifier', query) queryParams.append('mail', query) queryParams.append('given', query) + queryParams.append('middle', query) queryParams.append('family', query) if(this.api.viewConfigParameters.groupId != undefined) { - queryParams.append('groupid', this.api.viewConfigParameters.groupId) - } - if(this.api.viewConfigParameters.action != undefined) { - queryParams.append('action', this.api.viewConfigParameters.action) + queryParams.append('group_id', this.api.viewConfigParameters.groupId) } // Pagination // XXX Move this to configuration @@ -120,6 +119,16 @@ export default { this.query = event.query await this.findPeople(event.query, true) }, + calculateDisabled() { + $('.cm-autocomplete-panel').hide() + $('.cm-autocomplete-panel > ul > li').map((idx, litem) => { + if(litem.hasAttribute('data-p-disabled') + && litem?.getAttribute('data-p-disabled')?.toLowerCase() === "true") { + litem.classList.add("disabled"); + } + }) + $('.cm-autocomplete-panel').show() + }, constructEmailCsv(emailList) { const emailWithType = emailList.map( (mail) => { return mail.mail + " (" + this.app.types?.find((t) => t.id == mail.type_id)?.display_name + ")" @@ -144,17 +153,39 @@ export default { } return str }, + filterByEmailAddressType(items) { + if(this.app.cosettings[0].person_picker_email_address_type_id == null + || this.app.cosettings[0].person_picker_email_address_type_id == '') { + return items + } + + return items.filter((item) => { + return item.type_id == this.app.cosettings[0].person_picker_email_address_type_id; + }) + }, + filterByIdentifierType(items) { + if(this.app.cosettings[0].person_picker_identifier_type_id == '' + || this.app.cosettings[0].person_picker_identifier_type_id == null) { + return items + } + + return items.filter((item) => { + return item.type_id == this.app.cosettings[0].person_picker_identifier_type_id; + }) + }, parseResponse(data) { return data?.People?.map((item) => { - return { - "value": item.id, - "label": `${item?.primary_name?.given} ${item?.primary_name?.family} (ID: ${item?.id})`, - "email": item?.email_addresses, - "emailPretty": this.shortenString(this.constructEmailCsv(item?.email_addresses)), - "emailLabel": this.txt['email'] + ": ", - "identifier": item?.identifiers, - "identifierPretty": this.shortenString(this.constructIdentifierCsv(item?.identifiers)), - "identifierLabel": this.txt['Identifiers'] + ": " + return { + "value": item.id, + "label": `${item?.primary_name?.given} ${item?.primary_name?.family}`, + "itemId": `${item?.id}`, + "email": this.filterByEmailAddressType(item?.email_addresses), + "emailPretty": this.shortenString(this.constructEmailCsv(this.filterByEmailAddressType(item?.email_addresses))), + "emailLabel": this.txt['email'] + ": ", + "identifier": this.filterByIdentifierType(item?.identifiers), + "identifierPretty": this.shortenString(this.constructIdentifierCsv(this.filterByIdentifierType(item?.identifiers))), + "identifierLabel": this.txt['Identifiers'] + ": ", + "isMember": !!item?._matchingData?.GroupMembers?.id } }) }, @@ -180,12 +211,45 @@ export default { return str.substring(0,30) + '...' } return str + }, + onListNavigate(ev) { + const listItemId = ev.target.getAttribute('aria-activedescendant') + const $more = $('.cm-ac-pager')[0] + // Get the option item + const $option = $('#' + listItemId)[0]; + $('.cm-autocomplete-panel > ul > li').map((idx, litem) => { + litem.classList.remove("cm-al-last-item"); + }) + if($option == undefined) { + return + } + let listSize = $option.getAttribute('aria-setsize') + let itemPosition = $option.getAttribute('aria-posinset') + + // Handle down key. Navigate from list to footer + if(this.listLastPos == listSize + && ev.keyCode == '40' + && $more != undefined // If the footer is present, it means that the hasMorePages + // computed method has been evaluated to true + && listItemId == this.liItemLast.getAttribute('id')) { + // this.fetchMorePeople() + $('.cm-ac-pager > a')[0].click() + } + + if(itemPosition == listSize) { + // Mark as last option in the list + this.liItemLast = $option + if($more != undefined) { + $option.classList.add('cm-al-last-item') + } + } + this.listLastPos = itemPosition } }, mounted() { if(this.options.inputValue != undefined - && this.options.inputValue != '' - && this.options.htmlId == 'person_id') { + && this.options.inputValue != '' + && this.options.htmlId == 'person_id') { this.options.inputProps.value = `${this.options.formParams?.fullName} (ID: ${this.options.inputValue})` } }, @@ -221,27 +285,40 @@ export default { :placeholder="this.txt['autocomplete.people.placeholder']" panelClass="cm-autocomplete-panel" optionLabel="label" + optionDisabled="isMember" :minLength="this.options.minLength" :delay="500" loadingIcon=null :suggestions="this.people" forceSelection @complete="searchPeople" + @show="calculateDisabled" + @keyup.arrow-down="onListNavigate" + @keyup.arrow-up="onListNavigate" @item-select="setPerson">