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.'), + $('') + var $closeButton = $('') + .click(function() { + $(this).closest('.toast').data('toastObject').removeToast() + }) $toastHeader.append($closeButton) } $toast.append($toastHeader) @@ -860,7 +878,8 @@ class OverlayFactory { spinnerVariant: '', spinnerSmall: false, spinnerType: 'border', - fallbackBoostrapVariant: '' + fallbackBoostrapVariant: '', + wrapperCSSDisplay: '', } static overlayWrapper = '
' @@ -875,6 +894,14 @@ class OverlayFactory { /** Create the HTML of the overlay */ buildOverlay() { this.$overlayWrapper = $(OverlayFactory.overlayWrapper) + if (this.options.wrapperCSSDisplay) { + this.$overlayWrapper.css('display', this.options.wrapperCSSDisplay) + } + if (this.$node[0]) { + const boundingRect = this.$node[0].getBoundingClientRect() + this.$overlayWrapper.css('min-height', boundingRect.height) + this.$overlayWrapper.css('min-width', boundingRect.width) + } this.$overlayContainer = $(OverlayFactory.overlayContainer) this.$overlayBg = $(OverlayFactory.overlayBg) .addClass([`bg-${this.options.variant}`, (this.options.rounded ? 'rounded' : '')]) @@ -940,7 +967,8 @@ class OverlayFactory { } if (this.$node.is('input[type="checkbox"]') || this.$node.css('border-radius') !== '0px') { this.options.rounded = true - } + } + this.options.wrapperCSSDisplay = this.$node.css('display') let classes = this.$node.attr('class') if (classes !== undefined) { classes = classes.split(' ') @@ -1108,4 +1136,5 @@ class HtmlHelper { } return $table } -} \ No newline at end of file + +} diff --git a/webroot/js/main.js b/webroot/js/main.js index d950781..9097365 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -99,6 +99,22 @@ function syntaxHighlightJson(json, indent) { }); } +function getTextColour(hex) { + if (hex === undefined || hex.length == 0) { + return 'black' + } + hex = hex.slice(1) + var r = parseInt(hex.substring(0,2), 16) + var g = parseInt(hex.substring(2,4), 16) + var b = parseInt(hex.substring(4,6), 16) + var avg = ((2 * r) + b + (3 * g))/6 + if (avg < 128) { + return 'white' + } else { + return 'black' + } +} + var UI $(document).ready(() => { if (typeof UIFactory !== "undefined") {