Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-25](https://github.com/itk-dev/itk-projects/pull/25)
Add partners to initiatives, with an admin CRUD and CSV export. Deleting a
partner now confirms in a dialog that lists and links the initiatives it would
be detached from.

## [0.2.0] - 2026-06-30

* [PR-23](https://github.com/itk-dev/itk-projects/pull/23)
Expand Down
9 changes: 9 additions & 0 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ function initContactSelect() {
);
}

function initPartnerSelect() {
initCreatableSelect(
"[data-partner-select]",
"partnerPool",
(value) => value,
);
}

// One delegated handler on the document (which survives Turbo navigations and
// cache restores) both opens the menu — when the click lands on the toggle —
// and closes it on any outside click. Delegation avoids per-page binding, which
Expand All @@ -142,6 +150,7 @@ document.addEventListener("click", (event) => {
document.addEventListener("turbo:load", () => {
initCollections();
initContactSelect();
initPartnerSelect();
initTermSelect();
});

Expand Down
29 changes: 29 additions & 0 deletions assets/controllers/confirm_delete_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus";

/*
* Guards a destructive submit behind a modal that spells out the consequences —
* a plain confirm() can only carry text, and the point here is to link the
* records that would be affected. Attached to the form; the trigger is a real
* submit button, so with JavaScript off the form still posts unguarded rather
* than the button going dead. showModal() brings Escape and focus trapping.
*/
export default class extends Controller {
static targets = ["dialog"];

open(event) {
event.preventDefault();
this.dialogTarget.showModal();
}

cancel() {
this.dialogTarget.close();
}

// A modal dialog fills the top layer, so a click on the backdrop reports the
// dialog itself as the target; anything inside reports a descendant.
backdrop(event) {
if (event.target === this.dialogTarget) {
this.dialogTarget.close();
}
}
}
59 changes: 59 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,65 @@ body.is-lightbox-open {
overflow: hidden;
}

.confirm {
width: min(520px, calc(100vw - var(--itk-space-6)));
margin: auto;
padding: 0;
color: var(--itk-ink);
background-color: var(--itk-surface);
border: 1px solid var(--itk-slate-200);
border-radius: var(--itk-radius-3);
box-shadow: var(--itk-shadow-3);
}

.confirm::backdrop {
background-color: rgba(17, 19, 24, 0.55);
}

.confirm__body {
padding: var(--itk-space-5);
font-size: var(--itk-text-sm);
}

.confirm__body p {
margin: 0;
}

.confirm__title {
margin: 0 0 var(--itk-space-3);
font-size: var(--itk-text-md);
}

.confirm__list {
max-height: 240px;
margin: var(--itk-space-3) 0 0;
padding-left: var(--itk-space-5);
overflow-y: auto;
}

.confirm__list li + li {
margin-top: var(--itk-space-1);
}

.confirm__body p.confirm__warning {
margin-top: var(--itk-space-4);
color: var(--itk-accent);
}

.confirm__body p.confirm__warning + p.confirm__warning {
margin-top: var(--itk-space-1);
}

.confirm__actions {
display: flex;
gap: var(--itk-space-3);
justify-content: flex-end;
padding: var(--itk-space-4) var(--itk-space-5);
background-color: var(--itk-slate-50);
border-top: 1px solid var(--itk-slate-200);
border-radius: 0 0 var(--itk-radius-3) var(--itk-radius-3);
}

.form-actions {
display: flex;
gap: var(--itk-space-3);
Expand Down
41 changes: 41 additions & 0 deletions migrations/Version20260813090155.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260813090155 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE initiative_partner (initiative_id BINARY(16) NOT NULL, partner_id BINARY(16) NOT NULL, INDEX IDX_12D1DC4CAB7D9771 (initiative_id), INDEX IDX_12D1DC4C9393F8FE (partner_id), PRIMARY KEY (initiative_id, partner_id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE partner (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, website VARCHAR(255) DEFAULT NULL, created_by_id BINARY(16) DEFAULT NULL, modified_by_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_312B3E165E237E06 (name), INDEX IDX_312B3E16B03A8386 (created_by_id), INDEX IDX_312B3E1699049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE initiative_partner ADD CONSTRAINT FK_12D1DC4CAB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE initiative_partner ADD CONSTRAINT FK_12D1DC4C9393F8FE FOREIGN KEY (partner_id) REFERENCES partner (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE partner ADD CONSTRAINT FK_312B3E16B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE partner ADD CONSTRAINT FK_312B3E1699049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE initiative_partner DROP FOREIGN KEY FK_12D1DC4CAB7D9771');
$this->addSql('ALTER TABLE initiative_partner DROP FOREIGN KEY FK_12D1DC4C9393F8FE');
$this->addSql('ALTER TABLE partner DROP FOREIGN KEY FK_312B3E16B03A8386');
$this->addSql('ALTER TABLE partner DROP FOREIGN KEY FK_312B3E1699049ECE');
$this->addSql('DROP TABLE initiative_partner');
$this->addSql('DROP TABLE partner');
}
}
97 changes: 97 additions & 0 deletions src/Controller/Admin/PartnerController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\Partner;
use App\Form\PartnerType;
use App\Repository\PartnerRepository;
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/partners')]
#[IsGranted('ROLE_USER')]
class PartnerController extends AbstractController
{
#[Route('', name: 'admin_partners', methods: ['GET'])]
public function index(PartnerRepository $partners): Response
{
$usage = $partners->findInitiativeUsage();

// Pair each partner with its initiatives here rather than looking the usage
// up per row, which would mean keying a Twig array by a Ulid object.
$rows = [];
foreach ($partners->findAllOrdered() as $partner) {
$rows[] = [
'partner' => $partner,
'initiatives' => $usage[(string) $partner->getId()] ?? [],
];
}

return $this->render('admin/partners/index.html.twig', ['rows' => $rows]);
}

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

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

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

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

#[Route('/{id}/edit', name: 'admin_partner_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])]
public function edit(Request $request, Partner $partner, EntityManagerInterface $entityManager, PartnerRepository $partners): Response
{
$form = $this->createForm(PartnerType::class, $partner);
$form->handleRequest($request);

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

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

return $this->render('admin/partners/edit.html.twig', [
'form' => $form,
'partner' => $partner,
'initiatives' => $partners->findInitiativesUsing($partner),
]);
}

/**
* Deleting a partner also pulls it off every initiative that referenced it —
* `initiative_partner` is cleared by the join table's ON DELETE CASCADE, which
* Doctrine never sees because the association is unidirectional. The admin is
* told which initiatives are affected before confirming. Recording who went
* ahead anyway is still to come: that belongs here, and needs
* findInitiativesUsing() called before the flush destroys the evidence.
*/
#[Route('/{id}/delete', name: 'admin_partner_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])]
public function delete(Request $request, Partner $partner, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete-partner-'.$partner->getId(), (string) $request->request->get('_token'))) {
$entityManager->remove($partner);
$entityManager->flush();
$this->addFlash('success', 'flash.partner.deleted');
}

return $this->redirectToRoute('admin_partners');
}
}
4 changes: 3 additions & 1 deletion src/Controller/InitiativeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran
$translator->trans('initiative.funding'), $translator->trans('initiative.stakeholders'),
$translator->trans('initiative.strategies'), $translator->trans('initiative.tags'),
$translator->trans('initiative.time_period_start'), $translator->trans('initiative.time_period_end'),
$translator->trans('initiative.contacts'), $translator->trans('initiative.author'),
$translator->trans('initiative.contacts'), $translator->trans('initiative.partners'),
$translator->trans('initiative.author'),
]);

$names = static fn (iterable $items): string => implode(', ', array_map('strval', \is_array($items) ? $items : iterator_to_array($items)));
Expand All @@ -94,6 +95,7 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran
$row->getTimePeriodStart()?->format('Y-m-d'),
$row->getTimePeriodEnd()?->format('Y-m-d'),
$names($row->getContacts()),
$names($row->getPartners()),
($creator = $row->getCreatedBy()) instanceof User ? $creator->getName() : null,
]);
}
Expand Down
17 changes: 17 additions & 0 deletions src/DataFixtures/AppFixtures.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Entity\Contact;
use App\Entity\Department;
use App\Entity\Initiative;
use App\Entity\Partner;
use App\Entity\Term;
use App\Entity\User;
use App\Enum\EndorsementAuthor;
Expand All @@ -25,6 +26,7 @@ class AppFixtures extends Fixture
private const array STAKEHOLDERS = ['Aarhus Kommune', 'Region Midtjylland', 'Aarhus Universitet', 'Erhverv Aarhus', 'Lokale foreninger', 'Boligforeninger', 'VIA University College', 'Business Region Aarhus'];
private const array STRATEGIES = ['Klimaplan 2030', 'Erhvervsplan', 'Børn- og ungepolitik', 'Mobilitetsplan', 'Digitaliseringsstrategi', 'Sundhedspolitik'];
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'];

public function __construct(private readonly UserPasswordHasherInterface $hasher)
Expand Down Expand Up @@ -69,6 +71,18 @@ public function load(ObjectManager $manager): void
$areas[] = $area;
}

$partners = [];
foreach (self::PARTNERS as $name) {
$partner = (new Partner())
->setName($name)
->setDescription($name.' samarbejder med kommunen om udvikling, viden og afprøvning i konkrete initiativer.')
// ascii() turns spaces into dots (it also builds e-mail addresses), which
// a domain does not want, so drop them again.
->setWebsite('https://www.'.strtolower(str_replace('.', '', $this->ascii($name))).'.dk');
$manager->persist($partner);
$partners[] = $partner;
}

$contacts = [];
$firstNames = ['Anne', 'Mette', 'Lars', 'Søren', 'Camilla', 'Jens', 'Ida', 'Mads', 'Sofie', 'Peter', 'Louise', 'Thomas'];
$lastNames = ['Jensen', 'Nielsen', 'Hansen', 'Pedersen', 'Andersen', 'Christensen', 'Larsen', 'Sørensen'];
Expand Down Expand Up @@ -149,6 +163,9 @@ public function load(ObjectManager $manager): void
foreach (\array_slice($this->shuffleCopy($contacts), 0, mt_rand(1, 3)) as $contact) {
$initiative->addContact($contact);
}
foreach (\array_slice($this->shuffleCopy($partners), 0, mt_rand(1, 3)) as $partner) {
$initiative->addPartner($partner);
}

$initiative->setLinks(['https://www.aarhus.dk']);

Expand Down
29 changes: 29 additions & 0 deletions src/Entity/Initiative.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ class Initiative extends AbstractEntity
#[ORM\JoinTable(name: 'initiative_contact')]
private Collection $contacts;

/** @var Collection<int, Partner> */
#[Assert\Valid]
#[ORM\ManyToMany(targetEntity: Partner::class, cascade: ['persist'])]
#[ORM\JoinTable(name: 'initiative_partner')]
private Collection $partners;

/** @var Collection<int, InitiativeImage> */
#[ORM\OneToMany(targetEntity: InitiativeImage::class, mappedBy: 'initiative', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $images;
Expand Down Expand Up @@ -116,6 +122,7 @@ public function __construct()
parent::__construct();
$this->strategies = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->partners = new ArrayCollection();
$this->stakeholders = new ArrayCollection();
$this->tags = new ArrayCollection();
$this->images = new ArrayCollection();
Expand Down Expand Up @@ -285,6 +292,28 @@ public function removeContact(Contact $contact): static
return $this;
}

/** @return Collection<int, Partner> */
public function getPartners(): Collection
{
return $this->partners;
}

public function addPartner(Partner $partner): static
{
if (!$this->partners->contains($partner)) {
$this->partners->add($partner);
}

return $this;
}

public function removePartner(Partner $partner): static
{
$this->partners->removeElement($partner);

return $this;
}

/** @return Collection<int, InitiativeImage> */
public function getImages(): Collection
{
Expand Down
Loading
Loading