Skip to content

Add filter selection UI to the top-filters on an index view (CFM-296) #131

Merged
merged 4 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/resources/locales/en_US/menu.po
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
msgid "artifacts"
msgstr "Available {0} Artifacts"

msgid "available.filters"
msgstr "Available Filters"

msgid "co.artifacts"
msgstr "Artifacts"

Expand Down Expand Up @@ -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"

Expand Down
4 changes: 3 additions & 1 deletion app/src/Controller/PeopleController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -49,7 +50,8 @@ class PeopleController extends StandardController {
'sortableFields' => [
'PrimaryName.given',
'PrimaryName.family'
]
],
'finder' => 'indexed'
];

/**
Expand Down
10 changes: 7 additions & 3 deletions app/src/Controller/StandardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
Expand Down
100 changes: 89 additions & 11 deletions app/src/Lib/Traits/SearchFilterTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -81,36 +135,60 @@ 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
$search_types = ['integer', 'boolean'];
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
Expand All @@ -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));
});
Expand Down
8 changes: 7 additions & 1 deletion app/src/Lib/Util/StringUtilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 62 additions & 6 deletions app/src/Model/Table/PeopleTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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;
}
}

Expand Down
Loading