From f6333a47c2a716e50a72334169361f0b7203bfe0 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 11:20:24 +0200 Subject: [PATCH 01/21] Add Partner entity Co-Authored-By: Claude Opus 5 (1M context) --- migrations/Version20260813090155.php | 41 +++++++++++++++++ src/Entity/Partner.php | 69 ++++++++++++++++++++++++++++ src/Repository/PartnerRepository.php | 58 +++++++++++++++++++++++ tests/Unit/Entity/PartnerTest.php | 37 +++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 migrations/Version20260813090155.php create mode 100644 src/Entity/Partner.php create mode 100644 src/Repository/PartnerRepository.php create mode 100644 tests/Unit/Entity/PartnerTest.php diff --git a/migrations/Version20260813090155.php b/migrations/Version20260813090155.php new file mode 100644 index 0000000..705ac50 --- /dev/null +++ b/migrations/Version20260813090155.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/src/Entity/Partner.php b/src/Entity/Partner.php new file mode 100644 index 0000000..e6007bf --- /dev/null +++ b/src/Entity/Partner.php @@ -0,0 +1,69 @@ +name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getWebsite(): ?string + { + return $this->website; + } + + public function setWebsite(?string $website): static + { + $this->website = $website; + + return $this; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Repository/PartnerRepository.php b/src/Repository/PartnerRepository.php new file mode 100644 index 0000000..b14adbc --- /dev/null +++ b/src/Repository/PartnerRepository.php @@ -0,0 +1,58 @@ + + */ +class PartnerRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Partner::class); + } + + /** + * @return Partner[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('p') + ->orderBy('p.name', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Return an existing partner matched on name (case-insensitive) or a new, + * unflushed one. Lets partners be picked from the shared pool or typed in on + * the fly; the extra fields (description, website) are filled in later under + * the partners admin. + */ + public function findOrCreate(string $name): Partner + { + $name = trim($name); + + $existing = $this->createQueryBuilder('p') + ->andWhere('LOWER(p.name) = :name') + ->setParameter('name', mb_strtolower($name)) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if ($existing instanceof Partner) { + return $existing; + } + + $partner = (new Partner())->setName($name); + $this->getEntityManager()->persist($partner); + + return $partner; + } +} diff --git a/tests/Unit/Entity/PartnerTest.php b/tests/Unit/Entity/PartnerTest.php new file mode 100644 index 0000000..1a51a87 --- /dev/null +++ b/tests/Unit/Entity/PartnerTest.php @@ -0,0 +1,37 @@ +getName()); + self::assertNull($partner->getDescription()); + self::assertNull($partner->getWebsite()); + // Timestamps are populated by the bundle's listener on flush, so they + // are still null on a freshly constructed (unpersisted) entity. + self::assertNull($partner->getCreatedAt()); + self::assertSame('', (string) $partner); + } + + public function testAccessors(): void + { + $partner = (new Partner()) + ->setName('Aarhus Universitet') + ->setDescription('Forsknings- og uddannelsesinstitution.') + ->setWebsite('https://www.au.dk'); + + self::assertSame('Aarhus Universitet', $partner->getName()); + self::assertSame('Forsknings- og uddannelsesinstitution.', $partner->getDescription()); + self::assertSame('https://www.au.dk', $partner->getWebsite()); + self::assertSame('Aarhus Universitet', (string) $partner); + } +} From ea61954d15116d561887ed122bd62ea96c739ea3 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 11:20:30 +0200 Subject: [PATCH 02/21] Add partner admin section Co-Authored-By: Claude Opus 5 (1M context) --- src/Controller/Admin/PartnerController.php | 78 ++++++++++ src/Form/PartnerType.php | 47 ++++++ templates/admin/base.html.twig | 1 + templates/admin/partners/_form.html.twig | 12 ++ templates/admin/partners/edit.html.twig | 16 ++ templates/admin/partners/index.html.twig | 46 ++++++ templates/admin/partners/new.html.twig | 12 ++ .../Admin/PartnerControllerTest.php | 140 ++++++++++++++++++ tests/Controller/SmokeTest.php | 2 + translations/messages.da.yaml | 35 ++++- translations/messages.en.yaml | 35 ++++- 11 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 src/Controller/Admin/PartnerController.php create mode 100644 src/Form/PartnerType.php create mode 100644 templates/admin/partners/_form.html.twig create mode 100644 templates/admin/partners/edit.html.twig create mode 100644 templates/admin/partners/index.html.twig create mode 100644 templates/admin/partners/new.html.twig create mode 100644 tests/Controller/Admin/PartnerControllerTest.php diff --git a/src/Controller/Admin/PartnerController.php b/src/Controller/Admin/PartnerController.php new file mode 100644 index 0000000..b59421a --- /dev/null +++ b/src/Controller/Admin/PartnerController.php @@ -0,0 +1,78 @@ +render('admin/partners/index.html.twig', [ + 'partners' => $partners->findAllOrdered(), + ]); + } + + #[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): 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, + ]); + } + + #[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'); + } +} diff --git a/src/Form/PartnerType.php b/src/Form/PartnerType.php new file mode 100644 index 0000000..b04b014 --- /dev/null +++ b/src/Form/PartnerType.php @@ -0,0 +1,47 @@ + + */ +class PartnerType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('name', TextType::class, [ + 'label' => 'partner.name', + 'help' => 'partner.name_help', + ]) + ->add('description', TextareaType::class, [ + 'label' => 'partner.description', + 'required' => false, + 'attr' => ['rows' => 4], + 'help' => 'partner.description_help', + ]) + ->add('website', UrlType::class, [ + 'label' => 'partner.website', + 'required' => false, + 'default_protocol' => 'https', + 'help' => 'partner.website_help', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Partner::class, + ]); + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 6fc9042..878155e 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -8,6 +8,7 @@ {{ 'nav.users'|trans }} {% endif %} {{ 'nav.contacts'|trans }} + {{ 'nav.partners'|trans }} {{ 'nav.departments'|trans }} {{ 'nav.areas'|trans }} diff --git a/templates/admin/partners/_form.html.twig b/templates/admin/partners/_form.html.twig new file mode 100644 index 0000000..6bcad63 --- /dev/null +++ b/templates/admin/partners/_form.html.twig @@ -0,0 +1,12 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.name) }} + {{ form_row(form.description) }} + {{ form_row(form.website) }} +
+
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/admin/partners/edit.html.twig b/templates/admin/partners/edit.html.twig new file mode 100644 index 0000000..d849f2e --- /dev/null +++ b/templates/admin/partners/edit.html.twig @@ -0,0 +1,16 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + +
+ + +
+
+ {{ include('admin/partners/_form.html.twig', {button_label: 'action.save'}) }} +{% endblock %} diff --git a/templates/admin/partners/index.html.twig b/templates/admin/partners/index.html.twig new file mode 100644 index 0000000..cb5e62d --- /dev/null +++ b/templates/admin/partners/index.html.twig @@ -0,0 +1,46 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ 'action.new'|trans }} + + +
+ {% if partners is empty %} + + {% else %} +
+ + + + + + + + + + + {% for partner in partners %} + + + + + + + {% endfor %} + +
{{ 'partner.name'|trans }}{{ 'partner.description'|trans }}{{ 'partner.website'|trans }}
{{ partner.name }}{{ partner.description ? partner.description|slice(0, 80) ~ (partner.description|length > 80 ? '…') : '—' }}{% if partner.website %}{{ partner.website }}{% else %}—{% endif %} +
+ {{ 'action.edit'|trans }} +
+ + +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/partners/new.html.twig b/templates/admin/partners/new.html.twig new file mode 100644 index 0000000..0cee7c0 --- /dev/null +++ b/templates/admin/partners/new.html.twig @@ -0,0 +1,12 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + + {{ include('admin/partners/_form.html.twig', {button_label: 'action.create'}) }} +{% endblock %} diff --git a/tests/Controller/Admin/PartnerControllerTest.php b/tests/Controller/Admin/PartnerControllerTest.php new file mode 100644 index 0000000..159d571 --- /dev/null +++ b/tests/Controller/Admin/PartnerControllerTest.php @@ -0,0 +1,140 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/partners'); + + $this->assertResponseIsSuccessful(); + } + + public function testNewCreatesPartner(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/partners/new'); + $this->assertResponseIsSuccessful(); + + $name = 'Test Partner '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form([ + 'partner[name]' => $name, + 'partner[description]' => 'A partner created in a test.', + 'partner[website]' => 'https://example.com', + ]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/partners'); + + $partner = $this->partners()->findOneBy(['name' => $name]); + self::assertInstanceOf(Partner::class, $partner); + self::assertSame('A partner created in a test.', $partner->getDescription()); + self::assertSame('https://example.com', $partner->getWebsite()); + + $this->removePartner((string) $partner->getId()); + } + + public function testNewRejectsADuplicateName(): void + { + $this->loginAsAdmin(); + $name = 'Duplicate Partner '.uniqid(); + $id = (string) $this->createPartner($name)->getId(); + + $crawler = $this->client->request('GET', '/admin/partners/new'); + $form = $crawler->filter('button.btn--primary')->form(['partner[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->partners()->findBy(['name' => $name])); + + $this->removePartner($id); + } + + public function testNewRejectsANonHttpWebsite(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/partners/new'); + + $name = 'Bad Website Partner '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form([ + 'partner[name]' => $name, + 'partner[website]' => 'javascript:alert(1)', + ]); + $this->client->submit($form); + + $this->assertResponseStatusCodeSame(422); + self::assertCount(0, $this->partners()->findBy(['name' => $name])); + } + + public function testEditUpdatesPartner(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createPartner('Editable Partner '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/partners/%s/edit', $id)); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form(['partner[name]' => 'Edited Partner '.uniqid()]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/partners'); + $this->removePartner($id); + } + + public function testDeleteRemovesPartnerWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createPartner('Deletable Partner '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/partners/%s/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/partners'); + $this->entityManager()->clear(); + self::assertNull($this->partners()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createPartner('Surviving Partner '.uniqid())->getId(); + + $this->client->request('POST', sprintf('/admin/partners/%s/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/admin/partners'); + $this->entityManager()->clear(); + self::assertNotNull($this->partners()->find($id)); + $this->removePartner($id); + } + + private function createPartner(string $name): Partner + { + $partner = (new Partner())->setName($name); + $em = $this->entityManager(); + $em->persist($partner); + $em->flush(); + + return $partner; + } + + private function removePartner(string $id): void + { + $this->entityManager()->clear(); + $partner = $this->partners()->find($id); + if (null !== $partner) { + $em = $this->entityManager(); + $em->remove($partner); + $em->flush(); + } + } +} diff --git a/tests/Controller/SmokeTest.php b/tests/Controller/SmokeTest.php index 02abec7..02eb87a 100644 --- a/tests/Controller/SmokeTest.php +++ b/tests/Controller/SmokeTest.php @@ -96,6 +96,8 @@ public static function authenticatedPages(): iterable yield 'admin user new' => ['/admin/users/new']; yield 'admin contacts' => ['/admin/contacts']; yield 'admin contact new' => ['/admin/contacts/new']; + yield 'admin partners' => ['/admin/partners']; + yield 'admin partner new' => ['/admin/partners/new']; yield 'admin departments' => ['/admin/departments']; yield 'admin department new' => ['/admin/departments/new']; } diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index e1cdcbe..d2fed5f 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -58,14 +58,14 @@ tour: intro: "Det her er Projektdatabasen — her samler vi afdelingernes initiativer og projekter ét sted, så vi kan se, hvad der sker på tværs." dashboard: "På overblikket ser du nøgletal, status og eventuelle muligheder for samarbejds på tværs af afdelinger." create: "Se alle initiativer via menupunktet Initiativer — eller opret et nyt med det samme via Opret-knappen." - admin: "Inde i menuen her finder du menupunktet Administration, hvor du kan oprette, se og redigere afdelinger, områder og kontaktpersoner." + admin: "Inde i menuen her finder du menupunktet Administration, hvor du kan oprette, se og redigere afdelinger, områder, samarbejdspartnere og kontaktpersoner." outro: "Så er du klar! Jeg er altid lige her i hjørnet, hvis du får brug for et tip. 🌟" autosave: saving: Gemmer… saved: Gemt unsaved: Ikke-gemte ændringer - required: Tilføj en titel for at gemme. + required: Tilføj en titel og mindst én samarbejdspartner for at gemme. error: Kunne ikke gemme — tjek de påkrævede felter offline: Kunne ikke gemme — dine ændringer er bevaret her completion: Udfyldningsgrad @@ -83,6 +83,7 @@ nav: dashboard: Overblik initiatives: Initiativer contacts: Kontaktpersoner + partners: Samarbejdspartnere departments: Afdelinger areas: Områder users: Brugere @@ -212,6 +213,9 @@ initiative: time_period: Tidshorisont links: Relevante links contacts: Kontaktpersoner + partners: Samarbejdspartnere + partners_help: Vælg en eksisterende samarbejdspartner, eller skriv en ny og tryk Enter. Mindst én er påkrævet. + partners_required: Tilknyt mindst én samarbejdspartner. author: Udfyldt af terms_help: Vælg eksisterende, eller skriv en ny og tryk Enter. completion_field_hint: Dette felt tæller med i udfyldningsgraden. @@ -230,9 +234,11 @@ initiative: show: details: Detaljer contacts: Kontaktpersoner + partners: Samarbejdspartnere classification: Klassificering media: Filer og billeder no_contacts: Ingen kontaktpersoner tilknyttet. + no_partners: Ingen samarbejdspartnere tilknyttet. empty: title: Ingen initiativer fundet hint: Prøv at justere dine filtre, eller opret et nyt initiativ. @@ -241,7 +247,7 @@ initiative: classification: Klassificering endorsement: Vedtagelse economy: Økonomi og tid - relations: Interessenter og kontakter + relations: Interessenter, samarbejdspartnere og kontakter media: Filer og billeder meta: Øvrigt @@ -265,6 +271,25 @@ contact: title: Ingen kontaktpersoner endnu hint: Opret en kontaktperson, eller tilføj en direkte på et initiativ. +partner: + name: Navn + name_help: Samarbejdspartnerens navn, som det vises på initiativer. + name_duplicate: Der findes allerede en samarbejdspartner med dette navn. + description: Beskrivelse + description_help: En kort beskrivelse af samarbejdspartneren. + website: Hjemmeside + website_help: Link til samarbejdspartnerens hjemmeside, hvis den findes. + index: + title: Samarbejdspartnere + subtitle: Samarbejdspartnere der kan tilknyttes initiativer. + new: + title: Ny samarbejdspartner + edit: + title: Rediger samarbejdspartner + empty: + title: Ingen samarbejdspartnere endnu + hint: Opret en samarbejdspartner, eller tilføj en direkte på et initiativ. + department: name: Navn name_help: Afdelingens navn, som det vises på initiativer. @@ -334,6 +359,10 @@ flash: created: Kontaktpersonen blev oprettet. updated: Kontaktpersonen blev opdateret. deleted: Kontaktpersonen blev slettet. + partner: + created: Samarbejdspartneren blev oprettet. + updated: Samarbejdspartneren blev opdateret. + deleted: Samarbejdspartneren blev slettet. department: created: Afdelingen blev oprettet. updated: Afdelingen blev opdateret. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 607bf6d..d57fdfc 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -58,14 +58,14 @@ tour: intro: "This is the Project database — where we gather the departments' initiatives and projects in one place, so we can see what's happening across them." dashboard: "The overview shows key figures, status and any opportunities for collaboration across departments." create: "Browse every initiative from the Initiativer menu — or create a new one right away with the Opret button." - admin: "Inside this menu you'll find Administration, where you can create, view and edit departments, areas and contacts." + admin: "Inside this menu you'll find Administration, where you can create, view and edit departments, areas, partners and contacts." outro: "You're all set! I'm always right here in the corner if you need a tip. 🌟" autosave: saving: Saving… saved: Saved unsaved: Unsaved changes - required: Add a title to save. + required: Add a title and at least one partner to save. error: Couldn’t save — check the required fields offline: Save failed — your changes are kept here completion: Form completion @@ -83,6 +83,7 @@ nav: dashboard: Overview initiatives: Initiatives contacts: Contacts + partners: Partners departments: Departments areas: Areas users: Users @@ -212,6 +213,9 @@ initiative: time_period: Time period links: Relevant links contacts: Contacts + partners: Partners + partners_help: Choose an existing partner, or type a new and press Enter. At least one is required. + partners_required: Attach at least one partner. author: Filled out by terms_help: Choose an existing one, or type a new and press Enter. completion_field_hint: This field counts towards the completion rate. @@ -230,9 +234,11 @@ initiative: show: details: Details contacts: Contacts + partners: Partners classification: Classification media: Files and images no_contacts: No contacts attached. + no_partners: No partners attached. empty: title: No initiatives found hint: Try adjusting your filters, or create a new initiative. @@ -241,7 +247,7 @@ initiative: classification: Classification endorsement: Endorsement economy: Economy and time - relations: Stakeholders and contacts + relations: Stakeholders, partners and contacts media: Files and images meta: Other @@ -265,6 +271,25 @@ contact: title: No contacts yet hint: Create a contact, or add one directly on an initiative. +partner: + name: Name + name_help: The partner's name as shown on initiatives. + name_duplicate: A partner with this name already exists. + description: Description + description_help: A brief description of the partner. + website: Website + website_help: Link to the partner's website, if any. + index: + title: Partners + subtitle: Partners that can be attached to initiatives. + new: + title: New partner + edit: + title: Edit partner + empty: + title: No partners yet + hint: Create a partner, or add one directly on an initiative. + department: name: Name name_help: The department name as shown on initiatives. @@ -334,6 +359,10 @@ flash: created: The contact was created. updated: The contact was updated. deleted: The contact was deleted. + partner: + created: The partner was created. + updated: The partner was updated. + deleted: The partner was deleted. department: created: The department was created. updated: The department was updated. From a04162bd5abdc7baa557a2842ecd579937a1bacf Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 11:20:37 +0200 Subject: [PATCH 03/21] Require one or more partners on initiatives Co-Authored-By: Claude Opus 5 (1M context) --- assets/app.js | 9 +++ src/DataFixtures/AppFixtures.php | 16 ++++ src/Entity/Initiative.php | 40 ++++++++++ .../PartnersTextTransformer.php | 68 +++++++++++++++++ src/Form/InitiativeType.php | 5 ++ src/Form/PartnersTextType.php | 73 +++++++++++++++++++ templates/initiative/_form.html.twig | 2 + templates/initiative/show.html.twig | 19 +++++ tests/Controller/InitiativeControllerTest.php | 37 +++++++++- tests/FunctionalTestCase.php | 9 +++ tests/Repository/PartnerRepositoryTest.php | 59 +++++++++++++++ tests/Unit/Entity/InitiativeTest.php | 20 +++++ .../PartnersTextTransformerTest.php | 56 ++++++++++++++ 13 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 src/Form/DataTransformer/PartnersTextTransformer.php create mode 100644 src/Form/PartnersTextType.php create mode 100644 tests/Repository/PartnerRepositoryTest.php create mode 100644 tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php diff --git a/assets/app.js b/assets/app.js index 1c342a4..b1a4922 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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 @@ -142,6 +150,7 @@ document.addEventListener("click", (event) => { document.addEventListener("turbo:load", () => { initCollections(); initContactSelect(); + initPartnerSelect(); initTermSelect(); }); diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index aa04607..2995873 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -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; @@ -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) @@ -69,6 +71,16 @@ 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.') + ->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']; @@ -149,6 +161,10 @@ public function load(ObjectManager $manager): void foreach (\array_slice($this->shuffleCopy($contacts), 0, mt_rand(1, 3)) as $contact) { $initiative->addContact($contact); } + // Every initiative gets at least one partner — the form requires it. + foreach (\array_slice($this->shuffleCopy($partners), 0, mt_rand(1, 3)) as $partner) { + $initiative->addPartner($partner); + } $initiative->setLinks(['https://www.aarhus.dk']); diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index a274d9c..8f6e997 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -71,6 +71,12 @@ class Initiative extends AbstractEntity #[ORM\JoinTable(name: 'initiative_contact')] private Collection $contacts; + /** @var Collection */ + #[Assert\Count(min: 1, minMessage: 'initiative.partners_required')] + #[ORM\ManyToMany(targetEntity: Partner::class, cascade: ['persist'])] + #[ORM\JoinTable(name: 'initiative_partner')] + private Collection $partners; + /** @var Collection */ #[ORM\OneToMany(targetEntity: InitiativeImage::class, mappedBy: 'initiative', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $images; @@ -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(); @@ -285,6 +292,39 @@ public function removeContact(Contact $contact): static return $this; } + /** @return Collection */ + 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; + } + + /** @param iterable $partners */ + public function setPartners(iterable $partners): static + { + $this->partners->clear(); + foreach ($partners as $partner) { + $this->addPartner($partner); + } + + return $this; + } + /** @return Collection */ public function getImages(): Collection { diff --git a/src/Form/DataTransformer/PartnersTextTransformer.php b/src/Form/DataTransformer/PartnersTextTransformer.php new file mode 100644 index 0000000..635b4af --- /dev/null +++ b/src/Form/DataTransformer/PartnersTextTransformer.php @@ -0,0 +1,68 @@ + + */ +final readonly class PartnersTextTransformer implements DataTransformerInterface +{ + public function __construct(private PartnerRepository $partnerRepository) + { + } + + public function transform(mixed $value): string + { + if (!is_iterable($value)) { + return ''; + } + + $names = []; + foreach ($value as $partner) { + if ($partner instanceof Partner) { + $names[] = $partner->getName(); + } + } + + return implode(', ', $names); + } + + /** + * @return Collection + */ + public function reverseTransform(mixed $value): Collection + { + $partners = new ArrayCollection(); + + if (!\is_string($value) || '' === trim($value)) { + return $partners; + } + + $seen = []; + foreach (explode(',', $value) as $name) { + $name = trim($name); + $key = mb_strtolower($name); + if ('' === $name || isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $partners->add($this->partnerRepository->findOrCreate($name)); + } + + return $partners; + } +} diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index 29a8d35..cf18145 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -164,6 +164,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'initiative.terms_help', ]) + ->add('partners', PartnersTextType::class, [ + 'label' => 'initiative.partners', + 'required' => true, + 'help' => 'initiative.partners_help', + ]) ->add('images', CollectionType::class, [ 'label' => 'initiative.images', 'entry_type' => InitiativeImageType::class, diff --git a/src/Form/PartnersTextType.php b/src/Form/PartnersTextType.php new file mode 100644 index 0000000..7600515 --- /dev/null +++ b/src/Form/PartnersTextType.php @@ -0,0 +1,73 @@ + + */ +final class PartnersTextType extends AbstractType +{ + public function __construct(private readonly PartnerRepository $partnerRepository) + { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new PartnersTextTransformer($this->partnerRepository)); + } + + /** + * Expose the existing partners so the client can offer them as a searchable + * pool (and let new ones join it). The names are rendered as a JSON data + * attribute the Tom Select initialiser reads. + * + * A required field also gets the autosave marker, so the client holds back an + * initiative that has no partner yet instead of posting it and reporting a + * save error. + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $pool = array_map( + static fn (Partner $partner): string => (string) $partner->getName(), + $this->partnerRepository->findAllOrdered(), + ); + + $attr = [ + 'data-partner-select' => '', + 'data-partner-pool' => json_encode($pool, \JSON_THROW_ON_ERROR), + ]; + + if ($options['required']) { + $attr['data-autosave-required'] = 'true'; + } + + $view->vars['attr'] = array_merge($view->vars['attr'], $attr); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'invalid_message' => 'form.terms.invalid', + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 3cecdae..ef27bd9 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -102,6 +102,8 @@
{{ 'initiative.section.relations'|trans }}
{{ form_row(form.stakeholders) }} + {{ form_row(form.partners) }} + {{ form_row(form.contacts) }} diff --git a/templates/initiative/show.html.twig b/templates/initiative/show.html.twig index 1102366..df1c2bd 100644 --- a/templates/initiative/show.html.twig +++ b/templates/initiative/show.html.twig @@ -62,6 +62,25 @@ +
+ +
+ {% if initiative.partners|length > 0 %} + {% for partner in initiative.partners %} +
+
{{ partner.name }}
+ {% if partner.description %}
{{ partner.description }}
{% endif %} + {% if partner.website %} + + {% endif %} +
+ {% endfor %} + {% else %} +

{{ 'initiative.show.no_partners'|trans }}

+ {% endif %} +
+
+ {% if initiative.images|length > 0 or initiative.attachments|length > 0 %}
diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index f3b1f4f..b331317 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -24,6 +24,8 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): 'title' => 'Coverage initiative', // A typed name creates a new contact on the fly and attaches it. 'contacts' => 'Coverage Contact', + // Same free-tagging behaviour for partners, of which at least one is required. + 'partners' => 'Coverage Partner', '_token' => $token, ], ]); @@ -34,6 +36,7 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $initiative = $this->initiatives()->findOneBy(['title' => 'Coverage initiative']); self::assertInstanceOf(Initiative::class, $initiative); self::assertGreaterThanOrEqual(1, $initiative->getContacts()->count(), 'Inline contact should be merged in.'); + self::assertGreaterThanOrEqual(1, $initiative->getPartners()->count(), 'Inline partner should be merged in.'); $em->remove($initiative); $em->flush(); @@ -44,6 +47,25 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $em->flush(); } + public function testNewRejectsAnInitiativeWithoutAPartner(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/initiatives/new'); + + $token = (string) $crawler->filter('input[name="initiative[_token]"]')->attr('value'); + $this->client->request('POST', '/initiatives/new', [ + 'initiative' => [ + 'title' => 'Partnerless initiative', + 'partners' => '', + '_token' => $token, + ], + ]); + + // Assert\Count(min: 1) rejects it: the form redisplays with a 422 and nothing is saved. + $this->assertResponseStatusCodeSame(422); + self::assertNull($this->initiatives()->findOneBy(['title' => 'Partnerless initiative'])); + } + public function testEditUpdatesInitiative(): void { $this->loginAsAdmin(); @@ -57,6 +79,7 @@ public function testEditUpdatesInitiative(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Edited initiative', + 'partners' => 'Coverage Partner', 'images' => [['imageFile' => '']], 'attachments' => [['file' => '']], '_token' => $token, @@ -113,6 +136,7 @@ public function testEditDropsAttachmentsLeftWithoutAFile(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Has empty attachment', + 'partners' => 'Coverage Partner', // Re-submit the file-less attachment (empty file, no upload) so the // form keeps it; the controller's removeEmptyMedia() then drops it. 'attachments' => [['file' => '']], @@ -144,6 +168,7 @@ public function testEditDropsImagesLeftWithoutAFile(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Has empty image', + 'partners' => 'Coverage Partner', // Re-submit the file-less image so the form keeps it; the // controller's removeEmptyMedia() then drops it. 'images' => [['imageFile' => '']], @@ -167,7 +192,7 @@ public function testNewAutosaveReturnsCreatedWithLocationHeader(): void // Autosave posts via fetch with the X-Autosave header and no CSRF token. $this->client->request('POST', '/initiatives/new', [ - 'initiative' => ['title' => 'Autosaved initiative'], + 'initiative' => ['title' => 'Autosaved initiative', 'partners' => 'Coverage Partner'], ], [], ['HTTP_X-Autosave' => '1']); $this->assertResponseStatusCodeSame(Response::HTTP_CREATED); @@ -199,7 +224,7 @@ public function testEditAutosaveReturnsNoContent(): void $id = (string) $initiative->getId(); $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ - 'initiative' => ['title' => 'Autosave edited'], + 'initiative' => ['title' => 'Autosave edited', 'partners' => 'Coverage Partner'], ], [], ['HTTP_X-Autosave' => '1']); $this->assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); @@ -222,10 +247,16 @@ public function testEditAutosaveReturnsUnprocessableWhenInvalid(): void $this->removeInitiative($id); } + /** + * An initiative must have at least one partner, so give it one up front — + * otherwise every form post in these tests fails validation. + */ private function createInitiative(string $title): Initiative { - $initiative = (new Initiative())->setTitle($title); $em = $this->entityManager(); + $initiative = (new Initiative()) + ->setTitle($title) + ->addPartner($this->partners()->findOrCreate('Coverage Partner')); $em->persist($initiative); $em->flush(); diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 0e1d48a..a235d81 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -9,6 +9,7 @@ use App\Repository\ContactRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; +use App\Repository\PartnerRepository; use App\Repository\TermRepository; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; @@ -62,6 +63,14 @@ protected function contacts(): ContactRepository return $repository; } + protected function partners(): PartnerRepository + { + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + + return $repository; + } + protected function departments(): DepartmentRepository { $repository = static::getContainer()->get(DepartmentRepository::class); diff --git a/tests/Repository/PartnerRepositoryTest.php b/tests/Repository/PartnerRepositoryTest.php new file mode 100644 index 0000000..6d93ff4 --- /dev/null +++ b/tests/Repository/PartnerRepositoryTest.php @@ -0,0 +1,59 @@ +get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + + $partners = $repository->findAllOrdered(); + + // Ordering is delegated to the database collation, so we only assert the + // method returns the persisted partners. + self::assertNotEmpty($partners); + } + + public function testFindOrCreateReturnsAnExistingPartnerCaseInsensitively(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $name = 'Findme Partner '.uniqid(); + $partner = (new Partner())->setName($name); + $em->persist($partner); + $em->flush(); + + $found = $repository->findOrCreate(mb_strtolower($name)); + self::assertSame($partner->getId(), $found->getId()); + + $em->remove($partner); + $em->flush(); + } + + public function testFindOrCreateBuildsANewUnflushedPartner(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + + $name = 'BrandNewPartner-'.uniqid(); + $partner = $repository->findOrCreate($name); + + self::assertSame($name, $partner->getName()); + self::assertCount(0, $repository->findBy(['name' => $name]), 'A freshly created partner is not yet flushed to the database.'); + } +} diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index 9613b3f..51d69a3 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -10,6 +10,7 @@ use App\Entity\Initiative; use App\Entity\InitiativeAttachment; use App\Entity\InitiativeImage; +use App\Entity\Partner; use App\Entity\Term; use App\Enum\EndorsementAuthor; use App\Enum\Funding; @@ -32,6 +33,7 @@ public function testDefaults(): void self::assertCount(0, $initiative->getStakeholders()); self::assertCount(0, $initiative->getTags()); self::assertCount(0, $initiative->getContacts()); + self::assertCount(0, $initiative->getPartners()); self::assertCount(0, $initiative->getImages()); self::assertCount(0, $initiative->getAttachments()); self::assertNull($initiative->getCreatedAt()); @@ -203,6 +205,24 @@ public function testContactCollection(): void self::assertCount(0, $initiative->getContacts()); } + public function testPartnerCollection(): void + { + $initiative = new Initiative(); + $partner = (new Partner())->setName('Aarhus Universitet'); + + $initiative->addPartner($partner); + $initiative->addPartner($partner); + self::assertCount(1, $initiative->getPartners()); + + $initiative->removePartner($partner); + self::assertCount(0, $initiative->getPartners()); + + $initiative->setPartners([(new Partner())->setName('A'), (new Partner())->setName('B')]); + self::assertCount(2, $initiative->getPartners()); + $initiative->setPartners([]); + self::assertCount(0, $initiative->getPartners()); + } + public function testImageCollectionLinksBackToInitiative(): void { $initiative = new Initiative(); diff --git a/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php new file mode 100644 index 0000000..15dda9b --- /dev/null +++ b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php @@ -0,0 +1,56 @@ +transformer()->transform(null)); + } + + public function testTransformJoinsPartnerNames(): void + { + $partners = [ + (new Partner())->setName('Aarhus Universitet'), + (new Partner())->setName('Alexandra Instituttet'), + ]; + + self::assertSame('Aarhus Universitet, Alexandra Instituttet', $this->transformer()->transform($partners)); + } + + public function testReverseTransformOfNonStringReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(null)); + } + + public function testReverseTransformOfBlankReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(' ')); + } + + public function testReverseTransformTrimsDeduplicatesAndResolvesPartners(): void + { + $repository = $this->createMock(PartnerRepository::class); + $repository->expects(self::exactly(2)) + ->method('findOrCreate') + ->willReturnCallback(static fn (string $name): Partner => (new Partner())->setName($name)); + + $transformer = new PartnersTextTransformer($repository); + + // "aarhus universitet" duplicates "Aarhus Universitet" (case-insensitive) and the empty segment is skipped. + self::assertCount(2, $transformer->reverseTransform('Aarhus Universitet, Alexandra Instituttet, , aarhus universitet')); + } + + private function transformer(): PartnersTextTransformer + { + return new PartnersTextTransformer($this->createStub(PartnerRepository::class)); + } +} From 85c815d75abc902f18a79d3eacfd3358a27d0171 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 11:20:42 +0200 Subject: [PATCH 04/21] Add partners to CSV export Co-Authored-By: Claude Opus 5 (1M context) --- src/Controller/InitiativeController.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Controller/InitiativeController.php b/src/Controller/InitiativeController.php index 7b2f8d0..7f81362 100644 --- a/src/Controller/InitiativeController.php +++ b/src/Controller/InitiativeController.php @@ -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))); @@ -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, ]); } From b4e1481f3d3fc3137fd5d4a2b3a01840d1ed7d28 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 11:20:42 +0200 Subject: [PATCH 05/21] Update changelog Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b75412..6c1a5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* Add a Partner entity (name, description, website) with an admin CRUD, and + require every initiative to have at least one partner — attached through a + searchable multiselect that can create new partners on the fly. + ## [0.2.0] - 2026-06-30 * [PR-23](https://github.com/itk-dev/itk-projects/pull/23) From 354716063c631444560dbb1558d8c0edb9a4e9be Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 12:41:40 +0200 Subject: [PATCH 06/21] Make partners optional on initiatives Co-Authored-By: Claude Opus 5 (1M context) --- src/Entity/Initiative.php | 13 +------ src/Form/InitiativeType.php | 2 +- src/Form/PartnersTextType.php | 14 +------ tests/Controller/InitiativeControllerTest.php | 39 ++++--------------- tests/Unit/Entity/InitiativeTest.php | 5 --- translations/messages.da.yaml | 5 +-- translations/messages.en.yaml | 5 +-- 7 files changed, 15 insertions(+), 68 deletions(-) diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index 8f6e997..e2f995c 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -72,7 +72,7 @@ class Initiative extends AbstractEntity private Collection $contacts; /** @var Collection */ - #[Assert\Count(min: 1, minMessage: 'initiative.partners_required')] + #[Assert\Valid] #[ORM\ManyToMany(targetEntity: Partner::class, cascade: ['persist'])] #[ORM\JoinTable(name: 'initiative_partner')] private Collection $partners; @@ -314,17 +314,6 @@ public function removePartner(Partner $partner): static return $this; } - /** @param iterable $partners */ - public function setPartners(iterable $partners): static - { - $this->partners->clear(); - foreach ($partners as $partner) { - $this->addPartner($partner); - } - - return $this; - } - /** @return Collection */ public function getImages(): Collection { diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index cf18145..df9453d 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -166,7 +166,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('partners', PartnersTextType::class, [ 'label' => 'initiative.partners', - 'required' => true, + 'required' => false, 'help' => 'initiative.partners_help', ]) ->add('images', CollectionType::class, [ diff --git a/src/Form/PartnersTextType.php b/src/Form/PartnersTextType.php index 7600515..004dc2c 100644 --- a/src/Form/PartnersTextType.php +++ b/src/Form/PartnersTextType.php @@ -35,10 +35,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void * Expose the existing partners so the client can offer them as a searchable * pool (and let new ones join it). The names are rendered as a JSON data * attribute the Tom Select initialiser reads. - * - * A required field also gets the autosave marker, so the client holds back an - * initiative that has no partner yet instead of posting it and reporting a - * save error. */ public function buildView(FormView $view, FormInterface $form, array $options): void { @@ -47,16 +43,10 @@ public function buildView(FormView $view, FormInterface $form, array $options): $this->partnerRepository->findAllOrdered(), ); - $attr = [ + $view->vars['attr'] = array_merge($view->vars['attr'], [ 'data-partner-select' => '', 'data-partner-pool' => json_encode($pool, \JSON_THROW_ON_ERROR), - ]; - - if ($options['required']) { - $attr['data-autosave-required'] = 'true'; - } - - $view->vars['attr'] = array_merge($view->vars['attr'], $attr); + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index b331317..a47d03d 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -24,7 +24,7 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): 'title' => 'Coverage initiative', // A typed name creates a new contact on the fly and attaches it. 'contacts' => 'Coverage Contact', - // Same free-tagging behaviour for partners, of which at least one is required. + // Same free-tagging behaviour for partners. 'partners' => 'Coverage Partner', '_token' => $token, ], @@ -44,28 +44,12 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): foreach ($this->contacts()->findBy(['name' => 'Coverage Contact']) as $contact) { $em->remove($contact); } + foreach ($this->partners()->findBy(['name' => 'Coverage Partner']) as $partner) { + $em->remove($partner); + } $em->flush(); } - public function testNewRejectsAnInitiativeWithoutAPartner(): void - { - $this->loginAsAdmin(); - $crawler = $this->client->request('GET', '/initiatives/new'); - - $token = (string) $crawler->filter('input[name="initiative[_token]"]')->attr('value'); - $this->client->request('POST', '/initiatives/new', [ - 'initiative' => [ - 'title' => 'Partnerless initiative', - 'partners' => '', - '_token' => $token, - ], - ]); - - // Assert\Count(min: 1) rejects it: the form redisplays with a 422 and nothing is saved. - $this->assertResponseStatusCodeSame(422); - self::assertNull($this->initiatives()->findOneBy(['title' => 'Partnerless initiative'])); - } - public function testEditUpdatesInitiative(): void { $this->loginAsAdmin(); @@ -79,7 +63,6 @@ public function testEditUpdatesInitiative(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Edited initiative', - 'partners' => 'Coverage Partner', 'images' => [['imageFile' => '']], 'attachments' => [['file' => '']], '_token' => $token, @@ -136,7 +119,6 @@ public function testEditDropsAttachmentsLeftWithoutAFile(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Has empty attachment', - 'partners' => 'Coverage Partner', // Re-submit the file-less attachment (empty file, no upload) so the // form keeps it; the controller's removeEmptyMedia() then drops it. 'attachments' => [['file' => '']], @@ -168,7 +150,6 @@ public function testEditDropsImagesLeftWithoutAFile(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Has empty image', - 'partners' => 'Coverage Partner', // Re-submit the file-less image so the form keeps it; the // controller's removeEmptyMedia() then drops it. 'images' => [['imageFile' => '']], @@ -192,7 +173,7 @@ public function testNewAutosaveReturnsCreatedWithLocationHeader(): void // Autosave posts via fetch with the X-Autosave header and no CSRF token. $this->client->request('POST', '/initiatives/new', [ - 'initiative' => ['title' => 'Autosaved initiative', 'partners' => 'Coverage Partner'], + 'initiative' => ['title' => 'Autosaved initiative'], ], [], ['HTTP_X-Autosave' => '1']); $this->assertResponseStatusCodeSame(Response::HTTP_CREATED); @@ -224,7 +205,7 @@ public function testEditAutosaveReturnsNoContent(): void $id = (string) $initiative->getId(); $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ - 'initiative' => ['title' => 'Autosave edited', 'partners' => 'Coverage Partner'], + 'initiative' => ['title' => 'Autosave edited'], ], [], ['HTTP_X-Autosave' => '1']); $this->assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT); @@ -247,16 +228,10 @@ public function testEditAutosaveReturnsUnprocessableWhenInvalid(): void $this->removeInitiative($id); } - /** - * An initiative must have at least one partner, so give it one up front — - * otherwise every form post in these tests fails validation. - */ private function createInitiative(string $title): Initiative { + $initiative = (new Initiative())->setTitle($title); $em = $this->entityManager(); - $initiative = (new Initiative()) - ->setTitle($title) - ->addPartner($this->partners()->findOrCreate('Coverage Partner')); $em->persist($initiative); $em->flush(); diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index 51d69a3..7a65c6c 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -216,11 +216,6 @@ public function testPartnerCollection(): void $initiative->removePartner($partner); self::assertCount(0, $initiative->getPartners()); - - $initiative->setPartners([(new Partner())->setName('A'), (new Partner())->setName('B')]); - self::assertCount(2, $initiative->getPartners()); - $initiative->setPartners([]); - self::assertCount(0, $initiative->getPartners()); } public function testImageCollectionLinksBackToInitiative(): void diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index d2fed5f..5973eb9 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -65,7 +65,7 @@ autosave: saving: Gemmer… saved: Gemt unsaved: Ikke-gemte ændringer - required: Tilføj en titel og mindst én samarbejdspartner for at gemme. + required: Tilføj en titel for at gemme. error: Kunne ikke gemme — tjek de påkrævede felter offline: Kunne ikke gemme — dine ændringer er bevaret her completion: Udfyldningsgrad @@ -214,8 +214,7 @@ initiative: links: Relevante links contacts: Kontaktpersoner partners: Samarbejdspartnere - partners_help: Vælg en eksisterende samarbejdspartner, eller skriv en ny og tryk Enter. Mindst én er påkrævet. - partners_required: Tilknyt mindst én samarbejdspartner. + partners_help: Vælg en eksisterende samarbejdspartner, eller skriv en ny og tryk Enter. author: Udfyldt af terms_help: Vælg eksisterende, eller skriv en ny og tryk Enter. completion_field_hint: Dette felt tæller med i udfyldningsgraden. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index d57fdfc..9dcea89 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -65,7 +65,7 @@ autosave: saving: Saving… saved: Saved unsaved: Unsaved changes - required: Add a title and at least one partner to save. + required: Add a title to save. error: Couldn’t save — check the required fields offline: Save failed — your changes are kept here completion: Form completion @@ -214,8 +214,7 @@ initiative: links: Relevant links contacts: Contacts partners: Partners - partners_help: Choose an existing partner, or type a new and press Enter. At least one is required. - partners_required: Attach at least one partner. + partners_help: Choose an existing partner, or type a new and press Enter. author: Filled out by terms_help: Choose an existing one, or type a new and press Enter. completion_field_hint: This field counts towards the completion rate. From 3a4653394544cfb7b312250a841021bfa7844a5d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 12:42:00 +0200 Subject: [PATCH 07/21] Validate partner name and website Co-Authored-By: Claude Opus 5 (1M context) --- src/Entity/Partner.php | 5 +++++ tests/Controller/Admin/PartnerControllerTest.php | 15 +++++++++++++++ translations/messages.da.yaml | 1 + translations/messages.en.yaml | 1 + 4 files changed, 22 insertions(+) diff --git a/src/Entity/Partner.php b/src/Entity/Partner.php index e6007bf..cbdd072 100644 --- a/src/Entity/Partner.php +++ b/src/Entity/Partner.php @@ -15,6 +15,10 @@ class Partner extends AbstractEntity { #[Assert\NotBlank] + #[Assert\Length(max: 255)] + // Comma is the separator of the free-tagging field on the initiative form, so a + // name containing one would be split into two partners on the next edit. + #[Assert\Regex(pattern: '/,/', match: false, message: 'partner.name_comma')] #[ORM\Column(length: 255, unique: true)] private ?string $name = null; @@ -23,6 +27,7 @@ class Partner extends AbstractEntity // Rejects non-http(s) URLs (e.g. javascript:) since the value is rendered as a link. #[Assert\Url(protocols: ['http', 'https'])] + #[Assert\Length(max: 255)] #[ORM\Column(length: 255, nullable: true)] private ?string $website = null; diff --git a/tests/Controller/Admin/PartnerControllerTest.php b/tests/Controller/Admin/PartnerControllerTest.php index 159d571..76ab25c 100644 --- a/tests/Controller/Admin/PartnerControllerTest.php +++ b/tests/Controller/Admin/PartnerControllerTest.php @@ -75,6 +75,21 @@ public function testNewRejectsANonHttpWebsite(): void self::assertCount(0, $this->partners()->findBy(['name' => $name])); } + public function testNewRejectsANameContainingAComma(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/partners/new'); + + // Comma is the separator of the free-tagging field on the initiative form, + // so such a name would later be split into two partners. + $name = 'Aarhus Kommune, Teknik og Miljø '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form(['partner[name]' => $name]); + $this->client->submit($form); + + $this->assertResponseStatusCodeSame(422); + self::assertCount(0, $this->partners()->findBy(['name' => $name])); + } + public function testEditUpdatesPartner(): void { $this->loginAsAdmin(); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 5973eb9..96ff3cd 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -274,6 +274,7 @@ partner: name: Navn name_help: Samarbejdspartnerens navn, som det vises på initiativer. name_duplicate: Der findes allerede en samarbejdspartner med dette navn. + name_comma: Navnet må ikke indeholde komma. description: Beskrivelse description_help: En kort beskrivelse af samarbejdspartneren. website: Hjemmeside diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 9dcea89..12377b2 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -274,6 +274,7 @@ partner: name: Name name_help: The partner's name as shown on initiatives. name_duplicate: A partner with this name already exists. + name_comma: The name cannot contain a comma. description: Description description_help: A brief description of the partner. website: Website From 5a73b232f2bd3665b2e2c6812f9cbf6d0a6d8ba7 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 12:42:00 +0200 Subject: [PATCH 08/21] Preload partners in CSV export Co-Authored-By: Claude Opus 5 (1M context) --- src/Repository/InitiativeRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 18ce917..13a93cc 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -144,7 +144,7 @@ public function findForExport(InitiativeFilter $filter): array return []; } - foreach (['strategies', 'stakeholders', 'tags', 'contacts'] as $association) { + foreach (['strategies', 'stakeholders', 'tags', 'contacts', 'partners'] as $association) { $this->createQueryBuilder('i') ->addSelect('rel') ->leftJoin('i.'.$association, 'rel') From 2deec1cba30d1056ee400aff82af5ffe8823855d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 12:42:00 +0200 Subject: [PATCH 09/21] Assert partner ordering in repository test Co-Authored-By: Claude Opus 5 (1M context) --- tests/Repository/PartnerRepositoryTest.php | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/Repository/PartnerRepositoryTest.php b/tests/Repository/PartnerRepositoryTest.php index 6d93ff4..0e319be 100644 --- a/tests/Repository/PartnerRepositoryTest.php +++ b/tests/Repository/PartnerRepositoryTest.php @@ -16,12 +16,28 @@ public function testFindAllOrderedReturnsPartnersSortedByName(): void self::bootKernel(); $repository = static::getContainer()->get(PartnerRepository::class); \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); - $partners = $repository->findAllOrdered(); + // A shared prefix keeps the two apart from whatever else the fixtures hold, + // and they are persisted in reverse so the ordering cannot come from + // insertion order. + $prefix = 'Ordered '.uniqid().' '; + $second = (new Partner())->setName($prefix.'B'); + $first = (new Partner())->setName($prefix.'A'); + $em->persist($second); + $em->persist($first); + $em->flush(); - // Ordering is delegated to the database collation, so we only assert the - // method returns the persisted partners. - self::assertNotEmpty($partners); + $names = array_values(array_filter( + array_map(static fn (Partner $partner): string => (string) $partner->getName(), $repository->findAllOrdered()), + static fn (string $name): bool => str_starts_with($name, $prefix), + )); + self::assertSame([$prefix.'A', $prefix.'B'], $names); + + $em->remove($first); + $em->remove($second); + $em->flush(); } public function testFindOrCreateReturnsAnExistingPartnerCaseInsensitively(): void From 5f9c40a9b218ae7d3440e75f33eee774267556e2 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 12:42:00 +0200 Subject: [PATCH 10/21] Fix partner fixture websites Co-Authored-By: Claude Opus 5 (1M context) --- src/DataFixtures/AppFixtures.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 2995873..8b118ca 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -76,7 +76,9 @@ public function load(ObjectManager $manager): void $partner = (new Partner()) ->setName($name) ->setDescription($name.' samarbejder med kommunen om udvikling, viden og afprøvning i konkrete initiativer.') - ->setWebsite('https://www.'.strtolower(str_replace(' ', '', $this->ascii($name))).'.dk'); + // 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; } @@ -161,7 +163,6 @@ public function load(ObjectManager $manager): void foreach (\array_slice($this->shuffleCopy($contacts), 0, mt_rand(1, 3)) as $contact) { $initiative->addContact($contact); } - // Every initiative gets at least one partner — the form requires it. foreach (\array_slice($this->shuffleCopy($partners), 0, mt_rand(1, 3)) as $partner) { $initiative->addPartner($partner); } From 3920d46e66a8fe7ff53f268266173baef999ff62 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 12:42:00 +0200 Subject: [PATCH 11/21] Update changelog Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1a5ec..351991c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -* Add a Partner entity (name, description, website) with an admin CRUD, and - require every initiative to have at least one partner — attached through a - searchable multiselect that can create new partners on the fly. +* [PR-25](https://github.com/itk-dev/itk-projects/pull/25) + Add partners to initiatives, with an admin CRUD and CSV export. ## [0.2.0] - 2026-06-30 From 58baf41343c80d44341f8dec17211a8490756228 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 14:22:54 +0200 Subject: [PATCH 12/21] Add partner usage queries Co-Authored-By: Claude Opus 5 (1M context) --- src/Repository/PartnerRepository.php | 63 ++++++++++++++++++++++ tests/Repository/PartnerRepositoryTest.php | 47 ++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/Repository/PartnerRepository.php b/src/Repository/PartnerRepository.php index b14adbc..28ae748 100644 --- a/src/Repository/PartnerRepository.php +++ b/src/Repository/PartnerRepository.php @@ -4,11 +4,15 @@ namespace App\Repository; +use App\Entity\Initiative; use App\Entity\Partner; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Uid\Ulid; /** + * @phpstan-type PartnerUsage list + * * @extends ServiceEntityRepository */ class PartnerRepository extends ServiceEntityRepository @@ -29,6 +33,65 @@ public function findAllOrdered(): array ->getResult(); } + /** + * Which initiatives reference each partner, keyed by partner id. Answers the + * whole admin list in one query so the delete confirmation can name the + * initiatives a partner would be pulled off. + * + * @return array + */ + public function findInitiativeUsage(): array + { + // Initiative owns the (unidirectional) association, so usage can only be + // asked from that side — Partner has no inverse collection to traverse. + // Selecting the joined p.id (rather than IDENTITY()) is what makes Doctrine + // apply the ULID type instead of handing back the raw binary FK. + /** @var list $rows */ + $rows = $this->getEntityManager()->createQueryBuilder() + ->select('p.id AS partnerId', 'i.id AS id', 'i.title AS title') + ->from(Initiative::class, 'i') + ->innerJoin('i.partners', 'p') + ->orderBy('i.title', 'ASC') + ->getQuery() + ->getArrayResult(); + + $usage = []; + foreach ($rows as $row) { + $usage[(string) $row['partnerId']][] = [ + 'id' => (string) $row['id'], + 'title' => $row['title'], + ]; + } + + return $usage; + } + + /** + * The initiatives referencing a single partner. Read this *before* removing the + * partner: `initiative_partner` is cleared by the join table's ON DELETE + * CASCADE, so after the flush there is nothing left to report. + * + * @return PartnerUsage + */ + public function findInitiativesUsing(Partner $partner): array + { + /** @var list $rows */ + $rows = $this->getEntityManager()->createQueryBuilder() + ->select('i.id AS id', 'i.title AS title') + ->from(Initiative::class, 'i') + ->innerJoin('i.partners', 'p') + ->andWhere('p.id = :partner') + ->setParameter('partner', $partner->getId(), 'ulid') + ->orderBy('i.title', 'ASC') + ->getQuery() + ->getArrayResult(); + + return array_map(static fn (array $row): array => [ + 'id' => (string) $row['id'], + 'title' => $row['title'], + ], $rows); + } + /** * Return an existing partner matched on name (case-insensitive) or a new, * unflushed one. Lets partners be picked from the shared pool or typed in on diff --git a/tests/Repository/PartnerRepositoryTest.php b/tests/Repository/PartnerRepositoryTest.php index 0e319be..f4f5f7e 100644 --- a/tests/Repository/PartnerRepositoryTest.php +++ b/tests/Repository/PartnerRepositoryTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Repository; +use App\Entity\Initiative; use App\Entity\Partner; use App\Repository\PartnerRepository; use Doctrine\ORM\EntityManagerInterface; @@ -60,6 +61,52 @@ public function testFindOrCreateReturnsAnExistingPartnerCaseInsensitively(): voi $em->flush(); } + public function testFindInitiativeUsageNamesTheReferencingInitiatives(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $partner = (new Partner())->setName('Usage Partner '.uniqid()); + $initiative = (new Initiative())->setTitle('Usage Initiative '.uniqid()); + $initiative->addPartner($partner); + $em->persist($partner); + $em->persist($initiative); + $em->flush(); + + $expected = [['id' => (string) $initiative->getId(), 'title' => $initiative->getTitle()]]; + self::assertSame($expected, $repository->findInitiativesUsing($partner)); + + // The bulk variant backing the admin list must agree with the single lookup. + $usage = $repository->findInitiativeUsage(); + self::assertSame($expected, $usage[(string) $partner->getId()] ?? []); + + $em->remove($initiative); + $em->remove($partner); + $em->flush(); + } + + public function testFindInitiativeUsageOmitsUnusedPartners(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $partner = (new Partner())->setName('Unused Partner '.uniqid()); + $em->persist($partner); + $em->flush(); + + self::assertSame([], $repository->findInitiativesUsing($partner)); + self::assertArrayNotHasKey((string) $partner->getId(), $repository->findInitiativeUsage()); + + $em->remove($partner); + $em->flush(); + } + public function testFindOrCreateBuildsANewUnflushedPartner(): void { self::bootKernel(); From 8462e715ef225af5889e660e7567bb4a26a1c94d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 14:23:00 +0200 Subject: [PATCH 13/21] Add confirm dialog controller Co-Authored-By: Claude Opus 5 (1M context) --- .../controllers/confirm_delete_controller.js | 29 +++++++++ assets/styles/app.css | 59 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 assets/controllers/confirm_delete_controller.js diff --git a/assets/controllers/confirm_delete_controller.js b/assets/controllers/confirm_delete_controller.js new file mode 100644 index 0000000..02be1e7 --- /dev/null +++ b/assets/controllers/confirm_delete_controller.js @@ -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(); + } + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index f26daac..28119b3 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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); From 3e978743828ce39c0b4702a219b5a48ffd949e71 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 14:23:07 +0200 Subject: [PATCH 14/21] Confirm partner deletion with affected initiatives Co-Authored-By: Claude Opus 5 (1M context) --- src/Controller/Admin/PartnerController.php | 27 ++++++-- templates/admin/partners/_delete.html.twig | 28 ++++++++ templates/admin/partners/edit.html.twig | 5 +- templates/admin/partners/index.html.twig | 14 ++-- .../Admin/PartnerControllerTest.php | 67 +++++++++++++++++++ translations/messages.da.yaml | 10 +++ translations/messages.en.yaml | 10 +++ 7 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 templates/admin/partners/_delete.html.twig diff --git a/src/Controller/Admin/PartnerController.php b/src/Controller/Admin/PartnerController.php index b59421a..0c0897f 100644 --- a/src/Controller/Admin/PartnerController.php +++ b/src/Controller/Admin/PartnerController.php @@ -22,9 +22,19 @@ class PartnerController extends AbstractController #[Route('', name: 'admin_partners', methods: ['GET'])] public function index(PartnerRepository $partners): Response { - return $this->render('admin/partners/index.html.twig', [ - 'partners' => $partners->findAllOrdered(), - ]); + $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'])] @@ -46,7 +56,7 @@ public function new(Request $request, EntityManagerInterface $entityManager): Re } #[Route('/{id}/edit', name: 'admin_partner_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])] - public function edit(Request $request, Partner $partner, EntityManagerInterface $entityManager): Response + public function edit(Request $request, Partner $partner, EntityManagerInterface $entityManager, PartnerRepository $partners): Response { $form = $this->createForm(PartnerType::class, $partner); $form->handleRequest($request); @@ -61,9 +71,18 @@ public function edit(Request $request, Partner $partner, EntityManagerInterface 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 { diff --git a/templates/admin/partners/_delete.html.twig b/templates/admin/partners/_delete.html.twig new file mode 100644 index 0000000..5213437 --- /dev/null +++ b/templates/admin/partners/_delete.html.twig @@ -0,0 +1,28 @@ +{% set count = initiatives|length %} +
+ + {# A real submit button, so the form still posts if the dialog never opens. #} + + + +
+

{{ 'partner.delete.title'|trans({'%name%': partner.name}) }}

+ {% if count > 0 %} +

{{ 'partner.delete.in_use'|trans({'%count%': count}) }}

+ +

{{ 'partner.delete.consequence'|trans }}

+

{{ 'partner.delete.irreversible'|trans }}

+ {% else %} +

{{ 'partner.delete.unused'|trans }}

+ {% endif %} +
+
+ + +
+
+
diff --git a/templates/admin/partners/edit.html.twig b/templates/admin/partners/edit.html.twig index d849f2e..bcad6ab 100644 --- a/templates/admin/partners/edit.html.twig +++ b/templates/admin/partners/edit.html.twig @@ -7,10 +7,7 @@ -
- - -
+ {{ include('admin/partners/_delete.html.twig', {partner: partner, initiatives: initiatives}) }} {{ include('admin/partners/_form.html.twig', {button_label: 'action.save'}) }} {% endblock %} diff --git a/templates/admin/partners/index.html.twig b/templates/admin/partners/index.html.twig index cb5e62d..f9f1b53 100644 --- a/templates/admin/partners/index.html.twig +++ b/templates/admin/partners/index.html.twig @@ -8,7 +8,7 @@
- {% if partners is empty %} + {% if rows is empty %} {% else %}
@@ -16,24 +16,22 @@ {{ 'partner.name'|trans }} - {{ 'partner.description'|trans }} {{ 'partner.website'|trans }} + {{ 'partner.usage'|trans }} - {% for partner in partners %} + {% for row in rows %} + {% set partner = row.partner %} {{ partner.name }} - {{ partner.description ? partner.description|slice(0, 80) ~ (partner.description|length > 80 ? '…') : '—' }} {% if partner.website %}{{ partner.website }}{% else %}—{% endif %} + {% if row.initiatives is empty %}{{ 'partner.usage_none'|trans }}{% else %}{{ 'partner.usage_count'|trans({'%count%': row.initiatives|length}) }}{% endif %}
{{ 'action.edit'|trans }} -
- - -
+ {{ include('admin/partners/_delete.html.twig', {partner: partner, initiatives: row.initiatives, small: true}) }}
diff --git a/tests/Controller/Admin/PartnerControllerTest.php b/tests/Controller/Admin/PartnerControllerTest.php index 76ab25c..a34ed6e 100644 --- a/tests/Controller/Admin/PartnerControllerTest.php +++ b/tests/Controller/Admin/PartnerControllerTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Controller\Admin; +use App\Entity\Initiative; use App\Entity\Partner; use App\Tests\FunctionalTestCase; @@ -17,6 +18,25 @@ public function testIndexIsAccessibleToEditors(): void $this->assertResponseIsSuccessful(); } + public function testIndexDeleteDialogLinksTheAffectedInitiatives(): void + { + $this->loginAsAdmin(); + $partner = $this->createPartner('Linked Partner '.uniqid()); + $initiative = $this->createInitiativeUsing($partner, 'Linked Initiative '.uniqid()); + + $crawler = $this->client->request('GET', '/admin/partners'); + $this->assertResponseIsSuccessful(); + + // The confirmation has to name what deleting would strip the partner off, and + // link straight to it — the count alone doesn't tell the admin what breaks. + $link = $crawler->filter(sprintf('dialog a[href="/initiatives/%s"]', $initiative->getId())); + self::assertCount(1, $link); + self::assertSame($initiative->getTitle(), trim($link->text())); + + $this->removeInitiative((string) $initiative->getId()); + $this->removePartner((string) $partner->getId()); + } + public function testNewCreatesPartner(): void { $this->loginAsAdmin(); @@ -119,6 +139,31 @@ public function testDeleteRemovesPartnerWithAValidToken(): void self::assertNull($this->partners()->find($id)); } + public function testDeleteDetachesThePartnerButKeepsTheInitiative(): void + { + $this->loginAsAdmin(); + $partner = $this->createPartner('Detachable Partner '.uniqid()); + $partnerId = (string) $partner->getId(); + $initiative = $this->createInitiativeUsing($partner, 'Surviving Initiative '.uniqid()); + $initiativeId = (string) $initiative->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/partners/%s/edit', $partnerId)); + self::assertStringContainsString($initiative->getTitle(), (string) $this->client->getResponse()->getContent()); + + $this->client->submit($crawler->filter('form[action$="/delete"]')->form()); + $this->assertResponseRedirects('/admin/partners'); + + // The join table is cleared by its ON DELETE CASCADE rather than by Doctrine, + // so pin both halves: the partner is gone, the initiative is not. + $this->entityManager()->clear(); + self::assertNull($this->partners()->find($partnerId)); + $survivor = $this->initiatives()->find($initiativeId); + self::assertInstanceOf(Initiative::class, $survivor); + self::assertCount(0, $survivor->getPartners()); + + $this->removeInitiative($initiativeId); + } + public function testDeleteIgnoresAnInvalidToken(): void { $this->loginAsAdmin(); @@ -142,6 +187,17 @@ private function createPartner(string $name): Partner return $partner; } + private function createInitiativeUsing(Partner $partner, string $title): Initiative + { + $initiative = (new Initiative())->setTitle($title); + $initiative->addPartner($partner); + $em = $this->entityManager(); + $em->persist($initiative); + $em->flush(); + + return $initiative; + } + private function removePartner(string $id): void { $this->entityManager()->clear(); @@ -152,4 +208,15 @@ private function removePartner(string $id): void $em->flush(); } } + + private function removeInitiative(string $id): void + { + $this->entityManager()->clear(); + $initiative = $this->initiatives()->find($id); + if (null !== $initiative) { + $em = $this->entityManager(); + $em->remove($initiative); + $em->flush(); + } + } } diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 96ff3cd..f1da462 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -279,6 +279,16 @@ partner: description_help: En kort beskrivelse af samarbejdspartneren. website: Hjemmeside website_help: Link til samarbejdspartnerens hjemmeside, hvis den findes. + usage: Bruges i + usage_none: Ikke i brug + usage_count: "{1}1 initiativ|]1,Inf[%count% initiativer" + delete: + title: 'Slet "%name%"?' + in_use: "{1}Samarbejdspartneren er tilknyttet ét initiativ:|]1,Inf[Samarbejdspartneren er tilknyttet %count% initiativer:" + unused: Samarbejdspartneren er ikke tilknyttet nogen initiativer. + consequence: Sletningen fjerner samarbejdspartneren fra initiativerne ovenfor. + irreversible: Denne handling kan ikke fortrydes. + confirm: Slet alligevel index: title: Samarbejdspartnere subtitle: Samarbejdspartnere der kan tilknyttes initiativer. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 12377b2..b0e08ea 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -279,6 +279,16 @@ partner: description_help: A brief description of the partner. website: Website website_help: Link to the partner's website, if any. + usage: Used in + usage_none: Not in use + usage_count: "{1}1 initiative|]1,Inf[%count% initiatives" + delete: + title: 'Delete "%name%"?' + in_use: "{1}This partner is attached to one initiative:|]1,Inf[This partner is attached to %count% initiatives:" + unused: This partner is not attached to any initiatives. + consequence: Deleting removes the partner from the initiatives above. + irreversible: This action cannot be undone. + confirm: Delete anyway index: title: Partners subtitle: Partners that can be attached to initiatives. From 79012b25d65a4a7b212b15d976f953daa63e70d4 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 13 Aug 2026 14:23:16 +0200 Subject: [PATCH 15/21] Update changelog Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 351991c..1db9880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ 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. + 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 From 46b2028124e430c0042f16a9488e31475a85cc99 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 18 Aug 2026 12:47:00 +0200 Subject: [PATCH 16/21] Address the review on partners, and confirm deletes one way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings: * Partners were exported and displayed but never added to the free-text search, unlike every other name-bearing collection. One subquery, mirroring contacts. * Assert\Valid on the partners association raised violations at partners[0].name — a path the single text input cannot render, on a form autosave never redraws. The transformer now checks the one thing this form can get wrong, so the error lands on the field itself. * The unique index on partner.name made lookup-then-insert a 500 waiting to happen, since partners are created inline from the autosaving form. Dropped in favour of the UniqueEntity check Contact and Term already rely on: the worst a race can now do is leave two rows to merge. * The migration says what it does. Delete confirmation was three idioms — onsubmit=confirm(), data-turbo-confirm, and the new dialog. Now one component, everywhere. Its trigger is no longer a submit button, so a click landing before Stimulus hydrates does nothing rather than deleting unguarded. --- CHANGELOG.md | 7 +-- .../controllers/confirm_delete_controller.js | 15 +++--- migrations/Version20260813090155.php | 9 +--- src/Entity/Initiative.php | 12 ++++- src/Entity/Partner.php | 16 ++++++- .../PartnersTextTransformer.php | 10 ++++ src/Repository/InitiativeRepository.php | 1 + templates/admin/areas/edit.html.twig | 9 ++-- templates/admin/areas/index.html.twig | 10 ++-- templates/admin/contacts/edit.html.twig | 8 ++-- templates/admin/contacts/index.html.twig | 9 ++-- templates/admin/departments/edit.html.twig | 8 ++-- templates/admin/departments/index.html.twig | 9 ++-- templates/admin/partners/_delete.html.twig | 46 ++++++++----------- templates/admin/users/edit.html.twig | 8 ++-- templates/admin/users/index.html.twig | 9 ++-- templates/components/ConfirmDelete.html.twig | 23 ++++++++++ templates/initiative/_results.html.twig | 13 ++++-- templates/initiative/edit.html.twig | 8 ++-- .../Admin/PartnerControllerTest.php | 32 +++++++++++++ .../PartnersTextTransformerTest.php | 13 ++++++ translations/messages.da.yaml | 6 +-- translations/messages.en.yaml | 6 +-- 23 files changed, 191 insertions(+), 96 deletions(-) create mode 100644 templates/components/ConfirmDelete.html.twig diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db9880..59c658e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ 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. + 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 diff --git a/assets/controllers/confirm_delete_controller.js b/assets/controllers/confirm_delete_controller.js index 02be1e7..ce68d6b 100644 --- a/assets/controllers/confirm_delete_controller.js +++ b/assets/controllers/confirm_delete_controller.js @@ -1,17 +1,18 @@ 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. + * The one way this app asks "are you sure?" — a modal that can spell out what a + * deletion takes with it, which confirm() cannot. + * + * The trigger is a plain button rather than a submit, so a click that lands + * before this controller has hydrated does nothing instead of deleting + * unguarded. Only the button inside the dialog submits. showModal() brings + * Escape and focus trapping with it. */ export default class extends Controller { static targets = ["dialog"]; - open(event) { - event.preventDefault(); + open() { this.dialogTarget.showModal(); } diff --git a/migrations/Version20260813090155.php b/migrations/Version20260813090155.php index 705ac50..b2bb7d5 100644 --- a/migrations/Version20260813090155.php +++ b/migrations/Version20260813090155.php @@ -7,21 +7,17 @@ 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 ''; + return 'Add the partner table and the initiative_partner join table.'; } 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('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'); @@ -30,7 +26,6 @@ public function up(Schema $schema): void 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'); diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index e2f995c..3da6397 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -71,8 +71,16 @@ class Initiative extends AbstractEntity #[ORM\JoinTable(name: 'initiative_contact')] private Collection $contacts; - /** @var Collection */ - #[Assert\Valid] + /** + * No cascading validation, as on the other free-tagging collections: a + * violation inside a Partner would carry the path partners[0].name, which the + * single text input on the initiative form cannot render, and autosave — the + * form's only save path — never redraws to show it. What the initiative form + * can put wrong is the name it types, so {@see \App\Form\DataTransformer\PartnersTextTransformer} + * checks that and reports it on the field itself. + * + * @var Collection + */ #[ORM\ManyToMany(targetEntity: Partner::class, cascade: ['persist'])] #[ORM\JoinTable(name: 'initiative_partner')] private Collection $partners; diff --git a/src/Entity/Partner.php b/src/Entity/Partner.php index cbdd072..7e2a05e 100644 --- a/src/Entity/Partner.php +++ b/src/Entity/Partner.php @@ -11,15 +11,27 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: PartnerRepository::class)] +#[ORM\Index(name: 'idx_partner_name', columns: ['name'])] #[UniqueEntity(fields: ['name'], message: 'partner.name_duplicate')] class Partner extends AbstractEntity { + public const int NAME_MAX_LENGTH = 255; + + /** + * Duplicate names are kept out by {@see UniqueEntity} and by the + * case-insensitive lookup in {@see PartnerRepository::findOrCreate()}, + * not by a unique index. Partners are created inline from the autosaving + * initiative form, where lookup and insert are separate steps: a unique index + * would turn two saves racing on the same new name into an uncaught driver + * exception, where the worst this can do is leave two rows for an admin to + * merge. Contact and Term, created the same way, are modelled the same. + */ #[Assert\NotBlank] - #[Assert\Length(max: 255)] + #[Assert\Length(max: self::NAME_MAX_LENGTH)] // Comma is the separator of the free-tagging field on the initiative form, so a // name containing one would be split into two partners on the next edit. #[Assert\Regex(pattern: '/,/', match: false, message: 'partner.name_comma')] - #[ORM\Column(length: 255, unique: true)] + #[ORM\Column(length: self::NAME_MAX_LENGTH)] private ?string $name = null; #[ORM\Column(type: Types::TEXT, nullable: true)] diff --git a/src/Form/DataTransformer/PartnersTextTransformer.php b/src/Form/DataTransformer/PartnersTextTransformer.php index 635b4af..06676b5 100644 --- a/src/Form/DataTransformer/PartnersTextTransformer.php +++ b/src/Form/DataTransformer/PartnersTextTransformer.php @@ -9,6 +9,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; /** * Bridges a comma-separated text input and a collection of {@see Partner}s: @@ -60,6 +61,15 @@ public function reverseTransform(mixed $value): Collection } $seen[$key] = true; + // Caught here rather than by a constraint on Partner: an error raised + // inside the entity would land on a path this single input cannot show. + if (mb_strlen($name) > Partner::NAME_MAX_LENGTH) { + $failure = new TransformationFailedException(sprintf('Partner name of %d characters exceeds the %d the column holds.', mb_strlen($name), Partner::NAME_MAX_LENGTH)); + $failure->setInvalidMessage('partner.name_too_long', ['%limit%' => Partner::NAME_MAX_LENGTH]); + + throw $failure; + } + $partners->add($this->partnerRepository->findOrCreate($name)); } diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 13a93cc..dacf72a 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -51,6 +51,7 @@ public function search(InitiativeFilter $filter): QueryBuilder sprintf('i.id IN (SELECT istr.id FROM %s istr JOIN istr.strategies st WHERE LOWER(st.name) LIKE :q)', Initiative::class), sprintf('i.id IN (SELECT isth.id FROM %s isth JOIN isth.stakeholders sh WHERE LOWER(sh.name) LIKE :q)', Initiative::class), sprintf('i.id IN (SELECT icon.id FROM %s icon JOIN icon.contacts co WHERE LOWER(co.name) LIKE :q)', Initiative::class), + sprintf('i.id IN (SELECT ipar.id FROM %s ipar JOIN ipar.partners pa WHERE LOWER(pa.name) LIKE :q)', Initiative::class), // Department and area 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)', Initiative::class), diff --git a/templates/admin/areas/edit.html.twig b/templates/admin/areas/edit.html.twig index 62c59aa..713c190 100644 --- a/templates/admin/areas/edit.html.twig +++ b/templates/admin/areas/edit.html.twig @@ -7,11 +7,10 @@ -
- - -
+ {{ include('admin/areas/_form.html.twig', {button_label: 'action.save'}) }} {% endblock %} diff --git a/templates/admin/areas/index.html.twig b/templates/admin/areas/index.html.twig index 123a90f..df3f862 100644 --- a/templates/admin/areas/index.html.twig +++ b/templates/admin/areas/index.html.twig @@ -26,11 +26,11 @@
{{ 'action.edit'|trans }} -
- - -
+
diff --git a/templates/admin/contacts/edit.html.twig b/templates/admin/contacts/edit.html.twig index fb95e98..fa8e469 100644 --- a/templates/admin/contacts/edit.html.twig +++ b/templates/admin/contacts/edit.html.twig @@ -7,10 +7,10 @@ -
- - -
+ {{ include('admin/contacts/_form.html.twig', {button_label: 'action.save'}) }} {% endblock %} diff --git a/templates/admin/contacts/index.html.twig b/templates/admin/contacts/index.html.twig index 201ca01..c227a63 100644 --- a/templates/admin/contacts/index.html.twig +++ b/templates/admin/contacts/index.html.twig @@ -32,10 +32,11 @@
{{ 'action.edit'|trans }} -
- - -
+
diff --git a/templates/admin/departments/edit.html.twig b/templates/admin/departments/edit.html.twig index 4f4fe63..0c20b23 100644 --- a/templates/admin/departments/edit.html.twig +++ b/templates/admin/departments/edit.html.twig @@ -7,10 +7,10 @@ -
- - -
+ {{ include('admin/departments/_form.html.twig', {button_label: 'action.save'}) }} {% endblock %} diff --git a/templates/admin/departments/index.html.twig b/templates/admin/departments/index.html.twig index 8f2724e..6953e5a 100644 --- a/templates/admin/departments/index.html.twig +++ b/templates/admin/departments/index.html.twig @@ -26,10 +26,11 @@
{{ 'action.edit'|trans }} -
- - -
+
diff --git a/templates/admin/partners/_delete.html.twig b/templates/admin/partners/_delete.html.twig index 5213437..222efd2 100644 --- a/templates/admin/partners/_delete.html.twig +++ b/templates/admin/partners/_delete.html.twig @@ -1,28 +1,20 @@ {% set count = initiatives|length %} -
- - {# A real submit button, so the form still posts if the dialog never opens. #} - - - -
-

{{ 'partner.delete.title'|trans({'%name%': partner.name}) }}

- {% if count > 0 %} -

{{ 'partner.delete.in_use'|trans({'%count%': count}) }}

- -

{{ 'partner.delete.consequence'|trans }}

-

{{ 'partner.delete.irreversible'|trans }}

- {% else %} -

{{ 'partner.delete.unused'|trans }}

- {% endif %} -
-
- - -
-
-
+ + {% if count > 0 %} +

{{ 'partner.delete.in_use'|trans({'%count%': count}) }}

+ +

{{ 'partner.delete.consequence'|trans }}

+ {% else %} +

{{ 'partner.delete.unused'|trans }}

+ {% endif %} +
diff --git a/templates/admin/users/edit.html.twig b/templates/admin/users/edit.html.twig index 3d7109c..6a0cecb 100644 --- a/templates/admin/users/edit.html.twig +++ b/templates/admin/users/edit.html.twig @@ -5,10 +5,10 @@ {% block admin_content %} {% if user.id != app.user.id %} -
- - -
+ {% endif %}
diff --git a/templates/admin/users/index.html.twig b/templates/admin/users/index.html.twig index 74ffef2..1f168b2 100644 --- a/templates/admin/users/index.html.twig +++ b/templates/admin/users/index.html.twig @@ -32,10 +32,11 @@
{{ 'action.edit'|trans }} {% if user.id != app.user.id %} -
- - -
+ {% endif %}
diff --git a/templates/components/ConfirmDelete.html.twig b/templates/components/ConfirmDelete.html.twig new file mode 100644 index 0000000..8b3d319 --- /dev/null +++ b/templates/components/ConfirmDelete.html.twig @@ -0,0 +1,23 @@ +{% props action, token, title, triggerClass = 'btn btn--danger', triggerTitle = null, triggerContent = null, confirmLabel = null, frame = null %} + +
+ + {# Deliberately not a submit button: the dialog is the only way through, so a + click landing before Stimulus has hydrated does nothing at all. #} + + + +
+

{{ title }}

+ {% block content %}{% endblock %} +

{{ 'action.irreversible'|trans }}

+
+
+ + +
+
+
diff --git a/templates/initiative/_results.html.twig b/templates/initiative/_results.html.twig index 982f330..6b07e82 100644 --- a/templates/initiative/_results.html.twig +++ b/templates/initiative/_results.html.twig @@ -59,10 +59,15 @@
{{ h.icon('eye') }} {{ h.icon('edit') }} -
- - -
+ {% set trashIcon %}{{ h.icon('trash') }}{% endset %} +
diff --git a/templates/initiative/edit.html.twig b/templates/initiative/edit.html.twig index 0934648..a9578ab 100644 --- a/templates/initiative/edit.html.twig +++ b/templates/initiative/edit.html.twig @@ -11,10 +11,10 @@ / {{ initiative.title }}
-
- - -
+ {{ include('initiative/_form.html.twig', {button_label: 'action.save', autosave: true}) }}
diff --git a/tests/Controller/Admin/PartnerControllerTest.php b/tests/Controller/Admin/PartnerControllerTest.php index a34ed6e..fbe0c18 100644 --- a/tests/Controller/Admin/PartnerControllerTest.php +++ b/tests/Controller/Admin/PartnerControllerTest.php @@ -177,6 +177,38 @@ public function testDeleteIgnoresAnInvalidToken(): void $this->removePartner($id); } + public function testInitiativesAreSearchableByPartnerName(): void + { + $this->loginAsEditor(); + $partner = $this->createPartner('Searchable Partner '.uniqid()); + $initiative = $this->createInitiativeUsing($partner, 'Findable Initiative '.uniqid()); + + $crawler = $this->client->request('GET', '/initiatives?q='.urlencode((string) $partner->getName())); + + $this->assertResponseIsSuccessful(); + self::assertStringContainsString((string) $initiative->getTitle(), $crawler->filter('#initiative-results')->text()); + + $this->removeInitiative((string) $initiative->getId()); + $this->removePartner((string) $partner->getId()); + } + + public function testTheDeleteTriggerCannotSubmitOnItsOwn(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createPartner('Guarded Partner '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/partners/%s/edit', $id)); + + // The only submit lives inside the dialog, so a click that lands before + // Stimulus has hydrated cannot delete anything. + $buttons = $crawler->filter('form[action$="/delete"] button'); + self::assertSame('button', $buttons->eq(0)->attr('type')); + self::assertCount(1, $crawler->filter('form[action$="/delete"] button[type="submit"]')); + self::assertCount(1, $crawler->filter('form[action$="/delete"] dialog button[type="submit"]')); + + $this->removePartner($id); + } + private function createPartner(string $name): Partner { $partner = (new Partner())->setName($name); diff --git a/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php index 15dda9b..13dc072 100644 --- a/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php +++ b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php @@ -8,6 +8,7 @@ use App\Form\DataTransformer\PartnersTextTransformer; use App\Repository\PartnerRepository; use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Exception\TransformationFailedException; final class PartnersTextTransformerTest extends TestCase { @@ -49,6 +50,18 @@ public function testReverseTransformTrimsDeduplicatesAndResolvesPartners(): void self::assertCount(2, $transformer->reverseTransform('Aarhus Universitet, Alexandra Instituttet, , aarhus universitet')); } + public function testReverseTransformRejectsANameTooLongForTheColumn(): void + { + try { + $this->transformer()->reverseTransform(str_repeat('a', Partner::NAME_MAX_LENGTH + 1)); + self::fail('An over-long partner name should not reach the database.'); + } catch (TransformationFailedException $failure) { + // Reported on the partners field itself, which a cascaded entity + // violation could not be. + self::assertSame('partner.name_too_long', $failure->getInvalidMessage()); + } + } + private function transformer(): PartnersTextTransformer { return new PartnersTextTransformer($this->createStub(PartnerRepository::class)); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f1da462..db7f44b 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -113,7 +113,8 @@ action: add_image: Tilføj billede add_attachment: Tilføj fil remove: Fjern - confirm_delete: Er du sikker på, at du vil slette dette? + confirm_delete_title: 'Slet “%name%”?' + irreversible: Denne handling kan ikke fortrydes. common: yes: Ja @@ -275,6 +276,7 @@ partner: name_help: Samarbejdspartnerens navn, som det vises på initiativer. name_duplicate: Der findes allerede en samarbejdspartner med dette navn. name_comma: Navnet må ikke indeholde komma. + name_too_long: "En samarbejdspartners navn må højst være på %limit% tegn." description: Beskrivelse description_help: En kort beskrivelse af samarbejdspartneren. website: Hjemmeside @@ -283,11 +285,9 @@ partner: usage_none: Ikke i brug usage_count: "{1}1 initiativ|]1,Inf[%count% initiativer" delete: - title: 'Slet "%name%"?' in_use: "{1}Samarbejdspartneren er tilknyttet ét initiativ:|]1,Inf[Samarbejdspartneren er tilknyttet %count% initiativer:" unused: Samarbejdspartneren er ikke tilknyttet nogen initiativer. consequence: Sletningen fjerner samarbejdspartneren fra initiativerne ovenfor. - irreversible: Denne handling kan ikke fortrydes. confirm: Slet alligevel index: title: Samarbejdspartnere diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index b0e08ea..484ced0 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -113,7 +113,8 @@ action: add_image: Add image add_attachment: Add file remove: Remove - confirm_delete: Are you sure you want to delete this? + confirm_delete_title: 'Delete “%name%”?' + irreversible: This action cannot be undone. common: yes: Yes @@ -275,6 +276,7 @@ partner: name_help: The partner's name as shown on initiatives. name_duplicate: A partner with this name already exists. name_comma: The name cannot contain a comma. + name_too_long: "A partner name cannot be longer than %limit% characters." description: Description description_help: A brief description of the partner. website: Website @@ -283,11 +285,9 @@ partner: usage_none: Not in use usage_count: "{1}1 initiative|]1,Inf[%count% initiatives" delete: - title: 'Delete "%name%"?' in_use: "{1}This partner is attached to one initiative:|]1,Inf[This partner is attached to %count% initiatives:" unused: This partner is not attached to any initiatives. consequence: Deleting removes the partner from the initiatives above. - irreversible: This action cannot be undone. confirm: Delete anyway index: title: Partners From b4ed187ed61d59976b319ff9db82c274e1d2162e Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 20 Aug 2026 14:46:19 +0200 Subject: [PATCH 17/21] Clamp over-long partner names instead of rejecting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TransformationFailedException desynchronised the form, so autosave — the initiative form's only save path — returned a bodiless 422 and showed the generic 'check the required fields' error. Nothing was missing and the field carried no error, so every later save failed with no way to see why. --- .../PartnersTextTransformer.php | 15 ++++--------- .../PartnersTextTransformerTest.php | 21 ++++++++++--------- translations/messages.da.yaml | 3 +-- translations/messages.en.yaml | 3 +-- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/src/Form/DataTransformer/PartnersTextTransformer.php b/src/Form/DataTransformer/PartnersTextTransformer.php index 06676b5..2b7bc98 100644 --- a/src/Form/DataTransformer/PartnersTextTransformer.php +++ b/src/Form/DataTransformer/PartnersTextTransformer.php @@ -9,7 +9,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Symfony\Component\Form\DataTransformerInterface; -use Symfony\Component\Form\Exception\TransformationFailedException; /** * Bridges a comma-separated text input and a collection of {@see Partner}s: @@ -54,22 +53,16 @@ public function reverseTransform(mixed $value): Collection $seen = []; foreach (explode(',', $value) as $name) { - $name = trim($name); + // Clamped rather than rejected: autosave is this form's only save path + // and it cannot redraw to show a field error, so refusing the value + // would block every later save with no way to see why. + $name = mb_substr(trim($name), 0, Partner::NAME_MAX_LENGTH); $key = mb_strtolower($name); if ('' === $name || isset($seen[$key])) { continue; } $seen[$key] = true; - // Caught here rather than by a constraint on Partner: an error raised - // inside the entity would land on a path this single input cannot show. - if (mb_strlen($name) > Partner::NAME_MAX_LENGTH) { - $failure = new TransformationFailedException(sprintf('Partner name of %d characters exceeds the %d the column holds.', mb_strlen($name), Partner::NAME_MAX_LENGTH)); - $failure->setInvalidMessage('partner.name_too_long', ['%limit%' => Partner::NAME_MAX_LENGTH]); - - throw $failure; - } - $partners->add($this->partnerRepository->findOrCreate($name)); } diff --git a/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php index 13dc072..a504617 100644 --- a/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php +++ b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php @@ -8,7 +8,6 @@ use App\Form\DataTransformer\PartnersTextTransformer; use App\Repository\PartnerRepository; use PHPUnit\Framework\TestCase; -use Symfony\Component\Form\Exception\TransformationFailedException; final class PartnersTextTransformerTest extends TestCase { @@ -50,16 +49,18 @@ public function testReverseTransformTrimsDeduplicatesAndResolvesPartners(): void self::assertCount(2, $transformer->reverseTransform('Aarhus Universitet, Alexandra Instituttet, , aarhus universitet')); } - public function testReverseTransformRejectsANameTooLongForTheColumn(): void + public function testReverseTransformClampsANameTooLongForTheColumn(): void { - try { - $this->transformer()->reverseTransform(str_repeat('a', Partner::NAME_MAX_LENGTH + 1)); - self::fail('An over-long partner name should not reach the database.'); - } catch (TransformationFailedException $failure) { - // Reported on the partners field itself, which a cascaded entity - // violation could not be. - self::assertSame('partner.name_too_long', $failure->getInvalidMessage()); - } + $repository = $this->createMock(PartnerRepository::class); + $repository->expects(self::once()) + ->method('findOrCreate') + ->willReturnCallback(static fn (string $name): Partner => (new Partner())->setName($name)); + + $transformer = new PartnersTextTransformer($repository); + $partners = $transformer->reverseTransform(str_repeat('a', Partner::NAME_MAX_LENGTH + 10)); + + // Clamping keeps the save going; rejecting would stall autosave silently. + self::assertSame(str_repeat('a', Partner::NAME_MAX_LENGTH), $partners->first()->getName()); } private function transformer(): PartnersTextTransformer diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index db7f44b..be779bd 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -113,7 +113,7 @@ action: add_image: Tilføj billede add_attachment: Tilføj fil remove: Fjern - confirm_delete_title: 'Slet “%name%”?' + confirm_delete_title: "Slet “%name%”?" irreversible: Denne handling kan ikke fortrydes. common: @@ -276,7 +276,6 @@ partner: name_help: Samarbejdspartnerens navn, som det vises på initiativer. name_duplicate: Der findes allerede en samarbejdspartner med dette navn. name_comma: Navnet må ikke indeholde komma. - name_too_long: "En samarbejdspartners navn må højst være på %limit% tegn." description: Beskrivelse description_help: En kort beskrivelse af samarbejdspartneren. website: Hjemmeside diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 484ced0..03fad9d 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -113,7 +113,7 @@ action: add_image: Add image add_attachment: Add file remove: Remove - confirm_delete_title: 'Delete “%name%”?' + confirm_delete_title: "Delete “%name%”?" irreversible: This action cannot be undone. common: @@ -276,7 +276,6 @@ partner: name_help: The partner's name as shown on initiatives. name_duplicate: A partner with this name already exists. name_comma: The name cannot contain a comma. - name_too_long: "A partner name cannot be longer than %limit% characters." description: Description description_help: A brief description of the partner. website: Website From 69786609f93b678f07480799c1deea5bc67f1928 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 20 Aug 2026 14:46:25 +0200 Subject: [PATCH 18/21] Close open dialogs before Turbo caches the page Submitting from inside a confirm dialog cached the page with the dialog still open; restoring that snapshot rendered it inline, out of the top layer. --- assets/app.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/app.js b/assets/app.js index b1a4922..133c44f 100644 --- a/assets/app.js +++ b/assets/app.js @@ -159,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(); + }); +}); From 557d612265a09e8995942ff142cc69f1292da06c Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 20 Aug 2026 14:46:26 +0200 Subject: [PATCH 19/21] Name the confirm dialog for screen readers --- templates/components/ConfirmDelete.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/components/ConfirmDelete.html.twig b/templates/components/ConfirmDelete.html.twig index 8b3d319..08c641e 100644 --- a/templates/components/ConfirmDelete.html.twig +++ b/templates/components/ConfirmDelete.html.twig @@ -9,7 +9,7 @@ {{- triggerContent ?? 'action.delete'|trans -}} - +

{{ title }}

{% block content %}{% endblock %} From 209d4e859dd581bdd910078d3f0b008c380239d1 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 20 Aug 2026 14:46:32 +0200 Subject: [PATCH 20/21] Trim partner comments The note on Partner::$name claimed Contact and Term as precedent for skipping a unique index, but Term carries uniq_term_name_vocabulary while taking the same lookup-then-insert race, and UniqueEntity only guards the admin form. Drops a TODO from the delete docblock too. --- assets/controllers/confirm_delete_controller.js | 11 ++--------- src/Controller/Admin/PartnerController.php | 10 ++-------- src/Entity/Initiative.php | 8 ++------ src/Entity/Partner.php | 12 +++--------- 4 files changed, 9 insertions(+), 32 deletions(-) diff --git a/assets/controllers/confirm_delete_controller.js b/assets/controllers/confirm_delete_controller.js index ce68d6b..fb52e76 100644 --- a/assets/controllers/confirm_delete_controller.js +++ b/assets/controllers/confirm_delete_controller.js @@ -1,14 +1,7 @@ import { Controller } from "@hotwired/stimulus"; -/* - * The one way this app asks "are you sure?" — a modal that can spell out what a - * deletion takes with it, which confirm() cannot. - * - * The trigger is a plain button rather than a submit, so a click that lands - * before this controller has hydrated does nothing instead of deleting - * unguarded. Only the button inside the dialog submits. showModal() brings - * Escape and focus trapping with it. - */ +// 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"]; diff --git a/src/Controller/Admin/PartnerController.php b/src/Controller/Admin/PartnerController.php index 0c0897f..c357cfe 100644 --- a/src/Controller/Admin/PartnerController.php +++ b/src/Controller/Admin/PartnerController.php @@ -75,14 +75,8 @@ public function edit(Request $request, Partner $partner, EntityManagerInterface ]); } - /** - * 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. - */ + // 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 { diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index 3da6397..113a744 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -72,12 +72,8 @@ class Initiative extends AbstractEntity private Collection $contacts; /** - * No cascading validation, as on the other free-tagging collections: a - * violation inside a Partner would carry the path partners[0].name, which the - * single text input on the initiative form cannot render, and autosave — the - * form's only save path — never redraws to show it. What the initiative form - * can put wrong is the name it types, so {@see \App\Form\DataTransformer\PartnersTextTransformer} - * checks that and reports it on the field itself. + * Not cascade-validated, as on the other free-tagging collections: a violation + * would carry the path partners[0].name, which the single text input cannot render. * * @var Collection */ diff --git a/src/Entity/Partner.php b/src/Entity/Partner.php index 7e2a05e..ca1bf6e 100644 --- a/src/Entity/Partner.php +++ b/src/Entity/Partner.php @@ -17,15 +17,9 @@ class Partner extends AbstractEntity { public const int NAME_MAX_LENGTH = 255; - /** - * Duplicate names are kept out by {@see UniqueEntity} and by the - * case-insensitive lookup in {@see PartnerRepository::findOrCreate()}, - * not by a unique index. Partners are created inline from the autosaving - * initiative form, where lookup and insert are separate steps: a unique index - * would turn two saves racing on the same new name into an uncaught driver - * exception, where the worst this can do is leave two rows for an admin to - * merge. Contact and Term, created the same way, are modelled the same. - */ + // UniqueEntity only guards the admin form; partners created inline from the + // initiative form are not cascade-validated, so duplicates there are kept out + // only by findOrCreate()'s lookup, which two concurrent saves can race past. #[Assert\NotBlank] #[Assert\Length(max: self::NAME_MAX_LENGTH)] // Comma is the separator of the free-tagging field on the initiative form, so a From bf21165a998bc022cc055b57b81c759485f4bf21 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Fri, 21 Aug 2026 10:09:06 +0200 Subject: [PATCH 21/21] Describe the two migrations that shipped without a description --- migrations/Version20260626175808.php | 2 +- migrations/Version20260627134918.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/Version20260626175808.php b/migrations/Version20260626175808.php index 8a580c4..b7682dd 100644 --- a/migrations/Version20260626175808.php +++ b/migrations/Version20260626175808.php @@ -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 diff --git a/migrations/Version20260627134918.php b/migrations/Version20260627134918.php index e26eb99..658186b 100644 --- a/migrations/Version20260627134918.php +++ b/migrations/Version20260627134918.php @@ -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