diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 0342a67..97a0c37 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -35,6 +35,9 @@ class CRUDComponent extends Component $options['filters'][] = 'quickFilter'; } $options['filters'][] = 'filteringLabel'; + if ($this->taggingSupported()) { + $options['filters'][] = 'filteringTags'; + } $optionFilters = empty($options['filters']) ? [] : $options['filters']; foreach ($optionFilters as $i => $filter) { @@ -50,6 +53,9 @@ class CRUDComponent extends Component if (!empty($options['contain'])) { $query->contain($options['contain']); } + if ($this->taggingSupported()) { + $query->contain('Tags'); + } if (!empty($options['fields'])) { $query->select($options['fields']); } @@ -73,15 +79,17 @@ class CRUDComponent extends Component $data = $this->Table->{$options['afterFind']}($data); } } - if (!empty($options['contextFilters'])) { - $this->setFilteringContext($options['contextFilters'], $params); - } + $this->setFilteringContext($options['contextFilters'] ?? [], $params); $this->Controller->set('data', $data); } } public function filtering(): void { + if ($this->taggingSupported()) { + $this->Controller->set('taggingEnabled', true); + $this->setAllTags(); + } $filters = !empty($this->Controller->filters) ? $this->Controller->filters : []; $this->Controller->set('filters', $filters); $this->Controller->viewBuilder()->setLayout('ajax'); @@ -246,8 +254,9 @@ class CRUDComponent extends Component $this->getMetaTemplates(); if ($this->taggingSupported()) { $params['contain'][] = 'Tags'; + $this->setAllTags(); } - $data = $this->Table->get($id, isset($params['get']) ? $params['get'] : []); + $data = $this->Table->get($id, isset($params['get']) ? $params['get'] : $params); $data = $this->getMetaFields($id, $data); if (!empty($params['fields'])) { $this->Controller->set('fields', $params['fields']); @@ -676,6 +685,8 @@ class CRUDComponent extends Component { $filteringLabel = !empty($params['filteringLabel']) ? $params['filteringLabel'] : ''; unset($params['filteringLabel']); + $filteringTags = !empty($params['filteringTags']) && $this->taggingSupported() ? $params['filteringTags'] : ''; + unset($params['filteringTags']); $customFilteringFunction = ''; $chosenFilter = ''; if (!empty($options['contextFilters']['custom'])) { @@ -719,10 +730,26 @@ class CRUDComponent extends Component } } } + + if ($this->taggingSupported() && !empty($filteringTags)) { + $activeFilters['filteringTags'] = $filteringTags; + $query = $this->setTagFilters($query, $filteringTags); + } + $this->Controller->set('activeFilters', $activeFilters); return $query; } + protected function setTagFilters($query, $tags) + { + $modelAlias = $this->Table->getAlias(); + $subQuery = $this->Table->find('tagged', [ + 'label' => $tags, + 'forceAnd' => true + ])->select($modelAlias . '.id'); + return $query = $query->where([$modelAlias . '.id IN' => $subQuery]); + } + protected function setNestedRelatedCondition($query, $filterParts, $filterValue) { $modelName = $filterParts[0]; diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index 046824f..71e4a0f 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -13,10 +13,12 @@ use Cake\ORM\TableRegistry; class IndividualsController extends AppController { + public $filters = ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type']; + public function index() { $this->CRUD->index([ - 'filters' => ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type'], + 'filters' => $this->filters, 'quickFilters' => ['uuid', 'email', 'first_name', 'last_name', 'position'], 'contextFilters' => [ 'fields' => [ @@ -33,6 +35,11 @@ class IndividualsController extends AppController $this->set('metaGroup', 'ContactDB'); } + public function filtering() + { + $this->CRUD->filtering(); + } + public function add() { $this->CRUD->add(); diff --git a/src/Controller/TagsController.php b/src/Controller/TagsController.php new file mode 100644 index 0000000..57481e0 --- /dev/null +++ b/src/Controller/TagsController.php @@ -0,0 +1,80 @@ +Table = TableRegistry::getTableLocator()->get('Tags.Tags'); + $this->CRUD->Table = $this->Table; + $this->CRUD->TableAlias = $this->CRUD->Table->getAlias(); + $this->CRUD->ObjectAlias = Inflector::singularize($this->CRUD->TableAlias); + } + + public function index() + { + $this->CRUD->index([ + 'filters' => ['label', 'colour'], + 'quickFilters' => [['label' => true], 'colour'] + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate'); + } + + public function add() + { + $this->CRUD->add(); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate'); + } + + public function view($id) + { + $this->CRUD->view($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate'); + } + + public function edit($id) + { + $this->CRUD->edit($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate'); + $this->render('add'); + } + + public function delete($id) + { + $this->CRUD->delete($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate'); + } +} diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index 81d4c65..5693321 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -17,6 +17,7 @@ class IndividualsTable extends AppTable $this->addBehavior('Tags.Tag', [ 'taggedCounter' => false, 'strategy' => 'array', + 'finderField' => 'label', ]); $this->hasMany( 'Alignments', diff --git a/src/View/Helper/TagHelper.php b/src/View/Helper/TagHelper.php index 9b0449b..f601650 100644 --- a/src/View/Helper/TagHelper.php +++ b/src/View/Helper/TagHelper.php @@ -33,14 +33,20 @@ class TagHelper extends Helper 'data-text-colour' => h($tag['text_colour']), ]; }, $options['allTags']) : []; - $selectConfig = [ - 'multiple' => true, - 'class' => ['tag-input', 'd-none'], - 'data-url' => $this->Url->build([ + $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); } @@ -48,24 +54,26 @@ class TagHelper extends Helper protected function picker(array $options = []) { $html = $this->Tag->control($options); - $html .= $this->Bootstrap->button([ - 'size' => 'sm', - 'icon' => 'plus', - 'variant' => 'secondary', - 'class' => ['badge'], - 'params' => [ - 'onclick' => 'createTagPicker(this)', - ] - ]); + if (!empty($this->getConfig('editable'))) { + $html .= $this->Bootstrap->button([ + 'size' => 'sm', + 'icon' => 'plus', + 'variant' => 'secondary', + 'class' => ['badge'], + 'params' => [ + 'onclick' => 'createTagPicker(this)', + ] + ]); + } + $html .= ''; return $html; } - public function tags(array $options = []) + public function tags(array $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_object($tag)) { diff --git a/templates/Individuals/index.php b/templates/Individuals/index.php index 91520e1..4930899 100644 --- a/templates/Individuals/index.php +++ b/templates/Individuals/index.php @@ -23,7 +23,8 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'button' => __('Filter'), 'placeholder' => __('Enter value to search'), 'data' => '', - 'searchKey' => 'value' + 'searchKey' => 'value', + 'allowFilering' => true ] ] ], @@ -54,6 +55,11 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'element' => 'alignments', 'scope' => $alignmentScope ], + [ + 'name' => __('Tags'), + 'data_path' => 'tags', + 'element' => 'tags', + ], [ 'name' => __('UUID'), 'sort' => 'uuid', diff --git a/templates/Tags/add.php b/templates/Tags/add.php new file mode 100644 index 0000000..412dc1c --- /dev/null +++ b/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/templates/Tags/index.php b/templates/Tags/index.php new file mode 100644 index 0000000..a826fb1 --- /dev/null +++ b/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/templates/Tags/view.php b/templates/Tags/view.php new file mode 100644 index 0000000..00ce41a --- /dev/null +++ b/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' => 'json', + ], + [ + 'key' => __('Colour'), + 'path' => 'colour', + ], + [ + 'key' => __('Created'), + 'path' => 'created', + ], + ], + 'children' => [] + ] +); diff --git a/templates/element/genericElements/Form/Fields/tagsField.php b/templates/element/genericElements/Form/Fields/tagsField.php index ba35700..4d67db8 100644 --- a/templates/element/genericElements/Form/Fields/tagsField.php +++ b/templates/element/genericElements/Form/Fields/tagsField.php @@ -1,8 +1,8 @@ Tag->tags([ - 'allTags' => $allTags, - 'tags' => $entity['tags'], + $tagsHtml = $this->Tag->tags($entity['tags'], [ + 'allTags' => [], 'picker' => true, + 'editable' => true, ]); ?>
diff --git a/templates/element/genericElements/IndexTable/Fields/tag.php b/templates/element/genericElements/IndexTable/Fields/tag.php new file mode 100644 index 0000000..fbb270a --- /dev/null +++ b/templates/element/genericElements/IndexTable/Fields/tag.php @@ -0,0 +1,6 @@ +Tag->tag($tag, [ + ]); + +?> diff --git a/templates/element/genericElements/IndexTable/Fields/tags.php b/templates/element/genericElements/IndexTable/Fields/tags.php index b3dd0d8..a5eb311 100644 --- a/templates/element/genericElements/IndexTable/Fields/tags.php +++ b/templates/element/genericElements/IndexTable/Fields/tags.php @@ -1,17 +1,5 @@ Hash->extract($row, $field['data_path']); - if (!empty($tags)) { - if (empty($tags[0])) { - $tags = array($tags); - } - echo $this->element( - 'ajaxTags', - array( - 'attributeId' => 0, - 'tags' => $tags, - 'tagAccess' => false, - 'static_tags_only' => 1 - ) - ); - } -?> + echo $this->Tag->tags($tags, [ + 'tags' + ]); diff --git a/templates/element/genericElements/SingleViews/Fields/tagField.php b/templates/element/genericElements/SingleViews/Fields/tagField.php new file mode 100644 index 0000000..523c9a6 --- /dev/null +++ b/templates/element/genericElements/SingleViews/Fields/tagField.php @@ -0,0 +1,3 @@ +Tag->tag($data, [ +]); diff --git a/templates/element/genericElements/SingleViews/Fields/tagsField.php b/templates/element/genericElements/SingleViews/Fields/tagsField.php index 16f7a71..414da50 100644 --- a/templates/element/genericElements/SingleViews/Fields/tagsField.php +++ b/templates/element/genericElements/SingleViews/Fields/tagsField.php @@ -1,9 +1,8 @@ Tag->tags([ +echo $this->Tag->tags($tags, [ 'allTags' => $allTags, - 'tags' => $tags, 'picker' => true, 'editable' => true, ]); diff --git a/templates/genericTemplates/filters.php b/templates/genericTemplates/filters.php index b3b0c91..6713b7b 100644 --- a/templates/genericTemplates/filters.php +++ b/templates/genericTemplates/filters.php @@ -15,7 +15,7 @@ $filteringForm = $this->Bootstrap->table( [ 'labelHtml' => sprintf('%s %s', __('Value'), - sprintf('', __('Supports strict match and LIKE match with the `%` character. Example: `%.com`')) + sprintf('', __('Supports strict matches and LIKE matches with the `%` character. Example: `%.com`')) ) ], __('Action') @@ -23,12 +23,28 @@ $filteringForm = $this->Bootstrap->table( 'items' => [] ]); +if ($taggingEnabled) { + $helpText = $this->Bootstrap->genNode('sup', [ + 'class' => ['ml-1 fa fa-info'], + 'title' => __('Supports negation matches (with the `!` character) and LIKE matches (with the `%` character). Example: `!exportable`, `%able`'), + ]); + $filteringTags = $this->Bootstrap->genNode('h5', [], __('Tags') . $helpText); + $filteringTags .= $this->Tag->tags([], [ + 'allTags' => $allTags, + 'picker' => true, + 'editable' => false, + ]); +} else { + $filteringTags = ''; +} +$modalBody = sprintf('%s%s', $filteringForm, $filteringTags); + echo $this->Bootstrap->modal([ 'title' => __('Filtering options for {0}', Inflector::singularize($this->request->getParam('controller'))), 'size' => 'lg', 'type' => 'confirm', - 'bodyHtml' => $filteringForm, + 'bodyHtml' => $modalBody, 'confirmText' => __('Filter'), 'confirmFunction' => 'filterIndex' ]); @@ -54,7 +70,9 @@ echo $this->Bootstrap->modal([ } activeFilters[fullFilter] = rowData['value'] }) - const searchParam = (new URLSearchParams(activeFilters)).toString(); + $select = modalObject.$modal.find('select.tag-input') + activeFilters['filteringTags'] = $select.select2('data').map(tag => tag.text) + const searchParam = jQuery.param(activeFilters); const url = `/${controller}/${action}?${searchParam}` const randomValue = getRandomValue() @@ -70,6 +88,8 @@ echo $this->Bootstrap->modal([ addControlRow($filteringTable) const randomValue = getRandomValue() const activeFilters = $(`#toggleFilterButton-${randomValue}`).data('activeFilters') + const tags = activeFilters['filteringTags'] !== undefined ? Object.assign({}, activeFilters)['filteringTags'] : [] + delete activeFilters['filteringTags'] for (let [field, value] of Object.entries(activeFilters)) { const fieldParts = field.split(' ') let operator = '=' @@ -81,6 +101,17 @@ echo $this->Bootstrap->modal([ } addFilteringRow($filteringTable, field, value, operator) } + $select = $filteringTable.closest('.modal-body').find('select.tag-input') + let passedTags = [] + tags.forEach(tagname => { + if (!$select.find("option[value='" + tagname + "']")) { + passedTags.push(new Option(tagname, tagname, true, true)) + } + }) + $select + .append(passedTags) + .val(tags) + .trigger('change') } function addControlRow($filteringTable) { diff --git a/templates/genericTemplates/tag.php b/templates/genericTemplates/tag.php index c1bc69b..41e84d3 100644 --- a/templates/genericTemplates/tag.php +++ b/templates/genericTemplates/tag.php @@ -1,7 +1,6 @@ Tag->tags([ + echo $this->Tag->tags($entity->tags, [ 'allTags' => $allTags, - 'tags' => $entity->tags, 'picker' => true, 'editable' => true, ]); diff --git a/webroot/css/main.css b/webroot/css/main.css index de19e15..f1f2d47 100644 --- a/webroot/css/main.css +++ b/webroot/css/main.css @@ -163,4 +163,8 @@ input[type="checkbox"]:disabled.change-cursor { .picker-container .picker-action .btn:first-child { border-top-left-radius: 0 !important; border-bottom-left-radius: 0 !important; +} + +.tag { + filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.5)); } \ No newline at end of file diff --git a/webroot/js/main.js b/webroot/js/main.js index 57e7560..20602a3 100644 --- a/webroot/js/main.js +++ b/webroot/js/main.js @@ -117,47 +117,37 @@ function getTextColour(hex) { function createTagPicker(clicked) { - function templateTag(state) { - if (!state.id) { - return state.label; - } - if (state.colour === undefined) { - state.colour = $(state.element).data('colour') - } - 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') - 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 $saveButton = $('').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(() => {