From 67f9fed884c212cfe19b32024835984f201236c2 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 11 Oct 2023 17:39:39 +0300 Subject: [PATCH] Add filter selection UI to the top-filters on an index view (CFM-296) (#131) * Add filter selection UI to the top-filters on an index view (CFM-296) (#130) * Join Model associations when using filtering (#132) * Return distinct records * add field ordering. Fix labeling lexicals. * Small improvements to the filter rebalance styling when boolean checkboxes are available (CFM-296) * Further correction to merge conflict on PeopleTable (CFM-296) --- app/resources/locales/en_US/menu.po | 6 ++ app/src/Controller/PeopleController.php | 4 +- app/src/Controller/StandardController.php | 10 +- app/src/Lib/Traits/SearchFilterTrait.php | 100 ++++++++++++++++-- app/src/Lib/Util/StringUtilities.php | 8 +- app/src/Model/Table/PeopleTable.php | 68 ++++++++++-- app/templates/element/filter.php | 122 +++++++++++++++++----- app/templates/element/javascript.php | 29 +++++ app/webroot/css/co-base.css | 34 +++++- app/webroot/css/co-responsive.css | 23 +++- 10 files changed, 348 insertions(+), 56 deletions(-) diff --git a/app/resources/locales/en_US/menu.po b/app/resources/locales/en_US/menu.po index 60bdcfedc..72149952c 100644 --- a/app/resources/locales/en_US/menu.po +++ b/app/resources/locales/en_US/menu.po @@ -27,6 +27,9 @@ msgid "artifacts" msgstr "Available {0} Artifacts" +msgid "available.filters" +msgstr "Available Filters" + msgid "co.artifacts" msgstr "Artifacts" @@ -201,6 +204,9 @@ msgstr "User Menu" msgid "menu.toggle" msgstr "Toggle menu collapse button" +msgid "options" +msgstr "Options" + msgid "registries" msgstr "Available {0} Registries" diff --git a/app/src/Controller/PeopleController.php b/app/src/Controller/PeopleController.php index 4577075c6..5d529a590 100644 --- a/app/src/Controller/PeopleController.php +++ b/app/src/Controller/PeopleController.php @@ -32,6 +32,7 @@ // XXX not doing anything with Log yet use Cake\Log\Log; use Cake\ORM\TableRegistry; +use http\QueryString; class PeopleController extends StandardController { public $paginate = [ @@ -49,7 +50,8 @@ class PeopleController extends StandardController { 'sortableFields' => [ 'PrimaryName.given', 'PrimaryName.family' - ] + ], + 'finder' => 'indexed' ]; /** diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index d264925d6..509027140 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -546,9 +546,13 @@ public function index() { if(!empty($link->attr)) { // If a link attribute is defined but no value is provided, then query // where the link attribute is NULL - $query = $table->find()->where([$table->getAlias().'.'.$link->attr => $link->value]); + // "all" is the default finder. But since we are utilizing the paginator here, we will check the configuration + // for any custom finder. + $query = $table->find( + $this->paginate['finder'] ?? "all" + )->where([$table->getAlias().'.'.$link->attr => $link->value]); } else { - $query = $table->find(); + $query = $table->find($this->paginate['finder'] ?? "all"); } // QueryModificationTrait @@ -563,7 +567,7 @@ public function index() { if(!empty($searchableAttributes)) { // Here we iterate over the attributes, and we add a new where clause for each one - foreach(array_keys($searchableAttributes) as $attribute) { + foreach($searchableAttributes as $attribute => $options) { if(!empty($this->request->getQuery($attribute))) { $query = $table->whereFilter($query, $attribute, $this->request->getQuery($attribute)); } elseif (!empty($this->request->getQuery($attribute . "_starts_at")) diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 00d52957c..70ffd5e18 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -35,6 +35,19 @@ trait SearchFilterTrait { // Array (and configuration) of permitted search filters private $searchFilters = array(); + // Optional filter configuration that dictates display state and allows for related models + private $filterConfig = array(); + + /** + * Get explicilty defined filter configuration defined in the table class. + * + * @since COmanage Registry v5.0.0 + */ + + public function getFilterConfig(): array { + return $this->filterConfig; + } + /** * Obtain the set of permitted search attributes. * @@ -44,14 +57,45 @@ trait SearchFilterTrait { public function getSearchableAttributes(string $controller, string $vv_tz=null): array { $modelname = Inflector::classify(Inflector::underscore($controller)); + $filterConfig = $this->getFilterConfig(); + + // 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' => isset($f['active']) ? $f['active'] : true, + 'model' => $f['model'], + 'order' => $f['order'] + ]; + } + } + foreach ($this->filterMetadataFields() as $column => $type) { // If the column is an array then we are accessing the Metadata fields. Skip if(is_array($type)) { continue; } + + // Set defaults + $fieldIsActive = true; + + // Gather filter configurations, if any, for local table fields. + // An active field is visible in the filter form. An inactive field is not but can be enabled. + if(!empty($filterConfig[$column])) { + if(isset($filterConfig[$column]['active'])) { + $fieldIsActive = $filterConfig[$column]['active']; + } + } + $this->searchFilters[$column] = [ 'type' => $type, - 'label' => \App\Lib\Util\StringUtilities::columnKey($modelname, $column, $vv_tz, true) + 'label' => \App\Lib\Util\StringUtilities::columnKey($modelname, $column, $vv_tz, true), + 'active' => $fieldIsActive, + 'order' => 99 // this is the default ]; // For the date fields we search ranges @@ -63,6 +107,16 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): return $this->searchFilters ?? []; } + + /** + * Set explicilty defined filter configuration defined in the table class. + * + * @since COmanage Registry v5.0.0 + */ + + public function setFilterConfig(array $filterConfig): void { + $this->filterConfig = $filterConfig; + } /** * Build a query where() clause for the configured attribute. @@ -81,6 +135,27 @@ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string|ar return $query; } + if(isset($this->searchFilters[$attribute]['model'])) { + $changelog_fk = strtolower(Inflector::underscore($this->searchFilters[$attribute]['model'])) . '_id'; + $fk = strtolower(Inflector::underscore(Inflector::singularize($this->_alias))) . '_id'; + $mtable_name = Inflector::tableize(Inflector::pluralize($this->searchFilters[$attribute]['model'])); + $mtable_alias = Inflector::pluralize($this->searchFilters[$attribute]['model']); + $query->join([$mtable_alias => [ + 'table' => $mtable_name, + 'conditions' => [ + $mtable_alias . '.' . $fk . '=' . $this->_alias . '.id', + $mtable_alias . '.' . 'deleted IS NOT TRUE', + $mtable_alias . '.' . $changelog_fk . ' IS NULL' + ], + 'type' => 'INNER' + ]]); + } + + // Prepend the Model name to the attribute + $attributeWithModelPrefix = isset($this->searchFilters[$attribute]['model']) ? + Inflector::pluralize($this->searchFilters[$attribute]['model']) . '.' . $attribute : + $this->_alias . '.' . $attribute; + $search = $q; $sub = false; // Primitive types @@ -88,29 +163,32 @@ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string|ar if( $this->searchFilters[$attribute]['type'] == "string") { $search = "%" . $search . "%"; $sub = true; + // Search type } elseif(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { - return $query->where([$attribute => $search]); + return $query->where([$attributeWithModelPrefix => $search]); + // Date } elseif($this->searchFilters[$attribute]['type'] == "date") { // Parse the date string with FrozenTime to improve error handling - return $query->where([$attribute => FrozenTime::parseDate($search, 'y-M-d')]); + return $query->where([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]); + // Timestamp } elseif( $this->searchFilters[$attribute]['type'] == "timestamp") { // Date between dates if(!empty($search[0]) && !empty($search[1])) { - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search) { - return $exp->between($attribute, "'" . $search[0] . "'", "'" . $search[1] . "'"); + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { + return $exp->between($attributeWithModelPrefix, "'" . $search[0] . "'", "'" . $search[1] . "'"); }); // The starts at is non-empty. So the data should be greater than the starts_at date } elseif(!empty($search[0]) && empty($search[1])) { - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search) { - return $exp->gte("'" . FrozenTime::parse($search[0]) . "'", $attribute); + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { + return $exp->gte("'" . FrozenTime::parse($search[0]) . "'", $attributeWithModelPrefix); }); // The ends at is non-empty. So the data should be less than the ends at date } elseif(!empty($search[1]) && empty($search[0])) { - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search) { - return $exp->lte("'" . FrozenTime::parse($search[1]) . "'", $attribute); + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { + return $exp->lte("'" . FrozenTime::parse($search[1]) . "'", $attributeWithModelPrefix); }); } else { // We return everything @@ -120,8 +198,8 @@ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string|ar } // String values - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search, $sub) { - $lower = $query->func()->lower([$attribute => 'identifier']); + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search, $sub) { + $lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']); return ($sub) ? $exp->like($lower, strtolower($search)) : $exp->eq($lower, strtolower($search)); }); diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 0b3e47aca..945456eb9 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -57,10 +57,16 @@ public static function classNameToForeignKey(string $className): string { public static function columnKey($modelsName, $c, $tz=null, $useCustomClMdlLabel=false): string { if(strpos($c, "_id", strlen($c)-3)) { + $postfix = ""; + if($c == "parent_id") { + // This means we are working with a model that implements a Tree behavior + $postfix = " ({$modelsName})"; + } + // Key is of the form field_id, use .ct label instead $k = self::foreignKeyToClassName($c); - return __d('controller', $k, [1]); + return __d('controller', $k, [1]) . $postfix; } // Look for a model specific key first diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 7008dbe2a..02f837963 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -156,6 +156,39 @@ 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', + 'model' => 'Name', + 'active' => true, + 'order' => 2 + ], + 'given' => [ + 'type' => 'relatedModel', + 'model' => 'Name', + 'active' => true, + 'order' => 1 + ], + 'mail' => [ + 'type' => 'relatedModel', + 'model' => 'EmailAddress', + 'active' => true, + 'order' => 3 + ], + 'identifier' => [ + 'type' => 'relatedModel', + 'model' => 'Identifier', + 'active' => true, + 'order' => 4 + ], + 'timezone' => [ + 'type' => 'field', + 'active' => false, + 'order' => 99 + ] + ]); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 @@ -245,6 +278,29 @@ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $op return true; } + /** + * Customized finder for the Index Population View + * + * @param Query $query Cake ORM Query + * @param array $options Cake ORM Query options + * + * @return CakeORMQuery Cake ORM Query + * @since COmanage Registry v5.0.0 + */ + public function findIndexed(Query $query, array $options): Query { + return $query->select([ + 'People.id', + 'PrimaryName.given', + 'PrimaryName.family', + 'People.status', + 'People.created', + 'People.modified', + 'People.timezone', + 'People.date_of_birth' + ]) + ->distinct(); + } + /** * Table specific logic to generate a display field. * @@ -429,9 +485,9 @@ public function marshalProvisioningData(int $id): array { $identifiers = []; - foreach($ret['data']->identifiers as $id) { - if($id->status == SuspendableStatusEnum::Active) { - $identifiers[] = $id; + foreach($ret['data']->identifiers as $ident) { + if($ident->status == SuspendableStatusEnum::Active) { + $identifiers[] = $ident; } } @@ -454,9 +510,9 @@ public function marshalProvisioningData(int $id): array { $identifiers = []; - foreach($ret['data']->identifiers as $id) { - if($id->status == SuspendableStatusEnum::Active) { - $identifiers[] = $id; + foreach($ret['data']->identifiers as $ident) { + if($ident->status == SuspendableStatusEnum::Active) { + $identifiers[] = $ident; } } diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index c316e9e0f..f772e7648 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -121,7 +121,7 @@ + + + Form->end(); ?> + diff --git a/app/templates/element/javascript.php b/app/templates/element/javascript.php index 38efe508e..31c1fe604 100644 --- a/app/templates/element/javascript.php +++ b/app/templates/element/javascript.php @@ -149,6 +149,35 @@ $("#top-filters-toggle .top-filters-active-filter").hide(); $("#top-filters-clear").click(); }); + + // Hide and show filter fields using the Available Filters menu + $('#top-filters-form .filter-selector').click(function(e) { + e.stopPropagation(); + + // Get the checkbox and the target filter id held in the checkbox value + let cb = $(this).find('input'); + let target = $(cb).val(); + + // Toggle the active and inactive state of the target filter + if(cb.prop('checked')) { + $('#' + target).closest('.filter-inactive').removeClass('filter-inactive').addClass('filter-active'); + } else { + $('#' + target).closest('.filter-active').removeClass('filter-active').addClass('filter-inactive'); + } + + // Toggle the submit container rebalance class so long as there are no datetime pickers + if(!$('#top-filters-fields .top-filters-fields-dates').length) { + // Count the number of standard visible fields, and apply the rebalance class on an odd number + // unless boolean fields are visible - in which case we remove it. + let standardFiltersCount = $('#top-filters-fields .filter-standard.filter-active').length; + let booleanFiltersCount = $('#top-filters-fields .filter-boolean.filter-active').length; + if(standardFiltersCount % 2 == 1 && !booleanFiltersCount) { + $('#top-filters-submit').addClass("tss-rebalance"); + } else { + $('#top-filters-submit').removeClass("tss-rebalance"); + } + } + }); // Make all submit buttons pretty (Bootstrap) $("input:submit").addClass("spin submit-button btn btn-primary"); diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index 03dea60ee..090930665 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -774,7 +774,7 @@ ul.form-list li.alert-banner .co-alert { #search-results .accordion-body { padding: 1em; } -/* TOP FILTER */ +/* TOP FILTERS */ .top-filters { margin-top: 0.5em; padding: 0; @@ -819,9 +819,13 @@ ul.form-list li.alert-banner .co-alert { display: none; padding: 0.25em 0.5em; } +#top-filters-fields .filter-inactive { + display: none; +} #top-filters-submit { - float: right; - width: 200px; + display: flex; + flex-direction: row-reverse; + justify-content: right; margin-bottom: 0.75em; } .top-filters input[type=text], @@ -851,14 +855,30 @@ ul.form-list li.alert-banner .co-alert { background-color: var(--cmg-color-highlight-002); } .top-filters .submit-button, -.top-filters .clear-button { - float: right; +.top-filters .clear-button, +.top-filters .options-button { font-size: 0.9em; width: 80px; height: 28px; margin: 1em 0.5em; padding: 0; } +.top-filters .options-button { + width: 90px; +} +#top-filters-options { + font-size: 0.9em; + padding: 0 1em 0.5em 1em; +} +#top-filters-options-container .dropdown-menu { + padding: 0; +} +#top-filters-options-container h4 { + font-size: 0.9em; + background-color: var(--cmg-color-bg-005); + text-align: center; + padding: 0.4em 0; +} .top-filters .top-filters-checkboxes { margin-top: 0.5em; } @@ -901,6 +921,10 @@ ul.form-list li.alert-banner .co-alert { background-color: var(--cmg-color-bg-006); color: var(--cmg-color-btn-bg-002) !important; } +.top-filters-active-filter.btn.btn-sm:focus { + border: 1px dotted var(--cmg-color-btn-bg-002); + box-shadow: 0 0 0 .25rem rgba(13,110,253,.25); +} .top-filters-active-filter-value::before { content: ":"; } diff --git a/app/webroot/css/co-responsive.css b/app/webroot/css/co-responsive.css index c18959aeb..44e9cd472 100644 --- a/app/webroot/css/co-responsive.css +++ b/app/webroot/css/co-responsive.css @@ -323,7 +323,7 @@ .call-to-action { margin-bottom: 0; } - /* TOP SEARCH */ + /* TOP FILTERS */ .top-filters-fields-subgroups { display: grid; grid-template-columns: 1fr 1fr; @@ -331,12 +331,23 @@ padding: 0 0.5em; align-items: center; } - .top-search-checkboxes { + .top-filters-checkbox-fields { + display: flex; + } + .top-filters-checkboxes { padding: 0.5em 0 0; } + #top-filters-submit { + position: relative; + } + .top-filters .submit-button, + .top-filters .clear-button, + .top-filters .options-button { + margin: 1em 0.25em; + } #top-filters-submit.tss-rebalance { margin-top: -3.5em; - position: relative; + float: right; } /* CO CONFIGURATION DASHBOARD */ .config-menu { @@ -418,6 +429,12 @@ ul.form-list li .field { column-gap: 2em; } + /* TOP FILTERS */ + .top-filters .submit-button, + .top-filters .clear-button, + .top-filters .options-button { + margin: 1em 0.5em; + } /* DATE / TIME PICKER */ .cm-time-picker-panel { right: -250px;