From 4b3bc03c889be6d78a1502176c0b4de4d30380dd Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 23 Apr 2024 17:36:12 +0300 Subject: [PATCH] CFM-291_Groups_Cous_extra_filtering_options (#185) * Add COUs any and none custom options. Move active filter button to its own element. Define extra custom filter Configuration structure for the default fields/columns. * Refactor filtering block.Refactor FieldHelper. * add locales * refactor activeToButton element * css changes date picker * fix groupmembers tab count * further decouple filter html to elements * css fix for 1 digit nums in tab title * improvements * fix frontend timezone handling --- app/resources/locales/en_US/field.po | 6 + app/resources/locales/en_US/information.po | 5 +- app/resources/locales/en_US/operation.po | 6 + app/src/Lib/Traits/IndexQueryTrait.php | 6 +- app/src/Lib/Traits/SearchFilterTrait.php | 82 +++- app/src/Lib/Util/FunctionUtilities.php | 2 +- app/src/Model/Table/CousTable.php | 23 + app/src/Model/Table/GroupMembersTable.php | 4 +- app/src/Model/Table/GroupNestingsTable.php | 38 +- app/src/Model/Table/GroupsTable.php | 2 +- app/src/Model/Table/IdentifiersTable.php | 50 ++- app/src/Model/Table/PeopleTable.php | 9 +- app/src/View/Helper/CommonHelper.php | 56 +++ app/src/View/Helper/FieldHelper.php | 220 +++++---- app/templates/Standard/index.php | 2 +- app/templates/element/datePicker.php | 27 +- app/templates/element/filter.php | 417 ------------------ .../element/filter/activeTopButton.php | 78 ++++ .../element/filter/dateTimeFilters.php | 59 +++ app/templates/element/filter/filter.php | 246 +++++++++++ .../element/filter/filterOptions.php | 63 +++ .../element/filter/topFiltersToggle.php | 58 +++ app/templates/element/subnavigation.php | 23 +- .../element/tabs/tabTitleWithCount.php | 39 ++ app/webroot/css/co-base.css | 15 +- 25 files changed, 933 insertions(+), 603 deletions(-) create mode 100644 app/src/View/Helper/CommonHelper.php delete mode 100644 app/templates/element/filter.php create mode 100644 app/templates/element/filter/activeTopButton.php create mode 100644 app/templates/element/filter/dateTimeFilters.php create mode 100644 app/templates/element/filter/filter.php create mode 100644 app/templates/element/filter/filterOptions.php create mode 100644 app/templates/element/filter/topFiltersToggle.php create mode 100644 app/templates/element/tabs/tabTitleWithCount.php 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 @@ +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 = ''; - } else { - $controlCode = ''; - } - } else { - $controlCode = '
' . __d('information', 'notset') . '
'; + $controlCode = ''; } + // 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
  • 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
  • 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 "" . "{__d('field', 'required')}"; } + /** + * Static Not Set Div Element + * + * @return string + * @since COmanage Registry v5.0.0 + */ + + protected function notSetElement(): string + { + return '
    ' . __d('information', 'notset') . '
    '; + } + /** * 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; } ?> - element('filter', $filterArgs); ?> + element('filter/filter', $filterArgs); ?> 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: ` + + + ` }); // Add custom global directives available to all child components. @@ -92,15 +104,4 @@ app.mount("#-container"); -
    - - -
    +
    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 @@ -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; -?> - -
    -
    - - - - - - - - - $params): ?> - - - - - - - - - - -
    -
    - $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 '
    '; - print $this->Form->label($key, $label); - print '
    '; - print $this->Form->text($key, $opts) . $this->element('datePicker', $date_args); - print '
    '; - print '
    '; - } 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 '
    '; - print $this->Form->control($key, $formParams); - print '
    '; - } - } - ?> - -
    -
    - $options): ?> -
    -
    - 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 - ]); - ?> -
    -
    - -
    -
    - -
    - - -
    - $options): ?> -
    -
    - -
    -
    - -
    -
    - 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); - ?> -
    -
    - -
    -
    - 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); - ?> -
    -
    -
    -
    - -
    - - - -
    > - - 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); - ?> - - -
    - - -
    - -
    -
    -
    -
    - -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 @@ + 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'] + ) + ); + +?> + + \ 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 @@ + + +
    + $options): ?> +
    +
    + +
    +
    + +
    + Form->label("{$key}_starts_at", __d('field', 'starts_at'), ['class' => 'filter-datepicker-lbl']); + print $this->Field->dateField("{$key}_starts_at", DateTypeEnum::DateOnly, $query)['controlCode']; + ?> +
    + +
    + Form->label("{$key}_ends_at", __d('field','ends_at'), ['class' => 'filter-datepicker-lbl']); + print $this->Field->dateField("{$key}_ends_at", DateTypeEnum::DateOnly, $query)['controlCode']; + ?> +
    +
    +
    + +
    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 @@ +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; +?> + +
    +
    + + element('filter/topFiltersToggle', compact('search_params')) ?> +
    +
    + $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 '
    '; + print $this->Form->label($key, $label); + print '
    '; + print $this->Field->dateField($key, DateTypeEnum::DateOnly, $query)['controlCode']; + print '
    '; + print '
    '; + } 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 '
    '; + print $this->Form->control($key, $formParams); + print '
    '; + } + } + ?> + +
    +
    + $options): ?> +
    +
    + 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 + ]); + ?> +
    +
    + +
    +
    + +
    + + element( + 'filter/dateTimeFilters', + compact('field_datetime_columns', 'query') + ); + } + ?> + + +
    > + + 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') + ); + } + ?> +
    +
    +
    +
    + +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 @@ + + +
    + + +
    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 @@ + + + + + + + + + + + + $params) { + // We have active filters - not just a sort. + $hasActiveFilters = true; + print $this->element('filter/activeTopButton', compact('key', 'params')); + } + ?> + + + + + + + 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 @@ 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 = "" - . "{$num_group_members}" - . '' - . "{$title}"; + // 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 @@ +Common->getModelTotalCount($model, $where); +} + +?> + + + + + \ 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;