diff --git a/CHANGELOG.md b/CHANGELOG.md index 64916a4..6d6bfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-40](https://github.com/itk-dev/itk-projects/pull/40) + Let a project belong to several areas, picked with a searchable multiselect. * [PR-43](https://github.com/itk-dev/itk-projects/pull/43) Overhaul "Økonomi og tid": add amount applied for, ITK budget, co-financing, funding rate and what the rest consists of, and let each relevant link carry a note. diff --git a/assets/app.js b/assets/app.js index 176a1f8..b4743f4 100644 --- a/assets/app.js +++ b/assets/app.js @@ -180,15 +180,17 @@ function initPartnerSelect() { ); } -// Departments and project types are fixed, admin-managed pools, so a -// — the project form's area, anchoring and type fields and +// the project list's department filter — becomes a searchable chip multiselect // without `create`. Tom Select reads the options and the selection from the // select itself and fires input/change on it, which is what autosave, the // progress bar and the live filter listen for. function initPoolSelect() { document - .querySelectorAll("[data-department-select], [data-type-select]") + .querySelectorAll( + "[data-department-select], [data-area-select], [data-type-select]", + ) .forEach((select) => { if (select.dataset.bound) { return; @@ -221,7 +223,9 @@ function initPoolSelect() { // so turbo:load rebuilds the widget on restore. function teardownPoolSelect() { document - .querySelectorAll("[data-department-select], [data-type-select]") + .querySelectorAll( + "[data-department-select], [data-area-select], [data-type-select]", + ) .forEach((select) => { const tomSelect = select.tomselect; if (!tomSelect) { diff --git a/migrations/Version20260929150846.php b/migrations/Version20260929150846.php new file mode 100644 index 0000000..1fa99a2 --- /dev/null +++ b/migrations/Version20260929150846.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE project_area (project_id BINARY(16) NOT NULL, area_id BINARY(16) NOT NULL, INDEX IDX_EE05F570166D1F9C (project_id), INDEX IDX_EE05F570BD0F409C (area_id), PRIMARY KEY (project_id, area_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE project_area ADD CONSTRAINT FK_EE05F570166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_area ADD CONSTRAINT FK_EE05F570BD0F409C FOREIGN KEY (area_id) REFERENCES area (id) ON DELETE CASCADE'); + // Carry the existing single area over before the column goes. + $this->addSql('INSERT INTO project_area (project_id, area_id) SELECT id, area_id FROM project WHERE area_id IS NOT NULL'); + $this->addSql('ALTER TABLE project DROP FOREIGN KEY `FK_2FB3D0EEBD0F409C`'); + $this->addSql('DROP INDEX IDX_2FB3D0EEBD0F409C ON project'); + $this->addSql('ALTER TABLE project DROP area_id'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project ADD area_id BINARY(16) DEFAULT NULL'); + $this->addSql('ALTER TABLE project ADD CONSTRAINT `FK_2FB3D0EEBD0F409C` FOREIGN KEY (area_id) REFERENCES area (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_2FB3D0EEBD0F409C ON project (area_id)'); + // A project with several areas keeps an arbitrary one of them. + $this->addSql('UPDATE project p JOIN project_area pa ON pa.project_id = p.id SET p.area_id = pa.area_id'); + $this->addSql('ALTER TABLE project_area DROP FOREIGN KEY FK_EE05F570166D1F9C'); + $this->addSql('ALTER TABLE project_area DROP FOREIGN KEY FK_EE05F570BD0F409C'); + $this->addSql('DROP TABLE project_area'); + } +} diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 339df04..a10e38a 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -96,7 +96,7 @@ public function export(Request $request, ProjectRepository $projects, Translator $row->getTitle(), $row->getTopic(), $translate($row->getStatus()), - $row->getArea()?->getName(), + $names($row->getAreas()), $names($row->getTypes()), $names($row->getOrganizationalAnchoring()), $row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'), diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 651b63f..272f0a0 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -144,7 +144,6 @@ public function load(ObjectManager $manager): void $budget = mt_rand(1, 40) * 50000; $project = (new Project()) ->setTitle($title) - ->setArea($areas[array_rand($areas)]) ->setStatus($statuses[array_rand($statuses)]) ->setSummary('Projektet arbejder med '.mb_strtolower($title).' gennem en tværgående indsats med fokus på borgernes hverdag og kommunens strategiske mål.') ->setDescription('Projektet er sat i gang, fordi kommunen har brug for at styrke indsatsen omkring '.mb_strtolower($title).".\n\nDet har ophæng i byrådets vedtagne strategier og i afdelingens handleplaner og gennemføres i samarbejde med relevante fagområder og eksterne partnere.") @@ -177,10 +176,14 @@ public function load(ObjectManager $manager): void } // Roughly a third of the projects span two departments so the dashboard's - // cross-department views have something to show. + // cross-department views have something to show; likewise every fourth + // project sits in two areas. foreach (\array_slice($this->shuffleCopy($departments), 0, 0 === $index % 3 ? 2 : 1) as $department) { $project->addOrganizationalAnchoring($department); } + foreach (\array_slice($this->shuffleCopy($areas), 0, 0 === $index % 4 ? 2 : 1) as $area) { + $project->addArea($area); + } $start = new \DateTimeImmutable(sprintf('2025-%02d-01', mt_rand(1, 12))); $project->setTimePeriodStart($start); diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 7002c1e..8324d80 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -22,15 +22,16 @@ class Project extends AbstractEntity * Fields that count toward {@see getCompletionPercentage()} and the client-side * progress bar. Limited to the project's own columns so list rendering stays * query-free; the booleans and the free-tagging lists are intentionally excluded. - * Types and departments are the two collections counted: both are fixed, - * admin-managed pools, and what a project is and where it is anchored are - * core to describing it. Of the economy fields, co-financing is a boolean - * and the remaining-funding text is explicitly optional, so neither counts. + * Areas, types and departments are the three collections counted: all are + * fixed, admin-managed pools, and what a project is about, what kind of thing + * it is and where it is anchored are core to describing it. Of the economy + * fields, co-financing is a boolean and the remaining-funding text is + * explicitly optional, so neither counts. * * @var list */ public const array COMPLETION_FIELDS = [ - 'title', 'area', 'summary', 'description', 'types', 'status', + 'title', 'areas', 'summary', 'description', 'types', 'status', 'organizationalAnchoring', 'amountApplied', 'budget', 'budgetItk', 'fundingRate', 'funding', 'timePeriodStart', 'timePeriodEnd', @@ -48,9 +49,15 @@ class Project extends AbstractEntity #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $topic = null; - #[ORM\ManyToOne(targetEntity: Area::class)] - #[ORM\JoinColumn(onDelete: 'SET NULL')] - private ?Area $area = null; + /** + * The thematic areas a project belongs to. Like departments, an admin-managed + * pool a project can sit in several of. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Area::class)] + #[ORM\JoinTable(name: 'project_area')] + private Collection $areas; /** A few lines summing up the project's purpose, content, strategy and plans. */ #[ORM\Column(type: Types::TEXT, nullable: true)] @@ -163,6 +170,7 @@ class Project extends AbstractEntity public function __construct() { parent::__construct(); + $this->areas = new ArrayCollection(); $this->organizationalAnchoring = new ArrayCollection(); $this->types = new ArrayCollection(); $this->contacts = new ArrayCollection(); @@ -194,14 +202,35 @@ public function setTopic(?string $topic): static return $this; } - public function getArea(): ?Area + /** @return Collection */ + public function getAreas(): Collection { - return $this->area; + return $this->areas; } - public function setArea(?Area $area): static + public function addArea(Area $area): static { - $this->area = $area; + if (!$this->areas->contains($area)) { + $this->areas->add($area); + } + + return $this; + } + + public function removeArea(Area $area): static + { + $this->areas->removeElement($area); + + return $this; + } + + /** @param iterable $areas */ + public function setAreas(iterable $areas): static + { + $this->areas->clear(); + foreach ($areas as $area) { + $this->addArea($area); + } return $this; } @@ -567,14 +596,14 @@ public function setLinks(array $links): static /** * Share of {@see COMPLETION_FIELDS} that are filled in, as a 0–100 percentage. - * Reads own columns plus the type and department collections (one lazy + * Reads own columns plus the area, type and department collections (one lazy * load each), so it is cheap enough to call per row in a listing. */ public function getCompletionPercentage(): int { $checks = [ null !== $this->title && '' !== $this->title, - null !== $this->area, + !$this->areas->isEmpty(), null !== $this->summary && '' !== $this->summary, null !== $this->description && '' !== $this->description, !$this->types->isEmpty(), diff --git a/src/Form/ProjectType.php b/src/Form/ProjectType.php index 9721fa9..d5cbf31 100644 --- a/src/Form/ProjectType.php +++ b/src/Form/ProjectType.php @@ -45,12 +45,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => ['rows' => 4], 'help' => 'project.topic_help', ]) - ->add('area', EntityType::class, [ + ->add('areas', EntityType::class, [ 'label' => 'project.area', 'class' => Area::class, 'choice_label' => 'name', + 'multiple' => true, 'required' => false, - 'placeholder' => 'form.choose', + // Areas are admin-managed like departments: a searchable multiselect + // (Tom Select, see app.js) without on-the-fly creation. + 'attr' => ['data-area-select' => true, 'placeholder' => 'form.choose'], 'help' => 'project.area_help', ]) ->add('summary', TextareaType::class, [ diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 93377bf..29a5679 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -56,7 +56,7 @@ public function search(ProjectFilter $filter): QueryBuilder // Department, area and type are related entities searched by // their stored name ("nik" should find "Teknik og Miljø"). sprintf('i.id IN (SELECT idep.id FROM %s idep JOIN idep.organizationalAnchoring dep WHERE LOWER(dep.name) LIKE :q)', Project::class), - sprintf('i.id IN (SELECT iare.id FROM %s iare JOIN iare.area ar WHERE LOWER(ar.name) LIKE :q)', Project::class), + sprintf('i.id IN (SELECT iare.id FROM %s iare JOIN iare.areas ar WHERE LOWER(ar.name) LIKE :q)', Project::class), sprintf('i.id IN (SELECT ityp.id FROM %s ityp JOIN ityp.types ty WHERE LOWER(ty.name) LIKE :q)', Project::class), ]; @@ -90,7 +90,7 @@ public function search(ProjectFilter $filter): QueryBuilder } if (null !== $filter->area) { - $qb->andWhere('i.area = :area')->setParameter('area', $filter->area->getId(), 'ulid'); + $qb->andWhere(':area MEMBER OF i.areas')->setParameter('area', $filter->area->getId(), 'ulid'); } if (null !== $filter->type) { @@ -156,7 +156,7 @@ public function findForExport(ProjectFilter $filter): array return []; } - foreach (['types', 'organizationalAnchoring', 'tags', 'contacts', 'partners'] as $association) { + foreach (['areas', 'types', 'organizationalAnchoring', 'tags', 'contacts', 'partners'] as $association) { $this->createQueryBuilder('i') ->addSelect('rel') ->leftJoin('i.'.$association, 'rel') @@ -272,45 +272,55 @@ public function findRecent(int $limit = 5): array /** * Lightweight per-project rows for the dashboard numbers: just the * columns the aggregates need, so it stays cheap to recompute on every live - * broadcast. `organizationalAnchoring` is the list of department ids (as - * strings); the other keys are plain columns. + * broadcast. `areas` and `organizationalAnchoring` are lists of area and + * department ids (as strings); the other keys are plain columns. * * @return list> */ public function dashboardRows(): array { - // Join and select the related ids (rather than IDENTITY()) so Doctrine - // applies the ULID type: IDENTITY() returns the raw binary FK, which would - // not match the canonical ULID strings the rest of build() keys on. $rows = $this->createQueryBuilder('i') - ->select( - 'i.id', - 'ar.id AS area', - 'i.status', - 'i.budget', - ) - ->leftJoin('i.area', 'ar') + ->select('i.id', 'i.status', 'i.budget') ->getQuery() ->getArrayResult(); - // Departments are many-to-many, so they are fetched separately and folded - // in: joining them above would repeat every project once per department. - $departmentsByProject = []; + // Areas and departments are many-to-many, so each is fetched separately + // and folded in: joining them above would repeat every project once per + // area times department. + $areasByProject = $this->relatedIdsByProject('areas'); + $departmentsByProject = $this->relatedIdsByProject('organizationalAnchoring'); + + foreach ($rows as &$row) { + $id = (string) $row['id']; + $row['areas'] = $areasByProject[$id] ?? []; + $row['organizationalAnchoring'] = $departmentsByProject[$id] ?? []; + unset($row['id']); + } + + return $rows; + } + + /** + * The ids of a to-many association, as strings, keyed by project id. + * + * @return array> + */ + private function relatedIdsByProject(string $association): array + { + // Join and select the related id (rather than IDENTITY()) so Doctrine + // applies the ULID type: IDENTITY() returns the raw binary FK, which would + // not match the canonical ULID strings DashboardData keys on. $pairs = $this->createQueryBuilder('i') - ->select('i.id AS project', 'd.id AS department') - ->join('i.organizationalAnchoring', 'd') + ->select('i.id AS project', 'rel.id AS related') + ->join('i.'.$association, 'rel') ->getQuery() ->getArrayResult(); + $byProject = []; foreach ($pairs as $pair) { - $departmentsByProject[(string) $pair['project']][] = (string) $pair['department']; + $byProject[(string) $pair['project']][] = (string) $pair['related']; } - foreach ($rows as &$row) { - $row['organizationalAnchoring'] = $departmentsByProject[(string) $row['id']] ?? []; - unset($row['id']); - } - - return $rows; + return $byProject; } } diff --git a/src/Service/DashboardData.php b/src/Service/DashboardData.php index c4ee3e1..1557cd4 100644 --- a/src/Service/DashboardData.php +++ b/src/Service/DashboardData.php @@ -53,10 +53,18 @@ public function build(): array foreach ($this->projects->dashboardRows() as $row) { ++$total; - $area = $this->enumValue($row['area'] ?? null); $status = $this->enumValue($row['status'] ?? null); $si = null !== $status ? ($statusIndex[$status] ?? null) : null; + // A project belongs to any number of areas, keyed by id. + $areas = []; + foreach (($row['areas'] ?? []) as $areaId) { + $area = $this->enumValue($areaId); + if (null !== $area) { + $areas[$area] = true; + } + } + // A project is anchored in any number of departments; keep only the // ids that resolve to a known department, as department indexes. $depts = []; @@ -73,10 +81,11 @@ public function build(): array // Every per-department aggregate counts a shared project once under // each of its departments: the budget shows what each department is - // involved in, so the bars sum to more than the plain total. + // involved in, so the bars sum to more than the plain total. Each of + // the project's areas is marked as worked on in each department. foreach ($depts as $di) { $deptsSeen[$di] = true; - if (null !== $area) { + foreach (array_keys($areas) as $area) { $deptsByArea[$area][$di] = true; } if (null !== ($row['budget'] ?? null)) { diff --git a/templates/project/_form.html.twig b/templates/project/_form.html.twig index f0641fa..4fdecac 100644 --- a/templates/project/_form.html.twig +++ b/templates/project/_form.html.twig @@ -48,7 +48,7 @@ {{ form_row(form.title, {attr: title_attr}) }} {{ form_row(form.topic) }}
- {{ form_row(form.area) }} + {{ form_row(form.areas) }} {{ form_row(form.types) }}
{{ form_row(form.summary) }} diff --git a/templates/project/show.html.twig b/templates/project/show.html.twig index a1ba9b6..2ded841 100644 --- a/templates/project/show.html.twig +++ b/templates/project/show.html.twig @@ -109,7 +109,7 @@
{{ 'project.area'|trans }}
-
{{ project.area ? project.area.name : '—' }}
+
{{ h.term_list(project.areas) }}
{{ 'project.types'|trans }}
{{ h.term_list(project.types) }}
diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php index 4a3e3ac..1291917 100644 --- a/tests/Controller/ProjectControllerTest.php +++ b/tests/Controller/ProjectControllerTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Controller; +use App\Entity\Area; use App\Entity\Contact; use App\Entity\Department; use App\Entity\Project; @@ -269,6 +270,64 @@ public function testDepartmentsAreSavedShownFilteredAndExported(): void $em->flush(); } + public function testAreasAreSavedShownFilteredAndExported(): void + { + $this->loginAsAdmin(); + $em = $this->entityManager(); + $first = (new Area())->setName('Area first '.uniqid()); + $second = (new Area())->setName('Area second '.uniqid()); + $em->persist($first); + $em->persist($second); + $em->flush(); + $elsewhere = $this->createProject('Project elsewhere '.uniqid()); + $title = 'Two-area project '.uniqid(); + + $crawler = $this->client->request('GET', '/projects/new'); + self::assertCount(1, $crawler->filter('select[name="project[areas][]"][multiple]')); + $token = (string) $crawler->filter('input[name="project[_token]"]')->attr('value'); + $this->client->request('POST', '/projects/new', [ + 'project' => [ + 'title' => $title, + 'areas' => [(string) $first->getId(), (string) $second->getId()], + '_token' => $token, + ], + ]); + $this->assertResponseRedirects(); + + $project = $this->projects()->findOneBy(['title' => $title]); + self::assertInstanceOf(Project::class, $project); + self::assertCount(2, $project->getAreas()); + $id = (string) $project->getId(); + + // Both areas are listed on the project page … + $crawler = $this->client->request('GET', '/projects/'.$id); + $details = $crawler->filter('.detail-list')->text(); + self::assertStringContainsString((string) $first->getName(), $details); + self::assertStringContainsString((string) $second->getName(), $details); + + // … the area filter narrows the list to projects in that area … + $crawler = $this->client->request('GET', '/projects?area='.$first->getId()); + $results = $crawler->filter('#project-results')->text(); + self::assertStringContainsString($title, $results); + self::assertStringNotContainsString((string) $elsewhere->getTitle(), $results); + + // … and the export lists every area of the project. + $this->client->request('GET', '/projects/export?area='.$second->getId()); + $csv = (string) $this->client->getInternalResponse()->getContent(); + self::assertStringContainsString($first->getName().', '.$second->getName(), $csv); + + $this->removeProject($id); + $this->removeProject((string) $elsewhere->getId()); + $em = $this->entityManager(); + foreach ([$first->getId(), $second->getId()] as $areaId) { + $area = $this->areas()->find($areaId); + if (null !== $area) { + $em->remove($area); + } + } + $em->flush(); + } + public function testTypesAreSavedShownFilteredAndExported(): void { $this->loginAsAdmin(); diff --git a/tests/Repository/ProjectRepositoryTest.php b/tests/Repository/ProjectRepositoryTest.php index d49270f..6ab9027 100644 --- a/tests/Repository/ProjectRepositoryTest.php +++ b/tests/Repository/ProjectRepositoryTest.php @@ -63,7 +63,7 @@ public function testSearchByDepartmentAreaOrTypeMatchesOnlyProjectsWithThem(): v ->setTitle('Anchored '.uniqid()) ->addOrganizationalAnchoring($department) ->addType($type) - ->setArea($area); + ->addArea($area); $loose = (new Project())->setTitle('Loose '.uniqid()); foreach ([$department, $area, $type, $anchored, $loose] as $entity) { $em->persist($entity); @@ -123,6 +123,46 @@ public function testSearchBySeveralDepartmentsMatchesProjectsAnchoredInAnyOfThem $em->flush(); } + public function testDashboardRowsListAProjectOnceWithAllItsAreasAndDepartments(): void + { + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $areas = [(new Area())->setName('Row area A '.uniqid()), (new Area())->setName('Row area B '.uniqid())]; + $departments = [(new Department())->setName('Row dept A '.uniqid()), (new Department())->setName('Row dept B '.uniqid())]; + $project = (new Project())->setTitle('Dashboard rows '.uniqid()); + foreach ($areas as $area) { + $project->addArea($area); + } + foreach ($departments as $department) { + $project->addOrganizationalAnchoring($department); + } + foreach ([...$areas, ...$departments, $project] as $entity) { + $em->persist($entity); + } + $em->flush(); + + $areaIds = array_map(static fn (Area $area): string => (string) $area->getId(), $areas); + $departmentIds = array_map(static fn (Department $department): string => (string) $department->getId(), $departments); + + // The rows carry no project id, but these areas are unique to this project, + // so exactly one row lists both — and it must list both departments too, + // rather than being repeated once per area × department. + $rows = $this->repository->dashboardRows(); + self::assertCount($this->repository->countAll(), $rows); + $matching = array_values(array_filter( + $rows, + static fn (array $row): bool => [] === array_diff($areaIds, $row['areas']), + )); + self::assertCount(1, $matching); + self::assertSame([], array_diff($departmentIds, $matching[0]['organizationalAnchoring'])); + + foreach ([$project, ...$areas, ...$departments] as $entity) { + $em->remove($entity); + } + $em->flush(); + } + /** @return list */ private function titles(ProjectFilter $filter): array { diff --git a/tests/Service/DashboardDataTest.php b/tests/Service/DashboardDataTest.php index 0c9f854..6895ac7 100644 --- a/tests/Service/DashboardDataTest.php +++ b/tests/Service/DashboardDataTest.php @@ -42,8 +42,9 @@ public function testBuildFromControlledRows(): void { $deptA = (new Department())->setName('A'); $deptB = (new Department())->setName('B'); - $shared = (new Area())->setName('Shared'); - $solo = (new Area())->setName('Solo'); + $shared = (string) (new Area())->setName('Shared')->getId(); + $solo = (string) (new Area())->setName('Solo')->getId(); + $third = (string) (new Area())->setName('Third')->getId(); $departments = $this->createStub(DepartmentRepository::class); $departments->method('findAllOrdered')->willReturn([$deptA, $deptB]); @@ -53,16 +54,19 @@ public function testBuildFromControlledRows(): void $projects = $this->createStub(ProjectRepository::class); $projects->method('dashboardRows')->willReturn([ // "Shared" worked on in two departments -> counts as a collaboration. - ['area' => (string) $shared->getId(), 'status' => Status::Granted, 'organizationalAnchoring' => [$a], 'budget' => 1000], - ['area' => (string) $shared->getId(), 'status' => Status::Granted, 'organizationalAnchoring' => [$b], 'budget' => 2000], + ['areas' => [$shared], 'status' => Status::Granted, 'organizationalAnchoring' => [$a], 'budget' => 1000], + ['areas' => [$shared], 'status' => Status::Granted, 'organizationalAnchoring' => [$b], 'budget' => 2000], // "Solo" only in one department, and without a budget. - ['area' => (string) $solo->getId(), 'status' => Status::Granted, 'organizationalAnchoring' => [$a], 'budget' => null], + ['areas' => [$solo], 'status' => Status::Granted, 'organizationalAnchoring' => [$a], 'budget' => null], // Anchored in both departments (plus an id no department has, which is // dropped) but without an area: its budget counts under each department, // and it is no collaboration on its own since it has no area. - ['area' => null, 'status' => Status::Granted, 'organizationalAnchoring' => [$a, $b, 'unknown'], 'budget' => 500], + ['areas' => [], 'status' => Status::Granted, 'organizationalAnchoring' => [$a, $b, 'unknown'], 'budget' => 500], + // Two areas in one department: each area is marked as worked on in B, + // which puts "Solo" in both departments too, while "Third" stays in one. + ['areas' => [$solo, $third], 'status' => Status::Granted, 'organizationalAnchoring' => [$b], 'budget' => null], // No area/department/status -> exercises the null guards. - ['area' => null, 'status' => null, 'organizationalAnchoring' => [], 'budget' => null], + ['areas' => [], 'status' => null, 'organizationalAnchoring' => [], 'budget' => null], ]); $translator = $this->createStub(TranslatorInterface::class); @@ -70,16 +74,16 @@ public function testBuildFromControlledRows(): void $data = (new DashboardData($projects, $departments, $translator))->build(); - self::assertSame(5, $data['kpis']['total']); - self::assertSame(4, $data['kpis']['inProgress']); + self::assertSame(6, $data['kpis']['total']); + self::assertSame(5, $data['kpis']['inProgress']); self::assertSame(2, $data['kpis']['departments']); self::assertSame(2, $data['kpis']['departmentsTotal']); - // Only "Shared" spans >= 2 departments; "Solo" does not count. - self::assertSame(1, $data['kpis']['collaboration']); + // "Shared" and "Solo" span >= 2 departments; "Third" does not count. + self::assertSame(2, $data['kpis']['collaboration']); self::assertSame(['A', 'B'], array_column($data['departments'], 'label')); // A shared project's budget counts in full under each of its departments. self::assertSame([1500, 2500], $data['budgetByDept']); - self::assertSame(4, array_sum($data['statusDistribution'])); + self::assertSame(5, array_sum($data['statusDistribution'])); // The stubbed translator echoes the key, so labels are the enum label keys. self::assertContains(['key' => Status::Granted->value, 'label' => Status::Granted->labelKey()], $data['statuses']); } diff --git a/tests/Unit/Entity/ProjectTest.php b/tests/Unit/Entity/ProjectTest.php index ebf08b1..93c8f8d 100644 --- a/tests/Unit/Entity/ProjectTest.php +++ b/tests/Unit/Entity/ProjectTest.php @@ -34,6 +34,7 @@ public function testDefaults(): void self::assertNull($project->getRemainingFunding()); self::assertSame([], $project->getFunding()); self::assertSame([], $project->getLinks()); + self::assertCount(0, $project->getAreas()); self::assertCount(0, $project->getOrganizationalAnchoring()); self::assertCount(0, $project->getTypes()); self::assertCount(0, $project->getTags()); @@ -48,12 +49,10 @@ public function testScalarAccessors(): void { $start = new \DateTimeImmutable('2025-01-01'); $end = new \DateTimeImmutable('2025-12-31'); - $area = (new Area())->setName('Klima og miljø'); $project = (new Project()) ->setTitle('Grøn omstilling') ->setTopic('Digital Europe Blueprint for Data Space') - ->setArea($area) ->setSummary('Opsummering') ->setDescription('Beskrivelse') ->setStatus(Status::Granted) @@ -71,7 +70,6 @@ public function testScalarAccessors(): void self::assertSame('Grøn omstilling', $project->getTitle()); self::assertSame('Digital Europe Blueprint for Data Space', $project->getTopic()); - self::assertSame($area, $project->getArea()); self::assertSame('Opsummering', $project->getSummary()); self::assertSame('Beskrivelse', $project->getDescription()); self::assertSame(Status::Granted, $project->getStatus()); @@ -95,7 +93,7 @@ public function testCompletionPercentage(): void $full = (new Project()) ->setTitle('T') - ->setArea((new Area())->setName('Klima og miljø')) + ->addArea((new Area())->setName('Klima og miljø')) ->setSummary('S') ->setDescription('D') ->addType((new ProjectType())->setName('Projekt')) @@ -180,6 +178,27 @@ public function testOrganizationalAnchoringCollection(): void self::assertCount(0, $project->getOrganizationalAnchoring()); } + public function testAreaCollection(): void + { + $project = new Project(); + $area = (new Area())->setName('Klima og miljø'); + + $project->addArea($area); + $project->addArea($area); + self::assertCount(1, $project->getAreas()); + + $project->removeArea($area); + self::assertCount(0, $project->getAreas()); + + $project->setAreas([ + (new Area())->setName('Mobilitet'), + (new Area())->setName('Velfærd'), + ]); + self::assertCount(2, $project->getAreas()); + $project->setAreas([]); + self::assertCount(0, $project->getAreas()); + } + public function testTypeCollection(): void { $project = new Project(); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index bc7982a..709013d 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -170,7 +170,7 @@ project: topic: Emne topic_help: Det overordnede program, projektet er en del af. area: Område - area_help: Det tematiske område projektet hører under. + area_help: Vælg et eller flere tematiske områder. summary: Opsummering summary_help: Kort opsummering af formål, indhold, strategi og planer. description: Beskrivelse @@ -182,7 +182,7 @@ project: status_additional: Status – supplerende tekst status_additional_help: Uddyb status, hvis der er behov. organizational_anchoring: Afdeling - organizational_anchoring_help: De afdelinger projektet er forankret i. Vælg flere, hvis projektet går på tværs. + organizational_anchoring_help: Vælg en eller flere afdelinger. endorsement: Er projektet vedtaget endorsement_author: Hvor er det vedtaget endorsement_author_help: Hvor projektet er vedtaget. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 3b0b556..c5e843e 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -170,7 +170,7 @@ project: topic: Topic topic_help: The wider programme this project is part of. area: Area - area_help: The thematic area the project belongs to. + area_help: Pick one or more thematic areas. summary: Summary summary_help: A brief summary of the purpose, content, strategy and plans. description: Description @@ -182,7 +182,7 @@ project: status_additional: Status – additional text status_additional_help: Add detail to the status if needed. organizational_anchoring: Department - organizational_anchoring_help: The departments the project is anchored in. Pick several for a cross-cutting project. + organizational_anchoring_help: Pick one or more departments. endorsement: Has the project been endorsed endorsement_author: Where it was endorsed endorsement_author_help: Where the project was endorsed.