diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 53a4f4a8d..d0a500df0 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -107,6 +107,9 @@ msgstr "Email" msgid "ends_at" msgstr "Ends at:" +msgid "ends_at.tz" +msgstr "Ends at ({0})" + msgid "extension" msgstr "Extension" @@ -243,6 +246,9 @@ msgstr "Sponsor" msgid "starts_at" msgstr "Starts at:" +msgid "starts_at.tz" +msgstr "Starts at ({0})" + msgid "state" msgstr "State" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 6a05a1458..933cd91f0 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -106,4 +106,7 @@ msgid "plugin.active.only" msgstr "Active, Cannot Be Disabled" msgid "plugin.inactive" -msgstr "Inactive" \ No newline at end of file +msgstr "Inactive" + +msgid "table.list" +msgstr "{0} List" \ No newline at end of file diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 4d3f3bfb1..9127afaee 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -63,6 +63,9 @@ msgstr "Apply Database Schema" msgid "assign" msgstr "Assign" +msgid "any" +msgstr "Any" + msgid "cancel" msgstr "Cancel" @@ -144,6 +147,9 @@ msgstr "Logout" msgid "next" msgstr "Next" +msgid "none" +msgstr "None" + msgid "page.display" msgstr "Display records" diff --git a/app/src/Lib/Traits/IndexQueryTrait.php b/app/src/Lib/Traits/IndexQueryTrait.php index e94394dd2..b62d65c11 100644 --- a/app/src/Lib/Traits/IndexQueryTrait.php +++ b/app/src/Lib/Traits/IndexQueryTrait.php @@ -136,7 +136,7 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [ $link = $this->getPrimaryLink(true); // Initialize the Query Object $query = $table->find(); - // Get a pointer to my expressions list + // Get a pointer to my expression list $newexp = $query->newExpr(); // The searchable attributes can have an AND or an OR conjunction. The first one is used from the filtering block // while the second one from the picker vue module. @@ -159,6 +159,8 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [ $this->viewBuilder() ->getVar('vv_tz')); + // Pass any additional field filter confiration in the view + $this->set('vv_searchable_attributes_extras', $table->getSearchFiltersExtras()); if(!empty($searchableAttributes)) { $this->set('vv_searchable_attributes', $searchableAttributes); @@ -195,7 +197,7 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [ // Specific expressions per view $query = match($requestParams['for'] ?? '') { // 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])) + '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()))), // Just return the query diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index e8d449e66..7827e6593 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -29,6 +29,7 @@ namespace App\Lib\Traits; +use App\Lib\Util\StringUtilities; use Bake\Utility\Model\AssociationFilter; use Cake\Database\Expression\QueryExpression; use Cake\Http\ServerRequest; @@ -37,9 +38,25 @@ use Cake\I18n\FrozenTime; trait SearchFilterTrait { - // Array (and configuration) of permitted search filters + /** + * Array (and configuration) of permitted search filters + * + * @var array + */ private array $searchFilters = []; - // Optional filter configuration that dictates display state and allows for related models + + /** + * Extra Configurations for each filter + * + * @var array + */ + private array $searchFiltersExtras = []; + + /** + * Optional filter configuration that dictates display state and allows for related models + * + * @var array + */ private array $filterConfig = []; /** @@ -167,21 +184,30 @@ public function expressionsConstructor(Query $query, QueryExpression $exp, strin $attributeWithModelPrefix = $modelPrefix . '.' . $attribute; - $search = $q; - // Use the `lower` function to apply uniformity for the search - $lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']); + + // Handle special expression functions here + if(\in_array($search, ['isnull', 'isnotnull'])) { + return match($search) { + 'isnull' => $exp->isNull($attributeWithModelPrefix), + 'isnotnull' => $exp->isNotNull($attributeWithModelPrefix) + }; + } + // XXX Strings and Enums are not treated the same. Enums require an exact match but strings - // are partially/non-case sensitive matched + // are partially/non-case sensitive matched return match ($this->searchFilters[$attribute]['type']) { - 'string' => $exp->like($lower, strtolower('%' . $search . '%')), + // Use the `lower` function to apply uniformity for the search + 'string' => $exp->like($query->func()->lower([$attributeWithModelPrefix => 'identifier']), + strtolower('%' . $search . '%')), 'integer', 'boolean', 'parent' => $exp->add([$attributeWithModelPrefix => $search]), 'date' => $exp->add([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]), 'timestamp' => $this->constructDateComparisonClause($exp, $attributeWithModelPrefix, $search), - default => $exp->eq($lower, strtolower($search)) + default => $exp->eq($query->func()->lower([$attributeWithModelPrefix => 'identifier']), + strtolower($search)) }; } @@ -209,16 +235,24 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): // Gather up related models defined in the $filterConfig // XXX For now, we'll list these first - but we should probably provide a better way to order these. foreach ($filterConfig as $field => $f) { - if($f['type'] == 'relatedModel') { - $fieldName = Inflector::classify(Inflector::underscore($field)); - $this->searchFilters[$field] = [ - 'type' => 'string', // XXX for now - this needs to be looked up. - 'label' => \App\Lib\Util\StringUtilities::columnKey($fieldName, $field, $vv_tz, true), - 'active' => $f['active'] ?? true, - 'model' => $f['model'], - 'order' => $f['order'] - ]; + $fieldName = Inflector::classify(Inflector::underscore($field)); + + if(isset($f['extras'])) { + $this->searchFiltersExtras[$field] = $f['extras']; + continue; } + + $filterType = $f['type'] ?? 'string'; + if(\in_array($f['type'], ['isNull', 'isNotNull'])) { + $filterType = 'boolean'; + } + $this->searchFilters[$field] = [ + 'type' => $filterType, + 'label' => $f['label'] ?? StringUtilities::columnKey($fieldName, $field, $vv_tz, true), + 'active' => $f['active'] ?? true, + 'model' => $f['model'], + 'order' => $f['order'] + ]; } foreach ($this->filterMetadataFields() as $column => $type) { @@ -239,7 +273,7 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): $attribute = [ 'type' => $type, - 'label' => \App\Lib\Util\StringUtilities::columnKey($modelname, $column, $vv_tz, true), + 'label' => StringUtilities::columnKey($modelname, $column, $vv_tz, true), 'active' => $fieldIsActive, 'order' => 99 // this is the default ]; @@ -270,7 +304,7 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): } /** - * Set explicilty defined filter configuration defined in the table class. + * Set explicit defined filter configuration defined in the table class. * * @since COmanage Registry v5.0.0 */ @@ -279,4 +313,14 @@ public function setFilterConfig(array $filterConfig): void { $this->filterConfig = $filterConfig; } + /** + * Get field extra configurations calculated in getSearchableAttributes + * + * @since COmanage Registry v5.0.0 + */ + public function getSearchFiltersExtras(): array + { + return $this->searchFiltersExtras; + } + } diff --git a/app/src/Lib/Util/FunctionUtilities.php b/app/src/Lib/Util/FunctionUtilities.php index cb7728d8b..8ed32710d 100644 --- a/app/src/Lib/Util/FunctionUtilities.php +++ b/app/src/Lib/Util/FunctionUtilities.php @@ -43,7 +43,7 @@ class FunctionUtilities { * // Chain of methods * 'getRequest', * 'getQuery' => [ - * // parameter name => parameter value, We are taking advantage the named parameters feature + * // parameter name => parameter value, We are taking advantage of the named parameters feature * 'name' =>'group_id' * ], * ] diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index 78dada52d..ee4d59b21 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -113,6 +113,29 @@ public function initialize(array $config): void { 'index' => ['platformAdmin', 'coAdmin'] ] ]); + + $this->setFilterConfig([ + 'identifier' => [ + 'type' => 'string', + 'model' => 'Identifiers', + 'active' => true, + 'order' => 4 + ], + 'parent_id' => [ + // We want to keep the default column configuration and add extra functionality. + // Here the extra functionality is additional to select options since the parent_id + // is of type select + // XXX If the extras key is present, no other provided key will be evaluated. The rest + // of the configuration will be expected from the TableMetaTrait::filterMetadataFields() + 'extras' => [ + 'options' => [ + 'isnotnull' => __d('operation','any'), + 'isnull' => __d('operation','none'), + __d('information','table.list', 'COUs') => '@DATA@', + ] + ] + ] + ]); } /** diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php index a35fdd5ef..3aaca7a71 100644 --- a/app/src/Model/Table/GroupMembersTable.php +++ b/app/src/Model/Table/GroupMembersTable.php @@ -131,13 +131,13 @@ public function initialize(array $config): void { $this->setFilterConfig([ 'family' => [ - 'type' => 'relatedModel', + 'type' => 'string', 'model' => 'People.Names', 'active' => true, 'order' => 2 ], 'given' => [ - 'type' => 'relatedModel', + 'type' => 'string', 'model' => 'People.Names', 'active' => true, 'order' => 1 diff --git a/app/src/Model/Table/GroupNestingsTable.php b/app/src/Model/Table/GroupNestingsTable.php index 26e76f323..0fdda7cb2 100644 --- a/app/src/Model/Table/GroupNestingsTable.php +++ b/app/src/Model/Table/GroupNestingsTable.php @@ -96,24 +96,26 @@ public function initialize(array $config): void { ] ]); - $this->setAutoViewVars([ - 'groupMembers' => [ - 'type' => 'auxiliary', - 'model' => 'GroupMembers', - 'whereEval' => [ - // Where Clause column name - 'GroupMembers.group_id' => [ - // Chain of methods that will construct the whereClause condition value - // Method that accepts no parameters - 'getRequest', - // Method that accepts only one parameter - // getQuery(name: 'group_id') - 'getQuery' => [ - 'name' =>'group_id' - ] - ] - ] - ]]); + // XXX Keeping for functionality reference +// $this->setAutoViewVars([ +// 'groupMembers' => [ +// 'type' => 'auxiliary', +// 'model' => 'GroupMembers', +// 'whereEval' => [ +// // Where Clause column name +// 'GroupMembers.group_id' => [ +// // Chain of methods that will construct the whereClause condition value +// // Method that accepts no parameters +// 'getRequest', +// // Method that accepts only one parameter +// // getQuery(name: 'group_id') +// 'getQuery' => [ +// 'name' =>'group_id' +// ] +// ] +// ] +// ]]); + } /** diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index 339693d10..a91ea9e38 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -150,7 +150,7 @@ public function initialize(array $config): void { $this->setFilterConfig([ 'identifier' => [ - 'type' => 'relatedModel', + 'type' => 'string', 'model' => 'Identifiers', 'active' => true, 'order' => 4 diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 881bd1587..ecaa2f28d 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -30,6 +30,7 @@ namespace App\Model\Table; use Cake\Event\EventInterface; +use Cake\ORM\Query; use Cake\ORM\Table; use Cake\Validation\Validator; use \App\Lib\Enum\SuspendableStatusEnum; @@ -191,24 +192,43 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour /** * Look up a Person ID from an identifier and identifier type ID. * Only active Identifiers can be used for lookups. - * - * @since COmanage Registry v5.0.0 - * @param int $typeId Identifier Type ID - * @param string $identifier Identifier + * + * @param int $typeId Identifier Type ID + * @param string $identifier Identifier + * @param int|null $coId CO Id + * @param bool $login The identifier is login enabled + * * @return int Person ID - * @throws Cake\Datasource\Exception\RecordNotFoundException + * @since COmanage Registry v5.0.0 */ - public function lookupPerson(int $typeId, string $identifier): int { - $id = $this->find() - ->where([ - 'identifier' => $identifier, - 'type_id' => $typeId, - 'status' => SuspendableStatusEnum::Active, - 'person_id IS NOT NULL' - ]) - ->firstOrFail(); - + public function lookupPerson(int $typeId, string $identifier, ?int $coId, bool $login=false): int { + $whereClause = [ + 'identifier' => $identifier, + 'status' => SuspendableStatusEnum::Active, + 'person_id IS NOT NULL' + ]; + + if($typeId) { + $whereClause['type_id'] = $typeId; + } + + if($login) { + $whereClause['login'] = true; + } + + $query = $this->find() + ->where($whereClause); + + if($coId) { + $query->matching( + 'People', + fn(QueryExpression $exp, Query $query) => $query->where(['People.co_id' => $coId]) + ); + } + + $id = $query->firstOrFail(); + return $id->person_id; } diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index e39220db6..39de20376 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -164,31 +164,32 @@ 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' => 'relatedModel', + 'type' => 'string', 'model' => 'Names', 'active' => true, 'order' => 2 ], 'given' => [ - 'type' => 'relatedModel', + 'type' => 'string', 'model' => 'Names', 'active' => true, 'order' => 1 ], 'mail' => [ - 'type' => 'relatedModel', + 'type' => 'string', 'model' => 'EmailAddresses', 'active' => true, 'order' => 3 ], 'identifier' => [ - 'type' => 'relatedModel', + 'type' => 'string', 'model' => 'Identifiers', 'active' => true, 'order' => 4 ], 'timezone' => [ 'type' => 'field', + 'model' => 'People', 'active' => false, 'order' => 99 ] diff --git a/app/src/View/Helper/CommonHelper.php b/app/src/View/Helper/CommonHelper.php new file mode 100644 index 000000000..e7660bf88 --- /dev/null +++ b/app/src/View/Helper/CommonHelper.php @@ -0,0 +1,56 @@ +<?php +/** + * COmanage Registry Common Helper + * + * Portions licensed to the University Corporation for Advanced Internet + * Development, Inc. ("UCAID") under one or more contributor license agreements. + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * UCAID licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @link https://www.internet2.edu/comanage COmanage Project + * @package registry + * @since COmanage Registry v5.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + */ + +declare(strict_types = 1); + +namespace App\View\Helper; + +use Cake\ORM\TableRegistry; +use Cake\Utility\Inflector; +use Cake\View\Helper; + +class CommonHelper extends Helper +{ + /** + * Select count(*) + * + * @param string $modelName Model name in `group_members` format + * @param array $whereClause where clause array + * + * @return int + */ + public function getModelTotalCount(string $modelName, array $whereClause): int + { + $modelsName = Inflector::camelize($modelName); + $ModelTable = TableRegistry::getTableLocator()->get($modelsName); + $count = $ModelTable->find() + ->where($whereClause) + ->count(); + + return $count; + } +} \ No newline at end of file diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 87bec56ae..1b26ac583 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -167,108 +167,137 @@ public function control(string $fieldName, $this->formInfoDiv($controlCode, $beforeField, $afterField) ) . $this->endLine(); } - + /** * Emit a date/time form control. * This is a wrapper function for $this->control() * - * @since COmanage Registry v5.0.0 - * @param string $fieldName Form field - * @param string $dateType Standard, DateOnly, FromTime, ThroughTime - * + * @param string $fieldName Form field + * @param string $dateType Standard, DateOnly, FromTime, ThroughTime + * @param array|null $queryParams Request Query parameters used by the filtering Blocks to get the date values + * * @return string HTML for control + * @since COmanage Registry v5.0.0 */ - public function dateControl(string $fieldName, string $dateType=DateTypeEnum::Standard): string { + public function dateControl(string $fieldName, string $dateType=DateTypeEnum::Standard, array $queryParams = null): string + { + $dateFieldConfig = $this->dateField($fieldName, $dateType, $queryParams); + return $this->control(fieldName: $fieldName, + options: $dateFieldConfig['coptions'], + ctrlCode: $dateFieldConfig['controlCode'], + cssClass: $dateFieldConfig['cssClass'], + labelIsTextOnly: $dateFieldConfig['labelIsTextOnly']); + + } + + /** + * Emit a date/time form control. + * This is a wrapper function for $this->control() + * + * @param string $fieldName Form field + * @param string $dateType Standard, DateOnly, FromTime, ThroughTime + * @param array|null $queryParams Request Query parameters used by the filtering Blocks to get the date values + * + * @return array HTML for control + * @since COmanage Registry v5.0.0 + */ + + public function dateField(string $fieldName, string $dateType=DateTypeEnum::Standard, array $queryParams = null): array + { + // Initialize + $dateFormat = $dateType === DateTypeEnum::DateOnly ? 'yyyy-MM-dd' : 'yyyy-MM-dd HH:mm:ss'; + $dateTitle = $dateType === DateTypeEnum::DateOnly ? 'datepicker.enterDate' : 'datepicker.enterDateTime'; + $datePattern = $dateType === DateTypeEnum::DateOnly ? '\d{4}-\d{2}-\d{2}' : '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'; + $date_object = null; + + if(isset($queryParams)) { + if(!empty($queryParams[$fieldName])) { + $date_object = FrozenTime::parse($queryParams[$fieldName]); + } + } else { + // This is an entity view. We are getting the data from the object + $entity = $this->getView()->get('vv_obj'); + $date_object = $entity->$fieldName; + } + + // Create the options array for the (text input) form control + $coptions = []; + + // A datetime field will be rendered as a plain text input with adjacent date and time pickers + // that will interact with the field value. Allowing direct access to the input field is for + // accessibility purposes. + + // ACTION VIEW if($this->action == 'view') { // return the date as plaintext - $coptions = []; - $entity = $this->getView()->get('vv_obj'); - if (!empty($entity->$fieldName)) { + $controlCode = $this->notSetElement(); + if ($date_object !== null) { // Adjust the time back to the user's timezone - if ($dateType == DateTypeEnum::DateOnly) { - $controlCode = '<time>' . $entity->$fieldName->i18nFormat("yyyy-MM-dd", $this->getView()->get('vv_tz')) . '</time>'; - } else { - $controlCode = '<time>' . $entity->$fieldName->i18nFormat("yyyy-MM-dd HH:mm:ss", $this->getView()->get('vv_tz')) . '</time>'; - } - } else { - $controlCode = '<div class="not-set">' . __d('information', 'notset') . '</div>'; + $controlCode = '<time>' . $date_object->i18nFormat($dateFormat) . '</time>'; } + // Return this to the generic control() function + return ['controlCode' => $controlCode, + 'coptions' => [], + 'cssClass' => '', + 'labelIsTextOnly' => true]; return $this->control($fieldName, $coptions, ctrlCode: $controlCode, labelIsTextOnly: true); - - } else { - // A datetime field will be rendered as a plain text input with adjacent date and time pickers - // that will interact with the field value. Allowing direct access to the input field is for - // accessibility purposes. - - $pickerType = $dateType; - // Special-case the very common "valid_from" and "valid_through" fields so we won't need - // to specify their types in fields.inc. - if ($fieldName == 'valid_from') { - $pickerType = DateTypeEnum::FromTime; - } - if ($fieldName == 'valid_through') { - $pickerType = DateTypeEnum::ThroughTime; - } - - // Append the timezone to the label -- TODO: see that the timezone gets output to the display - $label = __d('field', $fieldName . ".tz", [$this->_View->get('vv_tz')]); - - // Create the options array for the (text input) form control - $coptions = []; - $coptions['class'] = 'form-control datepicker'; - - if ($pickerType == DateTypeEnum::DateOnly) { - $coptions['placeholder'] = 'YYYY-MM-DD'; - $coptions['pattern'] = '\d{4}-\d{2}-\d{2}'; - $coptions['title'] = __d('field', 'datepicker.enterDate'); - } else { - $coptions['placeholder'] = 'YYYY-MM-DD HH:MM:SS'; - $coptions['pattern'] = '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'; - $coptions['title'] = __d('field', 'datepicker.enterDateTime'); - } - $coptions['id'] = str_replace("_", "-", $fieldName); - - $entity = $this->getView()->get('vv_obj'); - - // Default the picker date to today - $now = FrozenTime::now(); - $pickerDate = $now->i18nFormat('yyyy-MM-dd'); - - // Get the existing values, if present - if(!empty($entity->$fieldName)) { - // Adjust the time back to the user's timezone - if($pickerType == DateTypeEnum::DateOnly) { - $coptions['value'] = $entity->$fieldName->i18nFormat("yyyy-MM-dd", $this->getView()->get('vv_tz')); - } else { - $coptions['value'] = $entity->$fieldName->i18nFormat("yyyy-MM-dd HH:mm:ss", $this->getView()->get('vv_tz')); - } - $pickerDate = $entity->$fieldName->i18nFormat("yyyy-MM-dd", $this->getView()->get('vv_tz')); - } - - // Set the date picker floor year value (-100 years) - $pickerDateFT = new FrozenTime($pickerDate); - $pickerDateFT = $pickerDateFT->subYears(100); - $pickerFloor = $pickerDateFT->i18nFormat("yyyy-MM-dd"); - - $date_picker_args = [ - 'fieldName' => $fieldName, - 'pickerDate' => $pickerDate, - 'pickerType' => $pickerType, - 'pickerFloor' => $pickerFloor - ]; - - // Create a text field to hold our value and call the datePicker - $controlCode = $this->Form->text($fieldName, $coptions) - . $this->getView()->element('datePicker', $date_picker_args); - - // Specify a class on the <li> form control wrapper - $liClass = "fields-datepicker"; - - // Pass everything to the generic control() function - return $this->control($fieldName, $coptions, ctrlCode: $controlCode, cssClass: $liClass); } + + // Special-case the very common "valid_from" and "valid_through" fields, so we won't need + // to specify their types in fields.inc. + $pickerType = match ($fieldName) { + 'valid_from' => DateTypeEnum::FromTime, + 'valid_through' => DateTypeEnum::ThroughTime, + default => $dateType + }; + + // Append the timezone to the label + $coptions['class'] = 'form-control datepicker'; + $coptions['placeholder'] = $dateFormat; + if(!empty($label)) { + $coptions['label'] = $label; + } + $coptions['pattern'] = $datePattern; + $coptions['title'] = __d('field', $dateTitle); + + $coptions['id'] = str_replace('_', '-', $fieldName); + + + // Default the picker date to today + $now = FrozenTime::now(); + $pickerDate = $now->i18nFormat($dateFormat); + + // Get the existing values, if present + if($date_object !== null) { + // Adjust the time back to the user's timezone + $coptions['value'] = $date_object->i18nFormat($dateFormat); + $pickerDate = $date_object->i18nFormat($dateFormat); + } + + // Set the date picker floor year value (-100 years)() + $pickerDateFT = new FrozenTime($pickerDate); + $pickerDateFT = $pickerDateFT->subYears(100); + $pickerFloor = $pickerDateFT->i18nFormat($dateFormat); + + $date_picker_args = [ + 'fieldName' => $fieldName, + 'pickerDate' => $pickerDate, + 'pickerType' => $pickerType, + 'pickerFloor' => $pickerFloor, + ]; + + // Create a text field to hold our value and call the datePicker + $controlCode = $this->Form->text($fieldName, $coptions) . $this->getView()->element('datePicker', $date_picker_args); + + // Specify a class on the <li> form control wrapper + $liClass = 'fields-datepicker'; + // Pass everything to the generic control() function + return ['controlCode' => $controlCode, + 'coptions' => $coptions, + 'labelIsTextOnly' => false, + 'cssClass' => $liClass]; } /** @@ -667,11 +696,24 @@ public function peopleAutocompleteControl(string $fieldName, array $viewConfigPa * @return string * @since COmanage Registry v5.0.0 */ - protected function requiredSpanElement() { + protected function requiredSpanElement(): string + { return "<span class='required' aria-hidden='true'>*</span>" . "<span class='visually-hidden'>{__d('field', 'required')}</span>"; } + /** + * Static Not Set Div Element + * + * @return string + * @since COmanage Registry v5.0.0 + */ + + protected function notSetElement(): string + { + return '<div class="not-set">' . __d('information', 'notset') . '</div>'; + } + /** * Emit a source control for an MVEA that has a source_foo_id field pointing * to an External Identity attribute. diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 783ffa70e..a262d8d08 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -180,7 +180,7 @@ $filterArgs['indexColumns'] = $indexColumns; } ?> - <?= $this->element('filter', $filterArgs); ?> + <?= $this->element('filter/filter', $filterArgs); ?> <?php endif; ?> <!-- Index table --> diff --git a/app/templates/element/datePicker.php b/app/templates/element/datePicker.php index 7d25f5d20..6eda46363 100644 --- a/app/templates/element/datePicker.php +++ b/app/templates/element/datePicker.php @@ -70,7 +70,19 @@ }, components: { CmDateTimePicker - } + }, + template: ` + <cm-date-time-picker + :id="id" + :target="target" + :date="date" + :datemin="datemin" + :datemax="datemax" + :type="type" + :ampm="ampm" + :txt="txt"> + </cm-date-time-picker> + ` }); // Add custom global directives available to all child components. @@ -92,15 +104,4 @@ app.mount("#<?= $pickerId ?>-container"); </script> -<div id="<?= $pickerId ?>-container"> - <cm-date-time-picker - :id="id" - :target="target" - :date="date" - :datemin="datemin" - :datemax="datemax" - :type="type" - :ampm="ampm" - :txt="txt"> - </cm-date-time-picker> -</div> +<div id="<?= $pickerId ?>-container" class="datepicker-container"></div> diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php deleted file mode 100644 index d7a6226d9..000000000 --- a/app/templates/element/filter.php +++ /dev/null @@ -1,417 +0,0 @@ -<?php -/** - * COmanage Registry Filter Element - for index view filtering - * - * Portions licensed to the University Corporation for Advanced Internet - * Development, Inc. ("UCAID") under one or more contributor license agreements. - * See the NOTICE file distributed with this work for additional information - * regarding copyright ownership. - * - * UCAID licenses this file to you under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with the - * License. You may obtain a copy of the License at: - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * @link https://www.internet2.edu/comanage COmanage Project - * @package registry - * @since COmanage Registry v5.0.0 - * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) - */ -use Cake\Collection\Collection; -use Cake\Utility\Inflector; - - -// $this->name = Models -$modelsName = $this->name; -// $modelName = Model -$modelName = Inflector::singularize($modelsName); -// $columns = the passed parameter $indexColumns as found in columns.inc; provides overrides for labels and sorting. -$columns = $indexColumns; - -// Get the query string and separate the search params from the non-search params -$query = $this->request->getQueryParams(); -// Search attributes collection -$search_attributes_collection = new Collection($vv_searchable_attributes); -$alias_params = $search_attributes_collection->filter(fn ($val, $attr) => (is_array($val) && array_key_exists('alias', $val)) ) - ->extract('alias') - ->unfold() - ->toArray(); -// For the non search params we need to search the alias params as well -$searchable_parameters = [ - ...array_keys($vv_searchable_attributes), - ...$alias_params - ]; -$non_search_params = (new Collection($query))->filter( fn($value, $key) => !in_array($key, $searchable_parameters) ) - ->toArray(); - -// Filter the search params and take params with aliases into consideration -$search_params = []; -foreach ($vv_searchable_attributes as $attr => $value) { - if(isset($query[$attr])) { - $search_params[$attr] = $query[$attr]; - continue; - } - - if(isset($value['alias']) - && is_array($value['alias'])) { - foreach ($value['alias'] as $alias_key) { - if(isset($query[$alias_key])) { - $search_params[$attr][$alias_key] = $query[$alias_key]; - } - } - } -} - -// Begin the form -print $this->Form->create(null, [ - 'id' => 'top-filters-form', - 'type' => 'get' -]); - -// Pass back the non-search params as hidden fields, but always exclude the page parameter -// because we need to start new searches on page one (or we're likely to end up with a 404). -if(!empty($non_search_params)) { - foreach($non_search_params as $param => $value) { - if($param != 'page') { - print $this->Form->hidden(filter_var($param, FILTER_SANITIZE_SPECIAL_CHARS), array('default' => filter_var($value, FILTER_SANITIZE_SPECIAL_CHARS))) . "\n"; - } - } -} - -// Boolean to distinguish between search filters and sort parameters -$hasActiveFilters = false; -?> - -<div id="<?= $modelName . ucfirst($this->request->getParam('action')) ?>Search" class="top-filters"> - <fieldset> - <legend id="top-filters-toggle"> - <em class="material-icons top-filters-search-icon" aria-hidden="true">search</em> - <span class="top-filters-title"> - <?= __d('operation', 'filter'); ?> - </span> - - <?php if(!empty($search_params)):?> - <span id="top-filters-active-filters"> - <?php foreach($search_params as $key => $params): ?> - <?php - // Construct aria-controls string - $aria_controls = $key; - // We save the name of the id into a dataset variable, data-identifier. This is an easy way - // to store the correct identifier in the case of dates. Dates have two search fields for each column - // which makes it more complicated to keep track of the id. - $data_identifier = is_array($params) ? implode(':', array_keys($params)) : $key; - - // We have active filters - not just a sort. - $hasActiveFilters = true; - - // The populated variables are in plural while the column names are singular - // Convention: It is a prerequisite that the vvar should be the plural of the column name - $populated_vvar = lcfirst(Inflector::pluralize(Inflector::camelize($key))); - $button_label = isset($$populated_vvar) ? - $$populated_vvar[ $search_params[$key] ] : - (is_array($search_params[$key]) ? 'Range' : $search_params[$key]); - ?> - <button class="top-filters-active-filter deletebutton spin btn btn-default btn-sm" data-identifier="<?= $data_identifier ?>" type="button" aria-controls="<?php print $aria_controls; ?>" title="<?= __d('operation', 'clear.filters',[2]); ?>"> - <em class="material-icons" aria-hidden="true">cancel</em> - <span class="top-filters-active-filter-title"> - <?= Inflector::humanize(Inflector::underscore($vv_searchable_attributes[$key]['label'] ?? $columns[$key]['label'])) ?> - </span> - <?php if($vv_searchable_attributes[$key]['type'] != 'boolean'): ?> - <span class="top-filters-active-filter-value"> - <?= filter_var($button_label, FILTER_SANITIZE_SPECIAL_CHARS); ?> - </span> - <?php endif; ?> - </button> - <?php endforeach; ?> - <?php if($hasActiveFilters): ?> - <button id="top-filters-clear-all-button" class="filter-clear-all-button spin btn" type="button" aria-controls="top-filters-clear" onclick="event.stopPropagation()"> - <?= __d('operation', 'clear.filters',[2]); ?> - </button> - <?php endif; ?> - </span> - <?php endif; ?> - <button class="cm-toggle nospin" aria-expanded="false" aria-controls="top-filters-fields" type="button"><em class="material-icons drop-arrow">arrow_drop_down</em></button> - </legend> - <div id="top-filters-fields"> - <div class="top-filters-fields-subgroups"> - <?php - $field_booleans_columns = []; - $field_datetime_columns = []; - - $inactiveFiltersCount = 0; // for re-balancing the columns and submit buttons - - foreach($vv_searchable_attributes as $key => $options) { - if($options['type'] == 'boolean') { - $field_booleans_columns[$key] = $options; - continue; - } elseif ($options['type'] == 'timestamp') { - $field_datetime_columns[$key] = $options; - continue; - } - - $label = Inflector::humanize( - Inflector::underscore( - $options['label'] ?? $columns[$key]['label'] - ) - ); - - if($options['type'] == 'date') { - // Date fields use a date picker (e.g. DOB) - // (Note that timestamps are handled specially. See below.) - $opts = []; - $opts['type'] = 'text'; // date inputs must be text for accessibility reasons for now. - $opts['required'] = false; - $opts['pattern'] = '\d{4}-\d{2}-\d{2}'; - $opts['placeholder'] = 'YYYY-MM-DD'; - $opts['title'] = __d('field','datepicker.enterDate'); - $opts['class'] = 'form-control datepicker'; - $opts['id'] = str_replace("_", "-", $key); - - $date = \Cake\I18n\FrozenTime::parseDate(date("Y-m-d"),'yyyy-MM-dd'); - if(!empty($query[$key])) { - $date = \Cake\I18n\FrozenTime::parseDate($query[$key],'yyyy-MM-dd'); - $opts['value'] = $date->i18nFormat("yyyy-MM-dd"); - } - $pickerDate = $date->i18nFormat("yyyy-MM-dd"); - $pickerFloor = $date->subYears(100)->i18nFormat("yyyy-MM-dd"); - - $date_args = [ - 'fieldName' => $key, - 'pickerDate' => $pickerDate, - 'pickerType' => \App\Lib\Enum\DateTypeEnum::DateOnly, - 'pickerFloor' => $pickerFloor - ]; - - $wrapperCssClass = 'filter-active'; - if(empty($options['active'])) { - $wrapperCssClass = 'filter-inactive'; - $inactiveFiltersCount++; - } - - // Create a text field to hold our date value. - print '<div class="top-filters-fields-date filter-standard ' . $wrapperCssClass . '">'; - print $this->Form->label($key, $label); - print '<div class="d-flex">'; - print $this->Form->text($key, $opts) . $this->element('datePicker', $date_args); - print '</div>'; - print '</div>'; - } else { - // text input - $formParams = [ - 'label' => $label, - // The default type is text, but we might convert to select below - 'type' => 'text', - 'value' => (!empty($query[$key]) ? $query[$key] : ''), - 'required' => false, - 'class' => 'form-control' - ]; - } - - // The populated variables are in plural while the column names are singular - // Convention: It is a prerequisite that the vvar should be the plural of the column name - $populated_vvar = lcfirst(Inflector::pluralize(Inflector::camelize($key))); - if(isset($$populated_vvar)) { - // If we have an AutoViewVar matching the name of this key, - // convert to a select - $formParams['type'] = 'select'; - $formParams['options'] = $$populated_vvar; - // Allow empty so a filter doesn't require (eg) SOR - $formParams['empty'] = true; - } - - $wrapperCssClass = 'filter-active'; - if(empty($options['active'])) { - $wrapperCssClass = 'filter-inactive'; - $inactiveFiltersCount++; - } - - if($options['type'] != 'date') { - print '<div class="filter-standard ' . $wrapperCssClass . '">'; - print $this->Form->control($key, $formParams); - print '</div>'; - } - } - ?> - <?php if(!empty($field_booleans_columns)): ?> - <div class="top-filters-checkboxes input"> - <div class="top-filters-checkbox-fields"> - <?php foreach($field_booleans_columns as $key => $options): ?> - <div class="filter-boolean <?= empty($options['active']) ? 'filter-inactive' : 'filter-active' ?>"> - <div class="form-check form-check-inline"> - <?php - print $this->Form->label($options['label'] ?? $key); - print $this->Form->checkbox($key, [ - 'id' => str_replace("_", "-", $key), - 'class' => 'form-check-input', - 'checked' => $query[$key] ?? 0, - 'hiddenField' => false, - 'required' => false - ]); - ?> - </div> - </div> - <?php endforeach; ?> - </div> - </div> - <?php endif; ?> - </div> - - <?php if(!empty($field_datetime_columns)): ?> - <div class="top-filters-fields-subgroups"> - <?php foreach($field_datetime_columns as $key => $options): ?> - <div class="input"> - <div class="top-search-date-label"> - <?= !empty($columns[$key]['label']) ? $columns[$key]['label'] : Inflector::humanize($key) ?> - </div> - <div class="top-filters-fields-dates"> - <!-- Start at --> - <div class="top-search-start-date"> - <div class="d-flex"> - <?php - // A datetime field will be rendered as plain text input with adjacent date and time pickers - // that will interact with the field value. Allowing direct access to the input field is for - // accessibility purposes. - $starts_field = $key . "_starts_at"; - $coptions = []; - $coptions['class'] = 'form-control datepicker'; - $coptions['label'] = __d('field','starts_at'); - $coptions['required'] = false; - $coptions['placeholder'] = ''; -// $coptions['placeholder'] = 'YYYY-MM-DD HH:MM:SS'; - $coptions['pattern'] = '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'; - $coptions['title'] = __d('field','datepicker.enterDateTime'); - $coptions['id'] = str_replace("_", "-", $starts_field); - - $pickerDate = ''; - if(!empty($query[$starts_field])) { - $starts_date = \Cake\I18n\FrozenTime::parse($query[$starts_field]); - // Adjust the time back to the user's timezone - $coptions['value'] = $starts_date->i18nFormat("yyyy-MM-dd HH:mm:ss", $this->get('vv_tz')); - $pickerDate = $starts_date->i18nFormat("yyyy-MM-dd", $this->get('vv_tz')); - } - - $date_args = [ - 'fieldName' => $starts_field, - 'pickerDate' => $pickerDate - ]; - // Create a text field to hold our value. - print $this->Form->label($starts_field, __d('field','starts_at'), ['class' => 'filter-datepicker-lbl']); - print $this->Form->text($starts_field, $coptions) . $this->element('datePicker', $date_args); - ?> - </div> - </div> - <!-- Ends at --> - <div class="top-search-end-date"> - <div class="d-flex"> - <?php - // A datetime field will be rendered as plain text input with adjacent date and time pickers - // that will interact with the field value. Allowing direct access to the input field is for - // accessibility purposes. - $ends_field = $key . "_ends_at"; - $coptions = []; - $coptions['class'] = 'form-control datepicker'; - $coptions['required'] = false; - $coptions['placeholder'] = ''; // todo: Make this configurable -// $coptions['placeholder'] = 'YYYY-MM-DD HH:MM:SS'; - $coptions['pattern'] = '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'; - $coptions['title'] = __d('field','datepicker.enterDateTime'); - $coptions['label'] = __d('field','ends_at'); - $coptions['id'] = str_replace("_", "-", $ends_field); - - $pickerDate = ''; - if(!empty($query[$ends_field])) { - // Adjust the time back to the user's timezone - $ends_date = \Cake\I18n\FrozenTime::parse($query[$ends_field]); - $coptions['value'] = $ends_date->i18nFormat("yyyy-MM-dd HH:mm:ss", $this->get('vv_tz')); - $pickerDate = $ends_date->i18nFormat("yyyy-MM-dd", $this->get('vv_tz')); - } - - $date_args = [ - 'fieldName' => $ends_field, - 'pickerDate' => $pickerDate - ]; - // Create a text field to hold our value. - print $this->Form->label($ends_field, __d('field','ends_at'), ['class' => 'filter-datepicker-lbl']); - print $this->Form->text($ends_field, $coptions) . $this->element('datePicker', $date_args); - ?> - </div> - </div> - </div> - </div> - <?php endforeach; ?> - </div> - <?php endif; ?> - - <?php $rebalanceColumns = (((count($vv_searchable_attributes) - $inactiveFiltersCount) % 2 == 1) && empty($field_booleans_columns)) ? ' class="tss-rebalance"' : ''; ?> - <div id="top-filters-submit"<?php print $rebalanceColumns ?>> - - <?php - // Order of the submit buttons is important here: the Enter key will submit the first (and we want the tab order to follow suit). - // We reverse the visual order of all these buttons with CSS (flex-direction: row-reverse;). - - // search button (submit) - $args = array(); - $args['id'] = 'top-filters-filter-button'; - $args['aria-label'] = __d('operation', 'filter'); - $args['class'] = 'submit-button spin btn btn-primary'; - print $this->Form->submit(__d('operation', 'filter'),$args); - - // clear button - $args = array(); - $args['id'] = 'top-filters-clear'; - $args['class'] = 'clear-button spin btn btn-default'; - $args['aria-label'] = __d('operation', 'clear'); - $args['onclick'] = 'clearTopSearch(this.form)'; - print $this->Form->button(__d('operation', 'clear'),$args); - ?> - - <?php if(!empty($vv_searchable_attributes)): ?> - <div id="top-filters-options-container"> - <button id="top-filters-options-button" class="btn btn-default options-button dropdown-toggle" - type="button" data-bs-toggle="dropdown" aria-expanded="false"> - <?= __d('menu', 'options') ?> - </button> - <div class="dropdown-menu dropdown-menu-lg-end" aria-labelledby="top-filters-options-button"> - <h4><?= __d('menu','available.filters') ?></h4> - <div id="top-filters-options"> - <?php foreach($vv_searchable_attributes as $key => $options): ?> - <?php if($options['type'] == 'timestamp' || $options['type'] == 'boolean') continue; // skip timestamp types and put booleans at the bottom of the list ?> - <div class="form-check filter-selector filter-selector-text"> - <input class="form-check-input" - type="checkbox" - value="<?= Cake\Utility\Inflector::dasherize($key) ?>" - id="filter-selector-<?= $key ?>"<?= !empty($options['active']) ? ' checked' : '' ?>> - <label class="form-check-label" for="filter-selector-<?= $key ?>"> - <?= $options['label'] ?> - </label> - </div> - <?php endforeach; ?> - <?php foreach($field_booleans_columns as $key => $options): ?> - <div class="form-check filter-selector filter-selector-boolean"> - <input class="form-check-input" - type="checkbox" - value="<?= Cake\Utility\Inflector::dasherize($key) ?>" - id="filter-selector-<?= $key ?>"<?= !empty($options['active']) ? ' checked' : '' ?>> - <label class="form-check-label" for="filter-selector-<?= $key ?>"> - <?= $options['label'] ?> - </label> - </div> - <?php endforeach; ?> - </div> - </div> - </div> - <?php endif; ?> - </div> - </div> - </fieldset> -</div> - -<?= $this->Form->end(); ?> - diff --git a/app/templates/element/filter/activeTopButton.php b/app/templates/element/filter/activeTopButton.php new file mode 100644 index 000000000..2c3895d95 --- /dev/null +++ b/app/templates/element/filter/activeTopButton.php @@ -0,0 +1,78 @@ +<?php +/** + * COmanage Registry Active Top Button Element + * + * Portions licensed to the University Corporation for Advanced Internet + * Development, Inc. ("UCAID") under one or more contributor license agreements. + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * UCAID licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @link https://www.internet2.edu/comanage COmanage Project + * @package registry + * @since COmanage Registry v5.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + */ + +use Cake\Utility\{Inflector, Hash}; + +// Construct aria-controls string +$aria_controls = $key; +// We save the name of the id into a dataset variable, data-identifier. This is an easy way +// to store the correct identifier in the case of dates. Dates have two search fields for each column +// which makes it more complicated to keep track of the id. +$data_identifier = is_array($params) ? implode(':', array_keys($params)) : $key; + +// The populated variables are in plural while the column names are singular +// Convention: It is a prerequisite that the vvar should be the plural of the column name +$populated_vvar = lcfirst(Inflector::pluralize(Inflector::camelize($key))); +$button_label = 'Range'; +if(isset($$populated_vvar) && isset($$populated_vvar[$params])) { + $button_label = $$populated_vvar[$params]; +} elseif(!is_array($params)) { + $button_label = $params; + if(isset($vv_searchable_attributes_extras)) { + $flattenedSearchableAttributesExtras = Hash::flatten($vv_searchable_attributes_extras); + $filteredFlattenedSearchableAttributesExtras = array_filter( + $flattenedSearchableAttributesExtras, + static fn($flKey) => str_contains($flKey, $params), + ARRAY_FILTER_USE_KEY + ); + if(!empty($filteredFlattenedSearchableAttributesExtras)) { + $button_label = array_pop($filteredFlattenedSearchableAttributesExtras); + } + } +} + +$filter_title = + Inflector::humanize( + Inflector::underscore( + $vv_searchable_attributes[$key]['label'] ?? $columns[$key]['label'] + ) + ); + +?> + +<button class="top-filters-active-filter deletebutton spin btn btn-default btn-sm" + data-identifier="<?= $data_identifier ?>" + type="button" aria-controls="<?= $aria_controls ?>" + title="<?= __d('operation', 'clear.filters',[2]) ?>"> + <em class="material-icons" aria-hidden="true">cancel</em> + <span class="top-filters-active-filter-title"><?= $filter_title ?></span> + <?php if($vv_searchable_attributes[$key]['type'] != 'boolean'): ?> + <span class="top-filters-active-filter-value"> + <?= filter_var($button_label, FILTER_SANITIZE_SPECIAL_CHARS) ?> + </span> + <?php endif; ?> +</button> \ No newline at end of file diff --git a/app/templates/element/filter/dateTimeFilters.php b/app/templates/element/filter/dateTimeFilters.php new file mode 100644 index 000000000..4c7440dba --- /dev/null +++ b/app/templates/element/filter/dateTimeFilters.php @@ -0,0 +1,59 @@ +<?php +/** + * COmanage Registry Filter Element - for index view filtering + * + * Portions licensed to the University Corporation for Advanced Internet + * Development, Inc. ("UCAID") under one or more contributor license agreements. + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * UCAID licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @link https://www.internet2.edu/comanage COmanage Project + * @package registry + * @since COmanage Registry v5.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + */ + +use App\Lib\Enum\DateTypeEnum; +use Cake\Utility\Inflector; + +?> + +<div class="top-filters-fields-subgroups"> + <?php foreach($field_datetime_columns as $key => $options): ?> + <div class="input"> + <div class="top-search-date-label"> + <?= !empty($columns[$key]['label']) ? $columns[$key]['label'] : Inflector::humanize($key) ?> + </div> + <div class="top-filters-fields-dates"> + <!-- Start at --> + <div class="top-search-start-date"> + <?php + // Create a text field to hold our value. + print $this->Form->label("{$key}_starts_at", __d('field', 'starts_at'), ['class' => 'filter-datepicker-lbl']); + print $this->Field->dateField("{$key}_starts_at", DateTypeEnum::DateOnly, $query)['controlCode']; + ?> + </div> + <!-- Ends at --> + <div class="top-search-end-date"> + <?php + // Create a text field to hold our value. + print $this->Form->label("{$key}_ends_at", __d('field','ends_at'), ['class' => 'filter-datepicker-lbl']); + print $this->Field->dateField("{$key}_ends_at", DateTypeEnum::DateOnly, $query)['controlCode']; + ?> + </div> + </div> + </div> + <?php endforeach; ?> +</div> diff --git a/app/templates/element/filter/filter.php b/app/templates/element/filter/filter.php new file mode 100644 index 000000000..ca534135b --- /dev/null +++ b/app/templates/element/filter/filter.php @@ -0,0 +1,246 @@ +<?php +/** + * COmanage Registry Filter Element - for index view filtering + * + * Portions licensed to the University Corporation for Advanced Internet + * Development, Inc. ("UCAID") under one or more contributor license agreements. + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * UCAID licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @link https://www.internet2.edu/comanage COmanage Project + * @package registry + * @since COmanage Registry v5.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + */ +use Cake\Collection\Collection; +use Cake\Utility\{Inflector, Hash}; +use App\Lib\Enum\DateTypeEnum; + + +// $this->name = Models +$modelsName = $this->name; +// $modelName = Model +$modelName = Inflector::singularize($modelsName); +// $columns = the passed parameter $indexColumns as found in columns.inc; provides overrides for labels and sorting. +$columns = $indexColumns; + +// Get the query string and separate the search params from the non-search params +$query = $this->request->getQueryParams(); +// Search attributes collection +$search_attributes_collection = new Collection($vv_searchable_attributes); +$alias_params = $search_attributes_collection->filter(fn ($val, $attr) => (is_array($val) && array_key_exists('alias', $val)) ) + ->extract('alias') + ->unfold() + ->toArray(); +// For the non search params we need to search the alias params as well +$searchable_parameters = [ + ...array_keys($vv_searchable_attributes), + ...$alias_params + ]; +$non_search_params = (new Collection($query))->filter( fn($value, $key) => !in_array($key, $searchable_parameters) ) + ->toArray(); + +// Filter the search params and take params with aliases into consideration +$search_params = []; +foreach ($vv_searchable_attributes as $attr => $value) { + if(isset($query[$attr])) { + $search_params[$attr] = $query[$attr]; + continue; + } + + if(isset($value['alias']) + && is_array($value['alias'])) { + foreach ($value['alias'] as $alias_key) { + if(isset($query[$alias_key])) { + $search_params[$attr][$alias_key] = $query[$alias_key]; + } + } + } +} + +// Begin the form +print $this->Form->create(null, [ + 'id' => 'top-filters-form', + 'type' => 'get' +]); + +// Pass back the non-search params as hidden fields, but always exclude the page parameter +// because we need to start new searches on page one (or we're likely to end up with a 404). +if(!empty($non_search_params)) { + foreach($non_search_params as $param => $value) { + if($param != 'page') { + print $this->Form->hidden(filter_var($param, FILTER_SANITIZE_SPECIAL_CHARS), array('default' => filter_var($value, FILTER_SANITIZE_SPECIAL_CHARS))) . "\n"; + } + } +} + +// Boolean to distinguish between search filters and sort parameters +$hasActiveFilters = false; +?> + +<div id="<?= $modelName . ucfirst($this->request->getParam('action')) ?>Search" class="top-filters"> + <fieldset> + <!-- Top Filters toggle legend --> + <?= $this->element('filter/topFiltersToggle', compact('search_params')) ?> + <div id="top-filters-fields"> + <div class="top-filters-fields-subgroups"> + <?php + $field_booleans_columns = []; + $field_datetime_columns = []; + + $inactiveFiltersCount = 0; // for re-balancing the columns and submit buttons + + foreach($vv_searchable_attributes as $key => $options) { + if($options['type'] == 'boolean') { + $field_booleans_columns[$key] = $options; + continue; + } elseif ($options['type'] == 'timestamp') { + $field_datetime_columns[$key] = $options; + continue; + } + + $wrapperCssClass = 'filter-active'; + if(empty($options['active'])) { + $wrapperCssClass = 'filter-inactive'; + $inactiveFiltersCount++; + } + + + $label = Inflector::humanize( + Inflector::underscore( + $options['label'] ?? $columns[$key]['label'] + ) + ); + + if($options['type'] == 'date') { + // Create a text field to hold our date value. + print '<div class="top-filters-fields-date filter-standard ' . $wrapperCssClass . '">'; + print $this->Form->label($key, $label); + print '<div class="d-flex">'; + print $this->Field->dateField($key, DateTypeEnum::DateOnly, $query)['controlCode']; + print '</div>'; + print '</div>'; + } else { + // text input + $formParams = [ + 'label' => $label, + // The default type is text, but we might convert to select below + 'type' => 'text', + 'value' => (!empty($query[$key]) ? $query[$key] : ''), + 'required' => false, + 'class' => 'form-control' + ]; + } + + // The populated variables are in plural while the column names are singular + // Convention: It is a prerequisite that the vvar should be the plural of the column name + $populated_vvar = lcfirst(Inflector::pluralize(Inflector::camelize($key))); + if(isset($$populated_vvar)) { + // If we have an AutoViewVar matching the name of this key, + // convert to a select + $formParams['type'] = 'select'; + $formParams['options'] = $$populated_vvar; + if(isset($vv_searchable_attributes_extras[$key]['options'])) { + // Flatten the custom options + $customOptionsFlattened = Hash::flatten($vv_searchable_attributes_extras[$key]['options']); + // Get the key of the place holder string + $dataKey = array_search('@DATA@', $customOptionsFlattened, true); + if($dataKey !== false) { + $customOptionsFlattened[$dataKey] = $formParams['options']; + $formParams['options'] = Hash::expand($customOptionsFlattened); + } + } + // Allow empty so a filter doesn't require (eg) SOR + $formParams['empty'] = true; + } + + if($options['type'] != 'date') { + print '<div class="filter-standard ' . $wrapperCssClass . '">'; + print $this->Form->control($key, $formParams); + print '</div>'; + } + } + ?> + <?php if(!empty($field_booleans_columns)): ?> + <div class="top-filters-checkboxes input"> + <div class="top-filters-checkbox-fields"> + <?php foreach($field_booleans_columns as $key => $options): ?> + <div class="filter-boolean <?= empty($options['active']) ? 'filter-inactive' : 'filter-active' ?>"> + <div class="form-check form-check-inline"> + <?php + print $this->Form->label($options['label'] ?? $key); + print $this->Form->checkbox($key, [ + 'id' => str_replace("_", "-", $key), + 'class' => 'form-check-input', + 'checked' => $query[$key] ?? 0, + 'hiddenField' => false, + 'required' => false + ]); + ?> + </div> + </div> + <?php endforeach; ?> + </div> + </div> + <?php endif; ?> + </div> + + <?php + // Date Time filtering block + if (!empty($field_datetime_columns)) { + print $this->element( + 'filter/dateTimeFilters', + compact('field_datetime_columns', 'query') + ); + } + ?> + + <?php $rebalanceColumns = (((count($vv_searchable_attributes) - $inactiveFiltersCount) % 2 == 1) && empty($field_booleans_columns)) ? ' class="tss-rebalance"' : ''; ?> + <div id="top-filters-submit"<?= $rebalanceColumns ?>> + + <?php + // Order of the submitted buttons is important here: the Enter key will submit the first (and we want the tab order to follow suit). + // We reverse the visual order of all these buttons with CSS (flex-direction: row-reverse;). + + // search button (submit) + $args = array(); + $args['id'] = 'top-filters-filter-button'; + $args['aria-label'] = __d('operation', 'filter'); + $args['class'] = 'submit-button spin btn btn-primary'; + print $this->Form->submit(__d('operation', 'filter'),$args); + + // clear button + $args = array(); + $args['id'] = 'top-filters-clear'; + $args['class'] = 'clear-button spin btn btn-default'; + $args['aria-label'] = __d('operation', 'clear'); + $args['onclick'] = 'clearTopSearch(this.form)'; + print $this->Form->button(__d('operation', 'clear'),$args); + + // Options dropdown list + if(!empty($vv_searchable_attributes)) { + print $this->element( + 'filter/filterOptions', + compact('vv_searchable_attributes', 'field_booleans_columns') + ); + } + ?> + </div> + </div> + </fieldset> +</div> + +<?= $this->Form->end(); ?> + diff --git a/app/templates/element/filter/filterOptions.php b/app/templates/element/filter/filterOptions.php new file mode 100644 index 000000000..327f456db --- /dev/null +++ b/app/templates/element/filter/filterOptions.php @@ -0,0 +1,63 @@ +<?php +/** + * COmanage Registry Filter Options + * + * Portions licensed to the University Corporation for Advanced Internet + * Development, Inc. ("UCAID") under one or more contributor license agreements. + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * UCAID licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @link https://www.internet2.edu/comanage COmanage Project + * @package registry + * @since COmanage Registry v5.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + */ + +?> + +<div id="top-filters-options-container"> + <button id="top-filters-options-button" class="btn btn-default options-button dropdown-toggle" + type="button" data-bs-toggle="dropdown" aria-expanded="false"> + <?= __d('menu', 'options') ?> + </button> + <div class="dropdown-menu dropdown-menu-lg-end" aria-labelledby="top-filters-options-button"> + <h4><?= __d('menu','available.filters') ?></h4> + <div id="top-filters-options"> + <?php foreach($vv_searchable_attributes as $key => $options): ?> + <?php if($options['type'] == 'timestamp' || $options['type'] == 'boolean') continue; // skip timestamp types and put booleans at the bottom of the list ?> + <div class="form-check filter-selector filter-selector-text"> + <input class="form-check-input" + type="checkbox" + value="<?= Cake\Utility\Inflector::dasherize($key) ?>" + id="filter-selector-<?= $key ?>"<?= !empty($options['active']) ? ' checked' : '' ?>> + <label class="form-check-label" for="filter-selector-<?= $key ?>"> + <?= $options['label'] ?> + </label> + </div> + <?php endforeach; ?> + <?php foreach($field_booleans_columns as $key => $options): ?> + <div class="form-check filter-selector filter-selector-boolean"> + <input class="form-check-input" + type="checkbox" + value="<?= Cake\Utility\Inflector::dasherize($key) ?>" + id="filter-selector-<?= $key ?>"<?= !empty($options['active']) ? ' checked' : '' ?>> + <label class="form-check-label" for="filter-selector-<?= $key ?>"> + <?= $options['label'] ?> + </label> + </div> + <?php endforeach; ?> + </div> + </div> +</div> diff --git a/app/templates/element/filter/topFiltersToggle.php b/app/templates/element/filter/topFiltersToggle.php new file mode 100644 index 000000000..442b474e4 --- /dev/null +++ b/app/templates/element/filter/topFiltersToggle.php @@ -0,0 +1,58 @@ +<?php +/** + * COmanage Registry Top Filters Toggle Element + * + * Portions licensed to the University Corporation for Advanced Internet + * Development, Inc. ("UCAID") under one or more contributor license agreements. + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * UCAID licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @link https://www.internet2.edu/comanage COmanage Project + * @package registry + * @since COmanage Registry v5.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + */ + +// $search_params passed as a parameter + +$hasActiveFilters = false; + +?> + + +<legend id="top-filters-toggle"> + <em class="material-icons top-filters-search-icon" aria-hidden="true">search</em> + <span class="top-filters-title"> + <?= __d('operation', 'filter'); ?> + </span> + + <?php if(!empty($search_params)):?> + <span id="top-filters-active-filters"> + <?php + foreach($search_params as $key => $params) { + // We have active filters - not just a sort. + $hasActiveFilters = true; + print $this->element('filter/activeTopButton', compact('key', 'params')); + } + ?> + <?php if($hasActiveFilters): ?> + <button id="top-filters-clear-all-button" class="filter-clear-all-button spin btn" type="button" aria-controls="top-filters-clear" onclick="event.stopPropagation()"> + <?= __d('operation', 'clear.filters',[2]); ?> + </button> + <?php endif; ?> + </span> + <?php endif; ?> + <button class="cm-toggle nospin" aria-expanded="false" aria-controls="top-filters-fields" type="button"><em class="material-icons drop-arrow">arrow_drop_down</em></button> +</legend> diff --git a/app/templates/element/subnavigation.php b/app/templates/element/subnavigation.php index 0455fb437..22f3461c2 100644 --- a/app/templates/element/subnavigation.php +++ b/app/templates/element/subnavigation.php @@ -302,25 +302,16 @@ <?php $linkClass = ($active == 'members') ? 'nav-link active' : 'nav-link'; $title = __d('controller', 'Members', [99]); - $num_group_members = 0; - // Group Members tab - if(isset($group_members)) { - $num_group_members = $this->Paginator->counter('{{count}}'); - } elseif(isset($groupMembers)) { - // Group Nesting - $num_group_members = count($groupMembers); - } elseif($vv_obj?->group_members) { - // Group Properties Tab - $num_group_members = count($vv_obj->group_members); - } - $tab_title = "<span class='tab-count'>" - . "<span class='tab-count-item'>{$num_group_members}</span>" - . '</span>' - . "<span class='tab-title'>{$title}</span>"; + // Create the title with the count badge + $title = $this->element('tabs/tabTitleWithCount', [ + 'title' => $title, + 'model' => 'group_members', + 'where' => $linkFilter + ]); print $this->Html->link( - $tab_title, + $title, [ 'controller' => 'group_members', 'action' => 'index', '?' => $linkFilter diff --git a/app/templates/element/tabs/tabTitleWithCount.php b/app/templates/element/tabs/tabTitleWithCount.php new file mode 100644 index 000000000..07845fa6c --- /dev/null +++ b/app/templates/element/tabs/tabTitleWithCount.php @@ -0,0 +1,39 @@ +<?php +/** + * COmanage Registry Tab Title With count Element + * + * Portions licensed to the University Corporation for Advanced Internet + * Development, Inc. ("UCAID") under one or more contributor license agreements. + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * UCAID licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @link https://www.internet2.edu/comanage COmanage Project + * @package registry + * @since COmanage Registry v5.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * + * + */ + +if(empty($num)) { + $num = $this->Common->getModelTotalCount($model, $where); +} + +?> + +<span class='tab-count'> + <span class='tab-count-item'><?= $num ?></span> +</span> +<span class='tab-title'><?= $title ?></span> \ No newline at end of file diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index 0165ae67a..cb07ae8fa 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -716,6 +716,8 @@ ul.form-list li.alert-banner .co-alert { font-size: 0.9em; border: 1px solid var(--cmg-color-link); border-radius: 16px; + min-width: 24px; + text-align: center; } .tab-count-item { display: inline-block; @@ -855,7 +857,6 @@ ul.form-list li.alert-banner .co-alert { .side-search select { width: 100%; box-sizing: border-box; - margin: 0 0 0.5em 0; height: 28px; padding: 2px 4px; border: 1px solid var(--cmg-color-bg-006); @@ -865,6 +866,12 @@ ul.form-list li.alert-banner .co-alert { .top-filters label { margin-bottom: 0; } +.top-search-end-date, +.top-search-start-date { + display: flex; + margin-bottom: 0.5em; + align-items: center; +} ::-webkit-input-placeholder, ::-moz-placeholder, :-ms-input-placeholder, @@ -964,9 +971,6 @@ ul.form-list li.alert-banner .co-alert { white-space: nowrap; margin-right: 0.5em; } -.top-filters-fields-date .duet-date__toggle { - margin-top: -3px; -} /* PLATFORM NOTICE (for COmanage CO) */ #platform-notice { padding: 0.5em; @@ -1448,6 +1452,9 @@ ul.form-list li.info-title { align-items: center; margin-left: 2.8em; } +.datepicker-container { + display: flex; +} .duet-date__toggle { width: 30px; height: 30px;