Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
multiselect.
Expand Down
31 changes: 17 additions & 14 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,23 +180,26 @@ function initPartnerSelect() {
);
}

// Departments are a fixed, admin-managed pool, so the project form's
// <select multiple> becomes a searchable chip multiselect without `create`.
// Departments and project types are fixed, admin-managed pools, so the
// project form's <select multiple> 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-type-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
Expand Down Expand Up @@ -225,7 +228,7 @@ document.addEventListener("turbo:load", () => {
initCollections();
initContactSelect();
initPartnerSelect();
initDepartmentSelect();
initPoolSelect();
initTermSelect();
});

Expand Down
38 changes: 38 additions & 0 deletions migrations/Version20260930065548.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260930065548 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace the project_type enum column with the admin-managed project_type entity and a many-to-many join table (no data is carried over).';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE project_type (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_B54F9F315E237E06 (name), INDEX IDX_B54F9F31B03A8386 (created_by_id), INDEX IDX_B54F9F3199049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE project_project_type (project_id BINARY(16) NOT NULL, project_type_id BINARY(16) NOT NULL, INDEX IDX_9FE87B52166D1F9C (project_id), INDEX IDX_9FE87B52535280F6 (project_type_id), PRIMARY KEY (project_id, project_type_id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE project_type ADD CONSTRAINT FK_B54F9F31B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE project_type ADD CONSTRAINT FK_B54F9F3199049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE project_project_type ADD CONSTRAINT FK_9FE87B52166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_project_type ADD CONSTRAINT FK_9FE87B52535280F6 FOREIGN KEY (project_type_id) REFERENCES project_type (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project DROP project_type');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE project_project_type DROP FOREIGN KEY FK_9FE87B52166D1F9C');
$this->addSql('ALTER TABLE project_project_type DROP FOREIGN KEY FK_9FE87B52535280F6');
$this->addSql('ALTER TABLE project_type DROP FOREIGN KEY FK_B54F9F31B03A8386');
$this->addSql('ALTER TABLE project_type DROP FOREIGN KEY FK_B54F9F3199049ECE');
$this->addSql('DROP TABLE project_project_type');
$this->addSql('DROP TABLE project_type');
$this->addSql('ALTER TABLE project ADD project_type VARCHAR(32) DEFAULT NULL');
}
}
78 changes: 78 additions & 0 deletions src/Controller/Admin/ProjectTypeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\ProjectType;
use App\Form\ProjectTypeType;
use App\Repository\ProjectTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/project-types')]
#[IsGranted('ROLE_USER')]
class ProjectTypeController extends AbstractController
{
#[Route('', name: 'admin_project_types', methods: ['GET'])]
public function index(ProjectTypeRepository $types): Response
{
return $this->render('admin/project_types/index.html.twig', [
'types' => $types->findAllOrdered(),
]);
}

#[Route('/new', name: 'admin_project_type_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$type = new ProjectType();
$form = $this->createForm(ProjectTypeType::class, $type);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($type);
$entityManager->flush();
$this->addFlash('success', 'flash.project_type.created');

return $this->redirectToRoute('admin_project_types');
}

return $this->render('admin/project_types/new.html.twig', ['form' => $form]);
}

#[Route('/{id}/edit', name: 'admin_project_type_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])]
public function edit(Request $request, ProjectType $type, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(ProjectTypeType::class, $type);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
$this->addFlash('success', 'flash.project_type.updated');

return $this->redirectToRoute('admin_project_types');
}

return $this->render('admin/project_types/edit.html.twig', [
'form' => $form,
'type' => $type,
]);
}

#[Route('/{id}/delete', name: 'admin_project_type_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])]
public function delete(Request $request, ProjectType $type, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete-project-type-'.$type->getId(), (string) $request->request->get('_token'))) {
$entityManager->remove($type);
$entityManager->flush();
$this->addFlash('success', 'flash.project_type.deleted');
}

return $this->redirectToRoute('admin_project_types');
}
}
4 changes: 2 additions & 2 deletions src/Controller/ProjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.types'),
$translator->trans('project.organizational_anchoring'),
$translator->trans('project.endorsement'),
$translator->trans('project.endorsement_author'),
Expand All @@ -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->getTypes()),
$names($row->getOrganizationalAnchoring()),
$row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'),
$translate($row->getEndorsementAuthor()),
Expand Down
18 changes: 15 additions & 3 deletions src/DataFixtures/AppFixtures.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
use App\Entity\Department;
use App\Entity\Partner;
use App\Entity\Project;
use App\Entity\ProjectType;
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;
Expand All @@ -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 TYPES = ['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.',
Expand Down Expand Up @@ -75,6 +76,13 @@ public function load(ObjectManager $manager): void
$areas[] = $area;
}

$types = [];
foreach (self::TYPES as $name) {
$type = (new ProjectType())->setName($name);
$manager->persist($type);
$types[] = $type;
}

$partners = [];
foreach (self::PARTNERS as $name) {
$partner = (new Partner())
Expand Down Expand Up @@ -129,15 +137,13 @@ public function load(ObjectManager $manager): void
];

$statuses = Status::cases();
$types = ProjectType::cases();
$endorsers = EndorsementAuthor::cases();
$fundings = Funding::cases();

foreach ($titles as $index => $title) {
$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.")
Expand All @@ -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($types), 0, 0 === $index % 4 ? 2 : 1) as $type) {
$project->addType($type);
}

// 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) {
Expand Down
55 changes: 42 additions & 13 deletions src/Entity/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
* 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.
*
* @var list<string>
*/
public const array COMPLETION_FIELDS = [
'title', 'area', 'summary', 'description', 'projectType', 'status',
'title', 'area', 'summary', 'description', 'types', 'status',
'organizationalAnchoring',
'budget', 'funding', 'timePeriodStart', 'timePeriodEnd',
];
Expand Down Expand Up @@ -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<int, ProjectType>
*/
#[ORM\ManyToMany(targetEntity: ProjectType::class)]
#[ORM\JoinTable(name: 'project_project_type')]
private Collection $types;

#[ORM\Column(length: 32, nullable: true, enumType: Status::class)]
private ?Status $status = null;
Expand Down Expand Up @@ -133,6 +140,7 @@ public function __construct()
{
parent::__construct();
$this->organizationalAnchoring = new ArrayCollection();
$this->types = new ArrayCollection();
$this->strategies = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->partners = new ArrayCollection();
Expand Down Expand Up @@ -232,14 +240,35 @@ public function setStrategies(iterable $terms): static
return $this;
}

public function getProjectType(): ?ProjectType
/** @return Collection<int, ProjectType> */
public function getTypes(): Collection
{
return $this->projectType;
return $this->types;
}

public function setProjectType(?ProjectType $projectType): static
public function addType(ProjectType $type): static
{
$this->projectType = $projectType;
if (!$this->types->contains($type)) {
$this->types->add($type);
}

return $this;
}

public function removeType(ProjectType $type): static
{
$this->types->removeElement($type);

return $this;
}

/** @param iterable<ProjectType> $types */
public function setTypes(iterable $types): static
{
$this->types->clear();
foreach ($types as $type) {
$this->addType($type);
}

return $this;
}
Expand Down Expand Up @@ -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 type and department collections (one lazy
* load each), so it is cheap enough to call per row in a listing.
*/
public function getCompletionPercentage(): int
{
Expand All @@ -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->types->isEmpty(),
null !== $this->status,
!$this->organizationalAnchoring->isEmpty(),
null !== $this->budget,
Expand Down
Loading
Loading