Merge branch 'index' into main

connector
iglocska 2021-03-26 12:25:11 +01:00
commit f08bf54f28
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
15 changed files with 1342 additions and 130 deletions

View File

@ -0,0 +1,477 @@
<?php
namespace App\Command;
use Cake\Console\Command;
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Filesystem\File;
use Cake\Utility\Hash;
use Cake\Utility\Text;
use Cake\Validation\Validator;
use Cake\Http\Client;
class FieldSquasherCommand extends Command
{
protected $modelClass = 'Organisations';
private $targetModel = 'Organisations';
protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
{
$parser->setDescription('Squash field value from external data source');
$parser->addArgument('config', [
'help' => 'JSON configuration file path for the importer.',
'required' => true
]);
return $parser;
}
public function execute(Arguments $args, ConsoleIo $io)
{
$this->io = $io;
$configPath = $args->getArgument('config');
$config = $this->getConfigFromFile($configPath);
$this->processConfig($config);
$this->modelClass = $config['target']['model'];
$source = $config['source'];
$table = $this->modelClass;
$this->loadModel($table);
$sourceData = $this->getDataFromSource($source);
$candidateResult = $this->findCanditates($this->{$table}, $config, $sourceData);
$entitiesSample = array_slice($candidateResult['candidates'], 0, min(10, count($candidateResult['candidates'])));
$entitiesSample = array_slice($candidateResult['candidates'], 0, min(100, count($candidateResult['candidates'])));
$noCandidatesSample = array_slice($candidateResult['noCandidatesFound'], 0, min(10, count($candidateResult['noCandidatesFound'])));
$notExactCandidates = array_slice($candidateResult['notExactCandidates'], 0, min(10, count($candidateResult['notExactCandidates'])));
$totalNotFound = count($candidateResult['noCandidatesFound']);
$totalClosestFound = count($candidateResult['notExactCandidates']);
$totalFound = count($candidateResult['candidates']);
$this->io->out("Sample of no candidates found (total: {$totalNotFound}):");
$ioTable = $this->transformEntitiesIntoTable($noCandidatesSample);
$io->helper('Table')->output($ioTable);
$filename = 'no_candidates_found_' . time() . '.json';
$selection = $io->askChoice("Would you like to save these entries on the disk as `{$filename}`", ['Y', 'N'], 'Y');
if ($selection == 'Y') {
$this->saveDataOnDisk($filename, $candidateResult['noCandidatesFound']);
}
$this->io->out('');
if (!empty($notExactCandidates)) {
$this->io->out("Sample of closest candidates found (total not strictly matching: {$totalClosestFound}):");
$ioTable = $this->transformEntitiesIntoTable($notExactCandidates);
$io->helper('Table')->output($ioTable);
$filename = 'closest_candidates_found_' . time() . '.json';
$selection = $io->askChoice("Would you like to save these entries on the disk as `{$filename}`", ['Y', 'N'], 'Y');
if ($selection == 'Y') {
$this->saveDataOnDisk($filename, $candidateResult['notExactCandidates']);
}
$this->io->out('');
}
$this->io->out("Sample of exact candidates found (total striclty matching: {$totalFound}):");
$ioTable = $this->transformEntitiesIntoTable($entitiesSample, [
'id',
$config['finder']['joinFields']['squashed'],
$config['target']['squashedField'],
"{$config['target']['squashedField']}_original_value",
'based_on_best_match',
'best_candidates_found',
'match_score'
]);
$io->helper('Table')->output($ioTable);
$filename = 'replacement_done_' . time() . '.json';
$selection = $io->askChoice("Would you like to save these entries on the disk as `{$filename}`", ['Y', 'N'], 'Y');
if ($selection == 'Y') {
$this->saveDataOnDisk($filename, $candidateResult['candidates']);
}
die(1);
$selection = $io->askChoice('A sample of the data you about to be saved is provided above. Would you like to proceed?', ['Y', 'N'], 'N');
if ($selection == 'Y') {
// $this->saveData($this->{$table}, $entities);
}
}
private function saveData($table, $entities)
{
$this->loadModel('MetaFields');
$this->io->verbose('Saving data');
$progress = $this->io->helper('Progress');
$entities = $table->saveMany($entities);
if ($entities === false) {
$this->io->error('Error while saving data');
}
}
private function findCanditates($table, $config, $source)
{
$this->io->verbose('Finding candidates');
if ($config['finder']['type'] == 'exact') {
$candidateResult = $this->findCanditatesByStrictMatching($table, $config, $source);
} else if ($config['finder']['type'] == 'closest') {
$candidateResult = $this->findCanditatesByClosestMatch($table, $config, $source);
} else {
$this->io->error('Unsupported search type');
die(1);
}
return $candidateResult;
}
private function findCanditatesByStrictMatching($table, $config, $source)
{
$squashingObjects = Hash::extract($source, $config['finder']['path']);
if (empty($squashingObjects)) {
$this->io->error('finder.path returned nothing');
return [];
}
$values = Hash::extract($squashingObjects, "{n}.{$config['finder']['joinFields']['squashing']}");
$query = $table->find('list', [
'keyField' => $config['finder']['joinFields']['squashed'],
'valueField' => function ($entry) {
return $entry;
}
])->where([
"{$config['finder']['joinFields']['squashed']} IN" => $values
]);
$potentialCanditates = $query->toArray();
$candidates = [];
$noCandidatesFound = [];
foreach ($squashingObjects as $squashingObject) {
$squashingJoinField = Hash::get($squashingObject, $config['finder']['joinFields']['squashing']);
if (empty($potentialCanditates[$squashingJoinField])) {
$noCandidatesFound[] = $squashingObject;
} else {
$squashingData = Hash::get($squashingObject, $config['squashingData']['squashingField']);
if (isset($this->{$config['squashingData']['massage']})) {
$squashingData = $this->{$config['squashingData']['massage']}($squashingData);
}
$squashedTarget = $potentialCanditates[$squashingJoinField];
$squashedTarget->{"{$config['target']['squashedField']}_original_value"} = $squashedTarget->{$config['target']['squashedField']};
$squashedTarget->{$config['target']['squashedField']} = $squashingData;
$candidates[] = $squashedTarget;
}
}
return [
'candidates' => $candidates,
'notExactCandidates' => [],
'noCandidatesFound' => $noCandidatesFound,
];
}
private function findCanditatesByClosestMatch($table, $config, $source)
{
$squashingObjects = Hash::extract($source, $config['finder']['path']);
if (empty($squashingObjects)) {
$this->io->error('finder.path returned nothing');
return [];
}
$query = $table->find();
$allCanditates = $query->toArray();
$squashingJoinField = $config['finder']['joinFields']['squashing'];
$squashedJoinField = $config['finder']['joinFields']['squashed'];
$closestMatchResults = [];
$squashingObjects = $this->getBestOccurenceSet($squashingObjects, $allCanditates, $squashingJoinField, $squashedJoinField);
// pick the best match
foreach ($squashingObjects as $i => $squashingObject) {
if (empty($squashingObjects[$i]['__scores'])) {
continue;
}
ksort($squashingObjects[$i]['__scores'], SORT_NUMERIC);
$squashingObjects[$i]['__scores'] = array_slice($squashingObjects[$i]['__scores'], 0, 1, true);
$bestScore = array_key_first($squashingObjects[$i]['__scores']);
$squashingObjects[$i]['__scores'][$bestScore] = array_values($squashingObjects[$i]['__scores'][$bestScore])[0];
}
$candidates = [];
$noCandidatesFound = [];
$notExactCandidates = [];
$scoreThreshold = !empty($config['finder']['levenshteinScore']) ? $config['finder']['levenshteinScore'] : 10;
foreach ($squashingObjects as $i => $squashingObject) {
if (empty($squashingObjects[$i]['__scores'])) {
$noCandidatesFound[] = $squashingObject;
continue;
}
$bestScore = array_key_first($squashingObject['__scores']);
$bestMatch = $squashingObject['__scores'][$bestScore];
$squashingData = Hash::get($squashingObject, $config['squashingData']['squashingField']);
if (isset($this->{$config['squashingData']['massage']})) {
$squashingData = $this->{$config['squashingData']['massage']}($squashingData);
}
$squashedTarget = $bestMatch;
if ($bestScore <= $scoreThreshold) {
$squashedTarget["{$config['target']['squashedField']}_original_value"] = $squashedTarget[$config['target']['squashedField']];
$squashedTarget['match_score'] = $bestScore;
$squashedTarget['based_on_best_match_joinFields'] = Hash::get($squashingObject, $squashingJoinField);
// $squashedTarget['based_on_best_match'] = json_encode($squashingObject);
$squashedTarget[$config['target']['squashedField']] = $squashingData;
if ($bestScore > 0) {
$notExactCandidates[] = $squashedTarget;
} else {
$candidates[] = $squashedTarget;
}
} else {
$squashingObjectBestMatchInfo = "[{$bestMatch[$squashingJoinField]}, {$bestScore}]";
$squashingObject['__scores'] = $squashingObjectBestMatchInfo;
$noCandidatesFound[] = $squashingObject;
}
}
return [
'candidates' => $candidates,
'notExactCandidates' => $notExactCandidates,
'noCandidatesFound' => $noCandidatesFound
];
}
private function removeCandidatesFromSquashingSet($squashingObjects, $bestMatch, $candidateID)
{
foreach ($squashingObjects as $i => $squashingObject) {
if (Hash::remove($squashingObject, '__scores') == $bestMatch) {
continue;
} else {
foreach ($squashingObject['__scores'] as $score => $candidates) {
foreach ($candidates as $j => $candidate) {
if ($candidate['id'] == $candidateID) {
unset($squashingObjects[$i]['__scores'][$score][$j]);
}
}
if (empty($squashingObjects[$i]['__scores'][$score])) {
unset($squashingObjects[$i]['__scores'][$score]);
}
}
}
}
return $squashingObjects;
}
private function getBestOccurenceSet($squashingObjects, $allCanditates, $squashingJoinField, $squashedJoinField)
{
// Compute proximity score
foreach ($squashingObjects as $i => $squashingObject) {
$squashingJoinValue = Hash::get($squashingObject, $squashingJoinField);
foreach ($allCanditates as $candidate) {
$squashedJoinValue = Hash::get($candidate, $squashedJoinField);
$proximityScore = $this->getProximityScore($squashingJoinValue, $squashedJoinValue);
$closestMatchResults[$candidate['id']][$proximityScore][] = $squashingObject;
$squashingObjects[$i]['__scores'][$proximityScore][] = $candidate;
}
}
// sort by score
foreach ($squashingObjects as $i => $squashingObject) {
ksort($squashingObjects[$i]['__scores'], SORT_NUMERIC);
}
foreach ($closestMatchResults as $i => $proximityScore) {
ksort($closestMatchResults[$i], SORT_NUMERIC);
}
// remove best occurence in other matching sets
foreach ($allCanditates as $candidate) {
$bestScore = array_key_first($closestMatchResults[$candidate['id']]);
$bestMatch = $closestMatchResults[$candidate['id']][$bestScore][0];
$squashingObjects = $this->removeCandidatesFromSquashingSet($squashingObjects, $bestMatch, $candidate['id']);
}
return $squashingObjects;
}
private function getProximityScore($value1, $value2)
{
if ($value1 == $value2) {
return -1;
} else {
return levenshtein(strtolower($value1), strtolower($value2));
}
}
private function getDataFromSource($source)
{
$data = $this->getDataFromFile($source);
if ($data === false) {
$data = $this->getDataFromURL($source);
}
return $data;
}
private function getDataFromURL($url)
{
$validator = new Validator();
$validator
->requirePresence('url')
->notEmptyString('url', 'Please provide a valid source')
->url('url');
$errors = $validator->validate(['url' => $url]);
if (!empty($errors)) {
$this->io->error(json_encode(Hash::extract($errors, '{s}'), JSON_PRETTY_PRINT));
die(1);
}
$http = new Client();
$this->io->verbose('Downloading file');
$response = $http->get($url);
return $response->getJson();
}
private function getDataFromFile($path)
{
$file = new File($path);
if ($file->exists()) {
$this->io->verbose('Reading file');
$data = $file->read();
$file->close();
if (!empty($data)) {
$data = json_decode($data, true);
if (is_null($data)) {
$this->io->error('Error while parsing the source file');
die(1);
}
return $data;
}
}
return false;
}
private function saveDataOnDisk($filename, $data)
{
$file = new File($filename, true);
$file->write(json_encode($data));
$this->io->out("File saved at: {$file->pwd()}");
$file->close();
}
private function getConfigFromFile($configPath)
{
$file = new File($configPath);
if ($file->exists()) {
$config = $file->read();
$file->close();
if (!empty($config)) {
$config = json_decode($config, true);
if (is_null($config)) {
$this->io->error('Error while parsing the configuration file');
die(1);
}
return $config;
} else {
$this->io->error('Configuration file cound not be read');
}
} else {
$this->io->error('Configuration file not found');
}
}
private function processConfig($config)
{
$allowedModels = ['Organisations', 'Individuals'];
$allowedFinderType = ['exact', 'closest'];
if (empty($config['source']) || empty($config['finder']) || empty($config['target']) || empty($config['squashingData'])) {
$this->io->error('Error while parsing the configuration file, some of these fields are missing: `source`, `finder`, `target`, `squashingData`');
die(1);
}
if (!empty($config['target']['model'])) {
if (!in_array($config['target']['model'], $allowedModels)) {
$this->io->error('Error while parsing the configuration file, target.model configuration must be one of: ' . implode(', ', $allowedModels));
die(1);
}
} else {
$this->io->error('Error while parsing the configuration file, target.model configuration is missing');
die(1);
}
if (empty($config['finder']['path']) || empty($config['finder']['joinFields'])) {
$this->io->error('Error while parsing the configuration file, some finder fields are missing');
die(1);
}
if (!empty($config['finder']['type'])) {
if (!in_array($config['finder']['type'], $allowedFinderType)) {
$this->io->error('Error while parsing the configuration file, finder.type configuration must be one of: ' . implode(', ', $allowedFinderType));
die(1);
}
} else {
$this->io->error('Error while parsing the configuration file, finder.type configuration is missing');
die(1);
}
}
private function transformResultSetsIntoTable($result, $header=[])
{
$table = [[]];
if (!empty($result)) {
$tableHeader = empty($header) ? array_keys($result[0]) : $header;
$tableContent = [];
foreach ($result as $item) {
if (empty($header)) {
$tableContent[] = array_map('strval', array_values($item));
} else {
$row = [];
foreach ($tableHeader as $key) {
$row[] = (string) $item[$key];
}
$tableContent[] = $row;
}
}
$table = array_merge([$tableHeader], $tableContent);
}
return $table;
}
private function transformEntitiesIntoTable($entities, $header=[])
{
$table = [[]];
if (!empty($entities)) {
if (empty($header)) {
if (!is_array($entities[0])) {
$tableHeader = array_keys(Hash::flatten($entities[0]->toArray()));
} else {
$tableHeader = array_keys($entities[0]);
}
} else {
$tableHeader = $header;
}
$tableContent = [];
foreach ($entities as $entity) {
$row = [];
foreach ($tableHeader as $key) {
$subKeys = explode('.', $key);
if (is_array($entity[$key])) {
$row[] = json_encode($entity[$key]);
} else {
$row[] = (string) $entity[$key];
}
}
$tableContent[] = $row;
}
$table = array_merge([$tableHeader], $tableContent);
}
return $table;
}
private function invertArray($data)
{
$inverted = [];
foreach ($data as $key => $values) {
foreach ($values as $i => $value) {
$inverted[$i][$key] = $value;
}
}
return $inverted;
}
private function genUUID($value)
{
return Text::uuid();
}
private function nullToEmptyString($value)
{
return is_null($value) ? '' : $value;
}
}

View File

@ -42,6 +42,9 @@ class ImporterCommand extends Command
protected $modelClass = 'Organisations';
private $fieldsNoOverride = [];
private $format = 'json';
private $noMetaTemplate = false;
private $autoYes = false;
private $updateOnly = false;
protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
{
@ -64,6 +67,18 @@ class ImporterCommand extends Command
'help' => 'The target cerebrate model for the import',
'default' => 'Organisations',
'choices' => ['Organisations', 'Individuals', 'AuthKeys']
]);
$parser->addOption('yes', [
'short' => 'y',
'help' => 'Automatically assume yes to any prompts',
'default' => false,
'boolean' => true
]);
$parser->addOption('update-only', [
'short' => 'u',
'help' => 'Only update existing record. No new record will be created. primary_key MUST be supplied',
'default' => false,
'boolean' => true
]);
return $parser;
}
@ -78,27 +93,38 @@ class ImporterCommand extends Command
if (!is_null($model_class)) {
$this->modelClass = $model_class;
}
$this->autoYes = $args->getOption('yes');
$this->updateOnly = $args->getOption('update-only');
if ($this->updateOnly && is_null($primary_key)) {
$io->error('A `primary_key` must be supplied when using `--update-only` mode.');
die(1);
}
$table = $this->modelClass;
$this->loadModel($table);
$config = $this->getConfigFromFile($configPath);
$this->processConfig($config);
$sourceData = $this->getDataFromSource($source);
$sourceData = $this->getDataFromSource($source, $config);
$data = $this->extractData($this->{$table}, $config, $sourceData);
$entities = $this->marshalData($this->{$table}, $data, $config, $primary_key);
$entitiesSample = array_slice($entities, 0, min(10, count($entities)));
$entitiesSample = array_slice($entities, 0, min(5, count($entities)));
$ioTable = $this->transformEntitiesIntoTable($entitiesSample);
$io->helper('Table')->output($ioTable);
$selection = $io->askChoice('A sample of the data you are about to save is provided above. Would you like to proceed?', ['Y', 'N'], 'N');
if ($selection == 'Y') {
if ($this->autoYes) {
$this->saveData($this->{$table}, $entities);
} else {
$selection = $io->askChoice('A sample of the data you about to be saved is provided above. Would you like to proceed?', ['Y', 'N'], 'N');
if ($selection == 'Y') {
$this->saveData($this->{$table}, $entities);
}
}
}
private function marshalData($table, $data, $config, $primary_key=null)
{
$this->loadModel('MetaTemplates');
$this->loadModel('MetaFields');
$entities = [];
if (is_null($primary_key)) {
@ -112,42 +138,71 @@ class ImporterCommand extends Command
$entity = $query->first();
}
if (is_null($entity)) {
$entity = $table->newEmptyEntity();
if (!$this->updateOnly) {
$entity = $table->newEmptyEntity();
}
} else {
$this->lockAccess($entity);
}
$entity = $table->patchEntity($entity, $item);
$entities[] = $entity;
if (!is_null($entity)) {
$entity = $table->patchEntity($entity, $item);
$entities[] = $entity;
}
}
}
$hasErrors = false;
if (!$this->noMetaTemplate) {
$metaTemplate = $this->MetaTemplates->find()
->where(['uuid' => $config['metaTemplateUUID']])
->first();
if (!is_null($metaTemplate)) {
$metaTemplateFieldsMapping = $this->MetaTemplates->MetaTemplateFields->find('list', [
'keyField' => 'field',
'valueField' => 'id'
])->where(['meta_template_id' => $metaTemplate->id])->toArray();
} else {
$this->io->error("Unkown template for UUID $metaTemplateUUID");
die(1);
}
}
foreach ($entities as $i => $entity) {
if ($entity->hasErrors()) {
$hasErrors = true;
$this->io->error(json_encode(['entity' => $entity, 'errors' => $entity->getErrors()], JSON_PRETTY_PRINT));
} else {
$metaFields = [];
foreach ($entity['metaFields'] as $fieldName => $fieldValue) {
$metaEntity = null;
if (!$entity->isNew()) {
$query = $this->MetaFields->find('all')->where([
'parent_id' => $entity->id,
'field' => $fieldName
]);
$metaEntity = $query->first();
if (!$this->noMetaTemplate && !is_null($metaTemplate)) {
$metaFields = [];
foreach ($entity['metaFields'] as $fieldName => $fieldValue) {
$metaEntity = null;
if (!$entity->isNew()) {
$query = $this->MetaFields->find('all')->where([
'parent_id' => $entity->id,
'field' => $fieldName,
'meta_template_id' => $metaTemplate->id
]);
$metaEntity = $query->first();
}
if (is_null($metaEntity)) {
$metaEntity = $this->MetaFields->newEmptyEntity();
$metaEntity->field = $fieldName;
$metaEntity->scope = $table->metaFields;
$metaEntity->meta_template_id = $metaTemplate->id;
if (isset($metaTemplateFieldsMapping[$fieldName])) { // a meta field template must exists
$metaEntity->meta_template_field_id = $metaTemplateFieldsMapping[$fieldName];
} else {
$hasErrors = true;
$this->io->error("Field $fieldName is unkown for template {$metaTemplate->name}");
break;
}
}
if ($this->canBeOverriden($metaEntity)) {
$metaEntity->value = $fieldValue;
}
$metaFields[] = $metaEntity;
}
if (is_null($metaEntity)) {
$metaEntity = $this->MetaFields->newEmptyEntity();
$metaEntity->field = $fieldName;
$metaEntity->scope = $table->metaFields;
$metaEntity->parent_id = $entity->id;
}
if ($this->canBeOverriden($metaEntity)) {
$metaEntity->value = $fieldValue;
}
$metaFields[] = $metaEntity;
$entities[$i]->metaFields = $metaFields;
}
$entities[$i]->metaFields = $metaFields;
}
}
if (!$hasErrors) {
@ -176,7 +231,9 @@ class ImporterCommand extends Command
'length' => 20
]);
foreach ($entities as $i => $entity) {
$this->saveMetaFields($entity);
if (!$this->noMetaTemplate) {
$this->saveMetaFields($entity);
}
$progress->increment(1);
$progress->draw();
}
@ -188,6 +245,7 @@ class ImporterCommand extends Command
foreach ($entity->metaFields as $i => $metaEntity) {
$metaEntity->parent_id = $entity->id;
if ($metaEntity->hasErrors() || is_null($metaEntity->value)) {
$this->io->error(json_encode(['entity' => $metaEntity, 'errors' => $metaEntity->getErrors()], JSON_PRETTY_PRINT));
unset($entity->metaFields[$i]);
}
}
@ -270,16 +328,16 @@ class ImporterCommand extends Command
return !in_array($metaEntity->field, $this->fieldsNoOverride);
}
private function getDataFromSource($source)
private function getDataFromSource($source, $config)
{
$data = $this->getDataFromFile($source);
if ($data === false) {
$data = $this->getDataFromURL($source);
$data = $this->getDataFromURL($source, $config);
}
return $data;
}
private function getDataFromURL($url)
private function getDataFromURL($url, $config)
{
$validator = new Validator();
$validator
@ -293,7 +351,11 @@ class ImporterCommand extends Command
}
$http = new Client();
$this->io->verbose('Downloading file');
$response = $http->get($url);
$httpConfig = [
'headers' => !empty($config['sourceHeaders']) ? $config['sourceHeaders'] : []
];
$query = [];
$response = $http->get($url, $query, $httpConfig);
if ($this->format == 'json') {
return $response->getJson();
} else if ($this->format == 'csv') {
@ -355,6 +417,10 @@ class ImporterCommand extends Command
$this->io->error('Error while parsing the configuration file, mapping missing');
die(1);
}
if (empty($config['metaTemplateUUID'])) {
$this->io->warning('No `metaTemplateUUID` provided. No meta fields will be created.');
$this->noMetaTemplate = true;
}
if (!empty($config['format'])) {
$this->format = $config['format'];
}
@ -454,4 +520,9 @@ class ImporterCommand extends Command
{
return Text::uuid();
}
private function nullToEmptyString($value)
{
return is_null($value) ? '' : $value;
}
}

View File

@ -0,0 +1,20 @@
{
"source": "/var/www/cerebrate/src/Command/misp_org.json",
"finder": {
"joinFields": {
"squashed": "name",
"squashing": "name"
},
"path": "{n}.Organisation",
"type": "closest",
"levenshteinScore": 1
},
"target": {
"model": "Organisations",
"squashedField": "uuid"
},
"squashingData": {
"squashingField": "uuid",
"massage": "validateUUID"
}
}

View File

@ -30,9 +30,15 @@ class CRUDComponent extends Component
}
$options['filters'][] = 'quickFilter';
}
$params = $this->Controller->ParamHandler->harvestParams(empty($options['filters']) ? [] : $options['filters']);
$options['filters'][] = 'filteringLabel';
$optionFilters = empty($options['filters']) ? [] : $options['filters'];
foreach ($optionFilters as $i => $filter) {
$optionFilters[] = "{$filter} !=";
}
$params = $this->Controller->ParamHandler->harvestParams($optionFilters);
$query = $this->Table->find();
$query = $this->setFilters($params, $query);
$query = $this->setFilters($params, $query, $options);
$query = $this->setQuickFilters($params, $query, empty($options['quickFilters']) ? [] : $options['quickFilters']);
if (!empty($options['contain'])) {
$query->contain($options['contain']);
@ -49,6 +55,14 @@ class CRUDComponent extends Component
$this->Controller->set('data', $data);
}
}
public function filtering(): void
{
$filters = !empty($this->Controller->filters) ? $this->Controller->filters : [];
$this->Controller->set('filters', $filters);
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/filters');
}
/**
* getResponsePayload Returns the adaquate response payload based on the request context
@ -243,21 +257,35 @@ class CRUDComponent extends Component
if (empty($this->Table->metaFields)) {
return $data;
}
$query = $this->MetaFields->MetaTemplates->find();
$metaFields = $this->Table->metaFields;
$query->contain('MetaTemplateFields', function ($q) use ($id, $metaFields) {
return $q->innerJoinWith('MetaFields')
->where(['MetaFields.scope' => $metaFields, 'MetaFields.parent_id' => $id]);
});
$query->innerJoinWith('MetaTemplateFields', function ($q) {
return $q->contain('MetaFields')->innerJoinWith('MetaFields');
});
$query->group(['MetaTemplates.id'])->order(['MetaTemplates.is_default' => 'DESC']);
$metaTemplates = $query->all();
$metaFieldScope = $this->Table->metaFields;
$query = $this->MetaTemplates->find()->where(['MetaTemplates.scope' => $metaFieldScope]);
$query->contain(['MetaTemplateFields.MetaFields' => function ($q) use ($id, $metaFieldScope) {
return $q->where(['MetaFields.scope' => $metaFieldScope, 'MetaFields.parent_id' => $id]);
}]);
$query
->order(['MetaTemplates.is_default' => 'DESC'])
->order(['MetaTemplates.name' => 'ASC']);
$metaTemplates = $query->all()->toArray();
$metaTemplates = $this->pruneEmptyMetaTemplates($metaTemplates);
$data['metaTemplates'] = $metaTemplates;
return $data;
}
public function pruneEmptyMetaTemplates($metaTemplates)
{
foreach ($metaTemplates as $i => $metaTemplate) {
foreach ($metaTemplate['meta_template_fields'] as $j => $metaTemplateField) {
if (empty($metaTemplateField['meta_fields'])) {
unset($metaTemplates[$i]['meta_template_fields'][$j]);
}
}
if (empty($metaTemplates[$i]['meta_template_fields'])) {
unset($metaTemplates[$i]);
}
}
return $metaTemplates;
}
public function getMetaFields($id, $data)
{
if (empty($this->Table->metaFields)) {
@ -360,41 +388,90 @@ class CRUDComponent extends Component
return $query;
}
protected function setFilters($params, \Cake\ORM\Query $query): \Cake\ORM\Query
protected function setFilters($params, \Cake\ORM\Query $query, array $options): \Cake\ORM\Query
{
$params = $this->massageFilters($params);
$conditions = array();
if (!empty($params['simpleFilters'])) {
foreach ($params['simpleFilters'] as $filter => $filterValue) {
if ($filter === 'quickFilter') {
continue;
}
if (is_array($filterValue)) {
$query->where([($filter . ' IN') => $filterValue]);
} else {
if (strlen(trim($filterValue, '%')) === strlen($filterValue)) {
$query->where([$filter => $filterValue]);
} else {
$query->like([$filter => $filterValue]);
}
$filteringLabel = !empty($params['filteringLabel']) ? $params['filteringLabel'] : '';
unset($params['filteringLabel']);
$customFilteringFunction = '';
$chosenFilter = '';
if (!empty($options['contextFilters']['custom'])) {
foreach ($options['contextFilters']['custom'] as $filter) {
if ($filter['label'] == $filteringLabel) {
$customFilteringFunction = $filter;
$chosenFilter = $filter;
break;
}
}
}
if (!empty($params['relatedFilters'])) {
foreach ($params['relatedFilters'] as $filter => $filterValue) {
$filterParts = explode('.', $filter);
$query->matching($filterParts[0], function(\Cake\ORM\Query $q) use ($filterValue, $filter) {
if (strlen(trim($filterValue, '%')) === strlen($filterValue)) {
return $q->where([$filter => $filterValue]);
} else {
return $q->like([$filter => $filterValue]);
}
});
$activeFilters = [];
if (!empty($customFilteringFunction['filterConditionFunction'])) {
$query = $customFilteringFunction['filterConditionFunction']($query);
$activeFilters['filteringLabel'] = $filteringLabel;
} else {
if (!empty($chosenFilter)) {
$params = $this->massageFilters($chosenFilter['filterCondition']);
} else {
$params = $this->massageFilters($params);
}
if (!empty($params['simpleFilters'])) {
foreach ($params['simpleFilters'] as $filter => $filterValue) {
$activeFilters[$filter] = $filterValue;
if ($filter === 'quickFilter') {
continue;
}
if (is_array($filterValue)) {
$query->where([($filter . ' IN') => $filterValue]);
} else {
$query = $this->setValueCondition($query, $filter, $filterValue);
}
}
}
if (!empty($params['relatedFilters'])) {
foreach ($params['relatedFilters'] as $filter => $filterValue) {
$activeFilters[$filter] = $filterValue;
$filterParts = explode('.', $filter);
$query = $this->setNestedRelatedCondition($query, $filterParts, $filterValue);
}
}
}
$this->Controller->set('activeFilters', $activeFilters);
return $query;
}
protected function setNestedRelatedCondition($query, $filterParts, $filterValue)
{
$modelName = $filterParts[0];
if (count($filterParts) == 2) {
$fieldName = implode('.', $filterParts);
$query = $this->setRelatedCondition($query, $modelName, $fieldName, $filterValue);
} else {
$filterParts = array_slice($filterParts, 1);
$query = $query->matching($modelName, function(\Cake\ORM\Query $q) use ($filterParts, $filterValue) {
return $this->setNestedRelatedCondition($q, $filterParts, $filterValue);
});
}
return $query;
}
protected function setRelatedCondition($query, $modelName, $fieldName, $filterValue)
{
return $query->matching($modelName, function(\Cake\ORM\Query $q) use ($fieldName, $filterValue) {
return $this->setValueCondition($q, $fieldName, $filterValue);
});
}
protected function setValueCondition($query, $fieldName, $value)
{
if (strlen(trim($value, '%')) === strlen($value)) {
return $query->where([$fieldName => $value]);
} else {
return $query->where(function ($exp, \Cake\ORM\Query $q) use ($fieldName, $value) {
return $exp->like($fieldName, $value);
});
}
}
protected function setFilteringContext($contextFilters, $params)
{
$filteringContexts = [];
@ -425,6 +502,57 @@ class CRUDComponent extends Component
$this->Controller->set('filteringContexts', $filteringContexts);
}
public function setParentConditionsForMetaFields($query, array $metaConditions)
{
$metaTemplates = $this->MetaFields->MetaTemplates->find('list', [
'keyField' => 'name',
'valueField' => 'id'
])->where(['name IN' => array_keys($metaConditions)])->all()->toArray();
$fieldsConditions = [];
foreach ($metaConditions as $templateName => $templateConditions) {
$metaTemplateID = isset($metaTemplates[$templateName]) ? $metaTemplates[$templateName] : -1;
foreach ($templateConditions as $conditions) {
$conditions['meta_template_id'] = $metaTemplateID;
$fieldsConditions[] = $conditions;
}
}
$matchingMetaQuery = $this->getParentIDQueryForMetaANDConditions($fieldsConditions);
return $query->where(['id IN' => $matchingMetaQuery]);
}
private function getParentIDQueryForMetaANDConditions(array $metaANDConditions)
{
if (empty($metaANDConditions)) {
throw new Exception('Invalid passed conditions');
}
foreach ($metaANDConditions as $i => $conditions) {
$metaANDConditions[$i]['scope'] = $this->Table->metaFields;
}
$firstCondition = $this->prefixConditions('MetaFields', $metaANDConditions[0]);
$conditionsToJoin = array_slice($metaANDConditions, 1);
$query = $this->MetaFields->find()
->select('parent_id')
->where($firstCondition);
foreach ($conditionsToJoin as $i => $conditions) {
$joinedConditions = $this->prefixConditions("m{$i}", $conditions);
$joinedConditions[] = "m{$i}.parent_id = MetaFields.parent_id";
$query->rightJoin(
["m{$i}" => 'meta_fields'],
$joinedConditions
);
}
return $query;
}
private function prefixConditions(string $prefix, array $conditions)
{
$prefixedConditions = [];
foreach ($conditions as $condField => $condValue) {
$prefixedConditions["${prefix}.${condField}"] = $condValue;
}
return $prefixedConditions;
}
public function toggle(int $id, string $fieldName = 'enabled', array $params = []): void
{
if (empty($id)) {

View File

@ -24,6 +24,7 @@ class ParamHandlerComponent extends Component
$parsedParams = array();
foreach ($filterList as $k => $filter) {
$queryString = str_replace('.', '_', $filter);
$queryString = str_replace(' ', '_', $queryString);
if ($this->request->getQuery($queryString) !== null) {
$parsedParams[$filter] = $this->request->getQuery($queryString);
continue;

View File

@ -12,11 +12,51 @@ use Cake\Http\Exception\ForbiddenException;
class OrganisationsController extends AppController
{
public $filters = ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id', 'MetaFields.field', 'MetaFields.value', 'MetaFields.MetaTemplates.name'];
public function index()
{
$this->CRUD->index([
'filters' => ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id'],
'quickFilters' => ['name', 'uuid', 'nationality', 'sector', 'type', 'url'],
'filters' => $this->filters,
'quickFilters' => [['name' => true], 'uuid', 'nationality', 'sector', 'type', 'url'],
'contextFilters' => [
'custom' => [
[
'label' => __('ENISA Accredited'),
'filterCondition' => [
'MetaFields.field' => 'enisa-tistatus',
'MetaFields.value' => 'Accredited',
'MetaFields.MetaTemplates.name' => 'ENISA CSIRT Network'
]
],
[
'label' => __('ENISA not-Accredited'),
'filterCondition' => [
'MetaFields.field' => 'enisa-tistatus',
'MetaFields.value !=' => 'Accredited',
'MetaFields.MetaTemplates.name' => 'ENISA CSIRT Network'
]
],
[
'label' => __('ENISA CSIRT Network (GOV)'),
'filterConditionFunction' => function($query) {
return $this->CRUD->setParentConditionsForMetaFields($query, [
'ENISA CSIRT Network' => [
[
'field' => 'constituency',
'value LIKE' => '%Government%',
],
[
'field' => 'csirt-network-status',
'value' => 'Member',
],
]
]);
}
]
],
],
'contain' => ['Alignments' => 'Individuals']
]);
$responsePayload = $this->CRUD->getResponsePayload();
@ -27,6 +67,11 @@ class OrganisationsController extends AppController
$this->set('metaGroup', 'ContactDB');
}
public function filtering()
{
$this->CRUD->filtering();
}
public function add()
{
$this->CRUD->add();

View File

@ -27,6 +27,8 @@ class MetaFieldsTable extends AppTable
->notEmptyString('meta_template_id')
->notEmptyString('meta_template_field_id')
->requirePresence(['scope', 'field', 'value', 'uuid', 'meta_template_id', 'meta_template_field_id'], 'create');
// add validation regex
return $validator;
}
}

View File

@ -38,7 +38,7 @@ class OrganisationsTable extends AppTable
[
'dependent' => true,
'foreignKey' => 'parent_id',
'conditions' => ['scope' => 'organisation']
'conditions' => ['MetaFields.scope' => 'organisation']
]
);
$this->setDisplayField('name');

View File

@ -42,6 +42,7 @@
namespace App\View\Helper;
use Cake\View\Helper;
use Cake\Utility\Inflector;
use Cake\Utility\Security;
use InvalidArgumentException;
@ -70,11 +71,23 @@ class BootstrapHelper extends Helper
$bsButton = new BoostrapButton($options);
return $bsButton->button();
}
public function badge($options)
{
$bsBadge = new BoostrapBadge($options);
return $bsBadge->badge();
}
public function modal($options)
{
$bsButton = new BoostrapModal($options);
return $bsButton->modal();
}
}
class BootstrapGeneric
{
public static $variants = ['primary', 'success', 'danger', 'warning', 'info', 'light', 'dark', 'white', 'transparent'];
public static $variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark', 'white', 'transparent'];
protected $allowedOptionValues = [];
protected $options = [];
@ -121,6 +134,18 @@ class BootstrapGeneric
}
return sprintf('%s="%s"', $paramName, implode(' ', $values));
}
protected static function genericCloseButton($dismissTarget)
{
return BootstrapGeneric::genNode('button', [
'type' => 'button',
'class' => 'close',
'data-dismiss' => $dismissTarget,
'arial-label' => __('Close')
], BootstrapGeneric::genNode('span', [
'arial-hidden' => 'true'
], '&times;'));
}
}
class BootstrapTabs extends BootstrapGeneric
@ -392,16 +417,7 @@ class BoostrapAlert extends BootstrapGeneric {
{
$html = '';
if ($this->options['dismissible']) {
$html .= $this->openNode('button', [
'type' => 'button',
'class' => 'close',
'data-dismiss' => 'alert',
'arial-label' => 'close'
]);
$html .= $this->genNode('span', [
'arial-hidden' => 'true'
], '&times;');
$html .= $this->closeNode('button');
$html .= $this->genericCloseButton('alert');
}
return $html;
}
@ -460,7 +476,7 @@ class BoostrapTable extends BootstrapGeneric {
$this->options['hover'] ? 'table-hover' : '',
$this->options['small'] ? 'table-sm' : '',
!empty($this->options['variant']) ? "table-{$this->options['variant']}" : '',
!empty($this->options['tableClass']) ? $this->options['tableClass'] : ''
!empty($this->options['tableClass']) ? (is_array($this->options['tableClass']) ? implode(' ', $this->options['tableClass']) : $this->options['tableClass']) : ''
],
]);
@ -482,11 +498,17 @@ class BoostrapTable extends BootstrapGeneric {
$head .= $this->openNode('tr');
foreach ($this->fields as $i => $field) {
if (is_array($field)) {
$label = !empty($field['label']) ? $field['label'] : Inflector::humanize($field['key']);
if (!empty($field['labelHtml'])) {
$label = $field['labelHtml'];
} else {
$label = !empty($field['label']) ? $field['label'] : Inflector::humanize($field['key']);
$label = h($label);
}
} else {
$label = Inflector::humanize($field);
$label = h($label);
}
$head .= $this->genNode('th', [], h($label));
$head .= $this->genNode('th', [], $label);
}
$head .= $this->closeNode('tr');
$head .= $this->closeNode('thead');
@ -497,7 +519,7 @@ class BoostrapTable extends BootstrapGeneric {
{
$body = $this->openNode('tbody', [
'class' => [
!empty($this->options['bodyClass']) ? $this->options['bodyClass'] : ''
!empty($this->options['bodyClass']) ? (is_array($this->options['bodyClass']) ? implode(' ', $this->options['bodyClass']) : $this->options['bodyClass']) : ''
],
]);
foreach ($this->items as $i => $row) {
@ -566,7 +588,8 @@ class BoostrapButton extends BootstrapGeneric {
'class' => [],
'type' => 'button',
'nodeType' => 'button',
'params' => []
'params' => [],
'badge' => false
];
private $bsClasses = [];
@ -577,6 +600,9 @@ class BoostrapButton extends BootstrapGeneric {
'size' => ['', 'sm', 'lg'],
'type' => ['button', 'submit', 'reset']
];
if (empty($options['class'])) {
$options['class'] = '';
}
$options['class'] = !is_array($options['class']) ? [$options['class']] : $options['class'];
$this->processOptions($options);
}
@ -615,6 +641,10 @@ class BoostrapButton extends BootstrapGeneric {
$html .= $this->genIcon();
$html .= $this->genContent();
if (!empty($this->options['badge'])) {
$bsBadge = new BoostrapBadge($this->options['badge']);
$html .= $bsBadge->badge();
}
$html .= $this->closeNode($this->options['nodeType']);
return $html;
}
@ -630,4 +660,198 @@ class BoostrapButton extends BootstrapGeneric {
{
return !is_null($this->options['html']) ? $this->options['html'] : $this->options['text'];
}
}
}
class BoostrapBadge extends BootstrapGeneric {
private $defaultOptions = [
'text' => '',
'variant' => 'primary',
'pill' => false,
'title' => ''
];
function __construct($options) {
$this->allowedOptionValues = [
'variant' => BootstrapGeneric::$variants,
];
$this->processOptions($options);
}
private function processOptions($options)
{
$this->options = array_merge($this->defaultOptions, $options);
$this->checkOptionValidity();
}
public function badge()
{
return $this->genBadge();
}
private function genBadge()
{
$html = $this->genNode('span', [
'class' => [
'badge',
"badge-{$this->options['variant']}",
$this->options['pill'] ? 'badge-pill' : '',
],
'title' => $this->options['title']
], h($this->options['text']));
return $html;
}
}
class BoostrapModal extends BootstrapGeneric {
private $defaultOptions = [
'size' => '',
'centered' => true,
'scrollable' => true,
'backdropStatic' => false,
'title' => '',
'titleHtml' => false,
'body' => '',
'bodyHtml' => false,
'footerHtml' => false,
'confirmText' => 'Confirm',
'cancelText' => 'Cancel',
'modalClass' => [''],
'headerClass' => [''],
'bodyClass' => [''],
'footerClass' => [''],
'type' => 'ok-only',
'variant' => '',
'confirmFunction' => '',
'cancelFunction' => ''
];
private $bsClasses = null;
function __construct($options) {
$this->allowedOptionValues = [
'size' => ['sm', 'lg', 'xl', ''],
'type' => ['ok-only','confirm','confirm-success','confirm-warning','confirm-danger'],
'variant' => array_merge(BootstrapGeneric::$variants, ['']),
];
$this->processOptions($options);
}
private function processOptions($options)
{
$this->options = array_merge($this->defaultOptions, $options);
$this->checkOptionValidity();
}
public function modal()
{
return $this->genModal();
}
private function genModal()
{
$dialog = $this->openNode('div', [
'class' => array_merge(
['modal-dialog', (!empty($this->options['size'])) ? "modal-{$this->options['size']}" : ''],
$this->options['modalClass']
),
]);
$content = $this->openNode('div', [
'class' => ['modal-content'],
]);
$header = $this->genHeader();
$body = $this->genBody();
$footer = $this->genFooter();
$closedDiv = $this->closeNode('div');
$html = "{$dialog}{$content}{$header}{$body}{$footer}{$closedDiv}{$closedDiv}";
return $html;
}
private function genHeader()
{
$header = $this->openNode('div', ['class' => array_merge(['modal-header'], $this->options['headerClass'])]);
if (!empty($this->options['titleHtml'])) {
$header .= $this->options['titleHtml'];
} else {
$header .= $this->genNode('h5', ['class' => ['modal-title']], h($this->options['title']));
}
if (empty($this->options['backdropStatic'])) {
$header .= $this->genericCloseButton('modal');
}
$header .= $this->closeNode('div');
return $header;
}
private function genBody()
{
$body = $this->openNode('div', ['class' => array_merge(['modal-body'], $this->options['bodyClass'])]);
if (!empty($this->options['bodyHtml'])) {
$body .= $this->options['bodyHtml'];
} else {
$body .= h($this->options['body']);
}
$body .= $this->closeNode('div');
return $body;
}
private function genFooter()
{
$footer = $this->openNode('div', ['class' => array_merge(['modal-footer'], $this->options['footerClass'])]);
if (!empty($this->options['footerHtml'])) {
$footer .= $this->options['footerHtml'];
} else {
$footer .= $this->getFooterBasedOnType();
}
$footer .= $this->closeNode('div');
return $footer;
}
private function getFooterBasedOnType() {
if ($this->options['type'] == 'ok-only') {
return $this->getFooterOkOnly();
} else if (str_contains($this->options['type'], 'confirm')) {
return $this->getFooterConfirm();
} else {
return $this->getFooterOkOnly();
}
}
private function getFooterOkOnly()
{
return (new BoostrapButton([
'variant' => 'primary',
'text' => __('Ok'),
'params' => [
'data-dismiss' => 'modal',
'onclick' => $this->options['confirmFunction']
]
]))->button();
}
private function getFooterConfirm()
{
if ($this->options['type'] == 'confirm') {
$variant = 'primary';
} else {
$variant = explode('-', $this->options['type'])[1];
}
$buttonCancel = (new BoostrapButton([
'variant' => 'secondary',
'text' => h($this->options['cancelText']),
'params' => [
'data-dismiss' => 'modal',
'onclick' => $this->options['cancelFunction']
]
]))->button();
$buttonConfirm = (new BoostrapButton([
'variant' => $variant,
'text' => h($this->options['confirmText']),
'params' => [
'data-dismiss' => 'modal',
'onclick' => $this->options['confirmFunction']
]
]))->button();
return $buttonCancel . $buttonConfirm;
}
}

View File

@ -15,12 +15,17 @@ echo $this->element('genericElements/IndexTable/index_table', [
]
]
],
[
'type' => 'context_filters',
'context_filters' => $filteringContexts
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
'searchKey' => 'value',
'allowFilering' => true
]
]
],

View File

@ -84,7 +84,7 @@
function submitForm(api, url) {
const reloadUrl = '<?= isset($field['toggle_data']['reload_url']) ? $field['toggle_data']['reload_url'] : $this->Url->build(['action' => 'index']) ?>'
return api.fetchAndPostForm(url, {})
return api.fetchAndPostForm(url, {}, false, true)
.then(() => {
<?php if (!empty($field['toggle_data']['skip_full_reload'])): ?>
const isChecked = $('#<?= $checkboxId ?>').prop('checked')

View File

@ -5,25 +5,37 @@
$urlParams = [
'controller' => $this->request->getParam('controller'),
'action' => 'index',
'?' => $filteringContext['filterCondition']
'?' => array_merge($filteringContext['filterCondition'], ['filteringLabel' => $filteringContext['label']])
];
$currentQuery = $this->request->getQuery();
unset($currentQuery['page'], $currentQuery['limit'], $currentQuery['sort']);
$filteringLabel = !empty($currentQuery['filteringLabel']) ? $currentQuery['filteringLabel'] : '';
unset($currentQuery['page'], $currentQuery['limit'], $currentQuery['sort'], $currentQuery['filteringLabel']);
if (!empty($filteringContext['filterCondition'])) { // PHP replaces `.` by `_` when fetching the request parameter
$currentFilteringContextKey = array_key_first($filteringContext['filterCondition']);
$currentFilteringContext = [
str_replace('.', '_', $currentFilteringContextKey) => $filteringContext['filterCondition'][$currentFilteringContextKey]
];
$currentFilteringContext = [];
foreach ($filteringContext['filterCondition'] as $currentFilteringContextKey => $value) {
$currentFilteringContextKey = str_replace('.', '_', $currentFilteringContextKey);
$currentFilteringContextKey = str_replace(' ', '_', $currentFilteringContextKey);
$currentFilteringContext[$currentFilteringContextKey] = $value;
}
} else {
$currentFilteringContext = $filteringContext['filterCondition'];
}
$contextArray[] = [
'active' => $currentQuery == $currentFilteringContext,
'active' => (
(
$currentQuery == $currentFilteringContext && // query conditions match
!isset($filteringContext['filterConditionFunction']) && // not a custom filtering
empty($filteringLabel) // do not check `All` by default
) ||
$filteringContext['label'] == $filteringLabel // labels should not be duplicated
),
'isFilter' => true,
'onClick' => 'changeIndexContext',
'onClickParams' => [
'this',
$this->Url->build($urlParams),
$this->Url->build($urlParams, [
'escape' => false, // URL builder escape `&` when multiple ? arguments
]),
"#table-container-${tableRandomValue}",
"#table-container-${tableRandomValue} table.table",
],

View File

@ -12,12 +12,32 @@
* - id: element ID for the input field - defaults to quickFilterField
*/
if (!isset($data['requirement']) || $data['requirement']) {
$filteringButton = '';
if (!empty($data['allowFilering'])) {
$activeFilters = !empty($activeFilters) ? $activeFilters : [];
$buttonConfig = [
'icon' => 'filter',
'params' => [
'title' => __('Filter index'),
'id' => sprintf('toggleFilterButton-%s', h($tableRandomValue))
]
];
if (count($activeFilters) > 0) {
$buttonConfig['badge'] = [
'variant' => 'light',
'text' => count($activeFilters),
'title' => __n('There is {0} active filter', 'There are {0} active filters', count($activeFilters), count($activeFilters))
];
}
$filteringButton = $this->Bootstrap->button($buttonConfig);
}
$button = empty($data['button']) && empty($data['fa-icon']) ? '' : sprintf(
'<div class="input-group-append"><button class="btn btn-primary" %s id="quickFilterButton-%s">%s%s</button></div>',
'<div class="input-group-append"><button class="btn btn-primary" %s id="quickFilterButton-%s">%s%s</button>%s</div>',
empty($data['data']) ? '' : h($data['data']),
h($tableRandomValue),
empty($data['fa-icon']) ? '' : sprintf('<i class="fa fa-%s"></i>', h($data['fa-icon'])),
empty($data['button']) ? '' : h($data['button'])
empty($data['button']) ? '' : h($data['button']),
$filteringButton
);
if (!empty($data['cancel'])) {
$button .= $this->element('/genericElements/ListTopBar/element_simple', array('data' => $data['cancel']));
@ -45,6 +65,7 @@
var action = '<?= $this->request->getParam('action') ?>';
var additionalUrlParams = '';
var quickFilter = <?= json_encode(!empty($quickFilter) ? $quickFilter : []) ?>;
var activeFilters = <?= json_encode(!empty($activeFilters) ? $activeFilters : []) ?>;
<?php
if (!empty($data['additionalUrlParams'])) {
echo sprintf(
@ -75,6 +96,14 @@
$(`#quickFilterField-${randomValue}`).popover('hide')
});
$(`#toggleFilterButton-${randomValue}`)
.data('activeFilters', activeFilters)
.click(function() {
const url = `/${controller}/filtering`
const reloadUrl = `/${controller}/index${additionalUrlParams}`
openFilteringModal(this, url, reloadUrl, $(`#table-container-${randomValue}`));
})
function doFilter($button) {
$(`#quickFilterField-${randomValue}`).popover('hide')
const encodedFilters = encodeURIComponent($(`#quickFilterField-${randomValue}`).val())
@ -114,5 +143,12 @@
return $table[0].outerHTML
}
function openFilteringModal(clicked, url, reloadUrl, tableId) {
const loadingOverlay = new OverlayFactory(clicked);
loadingOverlay.show()
UI.openModalFromURL(url, reloadUrl, tableId).finally(() => {
loadingOverlay.hide()
})
}
});
</script>

View File

@ -0,0 +1,188 @@
<?php
use Cake\Utility\Inflector;
$filteringForm = $this->Bootstrap->table(
[
'small' => true,
'striped' => false,
'hover' => false,
'tableClass' => ['indexFilteringTable'],
],
[
'fields' => [
__('Field'),
__('Operator'),
[
'labelHtml' => sprintf('%s %s',
__('Value'),
sprintf('<span class="fa fa-info ml-1" title="%s"><span>', __('Supports strict match and LIKE match with the `%` character.&#10;Example: `%.com`'))
)
],
__('Action')
],
'items' => []
]);
echo $this->Bootstrap->modal([
'title' => __('Filtering options for {0}', Inflector::singularize($this->request->getParam('controller'))),
'size' => 'lg',
'type' => 'confirm',
'bodyHtml' => $filteringForm,
'confirmText' => __('Filter'),
'confirmFunction' => 'filterIndex(this)'
]);
?>
<script>
$(document).ready(() => {
const $filteringTable = $('table.indexFilteringTable')
initFilteringTable($filteringTable)
})
function filterIndex(clicked) {
const controller = '<?= $this->request->getParam('controller') ?>';
const action = 'index';
const $clicked = $(clicked)
const $tbody = $clicked.closest('div.modal-content').find('table.indexFilteringTable tbody')
const $rows = $tbody.find('tr:not(#controlRow)')
const activeFilters = {}
$rows.each(function() {
const rowData = getDataFromRow($(this))
let fullFilter = rowData['name']
if (rowData['operator'] == '!=') {
fullFilter += ' !='
}
activeFilters[fullFilter] = rowData['value']
})
const searchParam = (new URLSearchParams(activeFilters)).toString();
const url = `/${controller}/${action}?${searchParam}`
const randomValue = getRandomValue()
UI.reload(url, $(`#table-container-${randomValue}`), $(`#table-container-${randomValue} table.table`), [{
node: $(`#toggleFilterButton-${randomValue}`),
config: {}
}])
}
function initFilteringTable($filteringTable) {
const $controlRow = $filteringTable.find('#controlRow')
$filteringTable.find('tbody').empty()
addControlRow($filteringTable)
const randomValue = getRandomValue()
const activeFilters = $(`#toggleFilterButton-${randomValue}`).data('activeFilters')
for (let [field, value] of Object.entries(activeFilters)) {
const fieldParts = field.split(' ')
let operator = '='
if (fieldParts.length == 2 && fieldParts[1] == '!=') {
operator = '!='
field = fieldParts[0]
} else if (fieldParts.length > 2) {
console.error('Field contains multiple spaces. ' + field)
}
addFilteringRow($filteringTable, field, value, operator)
}
}
function addControlRow($filteringTable) {
const availableFilters = <?= json_encode($filters) ?>;
const $selectField = $('<select/>').addClass('fieldSelect custom-select custom-select-sm')
availableFilters.forEach(filter => {
$selectField.append($('<option/>').text(filter))
});
const $selectOperator = $('<select/>').addClass('fieldOperator custom-select custom-select-sm')
.append([
$('<option/>').text('=').val('='),
$('<option/>').text('!=').val('!='),
])
const $row = $('<tr/>').attr('id', 'controlRow')
.append(
$('<td/>').append($selectField),
$('<td/>').append($selectOperator),
$('<td/>').append(
$('<input>').attr('type', 'text').addClass('fieldValue form-control form-control-sm')
),
$('<td/>').append(
$('<button/>').attr('type', 'button').addClass('btn btn-sm btn-primary')
.append($('<span/>').addClass('fa fa-plus'))
.click(addFiltering)
)
)
$filteringTable.append($row)
}
function addFilteringRow($filteringTable, field, value, operator) {
const $selectOperator = $('<select/>').addClass('fieldOperator custom-select custom-select-sm')
.append([
$('<option/>').text('=').val('='),
$('<option/>').text('!=').val('!='),
]).val(operator)
const $row = $('<tr/>')
.append(
$('<td/>').text(field).addClass('fieldName').data('fieldName', field),
$('<td/>').append($selectOperator),
$('<td/>').append(
$('<input>').attr('type', 'text').addClass('fieldValue form-control form-control-sm').val(value)
),
$('<td/>').append(
$('<button/>').attr('type', 'button').addClass('btn btn-sm btn-danger')
.append($('<span/>').addClass('fa fa-trash'))
.click(removeSelf)
)
)
$filteringTable.append($row)
const $controlRow = $filteringTable.find('#controlRow')
disableOptionFromSelect($controlRow, field)
}
function addFiltering() {
const $table = $(this).closest('table.indexFilteringTable')
const $controlRow = $table.find('#controlRow')
const field = $controlRow.find('select.fieldSelect').val()
const value = $controlRow.find('input.fieldValue').val()
const operator = $controlRow.find('input.fieldOperator').val()
addFilteringRow($table, field, value, operator)
$controlRow.find('input.fieldValue').val('')
$controlRow.find('select.fieldSelect').val('')
}
function removeSelf() {
const $row = $(this).closest('tr')
const $controlRow = $row.closest('table.indexFilteringTable').find('#controlRow')
const field = $row.data('fieldName')
$row.remove()
enableOptionFromSelect($controlRow, field)
}
function disableOptionFromSelect($controlRow, optionName) {
$controlRow.find('select.fieldSelect option').each(function() {
const $option = $(this)
if ($option.text() == optionName) {
$option.prop('disabled', true)
}
});
}
function enableOptionFromSelect($controlRow, optionName) {
$controlRow.find('select.fieldSelect option').each(function() {
const $option = $(this)
if ($option.text() == optionName) {
$option.prop('disabled', false)
}
});
}
function getDataFromRow($row) {
const rowData = {};
rowData['name'] = $row.find('td.fieldName').data('fieldName')
rowData['operator'] = $row.find('select.fieldOperator').val()
rowData['value'] = $row.find('input.fieldValue').val()
return rowData
}
function getRandomValue() {
const $container = $('div[id^="table-container-"]')
const randomValue = $container.attr('id').split('-')[2]
return randomValue
}
</script>

View File

@ -48,6 +48,7 @@ class UIFactory {
/**
* Creates and displays a modal where the modal's content is fetched from the provided URL. Reloads the table after a successful operation and handles displayOnSuccess option
* @param {string} url - The URL from which the modal's content should be fetched
* @param {string} reloadUrl - The URL from which the data should be fetched after confirming
* @param {string} tableId - The table ID which should be reloaded on success
* @return {Promise<Object>} Promise object resolving to the ModalFactory object
*/
@ -588,33 +589,35 @@ class ModalFactory {
/** Attach the submission click listener for modals that have been generated by raw HTML */
findSubmitButtonAndAddListener(clearOnclick=true) {
const $submitButton = this.$modal.find('.modal-footer #submitButton')
const formID = $submitButton.data('form-id')
let $form
if (formID) {
$form = $(formID)
} else {
$form = this.$modal.find('form')
if ($submitButton[0]) {
const formID = $submitButton.data('form-id')
let $form
if (formID) {
$form = $(formID)
} else {
$form = this.$modal.find('form')
}
if (clearOnclick) {
$submitButton[0].removeAttribute('onclick')
}
this.options.APIConfirm = (tmpApi) => {
return tmpApi.postForm($form[0])
.then((data) => {
if (data.success) {
this.options.POSTSuccessCallback(data)
} else { // Validation error
this.injectFormValidationFeedback(form, data.errors)
return Promise.reject('Validation error');
}
})
.catch((errorMessage) => {
this.options.POSTFailCallback(errorMessage)
return Promise.reject(errorMessage);
})
}
$submitButton.click(this.getConfirmationHandlerFunction())
}
if (clearOnclick) {
$submitButton[0].removeAttribute('onclick')
}
this.options.APIConfirm = (tmpApi) => {
return tmpApi.postForm($form[0])
.then((data) => {
if (data.success) {
this.options.POSTSuccessCallback(data)
} else { // Validation error
this.injectFormValidationFeedback(form, data.errors)
return Promise.reject('Validation error');
}
})
.catch((errorMessage) => {
this.options.POSTFailCallback(errorMessage)
return Promise.reject(errorMessage);
})
}
$submitButton.click(this.getConfirmationHandlerFunction())
}
}