record single task states transition and add them in timeline

This commit is contained in:
Julien Fastré 2018-05-03 23:38:08 +02:00
parent 57169b3148
commit 86f7188d4a
17 changed files with 645 additions and 7 deletions

View File

@ -20,6 +20,8 @@ use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\PersonBundle\Entity\PersonRepository;
use Chill\MainBundle\Entity\UserRepository;
use Chill\TaskBundle\Event\TaskEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class SingleTaskController extends Controller
{
@ -30,8 +32,10 @@ class SingleTaskController extends Controller
* name="chill_task_single_task_new"
* )
*/
public function newAction(Request $request)
{
public function newAction(
Request $request,
EventDispatcherInterface $dispatcher
) {
$task = (new SingleTask())
->setAssignee($this->getUser())
@ -67,6 +71,8 @@ class SingleTaskController extends Controller
if ($form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->persist($task);
$dispatcher->dispatch(TaskEvent::PERSIST, new TaskEvent($task));
$em->flush();

View File

@ -32,6 +32,8 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface
$loader->load('services/workflow.yml');
$loader->load('services/templating.yml');
$loader->load('services/menu.yml');
$loader->load('services/event.yml');
$loader->load('services/timeline.yml');
}
public function prepend(ContainerBuilder $container)

View File

@ -22,6 +22,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
use Chill\TaskBundle\Workflow\TaskWorkflowManager;
use Symfony\Component\DependencyInjection\Reference;
use Chill\TaskBundle\Templating\UI\CountNotificationTask;
use Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent;
/**
*
@ -39,6 +40,7 @@ class TaskWorkflowDefinitionCompilerPass implements CompilerPassInterface
$workflowManagerDefinition = $container->getDefinition(TaskWorkflowManager::class);
$counterDefinition = $container->getDefinition(CountNotificationTask::class);
$lifecycleDefinition = $container->getDefinition(TaskLifecycleEvent::class);
foreach ($container->findTaggedServiceIds('chill_task.workflow_definition') as $id => $tags) {
// registering the definition to manager
@ -58,6 +60,12 @@ class TaskWorkflowDefinitionCompilerPass implements CompilerPassInterface
'method' => 'resetCacheOnNewStates',
'priority' => 0
]);
$lifecycleDefinition
->addTag('kernel.event_listener', [
'event' => sprintf('workflow.%s.transition', $definition->getClass()::getAssociatedWorkflowName()),
'method' => 'onTransition',
'priority' => 0
]);
}
}
}

View File

@ -86,6 +86,11 @@ abstract class AbstractTask implements HasScopeInterface, HasCenterInterface
* @ORM\Column(name="closed", type="boolean", options={ "default"=false })
*/
private $closed = false;
public function __construct()
{
}
/**
* Set type

View File

@ -4,6 +4,7 @@ namespace Chill\TaskBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Doctrine\Common\Collections\Collection;
/**
* SingleTask
@ -58,8 +59,25 @@ class SingleTask extends AbstractTask
* )
*/
private $recurringTask;
/**
*
* @var \Doctrine\Common\Collections\Collection
* @ORM\OneToMany(
* targetEntity="\Chill\TaskBundle\Entity\Task\SingleTaskPlaceEvent",
* mappedBy="task"
* )
*/
private $taskPlaceEvents;
public function __construct()
{
$this->taskPlaceEvents = $events = new \Doctrine\Common\Collections\ArrayCollection;
parent::__construct();
}
/**
* Get id
*
@ -174,6 +192,16 @@ class SingleTask extends AbstractTask
$this->recurringTask = $recurringTask;
}
public function getTaskPlaceEvents(): Collection
{
return $this->taskPlaceEvents;
}
public function setTaskPlaceEvents(Collection $taskPlaceEvents)
{
$this->taskPlaceEvents = $taskPlaceEvents;
return $this;
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace Chill\TaskBundle\Entity\Task;
use Doctrine\ORM\Mapping as ORM;
use Chill\MainBundle\Entity\User;
/**
* AbstractTaskPlaceEvent
*
* @ORM\MappedSuperclass()
*/
class AbstractTaskPlaceEvent
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var datetime_immutable
*
* @ORM\Column(name="datetime", type="datetime_immutable")
*/
private $datetime;
/**
* @var string
*
* @ORM\Column(name="transition", type="string", length=255)
*/
private $transition = '';
/**
* @var string
*
* @ORM\Column(name="data", type="json")
*/
private $data = [];
/**
*
* @var User
* @ORM\ManyToOne(
* targetEntity="\Chill\MainBundle\Entity\User"
* )
*/
private $author;
public function __construct()
{
$this->datetime = new \DateTimeImmutable('now');
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set datetime.
*
* @param datetime_immutable $datetime
*
* @return AbstractTaskPlaceEvent
*/
public function setDatetime($datetime)
{
$this->datetime = $datetime;
return $this;
}
/**
* Get datetime.
*
* @return datetime_immutable
*/
public function getDatetime()
{
return $this->datetime;
}
/**
* Set transition.
*
* @param string $transition
*
* @return AbstractTaskPlaceEvent
*/
public function setTransition($transition)
{
$this->transition = $transition;
return $this;
}
/**
* Get transition.
*
* @return string
*/
public function getTransition()
{
return $this->transition;
}
/**
* Set data.
*
* @param string $data
*
* @return AbstractTaskPlaceEvent
*/
public function setData($data)
{
$this->data = $data;
return $this;
}
/**
* Get data.
*
* @return string
*/
public function getData()
{
return $this->data;
}
public function getAuthor(): User
{
return $this->author;
}
public function setAuthor(User $author)
{
$this->author = $author;
return $this;
}
}

View File

@ -0,0 +1,56 @@
<?php
/*
* Copyright (C) 2018 Champs Libres Cooperative <info@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\TaskBundle\Entity\Task;
use Doctrine\ORM\Mapping as ORM;
use Chill\TaskBundle\Entity\SingleTask;
/**
*
*
* @ORM\Table("chill_task.single_task_place_event")
* @ORM\Entity()
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class SingleTaskPlaceEvent extends AbstractTaskPlaceEvent
{
/**
*
* @var SingleTask
* @ORM\ManyToOne(
* targetEntity="\Chill\TaskBundle\Entity\SingleTask",
* inversedBy="taskPlaceEvents"
* )
*/
protected $task;
public function getTask(): SingleTask
{
return $this->task;
}
public function setTask(SingleTask $task)
{
$this->task = $task;
return $this;
}
}

View File

@ -0,0 +1,104 @@
<?php
/*
* Copyright (C) 2018 Champs Libres Cooperative <info@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\TaskBundle\Event\Lifecycle;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Chill\TaskBundle\Event\TaskEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Chill\TaskBundle\Entity\Task\SingleTaskPlaceEvent;
use Symfony\Component\Workflow\Event\Event as WorkflowEvent;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class TaskLifecycleEvent implements EventSubscriberInterface
{
/**
*
* @var TokenStorageInterface
*/
protected $tokenStorage;
/**
*
* @var EntityManagerInterface
*/
protected $em;
public function __construct(
TokenStorageInterface $tokenStorage,
EntityManagerInterface $em
) {
$this->tokenStorage = $tokenStorage;
$this->em = $em;
}
public static function getSubscribedEvents(): array
{
return [
TaskEvent::PERSIST => [
'onTaskPersist'
]
];
}
public function onTaskPersist(TaskEvent $e)
{
$task = $e->getTask();
$user = $this->tokenStorage->getToken()->getUser();
$event = (new SingleTaskPlaceEvent())
->setTask($task)
->setAuthor($user)
->setTransition('_creation')
->setData([
'new_states' => $task->getCurrentStates()
])
;
$task->getTaskPlaceEvents()->add($event);
$this->em->persist($event);
}
public function onTransition(WorkflowEvent $e)
{
$task = $e->getSubject();
$user = $this->tokenStorage->getToken()->getUser();
$event = (new SingleTaskPlaceEvent())
->setTask($task)
->setAuthor($user)
->setTransition($e->getTransition()->getName())
->setData([
'old_states' => $e->getTransition()->getFroms(),
'new_states' => $e->getTransition()->getTos(),
'workflow' => $e->getWorkflowName()
])
;
$task->getTaskPlaceEvents()->add($event);
$this->em->persist($event);
}
}

55
Event/TaskEvent.php Normal file
View File

@ -0,0 +1,55 @@
<?php
/*
* Copyright (C) 2018 Champs Libres Cooperative <info@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\TaskBundle\Event;
use Chill\TaskBundle\Entity\AbstractTask;
use Symfony\Component\EventDispatcher\Event;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class TaskEvent extends Event
{
const PERSIST = 'chill_task.task_persist';
/**
*
* @var AbstractTask
*/
protected $task;
public function __construct(AbstractTask $task)
{
$this->task = $task;
}
public function getTask(): AbstractTask
{
return $this->task;
}
public function setTask(AbstractTask $task)
{
$this->task = $task;
return $this;
}
}

View File

@ -0,0 +1,7 @@
services:
Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
$tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'
tags:
- { name: kernel.event_subscriber }

View File

@ -0,0 +1,8 @@
services:
Chill\TaskBundle\Timeline\TaskLifeCycleEventTimelineProvider:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
$registry: '@Symfony\Component\Workflow\Registry'
tags:
- { name: 'chill.timeline', context: 'person' }

View File

@ -0,0 +1,34 @@
<?php declare(strict_types = 1);
namespace Application\Migrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Create task place events table.
*/
class Version20180502194119 extends AbstractMigration
{
public function up(Schema $schema)
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('CREATE SEQUENCE chill_task.single_task_place_event_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_task.single_task_place_event (id INT NOT NULL, author_id INT DEFAULT NULL, task_id INT DEFAULT NULL, datetime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, transition VARCHAR(255) NOT NULL, data JSONB NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_D459EBEEF675F31B ON chill_task.single_task_place_event (author_id)');
$this->addSql('CREATE INDEX IDX_D459EBEE8DB60186 ON chill_task.single_task_place_event (task_id)');
$this->addSql('COMMENT ON COLUMN chill_task.single_task_place_event.datetime IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_task.single_task_place_event ADD CONSTRAINT FK_D459EBEEF675F31B FOREIGN KEY (author_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_task.single_task_place_event ADD CONSTRAINT FK_D459EBEE8DB60186 FOREIGN KEY (task_id) REFERENCES chill_task.single_task (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema)
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('DROP SEQUENCE chill_task.single_task_place_event_id_seq CASCADE');
$this->addSql('DROP TABLE chill_task.single_task_place_event');
}
}

View File

@ -59,6 +59,10 @@ Year: Année(s)
start: démarrer
close: clotûrer
cancel: annuler
'%user% has closed the task': %user% a fermé la tâche
'%user% has canceled the task': %user% a annulé la tâche
'%user% has started the task': %user% a débuté la tâche
'%user% has created the task': %user% a introduit la tâche
#Flash messages
'The task is created': 'La tâche a été créée'

View File

@ -57,7 +57,7 @@
<a href="" class="sc-button bt-task-exchange">&nbsp;</a>
<div class="bt-dropdown-content">
{% for transition in workflow_transitions(task) %}
<a href="{{ path('chill_task_task_transition', { 'taskId': task.id, 'transition': transition.name|trans, 'kind': 'single-task', 'return_path': app.request.uri }) }}" class="{{ task_workflow_metadata(task, 'transition.class', transition)|e('html_attr') }}">{{ task_workflow_metadata(task, 'transition.verb', transition)|trans }}</a>
<a href="{{ path('chill_task_task_transition', { 'taskId': task.id, 'transition': transition.name, 'kind': 'single-task', 'return_path': app.request.uri }) }}" class="{{ task_workflow_metadata(task, 'transition.class', transition)|e('html_attr') }}">{{ task_workflow_metadata(task, 'transition.verb', transition)|trans }}</a>
{% endfor %}
</div>
</div>

View File

@ -0,0 +1,33 @@
<div>
<h3 class="single-line">
{{ event.datetime|localizeddate('long', 'none') }}
<span class="task"> / {{ 'Task'|trans }}</span> /
{% if transition is not null %}
<span class="statement">{{ task_workflow_metadata(event.task, 'transition.sentence', transition)|trans({ '%user%': event.author.username }) }}</span>
{% else %}
<span class="statement">{{ '%user% has created the task'|trans({ '%user%': event.author.username }) }}</span>
{% endif %}
</h3>
<div class="statement">
<dl class="chill_view_data">
<dt class="inline">{{ 'title'|trans }}</dt>
<dd>{{ event.task.title }}</dd>
{% if event.task.description is not empty %}
<dt class="inline">{{ 'Description'|trans }}</dt>
<dd>
<blockquote class="chill-user-quote">
{{ event.task.description }}
</blockquote>
</dd>
{% endif %}
{% if event.task.endDate is not empty %}
<dt class="inline">{{ 'Task end date'|trans }}</dt>
<dd>{{ event.task.endDate|localizeddate('medium', 'none') }}</dd>
{% endif %}
</dl>
</div>
</div>

View File

@ -0,0 +1,131 @@
<?php
/*
* Copyright (C) 2018 Champs Libres Cooperative <info@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\TaskBundle\Timeline;
use Chill\MainBundle\Timeline\TimelineProviderInterface;
use Doctrine\ORM\EntityManagerInterface;
use Chill\TaskBundle\Entity\Task\SingleTaskPlaceEvent;
use Chill\TaskBundle\Entity\SingleTask;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Workflow;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
class TaskLifeCycleEventTimelineProvider implements TimelineProviderInterface
{
/**
*
* @var EntityManagerInterface
*/
protected $em;
/**
*
* @var Registry
*/
protected $registry;
const TYPE = 'chill_task.transition';
public function __construct(EntityManagerInterface $em, Registry $registry)
{
$this->em = $em;
$this->registry = $registry;
}
public function fetchQuery($context, $args)
{
if ($context !== 'person') {
throw new \LogicException(sprintf('%s is not able '
. 'to render context %s', self::class, $context));
}
$metadata = $this->em
->getClassMetadata(SingleTaskPlaceEvent::class);
$singleTaskMetadata = $this->em
->getClassMetadata(SingleTask::class);
return [
'id' => sprintf('%s.%s.%s', $metadata->getSchemaName(), $metadata->getTableName(), $metadata->getColumnName('id')),
'type' => self::TYPE,
'date' => $metadata->getColumnName('datetime'),
'FROM' => sprintf('%s JOIN %s ON %s = %s',
sprintf('%s.%s', $metadata->getSchemaName(), $metadata->getTableName()),
sprintf('%s.%s', $singleTaskMetadata->getSchemaName(), $singleTaskMetadata->getTableName()),
$metadata->getAssociationMapping('task')['joinColumns'][0]['name'],
sprintf('%s.%s.%s', $singleTaskMetadata->getSchemaName(), $singleTaskMetadata->getTableName(), $singleTaskMetadata->getColumnName('id'))
),
'WHERE' => sprintf('%s.%s = %d',
sprintf('%s.%s', $singleTaskMetadata->getSchemaName(), $singleTaskMetadata->getTableName()),
$singleTaskMetadata->getAssociationMapping('person')['joinColumns'][0]['name'],
$args['person']->getId()
)
];
}
public function getEntities(array $ids)
{
$events = $this->em
->getRepository(SingleTaskPlaceEvent::class)
->findBy([ 'id' => $ids ])
;
return \array_combine(
\array_map(function($e) { return $e->getId(); }, $events ),
$events
);
}
public function getEntityTemplate($entity, $context, array $args)
{
$workflow = $this->registry->get($entity->getTask(), $entity->getData['workflow']);
$transition = $this->getTransitionByName($entity->getTransition(), $workflow);
return [
'template' => 'ChillTaskBundle:Timeline:single_task_transition_person_context.html.twig',
'template_data' => [
'person' => $args['person'],
'event' => $entity,
'transition' => $transition
]
];
}
/**
*
* @param string $name
* @param Workflow $workflow
* @return \Symfony\Component\Workflow\Transition
*/
protected function getTransitionByName($name, Workflow $workflow)
{
foreach ($workflow->getDefinition()->getTransitions() as $transition) {
if ($transition->getName() === $name) {
return $transition;
}
}
}
public function supportsType($type): bool
{
return $type === self::TYPE;
}
}

View File

@ -31,15 +31,18 @@ class DefaultTaskDefinition implements \Chill\TaskBundle\Workflow\TaskWorkflowDe
const TRANSITION_METADATA = [
'close' => [
'verb' => 'close',
'class' => 'sc-button bt-task-label bt-task-close'
'class' => 'sc-button bt-task-label bt-task-close',
'sentence' => '%user% has closed the task'
],
'cancel' => [
'verb' => 'cancel',
'class' => 'sc-button bt-task-label bt-task-cancel'
'class' => 'sc-button bt-task-label bt-task-cancel',
'sentence' => '%user% has canceled the task'
],
'start' => [
'verb' => 'start',
'class' => 'sc-button bt-task-label bt-task-start'
'class' => 'sc-button bt-task-label bt-task-start',
'sentence' => '%user% has started the task'
]
];