Merge branch 'tags' into develop-unstable

pull/72/head
mokaddem 2021-09-17 16:46:54 +02:00
commit 0eb14195ee
48 changed files with 1719 additions and 45 deletions

View File

@ -29,12 +29,14 @@
},
"autoload": {
"psr-4": {
"App\\": "src/"
"App\\": "src/",
"Tags\\": "plugins/Tags/src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Test\\": "tests/",
"Tags\\Test\\": "plugins/Tags/tests/",
"Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
}
},

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
class TagSystem extends AbstractMigration
{
public function change() {
$tags = $this->table('tags_tags')
->addColumn('namespace', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
])
->addColumn('predicate', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
])
->addColumn('value', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
])
->addColumn('name', 'string', [
'default' => null,
'limit' => 255,
'null' => false,
])
->addColumn('colour', 'string', [
'default' => null,
'limit' => 7,
'null' => false,
])
->addColumn('counter', 'integer', [
'default' => 0,
'length' => 11,
'null' => false,
'signed' => false,
'comment' => 'Field used by the CounterCache behaviour to count the occurence of tags'
])
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->create();
$tagged = $this->table('tags_tagged')
->addColumn('tag_id', 'integer', [
'default' => null,
'null' => false,
'signed' => false,
'length' => 10,
])
->addColumn('fk_id', 'integer', [
'default' => null,
'null' => true,
'signed' => false,
'length' => 10,
'comment' => 'The ID of the entity being tagged'
])
->addColumn('fk_model', 'string', [
'default' => null,
'limit' => 255,
'null' => false,
'comment' => 'The model name of the entity being tagged'
])
->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
])
->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
])
->create();
$tags->addIndex(['name'], ['unique' => true])
->update();
$tagged->addIndex(['tag_id', 'fk_id', 'fk_table'], ['unique' => true])
->update();
}
}

View File

@ -0,0 +1,20 @@
<?php
use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;
$routes->plugin(
'Tags',
['path' => '/tags'],
function ($routes) {
$routes->setRouteClass(DashedRoute::class);
$routes->connect(
'/{action}/*',
['controller' => 'Tags']
);
// $routes->get('/', ['controller' => 'Tags']);
// $routes->get('/{id}', ['controller' => 'Tags', 'action' => 'view']);
// $routes->put('/{id}', ['controller' => 'Tags', 'action' => 'edit']);
}
);

View File

@ -0,0 +1,13 @@
<?php
namespace Tags\Controller;
use App\Controller\AppController as BaseController;
class AppController extends BaseController
{
public function initialize(): void
{
parent::initialize();
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace Tags\Controller;
use Tags\Controller\AppController;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use Cake\Utility\Text;
use Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\ForbiddenException;
use Cake\ORM\TableRegistry;
class TagsController extends AppController
{
public function index()
{
$this->CRUD->index([
'filters' => ['label', 'colour'],
'quickFilters' => [['label' => true], 'colour']
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function add()
{
$this->CRUD->add();
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function view($id)
{
$this->CRUD->view($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function edit($id)
{
$this->CRUD->edit($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
$this->render('add');
}
public function delete($id)
{
$this->CRUD->delete($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
// public function tag($model, $id)
// {
// $controller = $this->getControllerBeingTagged($model);
// $controller->CRUD->tag($id);
// $responsePayload = $controller->CRUD->getResponsePayload();
// if (!empty($responsePayload)) {
// return $responsePayload;
// }
// return $controller->getResponse();
// }
// public function untag($model, $id)
// {
// $controller = $this->getControllerBeingTagged($model);
// $controller->CRUD->untag($id);
// $responsePayload = $controller->CRUD->getResponsePayload();
// if (!empty($responsePayload)) {
// return $responsePayload;
// }
// return $controller->getResponse();
// }
// public function viewTags($model, $id)
// {
// $controller = $this->getControllerBeingTagged($model);
// $controller->CRUD->viewTags($id);
// $responsePayload = $controller->CRUD->getResponsePayload();
// if (!empty($responsePayload)) {
// return $responsePayload;
// }
// return $controller->getResponse();
// }
// private function getControllerBeingTagged($model)
// {
// $modelName = Inflector::camelize($model);
// $controllerName = "\\App\\Controller\\{$modelName}Controller";
// if (!class_exists($controllerName)) {
// throw new MethodNotAllowedException(__('Model `{0}` does not exists', $model));
// }
// $controller = new $controllerName;
// // Make sure that the request is correctly assigned to this controller
// return $controller;
// }
}

View File

@ -0,0 +1,355 @@
<?php
namespace Tags\Model\Behavior;
use Cake\ORM\Behavior;
use Cake\ORM\Entity;
use Cake\ORM\Query;
use Cake\ORM\Table;
class TagBehavior extends Behavior
{
protected $_defaultConfig = [
'finderField' => 'label',
'tagsAssoc' => [
'className' => 'Tags.Tags',
'joinTable' => 'tags_tagged',
'foreignKey' => 'fk_id',
'targetForeignKey' => 'tag_id',
'propertyName' => 'tags',
],
'tagsCounter' => ['counter'],
'taggedAssoc' => [
'className' => 'Tags.Tagged',
'foreignKey' => 'fk_id'
],
'implementedEvents' => [
'Model.beforeMarshal' => 'beforeMarshal',
'Model.beforeFind' => 'beforeFind',
'Model.beforeSave' => 'beforeSave',
],
'implementedMethods' => [
'normalizeTags' => 'normalizeTags',
],
'implementedFinders' => [
'tagged' => 'findByTag',
'untagged' => 'findUntagged',
],
];
public function initialize(array $config): void {
$this->bindAssociations();
$this->attachCounters();
}
public function bindAssociations() {
$config = $this->getConfig();
$tagsAssoc = $config['tagsAssoc'];
$taggedAssoc = $config['taggedAssoc'];
$table = $this->_table;
$tableAlias = $this->_table->getAlias();
$assocConditions = ['Tagged.fk_model' => $tableAlias];
if (!$table->hasAssociation('Tagged')) {
$table->hasMany('Tagged', array_merge(
$taggedAssoc,
[
'conditions' => $assocConditions
]
));
}
if (!$table->hasAssociation('Tags')) {
$table->belongsToMany('Tags', array_merge(
$tagsAssoc,
[
'through' => $table->Tagged->getTarget(),
'conditions' => $assocConditions,
]
));
}
if (!$table->Tags->hasAssociation($tableAlias)) {
$table->Tags->belongsToMany($tableAlias, array_merge(
$tagsAssoc,
[
'className' => get_class($table),
]
));
}
if (!$table->Tagged->hasAssociation($tableAlias)) {
$table->Tagged->belongsTo($tableAlias, [
'className' => get_class($table),
'foreignKey' => $tagsAssoc['foreignKey'],
'conditions' => $assocConditions,
'joinType' => 'INNER',
]);
}
if (!$table->Tagged->hasAssociation($tableAlias . 'Tags')) {
$table->Tagged->belongsTo($tableAlias . 'Tags', [
'className' => $tagsAssoc['className'],
'foreignKey' => $tagsAssoc['targetForeignKey'],
'conditions' => $assocConditions,
'joinType' => 'INNER',
]);
}
}
public function attachCounters() {
$config = $this->getConfig();
$taggedTable = $this->_table->Tagged;
if (!$taggedTable->hasBehavior('CounterCache')) {
$taggedTable->addBehavior('CounterCache', [
'Tags' => $config['tagsCounter']
]);
}
}
public function beforeMarshal($event, $data, $options) {
$property = $this->getConfig('tagsAssoc.propertyName');
$options['accessibleFields'][$property] = true;
$options['associated']['Tags']['accessibleFields']['id'] = true;
if (isset($data['tags'])) {
if (!empty($data['tags'])) {
$data[$property] = $this->normalizeTags($data['tags']);
}
}
}
public function beforeSave($event, $entity, $options)
{
if (empty($entity->tags)) {
return;
}
foreach ($entity->tags as $k => $tag) {
if (!$tag->isNew()) {
continue;
}
$existingTag = $this->getExistingTag($tag->label);
if (!$existing) {
continue;
}
$joinData = $tag->_joinData;
$tag = $existing;
$tag->_joinData = $joinData;
$entity->tags[$k] = $tag;
}
}
public function normalizeTags($tags) {
$result = [];
$modelAlias = $this->_table->getAlias();
$common = [
'_joinData' => [
'fk_model' => $modelAlias
]
];
$tagsTable = $this->_table->Tags;
$displayField = $tagsTable->getDisplayField();
$tagIdentifiers = [];
foreach ($tags as $tag) {
if (empty($tag)) {
continue;
}
if (is_object($tag)) {
$result[] = $tag->toArray();
}
$tagIdentifier = $this->getTagIdentifier($tag);
if (isset($tagIdentifiers[$tagIdentifier])) {
continue;
}
$tagIdentifiers[$tagIdentifier] = true;
$existingTag = $this->getExistingTag($tagIdentifier);
if ($existingTag) {
$result[] = array_merge($common, ['id' => $existingTag->id]);
continue;
}
$result[] = array_merge(
$common,
[
'label' => $tagIdentifier,
]
);
}
return $result;
}
protected function getTagIdentifier($tag)
{
if (is_object($tag)) {
return $tag->label;
} else {
return trim($tag);
}
}
protected function getExistingTag($tagName)
{
$tagsTable = $this->_table->Tags->getTarget();
$query = $tagsTable->find()->where([
'Tags.label' => $tagName
])
->select('Tags.id');
return $query->first();
}
public function findByTag(Query $query, array $options) {
$finderField = $optionsKey = $this->getConfig('finderField');
if (!$finderField) {
$finderField = $optionsKey = 'label';
}
if (!isset($options[$optionsKey])) {
throw new RuntimeException(__('Expected key `{0}` not present in find(\'tagged\') options argument.', $optionsKey));
}
$isAndOperator = isset($options['OperatorAND']) ? $options['OperatorAND'] : true;
$filterValue = $options[$optionsKey];
if (!$filterValue) {
return $query;
}
$filterValue = $this->dissectArgs($filterValue);
if (!empty($filterValue['NOT']) || !empty($filterValue['LIKE'])) {
return $this->findByComplexQueryConditions($query, $filterValue, $finderField, $isAndOperator);
}
$subQuery = $this->buildQuerySnippet($filterValue, $finderField, $isAndOperator);
if (is_string($subQuery)) {
$query->matching('Tags', function ($q) use ($finderField, $subQuery) {
$key = 'Tags.' . $finderField;
return $q->where([
$key => $subQuery,
]);
});
return $query;
}
$modelAlias = $this->_table->getAlias();
return $query->where([$modelAlias . '.id IN' => $subQuery]);
}
public function findUntagged(Query $query, array $options) {
$modelAlias = $this->_table->getAlias();
$foreignKey = $this->getConfig('tagsAssoc.foreignKey');
$conditions = ['fk_model' => $modelAlias];
$this->_table->hasOne('NoTags', [
'className' => $this->getConfig('taggedAssoc.className'),
'foreignKey' => $foreignKey,
'conditions' => $conditions
]);
$query = $query->contain(['NoTags'])->where(['NoTags.id IS' => null]);
return $query;
}
protected function dissectArgs($filterValue): array
{
if (!is_array($filterValue)) {
return $filterValue;
}
$dissected = [
'AND' => [],
'NOT' => [],
'LIKE' => [],
];
foreach ($filterValue as $value) {
if (substr($value, 0, 1) == '!') {
$dissected['NOT'][] = substr($value, 1);
}
else if (strpos($value, '%') != false) {
$dissected['LIKE'][] = $value;
} else {
$dissected['AND'][] = $value;
}
}
if (empty($dissected['NOT']) && empty($dissected['LIKE'])) {
return $dissected['AND'];
}
return $dissected;
}
protected function buildQuerySnippet($filterValue, string $finderField, bool $OperatorAND=true)
{
if (!is_array($filterValue)) {
return $filterValue;
}
$key = 'Tags.' . $finderField;
$foreignKey = $this->getConfig('tagsAssoc.foreignKey');
$conditions = [
$key . ' IN' => $filterValue,
];
$query = $this->_table->Tagged->find();
if ($OperatorAND) {
$query->contain(['Tags'])
->group('Tagged.' . $foreignKey)
->having('COUNT(*) = ' . count($filterValue))
->select('Tagged.' . $foreignKey)
->where($conditions);
} else {
$query->contain(['Tags'])
->select('Tagged.' . $foreignKey)
->where($conditions);
}
return $query;
}
protected function findByComplexQueryConditions($query, $filterValue, string $finderField, bool $OperatorAND=true)
{
$key = 'Tags.' . $finderField;
$taggedAlias = 'Tagged';
$foreignKey = $this->getConfig('tagsAssoc.foreignKey');
if (!empty($filterValue['AND'])) {
$subQuery = $this->buildQuerySnippet($filterValue['AND'], $finderField, $OperatorAND);
$modelAlias = $this->_table->getAlias();
$query->where([$modelAlias . '.id IN' => $subQuery]);
}
if (!empty($filterValue['NOT'])) {
$subQuery = $this->buildQuerySnippet($filterValue['NOT'], $finderField, false);
$modelAlias = $this->_table->getAlias();
$query->where([$modelAlias . '.id NOT IN' => $subQuery]);
}
if (!empty($filterValue['LIKE'])) {
$conditions = ['OR' => []];
foreach($filterValue['LIKE'] as $likeValue) {
$conditions['OR'][] = [
$key . ' LIKE' => $likeValue,
];
}
$subQuery = $this->buildQuerySnippet($filterValue['NOT'], $finderField, $OperatorAND);
if ($OperatorAND) {
$subQuery = $this->_table->Tagged->find()
->contain(['Tags'])
->group('Tagged.' . $foreignKey)
->having('COUNT(*) >= ' . count($filterValue['LIKE']))
->select('Tagged.' . $foreignKey)
->where($conditions);
} else {
$subQuery = $this->_table->Tagged->find()
->contain(['Tags'])
->select('Tagged.' . $foreignKey)
->where($conditions);
}
$modelAlias = $this->_table->getAlias();
$query->where([$modelAlias . '.id IN' => $subQuery]);
}
return $query;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Model\Entity;
use App\Model\Entity\AppModel;
class Tag extends AppModel {
protected $_accessible = [
'id' => false,
'counter' => false,
'*' => true,
];
protected $_accessibleOnNew = [
'label' => true,
'colour' => true,
];
protected $_virtual = ['text_colour'];
protected function _getTextColour()
{
$textColour = null;
if (!empty($this->colour)) {
$textColour = $this->getTextColour($this->colour);
}
return $textColour;
}
protected function getTextColour($RGB) {
$r = hexdec(substr($RGB, 1, 2));
$g = hexdec(substr($RGB, 3, 2));
$b = hexdec(substr($RGB, 5, 2));
$average = ((2 * $r) + $b + (3 * $g))/6;
if ($average < 127) {
return 'white';
} else {
return 'black';
}
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Model\Entity;
use Cake\ORM\Entity;
class Tagged extends AppModel {
protected $_accessible = [
'id' => false,
'*' => true,
];
}

View File

@ -0,0 +1,33 @@
<?php
namespace Tags\Model\Table;
use App\Model\Table\AppTable;
use Cake\Validation\Validator;
class TaggedTable extends AppTable
{
protected $_accessible = [
'id' => false
];
public function initialize(array $config): void
{
$this->setTable('tags_tagged');
$this->belongsTo('Tags', [
'className' => 'Tags.Tags',
'foreignKey' => 'tag_id',
'propertyName' => 'tag',
]);
$this->addBehavior('Timestamp');
}
public function validationDefault(Validator $validator): Validator
{
$validator
->notBlank('fk_model')
->notBlank('fk_id')
->notBlank('tag_id');
return $validator;
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Tags\Model\Table;
use App\Model\Table\AppTable;
use Cake\Validation\Validator;
class TagsTable extends AppTable
{
protected $_accessible = [
'id' => false
];
public function initialize(array $config): void
{
$this->setTable('tags_tags');
$this->setDisplayField('label'); // Change to name?
$this->addBehavior('Timestamp');
}
public function validationDefault(Validator $validator): Validator
{
$validator
->notBlank('label');
return $validator;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace Tags;
use Cake\Core\BasePlugin;
use Cake\Core\PluginApplicationInterface;
use Cake\Console\CommandCollection;
use Cake\Http\MiddlewareQueue;
class Plugin extends BasePlugin
{
public function middleware(MiddlewareQueue $middleware): MiddlewareQueue
{
// Add middleware here.
$middleware = parent::middleware($middleware);
return $middleware;
}
public function console(CommandCollection $commands): CommandCollection
{
// Add console commands here.
$commands = parent::console($commands);
return $commands;
}
public function bootstrap(PluginApplicationInterface $app): void
{
// Add constants, load configuration defaults.
// By default will load `config/bootstrap.php` in the plugin.
parent::bootstrap($app);
}
public function routes($routes): void
{
// Add routes.
// By default will load `config/routes.php` in the plugin.
parent::routes($routes);
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace Tags\View\Helper;
use Cake\View\Helper;
use Cake\Utility\Hash;
class TagHelper extends Helper
{
public $helpers = [
'Bootstrap',
'TextColour',
'FontAwesome',
'Form',
'Url',
'Tags.Tag',
];
protected $defaultConfig = [
'default_colour' => '#924da6',
'picker' => false,
'editable' => false,
];
public function control(array $options = [])
{
$field = 'tag_list';
$values = !empty($options['allTags']) ? array_map(function($tag) {
return [
'text' => h($tag['label']),
'value' => h($tag['label']),
'data-colour' => h($tag['colour']),
'data-text-colour' => h($tag['text_colour']),
];
}, $options['allTags']) : [];
$classes = ['tag-input', 'flex-grow-1'];
$url = '';
if (!empty($this->getConfig('editable'))) {
$url = $this->Url->build([
'controller' => $this->getView()->getName(),
'action' => 'tag',
$this->getView()->get('entity')['id']
]);
$classes[] = 'd-none';
}
$selectConfig = [
'multiple' => true,
'class' => $classes,
'data-url' => $url,
];
return $this->Form->select($field, $values, $selectConfig);
}
protected function picker(array $options = [])
{
$html = $this->Tag->control($options);
if (!empty($this->getConfig('editable'))) {
$html .= $this->Bootstrap->button([
'size' => 'sm',
'icon' => 'plus',
'variant' => 'secondary',
'class' => ['badge'],
'params' => [
'onclick' => 'createTagPicker(this)',
]
]);
} else {
$html .= '<script>$(document).ready(function() { initSelect2Pickers() })</script>';
}
return $html;
}
public function tags(array $tags = [], array $options = [])
{
$this->_config = array_merge($this->defaultConfig, $options);
$html = '<div class="tag-container-wrapper">';
$html .= '<div class="tag-container my-1 d-flex">';
$html .= '<div class="tag-list d-inline-block">';
foreach ($tags as $tag) {
if (is_object($tag)) {
$html .= $this->tag($tag);
} else {
$html .= $this->tag([
'label' => $tag
]);
}
}
$html .= '</div>';
if (!empty($this->getConfig('picker'))) {
$html .= $this->picker($options);
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
public function tag($tag, array $options = [])
{
if (empty($this->_config)) {
$this->_config = array_merge($this->defaultConfig, $options);
}
$tag['colour'] = !empty($tag['colour']) ? $tag['colour'] : $this->getConfig('default_colour');
$textColour = !empty($tag['text_colour']) ? $tag['text_colour'] : $this->TextColour->getTextColour(h($tag['colour']));;
if (!empty($this->getConfig('editable'))) {
$deleteButton = $this->Bootstrap->button([
'size' => 'sm',
'icon' => 'times',
'class' => ['ml-1', 'border-0', "text-${textColour}"],
'variant' => 'text',
'title' => __('Delete tag'),
'params' => [
'onclick' => sprintf('deleteTag(\'%s\', \'%s\', this)',
$this->Url->build([
'controller' => $this->getView()->getName(),
'action' => 'untag',
$this->getView()->get('entity')['id']
]),
h($tag['label'])
),
],
]);
} else {
$deleteButton = '';
}
$html = $this->Bootstrap->genNode('span', [
'class' => [
'tag',
'badge',
'mx-1',
'align-middle',
],
'title' => h($tag['label']),
'style' => sprintf('color:%s; background-color:%s', $textColour, h($tag['colour'])),
], h($tag['label']) . $deleteButton);
return $html;
}
}

View File

@ -0,0 +1,22 @@
<?php
echo $this->element('genericElements/Form/genericForm', array(
'data' => array(
'description' => __('Individuals are natural persons. They are meant to describe the basic information about an individual that may or may not be a user of this community. Users in genral require an individual object to identify the person behind them - however, no user account is required to store information about an individual. Individuals can have affiliations to organisations and broods as well as cryptographic keys, using which their messages can be verified and which can be used to securely contact them.'),
'model' => 'Organisations',
'fields' => array(
array(
'field' => 'label'
),
array(
'field' => 'colour',
'type' => 'color',
),
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
'action' => $this->request->getParam('action')
)
)
));
?>
</div>

View File

@ -0,0 +1,79 @@
<?php
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'children' => [
[
'type' => 'simple',
'children' => [
'data' => [
'type' => 'simple',
'text' => __('Add tag'),
'popover_url' => '/tags/add'
]
]
],
[
'type' => 'context_filters',
'context_filters' => $filteringContexts
],
[
'type' => 'search',
'button' => __('Filter'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
]
]
],
'fields' => [
[
'name' => '#',
'sort' => 'id',
'data_path' => 'id',
],
[
'name' => __('Label'),
'sort' => 'label',
'element' => 'tag'
],
[
'name' => __('Counter'),
'sort' => 'couter',
'data_path' => 'counter',
],
[
'name' => __('Colour'),
'sort' => 'colour',
'data_path' => 'colour',
],
[
'name' => __('Created'),
'sort' => 'created',
'data_path' => 'created',
],
],
'title' => __('Tag index'),
'description' => __('The list of all tags existing on this instance'),
'actions' => [
[
'url' => '/tags/view',
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'open_modal' => '/tags/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'open_modal' => '/tags/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
],
]
]
]);
echo '</div>';
?>

View File

@ -0,0 +1,32 @@
<?php
echo $this->element(
'/genericElements/SingleViews/single_view',
[
'data' => $entity,
'fields' => [
[
'key' => __('ID'),
'path' => 'id'
],
[
'key' => __('Label'),
'path' => '',
'type' => 'tag',
],
[
'key' => __('Counter'),
'path' => 'counter',
'type' => 'string',
],
[
'key' => __('Colour'),
'path' => 'colour',
],
[
'key' => __('Created'),
'path' => 'created',
],
],
'children' => []
]
);

View File

@ -0,0 +1,3 @@
.tag {
filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.5));
}

View File

@ -0,0 +1,142 @@
function createTagPicker(clicked) {
function closePicker($select, $container) {
$select.appendTo($container)
$container.parent().find('.picker-container').remove()
}
function getEditableButtons($select, $container) {
const $saveButton = $('<button></button>').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button')
.append($('<span></span>').text('Save').addClass('text-nowrap').prepend($('<i></i>').addClass('fa fa-save mr-1')))
.click(function() {
const tags = $select.select2('data').map(tag => tag.text)
addTags($select.data('url'), tags, $(this))
})
const $cancelButton = $('<button></button>').addClass(['btn btn-secondary btn-sm', 'align-self-start']).attr('type', 'button')
.append($('<span></span>').text('Cancel').addClass('text-nowrap').prepend($('<i></i>').addClass('fa fa-times mr-1')))
.click(function() {
closePicker($select, $container)
})
const $buttons = $('<span></span>').addClass(['picker-action', 'btn-group']).append($saveButton, $cancelButton)
return $buttons
}
const $clicked = $(clicked)
const $container = $clicked.closest('.tag-container')
const $select = $container.parent().find('select.tag-input').removeClass('d-none')
closePicker($select, $container)
const $pickerContainer = $('<div></div>').addClass(['picker-container', 'd-flex'])
$select.prependTo($pickerContainer)
$pickerContainer.append(getEditableButtons($select, $container))
$container.parent().append($pickerContainer)
initSelect2Picker($select)
}
function deleteTag(url, tags, clicked) {
if (!Array.isArray(tags)) {
tags = [tags];
}
const data = {
tag_list: JSON.stringify(tags)
}
const $statusNode = $(clicked).closest('.tag')
const APIOptions = {
statusNode: $statusNode,
skipFeedback: true,
}
return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((apiResult) => {
let $container = $statusNode.closest('.tag-container-wrapper')
refreshTagList(apiResult, $container).then(($tagContainer) => {
$container = $tagContainer // old container might not exist anymore since it was replaced after the refresh
})
const theToast = UI.toast({
variant: 'success',
title: apiResult.message,
bodyHtml: $('<div/>').append(
$('<span/>').text('Cancel untag operation.'),
$('<button/>').addClass(['btn', 'btn-primary', 'btn-sm', 'ml-3']).text('Restore tag').click(function() {
const split = url.split('/')
const controllerName = split[1]
const id = split[3]
const urlRetag = `/${controllerName}/tag/${id}`
addTags(urlRetag, tags, $container.find('.tag-container')).then(() => {
theToast.removeToast()
})
}),
),
})
}).catch((e) => {})
}
function addTags(url, tags, $statusNode) {
const data = {
tag_list: JSON.stringify(tags)
}
const APIOptions = {
statusNode: $statusNode
}
return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((apiResult) => {
const $container = $statusNode.closest('.tag-container-wrapper')
refreshTagList(apiResult, $container)
}).catch((e) => {})
}
function refreshTagList(apiResult, $container) {
const controllerName = apiResult.url.split('/')[1]
const entityId = apiResult.data.id
const url = `/${controllerName}/viewTags/${entityId}`
return UI.reload(url, $container)
}
function initSelect2Pickers() {
$('select.tag-input').each(function() {
if (!$(this).hasClass("select2-hidden-accessible")) {
initSelect2Picker($(this))
}
})
}
function initSelect2Picker($select) {
function templateTag(state, $select) {
if (!state.id) {
return state.label;
}
if (state.colour === undefined) {
state.colour = $(state.element).data('colour')
}
if ($select !== undefined && state.text[0] === '!') {
// fetch corresponding tag and set colors?
// const baseTag = state.text.slice(1)
// const existingBaseTag = $select.find('option').filter(function() {
// return $(this).val() === baseTag
// })
// if (existingBaseTag.length > 0) {
// state.colour = existingBaseTag.data('colour')
// state.text = baseTag
// }
}
return buildTag(state)
}
$select.select2({
placeholder: 'Pick a tag',
tags: true,
width: '100%',
templateResult: (state) => templateTag(state),
templateSelection: (state) => templateTag(state, $select),
})
}
function buildTag(options={}) {
if (!options.colour) {
options.colour = '#924da6'
}
const $tag = $('<span/>')
.addClass(['tag', 'badge', 'align-text-top'])
.css({color: getTextColour(options.colour), 'background-color': options.colour})
.text(options.text)
return $tag
}

View File

@ -29,6 +29,9 @@ use Authentication\AuthenticationServiceInterface;
use Authentication\AuthenticationServiceProviderInterface;
use Authentication\Middleware\AuthenticationMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Tags\Plugin as TagsPlugin;
/**
* Application setup class.
*
@ -59,6 +62,7 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
$this->addPlugin('DebugKit');
}
$this->addPlugin('Authentication');
$this->addPlugin('Tags', ['routes' => true]);
// Load more plugins here
}

View File

@ -63,9 +63,10 @@ class AppController extends Controller
]);
$this->loadModel('MetaFields');
$this->loadModel('MetaTemplates');
$table = $this->getTableLocator()->get($this->modelClass);
$this->loadComponent('CRUD', [
'request' => $this->request,
'table' => $this->{$this->modelClass},
'table' => $table,
'MetaFields' => $this->MetaFields,
'MetaTemplates' => $this->MetaTemplates
]);
@ -126,6 +127,10 @@ class AppController extends Controller
$this->request->getParam('prefix');
$this->set('darkMode', !empty(Configure::read('Cerebrate')['ui.dark']));
$this->set('baseurl', Configure::read('App.fullBaseUrl'));
if ($this->modelClass == 'Tags.Tags') {
$this->set('metaGroup', !empty($this->isAdmin) ? 'Administration' : 'Cerebrate');
}
}
private function authApiUser(): void

View File

@ -747,6 +747,30 @@ class ACLComponent extends Component
'label' => __('List Outbox Processors'),
'skipTopMenu' => 1
]
],
'skipTopMenu' => true,
],
'Tags' => [
'label' => __('Tags'),
'url' => '/tags/index',
'children' => [
'index' => [
'url' => '/tags/index',
'label' => __('List Tags'),
],
'view' => [
'url' => '/tags/view/{{id}}',
'label' => __('View Tags'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => true,
],
'delete' => [
'url' => '/tags/delete/{{id}}',
'label' => __('Delete Tag'),
'actions' => ['delete', 'edit', 'view'],
'skipTopMenu' => true,
'popup' => 1
],
]
],
'MetaTemplates' => [

View File

@ -7,6 +7,7 @@ use Cake\Error\Debugger;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use Cake\View\ViewBuilder;
use Cake\ORM\TableRegistry;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\NotFoundException;
@ -34,6 +35,9 @@ class CRUDComponent extends Component
$options['filters'][] = 'quickFilter';
}
$options['filters'][] = 'filteringLabel';
if ($this->taggingSupported()) {
$options['filters'][] = 'filteringTags';
}
$optionFilters = empty($options['filters']) ? [] : $options['filters'];
foreach ($optionFilters as $i => $filter) {
@ -49,6 +53,9 @@ class CRUDComponent extends Component
if (!empty($options['contain'])) {
$query->contain($options['contain']);
}
if ($this->taggingSupported()) {
$query->contain('Tags');
}
if (!empty($options['fields'])) {
$query->select($options['fields']);
}
@ -72,15 +79,17 @@ class CRUDComponent extends Component
$data = $this->Table->{$options['afterFind']}($data);
}
}
if (!empty($options['contextFilters'])) {
$this->setFilteringContext($options['contextFilters'], $params);
}
$this->setFilteringContext($options['contextFilters'] ?? [], $params);
$this->Controller->set('data', $data);
}
}
public function filtering(): void
{
if ($this->taggingSupported()) {
$this->Controller->set('taggingEnabled', true);
$this->setAllTags();
}
$filters = !empty($this->Controller->filters) ? $this->Controller->filters : [];
$this->Controller->set('filters', $filters);
$this->Controller->viewBuilder()->setLayout('ajax');
@ -243,7 +252,11 @@ class CRUDComponent extends Component
throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias));
}
$this->getMetaTemplates();
$data = $this->Table->get($id, isset($params['get']) ? $params['get'] : []);
if ($this->taggingSupported()) {
$params['contain'][] = 'Tags';
$this->setAllTags();
}
$data = $this->Table->get($id, isset($params['get']) ? $params['get'] : $params);
$data = $this->getMetaFields($id, $data);
if (!empty($params['fields'])) {
$this->Controller->set('fields', $params['fields']);
@ -350,6 +363,11 @@ class CRUDComponent extends Component
throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias));
}
if ($this->taggingSupported()) {
$params['contain'][] = 'Tags';
$this->setAllTags();
}
$data = $this->Table->get($id, $params);
$data = $this->attachMetaData($id, $data);
if (isset($params['afterFind'])) {
@ -404,6 +422,149 @@ class CRUDComponent extends Component
$this->Controller->render('/genericTemplates/delete');
}
public function tag($id=false): void
{
if (!$this->taggingSupported()) {
throw new Exception("Table {$this->TableAlias} does not support tagging");
}
if ($this->request->is('get')) {
$this->setAllTags();
if(!empty($id)) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$this->Controller->set('id', $entity->id);
$this->Controller->set('data', $entity);
$this->Controller->set('bulkEnabled', false);
} else {
$this->Controller->set('bulkEnabled', true);
}
} else if ($this->request->is('post') || $this->request->is('delete')) {
$ids = $this->getIdsOrFail($id);
$isBulk = count($ids) > 1;
$bulkSuccesses = 0;
foreach ($ids as $id) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$input = $this->request->getData();
$tagsToAdd = json_decode($input['tag_list']);
// patching will mirror tag in the DB, however, we only want to add tags
$input['tags'] = array_merge($tagsToAdd, $entity->tags);
$patchEntityParams = [
'fields' => ['tags'],
];
$entity = $this->Table->patchEntity($entity, $input, $patchEntityParams);
$savedData = $this->Table->save($entity);
$success = true;
if ($success) {
$bulkSuccesses++;
}
}
$message = $this->getMessageBasedOnResult(
$bulkSuccesses == count($ids),
$isBulk,
__('{0} tagged with `{1}`.', $this->ObjectAlias, $input['tag_list']),
__('All {0} have been tagged.', Inflector::pluralize($this->ObjectAlias)),
__('Could not tag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']),
__('{0} / {1} {2} have been tagged.',
$bulkSuccesses,
count($ids),
Inflector::pluralize($this->ObjectAlias)
)
);
$this->setResponseForController('tag', $bulkSuccesses, $message, $savedData);
}
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/tagForm');
}
public function untag($id=false): void
{
if (!$this->taggingSupported()) {
throw new Exception("Table {$this->TableAlias} does not support tagging");
}
if ($this->request->is('get')) {
$this->setAllTags();
if(!empty($id)) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$this->Controller->set('id', $entity->id);
$this->Controller->set('data', $entity);
$this->Controller->set('bulkEnabled', false);
} else {
$this->Controller->set('bulkEnabled', true);
}
} else if ($this->request->is('post') || $this->request->is('delete')) {
$ids = $this->getIdsOrFail($id);
$isBulk = count($ids) > 1;
$bulkSuccesses = 0;
foreach ($ids as $id) {
$params = [
'contain' => 'Tags',
];
$entity = $this->Table->get($id, $params);
$input = $this->request->getData();
$tagsToRemove = json_decode($input['tag_list']);
// patching will mirror tag in the DB, however, we only want to remove tags
$input['tags'] = array_filter($entity->tags, function ($existingTag) use ($tagsToRemove) {
return !in_array($existingTag->label, $tagsToRemove);
});
$patchEntityParams = [
'fields' => ['tags'],
];
$entity = $this->Table->patchEntity($entity, $input, $patchEntityParams);
$savedData = $this->Table->save($entity);
$success = true;
if ($success) {
$bulkSuccesses++;
}
}
$message = $this->getMessageBasedOnResult(
$bulkSuccesses == count($ids),
$isBulk,
__('{0} untagged with `{1}`.', $this->ObjectAlias, implode(', ', $tagsToRemove)),
__('All {0} have been untagged.', Inflector::pluralize($this->ObjectAlias)),
__('Could not untag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']),
__('{0} / {1} {2} have been untagged.',
$bulkSuccesses,
count($ids),
Inflector::pluralize($this->ObjectAlias)
)
);
$this->setResponseForController('tag', $bulkSuccesses, $message, $entity);
}
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/tagForm');
}
public function viewTags(int $id, array $params = []): void
{
if (!$this->taggingSupported()) {
throw new Exception("Table {$this->TableAlias} does not support tagging");
}
if (empty($id)) {
throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias));
}
$params['contain'][] = 'Tags';
$data = $this->Table->get($id, $params);
if (isset($params['afterFind'])) {
$data = $params['afterFind']($data);
}
if ($this->Controller->ParamHandler->isRest()) {
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
}
$this->Controller->set('entity', $data);
$this->setAllTags();
$this->Controller->viewBuilder()->setLayout('ajax');
$this->Controller->render('/genericTemplates/tag');
}
public function setResponseForController($action, $success, $message, $data=[], $errors=null)
{
if ($success) {
@ -522,6 +683,8 @@ class CRUDComponent extends Component
{
$filteringLabel = !empty($params['filteringLabel']) ? $params['filteringLabel'] : '';
unset($params['filteringLabel']);
$filteringTags = !empty($params['filteringTags']) && $this->taggingSupported() ? $params['filteringTags'] : '';
unset($params['filteringTags']);
$customFilteringFunction = '';
$chosenFilter = '';
if (!empty($options['contextFilters']['custom'])) {
@ -565,10 +728,26 @@ class CRUDComponent extends Component
}
}
}
if ($this->taggingSupported() && !empty($filteringTags)) {
$activeFilters['filteringTags'] = $filteringTags;
$query = $this->setTagFilters($query, $filteringTags);
}
$this->Controller->set('activeFilters', $activeFilters);
return $query;
}
protected function setTagFilters($query, $tags)
{
$modelAlias = $this->Table->getAlias();
$subQuery = $this->Table->find('tagged', [
'label' => $tags,
'forceAnd' => true
])->select($modelAlias . '.id');
return $query->where([$modelAlias . '.id IN' => $subQuery]);
}
protected function setNestedRelatedCondition($query, $filterParts, $filterValue)
{
$modelName = $filterParts[0];
@ -683,6 +862,18 @@ class CRUDComponent extends Component
return $prefixedConditions;
}
public function taggingSupported()
{
return $this->Table->behaviors()->has('Tag');
}
public function setAllTags()
{
$this->Tags = TableRegistry::getTableLocator()->get('Tags.Tags');
$allTags = $this->Tags->find()->all()->toList();
$this->Controller->set('allTags', $allTags);
}
public function toggle(int $id, string $fieldName = 'enabled', array $params = []): void
{
if (empty($id)) {

View File

@ -9,13 +9,16 @@ use Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\Http\Exception\ForbiddenException;
use Cake\ORM\TableRegistry;
class IndividualsController extends AppController
{
public $filters = ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type'];
public function index()
{
$this->CRUD->index([
'filters' => ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type'],
'filters' => $this->filters,
'quickFilters' => ['uuid', 'email', 'first_name', 'last_name', 'position'],
'contextFilters' => [
'fields' => [
@ -32,6 +35,11 @@ class IndividualsController extends AppController
$this->set('metaGroup', 'ContactDB');
}
public function filtering()
{
$this->CRUD->filtering();
}
public function add()
{
$this->CRUD->add();
@ -72,4 +80,31 @@ class IndividualsController extends AppController
}
$this->set('metaGroup', 'ContactDB');
}
public function tag($id)
{
$this->CRUD->tag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function untag($id)
{
$this->CRUD->untag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function viewTags($id)
{
$this->CRUD->viewTags($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
}

View File

@ -112,4 +112,31 @@ class OrganisationsController extends AppController
}
$this->set('metaGroup', 'ContactDB');
}
public function tag($id)
{
$this->CRUD->tag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function untag($id)
{
$this->CRUD->untag($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
public function viewTags($id)
{
$this->CRUD->viewTags($id);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;
}
}
}

View File

@ -14,6 +14,7 @@ class IndividualsTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
$this->addBehavior('Tags.Tag');
$this->hasMany(
'Alignments',
[

View File

@ -9,6 +9,8 @@ use Migrations\Migrations;
class InstanceTable extends AppTable
{
protected $activePlugins = ['Tags'];
public function initialize(array $config): void
{
parent::initialize($config);
@ -23,6 +25,16 @@ class InstanceTable extends AppTable
{
$migrations = new Migrations();
$status = $migrations->status();
foreach ($this->activePlugins as $pluginName) {
$pluginStatus = $migrations->status([
'plugin' => $pluginName
]);
$pluginStatus = array_map(function ($entry) use ($pluginName) {
$entry['plugin'] = $pluginName;
return $entry;
}, $pluginStatus);
$status = array_merge($status, $pluginStatus);
}
$status = array_reverse($status);
$updateAvailables = array_filter($status, function ($update) {

View File

@ -18,6 +18,7 @@ class OrganisationsTable extends AppTable
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Tags.Tag');
$this->hasMany(
'Alignments',
[

View File

@ -43,5 +43,6 @@ class AppView extends View
$this->loadHelper('PrettyPrint');
$this->loadHelper('FormFieldMassage');
$this->loadHelper('Paginator', ['templates' => 'cerebrate-pagination-templates']);
$this->loadHelper('Tags.Tag');
}
}

View File

@ -834,6 +834,7 @@ class BoostrapButton extends BootstrapGeneric {
'class' => [],
'type' => 'button',
'nodeType' => 'button',
'title' => '',
'params' => [],
'badge' => false
];
@ -842,7 +843,7 @@ class BoostrapButton extends BootstrapGeneric {
function __construct($options) {
$this->allowedOptionValues = [
'variant' => BootstrapGeneric::$variants,
'variant' => array_merge(BootstrapGeneric::$variants, ['link', 'text']),
'size' => ['', 'sm', 'lg'],
'type' => ['button', 'submit', 'reset']
];
@ -865,11 +866,15 @@ class BoostrapButton extends BootstrapGeneric {
$this->bsClasses[] = "btn-{$this->options['variant']}";
}
if (!empty($this->options['size'])) {
$this->bsClasses[] = "btn-$this->options['size']";
$this->bsClasses[] = "btn-{$this->options['size']}";
}
if ($this->options['block']) {
$this->bsClasses[] = 'btn-block';
}
if ($this->options['variant'] == 'text') {
$this->bsClasses[] = 'p-0';
$this->bsClasses[] = 'lh-1';
}
}
public function button()
@ -882,7 +887,8 @@ class BoostrapButton extends BootstrapGeneric {
$html = $this->openNode($this->options['nodeType'], array_merge($this->options['params'], [
'class' => array_merge($this->options['class'], $this->bsClasses),
'role' => "alert",
'type' => $this->options['type']
'type' => $this->options['type'],
'title' => h($this->options['title']),
]));
$html .= $this->genIcon();
@ -914,7 +920,8 @@ class BoostrapBadge extends BootstrapGeneric {
'text' => '',
'variant' => 'primary',
'pill' => false,
'title' => ''
'title' => '',
'class' => [],
];
function __construct($options) {
@ -938,11 +945,11 @@ class BoostrapBadge extends BootstrapGeneric {
private function genBadge()
{
$html = $this->genNode('span', [
'class' => [
'class' => array_merge($this->options['class'], [
'badge',
"badge-{$this->options['variant']}",
$this->options['pill'] ? 'badge-pill' : '',
],
]),
'title' => $this->options['title']
], h($this->options['text']));
return $html;

View File

@ -0,0 +1,21 @@
<?php
namespace App\View\Helper;
use Cake\View\Helper;
// This helper helps determining the brightness of a colour (initially only used for the tagging) in order to decide
// what text colour to use against the background (black or white)
class TextColourHelper extends Helper {
public function getTextColour($RGB) {
$r = hexdec(substr($RGB, 1, 2));
$g = hexdec(substr($RGB, 3, 2));
$b = hexdec(substr($RGB, 5, 2));
$average = ((2 * $r) + $b + (3 * $g))/6;
if ($average < 127) {
return 'white';
} else {
return 'black';
}
}
}

View File

@ -20,7 +20,11 @@
),
array(
'field' => 'position'
)
),
array(
'field' => 'tag_list',
'type' => 'tags'
),
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(

View File

@ -23,7 +23,8 @@ echo $this->element('genericElements/IndexTable/index_table', [
'button' => __('Filter'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
'searchKey' => 'value',
'allowFilering' => true
]
]
],
@ -54,6 +55,11 @@ echo $this->element('genericElements/IndexTable/index_table', [
'element' => 'alignments',
'scope' => $alignmentScope
],
[
'name' => __('Tags'),
'data_path' => 'tags',
'element' => 'tags',
],
[
'name' => __('UUID'),
'sort' => 'uuid',

View File

@ -28,6 +28,10 @@ echo $this->element(
'key' => __('Position'),
'path' => 'position'
],
[
'key' => __('Tags'),
'type' => 'tags',
],
[
'key' => __('Alignments'),
'type' => 'alignment',

View File

@ -27,6 +27,10 @@ foreach ($status as $i => &$update) {
} else if ($update['status'] == 'down') {
$update['_rowVariant'] = 'danger';
}
if (!empty($update['plugin'])) {
$update['name'] = "{$update['plugin']}.{$update['name']}";
}
}
echo $this->Bootstrap->table([], [

View File

@ -71,7 +71,12 @@ echo $this->element('genericElements/IndexTable/index_table', [
[
'name' => __('Type'),
'data_path' => 'type',
]
],
[
'name' => __('Tags'),
'data_path' => 'tags',
'element' => 'tags',
],
],
'title' => __('ContactDB Organisation Index'),
'description' => __('A list of organisations known by your Cerebrate instance. This list can get populated either directly, by adding new organisations or by fetching them from trusted remote sources.'),

View File

@ -37,6 +37,10 @@ echo $this->element(
'key' => __('Contacts'),
'path' => 'contacts'
],
[
'key' => __('Tags'),
'type' => 'tags',
],
[
'key' => __('Alignments'),
'type' => 'alignment',

View File

@ -0,0 +1,13 @@
<?php
$tagsHtml = $this->Tag->tags($entity['tags'], [
'allTags' => [],
'picker' => true,
'editable' => true,
]);
?>
<div class="form-group row">
<div class="col-sm-2 col-form-label"><?= __('Tags') ?></div>
<div class="col-sm-10">
<?= $tagsHtml ?>
</div>
</div>

View File

@ -0,0 +1,6 @@
<?php
$tag = $row;
echo $this->Tag->tag($tag, [
]);
?>

View File

@ -1,17 +1,5 @@
<?php
$tags = $this->Hash->extract($row, $field['data_path']);
if (!empty($tags)) {
if (empty($tags[0])) {
$tags = array($tags);
}
echo $this->element(
'ajaxTags',
array(
'attributeId' => 0,
'tags' => $tags,
'tagAccess' => false,
'static_tags_only' => 1
)
);
}
?>
echo $this->Tag->tags($tags, [
'tags'
]);

View File

@ -0,0 +1,3 @@
<?php
$value = Cake\Utility\Hash::get($data, $field['path']);
echo h($value);

View File

@ -0,0 +1,3 @@
<?php
echo $this->Tag->tag($data, [
]);

View File

@ -0,0 +1,8 @@
<?php
// $tags = Cake\Utility\Hash::extract($data, $field['path']);
$tags = Cake\Utility\Hash::get($data, 'tags');
echo $this->Tag->tags($tags, [
'allTags' => $allTags,
'picker' => true,
'editable' => true,
]);

View File

@ -15,7 +15,7 @@ $filteringForm = $this->Bootstrap->table(
[
'labelHtml' => sprintf('%s %s',
__('Value'),
sprintf('<sup class="fa fa-info" title="%s"><sup>', __('Supports strict match and LIKE match with the `%` character.&#10;Example: `%.com`'))
sprintf('<sup class="fa fa-info" title="%s"><sup>', __('Supports strict matches and LIKE matches with the `%` character.&#10;Example: `%.com`'))
)
],
__('Action')
@ -23,12 +23,28 @@ $filteringForm = $this->Bootstrap->table(
'items' => []
]);
if ($taggingEnabled) {
$helpText = $this->Bootstrap->genNode('sup', [
'class' => ['ml-1 fa fa-info'],
'title' => __('Supports negation matches (with the `!` character) and LIKE matches (with the `%` character).&#10;Example: `!exportable`, `%able`'),
]);
$filteringTags = $this->Bootstrap->genNode('h5', [], __('Tags') . $helpText);
$filteringTags .= $this->Tag->tags([], [
'allTags' => $allTags,
'picker' => true,
'editable' => false,
]);
} else {
$filteringTags = '';
}
$modalBody = sprintf('%s%s', $filteringForm, $filteringTags);
echo $this->Bootstrap->modal([
'title' => __('Filtering options for {0}', Inflector::singularize($this->request->getParam('controller'))),
'size' => 'lg',
'type' => 'confirm',
'bodyHtml' => $filteringForm,
'bodyHtml' => $modalBody,
'confirmText' => __('Filter'),
'confirmFunction' => 'filterIndex'
]);
@ -54,7 +70,9 @@ echo $this->Bootstrap->modal([
}
activeFilters[fullFilter] = rowData['value']
})
const searchParam = (new URLSearchParams(activeFilters)).toString();
$select = modalObject.$modal.find('select.tag-input')
activeFilters['filteringTags'] = $select.select2('data').map(tag => tag.text)
const searchParam = jQuery.param(activeFilters);
const url = `/${controller}/${action}?${searchParam}`
const randomValue = getRandomValue()
@ -69,7 +87,9 @@ echo $this->Bootstrap->modal([
$filteringTable.find('tbody').empty()
addControlRow($filteringTable)
const randomValue = getRandomValue()
const activeFilters = $(`#toggleFilterButton-${randomValue}`).data('activeFilters')
const activeFilters = Object.assign({}, $(`#toggleFilterButton-${randomValue}`).data('activeFilters'))
const tags = activeFilters['filteringTags'] !== undefined ? Object.assign({}, activeFilters)['filteringTags'] : []
delete activeFilters['filteringTags']
for (let [field, value] of Object.entries(activeFilters)) {
const fieldParts = field.split(' ')
let operator = '='
@ -81,6 +101,20 @@ echo $this->Bootstrap->modal([
}
addFilteringRow($filteringTable, field, value, operator)
}
$select = $filteringTable.closest('.modal-body').find('select.tag-input')
let passedTags = []
tags.forEach(tagname => {
const existingOption = $select.find('option').filter(function() {
return $(this).val() === tagname
})
if (existingOption.length == 0) {
passedTags.push(new Option(tagname, tagname, true, true))
}
})
$select
.append(passedTags)
.val(tags)
.trigger('change')
}
function addControlRow($filteringTable) {

View File

@ -0,0 +1,6 @@
<?php
echo $this->Tag->tags($entity->tags, [
'allTags' => $allTags,
'picker' => true,
'editable' => true,
]);

View File

@ -0,0 +1,24 @@
<?php
$form = $this->element('genericElements/Form/genericForm', [
'entity' => null,
'ajax' => false,
'raw' => true,
'data' => [
'fields' => [
[
'type' => 'text',
'field' => 'ids',
'default' => !empty($id) ? json_encode([$id]) : ''
],
[
'type' => 'text',
'field' => 'tag_list',
],
],
'submit' => [
'action' => $this->request->getParam('action')
]
]
]);
$formHTML = sprintf('<div class="d-none">%s</div>', $form);
echo $formHTML;

View File

@ -60,6 +60,10 @@ $cakeDescription = 'Cerebrate';
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
<?= $this->Html->css('bootstrap-additional.css') ?>
<?= $this->Html->script('Tags.tagging') ?>
<?= $this->Html->css('Tags.tagging') ?>
<?= $this->Html->meta('favicon.ico', '/img/favicon.ico', ['type' => 'icon']); ?>
</head>
<body>
@ -86,6 +90,5 @@ $cakeDescription = 'Cerebrate';
</body>
<script>
const darkMode = (<?= empty($darkMode) ? 'false' : 'true' ?>)
$.fn.select2.defaults.set('theme', 'bootstrap4');
</script>
</html>

View File

@ -115,6 +115,13 @@
.text-black {color:black;}
.text-white {color:white;}
.lh-1 {
line-height: 1;
}
.lh-2 {
line-height: 1.5;
}
.link-unstyled, .link-unstyled:link, .link-unstyled:hover {
color: inherit;
text-decoration: inherit;
@ -145,3 +152,15 @@ input[type="checkbox"]:disabled.change-cursor {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.select2-selection__choice { /* Our app do not have the same font size */
padding-left: 1.5em !important;
}
.picker-container .select2-selection {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.picker-container .picker-action .btn:first-child {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}

View File

@ -217,8 +217,9 @@ class UIFactory {
return AJAXApi.quickFetchURL(url, {
statusNode: $statusNode[0],
}).then((theHTML) => {
$container.replaceWith(theHTML)
return $container
var $tmp = $(theHTML);
$container.replaceWith($tmp)
return $tmp;
}).finally(() => {
otherStatusNodes.forEach(overlay => {
overlay.hide()
@ -267,9 +268,9 @@ class Toaster {
* @property {string=('primary'|'secondary'|'success'|'danger'|'warning'|'info'|'light'|'dark'|'white'|'transparent')} variant - The variant of the toast
* @property {boolean} autohide - If the toast show be hidden after some time defined by the delay
* @property {number} delay - The number of milliseconds the toast should stay visible before being hidden
* @property {string} titleHtml - The raw HTML title's content of the toast
* @property {string} mutedHtml - The raw HTML muted's content of the toast
* @property {string} bodyHtml - The raw HTML body's content of the toast
* @property {(jQuery|string)} titleHtml - The raw HTML title's content of the toast
* @property {(jQuery|string)} mutedHtml - The raw HTML muted's content of the toast
* @property {(jQuery|string)} bodyHtml - The raw HTML body's content of the toast
* @property {boolean} closeButton - If the toast's title should include a close button
*/
static defaultOptions = {
@ -290,6 +291,7 @@ class Toaster {
makeToast() {
if (this.isValid()) {
this.$toast = Toaster.buildToast(this.options)
this.$toast.data('toastObject', this)
$('#mainToastContainer').append(this.$toast)
}
}
@ -300,6 +302,19 @@ class Toaster {
var that = this
this.$toast.toast(this.bsToastOptions)
.toast('show')
.on('hide.bs.toast', function (evt) {
const $toast = $(this)
const hoveredElements = $(':hover').filter(function() {
return $(this).is($toast)
});
if (hoveredElements.length > 0) {
console.log('Toast hovered. Not hidding')
evt.preventDefault()
setTimeout(() => {
$toast.toast('hide')
}, that.options.delay);
}
})
.on('hidden.bs.toast', function () {
that.removeToast()
})
@ -354,7 +369,10 @@ class Toaster {
$toastHeader.append($toastHeaderMuted)
}
if (options.closeButton) {
var $closeButton = $('<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close"><span aria-hidden="true">&times;</span></button>')
var $closeButton = $('<button type="button" class="ml-2 mb-1 close" aria-label="Close"><span aria-hidden="true">&times;</span></button>')
.click(function() {
$(this).closest('.toast').data('toastObject').removeToast()
})
$toastHeader.append($closeButton)
}
$toast.append($toastHeader)
@ -860,7 +878,8 @@ class OverlayFactory {
spinnerVariant: '',
spinnerSmall: false,
spinnerType: 'border',
fallbackBoostrapVariant: ''
fallbackBoostrapVariant: '',
wrapperCSSDisplay: '',
}
static overlayWrapper = '<div aria-busy="true" class="position-relative"/>'
@ -875,6 +894,14 @@ class OverlayFactory {
/** Create the HTML of the overlay */
buildOverlay() {
this.$overlayWrapper = $(OverlayFactory.overlayWrapper)
if (this.options.wrapperCSSDisplay) {
this.$overlayWrapper.css('display', this.options.wrapperCSSDisplay)
}
if (this.$node[0]) {
const boundingRect = this.$node[0].getBoundingClientRect()
this.$overlayWrapper.css('min-height', boundingRect.height)
this.$overlayWrapper.css('min-width', boundingRect.width)
}
this.$overlayContainer = $(OverlayFactory.overlayContainer)
this.$overlayBg = $(OverlayFactory.overlayBg)
.addClass([`bg-${this.options.variant}`, (this.options.rounded ? 'rounded' : '')])
@ -940,7 +967,8 @@ class OverlayFactory {
}
if (this.$node.is('input[type="checkbox"]') || this.$node.css('border-radius') !== '0px') {
this.options.rounded = true
}
}
this.options.wrapperCSSDisplay = this.$node.css('display')
let classes = this.$node.attr('class')
if (classes !== undefined) {
classes = classes.split(' ')
@ -1108,4 +1136,5 @@ class HtmlHelper {
}
return $table
}
}
}

View File

@ -99,6 +99,22 @@ function syntaxHighlightJson(json, indent) {
});
}
function getTextColour(hex) {
if (hex === undefined || hex.length == 0) {
return 'black'
}
hex = hex.slice(1)
var r = parseInt(hex.substring(0,2), 16)
var g = parseInt(hex.substring(2,4), 16)
var b = parseInt(hex.substring(4,6), 16)
var avg = ((2 * r) + b + (3 * g))/6
if (avg < 128) {
return 'white'
} else {
return 'black'
}
}
var UI
$(document).ready(() => {
if (typeof UIFactory !== "undefined") {