Merge pull request 'fix(project) : sélection du workflow à la création + filet par défaut' (#29) from fix/project-creation-workflow into develop
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
@@ -125,6 +125,10 @@ services:
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\EventListener\ProjectDefaultWorkflowListener:
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
|
||||
|
||||
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
@@ -32,6 +32,13 @@
|
||||
empty-option-label="Aucun client"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="!isEditing"
|
||||
v-model="form.workflowId"
|
||||
:options="workflowOptions"
|
||||
label="Workflow"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
@@ -124,10 +131,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
|
||||
@@ -174,12 +183,24 @@ const bookstackShelfOptions = computed(() =>
|
||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const { getAll: getAllWorkflows } = useWorkflowService()
|
||||
const workflows = ref<Workflow[]>([])
|
||||
|
||||
const workflowOptions = computed(() =>
|
||||
workflows.value.map(w => ({ label: w.name, value: w.id }))
|
||||
)
|
||||
|
||||
function defaultWorkflowId(): number | null {
|
||||
return (workflows.value.find(w => w.isDefault) ?? workflows.value[0])?.id ?? null
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
workflowId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
bookstackShelfId: null as number | null,
|
||||
})
|
||||
@@ -222,6 +243,7 @@ watch(() => props.modelValue, (open) => {
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.workflowId = defaultWorkflowId()
|
||||
form.giteaRepoFullName = null
|
||||
form.bookstackShelfId = null
|
||||
}
|
||||
@@ -269,6 +291,9 @@ async function handleSubmit() {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
payload.code = form.code
|
||||
if (form.workflowId) {
|
||||
payload.workflow = `/api/workflows/${form.workflowId}`
|
||||
}
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
@@ -308,6 +333,15 @@ async function handleArchiveToggle() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
workflows.value = await getAllWorkflows()
|
||||
// Si le drawer est déjà ouvert en création, pré-remplir une fois les workflows chargés.
|
||||
if (props.modelValue && !props.project && !form.workflowId) {
|
||||
form.workflowId = defaultWorkflowId()
|
||||
}
|
||||
} catch {
|
||||
// Workflows indisponibles, ignore (le serveur assignera le défaut)
|
||||
}
|
||||
try {
|
||||
giteaRepos.value = await listRepositories()
|
||||
} catch {
|
||||
|
||||
@@ -92,10 +92,11 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?ClientInterface $client = null;
|
||||
|
||||
// workflow_id reste NOT NULL en base ; quand l'appelant n'en fournit pas,
|
||||
// ProjectDefaultWorkflowListener assigne le workflow par défaut au prePersist.
|
||||
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
|
||||
private ?Workflow $workflow = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
|
||||
|
||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Assigns the default workflow to a project when none was provided.
|
||||
* Guarantees the NOT NULL workflow_id constraint across every persistence
|
||||
* path (API Platform, raw API, MCP) without forcing the caller to supply one.
|
||||
*/
|
||||
final readonly class ProjectDefaultWorkflowListener
|
||||
{
|
||||
public function __construct(private WorkflowRepositoryInterface $workflowRepository) {}
|
||||
|
||||
public function prePersist(Project $project, PrePersistEventArgs $args): void
|
||||
{
|
||||
if (null !== $project->getWorkflow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$default = $this->workflowRepository->findDefault()
|
||||
?? ($this->workflowRepository->findBy([], ['position' => 'ASC'], 1)[0] ?? null);
|
||||
|
||||
if (null === $default) {
|
||||
throw new RuntimeException('Cannot create a project: no workflow exists. Seed at least one workflow first.');
|
||||
}
|
||||
|
||||
$project->setWorkflow($default);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
@@ -15,12 +16,13 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
|
||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters. Optional workflowId selects the kanban workflow; the default workflow is used when omitted.')]
|
||||
class CreateProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepositoryInterface $clientRepository,
|
||||
private readonly WorkflowRepositoryInterface $workflowRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
@@ -30,6 +32,7 @@ class CreateProjectTool
|
||||
?string $description = null,
|
||||
?string $color = null,
|
||||
?int $clientId = null,
|
||||
?int $workflowId = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
@@ -52,6 +55,14 @@ class CreateProjectTool
|
||||
}
|
||||
$project->setClient($client);
|
||||
}
|
||||
if (null !== $workflowId) {
|
||||
$workflow = $this->workflowRepository->findById($workflowId);
|
||||
if (null === $workflow) {
|
||||
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
|
||||
}
|
||||
$project->setWorkflow($workflow);
|
||||
}
|
||||
// When no workflow is supplied, ProjectDefaultWorkflowListener assigns the default at prePersist.
|
||||
|
||||
$this->entityManager->persist($project);
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\ProjectManagement;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\ProjectManagement\Domain\Entity\Workflow;
|
||||
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* Vérifie que la création d'un projet fonctionne avec ou sans workflow fourni :
|
||||
* - sans workflow → le workflow par défaut est assigné par le listener prePersist
|
||||
* - avec workflow → le workflow choisi est conservé.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProjectCreationWorkflowTest extends WebTestCase
|
||||
{
|
||||
public function testCreateProjectWithoutWorkflowAssignsDefault(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$client->loginUser($this->createManager($em));
|
||||
|
||||
$client->request('POST', '/api/projects', server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
], content: json_encode([
|
||||
'code' => $this->randomCode(),
|
||||
'name' => 'Projet sans workflow',
|
||||
]));
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertArrayHasKey('workflow', $data);
|
||||
self::assertNotNull($data['workflow'], 'Un workflow par défaut doit avoir été assigné.');
|
||||
}
|
||||
|
||||
public function testCreateProjectWithExplicitWorkflow(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$workflow = self::getContainer()->get(WorkflowRepositoryInterface::class)->findDefault()
|
||||
?? $em->getRepository(Workflow::class)->findOneBy([]);
|
||||
self::assertInstanceOf(Workflow::class, $workflow, 'Les fixtures doivent fournir au moins un workflow.');
|
||||
|
||||
$client->loginUser($this->createManager($em));
|
||||
|
||||
$client->request('POST', '/api/projects', server: [
|
||||
'CONTENT_TYPE' => 'application/ld+json',
|
||||
], content: json_encode([
|
||||
'code' => $this->randomCode(),
|
||||
'name' => 'Projet avec workflow',
|
||||
'workflow' => '/api/workflows/'.$workflow->getId(),
|
||||
]));
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertSame($workflow->getId(), $data['workflow']['id'] ?? null);
|
||||
}
|
||||
|
||||
private function createManager(EntityManagerInterface $em): User
|
||||
{
|
||||
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.manage']);
|
||||
self::assertInstanceOf(Permission::class, $permission, 'Lancer app:sync-permissions pour project-management.projects.manage.');
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('proj-create-'.uniqid());
|
||||
$user->setPassword('x');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
$user->addDirectPermission($permission);
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function randomCode(): string
|
||||
{
|
||||
$letters = '';
|
||||
for ($i = 0; $i < 6; ++$i) {
|
||||
$letters .= chr(random_int(65, 90));
|
||||
}
|
||||
|
||||
return $letters;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user