diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b75412..4bf3a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-25](https://github.com/itk-dev/itk-projects/pull/25) + Add 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. + ## [0.2.0] - 2026-06-30 * [PR-23](https://github.com/itk-dev/itk-projects/pull/23) 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/InitiativeController.php b/src/Controller/InitiativeController.php index 7b2f8d0..8615e1c 100644 --- a/src/Controller/InitiativeController.php +++ b/src/Controller/InitiativeController.php @@ -64,7 +64,8 @@ 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'), @@ -80,6 +81,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()), diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index aa04607..8cd35ce 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -26,6 +26,12 @@ class AppFixtures extends Fixture 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 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) { @@ -127,6 +133,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)]); } diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index a274d9c..8f56cf6 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; @@ -134,6 +142,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; diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index 29a8d35..a0e0e4e 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, diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 18ce917..245dd1b 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 diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 3cecdae..447f765 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) }}
{{ initiative.description }}
{% else %}—
{% endif %} + {% if initiative.topic %} +{{ initiative.topic }}
+ {% endif %} {% if initiative.statusAdditional %}{{ initiative.statusAdditional }}
diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index f3b1f4f..57e35de 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -44,6 +44,37 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $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(); diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index 9613b3f..65ec08f 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -48,6 +48,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 +62,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()); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index e1cdcbe..ba3928f 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -184,6 +184,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 diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 607bf6d..797a4c9 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -184,6 +184,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