From b1e5bbad1aa35380453c052389762981f8bd7225 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 24 Aug 2021 10:48:53 +0200 Subject: [PATCH 01/16] new: [tag] Started integration of tag plugin with custom helpers - WiP --- composer.json | 1 + src/Application.php | 1 + src/Model/Table/IndividualsTable.php | 4 + src/View/Helper/BootstrapHelper.php | 22 +++-- src/View/Helper/TagHelper.php | 98 +++++++++++++++++++ src/View/Helper/TextColourHelper.php | 21 ++++ templates/Individuals/view.php | 4 + .../SingleViews/Fields/tagsField.php | 16 +++ templates/layout/default.php | 1 - webroot/css/main.css | 19 ++++ webroot/js/bootstrap-helper.js | 11 ++- webroot/js/main.js | 54 ++++++++++ 12 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 src/View/Helper/TagHelper.php create mode 100644 src/View/Helper/TextColourHelper.php create mode 100644 templates/element/genericElements/SingleViews/Fields/tagsField.php diff --git a/composer.json b/composer.json index f0b35aa..13f42e8 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "cakephp/cakephp": "^4.0", "cakephp/migrations": "^3.0", "cakephp/plugin-installer": "^1.2", + "dereuromark/cakephp-tags": "^1.2", "erusev/parsedown": "^1.7", "mobiledetect/mobiledetectlib": "^2.8" }, diff --git a/src/Application.php b/src/Application.php index 2da0df1..c6529bd 100644 --- a/src/Application.php +++ b/src/Application.php @@ -59,6 +59,7 @@ class Application extends BaseApplication implements AuthenticationServiceProvid $this->addPlugin('DebugKit'); } $this->addPlugin('Authentication'); + $this->addPlugin('Tags'); // Load more plugins here } diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index d6893f7..81d4c65 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -14,6 +14,10 @@ class IndividualsTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->addBehavior('Tags.Tag', [ + 'taggedCounter' => false, + 'strategy' => 'array', + ]); $this->hasMany( 'Alignments', [ diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 05ce6fe..83cd7f1 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -670,6 +670,7 @@ class BoostrapButton extends BootstrapGeneric { 'class' => [], 'type' => 'button', 'nodeType' => 'button', + 'title' => '', 'params' => [], 'badge' => false ]; @@ -678,7 +679,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'] ]; @@ -701,11 +702,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() @@ -718,7 +723,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(); @@ -734,7 +740,8 @@ class BoostrapButton extends BootstrapGeneric { private function genIcon() { return $this->genNode('span', [ - 'class' => ['mr-1', "fa fa-{$this->options['icon']}"], + // 'class' => ['mr-1', "fa fa-{$this->options['icon']}"], + 'class' => ['', "fa fa-{$this->options['icon']}"], ]); } @@ -749,7 +756,8 @@ class BoostrapBadge extends BootstrapGeneric { 'text' => '', 'variant' => 'primary', 'pill' => false, - 'title' => '' + 'title' => '', + 'class' => [], ]; function __construct($options) { @@ -773,11 +781,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/TagHelper.php b/src/View/Helper/TagHelper.php new file mode 100644 index 0000000..2cca055 --- /dev/null +++ b/src/View/Helper/TagHelper.php @@ -0,0 +1,98 @@ + '#983965', + ]; + + public function control(array $options = []) + { + return $this->Tag->control($options); + } + + public function picker(array $options = []) + { + $optionsHtml = ''; + foreach ($options['allTags'] as $i => $tag) { + $optionsHtml .= $this->Bootstrap->genNode('option', [ + 'value' => h($tag['text']), + 'data-colour' => h($tag['colour']), + ], h($tag['text'])); + } + $html = $this->Bootstrap->genNode('select', [ + 'class' => ['tag-input', 'd-none'], + 'multiple' => '', + ], $optionsHtml); + $html .= $this->Bootstrap->button([ + 'size' => 'sm', + 'icon' => 'plus', + 'variant' => 'secondary', + 'class' => ['badge'], + 'params' => [ + 'onclick' => 'createTagPicker(this)', + ] + ]); + return $html; + } + + public function tags(array $options = []) + { + $tags = !empty($options['tags']) ? $options['tags'] : []; + $html = '
'; + $html .= '
'; + foreach ($tags as $tag) { + if (is_array($tag)) { + $html .= $this->tag($tag); + } else { + $html .= $this->tag([ + 'name' => $tag + ]); + } + } + $html .= '
'; + + if (!empty($options['picker'])) { + $html .= $this->picker($options); + } + $html .= '
'; + return $html; + } + + public function tag(array $tag) + { + $tag['colour'] = !empty($tag['colour']) ? $tag['colour'] : $this->getConfig()['default_colour']; + $textColour = $this->TextColour->getTextColour(h($tag['colour'])); + $deleteButton = $this->Bootstrap->button([ + 'size' => 'sm', + 'icon' => 'times', + 'class' => ['ml-1', 'border-0', "text-${textColour}"], + 'variant' => 'text', + 'title' => __('Delete tag'), + ]); + + $html = $this->Bootstrap->genNode('span', [ + 'class' => [ + 'tag', + 'badge', + 'mx-1', + 'align-middle', + ], + 'title' => h($tag['name']), + 'style' => sprintf('color:%s; background-color:%s', $textColour, h($tag['colour'])), + ], h($tag['name']) . $deleteButton); + 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 @@ +element( 'key' => __('Position'), 'path' => 'position' ], + [ + 'key' => __('Tags'), + 'type' => 'tags', + ], [ 'key' => __('Alignments'), 'type' => 'alignment', diff --git a/templates/element/genericElements/SingleViews/Fields/tagsField.php b/templates/element/genericElements/SingleViews/Fields/tagsField.php new file mode 100644 index 0000000..a8b9f88 --- /dev/null +++ b/templates/element/genericElements/SingleViews/Fields/tagsField.php @@ -0,0 +1,16 @@ + 'tlp:red', 'text' => 'tlp:red', 'colour' => 'red'], + ['id' => 'tlp:green', 'text' => 'tlp:green', 'colour' => 'green'], + ['id' => 'tlp:amber', 'text' => 'tlp:amber', 'colour' => '#983965'], + ['id' => 'tlp:white', 'text' => 'tlp:white', 'colour' => 'white'], +]; +$this->loadHelper('Tag'); +echo $this->Tag->tags([ + 'allTags' => $allTags, + 'tags' => $tagList, + 'picker' => true, +]); \ No newline at end of file diff --git a/templates/layout/default.php b/templates/layout/default.php index 1e2cbde..d97b800 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -86,6 +86,5 @@ $cakeDescription = 'Cerebrate'; diff --git a/webroot/css/main.css b/webroot/css/main.css index bcf6c0e..de19e15 100644 --- a/webroot/css/main.css +++ b/webroot/css/main.css @@ -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; +} \ No newline at end of file diff --git a/webroot/js/bootstrap-helper.js b/webroot/js/bootstrap-helper.js index 61bca3f..2fc230a 100644 --- a/webroot/js/bootstrap-helper.js +++ b/webroot/js/bootstrap-helper.js @@ -1108,4 +1108,13 @@ class HtmlHelper { } return $table } -} \ No newline at end of file + + static tag(options={}) { + const $tag = $('') + .addClass(['tag', 'badge', 'border']) + .css({color: getTextColour(options.colour), 'background-color': options.colour}) + .text(options.text) + + return $tag + } +} diff --git a/webroot/js/main.js b/webroot/js/main.js index d950781..9ff0797 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -99,6 +99,60 @@ 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' + } +} + +function createTagPicker(clicked) { + + function templateTag(state) { + if (!state.id) { + return state.text; + } + if (state.colour === undefined) { + state.colour = $(state.element).data('colour') + } + return HtmlHelper.tag(state) + } + + const $clicked = $(clicked) + const $container = $clicked.closest('.tag-container') + $('.picker-container').remove() + const $pickerContainer = $('
').addClass(['picker-container', 'd-flex']) + const $select = $container.find('select.tag-input').removeClass('d-none').addClass('flex-grow-1') + const $saveButton = $('').addClass(['btn btn-primary btn-sm', 'align-self-start']) + .append($('').text('Save').prepend($('').addClass('fa fa-save mr-1'))) + const $cancelButton = $('').addClass(['btn btn-secondary btn-sm', 'align-self-start']) + .append($('').text('Cancel').prepend($('').addClass('fa fa-times mr-1'))) + .click(function() { + $select.appendTo($container) + $pickerContainer.remove() + }) + const $buttons = $('').addClass(['picker-action', 'btn-group']).append($saveButton, $cancelButton) + $select.prependTo($pickerContainer) + $pickerContainer.append($buttons) + $container.parent().append($pickerContainer) + $select.select2({ + placeholder: 'Pick a tag', + tags: true, + templateResult: templateTag, + templateSelection: templateTag, + }) +} + + var UI $(document).ready(() => { if (typeof UIFactory !== "undefined") { From 8b659fb6afec2285a5b0255a028fbc1ca9ff28b2 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Thu, 26 Aug 2021 12:06:12 +0200 Subject: [PATCH 02/16] chg: [tag] Continuation of integrating tagging plugin - WiP - Tagging / Untagging --- src/Controller/Component/CRUDComponent.php | 154 ++++++++++++++++++ src/Controller/IndividualsController.php | 27 +++ src/View/Helper/TagHelper.php | 84 ++++++---- templates/Individuals/add.php | 6 +- .../genericElements/Form/Fields/tagsField.php | 19 +++ .../SingleViews/Fields/tagsField.php | 5 +- templates/genericTemplates/tag.php | 13 ++ templates/genericTemplates/tagForm.php | 24 +++ webroot/js/bootstrap-helper.js | 25 ++- webroot/js/main.js | 70 +++++++- 10 files changed, 382 insertions(+), 45 deletions(-) create mode 100644 templates/element/genericElements/Form/Fields/tagsField.php create mode 100644 templates/genericTemplates/tag.php create mode 100644 templates/genericTemplates/tagForm.php diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 14f4c4f..2f32bcd 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -243,6 +243,9 @@ class CRUDComponent extends Component throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } $this->getMetaTemplates(); + if ($this->taggingSupported()) { + $params['contain'][] = 'Tags'; + } $data = $this->Table->get($id, isset($params['get']) ? $params['get'] : []); $data = $this->getMetaFields($id, $data); if (!empty($params['fields'])) { @@ -350,6 +353,10 @@ class CRUDComponent extends Component throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } + if ($this->taggingSupported()) { + $params['contain'][] = 'Tags'; + } + $data = $this->Table->get($id, $params); $data = $this->attachMetaData($id, $data); if (isset($params['afterFind'])) { @@ -404,6 +411,148 @@ 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')) { + 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); + // patching will mirror tag in the DB, however, we only want to add tags + $input = $this->request->getData(); + $tagsToAdd = explode(',', $input['tag_list']); + $entity->tag_list = $entity->tag_list; + $input['tag_list'] = implode(',', array_merge($tagsToAdd, $entity->tag_list)); + $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.', $this->ObjectAlias), + __('All {0} have been tagged.', Inflector::pluralize($this->ObjectAlias)), + __('Could not tag {0}.', $this->ObjectAlias), + __('{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')) { + 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); + // patching will mirror tag in the DB, however, we only want to remove tags + $input = $this->request->getData(); + $tagsToRemove = explode(',', $input['tag_list']); + $entity->tag_list = $entity->tag_list; + $input['tag_list'] = implode(',', array_filter($entity->tag_list, function ($existingTag) use ($tagsToRemove) { + return !in_array($existingTag, $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.', $this->ObjectAlias), + __('All {0} have been untagged.', Inflector::pluralize($this->ObjectAlias)), + __('Could not untag {0}.', $this->ObjectAlias), + __('{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->Controller->viewBuilder()->setLayout('ajax'); + $this->Controller->render('/genericTemplates/tag'); + } + public function setResponseForController($action, $success, $message, $data=[], $errors=null) { if ($success) { @@ -683,6 +832,11 @@ class CRUDComponent extends Component return $prefixedConditions; } + public function taggingSupported() + { + return $this->Table->behaviors()->has('Tag'); + } + 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..13e37c8 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -72,4 +72,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/View/Helper/TagHelper.php b/src/View/Helper/TagHelper.php index 2cca055..9e38f1c 100644 --- a/src/View/Helper/TagHelper.php +++ b/src/View/Helper/TagHelper.php @@ -11,31 +11,38 @@ class TagHelper extends Helper 'Bootstrap', 'TextColour', 'FontAwesome', + 'Form', + 'Url', 'Tags.Tag', ]; - protected $_defaultConfig = [ - 'default_colour' => '#983965', + protected $defaultConfig = [ + 'default_colour' => '#924da6', + 'picker' => false, + 'editable' => false, ]; public function control(array $options = []) { - return $this->Tag->control($options); - } - - public function picker(array $options = []) - { - $optionsHtml = ''; - foreach ($options['allTags'] as $i => $tag) { - $optionsHtml .= $this->Bootstrap->genNode('option', [ + $field = 'tag_list'; + $values = !empty($options['allTags']) ? array_map(function($tag) { + return [ + 'text' => h($tag['text']), 'value' => h($tag['text']), 'data-colour' => h($tag['colour']), - ], h($tag['text'])); - } - $html = $this->Bootstrap->genNode('select', [ - 'class' => ['tag-input', 'd-none'], - 'multiple' => '', - ], $optionsHtml); + ]; + }, $options['allTags']) : []; + $selectConfig = [ + 'multiple' => true, + // 'value' => $options['tags'], + 'class' => ['tag-input', 'd-none'] + ]; + return $this->Form->select($field, $values, $selectConfig); + } + + protected function picker(array $options = []) + { + $html = $this->Tag->control($options); $html .= $this->Bootstrap->button([ 'size' => 'sm', 'icon' => 'plus', @@ -50,8 +57,10 @@ class TagHelper extends Helper public function tags(array $options = []) { + $this->_config = array_merge($this->defaultConfig, $options); $tags = !empty($options['tags']) ? $options['tags'] : []; - $html = '
'; + $html = '
'; + $html .= '
'; $html .= '
'; foreach ($tags as $tag) { if (is_array($tag)) { @@ -64,25 +73,44 @@ class TagHelper extends Helper } $html .= '
'; - if (!empty($options['picker'])) { + if (!empty($this->getConfig('picker'))) { $html .= $this->picker($options); } $html .= '
'; + $html .= '
'; return $html; } - public function tag(array $tag) + public function tag(array $tag, array $options = []) { - $tag['colour'] = !empty($tag['colour']) ? $tag['colour'] : $this->getConfig()['default_colour']; + if (empty($this->_config)) { + $this->_config = array_merge($this->defaultConfig, $options); + } + $tag['colour'] = !empty($tag['colour']) ? $tag['colour'] : $this->getConfig('default_colour'); $textColour = $this->TextColour->getTextColour(h($tag['colour'])); - $deleteButton = $this->Bootstrap->button([ - 'size' => 'sm', - 'icon' => 'times', - 'class' => ['ml-1', 'border-0', "text-${textColour}"], - 'variant' => 'text', - 'title' => __('Delete tag'), - ]); - + + 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['name']) + ), + ], + ]); + } else { + $deleteButton = ''; + } + $html = $this->Bootstrap->genNode('span', [ 'class' => [ 'tag', diff --git a/templates/Individuals/add.php b/templates/Individuals/add.php index 1941ad3..935c2de 100644 --- a/templates/Individuals/add.php +++ b/templates/Individuals/add.php @@ -20,7 +20,11 @@ ), array( 'field' => 'position' - ) + ), + array( + 'field' => 'tag_list', + 'type' => 'tags' + ), ), 'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates, 'submit' => array( diff --git a/templates/element/genericElements/Form/Fields/tagsField.php b/templates/element/genericElements/Form/Fields/tagsField.php new file mode 100644 index 0000000..fdc99f1 --- /dev/null +++ b/templates/element/genericElements/Form/Fields/tagsField.php @@ -0,0 +1,19 @@ + 'tlp:red', 'text' => 'tlp:red', 'colour' => 'red'], + ['id' => 'tlp:green', 'text' => 'tlp:green', 'colour' => 'green'], + ['id' => 'tlp:amber', 'text' => 'tlp:amber', 'colour' => '#983965'], + ['id' => 'tlp:white', 'text' => 'tlp:white', 'colour' => 'white'], + ]; + $tagsHtml = $this->Tag->tags([ + 'allTags' => $allTags, + 'tags' => $entity['tag_list'], + 'picker' => true, + ]); +?> +
+
+
+ +
+
\ No newline at end of file diff --git a/templates/element/genericElements/SingleViews/Fields/tagsField.php b/templates/element/genericElements/SingleViews/Fields/tagsField.php index a8b9f88..564ee3e 100644 --- a/templates/element/genericElements/SingleViews/Fields/tagsField.php +++ b/templates/element/genericElements/SingleViews/Fields/tagsField.php @@ -1,16 +1,15 @@ 'tlp:red', 'text' => 'tlp:red', 'colour' => 'red'], ['id' => 'tlp:green', 'text' => 'tlp:green', 'colour' => 'green'], ['id' => 'tlp:amber', 'text' => 'tlp:amber', 'colour' => '#983965'], ['id' => 'tlp:white', 'text' => 'tlp:white', 'colour' => 'white'], ]; -$this->loadHelper('Tag'); echo $this->Tag->tags([ 'allTags' => $allTags, 'tags' => $tagList, 'picker' => true, -]); \ No newline at end of file + 'editable' => true, +]); diff --git a/templates/genericTemplates/tag.php b/templates/genericTemplates/tag.php new file mode 100644 index 0000000..4f97df8 --- /dev/null +++ b/templates/genericTemplates/tag.php @@ -0,0 +1,13 @@ + 'tlp:red', 'text' => 'tlp:red', 'colour' => 'red'], + ['id' => 'tlp:green', 'text' => 'tlp:green', 'colour' => 'green'], + ['id' => 'tlp:amber', 'text' => 'tlp:amber', 'colour' => '#983965'], + ['id' => 'tlp:white', 'text' => 'tlp:white', 'colour' => 'white'], + ]; + echo $this->Tag->tags([ + 'allTags' => $allTags, + 'tags' => $entity->tag_list, + '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/webroot/js/bootstrap-helper.js b/webroot/js/bootstrap-helper.js index 2fc230a..48be80c 100644 --- a/webroot/js/bootstrap-helper.js +++ b/webroot/js/bootstrap-helper.js @@ -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 = { @@ -860,7 +861,8 @@ class OverlayFactory { spinnerVariant: '', spinnerSmall: false, spinnerType: 'border', - fallbackBoostrapVariant: '' + fallbackBoostrapVariant: '', + wrapperCSSDisplay: '', } static overlayWrapper = '
' @@ -875,6 +877,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 +950,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(' ') diff --git a/webroot/js/main.js b/webroot/js/main.js index 9ff0797..7c5cd73 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -127,18 +127,26 @@ function createTagPicker(clicked) { return HtmlHelper.tag(state) } + function closePicker($select, $container) { + $select.appendTo($container) + $container.parent().find('.picker-container').remove() + } + const $clicked = $(clicked) const $container = $clicked.closest('.tag-container') - $('.picker-container').remove() + const $select = $container.parent().find('select.tag-input').removeClass('d-none').addClass('flex-grow-1') + closePicker($select, $container) const $pickerContainer = $('
').addClass(['picker-container', 'd-flex']) - const $select = $container.find('select.tag-input').removeClass('d-none').addClass('flex-grow-1') - const $saveButton = $('').addClass(['btn btn-primary btn-sm', 'align-self-start']) + const $saveButton = $('').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button') .append($('').text('Save').prepend($('').addClass('fa fa-save mr-1'))) - const $cancelButton = $('').addClass(['btn btn-secondary btn-sm', 'align-self-start']) + .click(function() { + const tags = $select.select2('data').map(tag => tag.text) + addTags(tags, $(this)) + }) + const $cancelButton = $('').addClass(['btn btn-secondary btn-sm', 'align-self-start']).attr('type', 'button') .append($('').text('Cancel').prepend($('').addClass('fa fa-times mr-1'))) .click(function() { - $select.appendTo($container) - $pickerContainer.remove() + closePicker($select, $container) }) const $buttons = $('').addClass(['picker-action', 'btn-group']).append($saveButton, $cancelButton) $select.prependTo($pickerContainer) @@ -152,6 +160,56 @@ function createTagPicker(clicked) { }) } +function deleteTag(url, tag, clicked) { + const data = { + tag_list: tag + } + const $statusNode = $(clicked).closest('.tag') + const APIOptions = { + statusNode: $statusNode, + skipFeedback: true, + } + return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((result) => { + let $container = $statusNode.closest('.tag-container-wrapper') + refreshTagList(result, $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: 'Tag deleted', + bodyHtml: $('
').append( + $('').text('Cancel untag operation.'), + $('').addClass(['btn btn-secondary btn-sm', 'align-self-start']).attr('type', 'button') .append($('').text('Cancel').prepend($('').addClass('fa fa-times mr-1'))) @@ -176,11 +176,15 @@ function deleteTag(url, tag, clicked) { }) const theToast = UI.toast({ variant: 'success', - title: 'Tag deleted', + title: result.message, bodyHtml: $('
').append( $('').text('Cancel untag operation.'), $('').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button') + function getEditableButtons($select, $container) { + const $saveButton = $('').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button') .append($('').text('Save').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').prepend($('').addClass('fa fa-times mr-1'))) - .click(function() { - closePicker($select, $container) - }) - const $buttons = $('').addClass(['picker-action', 'btn-group']).append($saveButton, $cancelButton) + const $cancelButton = $('').addClass(['btn btn-secondary btn-sm', 'align-self-start']).attr('type', 'button') + .append($('').text('Cancel').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')//.addClass('flex-grow-1') + closePicker($select, $container) + const $pickerContainer = $('
').addClass(['picker-container', 'd-flex']) + $select.prependTo($pickerContainer) - $pickerContainer.append($buttons) + $pickerContainer.append(getEditableButtons($select, $container)) $container.parent().append($pickerContainer) - $select.select2({ - placeholder: 'Pick a tag', - tags: true, - templateResult: templateTag, - templateSelection: templateTag, - }) + initSelect2Picker($select) } function deleteTag(url, tag, clicked) { @@ -213,6 +203,35 @@ function refreshTagList(result, $container) { 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) { + if (!state.id) { + return state.label; + } + if (state.colour === undefined) { + state.colour = $(state.element).data('colour') + } + return HtmlHelper.tag(state) + } + + $select.select2({ + placeholder: 'Pick a tag', + tags: true, + width: '100%', + templateResult: templateTag, + templateSelection: templateTag, + }) +} + var UI $(document).ready(() => { From e8358104062dd2b51e8db9ee100a8329d6b2fb9a Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 31 Aug 2021 11:19:15 +0200 Subject: [PATCH 05/16] fix: [genericTemplates:filters] Custom tags (such as negated and like) are correctly parsed and added to the picker --- templates/genericTemplates/filters.php | 5 ++++- webroot/js/main.js | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/templates/genericTemplates/filters.php b/templates/genericTemplates/filters.php index 6713b7b..3be1b09 100644 --- a/templates/genericTemplates/filters.php +++ b/templates/genericTemplates/filters.php @@ -104,7 +104,10 @@ echo $this->Bootstrap->modal([ $select = $filteringTable.closest('.modal-body').find('select.tag-input') let passedTags = [] tags.forEach(tagname => { - if (!$select.find("option[value='" + tagname + "']")) { + const existingOption = $select.find('option').filter(function() { + return $(this).val() === tagname + }) + if (existingOption.length == 0) { passedTags.push(new Option(tagname, tagname, true, true)) } }) diff --git a/webroot/js/main.js b/webroot/js/main.js index 20602a3..465969d 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -213,13 +213,24 @@ function initSelect2Pickers() { function initSelect2Picker($select) { - function templateTag(state) { + 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 HtmlHelper.tag(state) } @@ -227,8 +238,8 @@ function initSelect2Picker($select) { placeholder: 'Pick a tag', tags: true, width: '100%', - templateResult: templateTag, - templateSelection: templateTag, + templateResult: (state) => templateTag(state), + templateSelection: (state) => templateTag(state, $select), }) } From 61255e2837097e80221fb138d5197e4daff7823d Mon Sep 17 00:00:00 2001 From: mokaddem Date: Tue, 31 Aug 2021 15:21:28 +0200 Subject: [PATCH 06/16] chg: [tags] Improved UI and added missing files --- src/Controller/Component/CRUDComponent.php | 18 +- src/Controller/OrganisationsController.php | 27 +++ src/Model/Behavior/TagBehavior.php | 212 +++++++++++++++++++++ src/Model/Entity/Tag.php | 43 +++++ src/Model/Entity/Tagged.php | 14 ++ src/Model/Table/IndividualsTable.php | 6 +- src/Model/Table/OrganisationsTable.php | 1 + src/Model/Table/TaggedTable.php | 34 ++++ src/Model/Table/TagsTable.php | 28 +++ src/View/Helper/TagHelper.php | 3 +- templates/Organisations/view.php | 4 + webroot/js/main.js | 15 +- 12 files changed, 383 insertions(+), 22 deletions(-) create mode 100644 src/Model/Behavior/TagBehavior.php create mode 100644 src/Model/Entity/Tag.php create mode 100644 src/Model/Entity/Tagged.php create mode 100644 src/Model/Table/TaggedTable.php create mode 100644 src/Model/Table/TagsTable.php diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 97a0c37..51cdbfc 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -449,11 +449,10 @@ class CRUDComponent extends Component 'contain' => 'Tags', ]; $entity = $this->Table->get($id, $params); - // patching will mirror tag in the DB, however, we only want to add tags $input = $this->request->getData(); - $tagsToAdd = explode(',', $input['tag_list']); - $entity->tag_list = $entity->tag_list; - $input['tag_list'] = implode(',', array_merge($tagsToAdd, $entity->tag_list)); + $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'], ]; @@ -509,13 +508,12 @@ class CRUDComponent extends Component 'contain' => 'Tags', ]; $entity = $this->Table->get($id, $params); - // patching will mirror tag in the DB, however, we only want to remove tags $input = $this->request->getData(); - $tagsToRemove = explode(',', $input['tag_list']); - $entity->tag_list = $entity->tag_list; - $input['tag_list'] = implode(',', array_filter($entity->tag_list, function ($existingTag) use ($tagsToRemove) { - return !in_array($existingTag, $tagsToRemove); - })); + $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'], ]; 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/Behavior/TagBehavior.php b/src/Model/Behavior/TagBehavior.php new file mode 100644 index 0000000..63dd57d --- /dev/null +++ b/src/Model/Behavior/TagBehavior.php @@ -0,0 +1,212 @@ + [ + 'className' => 'Tags', + // 'joinTable' => 'tagged', // uncomment me! + 'joinTable' => 'tags_tagged', // remove me! + 'foreignKey' => 'fk_id', + 'targetForeignKey' => 'tag_id', + 'propertyName' => 'tags', + ], + 'tagsCounter' => ['counter'], + 'taggedAssoc' => [ + 'className' => '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(); + } +} \ No newline at end of file diff --git a/src/Model/Entity/Tag.php b/src/Model/Entity/Tag.php new file mode 100644 index 0000000..19802f0 --- /dev/null +++ b/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/src/Model/Entity/Tagged.php b/src/Model/Entity/Tagged.php new file mode 100644 index 0000000..eac3b44 --- /dev/null +++ b/src/Model/Entity/Tagged.php @@ -0,0 +1,14 @@ + false, + '*' => true, + ]; + +} diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index 5693321..3a621b3 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -14,11 +14,7 @@ class IndividualsTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); - $this->addBehavior('Tags.Tag', [ - 'taggedCounter' => false, - 'strategy' => 'array', - 'finderField' => 'label', - ]); + $this->addBehavior('Tag'); $this->hasMany( 'Alignments', [ diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index 8587864..a43b7e9 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('Tag'); $this->hasMany( 'Alignments', [ diff --git a/src/Model/Table/TaggedTable.php b/src/Model/Table/TaggedTable.php new file mode 100644 index 0000000..289cbbc --- /dev/null +++ b/src/Model/Table/TaggedTable.php @@ -0,0 +1,34 @@ + false + ]; + + public function initialize(array $config): void + { + // $this->setTable('tagged'); + $this->setTable('tags_tagged'); + $this->belongsTo('Tags', [ + 'className' => '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/src/Model/Table/TagsTable.php b/src/Model/Table/TagsTable.php new file mode 100644 index 0000000..6a885dc --- /dev/null +++ b/src/Model/Table/TagsTable.php @@ -0,0 +1,28 @@ + false + ]; + + public function initialize(array $config): void + { + // $this->setTable('tags'); + $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/src/View/Helper/TagHelper.php b/src/View/Helper/TagHelper.php index f601650..c027e98 100644 --- a/src/View/Helper/TagHelper.php +++ b/src/View/Helper/TagHelper.php @@ -64,8 +64,9 @@ class TagHelper extends Helper 'onclick' => 'createTagPicker(this)', ] ]); + } else { + $html .= ''; } - $html .= ''; return $html; } diff --git a/templates/Organisations/view.php b/templates/Organisations/view.php index 323571e..0652a8c 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/webroot/js/main.js b/webroot/js/main.js index 465969d..eca62cc 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -124,13 +124,13 @@ function createTagPicker(clicked) { function getEditableButtons($select, $container) { const $saveButton = $('').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button') - .append($('').text('Save').prepend($('').addClass('fa fa-save mr-1'))) + .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').prepend($('').addClass('fa fa-times mr-1'))) + .append($('').text('Cancel').addClass('text-nowrap').prepend($('').addClass('fa fa-times mr-1'))) .click(function() { closePicker($select, $container) }) @@ -150,9 +150,12 @@ function createTagPicker(clicked) { initSelect2Picker($select) } -function deleteTag(url, tag, clicked) { +function deleteTag(url, tags, clicked) { + if (!Array.isArray(tags)) { + tags = [tags]; + } const data = { - tag_list: tag + tag_list: JSON.stringify(tags) } const $statusNode = $(clicked).closest('.tag') const APIOptions = { @@ -174,7 +177,7 @@ function deleteTag(url, tag, clicked) { const controllerName = split[1] const id = split[3] const urlRetag = `/${controllerName}/tag/${id}` - addTags(urlRetag, [tag], $container.find('.tag-container')).then(() => { + addTags(urlRetag, tags, $container.find('.tag-container')).then(() => { theToast.removeToast() }) }), @@ -185,7 +188,7 @@ function deleteTag(url, tag, clicked) { function addTags(url, tags, $statusNode) { const data = { - tag_list: tags + tag_list: JSON.stringify(tags) } const APIOptions = { statusNode: $statusNode From eed5b9226a6ae88788aeb941376ce0320dc4990b Mon Sep 17 00:00:00 2001 From: mokaddem Date: Wed, 1 Sep 2021 16:12:56 +0200 Subject: [PATCH 07/16] chg: [behavior:tags] Custom finder and small improvements --- src/Controller/Component/CRUDComponent.php | 2 +- src/Model/Behavior/TagBehavior.php | 149 ++++++++++++++++++++- templates/genericTemplates/filters.php | 2 +- webroot/js/bootstrap-helper.js | 2 +- webroot/js/main.js | 16 +-- 5 files changed, 158 insertions(+), 13 deletions(-) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 51cdbfc..75f02ba 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -745,7 +745,7 @@ class CRUDComponent extends Component 'label' => $tags, 'forceAnd' => true ])->select($modelAlias . '.id'); - return $query = $query->where([$modelAlias . '.id IN' => $subQuery]); + return $query->where([$modelAlias . '.id IN' => $subQuery]); } protected function setNestedRelatedCondition($query, $filterParts, $filterValue) diff --git a/src/Model/Behavior/TagBehavior.php b/src/Model/Behavior/TagBehavior.php index 63dd57d..437e74c 100644 --- a/src/Model/Behavior/TagBehavior.php +++ b/src/Model/Behavior/TagBehavior.php @@ -11,6 +11,7 @@ class TagBehavior extends Behavior { protected $_defaultConfig = [ + 'finderField' => 'label', 'tagsAssoc' => [ 'className' => 'Tags', // 'joinTable' => 'tagged', // uncomment me! @@ -148,9 +149,7 @@ class TagBehavior extends Behavior public function normalizeTags($tags) { $result = []; - $modelAlias = $this->_table->getAlias(); - $common = [ '_joinData' => [ 'fk_model' => $modelAlias @@ -209,4 +208,150 @@ class TagBehavior extends Behavior ->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/templates/genericTemplates/filters.php b/templates/genericTemplates/filters.php index 3be1b09..9a7c49a 100644 --- a/templates/genericTemplates/filters.php +++ b/templates/genericTemplates/filters.php @@ -87,7 +87,7 @@ 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)) { diff --git a/webroot/js/bootstrap-helper.js b/webroot/js/bootstrap-helper.js index cc740d3..39cc6ce 100644 --- a/webroot/js/bootstrap-helper.js +++ b/webroot/js/bootstrap-helper.js @@ -1125,7 +1125,7 @@ class HtmlHelper { options.colour = '#924da6' } const $tag = $('') - .addClass(['tag', 'badge', 'border']) + .addClass(['tag', 'badge', 'align-text-top']) .css({color: getTextColour(options.colour), 'background-color': options.colour}) .text(options.text) diff --git a/webroot/js/main.js b/webroot/js/main.js index eca62cc..3acc35f 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -162,14 +162,14 @@ function deleteTag(url, tags, clicked) { statusNode: $statusNode, skipFeedback: true, } - return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((result) => { + return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((apiResult) => { let $container = $statusNode.closest('.tag-container-wrapper') - refreshTagList(result, $container).then(($tagContainer) => { + 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: result.message, + title: apiResult.message, bodyHtml: $('
').append( $('').text('Cancel untag operation.'), $('').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', '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')//.addClass('flex-grow-1') - 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.'), - $('') + var $closeButton = $('') + .click(function() { + $(this).closest('.toast').data('toastObject').removeToast() + }) $toastHeader.append($closeButton) } $toast.append($toastHeader) From 8df647cdb97f3bd0f491b1b5742023cce10833f8 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 3 Sep 2021 09:47:13 +0200 Subject: [PATCH 12/16] chg: [migration] Added support of plugin migrations --- src/Model/Table/InstanceTable.php | 12 ++++++++++++ templates/Instance/migration_index.php | 4 ++++ 2 files changed, 16 insertions(+) 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/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([], [ From ea3168b84087b02c77928ba25764936853841a3f Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 3 Sep 2021 09:47:36 +0200 Subject: [PATCH 13/16] new: [genericElements:singleView] Added new string field to extract without type deduction --- .../element/genericElements/SingleViews/Fields/stringField.php | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 templates/element/genericElements/SingleViews/Fields/stringField.php 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 @@ + Date: Fri, 3 Sep 2021 09:48:49 +0200 Subject: [PATCH 14/16] chg: [tags] Moved templates into the plugin folder --- {templates => plugins/Tags/templates}/Tags/add.php | 0 {templates => plugins/Tags/templates}/Tags/index.php | 0 {templates => plugins/Tags/templates}/Tags/view.php | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename {templates => plugins/Tags/templates}/Tags/add.php (100%) rename {templates => plugins/Tags/templates}/Tags/index.php (100%) rename {templates => plugins/Tags/templates}/Tags/view.php (95%) diff --git a/templates/Tags/add.php b/plugins/Tags/templates/Tags/add.php similarity index 100% rename from templates/Tags/add.php rename to plugins/Tags/templates/Tags/add.php diff --git a/templates/Tags/index.php b/plugins/Tags/templates/Tags/index.php similarity index 100% rename from templates/Tags/index.php rename to plugins/Tags/templates/Tags/index.php diff --git a/templates/Tags/view.php b/plugins/Tags/templates/Tags/view.php similarity index 95% rename from templates/Tags/view.php rename to plugins/Tags/templates/Tags/view.php index 00ce41a..0886fc9 100644 --- a/templates/Tags/view.php +++ b/plugins/Tags/templates/Tags/view.php @@ -16,7 +16,7 @@ echo $this->element( [ 'key' => __('Counter'), 'path' => 'counter', - 'type' => 'json', + 'type' => 'string', ], [ 'key' => __('Colour'), From 99c857d5868a6237408d7edba0da7e697f1738e7 Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 3 Sep 2021 09:49:20 +0200 Subject: [PATCH 15/16] chg: [aclcomponent] Added ACL entry --- src/Controller/AppController.php | 4 ++++ src/Controller/Component/ACLComponent.php | 24 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index ee1c1b1..913a633 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -127,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' => [ From 6a682905616d001c08c0fcb34c64a48856836fdb Mon Sep 17 00:00:00 2001 From: mokaddem Date: Fri, 3 Sep 2021 09:59:49 +0200 Subject: [PATCH 16/16] chg: [plugin:tags] Added migration script --- .../Migrations/20210831121348_TagSystem.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 plugins/Tags/config/Migrations/20210831121348_TagSystem.php 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