diff --git a/.env b/.env index 17787bb..30dfb2e 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ -COMPOSE_PROJECT_NAME=itk-project-database -COMPOSE_DOMAIN=itk-project-database.local.itkdev.dk +COMPOSE_PROJECT_NAME=itk-projects +COMPOSE_DOMAIN=itk-projects.local.itkdev.dk ITKDEV_TEMPLATE=symfony-8 # In all environments, the following files are loaded if they exist, diff --git a/CHANGELOG.md b/CHANGELOG.md index eecf410..d3a07bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,58 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-08-24 + +* [PR-27](https://github.com/itk-dev/itk-projects/pull/27) + Add a Topic field to initiatives, naming the wider programme an initiative is + part of. Shown under the title on the form and on the initiative page, covered + by the free-text filter, and included in the CSV export. +* [PR-26](https://github.com/itk-dev/itk-projects/pull/26) + Reflect the initiative list's filters in the address bar so they can be + deeplinked. +* [PR-25](https://github.com/itk-dev/itk-projects/pull/25) + 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-project-database/pull/23) +* [PR-23](https://github.com/itk-dev/itk-projects/pull/23) Introduce reusable Twig components (page header, card header, empty state, KPI) to replace repeated markup. -* [PR-22](https://github.com/itk-dev/itk-project-database/pull/22) +* [PR-22](https://github.com/itk-dev/itk-projects/pull/22) Use the ITK logo in the nav and login, tidy the dashboard header, and refresh the login screen. -* [PR-21](https://github.com/itk-dev/itk-project-database/pull/21) +* [PR-21](https://github.com/itk-dev/itk-projects/pull/21) Add a first-login guided tour where Glimt walks new users through the platform, dashboard, creating initiatives and the admin panel. -* [PR-20](https://github.com/itk-dev/itk-project-database/pull/20) +* [PR-20](https://github.com/itk-dev/itk-projects/pull/20) Minor improvements to the user menu styling. -* [PR-19](https://github.com/itk-dev/itk-project-database/pull/19) +* [PR-19](https://github.com/itk-dev/itk-projects/pull/19) Auto-upload files with a progress bar and image preview, view images in an in-page lightbox, and refresh the media field styling. -* [PR-18](https://github.com/itk-dev/itk-project-database/pull/18) +* [PR-18](https://github.com/itk-dev/itk-projects/pull/18) Rework the dashboard with an outstanding-work panel and a redesigned activity feed, add help text to every graph, and fix the mascot's finish nudges. -* [PR-17](https://github.com/itk-dev/itk-project-database/pull/17) +* [PR-17](https://github.com/itk-dev/itk-projects/pull/17) Fix the mascot nudges that never appeared, nudge users to finish incomplete contacts with a link to their edit page. -* [PR-16](https://github.com/itk-dev/itk-project-database/pull/16) +* [PR-16](https://github.com/itk-dev/itk-projects/pull/16) Turn the strategies and tags fields into a searchable, shared tag pool where new entries are capitalised and reused as suggestions. -* [PR-15](https://github.com/itk-dev/itk-project-database/pull/15) +* [PR-15](https://github.com/itk-dev/itk-projects/pull/15) Make Kategori a user-defined Area entity with an admin CRUD, replacing the fixed Category enum. ## [0.1.0] - 2026-06-26 -* [PR-9](https://github.com/itk-dev/itk-project-database/pull/9) +* [PR-9](https://github.com/itk-dev/itk-projects/pull/9) Make department a managed entity and move it and contacts to the admin section -* [PR-7](https://github.com/itk-dev/itk-project-database/pull/7) +* [PR-7](https://github.com/itk-dev/itk-projects/pull/7) Add a mascot motivating users to create and complete initiatives -* [PR-6](https://github.com/itk-dev/itk-project-database/pull/6) +* [PR-6](https://github.com/itk-dev/itk-projects/pull/6) Mark udfyldningsgrad fields with a star that flies into a progress trophy -* [PR-5](https://github.com/itk-dev/itk-project-database/pull/5) +* [PR-5](https://github.com/itk-dev/itk-projects/pull/5) Improve initiative list, search and filters using partial page rendering -* [PR-8](https://github.com/itk-dev/itk-project-database/pull/8) +* [PR-8](https://github.com/itk-dev/itk-projects/pull/8) Adopt itk-dev/entity-bundle: ULID identifiers + shared blamable/timestampable -* [PR-10](https://github.com/itk-dev/itk-project-database/pull/10) +* [PR-10](https://github.com/itk-dev/itk-projects/pull/10) Correct docker compose setup. -* [PR-4](https://github.com/itk-dev/itk-project-database/pull/4) +* [PR-4](https://github.com/itk-dev/itk-projects/pull/4) Add Chart.js graphs to the dashboard -* [PR-3](https://github.com/itk-dev/itk-project-database/pull/3) +* [PR-3](https://github.com/itk-dev/itk-projects/pull/3) Add real-time activity feed with autosave -* [PR-2](https://github.com/itk-dev/itk-project-database/pull/2) +* [PR-2](https://github.com/itk-dev/itk-projects/pull/2) Add Symfony UX Turbo and stimulus. -* [PR-1](https://github.com/itk-dev/itk-project-database/pull/1) +* [PR-1](https://github.com/itk-dev/itk-projects/pull/1) Initial Symfony 8 rebuild of the project database. -[Unreleased]: https://github.com/itk-dev/itk-project-database/compare/0.2.0...HEAD -[0.2.0]: https://github.com/itk-dev/itk-project-database/compare/0.1.09i876rbhn%3E%20gvfcdevgbhjgnybtfr%20v8decsxz...0.2.0 -[0.1.0]: gvfcdevgbhjgnybtfr v8decsxz +[Unreleased]: https://github.com/itk-dev/itk-projects/compare/0.3.0...HEAD +[0.3.0]: https://github.com/itk-dev/itk-projects/compare/0.2.0...0.3.0 +[0.2.0]: https://github.com/itk-dev/itk-projects/compare/0.1.09i876rbhn%3E%20gvfcdevgbhjgnybtfr%20v8decsxz...0.2.0 +[0.1.0]: gvfcdevgbhjgnybtfr v8decsxz diff --git a/README.md b/README.md index d70e345..ccc509e 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,15 @@ -# Project database +# ITK Projects -A Symfony application for registering and browsing municipal **initiatives** and -their **contacts**. It is a rebuild of the previous Drupal-based project -database, focused on a friendlier interface for creating and getting an overview +A Symfony application for registering and browsing municipal **initiatives**. +It is a rebuild of the previous Drupal-based [project-database](https://github.com/itk-dev/project-database) +with an accompanied react application for graph visualizations [project-database-app](https://github.com/itk-dev/project-database-app), +focused on a friendlier interface for creating and getting an overview of initiatives. The project follows the itk-dev [`symfony` Docker template](https://github.com/itk-dev/devops_itkdev-docker) and runs on PHP 8.4 / Symfony 8. -## Features - -- Dashboard with key figures and a status overview of all initiatives. -- List of initiatives with full-text search, faceted filters, column sorting, - pagination and CSV export. -- Create and edit initiatives, including inline creation of contacts, - free-tagging of tags, stakeholders and strategies, and image/file uploads. -- Contact management. -- Private file/image uploads, served only to signed-in users. -- Local username/password login with user administration for administrators. -- Bilingual interface (Danish and English). - ## Requirements - [Docker](https://www.docker.com/) and the itk-dev @@ -36,17 +25,8 @@ development fixtures): task install ``` -Without Task: - -```sh -docker compose up --detach -docker compose exec phpfpm composer install -docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction -docker compose exec phpfpm bin/console doctrine:fixtures:load --no-interaction -``` - The site is served on the domain configured in `.env` -(`COMPOSE_DOMAIN`, e.g. `https://itk-project-database.local.itkdev.dk`). +(`COMPOSE_DOMAIN`, e.g. `https://itk-projects.local.itkdev.dk`). ### Signing in @@ -61,41 +41,14 @@ Create an administrator manually with: task create-admin -- you@example.com "Your Name" ``` -## Access control - -The application uses a single, flat trust model: authentication is required for -everything (the firewall protects `^/`), and **every authenticated user is fully -trusted**. Any signed-in user (`ROLE_USER`) can create, view, edit and delete any -initiative or contact, and can download any uploaded image or attachment by id. - -The only elevated capability is **user administration** (`/admin/**`), which -requires `ROLE_ADMIN`. - -This is intentional: the project database is an internal tool for a small, -trusted group of municipal editors, so per-record ownership or per-action -authorization would add complexity without a real security benefit. Uploaded -files are stored outside the web root and served only through the authenticated -`MediaController`, so they are never anonymously reachable — but they are not -restricted between authenticated users. - -If a future requirement calls for restricting who may edit/delete a given record -(or read a given file), introduce a Symfony [Voter](https://symfony.com/doc/current/security/voters.html) -rather than loosening or working around the flat model. - ## Development ```sh -task # list all tasks -task console -- # run a Symfony console command -task coding-standards:fix # apply coding standards -task static-analysis # run PHPStan -task test # run the test suite -task ci # run everything CI runs +task # list all tasks +task console -- # run a Symfony console command +task compose -- # run a composer command +task coding-standards:check # Check coding standards +task coding-standards:apply # Apply coding standards +task static-analysis # run PHPStan +task test # run the test suite ``` - -### Note on controlled vocabularies - -The controlled vocabularies (status, category, type, organisational anchoring, -endorsement author and funding) are modelled as PHP enums in `src/Enum/` with -placeholder values. Adjust the enum cases and their translations -(`translations/messages.*.yaml`) to match the real domain values. diff --git a/assets/app.js b/assets/app.js index 1c342a4..133c44f 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(); }); @@ -150,3 +159,11 @@ document.addEventListener("turbo:load", () => { document.addEventListener("turbo:frame-load", () => { initCollections(); }); + +// Submitting from inside a confirm dialog caches the page with the dialog still +// open; restoring that snapshot would render it inline, out of the top layer. +document.addEventListener("turbo:before-cache", () => { + document.querySelectorAll("dialog[open]").forEach((dialog) => { + dialog.close(); + }); +}); diff --git a/assets/controllers/confirm_delete_controller.js b/assets/controllers/confirm_delete_controller.js new file mode 100644 index 0000000..fb52e76 --- /dev/null +++ b/assets/controllers/confirm_delete_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus"; + +// The trigger is a plain button, so a click landing before this controller has +// hydrated does nothing; only the button inside the dialog submits. +export default class extends Controller { + static targets = ["dialog"]; + + open() { + this.dialogTarget.showModal(); + } + + cancel() { + this.dialogTarget.close(); + } + + // A modal dialog fills the top layer, so a click on the backdrop reports the + // dialog itself as the target; anything inside reports a descendant. + backdrop(event) { + if (event.target === this.dialogTarget) { + this.dialogTarget.close(); + } + } +} diff --git a/assets/controllers/live_search_controller.js b/assets/controllers/live_search_controller.js index e764124..aa0cd15 100644 --- a/assets/controllers/live_search_controller.js +++ b/assets/controllers/live_search_controller.js @@ -5,9 +5,17 @@ import { Controller } from "@hotwired/stimulus"; * (data-turbo-frame), so submitting it swaps only the results — no full page * load. Typing is debounced; selects submit on change. The submit button is * gone: this controller drives the submit, and clear() resets the fields. + * + * The frame carries data-turbo-action="advance", so the query it was fetched + * with becomes the address bar URL — the deeplink trimQuery() keeps clean. */ export default class extends Controller { - static values = { debounce: { type: Number, default: 300 } }; + static targets = ["form"]; + + static values = { + debounce: { type: Number, default: 300 }, + exportUrl: String, + }; connect() { this.timer = null; @@ -20,21 +28,64 @@ export default class extends Controller { submit() { window.clearTimeout(this.timer); this.timer = window.setTimeout( - () => this.element.requestSubmit(), + () => this.formTarget.requestSubmit(), this.debounceValue, ); } + // Filtering is live, so Enter has nothing left to run — and without a + // submit button the browser would submit the form itself on every press. + ignoreEnter(event) { + event.preventDefault(); + } + clear() { - for (const input of this.element.querySelectorAll("input")) { + for (const input of this.formTarget.querySelectorAll("input")) { if (!["submit", "button", "reset"].includes(input.type)) { input.value = ""; } } - for (const select of this.element.querySelectorAll("select")) { + for (const select of this.formTarget.querySelectorAll("select")) { select.selectedIndex = 0; } window.clearTimeout(this.timer); - this.element.requestSubmit(); + this.formTarget.requestSubmit(); + } + + // Turbo re-reads detail.url after this event, and the frame adopts the + // response URL — so dropping empty filters here is what shortens the link. + trimQuery(event) { + const url = new URL(event.detail.url); + url.search = this.query(); + event.detail.url = url; + } + + // Downloads a file, so it leaves Turbo behind. + exportCsv() { + const query = this.query(); + window.location.assign( + query ? `${this.exportUrlValue}?${query}` : this.exportUrlValue, + ); + } + + // Sorting is driven by links inside the frame rather than by a form field, + // so it has to come off the URL or a filter change would reset it. + query() { + const params = new URLSearchParams(); + for (const [name, value] of new FormData(this.formTarget)) { + if ("" !== value) { + params.append(name, value); + } + } + + const current = new URLSearchParams(window.location.search); + for (const name of ["sort", "direction"]) { + const value = current.get(name); + if (value) { + params.set(name, value); + } + } + + return params.toString(); } } 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); diff --git a/composer.json b/composer.json index 7f3eebb..6633df8 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "itk-dev/itk-project-database", + "name": "itk-dev/itk-projects", "description": "Symfony app for registering and browsing municipal initiatives.", "license": "proprietary", "type": "project", diff --git a/composer.lock b/composer.lock index 8d4a2e5..0927243 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1690432fc2541ccfd5e69c603125ca0e", + "content-hash": "35d45135fb8f48f223762d0579d66858", "packages": [ { "name": "composer/semver", 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 diff --git a/migrations/Version20260813090155.php b/migrations/Version20260813090155.php new file mode 100644 index 0000000..b2bb7d5 --- /dev/null +++ b/migrations/Version20260813090155.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE initiative_partner (initiative_id BINARY(16) NOT NULL, partner_id BINARY(16) NOT NULL, INDEX IDX_12D1DC4CAB7D9771 (initiative_id), INDEX IDX_12D1DC4C9393F8FE (partner_id), PRIMARY KEY (initiative_id, partner_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE partner (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, website VARCHAR(255) DEFAULT NULL, created_by_id BINARY(16) DEFAULT NULL, modified_by_id BINARY(16) DEFAULT NULL, INDEX idx_partner_name (name), INDEX IDX_312B3E16B03A8386 (created_by_id), INDEX IDX_312B3E1699049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE initiative_partner ADD CONSTRAINT FK_12D1DC4CAB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_partner ADD CONSTRAINT FK_12D1DC4C9393F8FE FOREIGN KEY (partner_id) REFERENCES partner (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE partner ADD CONSTRAINT FK_312B3E16B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE partner ADD CONSTRAINT FK_312B3E1699049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE initiative_partner DROP FOREIGN KEY FK_12D1DC4CAB7D9771'); + $this->addSql('ALTER TABLE initiative_partner DROP FOREIGN KEY FK_12D1DC4C9393F8FE'); + $this->addSql('ALTER TABLE partner DROP FOREIGN KEY FK_312B3E16B03A8386'); + $this->addSql('ALTER TABLE partner DROP FOREIGN KEY FK_312B3E1699049ECE'); + $this->addSql('DROP TABLE initiative_partner'); + $this->addSql('DROP TABLE partner'); + } +} diff --git a/migrations/Version20260814131716.php b/migrations/Version20260814131716.php new file mode 100644 index 0000000..2347cbb --- /dev/null +++ b/migrations/Version20260814131716.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE initiative ADD topic LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE initiative DROP topic'); + } +} diff --git a/src/Controller/Admin/PartnerController.php b/src/Controller/Admin/PartnerController.php new file mode 100644 index 0000000..c357cfe --- /dev/null +++ b/src/Controller/Admin/PartnerController.php @@ -0,0 +1,91 @@ +findInitiativeUsage(); + + // Pair each partner with its initiatives here rather than looking the usage + // up per row, which would mean keying a Twig array by a Ulid object. + $rows = []; + foreach ($partners->findAllOrdered() as $partner) { + $rows[] = [ + 'partner' => $partner, + 'initiatives' => $usage[(string) $partner->getId()] ?? [], + ]; + } + + return $this->render('admin/partners/index.html.twig', ['rows' => $rows]); + } + + #[Route('/new', name: 'admin_partner_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $partner = new Partner(); + $form = $this->createForm(PartnerType::class, $partner); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->persist($partner); + $entityManager->flush(); + $this->addFlash('success', 'flash.partner.created'); + + return $this->redirectToRoute('admin_partners'); + } + + return $this->render('admin/partners/new.html.twig', ['form' => $form]); + } + + #[Route('/{id}/edit', name: 'admin_partner_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])] + public function edit(Request $request, Partner $partner, EntityManagerInterface $entityManager, PartnerRepository $partners): Response + { + $form = $this->createForm(PartnerType::class, $partner); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + $this->addFlash('success', 'flash.partner.updated'); + + return $this->redirectToRoute('admin_partners'); + } + + return $this->render('admin/partners/edit.html.twig', [ + 'form' => $form, + 'partner' => $partner, + 'initiatives' => $partners->findInitiativesUsing($partner), + ]); + } + + // Also detaches the partner from every initiative: the join table is cleared by + // its ON DELETE CASCADE, which Doctrine never sees (unidirectional association). + #[Route('/{id}/delete', name: 'admin_partner_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])] + public function delete(Request $request, Partner $partner, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-partner-'.$partner->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($partner); + $entityManager->flush(); + $this->addFlash('success', 'flash.partner.deleted'); + } + + return $this->redirectToRoute('admin_partners'); + } +} diff --git a/src/Controller/InitiativeController.php b/src/Controller/InitiativeController.php index 7b2f8d0..c41f7ad 100644 --- a/src/Controller/InitiativeController.php +++ b/src/Controller/InitiativeController.php @@ -64,14 +64,16 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran $response = new StreamedResponse(function () use ($rows, $translator, $translate): void { $csv = Writer::createFromStream(fopen('php://output', 'w')); $csv->insertOne([ - 'id', $translator->trans('initiative.title'), $translator->trans('initiative.status'), + 'id', $translator->trans('initiative.title'), $translator->trans('initiative.topic'), + $translator->trans('initiative.status'), $translator->trans('initiative.area'), $translator->trans('initiative.initiative_type'), $translator->trans('initiative.organizational_anchoring'), $translator->trans('initiative.endorsement'), $translator->trans('initiative.endorsement_author'), $translator->trans('initiative.budget'), $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))); @@ -80,6 +82,7 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran $csv->insertOne([ (string) $row->getId(), $row->getTitle(), + $row->getTopic(), $translate($row->getStatus()), $row->getArea()?->getName(), $translate($row->getInitiativeType()), @@ -94,6 +97,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, ]); } diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index aa04607..c922ecb 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,7 +26,14 @@ 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']; + private const array TOPICS = [ + 'Digital Europe Blueprint for Data Space for smart and sustainable cities and communities.', + 'Horizon Europe — Climate-neutral and smart cities mission.', + 'Den fællesoffentlige digitaliseringsstrategi 2022–2026.', + 'Interreg Øresund-Kattegat-Skagerrak.', + ]; public function __construct(private readonly UserPasswordHasherInterface $hasher) { @@ -69,6 +77,18 @@ public function load(ObjectManager $manager): void $areas[] = $area; } + $partners = []; + foreach (self::PARTNERS as $name) { + $partner = (new Partner()) + ->setName($name) + ->setDescription($name.' samarbejder med kommunen om udvikling, viden og afprøvning i konkrete initiativer.') + // ascii() turns spaces into dots (it also builds e-mail addresses), which + // a domain does not want, so drop them again. + ->setWebsite('https://www.'.strtolower(str_replace('.', '', $this->ascii($name))).'.dk'); + $manager->persist($partner); + $partners[] = $partner; + } + $contacts = []; $firstNames = ['Anne', 'Mette', 'Lars', 'Søren', 'Camilla', 'Jens', 'Ida', 'Mads', 'Sofie', 'Peter', 'Louise', 'Thomas']; $lastNames = ['Jensen', 'Nielsen', 'Hansen', 'Pedersen', 'Andersen', 'Christensen', 'Larsen', 'Sørensen']; @@ -127,6 +147,11 @@ public function load(ObjectManager $manager): void ->setBudget(mt_rand(1, 40) * 50000); $initiative->setCreatedBy($users[array_rand($users)]); + // Not every initiative belongs to a wider programme. + if (0 !== $index % 3) { + $initiative->setTopic(self::TOPICS[$index % \count(self::TOPICS)]); + } + if (0 !== $index % 4) { $initiative->setEndorsementAuthor($endorsers[array_rand($endorsers)]); } @@ -149,6 +174,9 @@ public function load(ObjectManager $manager): void foreach (\array_slice($this->shuffleCopy($contacts), 0, mt_rand(1, 3)) as $contact) { $initiative->addContact($contact); } + foreach (\array_slice($this->shuffleCopy($partners), 0, mt_rand(1, 3)) as $partner) { + $initiative->addPartner($partner); + } $initiative->setLinks(['https://www.aarhus.dk']); diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index a274d9c..84b9a4c 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -35,6 +35,14 @@ class Initiative extends AbstractEntity #[ORM\Column(length: 255)] private ?string $title = null; + /** + * Which wider programme the initiative is a part of — the title names this + * project, the topic places it ("DS4SSCC" → "Digital Europe Blueprint for + * Data Space for smart and sustainable cities and communities"). + */ + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $topic = null; + #[ORM\ManyToOne(targetEntity: Area::class)] #[ORM\JoinColumn(onDelete: 'SET NULL')] private ?Area $area = null; @@ -71,6 +79,16 @@ class Initiative extends AbstractEntity #[ORM\JoinTable(name: 'initiative_contact')] private Collection $contacts; + /** + * 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 + */ + #[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 +134,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(); @@ -134,6 +153,18 @@ public function setTitle(?string $title): static return $this; } + public function getTopic(): ?string + { + return $this->topic; + } + + public function setTopic(?string $topic): static + { + $this->topic = $topic; + + return $this; + } + public function getArea(): ?Area { return $this->area; @@ -285,6 +316,28 @@ 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; + } + /** @return Collection */ public function getImages(): Collection { diff --git a/src/Entity/Partner.php b/src/Entity/Partner.php new file mode 100644 index 0000000..ca1bf6e --- /dev/null +++ b/src/Entity/Partner.php @@ -0,0 +1,80 @@ +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/Form/DataTransformer/PartnersTextTransformer.php b/src/Form/DataTransformer/PartnersTextTransformer.php new file mode 100644 index 0000000..2b7bc98 --- /dev/null +++ b/src/Form/DataTransformer/PartnersTextTransformer.php @@ -0,0 +1,71 @@ + + */ +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) { + // 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; + + $partners->add($this->partnerRepository->findOrCreate($name)); + } + + return $partners; + } +} diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index 29a8d35..11b4861 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -40,6 +40,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'initiative.title', 'help' => 'initiative.title_help', ]) + ->add('topic', TextareaType::class, [ + 'label' => 'initiative.topic', + 'required' => false, + 'attr' => ['rows' => 4], + 'help' => 'initiative.topic_help', + ]) ->add('area', EntityType::class, [ 'label' => 'initiative.area', 'class' => Area::class, @@ -164,6 +170,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'initiative.terms_help', ]) + ->add('partners', PartnersTextType::class, [ + 'label' => 'initiative.partners', + 'required' => false, + 'help' => 'initiative.partners_help', + ]) ->add('images', CollectionType::class, [ 'label' => 'initiative.images', 'entry_type' => InitiativeImageType::class, 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/src/Form/PartnersTextType.php b/src/Form/PartnersTextType.php new file mode 100644 index 0000000..004dc2c --- /dev/null +++ b/src/Form/PartnersTextType.php @@ -0,0 +1,63 @@ + + */ +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. + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $pool = array_map( + static fn (Partner $partner): string => (string) $partner->getName(), + $this->partnerRepository->findAllOrdered(), + ); + + $view->vars['attr'] = array_merge($view->vars['attr'], [ + 'data-partner-select' => '', + 'data-partner-pool' => json_encode($pool, \JSON_THROW_ON_ERROR), + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'invalid_message' => 'form.terms.invalid', + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 18ce917..d988f00 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -42,6 +42,7 @@ public function search(InitiativeFilter $filter): QueryBuilder $ors = [ 'LOWER(i.title) LIKE :q', + 'LOWER(i.topic) LIKE :q', 'LOWER(i.description) LIKE :q', 'LOWER(i.statusAdditional) LIKE :q', // Related names, matched without joining the root query so the @@ -51,6 +52,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), @@ -144,7 +146,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') diff --git a/src/Repository/PartnerRepository.php b/src/Repository/PartnerRepository.php new file mode 100644 index 0000000..28ae748 --- /dev/null +++ b/src/Repository/PartnerRepository.php @@ -0,0 +1,121 @@ + + * + * @extends ServiceEntityRepository + */ +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(); + } + + /** + * 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 + * 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/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/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/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 new file mode 100644 index 0000000..222efd2 --- /dev/null +++ b/templates/admin/partners/_delete.html.twig @@ -0,0 +1,20 @@ +{% set count = initiatives|length %} + + {% 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/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..bcad6ab --- /dev/null +++ b/templates/admin/partners/edit.html.twig @@ -0,0 +1,13 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + {{ 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 new file mode 100644 index 0000000..f9f1b53 --- /dev/null +++ b/templates/admin/partners/index.html.twig @@ -0,0 +1,44 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ 'action.new'|trans }} + + +
+ {% if rows is empty %} + + {% else %} +
+ + + + + + + + + + + {% for row in rows %} + {% set partner = row.partner %} + + + + + + + {% endfor %} + +
{{ 'partner.name'|trans }}{{ 'partner.website'|trans }}{{ 'partner.usage'|trans }}
{{ partner.name }}{% 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}) }} +
+
+
+ {% 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/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..08c641e --- /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/_form.html.twig b/templates/initiative/_form.html.twig index 3cecdae..e9ae60d 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -46,6 +46,7 @@ {% set title_attr = {'data-action': 'input->title-mirror#update', 'data-autosave-required': true} %} {% if not initiative.id %}{% set title_attr = title_attr|merge({autofocus: true}) %}{% endif %} {{ form_row(form.title, {attr: title_attr}) }} + {{ form_row(form.topic) }}
{{ form_row(form.area) }} {{ form_row(form.initiativeType) }} @@ -102,6 +103,8 @@
{{ 'initiative.section.relations'|trans }}
{{ form_row(form.stakeholders) }} + {{ form_row(form.partners) }} + {{ form_row(form.contacts) }}
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/templates/initiative/index.html.twig b/templates/initiative/index.html.twig index edf52f8..2e91ff4 100644 --- a/templates/initiative/index.html.twig +++ b/templates/initiative/index.html.twig @@ -3,21 +3,22 @@ {% block title %}{{ 'initiative.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block body %} -
+
- + {{ 'action.new'|trans }} {{ form_start(form, {attr: { class: 'filters', id: 'initiative-filters', - 'data-controller': 'live-search', - 'data-action': 'input->live-search#submit change->live-search#submit', + 'data-live-search-target': 'form', + 'data-action': 'input->live-search#submit change->live-search#submit turbo:before-fetch-request->live-search#trimQuery', 'data-turbo-frame': 'initiative-results', }}) }}
@@ -30,7 +31,9 @@ {{ form_end(form) }}
- + {# advance puts the frame's query in the address bar, so filtering, + sorting and paging all produce a shareable URL. #} + {{ include('initiative/_results.html.twig') }}
diff --git a/templates/initiative/show.html.twig b/templates/initiative/show.html.twig index 1102366..6adc7cd 100644 --- a/templates/initiative/show.html.twig +++ b/templates/initiative/show.html.twig @@ -29,6 +29,10 @@
{% if initiative.description %}

{{ initiative.description }}

{% else %}

{% endif %} + {% if initiative.topic %} +

{{ 'initiative.topic'|trans }}

+

{{ initiative.topic }}

+ {% endif %} {% if initiative.statusAdditional %}

{{ 'initiative.status_additional'|trans }}

{{ initiative.statusAdditional }}

@@ -62,6 +66,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/Admin/PartnerControllerTest.php b/tests/Controller/Admin/PartnerControllerTest.php new file mode 100644 index 0000000..fbe0c18 --- /dev/null +++ b/tests/Controller/Admin/PartnerControllerTest.php @@ -0,0 +1,254 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/partners'); + + $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(); + $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 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(); + $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 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(); + $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); + } + + 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); + $em = $this->entityManager(); + $em->persist($partner); + $em->flush(); + + 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(); + $partner = $this->partners()->find($id); + if (null !== $partner) { + $em = $this->entityManager(); + $em->remove($partner); + $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/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index f3b1f4f..88205a5 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -7,11 +7,51 @@ use App\Entity\Initiative; use App\Entity\InitiativeAttachment; use App\Entity\InitiativeImage; +use App\Enum\Status; use App\Tests\FunctionalTestCase; use Symfony\Component\HttpFoundation\Response; final class InitiativeControllerTest extends FunctionalTestCase { + public function testIndexAppliesFiltersFromTheQueryString(): void + { + $this->loginAsAdmin(); + $match = $this->createInitiative('Deeplinked initiative', Status::Active); + $other = $this->createInitiative('Deeplinked other initiative', Status::Cancelled); + + // Opening a shared link must narrow the list, not just fill the form. + $crawler = $this->client->request('GET', '/initiatives?q=Deeplinked&status=active&sort=title&direction=ASC'); + + $this->assertResponseIsSuccessful(); + self::assertSame('active', $crawler->filter('#initiative-filters select[name="status"] option[selected]')->attr('value')); + self::assertSame('Deeplinked', $crawler->filter('#initiative-filters input[name="q"]')->attr('value')); + + $titles = $crawler->filter('#initiative-results .cell-title')->each(static fn ($node): string => $node->text()); + self::assertContains('Deeplinked initiative', $titles); + self::assertNotContains('Deeplinked other initiative', $titles); + + $this->removeInitiative((string) $match->getId()); + $this->removeInitiative((string) $other->getId()); + } + + public function testIndexSearchFieldDoesNotSubmitOnEnter(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/initiatives'); + + $this->assertResponseIsSuccessful(); + + // Enter clicks the form's default button, so an owned submit button + // anywhere would turn it into a CSV download. + self::assertCount(0, $crawler->filter('#initiative-filters button[type="submit"], #initiative-filters input[type="submit"]')); + self::assertCount(0, $crawler->filter('button[form="initiative-filters"], input[form="initiative-filters"]')); + + $search = $crawler->filter('#initiative-filters input[name="q"]'); + self::assertStringContainsString('keydown.enter->live-search#ignoreEnter', (string) $search->attr('data-action')); + // Merged onto the field's attributes, not swapped in. + self::assertNotEmpty($search->attr('placeholder')); + } + public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): void { $this->loginAsAdmin(); @@ -24,6 +64,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. + 'partners' => 'Coverage Partner', '_token' => $token, ], ]); @@ -34,6 +76,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(); @@ -41,9 +84,43 @@ 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 testTopicIsSavedShownSearchableAndExported(): void + { + $this->loginAsAdmin(); + $topic = 'Digital Europe Blueprint '.uniqid(); + + $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' => 'Topic initiative', 'topic' => $topic, '_token' => $token], + ]); + $this->assertResponseRedirects(); + + $initiative = $this->initiatives()->findOneBy(['title' => 'Topic initiative']); + self::assertInstanceOf(Initiative::class, $initiative); + self::assertSame($topic, $initiative->getTopic()); + $id = (string) $initiative->getId(); + + $crawler = $this->client->request('GET', '/initiatives/'.$id); + self::assertStringContainsString($topic, $crawler->filter('.card__body')->first()->text()); + + // The free-text filter searches the topic alongside title and description. + $crawler = $this->client->request('GET', '/initiatives?q='.urlencode($topic)); + self::assertStringContainsString('Topic initiative', $crawler->filter('#initiative-results')->text()); + + $this->client->request('GET', '/initiatives/export?q='.urlencode($topic)); + $csv = (string) $this->client->getInternalResponse()->getContent(); + self::assertStringContainsString($topic, $csv); + + $this->removeInitiative($id); + } + public function testEditUpdatesInitiative(): void { $this->loginAsAdmin(); @@ -222,9 +299,9 @@ public function testEditAutosaveReturnsUnprocessableWhenInvalid(): void $this->removeInitiative($id); } - private function createInitiative(string $title): Initiative + private function createInitiative(string $title, ?Status $status = null): Initiative { - $initiative = (new Initiative())->setTitle($title); + $initiative = (new Initiative())->setTitle($title)->setStatus($status); $em = $this->entityManager(); $em->persist($initiative); $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/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..f4f5f7e --- /dev/null +++ b/tests/Repository/PartnerRepositoryTest.php @@ -0,0 +1,122 @@ +get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + // 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(); + + $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 + { + 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 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(); + $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..e61beb7 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()); @@ -48,6 +50,7 @@ public function testScalarAccessors(): void $initiative = (new Initiative()) ->setTitle('Grøn omstilling') + ->setTopic('Digital Europe Blueprint for Data Space') ->setArea($area) ->setDescription('Beskrivelse') ->setInitiativeType(InitiativeType::Project) @@ -61,6 +64,7 @@ public function testScalarAccessors(): void ->setTimePeriodEnd($end); self::assertSame('Grøn omstilling', $initiative->getTitle()); + self::assertSame('Digital Europe Blueprint for Data Space', $initiative->getTopic()); self::assertSame($area, $initiative->getArea()); self::assertSame('Beskrivelse', $initiative->getDescription()); self::assertSame(InitiativeType::Project, $initiative->getInitiativeType()); @@ -203,6 +207,19 @@ 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()); + } + public function testImageCollectionLinksBackToInitiative(): void { $initiative = new Initiative(); 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); + } +} diff --git a/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php new file mode 100644 index 0000000..a504617 --- /dev/null +++ b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php @@ -0,0 +1,70 @@ +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')); + } + + public function testReverseTransformClampsANameTooLongForTheColumn(): void + { + $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 + { + return new PartnersTextTransformer($this->createStub(PartnerRepository::class)); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index e1cdcbe..0f83aeb 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -58,7 +58,7 @@ 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: @@ -83,6 +83,7 @@ nav: dashboard: Overblik initiatives: Initiativer contacts: Kontaktpersoner + partners: Samarbejdspartnere departments: Afdelinger areas: Områder users: Brugere @@ -112,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 @@ -184,6 +186,8 @@ dashboard: initiative: title: Titel title_help: Et kort, sigende navn på initiativet. + topic: Emne + topic_help: Det overordnede program, initiativet er en del af. area: Område area_help: Det tematiske område initiativet hører under. description: Kort beskrivelse @@ -212,6 +216,8 @@ 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. 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 +236,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 +249,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 +273,34 @@ 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. + name_comma: Navnet må ikke indeholde komma. + description: Beskrivelse + 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: + 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. + confirm: Slet alligevel + 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 +370,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..85636c8 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -58,7 +58,7 @@ 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: @@ -83,6 +83,7 @@ nav: dashboard: Overview initiatives: Initiatives contacts: Contacts + partners: Partners departments: Departments areas: Areas users: Users @@ -112,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 @@ -184,6 +186,8 @@ dashboard: initiative: title: Title title_help: A short, descriptive name for the initiative. + topic: Topic + topic_help: The wider programme this initiative is part of. area: Area area_help: The thematic area the initiative belongs to. description: Short description @@ -212,6 +216,8 @@ 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. 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 +236,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 +249,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 +273,34 @@ 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. + name_comma: The name cannot contain a comma. + description: Description + 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: + 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. + confirm: Delete anyway + 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 +370,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.