From e4f2f0b4e56235feb3c25684ef97f2cadf1e9922 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 1 May 2022 15:18:20 +0300 Subject: [PATCH 01/20] Initial commit dynamic construct of Index filtering --- app/src/Lib/Traits/SearchFilterTrait.php | 76 +++++++----------------- app/src/Lib/Traits/TableMetaTrait.php | 65 ++++++++++++++++++++ app/src/Model/Table/CousTable.php | 5 -- app/src/Model/Table/TypesTable.php | 5 -- app/templates/ApiUsers/columns.inc | 3 + app/templates/element/filter.php | 7 ++- 6 files changed, 94 insertions(+), 67 deletions(-) diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index dd05c5fe3..616a389e8 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -34,33 +34,6 @@ trait SearchFilterTrait { // Array (and configuration) of permitted search filters private $searchFilters = array(); - - /** - * Determine the UI label for the specified attribute. - * - * @since COmanage Registry v5.0.0 - * @param string $attribute Attribute - * @return string Label - * @todo Merge this with _column_key from index.ctp - */ - - public function getLabel(string $attribute): string { - if(isset($this->searchFilters[$attribute]['label']) - && $this->searchFilters[$attribute]['label'] !== null) { - return $this->searchFilters[$attribute]['label']; - } - - // Try to construct a label from the language key. - $l = __d('field', $attribute); - - if($l != $attribute) { - return $l; - } - - // If we make it here, just return a pretty version of the $attribute name - return Inflector::humanize($attribute); - } - /** * Obtain the set of permitted search attributes. * @@ -69,37 +42,29 @@ public function getLabel(string $attribute): string { */ public function getSearchableAttributes(): array { - // Not every configuration element is necessary for the search form, and - // some need to be calculated, so we do that work here. - - $ret = []; - - foreach(array_keys($this->searchFilters) as $attr) { - $ret[ $attr ] = [ - 'label' => $this->getLabel($attr) + 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; + } + $this->searchFilters[$column] = [ + 'substring' => ($type === "string"), + 'datetime' => ($type === "timestamp"), + // todo: Probably the following line is redundant but i am leaving it for now + 'label' => (__d('field', $column) ?? Inflector::humanize($column)), + 'caseSensitive' => true, // hardcoding for now + ]; + + // Not every configuration element is necessary for the search form, and + // some need to be calculated, so we do that work here. + $ret[ $column ] = [ + 'label' => (__d('field', $column) ?? Inflector::humanize($column)) ]; } - - return $ret; - } - - /** - * Add a permitted search filters. - * - * @since COmanage Registry v5.0.0 - * @param string $attribute Attribute that filtering is permitted on (database name) - * @param bool $caseSensitive Whether this attribute is case sensitive - * @param string $label Label for this search field, or null to autocalculate - * @param bool $substring Whether substring searching is permitted for this attribute - */ - - public function setSearchFilter(string $attribute, - bool $caseSensitive=false, - string $label=null, - bool $substring=true): void { - $this->searchFilters[$attribute] = compact('caseSensitive', 'label', 'substring'); + + return $ret ?? []; } - + /** * Build a query where() clause for the configured attribute. * @@ -112,6 +77,7 @@ public function setSearchFilter(string $attribute, public function whereFilter(\Cake\ORM\Query $query, string $attribute, string $q): object { if(!empty($this->searchFilters[$attribute])) { + // todo: move caseSensitive into filter block itself $cs = (isset($this->searchFilters[$attribute]['caseSensitive']) && $this->searchFilters[$attribute]['caseSensitive']); diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index f43e3cfce..41e27b584 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -29,6 +29,8 @@ namespace App\Lib\Traits; +use Cake\Utility\Inflector; + trait TableMetaTrait { // Does this Table represent Registry objects or configuration? private $confTable = false; @@ -54,4 +56,67 @@ public function getIsConfigurationTable() { public function setIsConfigurationTable(bool $confTable) { $this->confTable = $confTable; } + + + /** + * Filter metadata fields. + * + * @since COmanage Registry v5.0.0 + * @return array An array of columns distinguished in metadata and non-metadata + */ + + protected function filterMetadataFields() { + // Get the list of columns + $coltype = $this->getSchema()->typeMap(); + $entity = $this->getEntityClass(); + $entity_namespace = explode('\\', $entity); + $modelName = end($entity_namespace); + + // Get the list of belongs_to associations and construct an exclude array + $assc_keys = []; + foreach ($this->associations() as $assc) { + if($assc->type() === "manyToOne") { + $assc_keys[] = Inflector::underscore(Inflector::classify($assc->getClassName())) . "_id"; + } + } + // Map the model (eg: Person) to the changelog key (person_id) + $mfk = Inflector::underscore($modelName) . "_id"; + + + $meta_fields = [ + ...$assc_keys, + $mfk, + 'actor_identifier', + // 'provisioning_target_id', + 'created', // todo: I might need to revisit this. We might want to filter according to date in some occassions. Like petitions + 'deleted', + 'id', + 'modified', + 'revision', + 'lft', // XXX For now i skip lft.rght column for tree structures + 'rght', + // 'parent_id', // todo: We need to filter using the parent_id. This should be an enumerator and should apply for all the models that use TreeBehavior + 'api_key' + // 'source_ad_hoc_attribute_id', + // 'source_address_id', + // 'source_email_address_id', + // 'source_identifier_id', + // 'source_name_id', + // 'source_external_identity_id', + // 'source_telephone_number_id', + ]; + + $newa = array(); + foreach($coltype as $clmn => $type) { + if(in_array($clmn, $meta_fields,true)) { + // Move the value to metadata + $newa['meta'][$clmn] = $type; + } else { + // Just copy the value + $newa[$clmn] = $type; + } + } + + return $newa ?? []; + } } diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index 082ad40d6..1a8122ecb 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -72,11 +72,6 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); - - // Set up the fields that may be filtered in the index view - $this->setSearchFilter('name', false, null, true); - $this->setSearchFilter('parent_id', true, null, false); - $this->setSearchFilter('description', false, null, true); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index 856662495..a43558dd5 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -113,11 +113,6 @@ public function initialize(array $config): void { 'class' => 'SuspendableStatusEnum' ] ]); - - // Set up the fields that may be filtered in the index view - $this->setSearchFilter('display_name', false, null, true); - $this->setSearchFilter('attribute', true, null, false); - $this->setSearchFilter('statuses', false, null, true); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) diff --git a/app/templates/ApiUsers/columns.inc b/app/templates/ApiUsers/columns.inc index 29d9aef96..c03b5a56c 100644 --- a/app/templates/ApiUsers/columns.inc +++ b/app/templates/ApiUsers/columns.inc @@ -32,6 +32,9 @@ if($vv_cur_co->id == 1) { ]; } +// Turn on the search/filter box for this index view +$enableFiltering = true; + $indexColumns = [ 'username' => [ 'type' => 'link', diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index 33c25a691..b919b0ee6 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -105,11 +105,14 @@ 'required' => false, ]; - if(isset($$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 = Cake\Utility\Inflector::pluralize($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'] = $$key; + $formParams['options'] = $$populated_vvar; // Allow empty so a filter doesn't require (eg) SOR $formParams['empty'] = true; } From 8dfc434fc66a94fc7e927e47e57f396c68bf9486 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 5 May 2022 20:06:10 +0300 Subject: [PATCH 02/20] Basic filtering works --- app/src/Lib/Traits/SearchFilterTrait.php | 56 ++++++++---------------- app/templates/element/filter.php | 7 ++- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 616a389e8..967e7fc69 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -51,8 +51,7 @@ public function getSearchableAttributes(): array { 'substring' => ($type === "string"), 'datetime' => ($type === "timestamp"), // todo: Probably the following line is redundant but i am leaving it for now - 'label' => (__d('field', $column) ?? Inflector::humanize($column)), - 'caseSensitive' => true, // hardcoding for now + 'label' => (__d('field', $column) ?? Inflector::humanize($column)) ]; // Not every configuration element is necessary for the search form, and @@ -76,42 +75,23 @@ public function getSearchableAttributes(): array { */ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string $q): object { - if(!empty($this->searchFilters[$attribute])) { - // todo: move caseSensitive into filter block itself - $cs = (isset($this->searchFilters[$attribute]['caseSensitive']) - && $this->searchFilters[$attribute]['caseSensitive']); - - $sub = (isset($this->searchFilters[$attribute]['substring']) - && $this->searchFilters[$attribute]['substring']); - - $search = $q; - - if($sub) { - // Substring - // note, for now at least, a user may infix their own % - $search .= "%"; - } - - if($cs) { - // Case sensitive - $query->where([$attribute => $search]); - } else { - // Case insensitive - $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search, $sub) { - $lower = $query->func()->lower([ - // https://book.cakephp.org/3/en/orm/query-builder.html#function-arguments - $attribute => 'identifier' - ]); - if($sub) { - return $exp->like($lower, strtolower($search)); - } else { - return $exp->eq($lower, strtolower($search)); - } - }); - } + // not a permitted attribute + if(empty($this->searchFilters[$attribute])) { + return $query; } - // else not a permitted attribute - - return $query; + + $search = $q; + $sub = false; + if(isset($this->searchFilters[$attribute]['substring']) + && $this->searchFilters[$attribute]['substring']) { + $search = "%" . $search . "%"; + $sub = true; + } + + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search, $sub) { + $lower = $query->func()->lower([$attribute => 'identifier']); + return ($sub) ? $exp->like($lower, strtolower($search)) + : $exp->eq($lower, strtolower($search)); + }); } } diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index b919b0ee6..9cc8005c8 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -71,6 +71,11 @@ // 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 = Cake\Utility\Inflector::pluralize($key); + $pvalue = isset($$populated_vvar) ? $$populated_vvar[ $search_params[$key] ] : $search_params[$key]; ?> From 48bf825648fc26fb531c021eddad036b04b7b192 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 5 May 2022 20:10:00 +0300 Subject: [PATCH 03/20] Enable Index filtering for all Models by default --- app/templates/ApiUsers/columns.inc | 4 ++-- app/templates/Cous/columns.inc | 4 ++-- app/templates/Standard/index.php | 7 +++++-- app/templates/Types/columns.inc | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/templates/ApiUsers/columns.inc b/app/templates/ApiUsers/columns.inc index c03b5a56c..87c426e6b 100644 --- a/app/templates/ApiUsers/columns.inc +++ b/app/templates/ApiUsers/columns.inc @@ -32,8 +32,8 @@ if($vv_cur_co->id == 1) { ]; } -// Turn on the search/filter box for this index view -$enableFiltering = true; +// Turn off the search/filter box for this index view +//$disableFiltering = true; $indexColumns = [ 'username' => [ diff --git a/app/templates/Cous/columns.inc b/app/templates/Cous/columns.inc index d2b50c09b..d50fd5ed2 100644 --- a/app/templates/Cous/columns.inc +++ b/app/templates/Cous/columns.inc @@ -25,8 +25,8 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// Turn on the search/filter box for this index view -$enableFiltering = true; +// Turn off the search/filter box for this index view +//$disableFiltering = true; $indexColumns = [ 'name' => [ diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index d602c32e1..a828e16e1 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -45,6 +45,9 @@ // Otherwise, we'll print out a "no records" message. $recordsExist = false; +// By default Index filtering is on and we need to explicitly disable it +$disableFiltering = false; + // Our default link actions, in order of preference, unless the column config overrides it $linkActions = ['edit', 'view']; @@ -157,9 +160,9 @@ function _column_key($modelsName, $c, $tz=null) { - + element('filter'); ?> - +
diff --git a/app/templates/Types/columns.inc b/app/templates/Types/columns.inc index 34a9deb23..0a3b29676 100644 --- a/app/templates/Types/columns.inc +++ b/app/templates/Types/columns.inc @@ -25,8 +25,8 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -// Turn on the search/filter box for this index view -$enableFiltering = true; +// Turn off the search/filter box for this index view +//$disableFiltering = true; $indexColumns = [ 'display_name' => [ From b016ad5307dfcc4c1c4a8e1b7d4a93a8cce5f0c1 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Thu, 5 May 2022 21:57:43 +0300 Subject: [PATCH 04/20] Add checkbox handling for boolean fields --- app/src/Controller/StandardController.php | 1 + app/src/Lib/Traits/SearchFilterTrait.php | 20 ++++++++--------- app/templates/element/filter.php | 27 +++++++++++++++++++++-- app/webroot/css/co-responsive.css | 3 +++ 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 8ddb7eeff..cb154e5e5 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -440,6 +440,7 @@ public function index() { $searchableAttributes = $table->getSearchableAttributes(); 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) { if(!empty($this->request->getQuery($attribute))) { $query = $table->whereFilter($query, $attribute, $this->request->getQuery($attribute)); diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 967e7fc69..7be746641 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -48,20 +48,13 @@ public function getSearchableAttributes(): array { continue; } $this->searchFilters[$column] = [ - 'substring' => ($type === "string"), - 'datetime' => ($type === "timestamp"), + 'type' => $type, // todo: Probably the following line is redundant but i am leaving it for now 'label' => (__d('field', $column) ?? Inflector::humanize($column)) ]; - - // Not every configuration element is necessary for the search form, and - // some need to be calculated, so we do that work here. - $ret[ $column ] = [ - 'label' => (__d('field', $column) ?? Inflector::humanize($column)) - ]; } - return $ret ?? []; + return $this->searchFilters ?? []; } /** @@ -82,12 +75,17 @@ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string $q $search = $q; $sub = false; - if(isset($this->searchFilters[$attribute]['substring']) - && $this->searchFilters[$attribute]['substring']) { + if( $this->searchFilters[$attribute]['type'] == "string") { $search = "%" . $search . "%"; $sub = true; } + // Boolean Values + if($this->searchFilters[$attribute]['type'] == 'boolean') { + return $query->where([$attribute => $search]); + } + + // 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 ($sub) ? $exp->like($lower, strtolower($search)) diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index 9cc8005c8..eec0a41bd 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -60,7 +60,7 @@ search - + @@ -99,9 +99,13 @@
$options) { + if($options['type'] == 'boolean') { + $field_booleans_columns[$key] = $options; + continue; + } $formParams = [ 'label' => $options['label'], // The default type is text, but we might convert to select below @@ -126,6 +130,25 @@ } ?>
+ +
+
Some Title here
+
+ $options): ?> +
+ Form->label($key); + print $this->Form->checkbox($key, [ + 'class' => 'form-check-input', + 'checked' => $query[$key] ?? 0, + 'hiddenField' => false + ]); + ?> +
+ +
+
+
> Date: Thu, 5 May 2022 22:02:48 +0300 Subject: [PATCH 05/20] Add People View index filtering --- app/src/Model/Table/PeopleTable.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 572384117..a770506f3 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -44,6 +44,7 @@ class PeopleTable extends Table { use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; + use \App\Lib\Traits\SearchFilterTrait; /** * Perform Cake Model initialization. From a5a9c14093af5eae53dd3da1d486c5b3b792745d Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 6 May 2022 19:41:33 +0300 Subject: [PATCH 06/20] Prepopulate potential tree parents. Filter using parent id in COU index view. --- app/src/Controller/StandardController.php | 8 ++++++++ app/src/Lib/Traits/SearchFilterTrait.php | 5 +++-- app/src/Model/Table/CousTable.php | 7 +++++++ app/templates/element/javascript.php | 6 +++++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index cb154e5e5..eb7ec11cc 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -573,6 +573,14 @@ protected function populateAutoViewVars(object $obj=null) { $this->set($vvar, $query->toArray()); break; + case 'parent': + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // XXX We assume that all models that load the Tree behavior will + // implement a potentialParents method + $this->set($vvar, $table->potentialParents($this->getCOID())); + break; default: // XXX I18n? and in match? throw new \LogicException('Unknonwn Auto View Var Type {0}', [$avv['type']]); diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 7be746641..9f031df0a 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -80,8 +80,9 @@ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string $q $sub = true; } - // Boolean Values - if($this->searchFilters[$attribute]['type'] == 'boolean') { + // Primitive types + $search_types = ['integer', 'boolean']; + if(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { return $query->where([$attribute => $search]); } diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index 1a8122ecb..7a641f17d 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -86,6 +86,12 @@ public function initialize(array $config): void { 'index' => ['platformAdmin', 'coAdmin'] ] ]); + + $this->setAutoViewVars([ + 'parent_ids' => [ + 'type' => 'parent' + ] + ]); } /** @@ -117,6 +123,7 @@ public function buildTableRules(RulesChecker $rules): RulesChecker { * @param int $id COU ID to determine potential parents of, or null for any (or a new) COU * @param bool $hierarchy Render the hierarchy in the name * @return Array Array of COU IDs and COU Names + * @todo Make a TreeTrait and move the function there */ public function potentialParents(int $coId, int $id=null, bool $hierarchy=false) { diff --git a/app/templates/element/javascript.php b/app/templates/element/javascript.php index 1058a1b6f..201139b51 100644 --- a/app/templates/element/javascript.php +++ b/app/templates/element/javascript.php @@ -127,7 +127,11 @@ e.preventDefault(); e.stopPropagation(); $(this).hide(); - filterId = '#' + $(this).attr("aria-controls"); + // CAKEPHP transforms snake case variables to kebab. As a result + // searching for the initial key will fail + original_value = $(this).attr("aria-controls"); + value_to_snake = original_value.replace(/_/g, "-");; + filterId = '#' + value_to_snake; $(filterId).val(""); $(this).closest('form').submit(); }); From 9a123674ed77d4e0077828953213dc8f7f98be28 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sat, 7 May 2022 19:11:42 +0300 Subject: [PATCH 07/20] Move datepicker field in its own element --- app/src/View/Helper/FieldHelper.php | 75 ++++------------------------ app/templates/element/datePicker.php | 66 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 65 deletions(-) create mode 100644 app/templates/element/datePicker.php diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 0da6e5aa5..137b70a26 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -100,7 +100,7 @@ public function control(string $fieldName, if($fieldName == 'valid_from' || $fieldName == 'valid_through') { // Append the timezone to the label $label = __d('field', $fieldName.".tz", [$this->_View->get('vv_tz')]); - + // 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. @@ -108,77 +108,22 @@ public function control(string $fieldName, $coptions['placeholder'] = 'YYYY-MM-DD HH:MM:SS'; // TODO: test for date-only inputs and send only the date $coptions['id'] = $fieldName; - $entity = $this->_View->get('vv_obj'); + $entity = $this->getView()->get('vv_obj'); $pickerDate = ''; if(!empty($entity->$fieldName)) { // Adjust the time back to the user's timezone - $coptions['value'] = $entity->$fieldName->i18nFormat("yyyy-MM-dd HH:mm:ss", $this->_View->get('vv_tz')); - $pickerDate = $entity->$fieldName->i18nFormat("yyyy-MM-dd", $this->_View->get('vv_tz')); + $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')); } + $date_args = [ + 'fieldName' => $fieldName, + 'pickerDate' => $pickerDate + ]; // Create a text field to hold our value. - $controlCode = $this->Form->text($fieldName, $coptions); - - // Create a date/time picker. The yyyy-MM-dd format is set above in $pickerDate. - $pickerId = 'datepicker-' . $fieldName; - $pickerTarget = $fieldName; - $pickerTimed = true; // TODO: set false if date-only - $pickerAmPm = false; // TODO: allow change between AM/PM and 24-hour mode - - $controlCode .= ' - -
- - -
'; + $controlCode = $this->Form->text($fieldName, $coptions) + . $this->getView()->element('datePicker', $date_args); $liClass = "fields-datepicker"; } else { diff --git a/app/templates/element/datePicker.php b/app/templates/element/datePicker.php new file mode 100644 index 000000000..9f9a27210 --- /dev/null +++ b/app/templates/element/datePicker.php @@ -0,0 +1,66 @@ + + + +
+ + +
From 2bc51513b57b9a59d02e7522a1a642b1d44cb643 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sat, 7 May 2022 21:02:12 +0300 Subject: [PATCH 08/20] [WIP]Support date range filtering --- app/src/Controller/StandardController.php | 7 +++ app/src/Lib/Traits/SearchFilterTrait.php | 24 +++---- app/templates/element/filter.php | 76 ++++++++++++++++++++++- app/webroot/css/co-base.css | 4 ++ app/webroot/css/co-responsive.css | 3 + 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index eb7ec11cc..180629d86 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -444,6 +444,13 @@ public function index() { foreach(array_keys($searchableAttributes) as $attribute) { if(!empty($this->request->getQuery($attribute))) { $query = $table->whereFilter($query, $attribute, $this->request->getQuery($attribute)); + } elseif (!empty($this->request->getQuery($attribute . "_starts_at")) + || !empty($this->request->getQuery($attribute . "_ends_at"))) { + $search_date = []; + // We allow empty for dates since we might refer to infinity (from whenever or to always) + $search_date[] = $this->request->getQuery($attribute . "_starts_at") ?? ""; + $search_date[] = $this->request->getQuery($attribute . "_ends_at") ?? ""; + $query = $table->whereFilter($query, $attribute, $search_date); } } diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 9f031df0a..c401034af 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -49,7 +49,6 @@ public function getSearchableAttributes(): array { } $this->searchFilters[$column] = [ 'type' => $type, - // todo: Probably the following line is redundant but i am leaving it for now 'label' => (__d('field', $column) ?? Inflector::humanize($column)) ]; } @@ -60,14 +59,15 @@ public function getSearchableAttributes(): array { /** * Build a query where() clause for the configured attribute. * - * @since COmanage Registry v5.0.0 - * @param \Cake\ORM\Query $query Cake ORM Query object - * @param string $attribute Attribute to filter on (database name) - * @param string $q Value to filter on + * @param \Cake\ORM\Query $query Cake ORM Query object + * @param string|array $attribute Attribute to filter on (database name) + * @param string $q Value to filter on + * * @return \Cake\ORM\Query Cake ORM Query object + * @since COmanage Registry v5.0.0 */ - public function whereFilter(\Cake\ORM\Query $query, string $attribute, string $q): object { + public function whereFilter(\Cake\ORM\Query $query, string|array $attribute, string $q): object { // not a permitted attribute if(empty($this->searchFilters[$attribute])) { return $query; @@ -75,15 +75,17 @@ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string $q $search = $q; $sub = false; + // Primitive types + $search_types = ['integer', 'boolean']; if( $this->searchFilters[$attribute]['type'] == "string") { $search = "%" . $search . "%"; $sub = true; - } - - // Primitive types - $search_types = ['integer', 'boolean']; - if(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { + } elseif(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { return $query->where([$attribute => $search]); + } elseif( $this->searchFilters[$attribute]['type'] == "timestamp") { + return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search) { + return $exp->between($attribute, $search[0], $search[1]); + }); } // String values diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index eec0a41bd..6ca492da8 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -100,11 +100,15 @@
$options) { if($options['type'] == 'boolean') { $field_booleans_columns[$key] = $options; continue; + } elseif ($options['type'] == 'timestamp') { + $field_datetime_columns[$key] = $options; + continue; } $formParams = [ 'label' => $options['label'], @@ -130,9 +134,79 @@ } ?>
+ + $options): ?> +
+
+
+ +
+
+ i18nFormat("yyyy-MM-dd HH:mm:ss", $this->get('vv_tz')); + $pickerDate = $query[$starts_field]->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, '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 = $query[$ends_field]->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, 'Ends at:', ['class' => 'filter-datepicker-lbl']); + print $this->Form->text($ends_field, $coptions) . $this->element('datePicker', $date_args); + ?> +
+
+
+
+ + +
-
Some Title here
+
On-Off
$options): ?>
diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css index c19df5a3c..139dd4d63 100644 --- a/app/webroot/css/co-base.css +++ b/app/webroot/css/co-base.css @@ -713,6 +713,10 @@ instead, reveal in user menu */ .top-filters-active-filters-remove button:hover { background-color: var(--cmg-color-lightgray-005); } +.filter-datepicker-lbl { + white-space: nowrap; + margin-right: 0.5em; +} /* RECONCILE TABLE */ #reconcile-table { width: auto; diff --git a/app/webroot/css/co-responsive.css b/app/webroot/css/co-responsive.css index 2ce80639d..b80bc40d0 100644 --- a/app/webroot/css/co-responsive.css +++ b/app/webroot/css/co-responsive.css @@ -205,6 +205,9 @@ margin-top: -3.5em; position: relative; } + .top-search-date-label { + padding: 0 0.5em; + } /* CO CONFIGURATION DASHBOARD */ #configuration-menu { column-count: 3; From 08e9faf19e0d5bc1c400beff7deda5ad8519984c Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 8 May 2022 16:48:36 +0300 Subject: [PATCH 09/20] [WIP]Support date range filtering #2 --- app/src/Lib/Traits/SearchFilterTrait.php | 38 ++++++++++-- app/templates/Standard/index.php | 2 +- app/templates/element/filter.php | 78 ++++++++++++++++++------ app/webroot/css/co-responsive.css | 2 +- 4 files changed, 92 insertions(+), 28 deletions(-) diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index c401034af..c6a55b714 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -51,6 +51,12 @@ public function getSearchableAttributes(): array { 'type' => $type, 'label' => (__d('field', $column) ?? Inflector::humanize($column)) ]; + + // For the date fields we search ranges + if($type === 'timestamp') { + $this->searchFilters[$column]['alias'][] = $column . '_starts_at'; + $this->searchFilters[$column]['alias'][] = $column . '_ends_at'; + } } return $this->searchFilters ?? []; @@ -60,14 +66,14 @@ public function getSearchableAttributes(): array { * Build a query where() clause for the configured attribute. * * @param \Cake\ORM\Query $query Cake ORM Query object - * @param string|array $attribute Attribute to filter on (database name) - * @param string $q Value to filter on + * @param string $attribute Attribute to filter on (database name) + * @param string|array $q Value to filter on * * @return \Cake\ORM\Query Cake ORM Query object * @since COmanage Registry v5.0.0 */ - public function whereFilter(\Cake\ORM\Query $query, string|array $attribute, string $q): object { + public function whereFilter(\Cake\ORM\Query $query, string $attribute, string|array $q): object { // not a permitted attribute if(empty($this->searchFilters[$attribute])) { return $query; @@ -83,9 +89,29 @@ public function whereFilter(\Cake\ORM\Query $query, string|array $attribute, str } elseif(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { return $query->where([$attribute => $search]); } elseif( $this->searchFilters[$attribute]['type'] == "timestamp") { - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attribute, $search) { - return $exp->between($attribute, $search[0], $search[1]); - }); + // 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]); + }); + // 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($attribute, $search[0]); + }); + // 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($attribute, $search[1]); + }); + } else { + // We return everything + return $query; + } + } // String values diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index a828e16e1..5ed37a01d 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -217,7 +217,7 @@ function _column_key($modelsName, $c, $tz=null) { break; case 'datetime': // XXX dates can be rendered as eg $entity->created->format(DATE_RFC850); - print $this->Time->nice($entity->$col, $vv_tz) . $suffix; + print !empty($entity->$col) ? $this->Time->nice($entity->$col, $vv_tz) . $suffix : ""; break; case 'enum': if($entity->$col) { diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index 6ca492da8..f40fcad5b 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -24,16 +24,48 @@ * @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 = \Cake\Utility\Inflector::singularize($modelsName); +$modelName = Inflector::singularize($modelsName); // 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_searchable_attributes); -$search_params = array_intersect_key($query, $vv_searchable_attributes); +// 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, [ @@ -61,7 +93,6 @@ search - $params): ?> @@ -74,16 +105,18 @@ // 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 = Cake\Utility\Inflector::pluralize($key); - $pvalue = isset($$populated_vvar) ? $$populated_vvar[ $search_params[$key] ] : $search_params[$key]; + $populated_vvar = Inflector::pluralize($key); + $button_label = isset($$populated_vvar) ? + $$populated_vvar[ $search_params[$key] ] : + (is_array($search_params[$key]) ? 'Range' : $search_params[$key]); ?> @@ -97,7 +130,7 @@
-
+
- + $options): ?>
-
-
+
+
@@ -149,14 +182,16 @@ $starts_field = $key . "_starts_at"; $coptions['class'] = 'form-control datepicker'; $coptions['label'] = 'Starts at:'; - $coptions['placeholder'] = 'YYYY-MM-DD HH:MM:SS'; // TODO: test for date-only inputs and send only the date + $coptions['required'] = false; + $coptions['placeholder'] = 'YYYY-MM-DD HH:MM:SS'; $coptions['id'] = $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'] = $query[$starts_field]->i18nFormat("yyyy-MM-dd HH:mm:ss", $this->get('vv_tz')); - $pickerDate = $query[$starts_field]->i18nFormat("yyyy-MM-dd", $this->get('vv_tz')); + $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 = [ @@ -178,15 +213,17 @@ // accessibility purposes. $ends_field = $key . "_ends_at"; $coptions['class'] = 'form-control datepicker'; - $coptions['label'] = 'Ends at:'; + $coptions['required'] = false; $coptions['placeholder'] = 'YYYY-MM-DD HH:MM:SS'; // TODO: test for date-only inputs and send only the date + $coptions['label'] = 'Ends at:'; $coptions['id'] = $ends_field; $pickerDate = ''; if(!empty($query[$ends_field])) { // Adjust the time back to the user's timezone - $coptions['value'] = $query[$ends_field]->i18nFormat("yyyy-MM-dd HH:mm:ss", $this->get('vv_tz')); - $pickerDate = $query[$ends_field]->i18nFormat("yyyy-MM-dd", $this->get('vv_tz')); + $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 = [ @@ -215,7 +252,8 @@ print $this->Form->checkbox($key, [ 'class' => 'form-check-input', 'checked' => $query[$key] ?? 0, - 'hiddenField' => false + 'hiddenField' => false, + 'required' => false ]); ?>
diff --git a/app/webroot/css/co-responsive.css b/app/webroot/css/co-responsive.css index b80bc40d0..e036179b3 100644 --- a/app/webroot/css/co-responsive.css +++ b/app/webroot/css/co-responsive.css @@ -192,7 +192,7 @@ margin-bottom: 0; } /* TOP SEARCH */ - #top-filters-fields-subgroups { + .top-filters-fields-subgroups { display: grid; grid-template-columns: 1fr 1fr; column-gap: 1em; From 7d5671c48e4d204269bd89ad01f20b16c2b777fa Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 10 May 2022 20:31:18 +0300 Subject: [PATCH 10/20] Fixed Form submitted fields for method get for Vue datepicker modules --- app/src/Lib/Traits/SearchFilterTrait.php | 7 ++- app/src/View/Helper/FieldHelper.php | 2 +- app/templates/element/filter.php | 57 ++++++++++--------- .../datepicker/cm-datetimepicker.js | 19 ++++--- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index c6a55b714..77b1452c5 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -30,6 +30,7 @@ namespace App\Lib\Traits; use Cake\Utility\Inflector; +use Cake\I18n\FrozenTime; trait SearchFilterTrait { // Array (and configuration) of permitted search filters @@ -93,19 +94,19 @@ public function whereFilter(\Cake\ORM\Query $query, string $attribute, string|ar 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 $exp->between($attribute, "'" . $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($attribute, $search[0]); + return $exp->gte("'" . FrozenTime::parse($search[0]) . "'", $attribute); }); // 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($attribute, $search[1]); + return $exp->lte("'" . FrozenTime::parse($search[1]) . "'", $attribute); }); } else { // We return everything diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 137b70a26..b96c34afb 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -47,7 +47,7 @@ class FieldHelper extends Helper { // The current entity, if edit or view protected $entity = null; - + /** * Emit an informational banner. * diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index f40fcad5b..ece0da3e2 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -167,14 +167,35 @@ } ?>
+ +
+
On-Off
+
+ $options): ?> +
+ Form->label($key); + print $this->Form->checkbox($key, [ + 'id' => $key, + 'class' => 'form-check-input', + 'checked' => $query[$key] ?? 0, + 'hiddenField' => false, + 'required' => false + ]); + ?> +
+ +
+
+ - $options): ?> -
-
-
+ $options): ?> +
+
+
-
+
- -
-
On-Off
-
- $options): ?> -
- Form->label($key); - print $this->Form->checkbox($key, [ - 'class' => 'form-check-input', - 'checked' => $query[$key] ?? 0, - 'hiddenField' => false, - 'required' => false - ]); - ?> -
- -
-
-
> { - e.preventDefault(); let dateWidgetInputs = document.querySelectorAll('duet-date-picker input'); - Array.prototype.slice.call(dateWidgetInputs).forEach( - function(element) { - element.parentNode.removeChild(element); - } - ); - curForm.submit(); + // Remove all the Vue related fields + Array.prototype.slice.call(dateWidgetInputs).forEach( (el) => { + el.parentNode.removeChild(el); + }); + // For the GET request send using the default flow + if (curForm.getAttribute('method') != 'get') { + e.preventDefault(); + curForm.submit(); + } }); }, template: ` From dc07d6825db6cc349ed5903ca1fe203da2754fbd Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 11 May 2022 19:30:15 +0300 Subject: [PATCH 11/20] Reset coptions table before calculating the date fields --- app/templates/element/filter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index ece0da3e2..11535d325 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -201,6 +201,7 @@ // 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'] = 'Starts at:'; $coptions['required'] = false; @@ -234,6 +235,7 @@ // 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 From 0d3ff1abf17be4735942ccfc026c672f78e7966a Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Wed, 11 May 2022 20:28:32 +0300 Subject: [PATCH 12/20] Fixed top filter button clear functionality --- app/templates/element/filter.php | 6 +++++- app/templates/element/javascript.php | 10 ++++------ app/webroot/js/comanage/comanage.js | 2 +- .../components/datepicker/cm-datetimepicker.js | 5 ++++- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index 11535d325..26f170713 100644 --- a/app/templates/element/filter.php +++ b/app/templates/element/filter.php @@ -99,6 +99,10 @@ -
@@ -122,7 +122,7 @@ ['escape' => false]); ?> - Html->link(__('registry.meta.registry'), '/'); ?> + Html->link(__('registry.meta.registry'), '/') ?>
@@ -146,7 +146,7 @@ @@ -158,7 +158,7 @@ @@ -166,14 +166,14 @@ - fetch('content'); ?> + fetch('content') ?>
- element('footer'); ?> + element('footer') ?>
@@ -190,10 +190,10 @@ class="toast-container"
- element('dialog'); ?> + element('dialog') ?> - Html->script('jstimezonedetect/jstz.min.js'); ?> + Html->script('jstimezonedetect/jstz.min.js') ?>