improved tag auto-completion and added tag management (#1083)

This commit is contained in:
Kevin Papst
2019-09-04 23:47:33 +02:00
committed by GitHub
parent 3ea05cb705
commit d041a3f4f9
25 changed files with 368 additions and 17 deletions

View File

@@ -13,6 +13,7 @@ Perform EACH version specific task between your version and the new one, otherwi
### Possible BC breaks
- Refactored toolbars and search, plugins needs to be checked
- Added `manage_tag` permission for new tag features
- Invoices now supports multiple repositories, some method signatures had to be changed (eg. `calculateSumIdentifier()`)
## [1.2](https://github.com/kevinpapst/kimai2/releases/tag/1.2)

View File

@@ -20,6 +20,7 @@ export default class KimaiAutocomplete extends KimaiPlugin {
}
init() {
this.minChars = this.getContainer().getConfiguration().get('autoComplete');
this.activateAutocomplete(this.selector);
}
@@ -59,7 +60,7 @@ export default class KimaiAutocomplete extends KimaiPlugin {
search: function () {
// custom minLength
var term = self.extractLastTag(this.value);
if (term.length < 2) {
if (term.length < self.minChars) {
return false;
}
},

View File

@@ -95,7 +95,7 @@ kimai:
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile']
TAGS: ['view_tag','delete_tag']
TAGS: ['view_tag','manage_tag','delete_tag']
USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']

View File

@@ -14,6 +14,8 @@ nelmio_api_doc:
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] }
- { alias: ActivityMetaField, type: App\Entity\ActivityMeta, groups: [Default, Activity] }
- { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] }
- { alias: TagEditForm, type: App\Form\API\TagApiEditForm, groups: [Default, Entity, Tag] }
- { alias: TagEntity, type: App\Entity\Tag, groups: [Default, Entity, Tag] }
- { alias: TimesheetEditForm, type: App\Form\API\TimesheetApiEditForm, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/1.c1bee41f.js",
"build/app.75bdc057.js"
"build/app.121b7493.js"
],
"css": [
"build/app.eb4ed947.css"
@@ -35,7 +35,7 @@
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.75bdc057.js": "sha384-a93v/mUzZDopA6To/YJEjOAxgJ6T4H6gmXnGf0ja6Bq4M7ZVDQqqKMX7/9hIhRPM",
"build/app.121b7493.js": "sha384-avvNYZprPc37DZ2jHh1hLY+0KdsyHsgQttMZLm4ZQ3/OiwWqUFo9xrqwR6Yn1H7m",
"build/app.eb4ed947.css": "sha384-TQ5nEns/+JBxjuHlqGXkiS1qt9Zdzb7tREhc4DPunQkTib/iuv8MfwELt8OPhF10",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",

View File

@@ -3,7 +3,7 @@
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.eb4ed947.css",
"build/app.js": "build/app.75bdc057.js",
"build/app.js": "build/app.121b7493.js",
"build/calendar.css": "build/calendar.b0551848.css",
"build/calendar.js": "build/calendar.5839778f.js",
"build/chart.js": "build/chart.0af3f813.js",

View File

@@ -11,6 +11,9 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Tag;
use App\Form\API\TagApiEditForm;
use App\Form\TagEditForm;
use App\Repository\TagRepository;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
@@ -19,7 +22,9 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @RouteResource("Tag")
@@ -58,7 +63,7 @@ class TagController extends BaseApiController
* )
* )
*
* @Rest\QueryParam(name="name", requirements="[a-zA-Z0-9 -\.]+", strict=true, nullable=true, description="Search term to filter tag list")
* @Rest\QueryParam(name="name", strict=true, nullable=true, description="Search term to filter tag list")
*
* @return Response
*/
@@ -69,7 +74,58 @@ class TagController extends BaseApiController
$data = $this->repository->findAllTagNames($filter);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection']);
$view->getContext()->setGroups(['Default', 'Collection', 'Tag']);
return $this->viewHandler->handle($view);
}
/**
* Creates a new tag
*
* @SWG\Post(
* description="Creates a new tag and returns it afterwards",
* @SWG\Response(
* response=200,
* description="Returns the new created tag",
* @SWG\Schema(ref="#/definitions/TagEntity"),
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/TagEditForm")
* )
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function postAction(Request $request)
{
if (!$this->isGranted('manage_tag')) {
throw new AccessDeniedHttpException('User cannot create tags');
}
$tag = new Tag();
$form = $this->createForm(TagApiEditForm::class, $tag);
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveTag($tag);
$view = new View($tag, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Tag']);
return $this->viewHandler->handle($view);
}
$view = new View($form);
$view->getContext()->setGroups(['Default', 'Entity', 'Tag']);
return $this->viewHandler->handle($view);
}

View File

@@ -266,6 +266,11 @@ class SystemConfigurationController extends AbstractController
->setLabel('theme.markdown_content')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('theme.autocomplete_chars')
->setLabel('theme.autocomplete_chars')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration'),
// FIXME should that be configurable per user?
/*
(new Configuration())

View File

@@ -9,9 +9,12 @@
namespace App\Controller;
use App\Entity\Tag;
use App\Form\TagEditForm;
use App\Form\Toolbar\TagToolbarForm;
use App\Repository\Query\TagQuery;
use App\Repository\TagRepository;
use Doctrine\ORM\ORMException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -51,6 +54,68 @@ class TagController extends AbstractController
]);
}
/**
* @Route(path="/{id}/edit", name="tags_edit", methods={"GET", "POST"})
* @Security("is_granted('manage_tag')")
*/
public function editAction(Tag $tag, TagRepository $repository, Request $request)
{
$editForm = $this->createForm(TagEditForm::class, $tag, [
'action' => $this->generateUrl('tags_edit', ['id' => $tag->getId()]),
'method' => 'POST',
]);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$repository->saveTag($tag);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('tags');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('tags/edit.html.twig', [
'tag' => $tag,
'form' => $editForm->createView()
]);
}
/**
* @Route(path="/create", name="tags_create", methods={"GET", "POST"})
* @Security("is_granted('manage_tag')")
*/
public function createAction(TagRepository $repository, Request $request)
{
$tag = new Tag();
$editForm = $this->createForm(TagEditForm::class, $tag, [
'action' => $this->generateUrl('tags_create'),
'method' => 'POST',
]);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$repository->saveTag($tag);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('tags');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('tags/edit.html.twig', [
'tag' => $tag,
'form' => $editForm->createView()
]);
}
/**
* @param TagQuery $query
* @return \Symfony\Component\Form\FormInterface

View File

@@ -346,6 +346,9 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->integerNode('autocomplete_chars')
->defaultValue(3)
->end()
->end()
;

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\API;
use App\Form\TagEditForm;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TagApiEditForm extends TagEditForm
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'csrf_protection' => false,
]);
}
}

50
src/Form/TagEditForm.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Tag;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TagEditForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'label' => 'label.name',
'attr' => [
'autofocus' => 'autofocus'
],
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Tag::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'tags_edit',
'attr' => [
'data-form-event' => 'kimai.tagUpdate'
],
]);
}
}

View File

@@ -18,6 +18,18 @@ use Pagerfanta\Pagerfanta;
class TagRepository extends EntityRepository
{
/**
* @param Tag $tag
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveTag(Tag $tag)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($tag);
$entityManager->flush();
}
/**
* @param Tag $tag
* @throws ORMException

View File

@@ -203,7 +203,8 @@
{
locale: '{{ app.request.locale }}',
twentyFourHours: {{ 'true'|hour24('false') }},
autoReloadDatatable: {% if theme_config('auto_reload_datatable') %}true{% else %}false{% endif %}
autoReloadDatatable: {% if theme_config('auto_reload_datatable') %}true{% else %}false{% endif %},
autoComplete: {{ theme_config('autocomplete_chars') }}
},
{
'confirm': '{{ 'confirm'|trans }}',

View File

@@ -3,6 +3,10 @@
{% set actions = {'search': {'class': 'search-toggle visible-xs-inline'}} %}
{% if is_granted('manage_tag') %}
{% set actions = actions|merge({'create': {'url': path('tags_create'), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'tags.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.tags', {'actions': actions, 'view': view}) %}
@@ -14,14 +18,32 @@
{% set actions = {} %}
{% if is_granted('view_other_timesheet') %}
{% set actions = actions|merge({'timesheet': path('admin_timesheet', {'tags': tag.name})}) %}
{% if tag.id is not empty %}
{% if is_granted('manage_tag') %}
{% set class = '' %}
{% if view != 'edit' %}
{% set class = 'modal-ajax-form' %}
{% endif %}
{% set actions = actions|merge({'edit': {'url': path('tags_edit', {'id': tag.id}), 'class': class}}) %}
{% endif %}
{% if is_granted('view_other_timesheet') %}
{% set actions = actions|merge({'timesheet': path('admin_timesheet', {'tags': tag.name})}) %}
{% endif %}
{% if is_granted('delete_tag') %}
{% set actions = actions|merge({'trash': {'url': path('delete_tag', {'id' : tag.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.tagDelete kimai.tagUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %}
{% endif %}
{% endif %}
{% if is_granted('delete_tag') %}
{% set actions = actions|merge({'trash': {'url': path('delete_tag', {'id' : tag.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.tagDelete kimai.tagUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %}
{% if view != 'index' %}
{% set actions = actions|merge({'back': path('tags')}) %}
{% endif %}
{% set event = trigger('actions.tag', {'actions': actions, 'view': view, 'tag': tag}) %}
{{ widgets.table_actions(event.payload.actions) }}
{% if view == 'index' %}
{{ widgets.table_actions(event.payload.actions) }}
{% else %}
{{ widgets.entity_actions(event.payload.actions) }}
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,14 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "tags/actions.html.twig" as actions %}
{% block page_title %}{{ 'tags.title'|trans({}, 'tags') }}{% endblock %}
{% block page_actions %}{{ actions.tag(tag, 'edit') }}{% endblock %}
{% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': tag.name|default('create'|trans),
'form': form,
'back': path('tags')
}) }}
{% endblock %}

View File

@@ -21,6 +21,7 @@
} %}
{% set tableName = 'admin_tags' %}
{% set manageAllowed = is_granted('manage_tag') %}
{{ tables.data_table_header(tableName, columns, false, 'kimai.tagUpdate') }}
{% for tag in tags %}
@@ -29,7 +30,7 @@
{% set type = 'warning' %}
{% endif %}
<tr>
<tr{% if manageAllowed %} class="modal-ajax-form open-edit" data-href="{{ path('tags_edit', {'id': tag.id}) }}"{% endif %}>
<td>{{ tag.name }}</td>
<td>{{ widgets.label(tag.amount, type) }}</td>
<td class="actions">

View File

@@ -55,6 +55,35 @@ class TagControllerTest extends APIControllerBaseTest
$this->assertEquals(0, count($result));
}
public function testPostAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
];
$this->request($client, '/api/tags', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertStructure($result);
$this->assertNotEmpty($result['id']);
}
public function testPostActionWithInvalidUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'name' => 'foo',
];
$this->request($client, '/api/tags', 'POST', [], json_encode($data));
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
$json = json_decode($response->getContent(), true);
$this->assertEquals('User cannot create tags', $json['message']);
}
public function testPartOfEntries()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -90,4 +119,21 @@ class TagControllerTest extends APIControllerBaseTest
{
$this->assertEntityNotFoundForDelete(User::ROLE_ADMIN, '/api/tags/255', []);
}
protected function assertStructure(array $result, $full = true)
{
$expectedKeys = [
'id', 'name', 'timesheets'
];
if ($full) {
$expectedKeys = array_merge($expectedKeys, []);
}
$actual = array_keys($result);
sort($actual);
sort($expectedKeys);
$this->assertEquals($expectedKeys, $actual, 'Tag structure does not match');
}
}

View File

@@ -60,4 +60,39 @@ class TagControllerTest extends ControllerBaseTest
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_admin_tags', 2);
}
public function testCreateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/tags/create');
$form = $client->getCrawler()->filter('form[name=tag_edit_form]')->form();
$client->submit($form, [
'tag_edit_form' => [
'name' => 'A tAG Name!',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/tags/'));
$client->followRedirect();
$this->assertHasDataTable($client);
$this->request($client, '/admin/tags/11/edit');
$editForm = $client->getCrawler()->filter('form[name=tag_edit_form]')->form();
$this->assertEquals('A tAG Name!', $editForm->get('tag_edit_form[name]')->getValue());
}
public function testEditAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/tags/1/edit');
$form = $client->getCrawler()->filter('form[name=tag_edit_form]')->form();
$client->submit($form, [
'tag_edit_form' => ['name' => 'Test 2 updated']
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/tags/'));
$client->followRedirect();
$this->assertHasDataTable($client);
$this->request($client, '/admin/tags/1/edit');
$editForm = $client->getCrawler()->filter('form[name=tag_edit_form]')->form();
$this->assertEquals('Test 2 updated', $editForm->get('tag_edit_form[name]')->getValue());
}
}

View File

@@ -231,6 +231,6 @@ class UserControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/user/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 80);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 81);
}
}

View File

@@ -117,6 +117,7 @@ class AppExtensionTest extends TestCase
'title' => null,
],
'auto_reload_datatable' => false,
'autocomplete_chars' => 3,
],
'kimai.theme.select_type' => null,
'kimai.theme.show_about' => true,

View File

@@ -70,6 +70,10 @@
<source>label.theme.select_type</source>
<target>Art der Auswahl-Boxen in Formularen</target>
</trans-unit>
<trans-unit id="label.theme.autocomplete_chars">
<source>label.theme.autocomplete_chars</source>
<target>Minimale Anzahl Buchstaben für den Start der Autovervollständigung</target>
</trans-unit>
<trans-unit id="label.calendar.week_numbers">
<source>label.calendar.week_numbers</source>
<target>Kalenderwochen anzeigen</target>

View File

@@ -70,6 +70,10 @@
<source>label.theme.select_type</source>
<target>Type of select-boxes in forms</target>
</trans-unit>
<trans-unit id="label.theme.autocomplete_chars">
<source>label.theme.autocomplete_chars</source>
<target>Minimum number of letters to start auto-completion</target>
</trans-unit>
<trans-unit id="label.calendar.week_numbers">
<source>label.calendar.week_numbers</source>
<target>Display week numbers</target>