chg: [tag] Continuation of integrating tagging plugin - WiP

- Tagging / Untagging
pull/72/head
mokaddem 2021-08-26 12:06:12 +02:00
parent b1e5bbad1a
commit 8b659fb6af
10 changed files with 382 additions and 45 deletions

View File

@ -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)) {

View File

@ -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;
}
}
}

View File

@ -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 = '<div class="tag-container my-1">';
$html = '<div class="tag-container-wrapper">';
$html .= '<div class="tag-container my-1">';
$html .= '<div class="tag-list d-inline-block">';
foreach ($tags as $tag) {
if (is_array($tag)) {
@ -64,25 +73,44 @@ class TagHelper extends Helper
}
$html .= '</div>';
if (!empty($options['picker'])) {
if (!empty($this->getConfig('picker'))) {
$html .= $this->picker($options);
}
$html .= '</div>';
$html .= '</div>';
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',

View File

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

View File

@ -0,0 +1,19 @@
<?php
$allTags = [
['id' => '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,
]);
?>
<div class="form-group row">
<div class="col-sm-2 col-form-label"><?= __('Tags') ?></div>
<div class="col-sm-10">
<?= $tagsHtml ?>
</div>
</div>

View File

@ -1,16 +1,15 @@
<?php
// $tags = Cake\Utility\Hash::extract($data, $field['path']);
$tagList = Cake\Utility\Hash::get($data, 'tag_list');
$tagList = ['tlp:red'];
$allTags = [
['id' => '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,
]);
'editable' => true,
]);

View File

@ -0,0 +1,13 @@
<?php
$allTags = [
['id' => '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,
]);

View File

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

View File

@ -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 = '<div aria-busy="true" class="position-relative"/>'
@ -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(' ')

View File

@ -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 = $('<div></div>').addClass(['picker-container', 'd-flex'])
const $select = $container.find('select.tag-input').removeClass('d-none').addClass('flex-grow-1')
const $saveButton = $('<button></button>').addClass(['btn btn-primary btn-sm', 'align-self-start'])
const $saveButton = $('<button></button>').addClass(['btn btn-primary btn-sm', 'align-self-start']).attr('type', 'button')
.append($('<span></span>').text('Save').prepend($('<i></i>').addClass('fa fa-save mr-1')))
const $cancelButton = $('<button></button>').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 = $('<button></button>').addClass(['btn btn-secondary btn-sm', 'align-self-start']).attr('type', 'button')
.append($('<span></span>').text('Cancel').prepend($('<i></i>').addClass('fa fa-times mr-1')))
.click(function() {
$select.appendTo($container)
$pickerContainer.remove()
closePicker($select, $container)
})
const $buttons = $('<span></span>').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: $('<div/>').append(
$('<span/>').text('Cancel untag operation.'),
$('<button/>').addClass(['btn', 'btn-primary', 'btn-sm', 'ml-3']).text('Restore tag').click(function() {
addTags([tag], $container.find('.tag-container')).then(() => {
theToast.removeToast()
})
}),
),
})
}).catch((e) => {})
}
function addTags(tags, $statusNode) {
const url = '/individuals/tag/2'
const data = {
tag_list: tags
}
const APIOptions = {
statusNode: $statusNode
}
return AJAXApi.quickFetchAndPostForm(url, data, APIOptions).then((result) => {
const $container = $statusNode.closest('.tag-container-wrapper')
refreshTagList(result, $container)
}).catch((e) => {})
}
function refreshTagList(result, $container) {
const controllerName = result.url.split('/')[1]
const entityId = result.data.id
const url = `/${controllerName}/viewTags/${entityId}`
return UI.reload(url, $container)
}
var UI
$(document).ready(() => {