From a8a054b99936cca2a8418261c4d43dffb7db4cc5 Mon Sep 17 00:00:00 2001 From: jekuaitk Date: Wed, 30 Sep 2026 09:17:31 +0200 Subject: [PATCH 1/2] 8382: Give project character an overhaul --- CHANGELOG.md | 3 + assets/app.js | 31 ++--- migrations/Version20260930065548.php | 38 ++++++ .../Admin/ProjectCharacterController.php | 78 ++++++++++++ src/Controller/ProjectController.php | 4 +- src/DataFixtures/AppFixtures.php | 18 ++- src/Entity/Project.php | 55 ++++++-- src/Entity/ProjectCharacter.php | 41 ++++++ src/Enum/ProjectType.php | 23 ---- src/Form/ProjectCharacterType.php | 32 +++++ src/Form/ProjectFilterType.php | 10 +- src/Form/ProjectType.php | 17 +-- src/Model/ProjectFilter.php | 4 +- src/Repository/ProjectCharacterRepository.php | 31 +++++ src/Repository/ProjectRepository.php | 16 +-- templates/admin/base.html.twig | 1 + .../admin/project_characters/_form.html.twig | 10 ++ .../admin/project_characters/edit.html.twig | 16 +++ .../admin/project_characters/index.html.twig | 43 +++++++ .../admin/project_characters/new.html.twig | 12 ++ templates/project/_form.html.twig | 2 +- templates/project/_results.html.twig | 4 +- templates/project/index.html.twig | 2 +- templates/project/show.html.twig | 4 +- .../Admin/ProjectCharacterControllerTest.php | 117 ++++++++++++++++++ tests/Controller/ProjectControllerTest.php | 60 +++++++++ tests/Controller/SmokeTest.php | 2 + tests/FunctionalTestCase.php | 9 ++ tests/Repository/AreaRepositoryTest.php | 2 +- tests/Repository/DepartmentRepositoryTest.php | 2 +- .../ProjectCharacterRepositoryTest.php | 25 ++++ tests/Repository/ProjectRepositoryTest.php | 30 +++-- tests/Unit/Entity/ProjectCharacterTest.php | 27 ++++ tests/Unit/Entity/ProjectTest.php | 28 ++++- tests/Unit/Enum/TranslatableEnumTest.php | 2 - tests/Unit/Model/ProjectFilterTest.php | 6 +- translations/messages.da.yaml | 30 +++-- translations/messages.en.yaml | 30 +++-- 38 files changed, 745 insertions(+), 120 deletions(-) create mode 100644 migrations/Version20260930065548.php create mode 100644 src/Controller/Admin/ProjectCharacterController.php create mode 100644 src/Entity/ProjectCharacter.php delete mode 100644 src/Enum/ProjectType.php create mode 100644 src/Form/ProjectCharacterType.php create mode 100644 src/Repository/ProjectCharacterRepository.php create mode 100644 templates/admin/project_characters/_form.html.twig create mode 100644 templates/admin/project_characters/edit.html.twig create mode 100644 templates/admin/project_characters/index.html.twig create mode 100644 templates/admin/project_characters/new.html.twig create mode 100644 tests/Controller/Admin/ProjectCharacterControllerTest.php create mode 100644 tests/Repository/ProjectCharacterRepositoryTest.php create mode 100644 tests/Unit/Entity/ProjectCharacterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d397b7c..997365b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-41](https://github.com/itk-dev/itk-projects/pull/41) + Let a project have several characters, picked from an admin-managed pool + instead of a fixed list. * [PR-33](https://github.com/itk-dev/itk-projects/pull/33) Let a project be anchored in several departments, picked with a searchable multiselect. diff --git a/assets/app.js b/assets/app.js index 4b9e80e..75b3b39 100644 --- a/assets/app.js +++ b/assets/app.js @@ -180,23 +180,26 @@ function initPartnerSelect() { ); } -// Departments are a fixed, admin-managed pool, so the project form's -// 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 and the progress bar listen for. -function initDepartmentSelect() { - document.querySelectorAll("[data-department-select]").forEach((select) => { - if (select.dataset.bound) { - return; - } - select.dataset.bound = "1"; +function initPoolSelect() { + document + .querySelectorAll("[data-department-select], [data-character-select]") + .forEach((select) => { + if (select.dataset.bound) { + return; + } + select.dataset.bound = "1"; - new TomSelect(select, { - plugins: ["remove_button"], - hideSelected: true, - maxOptions: null, + new TomSelect(select, { + plugins: ["remove_button"], + hideSelected: true, + maxOptions: null, + }); }); - }); } // One delegated handler on the document (which survives Turbo navigations and @@ -225,7 +228,7 @@ document.addEventListener("turbo:load", () => { initCollections(); initContactSelect(); initPartnerSelect(); - initDepartmentSelect(); + initPoolSelect(); initTermSelect(); }); diff --git a/migrations/Version20260930065548.php b/migrations/Version20260930065548.php new file mode 100644 index 0000000..d2adbfb --- /dev/null +++ b/migrations/Version20260930065548.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE project_project_character (project_id BINARY(16) NOT NULL, project_character_id BINARY(16) NOT NULL, INDEX IDX_B695A9DC166D1F9C (project_id), INDEX IDX_B695A9DC3B915A16 (project_character_id), PRIMARY KEY (project_id, project_character_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE project_character (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, name VARCHAR(255) NOT NULL, created_by_id BINARY(16) DEFAULT NULL, modified_by_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_8246F5E35E237E06 (name), INDEX IDX_8246F5E3B03A8386 (created_by_id), INDEX IDX_8246F5E399049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE project_project_character ADD CONSTRAINT FK_B695A9DC166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_project_character ADD CONSTRAINT FK_B695A9DC3B915A16 FOREIGN KEY (project_character_id) REFERENCES project_character (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_character ADD CONSTRAINT FK_8246F5E3B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE project_character ADD CONSTRAINT FK_8246F5E399049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE project DROP project_type'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_project_character DROP FOREIGN KEY FK_B695A9DC166D1F9C'); + $this->addSql('ALTER TABLE project_project_character DROP FOREIGN KEY FK_B695A9DC3B915A16'); + $this->addSql('ALTER TABLE project_character DROP FOREIGN KEY FK_8246F5E3B03A8386'); + $this->addSql('ALTER TABLE project_character DROP FOREIGN KEY FK_8246F5E399049ECE'); + $this->addSql('DROP TABLE project_project_character'); + $this->addSql('DROP TABLE project_character'); + $this->addSql('ALTER TABLE project ADD project_type VARCHAR(32) DEFAULT NULL'); + } +} diff --git a/src/Controller/Admin/ProjectCharacterController.php b/src/Controller/Admin/ProjectCharacterController.php new file mode 100644 index 0000000..199e691 --- /dev/null +++ b/src/Controller/Admin/ProjectCharacterController.php @@ -0,0 +1,78 @@ +render('admin/project_characters/index.html.twig', [ + 'characters' => $characters->findAllOrdered(), + ]); + } + + #[Route('/new', name: 'admin_project_character_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $character = new ProjectCharacter(); + $form = $this->createForm(ProjectCharacterType::class, $character); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->persist($character); + $entityManager->flush(); + $this->addFlash('success', 'flash.project_character.created'); + + return $this->redirectToRoute('admin_project_characters'); + } + + return $this->render('admin/project_characters/new.html.twig', ['form' => $form]); + } + + #[Route('/{id}/edit', name: 'admin_project_character_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])] + public function edit(Request $request, ProjectCharacter $character, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(ProjectCharacterType::class, $character); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + $this->addFlash('success', 'flash.project_character.updated'); + + return $this->redirectToRoute('admin_project_characters'); + } + + return $this->render('admin/project_characters/edit.html.twig', [ + 'form' => $form, + 'character' => $character, + ]); + } + + #[Route('/{id}/delete', name: 'admin_project_character_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])] + public function delete(Request $request, ProjectCharacter $character, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-project-character-'.$character->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($character); + $entityManager->flush(); + $this->addFlash('success', 'flash.project_character.deleted'); + } + + return $this->redirectToRoute('admin_project_characters'); + } +} diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 1d5e1db..1245d1c 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -69,7 +69,7 @@ public function export(Request $request, ProjectRepository $projects, Translator $translator->trans('project.topic'), $translator->trans('project.status'), $translator->trans('project.area'), - $translator->trans('project.project_type'), + $translator->trans('project.characters'), $translator->trans('project.organizational_anchoring'), $translator->trans('project.endorsement'), $translator->trans('project.endorsement_author'), @@ -93,7 +93,7 @@ public function export(Request $request, ProjectRepository $projects, Translator $row->getTopic(), $translate($row->getStatus()), $row->getArea()?->getName(), - $translate($row->getProjectType()), + $names($row->getCharacters()), $names($row->getOrganizationalAnchoring()), $row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'), $translate($row->getEndorsementAuthor()), diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index f982053..98bd826 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -9,11 +9,11 @@ use App\Entity\Department; use App\Entity\Partner; use App\Entity\Project; +use App\Entity\ProjectCharacter; use App\Entity\Term; use App\Entity\User; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\Vocabulary; use Doctrine\Bundle\FixturesBundle\Fixture; @@ -27,6 +27,7 @@ class AppFixtures extends Fixture private const array DEPARTMENTS = ['ITK Development', 'CFIA', 'Aarhus CityLab', 'Stab', 'OS2', 'AI Lab', 'IOT Lab', 'GTM', 'Fut Lab']; private const array PARTNERS = ['Aarhus Universitet', 'VIA University College', 'Alexandra Instituttet', 'Teknologisk Institut', 'Region Midtjylland', 'Erhverv Aarhus', 'Danmarks Tekniske Universitet', 'Aarhus Vand', 'AffaldVarme Aarhus', 'Dansk Industri']; private const array AREAS = ['Klima og miljø', 'Mobilitet', 'Velfærd', 'Kultur og fritid', 'Uddannelse', 'Erhverv', 'Digitalisering', 'Byudvikling']; + private const array CHARACTERS = ['Projekt', 'Program', 'Politik', 'Pilot', 'Drift']; private const array TOPICS = [ 'Digital Europe Blueprint for Data Space for smart and sustainable cities and communities.', 'Horizon Europe — Climate-neutral and smart cities mission.', @@ -75,6 +76,13 @@ public function load(ObjectManager $manager): void $areas[] = $area; } + $characters = []; + foreach (self::CHARACTERS as $name) { + $character = (new ProjectCharacter())->setName($name); + $manager->persist($character); + $characters[] = $character; + } + $partners = []; foreach (self::PARTNERS as $name) { $partner = (new Partner()) @@ -129,7 +137,6 @@ public function load(ObjectManager $manager): void ]; $statuses = Status::cases(); - $types = ProjectType::cases(); $endorsers = EndorsementAuthor::cases(); $fundings = Funding::cases(); @@ -137,7 +144,6 @@ public function load(ObjectManager $manager): void $project = (new Project()) ->setTitle($title) ->setArea($areas[array_rand($areas)]) - ->setProjectType($types[array_rand($types)]) ->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.") @@ -156,6 +162,12 @@ public function load(ObjectManager $manager): void $project->setFunding(\array_slice($this->shuffleCopy($fundings), 0, mt_rand(1, 3))); + // Most projects are one thing; every fourth is two, e.g. a pilot that is + // also operations, so the multiselect has something to show. + foreach (\array_slice($this->shuffleCopy($characters), 0, 0 === $index % 4 ? 2 : 1) as $character) { + $project->addCharacter($character); + } + // Roughly a third of the projects span two departments so the dashboard's // cross-department views have something to show. foreach (\array_slice($this->shuffleCopy($departments), 0, 0 === $index % 3 ? 2 : 1) as $department) { diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 73476b4..7f36927 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -6,7 +6,6 @@ use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Repository\ProjectRepository; use Doctrine\Common\Collections\ArrayCollection; @@ -22,13 +21,14 @@ 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. - * Departments are the one collection counted: they are a fixed, admin-managed - * pool and the anchoring is core to what a project is. + * Characters 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. * * @var list */ public const array COMPLETION_FIELDS = [ - 'title', 'area', 'summary', 'description', 'projectType', 'status', + 'title', 'area', 'summary', 'description', 'characters', 'status', 'organizationalAnchoring', 'budget', 'funding', 'timePeriodStart', 'timePeriodEnd', ]; @@ -62,8 +62,15 @@ class Project extends AbstractEntity #[ORM\JoinTable(name: 'project_strategy')] private Collection $strategies; - #[ORM\Column(length: 32, nullable: true, enumType: ProjectType::class)] - private ?ProjectType $projectType = null; + /** + * What kind of thing the project is ("Projekt", "Drift", …). A project can be + * several at once, e.g. a pilot that is also operations. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: ProjectCharacter::class)] + #[ORM\JoinTable(name: 'project_project_character')] + private Collection $characters; #[ORM\Column(length: 32, nullable: true, enumType: Status::class)] private ?Status $status = null; @@ -133,6 +140,7 @@ public function __construct() { parent::__construct(); $this->organizationalAnchoring = new ArrayCollection(); + $this->characters = new ArrayCollection(); $this->strategies = new ArrayCollection(); $this->contacts = new ArrayCollection(); $this->partners = new ArrayCollection(); @@ -232,14 +240,35 @@ public function setStrategies(iterable $terms): static return $this; } - public function getProjectType(): ?ProjectType + /** @return Collection */ + public function getCharacters(): Collection { - return $this->projectType; + return $this->characters; } - public function setProjectType(?ProjectType $projectType): static + public function addCharacter(ProjectCharacter $character): static { - $this->projectType = $projectType; + if (!$this->characters->contains($character)) { + $this->characters->add($character); + } + + return $this; + } + + public function removeCharacter(ProjectCharacter $character): static + { + $this->characters->removeElement($character); + + return $this; + } + + /** @param iterable $characters */ + public function setCharacters(iterable $characters): static + { + $this->characters->clear(); + foreach ($characters as $character) { + $this->addCharacter($character); + } return $this; } @@ -485,8 +514,8 @@ static function (string $link): bool { /** * Share of {@see COMPLETION_FIELDS} that are filled in, as a 0–100 percentage. - * Reads own columns plus the department collection (one lazy load), so it is - * cheap enough to call per row in a listing. + * Reads own columns plus the character and department collections (one lazy + * load each), so it is cheap enough to call per row in a listing. */ public function getCompletionPercentage(): int { @@ -495,7 +524,7 @@ public function getCompletionPercentage(): int null !== $this->area, null !== $this->summary && '' !== $this->summary, null !== $this->description && '' !== $this->description, - null !== $this->projectType, + !$this->characters->isEmpty(), null !== $this->status, !$this->organizationalAnchoring->isEmpty(), null !== $this->budget, diff --git a/src/Entity/ProjectCharacter.php b/src/Entity/ProjectCharacter.php new file mode 100644 index 0000000..13f806b --- /dev/null +++ b/src/Entity/ProjectCharacter.php @@ -0,0 +1,41 @@ +name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Enum/ProjectType.php b/src/Enum/ProjectType.php deleted file mode 100644 index 5166b24..0000000 --- a/src/Enum/ProjectType.php +++ /dev/null @@ -1,23 +0,0 @@ -value; - } -} diff --git a/src/Form/ProjectCharacterType.php b/src/Form/ProjectCharacterType.php new file mode 100644 index 0000000..43f1ebc --- /dev/null +++ b/src/Form/ProjectCharacterType.php @@ -0,0 +1,32 @@ + + */ +class ProjectCharacterType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'label' => 'project_character.name', + 'help' => 'project_character.name_help', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ProjectCharacter::class, + ]); + } +} diff --git a/src/Form/ProjectFilterType.php b/src/Form/ProjectFilterType.php index 1b7be1a..03a606c 100644 --- a/src/Form/ProjectFilterType.php +++ b/src/Form/ProjectFilterType.php @@ -6,7 +6,7 @@ use App\Entity\Area; use App\Entity\Department; -use App\Enum\ProjectType as ProjectTypeEnum; +use App\Entity\ProjectCharacter; use App\Enum\Status; use App\Model\ProjectFilter; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -46,12 +46,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'filter.all', ]) - ->add('projectType', EnumType::class, [ - 'label' => 'project.project_type', - 'class' => ProjectTypeEnum::class, + ->add('character', EntityType::class, [ + 'label' => 'project.characters', + 'class' => ProjectCharacter::class, + 'choice_label' => 'name', 'required' => false, 'placeholder' => 'filter.all', - 'choice_label' => static fn (ProjectTypeEnum $value): string => $value->labelKey(), ]) ->add('organizationalAnchoring', EntityType::class, [ 'label' => 'project.organizational_anchoring', diff --git a/src/Form/ProjectType.php b/src/Form/ProjectType.php index 149c2ea..4ee34da 100644 --- a/src/Form/ProjectType.php +++ b/src/Form/ProjectType.php @@ -7,9 +7,9 @@ use App\Entity\Area; use App\Entity\Department; use App\Entity\Project; +use App\Entity\ProjectCharacter; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType as ProjectTypeEnum; use App\Enum\Status; use App\Enum\Vocabulary; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -72,13 +72,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'project.terms_help', ]) - ->add('projectType', EnumType::class, [ - 'label' => 'project.project_type', - 'class' => ProjectTypeEnum::class, + ->add('characters', EntityType::class, [ + 'label' => 'project.characters', + 'class' => ProjectCharacter::class, + 'choice_label' => 'name', + 'multiple' => true, 'required' => false, - 'placeholder' => 'form.choose', - 'choice_label' => static fn (ProjectTypeEnum $value): string => $value->labelKey(), - 'help' => 'project.project_type_help', + // Admin-managed pool, so a searchable multiselect without on-the-fly + // creation, like the department field (Tom Select, see app.js). + 'attr' => ['data-character-select' => true, 'placeholder' => 'form.choose'], + 'help' => 'project.characters_help', ]) ->add('status', EnumType::class, [ 'label' => 'project.status', diff --git a/src/Model/ProjectFilter.php b/src/Model/ProjectFilter.php index 875a593..660cb39 100644 --- a/src/Model/ProjectFilter.php +++ b/src/Model/ProjectFilter.php @@ -6,7 +6,7 @@ use App\Entity\Area; use App\Entity\Department; -use App\Enum\ProjectType; +use App\Entity\ProjectCharacter; use App\Enum\Status; /** @@ -21,7 +21,7 @@ class ProjectFilter public ?Area $area = null; - public ?ProjectType $projectType = null; + public ?ProjectCharacter $character = null; public ?Department $organizationalAnchoring = null; diff --git a/src/Repository/ProjectCharacterRepository.php b/src/Repository/ProjectCharacterRepository.php new file mode 100644 index 0000000..b19e9ff --- /dev/null +++ b/src/Repository/ProjectCharacterRepository.php @@ -0,0 +1,31 @@ + + */ +class ProjectCharacterRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ProjectCharacter::class); + } + + /** + * @return ProjectCharacter[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('c') + ->orderBy('c.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 1181091..f251afc 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -8,7 +8,6 @@ use App\Entity\User; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\TranslatableEnum; use App\Model\ProjectFilter; @@ -53,17 +52,17 @@ public function search(ProjectFilter $filter): QueryBuilder sprintf('i.id IN (SELECT istr.id FROM %s istr JOIN istr.strategies st WHERE LOWER(st.name) LIKE :q)', Project::class), sprintf('i.id IN (SELECT icon.id FROM %s icon JOIN icon.contacts co WHERE LOWER(co.name) LIKE :q)', Project::class), sprintf('i.id IN (SELECT ipar.id FROM %s ipar JOIN ipar.partners pa WHERE LOWER(pa.name) LIKE :q)', Project::class), - // Department and area are related entities searched by their stored - // name ("nik" should find "Teknik og Miljø"). + // Department, area and character 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 ichr.id FROM %s ichr JOIN ichr.characters ch WHERE LOWER(ch.name) LIKE :q)', Project::class), ]; // Enum columns store slugs, but the user searches their translated - // labels ("nik" should find "Teknik og Miljø"); map labels to values. + // labels ("bevilliget" should find granted); map labels to values. $enumFields = [ 'status' => Status::cases(), - 'projectType' => ProjectType::cases(), 'endorsementAuthor' => EndorsementAuthor::cases(), ]; foreach ($enumFields as $field => $cases) { @@ -92,8 +91,9 @@ public function search(ProjectFilter $filter): QueryBuilder $qb->andWhere('i.area = :area')->setParameter('area', $filter->area->getId(), 'ulid'); } - if (null !== $filter->projectType) { - $qb->andWhere('i.projectType = :projectType')->setParameter('projectType', $filter->projectType->value); + if (null !== $filter->character) { + $qb->andWhere(':character MEMBER OF i.characters') + ->setParameter('character', $filter->character->getId(), 'ulid'); } if (null !== $filter->organizationalAnchoring) { @@ -147,7 +147,7 @@ public function findForExport(ProjectFilter $filter): array return []; } - foreach (['organizationalAnchoring', 'strategies', 'tags', 'contacts', 'partners'] as $association) { + foreach (['characters', 'organizationalAnchoring', 'strategies', 'tags', 'contacts', 'partners'] as $association) { $this->createQueryBuilder('i') ->addSelect('rel') ->leftJoin('i.'.$association, 'rel') diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 878155e..2ea5b4d 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -11,6 +11,7 @@ {{ 'nav.partners'|trans }} {{ 'nav.departments'|trans }} {{ 'nav.areas'|trans }} + {{ 'nav.project_characters'|trans }}
{% block admin_content %}{% endblock %} diff --git a/templates/admin/project_characters/_form.html.twig b/templates/admin/project_characters/_form.html.twig new file mode 100644 index 0000000..289ac60 --- /dev/null +++ b/templates/admin/project_characters/_form.html.twig @@ -0,0 +1,10 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.name) }} +
+
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/admin/project_characters/edit.html.twig b/templates/admin/project_characters/edit.html.twig new file mode 100644 index 0000000..0798875 --- /dev/null +++ b/templates/admin/project_characters/edit.html.twig @@ -0,0 +1,16 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'project_character.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + + + {{ include('admin/project_characters/_form.html.twig', {button_label: 'action.save'}) }} +{% endblock %} diff --git a/templates/admin/project_characters/index.html.twig b/templates/admin/project_characters/index.html.twig new file mode 100644 index 0000000..84cbdfe --- /dev/null +++ b/templates/admin/project_characters/index.html.twig @@ -0,0 +1,43 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'project_character.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ 'action.new'|trans }} + + +
+ {% if characters is empty %} + + {% else %} +
+ + + + + + + + + {% for character in characters %} + + + + + {% endfor %} + +
{{ 'project_character.name'|trans }}
{{ character.name }} + +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/project_characters/new.html.twig b/templates/admin/project_characters/new.html.twig new file mode 100644 index 0000000..36970a3 --- /dev/null +++ b/templates/admin/project_characters/new.html.twig @@ -0,0 +1,12 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'project_character.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + + {{ include('admin/project_characters/_form.html.twig', {button_label: 'action.create'}) }} +{% endblock %} diff --git a/templates/project/_form.html.twig b/templates/project/_form.html.twig index 495f8bb..4528ad6 100644 --- a/templates/project/_form.html.twig +++ b/templates/project/_form.html.twig @@ -49,7 +49,7 @@ {{ form_row(form.topic) }}
{{ form_row(form.area) }} - {{ form_row(form.projectType) }} + {{ form_row(form.characters) }}
{{ form_row(form.summary) }} {{ form_row(form.description) }} diff --git a/templates/project/_results.html.twig b/templates/project/_results.html.twig index a54112b..fbc69d8 100644 --- a/templates/project/_results.html.twig +++ b/templates/project/_results.html.twig @@ -27,7 +27,7 @@ {{ h.sortlink('title', 'project.title', sort, direction) }} {{ 'project.status'|trans }} - {{ 'project.project_type'|trans }} + {{ 'project.characters'|trans }} {{ 'project.organizational_anchoring'|trans }} {{ h.sortlink('timePeriodStart', 'project.time_period', sort, direction) }} {{ 'project.author'|trans }} @@ -47,7 +47,7 @@ — {% endif %} - {{ project.projectType ? project.projectType.labelKey|trans : '—' }} + {{ project.characters|length > 0 ? project.characters|map(c => c.name)|join(', ') : '—' }} {{ project.organizationalAnchoring|length > 0 ? project.organizationalAnchoring|map(d => d.name)|join(', ') : '—' }} {% if project.timePeriodStart %} diff --git a/templates/project/index.html.twig b/templates/project/index.html.twig index ee905a4..039ee07 100644 --- a/templates/project/index.html.twig +++ b/templates/project/index.html.twig @@ -24,7 +24,7 @@
{{ form_row(form.status) }} {{ form_row(form.area) }} - {{ form_row(form.projectType) }} + {{ form_row(form.character) }} {{ form_row(form.organizationalAnchoring) }} {{ form_row(form.endorsement) }}
diff --git a/templates/project/show.html.twig b/templates/project/show.html.twig index 8b56a0c..6781efb 100644 --- a/templates/project/show.html.twig +++ b/templates/project/show.html.twig @@ -97,8 +97,8 @@
{{ 'project.area'|trans }}
{{ project.area ? project.area.name : '—' }}
-
{{ 'project.project_type'|trans }}
-
{{ project.projectType ? project.projectType.labelKey|trans : '—' }}
+
{{ 'project.characters'|trans }}
+
{{ h.term_list(project.characters) }}
{{ 'project.organizational_anchoring'|trans }}
{{ h.term_list(project.organizationalAnchoring) }}
diff --git a/tests/Controller/Admin/ProjectCharacterControllerTest.php b/tests/Controller/Admin/ProjectCharacterControllerTest.php new file mode 100644 index 0000000..eeca935 --- /dev/null +++ b/tests/Controller/Admin/ProjectCharacterControllerTest.php @@ -0,0 +1,117 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/project-characters'); + + $this->assertResponseIsSuccessful(); + } + + public function testNewCreatesCharacter(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/project-characters/new'); + $this->assertResponseIsSuccessful(); + + $name = 'Test Character '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form(['project_character[name]' => $name]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/project-characters'); + + $character = $this->projectCharacters()->findOneBy(['name' => $name]); + self::assertInstanceOf(ProjectCharacter::class, $character); + $this->removeCharacter((string) $character->getId()); + } + + public function testNewRejectsADuplicateName(): void + { + $this->loginAsAdmin(); + $name = 'Duplicate Character '.uniqid(); + $id = (string) $this->createCharacter($name)->getId(); + + $crawler = $this->client->request('GET', '/admin/project-characters/new'); + $form = $crawler->filter('button.btn--primary')->form(['project_character[name]' => $name]); + $this->client->submit($form); + + // UniqueEntity rejects the second one: the form redisplays with a 422 + // (Symfony's status for an invalid submitted form) and nothing is saved. + $this->assertResponseStatusCodeSame(422); + self::assertCount(1, $this->projectCharacters()->findBy(['name' => $name])); + + $this->removeCharacter($id); + } + + public function testEditUpdatesCharacter(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createCharacter('Editable Character '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/project-characters/%s/edit', $id)); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form(['project_character[name]' => 'Edited Character '.uniqid()]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/project-characters'); + $this->removeCharacter($id); + } + + public function testDeleteRemovesCharacterWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createCharacter('Deletable Character '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/project-characters/%s/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/project-characters'); + $this->entityManager()->clear(); + self::assertNull($this->projectCharacters()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createCharacter('Surviving Character '.uniqid())->getId(); + + $this->client->request('POST', sprintf('/admin/project-characters/%s/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/admin/project-characters'); + $this->entityManager()->clear(); + self::assertNotNull($this->projectCharacters()->find($id)); + $this->removeCharacter($id); + } + + private function createCharacter(string $name): ProjectCharacter + { + $character = (new ProjectCharacter())->setName($name); + $em = $this->entityManager(); + $em->persist($character); + $em->flush(); + + return $character; + } + + private function removeCharacter(string $id): void + { + $this->entityManager()->clear(); + $character = $this->projectCharacters()->find($id); + if (null !== $character) { + $em = $this->entityManager(); + $em->remove($character); + $em->flush(); + } + } +} diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php index 1a2556e..bdee8d5 100644 --- a/tests/Controller/ProjectControllerTest.php +++ b/tests/Controller/ProjectControllerTest.php @@ -7,6 +7,7 @@ use App\Entity\Contact; use App\Entity\Department; use App\Entity\Project; +use App\Entity\ProjectCharacter; use App\Enum\Status; use App\Tests\FunctionalTestCase; use Symfony\Component\DomCrawler\Crawler; @@ -243,6 +244,65 @@ public function testDepartmentsAreSavedShownFilteredAndExported(): void $em->flush(); } + public function testCharactersAreSavedShownFilteredAndExported(): void + { + $this->loginAsAdmin(); + $em = $this->entityManager(); + $first = (new ProjectCharacter())->setName('Character first '.uniqid()); + $second = (new ProjectCharacter())->setName('Character second '.uniqid()); + $em->persist($first); + $em->persist($second); + $em->flush(); + $plain = $this->createProject('Plain project '.uniqid()); + $title = 'Characterised project '.uniqid(); + + $crawler = $this->client->request('GET', '/projects/new'); + self::assertCount(1, $crawler->filter('select[name="project[characters][]"][multiple][data-character-select]')); + $token = (string) $crawler->filter('input[name="project[_token]"]')->attr('value'); + $this->client->request('POST', '/projects/new', [ + 'project' => [ + 'title' => $title, + 'characters' => [(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->getCharacters()); + $id = (string) $project->getId(); + + // Both characters 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 character filter narrows the list to projects of that character … + $crawler = $this->client->request('GET', '/projects?character='.$first->getId()); + $results = $crawler->filter('#project-results')->text(); + self::assertStringContainsString($title, $results); + self::assertStringContainsString($first->getName().', '.$second->getName(), $results); + self::assertStringNotContainsString((string) $plain->getTitle(), $results); + + // … and the export lists every character of the project. + $this->client->request('GET', '/projects/export?character='.$first->getId()); + $csv = (string) $this->client->getInternalResponse()->getContent(); + self::assertStringContainsString($first->getName().', '.$second->getName(), $csv); + + $this->removeProject($id); + $this->removeProject((string) $plain->getId()); + $em = $this->entityManager(); + foreach ([$first->getId(), $second->getId()] as $characterId) { + $character = $this->projectCharacters()->find($characterId); + if (null !== $character) { + $em->remove($character); + } + } + $em->flush(); + } + public function testSummaryAndDescriptionAreSavedShownAndSearchable(): void { $this->loginAsAdmin(); diff --git a/tests/Controller/SmokeTest.php b/tests/Controller/SmokeTest.php index a9706bb..3d5e4d3 100644 --- a/tests/Controller/SmokeTest.php +++ b/tests/Controller/SmokeTest.php @@ -69,5 +69,7 @@ public static function authenticatedPages(): iterable yield 'admin partner new' => ['/admin/partners/new']; yield 'admin departments' => ['/admin/departments']; yield 'admin department new' => ['/admin/departments/new']; + yield 'admin project characters' => ['/admin/project-characters']; + yield 'admin project character new' => ['/admin/project-characters/new']; } } diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 6b97e18..b96f666 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -9,6 +9,7 @@ use App\Repository\ContactRepository; use App\Repository\DepartmentRepository; use App\Repository\PartnerRepository; +use App\Repository\ProjectCharacterRepository; use App\Repository\ProjectRepository; use App\Repository\TermRepository; use App\Repository\UserRepository; @@ -87,6 +88,14 @@ protected function areas(): AreaRepository return $repository; } + protected function projectCharacters(): ProjectCharacterRepository + { + $repository = static::getContainer()->get(ProjectCharacterRepository::class); + \assert($repository instanceof ProjectCharacterRepository); + + return $repository; + } + protected function terms(): TermRepository { $repository = static::getContainer()->get(TermRepository::class); diff --git a/tests/Repository/AreaRepositoryTest.php b/tests/Repository/AreaRepositoryTest.php index c9615eb..20c5eed 100644 --- a/tests/Repository/AreaRepositoryTest.php +++ b/tests/Repository/AreaRepositoryTest.php @@ -20,6 +20,6 @@ public function testFindAllOrderedReturnsAreas(): void // Ordering is delegated to the database collation, so we only assert the // method returns the persisted areas. self::assertNotEmpty($areas); - self::assertNotNull($areas[0]->getId()); + self::assertNotNull($areas[0]->getName()); } } diff --git a/tests/Repository/DepartmentRepositoryTest.php b/tests/Repository/DepartmentRepositoryTest.php index af77590..89a0fbe 100644 --- a/tests/Repository/DepartmentRepositoryTest.php +++ b/tests/Repository/DepartmentRepositoryTest.php @@ -20,6 +20,6 @@ public function testFindAllOrderedReturnsDepartments(): void // Ordering is delegated to the database collation, so we only assert the // method returns the persisted departments. self::assertNotEmpty($departments); - self::assertNotNull($departments[0]->getId()); + self::assertNotNull($departments[0]->getName()); } } diff --git a/tests/Repository/ProjectCharacterRepositoryTest.php b/tests/Repository/ProjectCharacterRepositoryTest.php new file mode 100644 index 0000000..d1108d4 --- /dev/null +++ b/tests/Repository/ProjectCharacterRepositoryTest.php @@ -0,0 +1,25 @@ +get(ProjectCharacterRepository::class); + \assert($repository instanceof ProjectCharacterRepository); + + $characters = $repository->findAllOrdered(); + + // Ordering is delegated to the database collation, so we only assert the + // method returns the persisted characters. + self::assertNotEmpty($characters); + self::assertNotNull($characters[0]->getName()); + } +} diff --git a/tests/Repository/ProjectRepositoryTest.php b/tests/Repository/ProjectRepositoryTest.php index a5f24f0..1867697 100644 --- a/tests/Repository/ProjectRepositoryTest.php +++ b/tests/Repository/ProjectRepositoryTest.php @@ -7,11 +7,12 @@ use App\Entity\Area; use App\Entity\Department; use App\Entity\Project; -use App\Enum\ProjectType; +use App\Entity\ProjectCharacter; use App\Enum\Status; use App\Model\ProjectFilter; use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; +use App\Repository\ProjectCharacterRepository; use App\Repository\ProjectRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -34,12 +35,14 @@ public function testSearchAppliesEveryFilterBranch(): void \assert($departments instanceof DepartmentRepository); $areas = static::getContainer()->get(AreaRepository::class); \assert($areas instanceof AreaRepository); + $characters = static::getContainer()->get(ProjectCharacterRepository::class); + \assert($characters instanceof ProjectCharacterRepository); $filter = new ProjectFilter(); $filter->q = '100%_'; // also exercises LIKE wildcard escaping $filter->status = Status::Granted; $filter->area = $areas->findAllOrdered()[0]; - $filter->projectType = ProjectType::Project; + $filter->character = $characters->findAllOrdered()[0]; $filter->organizationalAnchoring = $departments->findAllOrdered()[0]; $filter->endorsement = true; $filter->sort = 'title'; @@ -48,19 +51,21 @@ public function testSearchAppliesEveryFilterBranch(): void self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); } - public function testSearchByDepartmentOrAreaMatchesOnlyProjectsWithThem(): void + public function testSearchByDepartmentAreaOrCharacterMatchesOnlyProjectsWithThem(): void { $em = static::getContainer()->get(EntityManagerInterface::class); \assert($em instanceof EntityManagerInterface); $department = (new Department())->setName('Filter dept '.uniqid()); $area = (new Area())->setName('Filter area '.uniqid()); + $character = (new ProjectCharacter())->setName('Filter character '.uniqid()); $anchored = (new Project()) ->setTitle('Anchored '.uniqid()) ->addOrganizationalAnchoring($department) + ->addCharacter($character) ->setArea($area); $loose = (new Project())->setTitle('Loose '.uniqid()); - foreach ([$department, $area, $anchored, $loose] as $entity) { + foreach ([$department, $area, $character, $anchored, $loose] as $entity) { $em->persist($entity); } $em->flush(); @@ -69,15 +74,20 @@ public function testSearchByDepartmentOrAreaMatchesOnlyProjectsWithThem(): void $byDepartment->organizationalAnchoring = $department; $byArea = new ProjectFilter(); $byArea->area = $area; + $byCharacter = new ProjectFilter(); + $byCharacter->character = $character; + // The free-text search matches the character by its stored name. + $byCharacterName = new ProjectFilter(); + $byCharacterName->q = (string) $character->getName(); - // Both filters must actually narrow the list: the binary ULID foreign keys + // Every filter must actually narrow the list: the binary ULID foreign keys // only match when the id is bound with the ulid type, so an entity bound // as-is would silently return nothing. - foreach ([$byDepartment, $byArea] as $filter) { + foreach ([$byDepartment, $byArea, $byCharacter, $byCharacterName] as $filter) { self::assertSame([$anchored->getTitle()], $this->titles($filter)); } - foreach ([$anchored, $loose, $department, $area] as $entity) { + foreach ([$anchored, $loose, $department, $area, $character] as $entity) { $em->remove($entity); } $em->flush(); @@ -105,9 +115,9 @@ public function testSearchMatchesTranslatedFundingLabel(): void public function testSearchMatchesTranslatedEnumLabel(): void { $filter = new ProjectFilter(); - // "projekt" is the Danish label for the Project case of the project type, so the - // search maps it to the project slug and matches the enum column. - $filter->q = 'projekt'; + // "bevilliget" is the Danish label for the Granted status, so the search + // maps it to the granted slug and matches the enum column. + $filter->q = 'bevilliget'; self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); } diff --git a/tests/Unit/Entity/ProjectCharacterTest.php b/tests/Unit/Entity/ProjectCharacterTest.php new file mode 100644 index 0000000..b441513 --- /dev/null +++ b/tests/Unit/Entity/ProjectCharacterTest.php @@ -0,0 +1,27 @@ +getName()); + self::assertSame('', (string) $character); + } + + public function testAccessors(): void + { + $character = (new ProjectCharacter())->setName('Drift'); + + self::assertSame('Drift', $character->getName()); + self::assertSame('Drift', (string) $character); + } +} diff --git a/tests/Unit/Entity/ProjectTest.php b/tests/Unit/Entity/ProjectTest.php index 2a80fce..1f3f31d 100644 --- a/tests/Unit/Entity/ProjectTest.php +++ b/tests/Unit/Entity/ProjectTest.php @@ -9,10 +9,10 @@ use App\Entity\Department; use App\Entity\Partner; use App\Entity\Project; +use App\Entity\ProjectCharacter; use App\Entity\Term; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\Vocabulary; use PHPUnit\Framework\TestCase; @@ -28,6 +28,7 @@ public function testDefaults(): void self::assertSame([], $project->getFunding()); self::assertSame([], $project->getLinks()); self::assertCount(0, $project->getOrganizationalAnchoring()); + self::assertCount(0, $project->getCharacters()); self::assertCount(0, $project->getStrategies()); self::assertCount(0, $project->getTags()); self::assertCount(0, $project->getContacts()); @@ -49,7 +50,6 @@ public function testScalarAccessors(): void ->setArea($area) ->setSummary('Opsummering') ->setDescription('Beskrivelse') - ->setProjectType(ProjectType::Project) ->setStatus(Status::Granted) ->setStatusAdditional('Igangsat') ->setEndorsement(false) @@ -63,7 +63,6 @@ public function testScalarAccessors(): void self::assertSame($area, $project->getArea()); self::assertSame('Opsummering', $project->getSummary()); self::assertSame('Beskrivelse', $project->getDescription()); - self::assertSame(ProjectType::Project, $project->getProjectType()); self::assertSame(Status::Granted, $project->getStatus()); self::assertSame('Igangsat', $project->getStatusAdditional()); self::assertFalse($project->isEndorsement()); @@ -83,7 +82,7 @@ public function testCompletionPercentage(): void ->setArea((new Area())->setName('Klima og miljø')) ->setSummary('S') ->setDescription('D') - ->setProjectType(ProjectType::Project) + ->addCharacter((new ProjectCharacter())->setName('Projekt')) ->setStatus(Status::Granted) ->addOrganizationalAnchoring((new Department())->setName('Teknik og Miljø')) ->setBudget(1000) @@ -157,6 +156,27 @@ public function testOrganizationalAnchoringCollection(): void self::assertCount(0, $project->getOrganizationalAnchoring()); } + public function testCharacterCollection(): void + { + $project = new Project(); + $character = (new ProjectCharacter())->setName('Pilot'); + + $project->addCharacter($character); + $project->addCharacter($character); + self::assertCount(1, $project->getCharacters()); + + $project->removeCharacter($character); + self::assertCount(0, $project->getCharacters()); + + $project->setCharacters([ + (new ProjectCharacter())->setName('Projekt'), + (new ProjectCharacter())->setName('Drift'), + ]); + self::assertCount(2, $project->getCharacters()); + $project->setCharacters([]); + self::assertCount(0, $project->getCharacters()); + } + public function testStrategyCollection(): void { $project = new Project(); diff --git a/tests/Unit/Enum/TranslatableEnumTest.php b/tests/Unit/Enum/TranslatableEnumTest.php index 88495a2..8d04c37 100644 --- a/tests/Unit/Enum/TranslatableEnumTest.php +++ b/tests/Unit/Enum/TranslatableEnumTest.php @@ -6,7 +6,6 @@ use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\TranslatableEnum; use App\Enum\Vocabulary; @@ -18,7 +17,6 @@ public function testEveryCaseExposesAPrefixedLabelKey(): void { $this->assertLabelKeys(EndorsementAuthor::cases(), 'enum.endorsement_author.'); $this->assertLabelKeys(Funding::cases(), 'enum.funding.'); - $this->assertLabelKeys(ProjectType::cases(), 'enum.project_type.'); $this->assertLabelKeys(Status::cases(), 'enum.status.'); $this->assertLabelKeys(Vocabulary::cases(), 'enum.vocabulary.'); } diff --git a/tests/Unit/Model/ProjectFilterTest.php b/tests/Unit/Model/ProjectFilterTest.php index ad76ac1..c6697c1 100644 --- a/tests/Unit/Model/ProjectFilterTest.php +++ b/tests/Unit/Model/ProjectFilterTest.php @@ -6,7 +6,7 @@ use App\Entity\Area; use App\Entity\Department; -use App\Enum\ProjectType; +use App\Entity\ProjectCharacter; use App\Enum\Status; use App\Model\ProjectFilter; use PHPUnit\Framework\TestCase; @@ -20,7 +20,7 @@ public function testDefaults(): void self::assertNull($filter->q); self::assertNull($filter->status); self::assertNull($filter->area); - self::assertNull($filter->projectType); + self::assertNull($filter->character); self::assertNull($filter->organizationalAnchoring); self::assertNull($filter->endorsement); self::assertSame('createdAt', $filter->sort); @@ -33,7 +33,7 @@ public function testIsMutable(): void $filter->q = 'klima'; $filter->status = Status::Granted; $filter->area = (new Area())->setName('Klima og miljø'); - $filter->projectType = ProjectType::Project; + $filter->character = (new ProjectCharacter())->setName('Drift'); $filter->organizationalAnchoring = (new Department())->setName('Sundhed og Omsorg'); $filter->endorsement = true; $filter->sort = 'title'; diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index ab9483b..454b314 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -86,6 +86,7 @@ nav: partners: Samarbejdspartnere departments: Afdelinger areas: Områder + project_characters: Projektkarakterer users: Brugere admin: Administration preferences: Indstillinger @@ -173,8 +174,8 @@ project: description: Beskrivelse description_help: Hvorfor vi gør det, og hvad projektet har ophæng i. strategies: Strategier og planer - project_type: Projektets karakter - project_type_help: Projektets karakter, fx projekt eller drift. + characters: Projektets karakter + characters_help: Fx projekt eller drift. status: Status status_help: Hvor langt projektet er. status_additional: Status – supplerende tekst @@ -304,6 +305,21 @@ area: title: Ingen områder endnu hint: Opret et område, så det kan vælges på projekter. +project_character: + name: Navn + name_help: Karakterens navn, som det vises på projekter. + name_duplicate: Der findes allerede en projektkarakter med dette navn. + index: + title: Projektkarakterer + subtitle: Projektets karakter, fx projekt eller drift, der kan vælges på projekter. + new: + title: Ny projektkarakter + edit: + title: Rediger projektkarakter + empty: + title: Ingen projektkarakterer endnu + hint: Opret en projektkarakter, så den kan vælges på projekter. + user: email: E-mail email_help: Bruges som login og til notifikationer. @@ -355,6 +371,10 @@ flash: created: Området blev oprettet. updated: Området blev opdateret. deleted: Området blev slettet. + project_character: + created: Projektkarakteren blev oprettet. + updated: Projektkarakteren blev opdateret. + deleted: Projektkarakteren blev slettet. user: created: Brugeren blev oprettet. updated: Brugeren blev opdateret. @@ -371,12 +391,6 @@ enum: on_hold: Sat i bero rejected: Afvist completed: Afsluttet - project_type: - project: Projekt - programme: Program - policy: Politik - pilot: Pilot - operation: Drift endorsement_author: city_council: Byrådet magistrate: Magistraten diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index dade48e..2bc3cb6 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -86,6 +86,7 @@ nav: partners: Partners departments: Departments areas: Areas + project_characters: Project characters users: Users admin: Administration preferences: Preferences @@ -173,8 +174,8 @@ project: description: Description description_help: Why we are doing it, and what the project is anchored in. strategies: Strategies and plans - project_type: Type of project - project_type_help: The nature of the project, e.g. project or operation. + characters: Project character + characters_help: E.g. project or operation. status: Status status_help: How far along the project is. status_additional: Status – additional text @@ -304,6 +305,21 @@ area: title: No areas yet hint: Create an area so it can be chosen on projects. +project_character: + name: Name + name_help: The character name as shown on projects. + name_duplicate: A project character with this name already exists. + index: + title: Project characters + subtitle: The nature of a project, e.g. project or operation, that can be chosen on projects. + new: + title: New project character + edit: + title: Edit project character + empty: + title: No project characters yet + hint: Create a project character so it can be chosen on projects. + user: email: Email email_help: Used to sign in and for notifications. @@ -355,6 +371,10 @@ flash: created: The area was created. updated: The area was updated. deleted: The area was deleted. + project_character: + created: The project character was created. + updated: The project character was updated. + deleted: The project character was deleted. user: created: The user was created. updated: The user was updated. @@ -371,12 +391,6 @@ enum: on_hold: On hold rejected: Rejected completed: Completed - project_type: - project: Project - programme: Programme - policy: Policy - pilot: Pilot - operation: Operation endorsement_author: city_council: City Council magistrate: The Magistrate From b434365905a0f2111e65eba11f8cdb4d3f861711 Mon Sep 17 00:00:00 2001 From: jekuaitk Date: Mon, 5 Oct 2026 08:58:36 +0200 Subject: [PATCH 2/2] 8382: Rename project character to project type --- CHANGELOG.md | 2 +- assets/app.js | 4 +- migrations/Version20260930065548.php | 26 ++-- .../Admin/ProjectCharacterController.php | 78 ------------ .../Admin/ProjectTypeController.php | 78 ++++++++++++ src/Controller/ProjectController.php | 4 +- src/DataFixtures/AppFixtures.php | 18 +-- src/Entity/Project.php | 44 +++---- .../{ProjectCharacter.php => ProjectType.php} | 10 +- src/Form/ProjectFilterType.php | 8 +- src/Form/ProjectType.php | 12 +- ...tCharacterType.php => ProjectTypeType.php} | 12 +- src/Model/ProjectFilter.php | 4 +- src/Repository/ProjectRepository.php | 12 +- ...pository.php => ProjectTypeRepository.php} | 10 +- templates/admin/base.html.twig | 2 +- .../admin/project_characters/edit.html.twig | 16 --- .../admin/project_characters/new.html.twig | 12 -- .../_form.html.twig | 2 +- templates/admin/project_types/edit.html.twig | 16 +++ .../index.html.twig | 24 ++-- templates/admin/project_types/new.html.twig | 12 ++ templates/project/_form.html.twig | 2 +- templates/project/_results.html.twig | 4 +- templates/project/index.html.twig | 2 +- templates/project/show.html.twig | 4 +- .../Admin/ProjectCharacterControllerTest.php | 117 ------------------ .../Admin/ProjectTypeControllerTest.php | 117 ++++++++++++++++++ tests/Controller/ProjectControllerTest.php | 34 ++--- tests/Controller/SmokeTest.php | 4 +- tests/FunctionalTestCase.php | 8 +- .../ProjectCharacterRepositoryTest.php | 25 ---- tests/Repository/ProjectRepositoryTest.php | 32 ++--- .../Repository/ProjectTypeRepositoryTest.php | 25 ++++ tests/Unit/Entity/ProjectCharacterTest.php | 27 ---- tests/Unit/Entity/ProjectTest.php | 32 ++--- tests/Unit/Entity/ProjectTypeTest.php | 27 ++++ tests/Unit/Model/ProjectFilterTest.php | 6 +- translations/messages.da.yaml | 32 ++--- translations/messages.en.yaml | 32 ++--- 40 files changed, 468 insertions(+), 468 deletions(-) delete mode 100644 src/Controller/Admin/ProjectCharacterController.php create mode 100644 src/Controller/Admin/ProjectTypeController.php rename src/Entity/{ProjectCharacter.php => ProjectType.php} (68%) rename src/Form/{ProjectCharacterType.php => ProjectTypeType.php} (66%) rename src/Repository/{ProjectCharacterRepository.php => ProjectTypeRepository.php} (65%) delete mode 100644 templates/admin/project_characters/edit.html.twig delete mode 100644 templates/admin/project_characters/new.html.twig rename templates/admin/{project_characters => project_types}/_form.html.twig (74%) create mode 100644 templates/admin/project_types/edit.html.twig rename templates/admin/{project_characters => project_types}/index.html.twig (54%) create mode 100644 templates/admin/project_types/new.html.twig delete mode 100644 tests/Controller/Admin/ProjectCharacterControllerTest.php create mode 100644 tests/Controller/Admin/ProjectTypeControllerTest.php delete mode 100644 tests/Repository/ProjectCharacterRepositoryTest.php create mode 100644 tests/Repository/ProjectTypeRepositoryTest.php delete mode 100644 tests/Unit/Entity/ProjectCharacterTest.php create mode 100644 tests/Unit/Entity/ProjectTypeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 997365b..5d9f1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] * [PR-41](https://github.com/itk-dev/itk-projects/pull/41) - Let a project have several characters, picked from an admin-managed pool + Let a project have several types, picked from an admin-managed pool instead of a fixed list. * [PR-33](https://github.com/itk-dev/itk-projects/pull/33) Let a project be anchored in several departments, picked with a searchable diff --git a/assets/app.js b/assets/app.js index 75b3b39..4d83cea 100644 --- a/assets/app.js +++ b/assets/app.js @@ -180,14 +180,14 @@ function initPartnerSelect() { ); } -// Departments and project characters are fixed, admin-managed pools, so the +// Departments and project types are fixed, admin-managed pools, so the // project form's