From b95c469079b7a4edd0e434c7b843fef8bbba897c Mon Sep 17 00:00:00 2001 From: arlen <arlen@dogenmedia.com> Date: Wed, 22 Sep 2021 14:09:33 -0400 Subject: [PATCH] Matchgrid Records: implement filtering/search on index view (CO-1871) --- app/src/Locale/en_US/default.po | 21 +++ app/src/Template/Element/javascript.ctp | 44 +++++ app/src/Template/Element/search.ctp | 178 ++++++++++++++++++ app/src/Template/MatchgridRecords/columns.inc | 28 ++- app/src/Template/Standard/index.ctp | 16 +- app/webroot/css/co-base.css | 129 +++++++++++++ app/webroot/css/co-responsive.css | 11 ++ app/webroot/js/comanage.js | 10 + 8 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 app/src/Template/Element/search.ctp diff --git a/app/src/Locale/en_US/default.po b/app/src/Locale/en_US/default.po index a081e416..c2fe138a 100644 --- a/app/src/Locale/en_US/default.po +++ b/app/src/Locale/en_US/default.po @@ -324,6 +324,9 @@ msgstr "Requested Reference ID must already be in use, or be the keyword 'new'" msgid "match.fd.action" msgstr "Action" +msgid "match.fd.all" +msgstr "All" + msgid "match.fd.alphanumeric" msgstr "Alphanumeric" @@ -462,9 +465,15 @@ msgstr "(Please select a value)" msgid "match.fd.sor" msgstr "System of Record" +msgid "match.fd.sor.abbr" +msgstr "SOR" + msgid "match.fd.sorid" msgstr "System of Record ID" +msgid "match.fd.sorid.abbr" +msgstr "SOR ID" + msgid "match.fd.status" msgstr "Status" @@ -502,6 +511,9 @@ msgstr "There are no matchgrids currently defined." msgid "match.in.pagination.format" msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "match.in.records.none" +msgstr "No records to display" + ### Operations (Commands) msgid "match.op.add.a" msgstr "Add New {0}" @@ -518,6 +530,12 @@ msgstr "Build" msgid "match.op.build.confirm" msgstr "Are you sure you wish to (re)build this matchgrid?" +msgid "match.op.clear" +msgstr "Clear" + +msgid "match.op.clear.filters" +msgstr "{0,plural,=1{Clear Filter} other{Clear Filters}}" + msgid "match.op.configure.a" msgstr "Configure {0}" @@ -545,6 +563,9 @@ msgstr "Edit {0}" msgid "match.op.first" msgstr "First" +msgid "match.op.filter" +msgstr "Filter" + msgid "match.op.go" msgstr "Go" diff --git a/app/src/Template/Element/javascript.ctp b/app/src/Template/Element/javascript.ctp index ab33a6fb..f02d7f71 100644 --- a/app/src/Template/Element/javascript.ctp +++ b/app/src/Template/Element/javascript.ctp @@ -118,6 +118,50 @@ $("#user-panel").hide(); } }); + + // TOP SEARCH FILTER FORM + // Send only non-empty fields in the form + $("#top-search-form").submit(function() { + $("#top-search-form *").filter(':input').each(function () { + if($(this).val() == '') { + $(this).prop('disabled',true); + } + }); + }); + + // Toggle the top search filter box + $("#top-search-toggle, #top-search-toggle button.cm-toggle").click(function(e) { + e.preventDefault(); + e.stopPropagation(); + if ($("#top-search-fields").is(":visible")) { + $("#top-search-fields").hide(); + $("#top-search-toggle button.cm-toggle").attr("aria-expanded","false"); + $("#top-search-toggle button.cm-toggle .drop-arrow").text("arrow_drop_down"); + } else { + $("#top-search-fields").show(); + $("#top-search-toggle button.cm-toggle").attr("aria-expanded","true"); + $("#top-search-toggle button.cm-toggle .drop-arrow").text("arrow_drop_up"); + } + }); + + // Clear a specific top search filter by clicking the filter button + $("#top-search-toggle button.top-search-active-filter").click(function(e) { + e.preventDefault(); + e.stopPropagation(); + $(this).hide(); + filterId = '#' + $(this).attr("aria-controls"); + $(filterId).val(""); + $(this).closest('form').submit(); + }); + + // Clear all top filters from the filter bar + $("#top-search-clear-all-button").click(function(e) { + e.preventDefault(); + e.stopPropagation(); + $(this).hide(); + $("#top-search-toggle .top-search-active-filter").hide(); + $("#top-search-clear").click(); + }); // Accordion $(".accordion").accordion(); diff --git a/app/src/Template/Element/search.ctp b/app/src/Template/Element/search.ctp new file mode 100644 index 00000000..3fcfc066 --- /dev/null +++ b/app/src/Template/Element/search.ctp @@ -0,0 +1,178 @@ +<?php +/** + * COmanage Match Search 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 http://www.internet2.edu/comanage COmanage Project + * @package match + * @since COmanage Match v1.0.0 + * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + */ + +use \Cake\Utility\Inflector; + +// Globals +global $cm_lang, $cm_texts; + +// Get a pointer to our controller +$controller = $this->request->getParam('controller'); +$req = Inflector::singularize($controller); + +// Get the query string and separate the search params from the non-search params +$query = $this->request->getQueryParams(); +$non_search_params = array_diff_key($query, $vv_search_fields); +$search_params = array_intersect_key($query, $vv_search_fields); + +// Begin the form +print $this->Form->create($req, [ + 'id' => 'top-search-form', + 'type' => 'get', + 'url' => ['action' => $this->request->action] +]); + +// 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 +// XXX may no longer need this +$hasActiveFilters = false; +?> + +<div id="<?php print $req . ucfirst($this->request->action); ?>Search" class="top-search"> + <fieldset onclick="event.stopPropagation();"> + <legend id="top-search-toggle"> + <em class="material-icons">search</em> + <?= __('match.op.filter');?> + + <?php if(!empty($search_params)):?> + <span id="top-search-active-filters"> + <?php foreach($search_params as $key => $params): ?> + <?php + // Construct aria-controls string + $key_fields = explode('.', $key); + $aria_controls = $key_fields[0] . ucfirst($key_fields[1]); + + // We have active filters - not just a sort. + $hasActiveFilters = true; + ?> + <button class="top-search-active-filter deletebutton spin" type="button" aria-controls="<?php print $aria_controls; ?>" title="<?= __('match.op.clear.filters',[1]);?>"> + <span class="top-search-active-filter-title"><?php print $vv_search_fields[$key]['label']; ?></span> + <span class="top-search-active-filter-value"> + <?php + $value = $params; + // Get user friendly name from an Enumerator Class - XXX will need updating for Match if it becomes used + // XXX How should we handle dynamic Enumerator lists? + if(isset($vv_search_fields[$key]['enum']) + && isset($cm_texts[ $cm_lang ][$vv_search_fields[$key]['enum']][$params])) { + $value = $cm_texts[ $cm_lang ][$vv_search_fields[$key]['enum']][$params]; + print filter_var($value, FILTER_SANITIZE_SPECIAL_CHARS); + continue; + } + // Get user friendly name from the dropdown Select List + // XXX Currently we do not have a use case where the grouping name would create a namespace + if (isset($vv_search_fields[$key]['options'])) { + // Outside of any groups + if (isset($vv_search_fields[$key]['options'][$value])) { + print filter_var($vv_search_fields[$key]['options'][$value], FILTER_SANITIZE_SPECIAL_CHARS); + } else { + // Inside a group + foreach(array_keys($vv_search_fields[$key]['options']) as $optgroup) { + if( is_array($vv_search_fields[$key]['options'][$optgroup]) + && isset($vv_search_fields[$key]['options'][$optgroup][$value]) ) { + print filter_var($vv_search_fields[$key]['options'][$optgroup][$value], FILTER_SANITIZE_SPECIAL_CHARS); + print $this->Html->tag('span','(' . $optgroup . ')', array('class' => 'ml-1') ); + break; + } + } + } + } else { + print filter_var($value, FILTER_SANITIZE_SPECIAL_CHARS); + } + ?> + </span> + </button> + <?php endforeach; ?> + <?php if($hasActiveFilters): ?> + <button id="top-search-clear-all-button" class="filter-clear-all-button spin btn" type="button" aria-controls="top-search-clear" onclick="event.stopPropagation()"> + <?= __('match.op.clear.filters',[2]);?> + </button> + <?php endif; ?> + </span> + <?php endif; ?> + <button class="cm-toggle" aria-expanded="false" aria-controls="top-search-fields" type="button"><em class="material-icons drop-arrow">arrow_drop_down</em></button> + </legend> + <div id="top-search-fields"> + <div id="top-search-fields-subgroups"> + <?php + $i = 0; + $field_subgroup_columns = array(); + $skippedFields = 0; + foreach($vv_search_fields as $key => $options) { + // Exclude explicitly skipped fields + if(array_key_exists('searchSkip',$options)) { + $skippedFields++; + continue; + } + $formParams = array( + 'label' => !empty($options['searchLabel']) ? $options['searchLabel'] : $options['label'], + 'type' => !empty($options['searchType']) ? $options['searchType'] : 'text', + 'value' => (!empty($query[$key]) ? $query[$key] : ''), + 'required' => false, + ); + if(isset($options['searchEmpty'])) { + $formParams['empty'] = $options['searchEmpty']; + } + if(isset($options['searchOptions'])) { + $formParams['options'] = $options['searchOptions']; + } + + print $this->Form->input($key, $formParams); + } + ?> + </div> + <?php $rebalanceColumns = ((count($vv_search_fields)-$skippedFields) % 2 != 0) ? ' class="tss-rebalance"' : ''; ?> + <div id="top-search-submit"<?php print $rebalanceColumns ?>> + <?php + $args = array(); + // search button (submit) + $args['id'] = 'top-search-filter-button'; + $args['aria-label'] = __('match.op.filter'); + $args['class'] = 'submit-button spin btn btn-primary'; + print $this->Form->submit(__('match.op.filter'),$args); + + // clear button + $args['id'] = 'top-search-clear'; + $args['class'] = 'clear-button spin btn btn-default'; + $args['aria-label'] = __('match.op.clear'); + $args['onclick'] = 'clearTopSearch(this.form)'; + print $this->Form->button(__('match.op.clear'),$args); + ?> + </div> + </div> + </fieldset> +</div> + +<?php print $this->Form->end();?> \ No newline at end of file diff --git a/app/src/Template/MatchgridRecords/columns.inc b/app/src/Template/MatchgridRecords/columns.inc index 3c7b4225..1a1f5f4e 100644 --- a/app/src/Template/MatchgridRecords/columns.inc +++ b/app/src/Template/MatchgridRecords/columns.inc @@ -28,21 +28,38 @@ // We need to add matchgrid ID to all links (since ID is unique only within a matchgrid) // eg: matchgrid-records/edit/48?matchgrid_id=3 $forcePrimaryLink = true; + +// Turn on the search/filter box for this index view +// If $enableSearch is true, search input labels will be 'label' unless overridden by 'searchLabel'. +// If $enableSearch is true, a specific field can be excluded from the search by adding 'searchSkip' => true. +$enableSearch = true; // Note we don't need to do any special filtering, since MatchgridRecordsController will // force the model's table only to the one associated with the matchgrid $indexColumns = [ 'id' => [ - 'type' => 'echo' + 'label' => __('match.fd.id'), + 'type' => 'echo', + 'searchSkip' => true ], 'sor' => [ - 'type' => 'echo' + 'label' => __('match.fd.sor.abbr'), + 'type' => 'echo', + 'searchType' => 'select', + 'searchLabel' => __('match.fd.sor'), + 'searchEmpty' => __('match.fd.all'), + 'searchOptions' => $sor ], 'sorid' => [ - 'type' => 'echo' + 'label' => __('match.fd.sorid.abbr'), + 'type' => 'echo', + 'searchType' => 'text', + 'searchLabel' => __('match.fd.sorid') ], 'referenceid' => [ - 'type' => 'echo' + 'label' => __('match.fd.referenceid'), + 'type' => 'echo', + 'searchType' => 'text' ] ]; @@ -51,7 +68,8 @@ foreach($attributes as $attr) { if($attr->index_display === true) { $indexColumns[$attr->name] = [ 'label' => $attr->name, - 'type' => 'echo' + 'type' => 'echo', + 'searchType' => 'text' ]; } } \ No newline at end of file diff --git a/app/src/Template/Standard/index.ctp b/app/src/Template/Standard/index.ctp index 64e5f20a..dc963384 100644 --- a/app/src/Template/Standard/index.ctp +++ b/app/src/Template/Standard/index.ctp @@ -42,6 +42,10 @@ $modelsName = $this->name; // $tablefk = model_id $tableFK = Inflector::singularize($vv_tablename) . "_id"; +// Do we have records for this index? This will be set to true during render if we do. +// Otherwise, we'll print out a "no records" message. +$recordsExist = false; + // Our default link actions, in order of preference, unless the column config overrides it $linkActions = ['edit', 'view']; @@ -132,7 +136,13 @@ function _column_key($modelsName, $c, $tz=null) { </div> <?php endforeach; // $banners ?> <?php endif; // $banners ?> - + +<!-- Search block --> +<?php if(!empty($enableSearch)): ?> + <?= $this->element('search', ['vv_search_fields' => $indexColumns]); ?> +<?php endif; // $enableSearch ?> + +<!-- Index table --> <div class="table-container"> <table id="<?= $vv_tablename . '-table'; ?>"> <tr> @@ -316,7 +326,11 @@ function _column_key($modelsName, $c, $tz=null) { ?> </td> </tr> + <?php $recordsExist = true; ?> <?php endforeach; // $$vv_tablename ?> + <?php if(!$recordsExist): ?> + <tr><td colspan="<?= count($indexColumns); ?>"><?= __('match.in.records.none') ?></td></tr> + <?php endif; ?> </table> </div> diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index fe0576df..17f100ca 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -507,6 +507,135 @@ body.logged-in #top-menu { clear: both; margin-top: 1em; } +/* TOP SEARCH */ +.top-search { + margin-top: 0.5em; + padding: 0; +} +.top-search legend .material-icons { + vertical-align: middle; +} +.top-search legend button.cm-toggle { + position: absolute; + right: 0.5em; + border: none; + background-color: transparent; +} +#content .top-search legend .material-icons { + font-size: 20px; +} +#content .top-search legend button.cm-toggle .material-icons { + font-size: 34px; + line-height: 17px; +} +.top-search fieldset { + clear: both; + position: relative; + padding: 0.5em 0.5em 0; + margin: 0; + background-color: #f5f5f5; +} +.top-search.open fieldset { + padding-bottom: 0.5em; +} +.top-search legend { + width: 100%; + background-color: #f5f5f5; + margin: 0 -0.5em; + padding: 0.5em 0.5em 0; + line-height: 1.8em; + cursor: pointer; + font-size: 1em; + box-sizing: content-box; +} +#top-search-fields { + display: none; + padding: 0.25em 0.5em; +} +#top-search-submit { + float: right; + width: 200px; + margin-bottom: 0.75em; +} +.top-search input[type=text], +.top-search select, +.side-search input[type=text], +.side-search select { + width: 100%; + box-sizing: border-box; + margin: 0 0 0.5em 0; + height: 28px; + padding: 2px 4px; + border: 1px solid #ddd; + background-color: #fff; +} +.top-search label { + margin-bottom: 0; +} +::-webkit-input-placeholder, +::-moz-placeholder, +:-ms-input-placeholder, +:-moz-placeholder { + opacity: 0.2; +} +.top-search input[type=text]:focus, +.side-search input[type=text]:focus { + background-color: #ffd; +} +.top-search .submit-button, +.top-search .clear-button, +.side-search .submit-button, +.side-search .clear-button { + float: right; + font-size: 0.9em; + width: 80px; + line-height: 28px; + height: 28px; + margin: 1em 0.5em; + padding: 0; +} +.top-search .top-search-checkboxes { + margin-top: 0.5em; +} +.top-search .filter-clear-all-button { + font-size: 0.9em; + width: auto; + height: auto; + line-height: unset; + margin: 0; + padding: 0 1em; +} +.top-search .filter-clear-all-button:hover { + background-color: unset; +} +.top-search.top-search-hide-fields label, +.side-search label { + display: none; +} +#top-search-active-filters { + margin-left: 1em; + padding: 0.5em 0; +} +.top-search-active-filter { + margin: 0 0.25em 0 0; + white-space: nowrap; + /*color: #b00;*/ + font-style: italic; +} +.top-search-active-filter-title::after { + content: ": "; +} +.top-search-active-filter-title.no-value::after { + content: none; +} +.top-search-active-filters-remove button { + margin-left: 2em; + font-size: 0.9em; + background-color: #f5f5f5; +} +.top-search-active-filters-remove button:hover { + background-color: #eee; +} /* RECONCILE TABLE */ #reconcile-table { width: auto; diff --git a/app/webroot/css/co-responsive.css b/app/webroot/css/co-responsive.css index 03cfd923..dfadf3ad 100644 --- a/app/webroot/css/co-responsive.css +++ b/app/webroot/css/co-responsive.css @@ -191,6 +191,17 @@ .call-to-action { margin-bottom: 0; } + /* TOP SEARCH */ + #top-search-fields-subgroups { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 1em; + padding: 0 0.5em; + } + #top-search-submit.tss-rebalance { + margin-top: -3.5em; + position: relative; + } /* MATCHGRID MANAGEMENT */ #matchgrid-config-menu { display: grid; diff --git a/app/webroot/js/comanage.js b/app/webroot/js/comanage.js index 34a55626..213c7fd1 100644 --- a/app/webroot/js/comanage.js +++ b/app/webroot/js/comanage.js @@ -226,3 +226,13 @@ function limitPage(pageLimit,recordCount,currentPage) { window.location = currentUrl; } +// Clear the top search form for index views +// formObj - form object (DOM form obj, required) +function clearTopSearch(formObj) { + for (var i=0; i<formObj.elements.length; i++) { + if(formObj.elements[i].type != 'hidden') { + formObj.elements[i].disabled = true; + } + } + formObj.submit(); +}