added tags for timesheets (#604)

This commit is contained in:
Mathias
2019-05-12 01:40:04 +02:00
committed by Kevin Papst
parent f31118292c
commit e29e183e84
84 changed files with 2132 additions and 158 deletions

115
src/API/TagController.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
/*
* 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\API;
use App\Repository\TagRepository;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
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\Response;
/**
* @RouteResource("Tag")
*/
class TagController extends BaseApiController
{
/**
* @var TagRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param TagRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, TagRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing tags as string array",
* @SWG\Schema(
* type="array",
* @SWG\Items(type="string")
* )
* )
*
* @Rest\QueryParam(name="name", requirements="[a-zA-Z0-9 -\.]+", strict=true, nullable=true, description="Search term to filter tag list")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$filter = $paramFetcher->get('name');
$data = $this->repository->findAllTagNames($filter);
if (null === $data) {
$data = [];
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection']);
return $this->viewHandler->handle($view);
}
/**
* Delete an existing tag
*
* @SWG\Delete(
* @SWG\Response(
* response=204,
* description="Delete one tag"
* ),
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Tag ID to delete",
* required=true,
* )
*
* @Security("is_granted('delete_tag')")
*
* @param int $id
* @return Response
*/
public function deleteAction($id)
{
$tag = $this->repository->find($id);
if (null === $tag) {
throw new NotFoundException();
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($tag);
$entityManager->flush();
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
}

View File

@@ -15,8 +15,10 @@ use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use Doctrine\Common\Collections\ArrayCollection;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -49,24 +51,29 @@ class TimesheetController extends BaseApiController
* @var TimesheetConfiguration
*/
protected $configuration;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @var TagRepository
*/
protected $tagRepository;
/**
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
* @param UserDateTimeFactory $dateTime
* @param TimesheetConfiguration $configuration
* @param TagRepository $tagRepository
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration)
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration, TagRepository $tagRepository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->configuration = $configuration;
$this->dateTime = $dateTime;
$this->tagRepository = $tagRepository;
}
/**
@@ -87,6 +94,7 @@ class TimesheetController extends BaseApiController
* @Rest\QueryParam(name="activity", requirements="\d+", strict=true, nullable=true, description="Activity ID to filter timesheets")
* @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)")
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 25)")
* @Rest\QueryParam(name="tags", requirements="[a-zA-Z0-9 -,]+", strict=true, nullable=true, description="The name of tags which are in the datasets")
* @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: DESC)")
* @Rest\QueryParam(name="begin", requirements=@Constraints\DateTime, strict=true, nullable=true, description="Only records after this date will be included (format: ISO 8601)")
@@ -133,6 +141,13 @@ class TimesheetController extends BaseApiController
$query->setPageSize($size);
}
if (null !== ($tags = $paramFetcher->get('tags'))) {
$ids = $this->tagRepository->findIdsByTagNameList($tags);
if ($ids !== null && sizeof($ids) > 0) {
$query->setTags(new ArrayCollection($ids));
}
}
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}

View File

@@ -9,6 +9,7 @@
namespace App\Calendar;
use App\Entity\Tag;
use App\Entity\Timesheet;
class TimesheetEntity
@@ -45,6 +46,10 @@ class TimesheetEntity
* @var string
*/
protected $activity;
/**
* @var string|null
*/
protected $tags;
/**
* @var string|null
*/
@@ -66,6 +71,14 @@ class TimesheetEntity
$this->customer = $entry->getProject()->getCustomer()->getName();
$this->project = $entry->getProject()->getName();
$this->activity = $entry->getActivity()->getName();
if (sizeof($entry->getTags()) > 0) {
$arr = [];
/** @var Tag $tag */
foreach ($entry->getTags() as $tag) {
array_push($arr, $tag->getName());
}
$this->tags = implode(', ', $arr);
}
$color = $entry->getActivity()->getColor();
if (empty($color)) {
@@ -235,6 +248,25 @@ class TimesheetEntity
return $this;
}
/**
* @return string|null
*/
public function getTags(): ?string
{
return $this->tags;
}
/**
* @param string|null $tags
* @return TimesheetEntity
*/
public function setTags(?string $tags)
{
$this->tags = $tags;
return $this;
}
/**
* @return null|string
*/

View File

@@ -345,7 +345,7 @@ class InvoiceController extends AbstractController
'method' => 'POST',
'attr' => [
'id' => 'invoice-print-form'
]
],
]);
}

View File

@@ -0,0 +1,70 @@
<?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\Controller;
use App\Form\Toolbar\TagToolbarForm;
use App\Repository\Query\TagQuery;
use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/admin/tags")
* @Security("is_granted('view_tag')")
*/
class TagController extends AbstractController
{
/**
* @Route(path="/", defaults={"page": 1}, name="tags", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="tags_paginated", methods={"GET"})
*
* @param TagRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
public function listTags(TagRepository $repository, Request $request, $page)
{
$query = new TagQuery();
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var TagQuery $query */
$query = $form->getData();
}
$tags = $repository->getTagCount($query);
return $this->render('tags/index.html.twig', [
'tags' => $tags,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @param TagQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(TagQuery $query)
{
return $this->createForm(TagToolbarForm::class, $query, [
'action' => $this->generateUrl('tags', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
}

View File

@@ -9,12 +9,14 @@
namespace App\Controller;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -61,6 +63,14 @@ class TimesheetController extends AbstractController
$query->setUser($this->getUser());
if ($query->hasTags()) {
$query->setTags(
new ArrayCollection(
$this->getDoctrine()->getRepository(Tag::class)->findIdsByTagNameList(implode(',', $query->getTags()->toArray()))
)
);
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
@@ -149,8 +159,7 @@ class TimesheetController extends AbstractController
->setBegin($this->dateTime->createDateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
;
->setProject($timesheet->getProject());
$errors = $validator->validate($entry);
@@ -213,7 +222,7 @@ class TimesheetController extends AbstractController
}
/**
* @Route(path="/{id}/delete", defaults={"page": 1}, name="timesheet_delete", methods={"GET", "POST"})
* @Route(path="/{id}/delete", name="timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
* @param Timesheet $entry

View File

@@ -9,13 +9,14 @@
namespace App\Controller;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Form\DeleteType;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -59,6 +60,14 @@ class TimesheetTeamController extends AbstractController
}
}
if ($query->hasTags()) {
$query->setTags(
new ArrayCollection(
$this->getDoctrine()->getRepository(Tag::class)->findIdsByTagNameList(implode(',', $query->getTags()->toArray()))
)
);
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
@@ -137,7 +146,7 @@ class TimesheetTeamController extends AbstractController
}
/**
* @Route(path="/{id}/delete", defaults={"page": 1}, name="admin_timesheet_delete", methods={"GET", "POST"})
* @Route(path="/{id}/delete", name="admin_timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
* @param Timesheet $entry

View File

@@ -0,0 +1,86 @@
<?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\DataFixtures;
use App\Entity\Tag;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory;
/**
* Defines the sample data to load in the database when running the unit and
* functional tests or while development.
*
* Execute this command to load the data:
* bin/console doctrine:fixtures:load
*
* @codeCoverageIgnore
*/
class TagFixtures extends Fixture
{
public const MIN_TAGS = 50;
public const MAX_TAGS = 2000;
public const BATCH_SIZE = 100;
/**
* {@inheritdoc}
*/
public function load(ObjectManager $manager)
{
$faker = Factory::create();
$amount = rand(self::MIN_TAGS, self::MAX_TAGS);
$existing = [];
for ($i = 0; $i < $amount; $i++) {
$tag = new Tag();
$tagName = null;
if ($i % 2 == 9) {
$tagName = $faker->companyEmail;
} elseif ($i % 2 == 8) {
$tagName = $faker->firstName;
} elseif ($i % 2 == 7) {
$tagName = $faker->lastName;
} elseif ($i % 6 == 0) {
$tagName = $faker->iban();
} elseif ($i % 5 == 0) {
$tagName = $faker->city;
} elseif ($i % 4 == 0) {
$tagName = $faker->word;
} elseif ($i % 3 == 0) {
$tagName = $faker->streetName;
} elseif ($i % 2 == 0) {
$tagName = $faker->colorName;
} elseif ($i % 1 == 0) {
$tagName = $faker->text(rand(10, 20));
}
if (in_array($tagName, $existing)) {
continue;
}
$existing[] = $tagName;
$tag->setName($tagName);
$manager->persist($tag);
if ($i % self::BATCH_SIZE == 0) {
$manager->flush();
$manager->clear(Tag::class);
}
$manager->flush();
$manager->clear(Tag::class);
}
$manager->flush();
}
}

View File

@@ -11,6 +11,7 @@ namespace App\DataFixtures;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
@@ -50,6 +51,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return [
UserFixtures::class,
CustomerFixtures::class,
TagFixtures::class,
];
}
@@ -61,6 +63,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$allUser = $this->getAllUsers($manager);
$activities = $this->getAllActivities($manager);
$projects = $this->getAllProjects($manager);
$allTags = $this->getAllTags($manager);
$faker = Factory::create();
@@ -86,13 +89,13 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
$description
$description,
true
);
$manager->persist($entry);
if ($i % self::BATCH_SIZE == 0) {
//echo '['.$i.'] Timesheets for User ' . $user->getId() . PHP_EOL;
$manager->flush();
$manager->clear(Timesheet::class);
}
@@ -115,6 +118,37 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$manager->clear(Timesheet::class);
}
$manager->flush();
$entries = $manager->getRepository(Timesheet::class)->findAll();
foreach ($entries as $temp) {
$tagAmount = rand(0, 4);
for ($iTag = 0; $iTag < $tagAmount; $iTag++) {
$tagId = rand(1, TagFixtures::MAX_TAGS);
if (isset($allTags[$tagId])) {
$temp->addTag($allTags[$tagId]);
}
}
}
$manager->flush();
$manager->clear(Timesheet::class);
$manager->clear(Tag::class);
}
/**
* @param ObjectManager $manager
* @return Tag[]
*/
protected function getAllTags(ObjectManager $manager)
{
$all = [];
/* @var Tag[] $entries */
$entries = $manager->getRepository(Tag::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**

118
src/Entity/Tag.php Normal file
View File

@@ -0,0 +1,118 @@
<?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\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_tags",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\TagRepository")
* @UniqueEntity("name")
*/
class Tag
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=255)
*/
private $name;
/**
* @var Timesheet[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Timesheet", mappedBy="tags", fetch="EXTRA_LAZY")
*/
protected $timesheets;
public function __construct()
{
$this->timesheets = new ArrayCollection();
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @param string $tagName
* @return Tag
*/
public function setName($tagName)
{
$this->name = $tagName;
return $this;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param Timesheet $timesheet
*/
public function addTimesheet(Timesheet $timesheet)
{
if ($this->timesheets->contains($timesheet)) {
return;
}
$this->timesheets->add($timesheet);
$timesheet->addTag($this);
}
/**
* @param Timesheet $timesheet
*/
public function removeTimesheet(Timesheet $timesheet)
{
if (!$this->timesheets->contains($timesheet)) {
return;
}
$this->timesheets->removeElement($timesheet);
$timesheet->removeTag($this);
}
/**
* @return string
*/
public function __toString()
{
return $this->getName();
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -122,6 +123,30 @@ class Timesheet
*/
private $exported = false;
/**
* @var \App\Entity\Tag[]
*
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"persist"})
* @ORM\JoinTable(
* name="kimai2_timesheet_tags",
* joinColumns={
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* }
* )
*/
protected $tags;
/**
* Default constructor, initializes collections
*/
public function __construct()
{
$this->tags = new ArrayCollection();
}
/**
* Get entry id
*
@@ -319,6 +344,54 @@ class Timesheet
return $this->rate;
}
/**
* @param Tag $tag
* @return Timesheet
*/
public function addTag(Tag $tag)
{
if ($this->tags->contains($tag)) {
return $this;
}
$this->tags->add($tag);
$tag->addTimesheet($this);
return $this;
}
/**
* @param Tag $tag
*/
public function removeTag(Tag $tag)
{
if (!$this->tags->contains($tag)) {
return;
}
$this->tags->removeElement($tag);
$tag->removeTimesheet($this);
}
/**
* @return Tag[]|ArrayCollection
*/
public function getTags()
{
return $this->tags;
}
/**
* @return string[]
*/
public function getTagsAsArray()
{
return array_map(
function (Tag $element) {
return $element->getName();
},
$this->getTags()->toArray()
);
}
/**
* @return bool
*/

View File

@@ -116,6 +116,12 @@ class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks')
);
}
if ($auth->isGranted('view_tag')) {
$menu->addChild(
new MenuItemModel('tags', 'menu.tags', 'tags', [], 'fas fa-tags')
);
}
}
/**

View File

@@ -0,0 +1,82 @@
<?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\DataTransformer;
use App\Entity\Tag;
use App\Repository\TagRepository;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class TagArrayToStringTransformer implements DataTransformerInterface
{
/**
* @var TagRepository
*/
private $tagRepository;
/**
* @param TagRepository $tagRepository
*/
public function __construct(TagRepository $tagRepository)
{
$this->tagRepository = $tagRepository;
}
/**
* Transforms an array of tags to a string.
*
* @param Tag[]|null $tags
*
* @return string
*/
public function transform($tags): string
{
if (empty($tags)) {
return '';
}
return implode(', ', $tags);
}
/**
* Transforms a string to an array of tags.
*
* @param string $stringOfTags
*
* @return Tag[]
* @throws TransformationFailedException if object (issue) is not found
*/
public function reverseTransform($stringOfTags): array
{
// check for empty tag list
if (empty($stringOfTags)) {
return [];
}
$names = array_filter(array_unique(array_map('trim', explode(',', $stringOfTags))));
// Get the current tags and find the new ones that should be created
$tags = $this->tagRepository->findBy(['name' => $names]);
$newNames = array_diff($names, $tags);
foreach ($newNames as $name) {
$tag = new Tag();
$tag->setName($name);
$tags[] = $tag;
// There's no need to persist these new tags because Doctrine does that automatically
// thanks to the cascade={"persist"} option in the App\Entity\Timesheet::$tags property.
}
// Return an array of tags to transform them back into a Doctrine Collection.
// See Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer::reverseTransform()
return $tags;
}
}

View File

@@ -92,6 +92,5 @@ class SelectWithApiDataExtension extends AbstractTypeExtension
{
$resolver->setDefined(['api_data']);
$resolver->setAllowedTypes('api_data', 'array');
//$resolver->setDefault('api_data', []);
}
}

View File

@@ -18,6 +18,7 @@ use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
@@ -250,8 +251,17 @@ class TimesheetEditForm extends AbstractType
->add('description', TextareaType::class, [
'label' => 'label.description',
'required' => false,
])
;
]);
$builder
->add('tags', TagsInputType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'text',
'description' => 'Tags for timesheet entry',
],
'required' => false,
]);
if ($options['include_rate']) {
$builder

View File

@@ -14,6 +14,7 @@ use App\Form\Type\CustomerType;
use App\Form\Type\DateRangeType;
use App\Form\Type\PageSizeType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Form\Type\UserRoleType;
use App\Form\Type\UserType;
use App\Form\Type\VisibilityType;
@@ -208,4 +209,14 @@ abstract class AbstractToolbarForm extends AbstractType
'empty_data' => 1
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addTagInputField(FormBuilderInterface $builder)
{
$builder->add('tags', TagsInputType::class, [
'required' => false
]);
}
}

View File

@@ -33,6 +33,7 @@ class ExportToolbarForm extends AbstractToolbarForm
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addExportType($builder);
$this->addTagInputField($builder);
}
/**

View File

@@ -30,6 +30,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addTagInputField($builder);
}
protected function addTemplateChoice(FormBuilderInterface $builder)

View File

@@ -0,0 +1,37 @@
<?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\Toolbar;
use App\Repository\Query\TagQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TagToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addPageSizeChoice($builder);
$this->addHiddenPagination($builder);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => TagQuery::class,
'csrf_protection' => false,
]);
}
}

View File

@@ -33,6 +33,7 @@ class TimesheetToolbarForm extends AbstractToolbarForm
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addTagInputField($builder);
$this->addHiddenPagination($builder);
}

View File

@@ -0,0 +1,77 @@
<?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\Type;
use App\Form\DataTransformer\TagArrayToStringTransformer;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Custom form field type to enter tags or use one of autocompleted field
*/
class TagsInputType extends AbstractType
{
/**
* @var TagArrayToStringTransformer
*/
private $transformer;
/**
* @var UrlGeneratorInterface
*/
private $router;
/**
* @param TagArrayToStringTransformer $transformer
* @param UrlGeneratorInterface $router
*/
public function __construct(TagArrayToStringTransformer $transformer, UrlGeneratorInterface $router)
{
$this->transformer = $transformer;
$this->router = $router;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->addModelTransformer(new CollectionToArrayTransformer(), true)
->addModelTransformer($this->transformer, true);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.tag',
'attr' => [
'data-autocomplete-url' => $this->router->generate('get_tags'),
'class' => 'js-autocomplete',
'autocomplete' => 'off',
]
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return TextType::class;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* New feature: tagging of timesheet records
*
* @version 1.0
*/
class Version20190510205245 extends AbstractMigration
{
public function up(Schema $schema): void
{
$timesheetTags = $schema->createTable('kimai2_timesheet_tags');
$timesheetTags->addColumn('timesheet_id', 'integer', ['length' => 11, 'notnull' => true]);
$timesheetTags->addColumn('tag_id', 'integer', ['length' => 11, 'notnull' => true]);
$timesheetTags->addIndex(['timesheet_id'], 'IDX_E3284EFEABDD46BE');
$timesheetTags->addIndex(['tag_id'], 'IDX_E3284EFEBAD26311');
$timesheetTags->setPrimaryKey(['timesheet_id', 'tag_id']);
$tags = $schema->createTable('kimai2_tags');
$tags->addColumn('id', 'integer', ['length' => 11, 'autoincrement' => true, 'notnull' => true]);
$tags->addColumn('name', 'string', ['length' => 255, 'notnull' => true]);
$tags->addUniqueIndex(['name'], 'UNIQ_27CAF54C5E237E06');
$tags->setPrimaryKey(['id']);
}
public function down(Schema $schema): void
{
$tags = $schema->getTable('kimai2_tags');
$tags->dropIndex('UNIQ_27CAF54C5E237E06');
$timesheetTags = $schema->getTable('kimai2_timesheet_tags');
$timesheetTags->dropIndex('IDX_E3284EFEABDD46BE');
$timesheetTags->dropIndex('IDX_E3284EFEBAD26311');
$schema->dropTable('kimai2_timesheet_tags');
$schema->dropTable('kimai2_tags');
}
}

View File

@@ -0,0 +1,14 @@
<?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\Repository\Query;
class TagQuery extends BaseQuery
{
}

View File

@@ -54,6 +54,10 @@ class TimesheetQuery extends ActivityQuery
* @var DateRange
*/
protected $dateRange;
/**
* @var iterable
*/
protected $tags;
public function __construct()
{
@@ -208,4 +212,31 @@ class TimesheetQuery extends ActivityQuery
return $this;
}
/**
* @return iterable
*/
public function getTags()
{
return $this->tags;
}
/**
* @param iterable $tags
* @return $this
*/
public function setTags(iterable $tags)
{
$this->tags = $tags;
return $this;
}
/**
* @return bool
*/
public function hasTags()
{
return !empty($this->tags) && count($this->tags) > 0;
}
}

View File

@@ -0,0 +1,83 @@
<?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\Repository;
use App\Repository\Query\TagQuery;
class TagRepository extends AbstractRepository
{
/**
* Find ids of the given tagNames separated by comma
* @param string $tagNames
* @return array
*/
public function findIdsByTagNameList($tagNames)
{
$qb = $this
->createQueryBuilder('t')
->select('t.id');
$list = array_filter(array_unique(array_map('trim', explode(',', $tagNames))));
$cnt = 0;
foreach ($list as $listElem) {
$qb
->orWhere('t.name like :elem' . $cnt)
->setParameter('elem' . $cnt, '%' . $listElem . '%');
$cnt++;
}
return array_column($qb->getQuery()->getScalarResult(), 'id');
}
/**
* Find all tag names in an alphabetical order
*
* @param string $filter
* @return array
*/
public function findAllTagNames($filter = null)
{
$qb = $this->createQueryBuilder('t');
$qb
->select('t.name')
->addOrderBy('t.name', 'ASC');
if (null !== $filter) {
$qb
->andWhere('t.name LIKE :filter')
->setParameter('filter', '%' . $filter . '%');
}
return array_column($qb->getQuery()->getScalarResult(), 'name');
}
/**
* Returns an array of arrays with each inner array having the structure:
* - id
* - name
* - amount
*
* @param TagQuery $query
* @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/
public function getTagCount(TagQuery $query)
{
$qb = $this->createQueryBuilder('tag');
$qb
->select('tag.id, tag.name, count(timesheets.id) as amount')
->leftJoin('tag.timesheets', 'timesheets')
->addGroupBy('tag.id')
->orderBy('tag.name')
;
return $this->getBaseQueryResult($qb, $query);
}
}

View File

@@ -332,12 +332,13 @@ class TimesheetRepository extends AbstractRepository
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t', 'a', 'p', 'c', 'u')
$qb->select('t', 'a', 'p', 'c', 'u', 'tags')
->from(Timesheet::class, 't')
->leftJoin('t.activity', 'a')
->leftJoin('t.user', 'u')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
->leftJoin('t.tags', 'tags')
->orderBy('t.' . $query->getOrderBy(), $query->getOrder());
if (null !== $query->getUser()) {
@@ -382,6 +383,11 @@ class TimesheetRepository extends AbstractRepository
}
}
if ($query->hasTags()) {
$qb->andWhere('tags.id IN (:tags)')
->setParameter('tags', $query->getTags()->toArray());
}
return $this->getBaseQueryResult($qb, $query);
}