Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ 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, free-text search and CSV
export. Every delete across the app now confirms through one shared dialog,
which for partners lists and links the initiatives they would be detached
from.

## [0.2.0] - 2026-06-30

* [PR-23](https://github.com/itk-dev/itk-projects/pull/23)
Expand Down
17 changes: 17 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 All @@ -150,3 +159,11 @@ document.addEventListener("turbo:load", () => {
document.addEventListener("turbo:frame-load", () => {
initCollections();
});

// Submitting from inside a confirm dialog caches the page with the dialog still
// open; restoring that snapshot would render it inline, out of the top layer.
document.addEventListener("turbo:before-cache", () => {
document.querySelectorAll("dialog[open]").forEach((dialog) => {
dialog.close();
});
});
23 changes: 23 additions & 0 deletions assets/controllers/confirm_delete_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Controller } from "@hotwired/stimulus";

// The trigger is a plain button, so a click landing before this controller has
// hydrated does nothing; only the button inside the dialog submits.
export default class extends Controller {
static targets = ["dialog"];

open() {
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
2 changes: 1 addition & 1 deletion migrations/Version20260626175808.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class Version20260626175808 extends AbstractMigration
{
public function getDescription(): string
{
return '';
return 'Create the initial schema: user, initiative with its images and attachments, and the contact, department and term entities it references.';
}

public function up(Schema $schema): void
Expand Down
2 changes: 1 addition & 1 deletion migrations/Version20260627134918.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class Version20260627134918 extends AbstractMigration
{
public function getDescription(): string
{
return '';
return 'Add the area table and replace the initiative category column with an area reference.';
}

public function up(Schema $schema): void
Expand Down
36 changes: 36 additions & 0 deletions migrations/Version20260813090155.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20260813090155 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the partner table and the initiative_partner join table.';
}

public function up(Schema $schema): void
{
$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, INDEX idx_partner_name (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->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');
}
}
91 changes: 91 additions & 0 deletions src/Controller/Admin/PartnerController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?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),
]);
}

// Also detaches the partner from every initiative: the join table is cleared by
// its ON DELETE CASCADE, which Doctrine never sees (unidirectional association).
#[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
Loading
Loading