diff --git a/composer.json b/composer.json
index f0b35aa..c233ebd 100644
--- a/composer.json
+++ b/composer.json
@@ -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/"
}
},
diff --git a/plugins/Tags/config/Migrations/20210831121348_TagSystem.php b/plugins/Tags/config/Migrations/20210831121348_TagSystem.php
new file mode 100644
index 0000000..7b15623
--- /dev/null
+++ b/plugins/Tags/config/Migrations/20210831121348_TagSystem.php
@@ -0,0 +1,89 @@
+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();
+ }
+}
\ No newline at end of file
diff --git a/plugins/Tags/config/routes.php b/plugins/Tags/config/routes.php
new file mode 100644
index 0000000..3fab36a
--- /dev/null
+++ b/plugins/Tags/config/routes.php
@@ -0,0 +1,20 @@
+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']);
+ }
+);
\ No newline at end of file
diff --git a/plugins/Tags/src/Controller/AppController.php b/plugins/Tags/src/Controller/AppController.php
new file mode 100644
index 0000000..5a0bda8
--- /dev/null
+++ b/plugins/Tags/src/Controller/AppController.php
@@ -0,0 +1,13 @@
+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;
+ // }
+}
diff --git a/plugins/Tags/src/Model/Behavior/TagBehavior.php b/plugins/Tags/src/Model/Behavior/TagBehavior.php
new file mode 100644
index 0000000..3db1b67
--- /dev/null
+++ b/plugins/Tags/src/Model/Behavior/TagBehavior.php
@@ -0,0 +1,355 @@
+ '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;
+ }
+}
\ No newline at end of file
diff --git a/plugins/Tags/src/Model/Entity/Tag.php b/plugins/Tags/src/Model/Entity/Tag.php
new file mode 100644
index 0000000..19802f0
--- /dev/null
+++ b/plugins/Tags/src/Model/Entity/Tag.php
@@ -0,0 +1,43 @@
+ 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';
+ }
+ }
+
+}
diff --git a/plugins/Tags/src/Model/Entity/Tagged.php b/plugins/Tags/src/Model/Entity/Tagged.php
new file mode 100644
index 0000000..eac3b44
--- /dev/null
+++ b/plugins/Tags/src/Model/Entity/Tagged.php
@@ -0,0 +1,14 @@
+ false,
+ '*' => true,
+ ];
+
+}
diff --git a/plugins/Tags/src/Model/Table/TaggedTable.php b/plugins/Tags/src/Model/Table/TaggedTable.php
new file mode 100644
index 0000000..f6604d2
--- /dev/null
+++ b/plugins/Tags/src/Model/Table/TaggedTable.php
@@ -0,0 +1,33 @@
+ 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;
+ }
+}
diff --git a/plugins/Tags/src/Model/Table/TagsTable.php b/plugins/Tags/src/Model/Table/TagsTable.php
new file mode 100644
index 0000000..2149134
--- /dev/null
+++ b/plugins/Tags/src/Model/Table/TagsTable.php
@@ -0,0 +1,27 @@
+ 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;
+ }
+}
diff --git a/plugins/Tags/src/Plugin.php b/plugins/Tags/src/Plugin.php
new file mode 100644
index 0000000..84c8702
--- /dev/null
+++ b/plugins/Tags/src/Plugin.php
@@ -0,0 +1,41 @@
+ '#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 .= '';
+ }
+ return $html;
+ }
+
+ public function tags(array $tags = [], array $options = [])
+ {
+ $this->_config = array_merge($this->defaultConfig, $options);
+ $html = '
';
+ $html .= '
';
+ $html .= '
';
+ foreach ($tags as $tag) {
+ if (is_object($tag)) {
+ $html .= $this->tag($tag);
+ } else {
+ $html .= $this->tag([
+ 'label' => $tag
+ ]);
+ }
+ }
+ $html .= '
';
+
+ if (!empty($this->getConfig('picker'))) {
+ $html .= $this->picker($options);
+ }
+ $html .= '
';
+ $html .= '
';
+ 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;
+ }
+}
diff --git a/plugins/Tags/templates/Tags/add.php b/plugins/Tags/templates/Tags/add.php
new file mode 100644
index 0000000..412dc1c
--- /dev/null
+++ b/plugins/Tags/templates/Tags/add.php
@@ -0,0 +1,22 @@
+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')
+ )
+ )
+ ));
+?>
+
diff --git a/plugins/Tags/templates/Tags/index.php b/plugins/Tags/templates/Tags/index.php
new file mode 100644
index 0000000..a826fb1
--- /dev/null
+++ b/plugins/Tags/templates/Tags/index.php
@@ -0,0 +1,79 @@
+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 '';
+?>
diff --git a/plugins/Tags/templates/Tags/view.php b/plugins/Tags/templates/Tags/view.php
new file mode 100644
index 0000000..0886fc9
--- /dev/null
+++ b/plugins/Tags/templates/Tags/view.php
@@ -0,0 +1,32 @@
+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' => []
+ ]
+);
diff --git a/plugins/Tags/webroot/css/tagging.css b/plugins/Tags/webroot/css/tagging.css
new file mode 100644
index 0000000..260b4ce
--- /dev/null
+++ b/plugins/Tags/webroot/css/tagging.css
@@ -0,0 +1,3 @@
+.tag {
+ filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.5));
+}
diff --git a/plugins/Tags/webroot/js/tagging.js b/plugins/Tags/webroot/js/tagging.js
new file mode 100644
index 0000000..355f6a9
--- /dev/null
+++ b/plugins/Tags/webroot/js/tagging.js
@@ -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 = $('').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button')
+ .append($('').text('Save').addClass('text-nowrap').prepend($('').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 = $('').addClass(['btn btn-secondary btn-sm', 'align-self-start']).attr('type', 'button')
+ .append($('').text('Cancel').addClass('text-nowrap').prepend($('').addClass('fa fa-times mr-1')))
+ .click(function() {
+ closePicker($select, $container)
+ })
+ const $buttons = $('').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 = $('').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: $('').append(
+ $('').text('Cancel untag operation.'),
+ $('').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 = $('')
+ .addClass(['tag', 'badge', 'align-text-top'])
+ .css({color: getTextColour(options.colour), 'background-color': options.colour})
+ .text(options.text)
+
+ return $tag
+}
\ No newline at end of file
diff --git a/src/Application.php b/src/Application.php
index 2da0df1..2a8a88b 100644
--- a/src/Application.php
+++ b/src/Application.php
@@ -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
}
diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php
index 498b0ae..913a633 100644
--- a/src/Controller/AppController.php
+++ b/src/Controller/AppController.php
@@ -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
diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php
index fc8966c..8e0e25f 100644
--- a/src/Controller/Component/ACLComponent.php
+++ b/src/Controller/Component/ACLComponent.php
@@ -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' => [
diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php
index 14f4c4f..75f02ba 100644
--- a/src/Controller/Component/CRUDComponent.php
+++ b/src/Controller/Component/CRUDComponent.php
@@ -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)) {
diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php
index e3445eb..71e4a0f 100644
--- a/src/Controller/IndividualsController.php
+++ b/src/Controller/IndividualsController.php
@@ -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;
+ }
+ }
}
diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php
index 8e8cba8..d381595 100644
--- a/src/Controller/OrganisationsController.php
+++ b/src/Controller/OrganisationsController.php
@@ -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;
+ }
+ }
}
diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php
index d6893f7..f8c41f1 100644
--- a/src/Model/Table/IndividualsTable.php
+++ b/src/Model/Table/IndividualsTable.php
@@ -14,6 +14,7 @@ class IndividualsTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
+ $this->addBehavior('Tags.Tag');
$this->hasMany(
'Alignments',
[
diff --git a/src/Model/Table/InstanceTable.php b/src/Model/Table/InstanceTable.php
index 5613e95..dd935b8 100644
--- a/src/Model/Table/InstanceTable.php
+++ b/src/Model/Table/InstanceTable.php
@@ -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) {
diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php
index 8587864..2ac2b66 100644
--- a/src/Model/Table/OrganisationsTable.php
+++ b/src/Model/Table/OrganisationsTable.php
@@ -18,6 +18,7 @@ class OrganisationsTable extends AppTable
public function initialize(array $config): void
{
parent::initialize($config);
+ $this->addBehavior('Tags.Tag');
$this->hasMany(
'Alignments',
[
diff --git a/src/View/AppView.php b/src/View/AppView.php
index 87b775b..7636a87 100644
--- a/src/View/AppView.php
+++ b/src/View/AppView.php
@@ -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');
}
}
diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php
index 532f2fd..2afe51a 100644
--- a/src/View/Helper/BootstrapHelper.php
+++ b/src/View/Helper/BootstrapHelper.php
@@ -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;
diff --git a/src/View/Helper/TextColourHelper.php b/src/View/Helper/TextColourHelper.php
new file mode 100644
index 0000000..c484486
--- /dev/null
+++ b/src/View/Helper/TextColourHelper.php
@@ -0,0 +1,21 @@
+ 'position'
- )
+ ),
+ array(
+ 'field' => 'tag_list',
+ 'type' => 'tags'
+ ),
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
diff --git a/templates/Individuals/index.php b/templates/Individuals/index.php
index 91520e1..4930899 100644
--- a/templates/Individuals/index.php
+++ b/templates/Individuals/index.php
@@ -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',
diff --git a/templates/Individuals/view.php b/templates/Individuals/view.php
index 6c4d6a8..3eeddbc 100644
--- a/templates/Individuals/view.php
+++ b/templates/Individuals/view.php
@@ -28,6 +28,10 @@ echo $this->element(
'key' => __('Position'),
'path' => 'position'
],
+ [
+ 'key' => __('Tags'),
+ 'type' => 'tags',
+ ],
[
'key' => __('Alignments'),
'type' => 'alignment',
diff --git a/templates/Instance/migration_index.php b/templates/Instance/migration_index.php
index f2bef2e..022cc28 100644
--- a/templates/Instance/migration_index.php
+++ b/templates/Instance/migration_index.php
@@ -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([], [
diff --git a/templates/Organisations/index.php b/templates/Organisations/index.php
index da638cb..4a98718 100644
--- a/templates/Organisations/index.php
+++ b/templates/Organisations/index.php
@@ -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.'),
diff --git a/templates/Organisations/view.php b/templates/Organisations/view.php
index 4608d6a..415795c 100644
--- a/templates/Organisations/view.php
+++ b/templates/Organisations/view.php
@@ -37,6 +37,10 @@ echo $this->element(
'key' => __('Contacts'),
'path' => 'contacts'
],
+ [
+ 'key' => __('Tags'),
+ 'type' => 'tags',
+ ],
[
'key' => __('Alignments'),
'type' => 'alignment',
diff --git a/templates/element/genericElements/Form/Fields/tagsField.php b/templates/element/genericElements/Form/Fields/tagsField.php
new file mode 100644
index 0000000..4d67db8
--- /dev/null
+++ b/templates/element/genericElements/Form/Fields/tagsField.php
@@ -0,0 +1,13 @@
+Tag->tags($entity['tags'], [
+ 'allTags' => [],
+ 'picker' => true,
+ 'editable' => true,
+ ]);
+?>
+
\ No newline at end of file
diff --git a/templates/element/genericElements/IndexTable/Fields/tag.php b/templates/element/genericElements/IndexTable/Fields/tag.php
new file mode 100644
index 0000000..fbb270a
--- /dev/null
+++ b/templates/element/genericElements/IndexTable/Fields/tag.php
@@ -0,0 +1,6 @@
+Tag->tag($tag, [
+ ]);
+
+?>
diff --git a/templates/element/genericElements/IndexTable/Fields/tags.php b/templates/element/genericElements/IndexTable/Fields/tags.php
index b3dd0d8..a5eb311 100644
--- a/templates/element/genericElements/IndexTable/Fields/tags.php
+++ b/templates/element/genericElements/IndexTable/Fields/tags.php
@@ -1,17 +1,5 @@
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'
+ ]);
diff --git a/templates/element/genericElements/SingleViews/Fields/stringField.php b/templates/element/genericElements/SingleViews/Fields/stringField.php
new file mode 100644
index 0000000..fc27aa5
--- /dev/null
+++ b/templates/element/genericElements/SingleViews/Fields/stringField.php
@@ -0,0 +1,3 @@
+Tag->tag($data, [
+]);
diff --git a/templates/element/genericElements/SingleViews/Fields/tagsField.php b/templates/element/genericElements/SingleViews/Fields/tagsField.php
new file mode 100644
index 0000000..414da50
--- /dev/null
+++ b/templates/element/genericElements/SingleViews/Fields/tagsField.php
@@ -0,0 +1,8 @@
+Tag->tags($tags, [
+ 'allTags' => $allTags,
+ 'picker' => true,
+ 'editable' => true,
+]);
diff --git a/templates/genericTemplates/filters.php b/templates/genericTemplates/filters.php
index b3b0c91..9a7c49a 100644
--- a/templates/genericTemplates/filters.php
+++ b/templates/genericTemplates/filters.php
@@ -15,7 +15,7 @@ $filteringForm = $this->Bootstrap->table(
[
'labelHtml' => sprintf('%s %s',
__('Value'),
- sprintf('', __('Supports strict match and LIKE match with the `%` character.
Example: `%.com`'))
+ sprintf('', __('Supports strict matches and LIKE matches with the `%` character.
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).
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) {
diff --git a/templates/genericTemplates/tag.php b/templates/genericTemplates/tag.php
new file mode 100644
index 0000000..41e84d3
--- /dev/null
+++ b/templates/genericTemplates/tag.php
@@ -0,0 +1,6 @@
+Tag->tags($entity->tags, [
+ 'allTags' => $allTags,
+ 'picker' => true,
+ 'editable' => true,
+ ]);
diff --git a/templates/genericTemplates/tagForm.php b/templates/genericTemplates/tagForm.php
new file mode 100644
index 0000000..be5ebba
--- /dev/null
+++ b/templates/genericTemplates/tagForm.php
@@ -0,0 +1,24 @@
+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('%s
', $form);
+echo $formHTML;
diff --git a/templates/layout/default.php b/templates/layout/default.php
index 1e2cbde..93bd9eb 100644
--- a/templates/layout/default.php
+++ b/templates/layout/default.php
@@ -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']); ?>
@@ -86,6 +90,5 @@ $cakeDescription = 'Cerebrate';