From c6d91fbe56cfe4a73a130b0c1f79109ac23cc263 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 16:04:16 +0200 Subject: [PATCH 01/37] Add Area entity with admin CRUD --- src/Controller/Admin/AreaController.php | 78 ++++++++++++ src/Entity/Area.php | 36 ++++++ src/Form/AreaType.php | 31 +++++ src/Repository/AreaRepository.php | 31 +++++ templates/admin/areas/_form.html.twig | 10 ++ templates/admin/areas/edit.html.twig | 20 +++ templates/admin/areas/index.html.twig | 52 ++++++++ templates/admin/areas/new.html.twig | 13 ++ templates/admin/base.html.twig | 1 + tests/Controller/Admin/AreaControllerTest.php | 117 ++++++++++++++++++ tests/FunctionalTestCase.php | 9 ++ tests/Repository/AreaRepositoryTest.php | 25 ++++ tests/Unit/Entity/AreaTest.php | 27 ++++ translations/messages.da.yaml | 19 +++ translations/messages.en.yaml | 19 +++ 15 files changed, 488 insertions(+) create mode 100644 src/Controller/Admin/AreaController.php create mode 100644 src/Entity/Area.php create mode 100644 src/Form/AreaType.php create mode 100644 src/Repository/AreaRepository.php create mode 100644 templates/admin/areas/_form.html.twig create mode 100644 templates/admin/areas/edit.html.twig create mode 100644 templates/admin/areas/index.html.twig create mode 100644 templates/admin/areas/new.html.twig create mode 100644 tests/Controller/Admin/AreaControllerTest.php create mode 100644 tests/Repository/AreaRepositoryTest.php create mode 100644 tests/Unit/Entity/AreaTest.php diff --git a/src/Controller/Admin/AreaController.php b/src/Controller/Admin/AreaController.php new file mode 100644 index 0000000..0278257 --- /dev/null +++ b/src/Controller/Admin/AreaController.php @@ -0,0 +1,78 @@ +render('admin/areas/index.html.twig', [ + 'areas' => $areas->findAllOrdered(), + ]); + } + + #[Route('/new', name: 'admin_area_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $area = new Area(); + $form = $this->createForm(AreaType::class, $area); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->persist($area); + $entityManager->flush(); + $this->addFlash('success', 'flash.area.created'); + + return $this->redirectToRoute('admin_areas'); + } + + return $this->render('admin/areas/new.html.twig', ['form' => $form]); + } + + #[Route('/{id}/edit', name: 'admin_area_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])] + public function edit(Request $request, Area $area, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(AreaType::class, $area); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + $this->addFlash('success', 'flash.area.updated'); + + return $this->redirectToRoute('admin_areas'); + } + + return $this->render('admin/areas/edit.html.twig', [ + 'form' => $form, + 'area' => $area, + ]); + } + + #[Route('/{id}/delete', name: 'admin_area_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])] + public function delete(Request $request, Area $area, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-area-'.$area->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($area); + $entityManager->flush(); + $this->addFlash('success', 'flash.area.deleted'); + } + + return $this->redirectToRoute('admin_areas'); + } +} diff --git a/src/Entity/Area.php b/src/Entity/Area.php new file mode 100644 index 0000000..68cc18e --- /dev/null +++ b/src/Entity/Area.php @@ -0,0 +1,36 @@ +name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Form/AreaType.php b/src/Form/AreaType.php new file mode 100644 index 0000000..0cf4338 --- /dev/null +++ b/src/Form/AreaType.php @@ -0,0 +1,31 @@ + + */ +class AreaType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'label' => 'area.name', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Area::class, + ]); + } +} diff --git a/src/Repository/AreaRepository.php b/src/Repository/AreaRepository.php new file mode 100644 index 0000000..3cee73d --- /dev/null +++ b/src/Repository/AreaRepository.php @@ -0,0 +1,31 @@ + + */ +class AreaRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Area::class); + } + + /** + * @return Area[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('a') + ->orderBy('a.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/templates/admin/areas/_form.html.twig b/templates/admin/areas/_form.html.twig new file mode 100644 index 0000000..b8b51d2 --- /dev/null +++ b/templates/admin/areas/_form.html.twig @@ -0,0 +1,10 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.name) }} +
+
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/admin/areas/edit.html.twig b/templates/admin/areas/edit.html.twig new file mode 100644 index 0000000..4660e79 --- /dev/null +++ b/templates/admin/areas/edit.html.twig @@ -0,0 +1,20 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'area.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ 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 new file mode 100644 index 0000000..c5c3ac7 --- /dev/null +++ b/templates/admin/areas/index.html.twig @@ -0,0 +1,52 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'area.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + +
+ {% if areas is empty %} +
+
{{ 'area.empty.title'|trans }}
+

{{ 'area.empty.hint'|trans }}

+
+ {% else %} +
+ + + + + + + + + {% for area in areas %} + + + + + {% endfor %} + +
{{ 'area.name'|trans }}
{{ area.name }} +
+ {{ 'action.edit'|trans }} +
+ + +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/areas/new.html.twig b/templates/admin/areas/new.html.twig new file mode 100644 index 0000000..cb4bb46 --- /dev/null +++ b/templates/admin/areas/new.html.twig @@ -0,0 +1,13 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'area.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ include('admin/areas/_form.html.twig', {button_label: 'action.create'}) }} +{% endblock %} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index ffce036..6fc9042 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -9,6 +9,7 @@ {% endif %} {{ 'nav.contacts'|trans }} {{ 'nav.departments'|trans }} + {{ 'nav.areas'|trans }}
{% block admin_content %}{% endblock %} diff --git a/tests/Controller/Admin/AreaControllerTest.php b/tests/Controller/Admin/AreaControllerTest.php new file mode 100644 index 0000000..2c3c040 --- /dev/null +++ b/tests/Controller/Admin/AreaControllerTest.php @@ -0,0 +1,117 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/areas'); + + $this->assertResponseIsSuccessful(); + } + + public function testNewCreatesArea(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/areas/new'); + $this->assertResponseIsSuccessful(); + + $name = 'Test Area '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form(['area[name]' => $name]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/areas'); + + $area = $this->areas()->findOneBy(['name' => $name]); + self::assertInstanceOf(Area::class, $area); + $this->removeArea((string) $area->getId()); + } + + public function testNewRejectsADuplicateName(): void + { + $this->loginAsAdmin(); + $name = 'Duplicate Area '.uniqid(); + $id = (string) $this->createArea($name)->getId(); + + $crawler = $this->client->request('GET', '/admin/areas/new'); + $form = $crawler->filter('button.btn--primary')->form(['area[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->areas()->findBy(['name' => $name])); + + $this->removeArea($id); + } + + public function testEditUpdatesArea(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createArea('Editable Area '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/areas/%s/edit', $id)); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form(['area[name]' => 'Edited Area '.uniqid()]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/areas'); + $this->removeArea($id); + } + + public function testDeleteRemovesAreaWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createArea('Deletable Area '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/areas/%s/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/areas'); + $this->entityManager()->clear(); + self::assertNull($this->areas()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createArea('Surviving Area '.uniqid())->getId(); + + $this->client->request('POST', sprintf('/admin/areas/%s/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/admin/areas'); + $this->entityManager()->clear(); + self::assertNotNull($this->areas()->find($id)); + $this->removeArea($id); + } + + private function createArea(string $name): Area + { + $area = (new Area())->setName($name); + $em = $this->entityManager(); + $em->persist($area); + $em->flush(); + + return $area; + } + + private function removeArea(string $id): void + { + $this->entityManager()->clear(); + $area = $this->areas()->find($id); + if (null !== $area) { + $em = $this->entityManager(); + $em->remove($area); + $em->flush(); + } + } +} diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 16c9399..0e1d48a 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -5,6 +5,7 @@ namespace App\Tests; use App\Entity\User; +use App\Repository\AreaRepository; use App\Repository\ContactRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; @@ -69,6 +70,14 @@ protected function departments(): DepartmentRepository return $repository; } + protected function areas(): AreaRepository + { + $repository = static::getContainer()->get(AreaRepository::class); + \assert($repository instanceof AreaRepository); + + return $repository; + } + protected function terms(): TermRepository { $repository = static::getContainer()->get(TermRepository::class); diff --git a/tests/Repository/AreaRepositoryTest.php b/tests/Repository/AreaRepositoryTest.php new file mode 100644 index 0000000..c9615eb --- /dev/null +++ b/tests/Repository/AreaRepositoryTest.php @@ -0,0 +1,25 @@ +get(AreaRepository::class); + \assert($repository instanceof AreaRepository); + + $areas = $repository->findAllOrdered(); + + // Ordering is delegated to the database collation, so we only assert the + // method returns the persisted areas. + self::assertNotEmpty($areas); + self::assertNotNull($areas[0]->getId()); + } +} diff --git a/tests/Unit/Entity/AreaTest.php b/tests/Unit/Entity/AreaTest.php new file mode 100644 index 0000000..344b6ee --- /dev/null +++ b/tests/Unit/Entity/AreaTest.php @@ -0,0 +1,27 @@ +getName()); + self::assertSame('', (string) $area); + } + + public function testAccessors(): void + { + $area = (new Area())->setName('Klima og miljø'); + + self::assertSame('Klima og miljø', $area->getName()); + self::assertSame('Klima og miljø', (string) $area); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 8b9db83..0aa1c1a 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -64,6 +64,7 @@ nav: initiatives: Initiativer contacts: Kontaktpersoner departments: Afdelinger + areas: Områder users: Brugere admin: Administration language: Sprog @@ -225,6 +226,20 @@ department: title: Ingen afdelinger endnu hint: Opret en afdeling, så den kan vælges på initiativer. +area: + name: Navn + name_duplicate: Der findes allerede et område med dette navn. + index: + title: Områder + subtitle: Tematisk område der kan vælges på initiativer. + new: + title: Nyt område + edit: + title: Rediger område + empty: + title: Ingen områder endnu + hint: Opret et område, så det kan vælges på initiativer. + user: email: E-mail name: Navn @@ -266,6 +281,10 @@ flash: created: Afdelingen blev oprettet. updated: Afdelingen blev opdateret. deleted: Afdelingen blev slettet. + area: + created: Området blev oprettet. + updated: Området blev opdateret. + deleted: Området blev slettet. user: created: Brugeren blev oprettet. updated: Brugeren blev opdateret. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index a75172d..3419a45 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -64,6 +64,7 @@ nav: initiatives: Initiatives contacts: Contacts departments: Departments + areas: Areas users: Users admin: Administration language: Language @@ -225,6 +226,20 @@ department: title: No departments yet hint: Create a department so it can be chosen on initiatives. +area: + name: Name + name_duplicate: An area with this name already exists. + index: + title: Areas + subtitle: Thematic area that can be chosen on initiatives. + new: + title: New area + edit: + title: Edit area + empty: + title: No areas yet + hint: Create an area so it can be chosen on initiatives. + user: email: Email name: Name @@ -266,6 +281,10 @@ flash: created: The department was created. updated: The department was updated. deleted: The department was deleted. + area: + created: The area was created. + updated: The area was updated. + deleted: The area was deleted. user: created: The user was created. updated: The user was updated. From f6e833dc3efef16d1bc2dbc12783ac0ce15c4a94 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 16:04:52 +0200 Subject: [PATCH 02/37] Replace Initiative category enum with Area relation --- .../controllers/dashboard_viz_controller.js | 12 +-- migrations/Version20260627134918.php | 41 ++++++++++ src/Controller/InitiativeController.php | 4 +- src/DataFixtures/AppFixtures.php | 13 +++- src/Entity/Initiative.php | 18 ++--- src/Enum/Category.php | 26 ------- src/Form/InitiativeFilterType.php | 10 +-- src/Form/InitiativeType.php | 10 +-- src/Model/InitiativeFilter.php | 4 +- src/Repository/InitiativeRepository.php | 16 ++-- src/Service/DashboardData.php | 75 ++++++++++--------- templates/initiative/_form.html.twig | 2 +- templates/initiative/index.html.twig | 2 +- templates/initiative/show.html.twig | 4 +- tests/Repository/InitiativeRepositoryTest.php | 6 +- tests/Unit/Entity/InitiativeTest.php | 9 ++- tests/Unit/Enum/TranslatableEnumTest.php | 2 - tests/Unit/Model/InitiativeFilterTest.php | 6 +- tests/Unit/Service/ActivityPublisherTest.php | 5 +- translations/messages.da.yaml | 11 +-- translations/messages.en.yaml | 11 +-- 21 files changed, 150 insertions(+), 137 deletions(-) create mode 100644 migrations/Version20260627134918.php delete mode 100644 src/Enum/Category.php diff --git a/assets/controllers/dashboard_viz_controller.js b/assets/controllers/dashboard_viz_controller.js index 4313e75..9c86ec1 100644 --- a/assets/controllers/dashboard_viz_controller.js +++ b/assets/controllers/dashboard_viz_controller.js @@ -221,14 +221,14 @@ export default class extends Controller { buildHeatmap() { const d = this.viz; const el = this.heatmapTarget; - el.style.gridTemplateColumns = `minmax(120px, 168px) repeat(${d.categories.length}, minmax(40px, 1fr))`; + el.style.gridTemplateColumns = `minmax(120px, 168px) repeat(${d.areas.length}, minmax(40px, 1fr))`; if (!this.reduce) { el.classList.add("heat--paused"); } el.innerHTML = ""; el.appendChild(document.createElement("div")); - this.heatColHeads = d.categories.map((c) => { + this.heatColHeads = d.areas.map((c) => { const head = document.createElement("div"); head.className = "heat__collabel"; head.innerHTML = `${this.esc(c.label)}`; @@ -241,7 +241,7 @@ export default class extends Controller { label.className = "heat__rowlabel"; label.textContent = dep.label; el.appendChild(label); - return d.categories.map(() => { + return d.areas.map(() => { const cell = document.createElement("div"); cell.className = "heat__cell"; el.appendChild(cell); @@ -265,7 +265,7 @@ export default class extends Controller { row.forEach((v, ci) => { const cell = this.heatCells[di][ci]; cell.textContent = v === 0 ? "·" : v; - cell.title = `${d.departments[di].label} · ${d.categories[ci].label}: ${v}`; + cell.title = `${d.departments[di].label} · ${d.areas[ci].label}: ${v}`; const col = this.colorFor(v, max); if (col) { cell.classList.remove("is-zero"); @@ -278,12 +278,12 @@ export default class extends Controller { } if (initial && !this.reduce) { cell.style.animationDelay = - (di * d.categories.length + ci) * 14 + "ms"; + (di * d.areas.length + ci) * 14 + "ms"; } }), ); - d.categories.forEach((c, ci) => { + d.areas.forEach((c, ci) => { const depts = d.heatmap.reduce( (n, row) => n + (row[ci] > 0 ? 1 : 0), 0, diff --git a/migrations/Version20260627134918.php b/migrations/Version20260627134918.php new file mode 100644 index 0000000..e26eb99 --- /dev/null +++ b/migrations/Version20260627134918.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE area (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, name VARCHAR(255) NOT NULL, created_by_id BINARY(16) DEFAULT NULL, modified_by_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_D7943D685E237E06 (name), INDEX IDX_D7943D68B03A8386 (created_by_id), INDEX IDX_D7943D6899049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE area ADD CONSTRAINT FK_D7943D68B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE area ADD CONSTRAINT FK_D7943D6899049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE initiative ADD area_id BINARY(16) DEFAULT NULL, DROP category'); + $this->addSql('ALTER TABLE initiative ADD CONSTRAINT FK_E115DEFEBD0F409C FOREIGN KEY (area_id) REFERENCES area (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_E115DEFEBD0F409C ON initiative (area_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE area DROP FOREIGN KEY FK_D7943D68B03A8386'); + $this->addSql('ALTER TABLE area DROP FOREIGN KEY FK_D7943D6899049ECE'); + $this->addSql('DROP TABLE area'); + $this->addSql('ALTER TABLE initiative DROP FOREIGN KEY FK_E115DEFEBD0F409C'); + $this->addSql('DROP INDEX IDX_E115DEFEBD0F409C ON initiative'); + $this->addSql('ALTER TABLE initiative ADD category VARCHAR(32) DEFAULT NULL, DROP area_id'); + } +} diff --git a/src/Controller/InitiativeController.php b/src/Controller/InitiativeController.php index f4374b1..7b2f8d0 100644 --- a/src/Controller/InitiativeController.php +++ b/src/Controller/InitiativeController.php @@ -65,7 +65,7 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran $csv = Writer::createFromStream(fopen('php://output', 'w')); $csv->insertOne([ 'id', $translator->trans('initiative.title'), $translator->trans('initiative.status'), - $translator->trans('initiative.category'), $translator->trans('initiative.initiative_type'), + $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'), @@ -81,7 +81,7 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran (string) $row->getId(), $row->getTitle(), $translate($row->getStatus()), - $translate($row->getCategory()), + $row->getArea()?->getName(), $translate($row->getInitiativeType()), $row->getOrganizationalAnchoring()?->getName(), $row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'), diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index a77c7c1..aa04607 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -4,12 +4,12 @@ namespace App\DataFixtures; +use App\Entity\Area; use App\Entity\Contact; use App\Entity\Department; use App\Entity\Initiative; use App\Entity\Term; use App\Entity\User; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -25,6 +25,7 @@ class AppFixtures extends Fixture private const array STAKEHOLDERS = ['Aarhus Kommune', 'Region Midtjylland', 'Aarhus Universitet', 'Erhverv Aarhus', 'Lokale foreninger', 'Boligforeninger', 'VIA University College', 'Business Region Aarhus']; private const array STRATEGIES = ['Klimaplan 2030', 'Erhvervsplan', 'Børn- og ungepolitik', 'Mobilitetsplan', 'Digitaliseringsstrategi', 'Sundhedspolitik']; private const array DEPARTMENTS = ['ITK Development', 'CFIA', 'Aarhus CityLab', 'Stab', 'OS2', 'AI Lab', 'IOT Lab', 'GTM', 'Fut Lab']; + private const array AREAS = ['Klima og miljø', 'Mobilitet', 'Velfærd', 'Kultur og fritid', 'Uddannelse', 'Erhverv', 'Digitalisering', 'Byudvikling']; public function __construct(private readonly UserPasswordHasherInterface $hasher) { @@ -61,6 +62,13 @@ public function load(ObjectManager $manager): void $departments[] = $department; } + $areas = []; + foreach (self::AREAS as $name) { + $area = (new Area())->setName($name); + $manager->persist($area); + $areas[] = $area; + } + $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']; @@ -103,7 +111,6 @@ public function load(ObjectManager $manager): void ]; $statuses = Status::cases(); - $categories = Category::cases(); $types = InitiativeType::cases(); $endorsers = EndorsementAuthor::cases(); $fundings = Funding::cases(); @@ -111,7 +118,7 @@ public function load(ObjectManager $manager): void foreach ($titles as $index => $title) { $initiative = (new Initiative()) ->setTitle($title) - ->setCategory($categories[array_rand($categories)]) + ->setArea($areas[array_rand($areas)]) ->setInitiativeType($types[array_rand($types)]) ->setStatus($statuses[array_rand($statuses)]) ->setOrganizationalAnchoring($departments[array_rand($departments)]) diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index 63bb893..d9c48bd 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -4,7 +4,6 @@ namespace App\Entity; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -27,7 +26,7 @@ class Initiative extends AbstractEntity * @var list */ public const array COMPLETION_FIELDS = [ - 'title', 'category', 'description', 'initiativeType', 'status', + 'title', 'area', 'description', 'initiativeType', 'status', 'organizationalAnchoring', 'endorsementAuthor', 'budget', 'funding', 'timePeriodStart', 'timePeriodEnd', ]; @@ -36,8 +35,9 @@ class Initiative extends AbstractEntity #[ORM\Column(length: 255)] private ?string $title = null; - #[ORM\Column(length: 32, nullable: true, enumType: Category::class)] - private ?Category $category = null; + #[ORM\ManyToOne(targetEntity: Area::class)] + #[ORM\JoinColumn(onDelete: 'SET NULL')] + private ?Area $area = null; #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $description = null; @@ -134,14 +134,14 @@ public function setTitle(?string $title): static return $this; } - public function getCategory(): ?Category + public function getArea(): ?Area { - return $this->category; + return $this->area; } - public function setCategory(?Category $category): static + public function setArea(?Area $area): static { - $this->category = $category; + $this->area = $area; return $this; } @@ -486,7 +486,7 @@ public function getCompletionPercentage(): int { $checks = [ null !== $this->title && '' !== $this->title, - null !== $this->category, + null !== $this->area, null !== $this->description && '' !== $this->description, null !== $this->initiativeType, null !== $this->status, diff --git a/src/Enum/Category.php b/src/Enum/Category.php deleted file mode 100644 index 010e3d8..0000000 --- a/src/Enum/Category.php +++ /dev/null @@ -1,26 +0,0 @@ -value; - } -} diff --git a/src/Form/InitiativeFilterType.php b/src/Form/InitiativeFilterType.php index b8ab7e0..98bfdd4 100644 --- a/src/Form/InitiativeFilterType.php +++ b/src/Form/InitiativeFilterType.php @@ -4,8 +4,8 @@ namespace App\Form; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\InitiativeType as InitiativeTypeEnum; use App\Enum\Status; use App\Model\InitiativeFilter; @@ -39,12 +39,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'placeholder' => 'filter.all', 'choice_label' => static fn (Status $value): string => $value->labelKey(), ]) - ->add('category', EnumType::class, [ - 'label' => 'initiative.category', - 'class' => Category::class, + ->add('area', EntityType::class, [ + 'label' => 'initiative.area', + 'class' => Area::class, + 'choice_label' => 'name', 'required' => false, 'placeholder' => 'filter.all', - 'choice_label' => static fn (Category $value): string => $value->labelKey(), ]) ->add('initiativeType', EnumType::class, [ 'label' => 'initiative.initiative_type', diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index 737d839..d022cd5 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -4,10 +4,10 @@ namespace App\Form; +use App\Entity\Area; use App\Entity\Contact; use App\Entity\Department; use App\Entity\Initiative; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType as InitiativeTypeEnum; @@ -42,12 +42,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('title', TextType::class, [ 'label' => 'initiative.title', ]) - ->add('category', EnumType::class, [ - 'label' => 'initiative.category', - 'class' => Category::class, + ->add('area', EntityType::class, [ + 'label' => 'initiative.area', + 'class' => Area::class, + 'choice_label' => 'name', 'required' => false, 'placeholder' => 'form.choose', - 'choice_label' => static fn (Category $value): string => $value->labelKey(), ]) ->add('description', TextareaType::class, [ 'label' => 'initiative.description', diff --git a/src/Model/InitiativeFilter.php b/src/Model/InitiativeFilter.php index 64e0a1e..d609074 100644 --- a/src/Model/InitiativeFilter.php +++ b/src/Model/InitiativeFilter.php @@ -4,8 +4,8 @@ namespace App\Model; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\InitiativeType; use App\Enum\Status; @@ -19,7 +19,7 @@ class InitiativeFilter public ?Status $status = null; - public ?Category $category = null; + public ?Area $area = null; public ?InitiativeType $initiativeType = null; diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 4488808..9cfa46a 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -5,7 +5,6 @@ namespace App\Repository; use App\Entity\Initiative; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -52,16 +51,16 @@ 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), - // Department is a related entity searched by its stored name - // ("nik" should find "Teknik og Miljø"). + // 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), + sprintf('i.id IN (SELECT iare.id FROM %s iare JOIN iare.area ar WHERE LOWER(ar.name) LIKE :q)', Initiative::class), ]; // Enum columns store slugs, but the user searches their translated // labels ("nik" should find "Teknik og Miljø"); map labels to values. $enumFields = [ 'status' => Status::cases(), - 'category' => Category::cases(), 'initiativeType' => InitiativeType::cases(), 'endorsementAuthor' => EndorsementAuthor::cases(), ]; @@ -87,8 +86,8 @@ public function search(InitiativeFilter $filter): QueryBuilder $qb->andWhere('i.status = :status')->setParameter('status', $filter->status->value); } - if (null !== $filter->category) { - $qb->andWhere('i.category = :category')->setParameter('category', $filter->category->value); + if (null !== $filter->area) { + $qb->andWhere('i.area = :area')->setParameter('area', $filter->area); } if (null !== $filter->initiativeType) { @@ -249,13 +248,13 @@ public function findRecent(int $limit = 5): array */ public function dashboardRows(): array { - // Join and select the department id (rather than IDENTITY()) so Doctrine + // Join and select the related ids (rather than IDENTITY()) so Doctrine // applies the ULID type: IDENTITY() returns the raw binary FK, which would // not match the canonical ULID strings the rest of build() keys on. return $this->createQueryBuilder('i') ->select( 'i.title', - 'i.category', + 'ar.id AS area', 'i.status', 'department.id AS organizationalAnchoring', 'i.budget', @@ -263,6 +262,7 @@ public function dashboardRows(): array 'i.timePeriodStart', 'i.timePeriodEnd', ) + ->leftJoin('i.area', 'ar') ->leftJoin('i.organizationalAnchoring', 'department') ->getQuery() ->getArrayResult(); diff --git a/src/Service/DashboardData.php b/src/Service/DashboardData.php index 15edba2..b9c16ce 100644 --- a/src/Service/DashboardData.php +++ b/src/Service/DashboardData.php @@ -4,10 +4,11 @@ namespace App\Service; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\Funding; use App\Enum\Status; +use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; use Symfony\Contracts\Translation\TranslatorInterface; @@ -23,6 +24,7 @@ final class DashboardData public function __construct( private readonly InitiativeRepository $initiatives, private readonly DepartmentRepository $departments, + private readonly AreaRepository $areas, private readonly TranslatorInterface $translator, ) { } @@ -32,31 +34,34 @@ public function __construct( */ public function build(): array { - // Departments are managed entities now, so normalise them to the same - // {key, label} shape the enums get; the key is the id as a string, which - // is what dashboardRows() returns via IDENTITY(). + // Departments and areas are managed entities, so normalise them to the same + // {key, label} shape the enums get; the key is the id as a string, which is + // what dashboardRows() returns by joining and selecting the id. $departments = array_map( static fn (Department $department): array => ['key' => (string) $department->getId(), 'label' => (string) $department->getName()], $this->departments->findAllOrdered(), ); - $categories = Category::cases(); + $areas = array_map( + static fn (Area $area): array => ['key' => (string) $area->getId(), 'label' => (string) $area->getName()], + $this->areas->findAllOrdered(), + ); $statuses = Status::cases(); $fundings = Funding::cases(); $deptIndex = $this->indexKeys($departments); - $catIndex = $this->index($categories); + $areaIndex = $this->indexKeys($areas); $statusIndex = $this->index($statuses); $fundingIndex = $this->index($fundings); - $heatmap = $this->zeroMatrix(\count($departments), \count($categories)); + $heatmap = $this->zeroMatrix(\count($departments), \count($areas)); $statusByDept = $this->zeroMatrix(\count($statuses), \count($departments)); $statusDistribution = array_fill(0, \count($statuses), 0); $budgetByDept = array_fill(0, \count($departments), 0); $fundingCount = array_fill(0, \count($fundings), 0); - $reachByDept = array_fill(0, \count($categories), []); + $reachByDept = array_fill(0, \count($areas), []); - /** @var array>> $titlesByCategoryDept */ - $titlesByCategoryDept = []; + /** @var array>> $titlesByAreaDept */ + $titlesByAreaDept = []; $timeline = []; $total = 0; $deptsSeen = []; @@ -64,19 +69,19 @@ public function build(): array foreach ($this->initiatives->dashboardRows() as $row) { ++$total; $dept = $this->enumValue($row['organizationalAnchoring'] ?? null); - $cat = $this->enumValue($row['category'] ?? null); + $area = $this->enumValue($row['area'] ?? null); $status = $this->enumValue($row['status'] ?? null); $di = null !== $dept ? ($deptIndex[$dept] ?? null) : null; - $ci = null !== $cat ? ($catIndex[$cat] ?? null) : null; + $ai = null !== $area ? ($areaIndex[$area] ?? null) : null; $si = null !== $status ? ($statusIndex[$status] ?? null) : null; if (null !== $di) { $deptsSeen[$di] = true; } - if (null !== $di && null !== $ci) { - ++$heatmap[$di][$ci]; - $reachByDept[$ci][$di] = true; - $titlesByCategoryDept[$cat][$dept][] = (string) $row['title']; + if (null !== $di && null !== $ai) { + ++$heatmap[$di][$ai]; + $reachByDept[$ai][$di] = true; + $titlesByAreaDept[$area][$dept][] = (string) $row['title']; } if (null !== $si) { ++$statusDistribution[$si]; @@ -108,8 +113,8 @@ public function build(): array $inProgress = $statusDistribution[$statusIndex[Status::Active->value]] ?? 0; $collaborationCount = 0; - foreach ($categories as $category) { - if (\count($titlesByCategoryDept[$category->value] ?? []) >= 2) { + foreach ($areas as $area) { + if (\count($titlesByAreaDept[$area['key']] ?? []) >= 2) { ++$collaborationCount; } } @@ -123,7 +128,7 @@ public function build(): array 'collaboration' => $collaborationCount, ], 'departments' => $departments, - 'categories' => $this->labelled($categories), + 'areas' => $areas, 'statuses' => $this->labelled($statuses), 'fundings' => $this->labelled($fundings), 'heatmap' => $heatmap, @@ -131,23 +136,23 @@ public function build(): array 'statusDistribution' => $statusDistribution, 'budgetByDept' => $budgetByDept, 'fundingCount' => $fundingCount, - 'reach' => $this->reach($categories, $reachByDept), - 'collaboration' => $this->collaboration($categories, $departments, $titlesByCategoryDept), + 'reach' => $this->reach($areas, $reachByDept), + 'collaboration' => $this->collaboration($areas, $departments, $titlesByAreaDept), 'timeline' => \array_slice($timeline, 0, 10), ]; } /** - * Cross-department themes: a category worked on in two or more departments + * Cross-department themes: an area worked on in two or more departments * is a candidate for "sammenfald". Ranked by how broadly it spans. * - * @param list $categories + * @param list $areas * @param list $departments - * @param array>> $titlesByCategoryDept + * @param array>> $titlesByAreaDept * * @return list> */ - private function collaboration(array $categories, array $departments, array $titlesByCategoryDept): array + private function collaboration(array $areas, array $departments, array $titlesByAreaDept): array { $deptLabel = []; foreach ($departments as $d) { @@ -155,8 +160,8 @@ private function collaboration(array $categories, array $departments, array $tit } $opportunities = []; - foreach ($categories as $category) { - $byDept = $titlesByCategoryDept[$category->value] ?? []; + foreach ($areas as $area) { + $byDept = $titlesByAreaDept[$area['key']] ?? []; if (\count($byDept) < 2) { continue; } @@ -173,8 +178,8 @@ private function collaboration(array $categories, array $departments, array $tit $distinctDepts = \count($byDept); $strength = min(100, $distinctDepts * 22 + min($total, 8) * 4); $opportunities[] = [ - 'theme' => $this->t($category->labelKey()), - 'themeKey' => $category->value, + 'theme' => $area['label'], + 'themeKey' => $area['key'], 'departmentCount' => $distinctDepts, 'initiativeCount' => $total, 'strength' => $strength, @@ -190,19 +195,19 @@ private function collaboration(array $categories, array $departments, array $tit } /** - * @param list $categories - * @param list> $reachByDept + * @param list $areas + * @param list> $reachByDept * * @return list> */ - private function reach(array $categories, array $reachByDept): array + private function reach(array $areas, array $reachByDept): array { $reach = []; - foreach ($categories as $ci => $category) { - $reach[] = ['label' => $this->t($category->labelKey()), 'depts' => \count($reachByDept[$ci])]; + foreach ($areas as $ai => $area) { + $reach[] = ['label' => $area['label'], 'depts' => \count($reachByDept[$ai])]; } - // Kept in category order (not sorted by count) so the radar axes stay stable + // Kept in area order (not sorted by count) so the radar axes stay stable // across live updates rather than rotating when a count changes. return $reach; } diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index ee58980..6450293 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -46,7 +46,7 @@ {% if not initiative.id %}{% set title_attr = title_attr|merge({autofocus: true}) %}{% endif %} {{ form_row(form.title, {attr: title_attr}) }}
- {{ form_row(form.category) }} + {{ form_row(form.area) }} {{ form_row(form.initiativeType) }}
{{ form_row(form.description) }} diff --git a/templates/initiative/index.html.twig b/templates/initiative/index.html.twig index 599a002..c336dfb 100644 --- a/templates/initiative/index.html.twig +++ b/templates/initiative/index.html.twig @@ -28,7 +28,7 @@
{{ form_row(form.status) }} - {{ form_row(form.category) }} + {{ form_row(form.area) }} {{ form_row(form.initiativeType) }} {{ form_row(form.organizationalAnchoring) }} {{ form_row(form.endorsement) }} diff --git a/templates/initiative/show.html.twig b/templates/initiative/show.html.twig index 7257e73..b84439e 100644 --- a/templates/initiative/show.html.twig +++ b/templates/initiative/show.html.twig @@ -96,8 +96,8 @@
{{ 'initiative.show.classification'|trans }}
-
{{ 'initiative.category'|trans }}
-
{{ initiative.category ? initiative.category.labelKey|trans : '—' }}
+
{{ 'initiative.area'|trans }}
+
{{ initiative.area ? initiative.area.name : '—' }}
{{ 'initiative.initiative_type'|trans }}
{{ initiative.initiativeType ? initiative.initiativeType.labelKey|trans : '—' }}
diff --git a/tests/Repository/InitiativeRepositoryTest.php b/tests/Repository/InitiativeRepositoryTest.php index 9bc0438..fba6205 100644 --- a/tests/Repository/InitiativeRepositoryTest.php +++ b/tests/Repository/InitiativeRepositoryTest.php @@ -5,10 +5,10 @@ namespace App\Tests\Repository; use App\Entity\Initiative; -use App\Enum\Category; use App\Enum\InitiativeType; use App\Enum\Status; use App\Model\InitiativeFilter; +use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; use Doctrine\ORM\EntityManagerInterface; @@ -30,11 +30,13 @@ public function testSearchAppliesEveryFilterBranch(): void { $departments = static::getContainer()->get(DepartmentRepository::class); \assert($departments instanceof DepartmentRepository); + $areas = static::getContainer()->get(AreaRepository::class); + \assert($areas instanceof AreaRepository); $filter = new InitiativeFilter(); $filter->q = '100%_'; // also exercises LIKE wildcard escaping $filter->status = Status::Active; - $filter->category = Category::Climate; + $filter->area = $areas->findAllOrdered()[0]; $filter->initiativeType = InitiativeType::Project; $filter->organizationalAnchoring = $departments->findAllOrdered()[0]; $filter->endorsement = true; diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index ff7196f..97a5f2c 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -4,13 +4,13 @@ namespace App\Tests\Unit\Entity; +use App\Entity\Area; use App\Entity\Contact; use App\Entity\Department; use App\Entity\Initiative; use App\Entity\InitiativeAttachment; use App\Entity\InitiativeImage; use App\Entity\Term; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -44,10 +44,11 @@ public function testScalarAccessors(): void $start = new \DateTimeImmutable('2025-01-01'); $end = new \DateTimeImmutable('2025-12-31'); $department = (new Department())->setName('Teknik og Miljø'); + $area = (new Area())->setName('Klima og miljø'); $initiative = (new Initiative()) ->setTitle('Grøn omstilling') - ->setCategory(Category::Climate) + ->setArea($area) ->setDescription('Beskrivelse') ->setInitiativeType(InitiativeType::Project) ->setStatus(Status::Active) @@ -60,7 +61,7 @@ public function testScalarAccessors(): void ->setTimePeriodEnd($end); self::assertSame('Grøn omstilling', $initiative->getTitle()); - self::assertSame(Category::Climate, $initiative->getCategory()); + self::assertSame($area, $initiative->getArea()); self::assertSame('Beskrivelse', $initiative->getDescription()); self::assertSame(InitiativeType::Project, $initiative->getInitiativeType()); self::assertSame(Status::Active, $initiative->getStatus()); @@ -80,7 +81,7 @@ public function testCompletionPercentage(): void $full = (new Initiative()) ->setTitle('T') - ->setCategory(Category::Climate) + ->setArea((new Area())->setName('Klima og miljø')) ->setDescription('D') ->setInitiativeType(InitiativeType::Project) ->setStatus(Status::Active) diff --git a/tests/Unit/Enum/TranslatableEnumTest.php b/tests/Unit/Enum/TranslatableEnumTest.php index 2852b92..7b7a6d3 100644 --- a/tests/Unit/Enum/TranslatableEnumTest.php +++ b/tests/Unit/Enum/TranslatableEnumTest.php @@ -4,7 +4,6 @@ namespace App\Tests\Unit\Enum; -use App\Enum\Category; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -17,7 +16,6 @@ final class TranslatableEnumTest extends TestCase { public function testEveryCaseExposesAPrefixedLabelKey(): void { - $this->assertLabelKeys(Category::cases(), 'enum.category.'); $this->assertLabelKeys(EndorsementAuthor::cases(), 'enum.endorsement_author.'); $this->assertLabelKeys(Funding::cases(), 'enum.funding.'); $this->assertLabelKeys(InitiativeType::cases(), 'enum.initiative_type.'); diff --git a/tests/Unit/Model/InitiativeFilterTest.php b/tests/Unit/Model/InitiativeFilterTest.php index 754b5fc..e1fb801 100644 --- a/tests/Unit/Model/InitiativeFilterTest.php +++ b/tests/Unit/Model/InitiativeFilterTest.php @@ -4,8 +4,8 @@ namespace App\Tests\Unit\Model; +use App\Entity\Area; use App\Entity\Department; -use App\Enum\Category; use App\Enum\InitiativeType; use App\Enum\Status; use App\Model\InitiativeFilter; @@ -19,7 +19,7 @@ public function testDefaults(): void self::assertNull($filter->q); self::assertNull($filter->status); - self::assertNull($filter->category); + self::assertNull($filter->area); self::assertNull($filter->initiativeType); self::assertNull($filter->organizationalAnchoring); self::assertNull($filter->endorsement); @@ -32,7 +32,7 @@ public function testIsMutable(): void $filter = new InitiativeFilter(); $filter->q = 'klima'; $filter->status = Status::Active; - $filter->category = Category::Climate; + $filter->area = (new Area())->setName('Klima og miljø'); $filter->initiativeType = InitiativeType::Project; $filter->organizationalAnchoring = (new Department())->setName('Sundhed og Omsorg'); $filter->endorsement = true; diff --git a/tests/Unit/Service/ActivityPublisherTest.php b/tests/Unit/Service/ActivityPublisherTest.php index 1771be1..6148e79 100644 --- a/tests/Unit/Service/ActivityPublisherTest.php +++ b/tests/Unit/Service/ActivityPublisherTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Unit\Service; use App\Entity\Initiative; +use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; use App\Service\ActivityPublisher; @@ -34,9 +35,11 @@ public function testPublishSwallowsHubFailuresAndLogsThem(): void $initiatives->method('dashboardRows')->willReturn([]); $departments = $this->createStub(DepartmentRepository::class); $departments->method('findAllOrdered')->willReturn([]); + $areas = $this->createStub(AreaRepository::class); + $areas->method('findAllOrdered')->willReturn([]); $translator = $this->createStub(TranslatorInterface::class); $translator->method('trans')->willReturnArgument(0); - $dashboardData = new DashboardData($initiatives, $departments, $translator); + $dashboardData = new DashboardData($initiatives, $departments, $areas, $translator); $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::once())->method('warning'); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 0aa1c1a..88ed3e9 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -143,7 +143,7 @@ dashboard: initiative: title: Titel - category: Kategori + area: Område description: Kort beskrivelse strategies: Strategier og planer initiative_type: Initiativets karakter @@ -299,15 +299,6 @@ enum: on_hold: Sat i bero completed: Afsluttet cancelled: Annulleret - category: - climate: Klima og miljø - mobility: Mobilitet - welfare: Velfærd - culture: Kultur og fritid - education: Uddannelse - business: Erhverv - digitalisation: Digitalisering - urban_development: Byudvikling initiative_type: project: Projekt programme: Program diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 3419a45..0a40227 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -143,7 +143,7 @@ dashboard: initiative: title: Title - category: Category + area: Area description: Short description strategies: Strategies and plans initiative_type: Type of initiative @@ -299,15 +299,6 @@ enum: on_hold: On hold completed: Completed cancelled: Cancelled - category: - climate: Climate and environment - mobility: Mobility - welfare: Welfare - culture: Culture and leisure - education: Education - business: Business - digitalisation: Digitalisation - urban_development: Urban development initiative_type: project: Project programme: Programme From 0a33015f059e79274d94a5fdbb83fe3f01835c48 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 16:07:01 +0200 Subject: [PATCH 03/37] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8af74a..03da31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-15](https://github.com/itk-dev/itk-project-database/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) From 46cd337f9ea4137e6e1855884295b6416dc9fc21 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 18:43:35 +0200 Subject: [PATCH 04/37] Make strategies and tags a searchable, shared stategy and tags pool for serving inspiration --- assets/app.js | 52 +++++++++++++++++++++++++ src/Form/TermsTextType.php | 23 ++++++++++- src/Repository/TermRepository.php | 4 ++ tests/Repository/TermRepositoryTest.php | 7 ++++ translations/messages.da.yaml | 2 +- translations/messages.en.yaml | 2 +- 6 files changed, 87 insertions(+), 3 deletions(-) diff --git a/assets/app.js b/assets/app.js index b1550f8..0db4ccd 100644 --- a/assets/app.js +++ b/assets/app.js @@ -61,6 +61,57 @@ function initContactSelect() { }); } +// Free-tagging fields (strategies, stakeholders, tags): a chip multiselect whose +// dropdown is the vocabulary's shared pool, served as inspiration. create:true +// lets a user add a brand-new term that is persisted on save and then shows up in +// everyone's pool on the next page load. +function initTermSelect() { + document.querySelectorAll("[data-term-select]").forEach((input) => { + if (input.dataset.bound) { + return; + } + input.dataset.bound = "1"; + + let pool = []; + try { + pool = JSON.parse(input.dataset.termPool || "[]"); + } catch { + pool = []; + } + + // The current values plus the pool become the selectable options; the + // current ones are kept selected. + const current = input.value + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const options = [...new Set([...current, ...pool])].map((name) => ({ + value: name, + text: name, + })); + + const capitalize = (value) => + value ? value.charAt(0).toUpperCase() + value.slice(1) : value; + + new TomSelect(input, { + plugins: ["remove_button"], + options, + items: current, + // Capitalise a brand-new term so it matches the rest of the pool; a + // capitalised value also collapses onto an existing option of the + // same name instead of creating a near-duplicate. + create: (typed) => { + const name = capitalize(typed.trim()); + return { value: name, text: name }; + }, + createOnBlur: true, + persist: false, + hideSelected: true, + maxOptions: null, + }); + }); +} + // 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 @@ -86,4 +137,5 @@ document.addEventListener("click", (event) => { document.addEventListener("turbo:load", () => { initCollections(); initContactSelect(); + initTermSelect(); }); diff --git a/src/Form/TermsTextType.php b/src/Form/TermsTextType.php index 5f70b77..d067f00 100644 --- a/src/Form/TermsTextType.php +++ b/src/Form/TermsTextType.php @@ -4,17 +4,20 @@ namespace App\Form; +use App\Entity\Term; use App\Enum\Vocabulary; use App\Form\DataTransformer\TermsTextTransformer; use App\Repository\TermRepository; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; /** * A text input mapping a comma-separated list of names to a collection of - * free-tagging {@see \App\Entity\Term}s. Pass the target `vocabulary`. + * free-tagging {@see Term}s. Pass the target `vocabulary`. * * @extends AbstractType */ @@ -29,6 +32,24 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder->addModelTransformer(new TermsTextTransformer($this->termRepository, $options['vocabulary'])); } + /** + * Expose the vocabulary's existing terms 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 (Term $term): string => (string) $term->getName(), + $this->termRepository->findByVocabulary($options['vocabulary']), + ); + + $view->vars['attr'] = array_merge($view->vars['attr'], [ + 'data-term-select' => '', + 'data-term-pool' => json_encode($pool, \JSON_THROW_ON_ERROR), + ]); + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setRequired('vocabulary'); diff --git a/src/Repository/TermRepository.php b/src/Repository/TermRepository.php index f20c9d8..d10a05e 100644 --- a/src/Repository/TermRepository.php +++ b/src/Repository/TermRepository.php @@ -53,6 +53,10 @@ public function findOrCreate(string $name, Vocabulary $vocabulary): Term return $existing; } + // Store new terms capitalised so user-typed lowercase tags join the pool + // looking like the rest; the lookup above stays case-insensitive. + $name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1); + $term = (new Term($vocabulary))->setName($name); $this->getEntityManager()->persist($term); diff --git a/tests/Repository/TermRepositoryTest.php b/tests/Repository/TermRepositoryTest.php index 250ca39..5475281 100644 --- a/tests/Repository/TermRepositoryTest.php +++ b/tests/Repository/TermRepositoryTest.php @@ -48,4 +48,11 @@ public function testFindOrCreateBuildsANewUnflushedTerm(): void self::assertSame(Vocabulary::Strategy, $term->getVocabulary()); self::assertCount(0, $this->repository->findBy(['name' => $name]), 'A freshly created term is not yet flushed to the database.'); } + + public function testFindOrCreateCapitalisesANewTerm(): void + { + $term = $this->repository->findOrCreate('grøn omstilling '.uniqid(), Vocabulary::Strategy); + + self::assertSame('G', mb_substr((string) $term->getName(), 0, 1)); + } } diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 88ed3e9..b7834f2 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -163,7 +163,7 @@ initiative: contacts: Kontaktpersoner new_contacts: Opret nye kontaktpersoner author: Udfyldt af - terms_help: Adskil flere værdier med komma. + terms_help: Vælg eksisterende, eller skriv en ny og tryk Enter. completion_field_hint: Dette felt tæller med i udfyldningsgraden. images: Billeder attachments: Filer diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 0a40227..ac7de30 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -163,7 +163,7 @@ initiative: contacts: Contacts new_contacts: Add new contacts author: Filled out by - terms_help: Separate multiple values with commas. + terms_help: Choose an existing one, or type a new and press Enter. completion_field_hint: This field counts towards the completion rate. images: Images attachments: Files From 75338f53d973ebc609b4e854466e0eb5d0583cd0 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 20:37:25 +0200 Subject: [PATCH 05/37] Updated changelog --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03da31b..2f5a686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-16](https://github.com/itk-dev/itk-project-database/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) Make Kategori a user-defined Area entity with an admin CRUD, replacing the - fixed Category enum + fixed Category enum. ## [0.1.0] - 2026-06-26 @@ -35,4 +38,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial Symfony 8 rebuild of the project database. [Unreleased]: https://github.com/itk-dev/itk-project-database/compare/0.1.0...HEAD -[0.1.0]: https://github.com/itk-dev/itk-project-database/releases/tag/0.1.0 +[0.1.0]: https://github.com/itk-dev/itk-project-database/releases/tag/0.1.09i876rbhn gvfcdevgbhjgnybtfr v8decsxz From c9f056c138c4a44de84e77a6bf2ca6ae5929632d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 20:37:41 +0200 Subject: [PATCH 06/37] Fix tests --- tests/Service/DashboardDataTest.php | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/Service/DashboardDataTest.php b/tests/Service/DashboardDataTest.php index 157a55e..f895f0f 100644 --- a/tests/Service/DashboardDataTest.php +++ b/tests/Service/DashboardDataTest.php @@ -4,8 +4,16 @@ namespace App\Tests\Service; +use App\Entity\Area; +use App\Entity\Department; +use App\Enum\Funding; +use App\Enum\Status; +use App\Repository\AreaRepository; +use App\Repository\DepartmentRepository; +use App\Repository\InitiativeRepository; use App\Service\DashboardData; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Contracts\Translation\TranslatorInterface; final class DashboardDataTest extends KernelTestCase { @@ -24,4 +32,47 @@ public function testBuildAggregatesDepartmentData(): void self::assertGreaterThan(0, $data['kpis']['departments'], 'deptsSeen should be > 0'); self::assertGreaterThan(0, array_sum(array_map('array_sum', $data['heatmap'])), 'heatmap should have entries'); } + + /** + * Drives build() from controlled rows so every aggregation branch is + * exercised deterministically — independent of the random dev fixtures. + */ + public function testBuildFromControlledRows(): void + { + $deptA = (new Department())->setName('A'); + $deptB = (new Department())->setName('B'); + $shared = (new Area())->setName('Shared'); + $solo = (new Area())->setName('Solo'); + + $departments = $this->createStub(DepartmentRepository::class); + $departments->method('findAllOrdered')->willReturn([$deptA, $deptB]); + $areas = $this->createStub(AreaRepository::class); + $areas->method('findAllOrdered')->willReturn([$shared, $solo]); + + $start = new \DateTimeImmutable('2025-01-01'); + $end = new \DateTimeImmutable('2025-06-01'); + $initiatives = $this->createStub(InitiativeRepository::class); + $initiatives->method('dashboardRows')->willReturn([ + // "Shared" worked on in two departments -> a collaboration opportunity; + // also carries budget, funding (a known and an unknown slug) and a span. + ['title' => 'Shared A', 'area' => (string) $shared->getId(), 'status' => Status::Active, 'organizationalAnchoring' => (string) $deptA->getId(), 'budget' => 1000, 'funding' => [Funding::MunicipalBudget->value, 'unknown'], 'timePeriodStart' => $start, 'timePeriodEnd' => $end], + ['title' => 'Shared B', 'area' => (string) $shared->getId(), 'status' => Status::Active, 'organizationalAnchoring' => (string) $deptB->getId(), 'budget' => 2000, 'funding' => [], 'timePeriodStart' => null, 'timePeriodEnd' => null], + // "Solo" only in one department -> skipped by collaboration(). + ['title' => 'Solo', 'area' => (string) $solo->getId(), 'status' => Status::Active, 'organizationalAnchoring' => (string) $deptA->getId(), 'budget' => null, 'funding' => [], 'timePeriodStart' => null, 'timePeriodEnd' => null], + // No area/department/status -> exercises the null guards. + ['title' => 'Loose', 'area' => null, 'status' => null, 'organizationalAnchoring' => null, 'budget' => null, 'funding' => [], 'timePeriodStart' => null, 'timePeriodEnd' => null], + ]); + + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + $data = (new DashboardData($initiatives, $departments, $areas, $translator))->build(); + + self::assertSame(4, $data['kpis']['total']); + // Only "Shared" spans >= 2 departments; "Solo" is skipped. + self::assertSame(1, $data['kpis']['collaboration']); + self::assertCount(1, $data['collaboration']); + self::assertSame('Shared', $data['collaboration'][0]['theme']); + self::assertCount(1, $data['timeline']); + } } From 191436806e7ad928c484fd5f32a09058f581ef1d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 20:44:23 +0200 Subject: [PATCH 07/37] Updated changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5a686..dfae926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shared tag pool where new entries are capitalised and reused as suggestions. * [PR-15](https://github.com/itk-dev/itk-project-database/pull/15) Make Kategori a user-defined Area entity with an admin CRUD, replacing the - fixed Category enum. + fixed Category enum. ## [0.1.0] - 2026-06-26 @@ -38,4 +38,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial Symfony 8 rebuild of the project database. [Unreleased]: https://github.com/itk-dev/itk-project-database/compare/0.1.0...HEAD -[0.1.0]: https://github.com/itk-dev/itk-project-database/releases/tag/0.1.09i876rbhn gvfcdevgbhjgnybtfr v8decsxz +[0.1.0]: gvfcdevgbhjgnybtfr v8decsxz From 88f4c9d072b342c1bc7c3e89edee2abeaad5665b Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 21:19:03 +0200 Subject: [PATCH 08/37] Improve styling of multiselects and remove blue highlight effect from inputs --- assets/styles/app.css | 84 ++++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index 6ff8199..a8f6b10 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1123,8 +1123,15 @@ input:focus, select:focus, textarea:focus { outline: none; - border-color: var(--itk-blue); - box-shadow: var(--itk-focus); + border-color: var(--itk-slate-500); + box-shadow: none; +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + box-shadow: none; + border-radius: var(--itk-radius-2); } textarea { @@ -1895,7 +1902,7 @@ textarea { gap: var(--itk-space-1); width: 100%; min-height: 38px; - padding: 3px var(--itk-space-3); + padding: var(--itk-space-1) var(--itk-space-3); font-size: var(--itk-text-sm); color: var(--itk-ink); background-color: var(--itk-paper); @@ -1907,15 +1914,19 @@ textarea { box-shadow 0.15s; } +.ts-wrapper.multi.has-items .ts-control { + padding-left: var(--itk-space-3); +} + .ts-wrapper.focus .ts-control { - border-color: var(--itk-blue); - box-shadow: var(--itk-focus); + border-color: var(--itk-slate-500); + box-shadow: none; } .ts-wrapper .ts-control > input { flex: 1 1 auto; width: auto; - min-width: 6rem; + min-width: 8rem; margin: 0; padding: var(--itk-space-1) 0; border: none; @@ -1926,40 +1937,61 @@ textarea { color: var(--itk-ink); } +.ts-wrapper .ts-control > input::placeholder { + color: var(--itk-slate-500); +} + .ts-wrapper .ts-control > input:focus { outline: none; border: none; box-shadow: none; } -.ts-wrapper .ts-control .item { +.ts-wrapper.multi .ts-control .item { display: inline-flex; align-items: center; - gap: var(--itk-space-1); padding: 2px var(--itk-space-2); - font-size: var(--itk-text-xs); - font-weight: 500; - color: var(--itk-blue); - background-color: color-mix(in srgb, var(--itk-blue) 12%, transparent); - border-radius: var(--itk-radius-pill); + font-size: var(--itk-text-sm); + font-weight: 400; + line-height: 1.25; + color: var(--itk-ink); + background: var(--itk-slate-50); + background-image: none; + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); + box-shadow: none; + text-shadow: none; } -.ts-wrapper .ts-control .item .remove { - padding: 0 2px; +.ts-wrapper.multi .ts-control .item.active { + color: var(--itk-ink); + background: var(--itk-slate-200); + background-image: none; + border-color: var(--itk-slate-300); + box-shadow: none; +} + +.ts-wrapper.plugin-remove_button .ts-control .item .remove { + margin: 0; + padding: 0 var(--itk-space-2) 0 var(--itk-space-1); border: none; - color: inherit; - opacity: 0.6; + border-radius: 0; + background: transparent; + color: var(--itk-slate-500); + font-size: 1.05em; + line-height: 1; } -.ts-wrapper .ts-control .item .remove:hover { +.ts-wrapper.plugin-remove_button .ts-control .item .remove:hover { background: transparent; - opacity: 1; + color: var(--itk-ink); } .ts-dropdown { - margin-top: var(--itk-space-1); + margin-top: var(--itk-space-2); + padding: var(--itk-space-1); border: 1px solid var(--itk-slate-200); - border-radius: var(--itk-radius-2); + border-radius: var(--itk-radius-3); background-color: var(--itk-paper); box-shadow: var(--itk-shadow-2); font-size: var(--itk-text-sm); @@ -1967,12 +1999,16 @@ textarea { overflow: hidden; } -.ts-dropdown .option { +.ts-dropdown .option, +.ts-dropdown .create { padding: var(--itk-space-2) var(--itk-space-3); + border-radius: var(--itk-radius-2); + cursor: pointer; } -.ts-dropdown .active { - background-color: color-mix(in srgb, var(--itk-blue) 10%, transparent); +.ts-dropdown .active, +.ts-dropdown .active.create { + background-color: var(--itk-slate-100); color: var(--itk-ink); } From a5019e3a3eb0124a8b3798938d37daa4c8a54cc9 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 22:58:48 +0200 Subject: [PATCH 09/37] Add help text for input, textarea, selects --- assets/app.js | 52 +++++++------- assets/styles/app.css | 25 ++++++- src/Form/AreaType.php | 1 + src/Form/ContactType.php | 4 ++ src/Form/ContactsTextType.php | 63 +++++++++++++++++ .../ContactsTextTransformer.php | 68 +++++++++++++++++++ src/Form/DepartmentType.php | 1 + src/Form/InitiativeType.php | 44 ++++-------- src/Form/UserType.php | 2 + src/Repository/ContactRepository.php | 27 ++++++++ templates/form/fields.html.twig | 10 +-- templates/initiative/_form.html.twig | 18 ----- tests/Controller/InitiativeControllerTest.php | 3 +- tests/Repository/ContactRepositoryTest.php | 35 ++++++++++ .../ContactsTextTransformerTest.php | 56 +++++++++++++++ translations/messages.da.yaml | 21 +++++- translations/messages.en.yaml | 21 +++++- 17 files changed, 365 insertions(+), 86 deletions(-) create mode 100644 src/Form/ContactsTextType.php create mode 100644 src/Form/DataTransformer/ContactsTextTransformer.php create mode 100644 tests/Unit/Form/DataTransformer/ContactsTextTransformerTest.php diff --git a/assets/app.js b/assets/app.js index 0db4ccd..5295070 100644 --- a/assets/app.js +++ b/assets/app.js @@ -47,26 +47,16 @@ function initCollections() { }); } -function initContactSelect() { - document.querySelectorAll("[data-contact-select]").forEach((select) => { - if (select.dataset.bound) { - return; - } - select.dataset.bound = "1"; - new TomSelect(select, { - plugins: ["remove_button"], - hideSelected: true, - maxOptions: null, - }); - }); -} +const capitalize = (value) => + value ? value.charAt(0).toUpperCase() + value.slice(1) : value; -// Free-tagging fields (strategies, stakeholders, tags): a chip multiselect whose -// dropdown is the vocabulary's shared pool, served as inspiration. create:true -// lets a user add a brand-new term that is persisted on save and then shows up in -// everyone's pool on the next page load. -function initTermSelect() { - document.querySelectorAll("[data-term-select]").forEach((input) => { +// A chip multiselect whose dropdown is a shared pool served as inspiration. +// create lets a user add a brand-new value that is persisted on save and then +// shows up in everyone's pool on the next page load; a capitalised value also +// collapses onto an existing option of the same name instead of duplicating. +// `transform` normalises a freshly typed value before it becomes a chip. +function initCreatableSelect(selector, poolKey, transform) { + document.querySelectorAll(selector).forEach((input) => { if (input.dataset.bound) { return; } @@ -74,7 +64,7 @@ function initTermSelect() { let pool = []; try { - pool = JSON.parse(input.dataset.termPool || "[]"); + pool = JSON.parse(input.dataset[poolKey] || "[]"); } catch { pool = []; } @@ -90,18 +80,12 @@ function initTermSelect() { text: name, })); - const capitalize = (value) => - value ? value.charAt(0).toUpperCase() + value.slice(1) : value; - new TomSelect(input, { plugins: ["remove_button"], options, items: current, - // Capitalise a brand-new term so it matches the rest of the pool; a - // capitalised value also collapses onto an existing option of the - // same name instead of creating a near-duplicate. create: (typed) => { - const name = capitalize(typed.trim()); + const name = transform(typed.trim()); return { value: name, text: name }; }, createOnBlur: true, @@ -112,6 +96,20 @@ function initTermSelect() { }); } +// Free-tagging term fields (strategies, stakeholders, tags) capitalise new +// entries; contacts keep the typed name as-is. +function initTermSelect() { + initCreatableSelect("[data-term-select]", "termPool", capitalize); +} + +function initContactSelect() { + initCreatableSelect( + "[data-contact-select]", + "contactPool", + (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 diff --git a/assets/styles/app.css b/assets/styles/app.css index a8f6b10..0dc8c11 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1055,6 +1055,7 @@ h3 { } .form-row { + position: relative; display: flex; flex-direction: column; gap: var(--itk-space-2); @@ -1065,7 +1066,15 @@ h3 { margin-bottom: 0; } -.form-row > label { +.form-row__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--itk-space-2); +} + +.form-row > label, +.form-row__head > label { font-size: var(--itk-text-sm); font-weight: 600; color: var(--itk-slate-700); @@ -1093,7 +1102,21 @@ h3 { .form-row .help-text, .form-row .form-help { font-size: var(--itk-text-xs); + font-weight: 400; color: var(--itk-slate-500); + opacity: 0; + transition: opacity 0.15s ease; +} + +.form-row .help-text::before, +.form-row .form-help::before { + content: "–"; + margin-right: var(--itk-space-1); +} + +.form-row:focus-within .help-text, +.form-row:focus-within .form-help { + opacity: 1; } input[type="text"], diff --git a/src/Form/AreaType.php b/src/Form/AreaType.php index 0cf4338..43f4515 100644 --- a/src/Form/AreaType.php +++ b/src/Form/AreaType.php @@ -19,6 +19,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add('name', TextType::class, [ 'label' => 'area.name', + 'help' => 'area.name_help', ]); } diff --git a/src/Form/ContactType.php b/src/Form/ContactType.php index 668f6d6..89b029e 100644 --- a/src/Form/ContactType.php +++ b/src/Form/ContactType.php @@ -22,18 +22,22 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('name', TextType::class, [ 'label' => 'contact.name', + 'help' => 'contact.name_help', ]) ->add('email', EmailType::class, [ 'label' => 'contact.email', 'required' => false, + 'help' => 'contact.email_help', ]) ->add('phone', TelType::class, [ 'label' => 'contact.phone', 'required' => false, + 'help' => 'contact.phone_help', ]) ->add('department', TextType::class, [ 'label' => 'contact.department', 'required' => false, + 'help' => 'contact.department_help', ]); } diff --git a/src/Form/ContactsTextType.php b/src/Form/ContactsTextType.php new file mode 100644 index 0000000..6b9bbb2 --- /dev/null +++ b/src/Form/ContactsTextType.php @@ -0,0 +1,63 @@ + + */ +final class ContactsTextType extends AbstractType +{ + public function __construct(private readonly ContactRepository $contactRepository) + { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new ContactsTextTransformer($this->contactRepository)); + } + + /** + * Expose the existing contacts 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 (Contact $contact): string => (string) $contact->getName(), + $this->contactRepository->findAllOrdered(), + ); + + $view->vars['attr'] = array_merge($view->vars['attr'], [ + 'data-contact-select' => '', + 'data-contact-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/Form/DataTransformer/ContactsTextTransformer.php b/src/Form/DataTransformer/ContactsTextTransformer.php new file mode 100644 index 0000000..efc4d0b --- /dev/null +++ b/src/Form/DataTransformer/ContactsTextTransformer.php @@ -0,0 +1,68 @@ + + */ +final readonly class ContactsTextTransformer implements DataTransformerInterface +{ + public function __construct(private ContactRepository $contactRepository) + { + } + + public function transform(mixed $value): string + { + if (!is_iterable($value)) { + return ''; + } + + $names = []; + foreach ($value as $contact) { + if ($contact instanceof Contact) { + $names[] = $contact->getName(); + } + } + + return implode(', ', $names); + } + + /** + * @return Collection + */ + public function reverseTransform(mixed $value): Collection + { + $contacts = new ArrayCollection(); + + if (!\is_string($value) || '' === trim($value)) { + return $contacts; + } + + $seen = []; + foreach (explode(',', $value) as $name) { + $name = trim($name); + $key = mb_strtolower($name); + if ('' === $name || isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $contacts->add($this->contactRepository->findOrCreate($name)); + } + + return $contacts; + } +} diff --git a/src/Form/DepartmentType.php b/src/Form/DepartmentType.php index 676b0e7..604e7c4 100644 --- a/src/Form/DepartmentType.php +++ b/src/Form/DepartmentType.php @@ -20,6 +20,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('name', TextType::class, [ 'label' => 'department.name', + 'help' => 'department.name_help', ]); } diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index d022cd5..29a8d35 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -5,7 +5,6 @@ namespace App\Form; use App\Entity\Area; -use App\Entity\Contact; use App\Entity\Department; use App\Entity\Initiative; use App\Enum\EndorsementAuthor; @@ -24,8 +23,6 @@ use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -41,6 +38,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('title', TextType::class, [ 'label' => 'initiative.title', + 'help' => 'initiative.title_help', ]) ->add('area', EntityType::class, [ 'label' => 'initiative.area', @@ -48,11 +46,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_label' => 'name', 'required' => false, 'placeholder' => 'form.choose', + 'help' => 'initiative.area_help', ]) ->add('description', TextareaType::class, [ 'label' => 'initiative.description', 'required' => false, 'attr' => ['rows' => 4], + 'help' => 'initiative.description_help', ]) ->add('strategies', TermsTextType::class, [ 'label' => 'initiative.strategies', @@ -66,6 +66,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'form.choose', 'choice_label' => static fn (InitiativeTypeEnum $value): string => $value->labelKey(), + 'help' => 'initiative.initiative_type_help', ]) ->add('status', EnumType::class, [ 'label' => 'initiative.status', @@ -73,11 +74,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'form.choose', 'choice_label' => static fn (Status $value): string => $value->labelKey(), + 'help' => 'initiative.status_help', ]) ->add('statusAdditional', TextareaType::class, [ 'label' => 'initiative.status_additional', 'required' => false, 'attr' => ['rows' => 3], + 'help' => 'initiative.status_additional_help', ]) ->add('organizationalAnchoring', EntityType::class, [ 'label' => 'initiative.organizational_anchoring', @@ -85,6 +88,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_label' => 'name', 'required' => false, 'placeholder' => 'form.choose', + 'help' => 'initiative.organizational_anchoring_help', ]) ->add('endorsement', CheckboxType::class, [ 'label' => 'initiative.endorsement', @@ -96,11 +100,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'form.choose', 'choice_label' => static fn (EndorsementAuthor $value): string => $value->labelKey(), + 'help' => 'initiative.endorsement_author_help', ]) ->add('budget', IntegerType::class, [ 'label' => 'initiative.budget', 'required' => false, 'attr' => ['min' => 0], + 'help' => 'initiative.budget_help', ]) ->add('funding', EnumType::class, [ 'label' => 'initiative.funding', @@ -127,12 +133,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'widget' => 'single_text', 'input' => 'datetime_immutable', 'required' => false, + 'help' => 'initiative.time_period_start_help', ]) ->add('timePeriodEnd', DateType::class, [ 'label' => 'initiative.time_period_end', 'widget' => 'single_text', 'input' => 'datetime_immutable', 'required' => false, + 'help' => 'initiative.time_period_end_help', ]) ->add('links', CollectionType::class, [ 'label' => 'initiative.links', @@ -151,25 +159,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'prototype' => true, ]) - ->add('contacts', EntityType::class, [ + ->add('contacts', ContactsTextType::class, [ 'label' => 'initiative.contacts', - 'class' => Contact::class, - 'choice_label' => 'name', - 'multiple' => true, - 'required' => false, - 'by_reference' => false, - 'attr' => ['data-contact-select' => true], - ]) - ->add('newContacts', CollectionType::class, [ - 'label' => 'initiative.new_contacts', - 'entry_type' => ContactType::class, - 'allow_add' => true, - 'allow_delete' => true, - 'delete_empty' => static fn (?Contact $contact): bool => null === $contact || null === $contact->getName() || '' === trim((string) $contact->getName()), - 'by_reference' => false, 'required' => false, - 'prototype' => true, - 'mapped' => false, + 'help' => 'initiative.terms_help', ]) ->add('images', CollectionType::class, [ 'label' => 'initiative.images', @@ -189,17 +182,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'prototype' => true, ]); - - // Existing contacts bind directly through the select; brand-new ones are - // built in the unmapped "newContacts" collection and merged in here. - $builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event): void { - $initiative = $event->getData(); - if ($initiative instanceof Initiative) { - foreach ($event->getForm()->get('newContacts')->getData() as $contact) { - $initiative->addContact($contact); - } - } - }); } /** diff --git a/src/Form/UserType.php b/src/Form/UserType.php index 258cb90..2f654c8 100644 --- a/src/Form/UserType.php +++ b/src/Form/UserType.php @@ -34,10 +34,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('email', EmailType::class, [ 'label' => 'user.email', + 'help' => 'user.email_help', ]) ->add('name', TextType::class, [ 'label' => 'user.name', 'required' => false, + 'help' => 'user.name_help', ]) // Mapped onto User::$roles, so this field can grant ROLE_ADMIN. It // relies on UserController being gated by #[IsGranted('ROLE_ADMIN')]; diff --git a/src/Repository/ContactRepository.php b/src/Repository/ContactRepository.php index 96e5bd1..9d3e8d5 100644 --- a/src/Repository/ContactRepository.php +++ b/src/Repository/ContactRepository.php @@ -28,4 +28,31 @@ public function findAllOrdered(): array ->getQuery() ->getResult(); } + + /** + * Return an existing contact matched on name (case-insensitive) or a new, + * unflushed one. Lets people be picked from the shared pool or typed in on + * the fly; the extra fields (email, phone, department) are filled in later + * under the contacts admin. + */ + public function findOrCreate(string $name): Contact + { + $name = trim($name); + + $existing = $this->createQueryBuilder('c') + ->andWhere('LOWER(c.name) = :name') + ->setParameter('name', mb_strtolower($name)) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if ($existing instanceof Contact) { + return $existing; + } + + $contact = (new Contact())->setName($name); + $this->getEntityManager()->persist($contact); + + return $contact; + } } diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig index 83c0b77..218e2f5 100644 --- a/templates/form/fields.html.twig +++ b/templates/form/fields.html.twig @@ -10,12 +10,14 @@ {% block form_row %} {% set row_class = 'form-row' %}
- {{ form_label(form) }} +
+ {{ form_label(form) }} + {%- if help is not empty -%} +
{{ help|trans(help_translation_parameters, translation_domain) }}
+ {%- endif -%} +
{{ form_widget(form) }} {{ form_errors(form) }} - {%- if help is not empty -%} -
{{ help|trans(help_translation_parameters, translation_domain) }}
- {%- endif -%}
{% endblock %} diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 6450293..5b5acbf 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -99,23 +99,6 @@ {{ form_row(form.stakeholders) }} {{ form_row(form.contacts) }} - -
- -
-
- {% for contact in form.newContacts %} -
-
- {{ form_widget(contact) }} -
-
- {% endfor %} -
- -
-
@@ -171,7 +154,6 @@ {% endif %}
{% do form.links.setRendered() %} - {% do form.contacts.setRendered() %} {% do form.images.setRendered() %} {% do form.attachments.setRendered() %} {{ form_end(form) }} diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index 8d26a98..3c59420 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -21,7 +21,8 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $this->client->request('POST', '/initiatives/new', [ 'initiative' => [ 'title' => 'Coverage initiative', - 'newContacts' => [['name' => 'Coverage Contact']], + // A typed name creates a new contact on the fly and attaches it. + 'contacts' => 'Coverage Contact', // An empty image row exercises the image branch of removeEmptyMedia(). 'images' => [['alt' => 'empty image row']], '_token' => $token, diff --git a/tests/Repository/ContactRepositoryTest.php b/tests/Repository/ContactRepositoryTest.php index 6b34b1a..a3a5a58 100644 --- a/tests/Repository/ContactRepositoryTest.php +++ b/tests/Repository/ContactRepositoryTest.php @@ -4,7 +4,9 @@ namespace App\Tests\Repository; +use App\Entity\Contact; use App\Repository\ContactRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; final class ContactRepositoryTest extends KernelTestCase @@ -21,4 +23,37 @@ public function testFindAllOrderedReturnsContactsSortedByName(): void // method returns the persisted contacts. self::assertNotEmpty($contacts); } + + public function testFindOrCreateReturnsAnExistingContactCaseInsensitively(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(ContactRepository::class); + \assert($repository instanceof ContactRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $name = 'Findme Contact '.uniqid(); + $contact = (new Contact())->setName($name); + $em->persist($contact); + $em->flush(); + + $found = $repository->findOrCreate(mb_strtolower($name)); + self::assertSame($contact->getId(), $found->getId()); + + $em->remove($contact); + $em->flush(); + } + + public function testFindOrCreateBuildsANewUnflushedContact(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(ContactRepository::class); + \assert($repository instanceof ContactRepository); + + $name = 'BrandNewContact-'.uniqid(); + $contact = $repository->findOrCreate($name); + + self::assertSame($name, $contact->getName()); + self::assertCount(0, $repository->findBy(['name' => $name]), 'A freshly created contact is not yet flushed to the database.'); + } } diff --git a/tests/Unit/Form/DataTransformer/ContactsTextTransformerTest.php b/tests/Unit/Form/DataTransformer/ContactsTextTransformerTest.php new file mode 100644 index 0000000..cf4a833 --- /dev/null +++ b/tests/Unit/Form/DataTransformer/ContactsTextTransformerTest.php @@ -0,0 +1,56 @@ +transformer()->transform(null)); + } + + public function testTransformJoinsContactNames(): void + { + $contacts = [ + (new Contact())->setName('Anne Jensen'), + (new Contact())->setName('Lars Holm'), + ]; + + self::assertSame('Anne Jensen, Lars Holm', $this->transformer()->transform($contacts)); + } + + public function testReverseTransformOfNonStringReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(null)); + } + + public function testReverseTransformOfBlankReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(' ')); + } + + public function testReverseTransformTrimsDeduplicatesAndResolvesContacts(): void + { + $repository = $this->createMock(ContactRepository::class); + $repository->expects(self::exactly(2)) + ->method('findOrCreate') + ->willReturnCallback(static fn (string $name): Contact => (new Contact())->setName($name)); + + $transformer = new ContactsTextTransformer($repository); + + // "anne jensen" duplicates "Anne Jensen" (case-insensitive) and the empty segment is skipped. + self::assertCount(2, $transformer->reverseTransform('Anne Jensen, Lars Holm, , anne jensen')); + } + + private function transformer(): ContactsTextTransformer + { + return new ContactsTextTransformer($this->createStub(ContactRepository::class)); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index b7834f2..50fe7c1 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -87,7 +87,6 @@ action: reset: Nulstil export: Eksportér CSV add_link: Tilføj link - add_contact: Tilføj kontaktperson add_image: Tilføj billede add_attachment: Tilføj fil remove: Fjern @@ -143,25 +142,35 @@ dashboard: initiative: title: Titel + title_help: Et kort, sigende navn på initiativet. area: Område + area_help: Det tematiske område initiativet hører under. description: Kort beskrivelse + description_help: Kort opsummering af formål og indhold. strategies: Strategier og planer initiative_type: Initiativets karakter + initiative_type_help: Initiativets karakter, fx projekt eller drift. status: Status + status_help: Hvor langt initiativet er. status_additional: Status – supplerende tekst + status_additional_help: Uddyb status, hvis der er behov. organizational_anchoring: Afdeling + organizational_anchoring_help: Den afdeling der er ansvarlig for initiativet. endorsement: Er initiativet vedtaget endorsement_author: Hvor er det vedtaget + endorsement_author_help: Hvor initiativet er vedtaget. budget: Budget (kr.) + budget_help: Samlet budget i hele kroner. funding: Fundingkilder stakeholders: Interessenter og samarbejdspartnere tags: Tags time_period_start: Tidshorisont – start + time_period_start_help: Hvornår initiativet starter. time_period_end: Tidshorisont – slut + time_period_end_help: Hvornår initiativet forventes afsluttet. time_period: Tidshorisont links: Relevante links contacts: Kontaktpersoner - new_contacts: Opret nye kontaktpersoner 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. @@ -198,9 +207,13 @@ initiative: contact: name: Navn + name_help: Kontaktpersonens fulde navn. email: E-mail + email_help: Arbejds-e-mail, hvis den kendes. phone: Telefon + phone_help: Telefonnummer, hvis det kendes. department: Afdeling + department_help: Afdeling eller enhed, personen hører til. index: title: Kontaktpersoner subtitle: Personer der er tilknyttet initiativer. @@ -214,6 +227,7 @@ contact: department: name: Navn + name_help: Afdelingens navn, som det vises på initiativer. name_duplicate: Der findes allerede en afdeling med dette navn. index: title: Afdelinger @@ -228,6 +242,7 @@ department: area: name: Navn + name_help: Områdets navn, som det vises på initiativer. name_duplicate: Der findes allerede et område med dette navn. index: title: Områder @@ -242,7 +257,9 @@ area: user: email: E-mail + email_help: Bruges som login og til notifikationer. name: Navn + name_help: Vises i aktivitet og som forfatter. roles: Roller password: Adgangskode email_duplicate: Der findes allerede en bruger med denne e-mail. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index ac7de30..f40b8b5 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -87,7 +87,6 @@ action: reset: Reset export: Export CSV add_link: Add link - add_contact: Add contact add_image: Add image add_attachment: Add file remove: Remove @@ -143,25 +142,35 @@ dashboard: initiative: title: Title + title_help: A short, descriptive name for the initiative. area: Area + area_help: The thematic area the initiative belongs to. description: Short description + description_help: A brief summary of the purpose and content. strategies: Strategies and plans initiative_type: Type of initiative + initiative_type_help: The nature of the initiative, e.g. project or operation. status: Status + status_help: How far along the initiative is. status_additional: Status – additional text + status_additional_help: Add detail to the status if needed. organizational_anchoring: Department + organizational_anchoring_help: The department responsible for the initiative. endorsement: Has the initiative been endorsed endorsement_author: Where it was endorsed + endorsement_author_help: Where the initiative was endorsed. budget: Budget (DKK) + budget_help: Total budget in whole kroner. funding: Funding sources stakeholders: Stakeholders and partners tags: Tags time_period_start: Time period – start + time_period_start_help: When the initiative starts. time_period_end: Time period – end + time_period_end_help: When the initiative is expected to end. time_period: Time period links: Relevant links contacts: Contacts - new_contacts: Add new contacts 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. @@ -198,9 +207,13 @@ initiative: contact: name: Name + name_help: The contact's full name. email: Email + email_help: Work email, if known. phone: Phone + phone_help: Phone number, if known. department: Department + department_help: The department or unit the person belongs to. index: title: Contacts subtitle: People attached to initiatives. @@ -214,6 +227,7 @@ contact: department: name: Name + name_help: The department name as shown on initiatives. name_duplicate: A department with this name already exists. index: title: Departments @@ -228,6 +242,7 @@ department: area: name: Name + name_help: The area name as shown on initiatives. name_duplicate: An area with this name already exists. index: title: Areas @@ -242,7 +257,9 @@ area: user: email: Email + email_help: Used to sign in and for notifications. name: Name + name_help: Shown in activity and as the author. roles: Roles password: Password email_duplicate: A user with this email already exists. From 1186feb9746a390a98149ecf147433db615cd81f Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 23:28:21 +0200 Subject: [PATCH 10/37] Improved star behaviour --- .../controllers/form_progress_controller.js | 8 +++++ assets/styles/app.css | 29 ++++++++++++++----- templates/form/fields.html.twig | 2 +- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/assets/controllers/form_progress_controller.js b/assets/controllers/form_progress_controller.js index 165b2df..89f8b6c 100644 --- a/assets/controllers/form_progress_controller.js +++ b/assets/controllers/form_progress_controller.js @@ -127,11 +127,19 @@ export default class extends Controller { realRect(star) { const gone = star.classList.contains("completion-star--gone"); if (gone) { + // Drop the transition while measuring so the box reflects the star's + // settled size, not its collapsed (mid-transition) one. + star.style.transition = "none"; star.classList.remove("completion-star--gone"); } const rect = star.getBoundingClientRect(); if (gone) { star.classList.add("completion-star--gone"); + // Flush the collapsed state while the transition is still off, so + // re-enabling it below doesn't animate the star from full back to + // hidden — that was the brief flash beside the label. + void star.offsetWidth; + star.style.transition = ""; } return rect; diff --git a/assets/styles/app.css b/assets/styles/app.css index 0dc8c11..b3ec1fa 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1069,7 +1069,7 @@ h3 { .form-row__head { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; gap: var(--itk-space-2); } @@ -1080,23 +1080,36 @@ h3 { color: var(--itk-slate-700); } +.form-row__head > label { + display: inline-flex; + align-items: center; +} + .completion-star { - display: inline-block; - margin-left: var(--itk-space-1); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + width: 1.1em; + height: 1em; + margin-right: 2px; color: gold; - font-size: 1.1em; + font-size: 0.8em; line-height: 1; - vertical-align: text-top; - -webkit-text-stroke: 0.75px #000; + vertical-align: middle; + -webkit-text-stroke: 0.5px #000; paint-order: stroke fill; cursor: help; + transition: + width 0.25s ease, + margin-right 0.25s ease, + opacity 0.25s ease; } .completion-star--gone { width: 0; - margin-left: 0; + margin-right: 0; opacity: 0; - overflow: hidden; } .form-row .help-text, diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig index 218e2f5..e280112 100644 --- a/templates/form/fields.html.twig +++ b/templates/form/fields.html.twig @@ -1,10 +1,10 @@ {% use 'form_div_layout.html.twig' %} {% block form_label_content %} - {{- parent() -}} {%- if completion_field|default(false) -%} {%- endif -%} + {{- parent() -}} {% endblock %} {% block form_row %} From 0efc80fb85cbae7cc2b8042f69c1f0035a7c6d73 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 23:47:10 +0200 Subject: [PATCH 11/37] Added message when deleting title on saved initiative --- assets/controllers/autosave_controller.js | 15 +++++++++++++++ templates/initiative/_form.html.twig | 3 ++- translations/messages.da.yaml | 1 + translations/messages.en.yaml | 1 + 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/assets/controllers/autosave_controller.js b/assets/controllers/autosave_controller.js index 847fc22..fc20068 100644 --- a/assets/controllers/autosave_controller.js +++ b/assets/controllers/autosave_controller.js @@ -25,6 +25,7 @@ export default class extends Controller { type: String, default: "Save failed — your changes are kept here", }, + requiredText: { type: String, default: "Add a title to save" }, filesHintText: { type: String, default: "Click Save to upload files" }, isNew: { type: Boolean, default: false }, }; @@ -55,6 +56,14 @@ export default class extends Controller { } async save() { + // A required field (the title) is empty: don't POST an invalid form and + // flash a save error. Show a hint and keep the last saved version — once + // a title is typed again, the next change saves normally. + if (this.hasEmptyRequiredField()) { + this.setStatus(this.requiredTextValue, "unsaved"); + return; + } + // A picked-but-unsaved file is left for Save so we never half-upload it. if (this.hasPendingFile()) { this.setStatus(this.filesHintTextValue, "unsaved"); @@ -137,6 +146,12 @@ export default class extends Controller { ).some((input) => input.files && input.files.length > 0); } + hasEmptyRequiredField() { + return Array.from( + this.element.querySelectorAll("[data-autosave-required]"), + ).some((field) => "" === field.value.trim()); + } + // Files only upload on an explicit Save, so surface that button while one is staged. revealSaveForFiles() { if (this.hasSaveTarget) { diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 5b5acbf..6c79daa 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -14,6 +14,7 @@ 'data-autosave-unsaved-text-value': 'autosave.unsaved'|trans, 'data-autosave-error-text-value': 'autosave.error'|trans, 'data-autosave-offline-text-value': 'autosave.offline'|trans, + 'data-autosave-required-text-value': 'autosave.required'|trans, 'data-autosave-files-hint-text-value': 'autosave.files_hint'|trans, 'data-autosave-is-new-value': initiative.id ? 'false' : 'true', }) %} @@ -42,7 +43,7 @@
{{ 'initiative.section.basics'|trans }}
- {% set title_attr = {'data-action': 'input->title-mirror#update'} %} + {% 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}) }}
diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 50fe7c1..5de9cd4 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -46,6 +46,7 @@ autosave: saving: Gemmer… saved: Gemt unsaved: Ikke-gemte ændringer + required: Tilføj en titel for at gemme. error: Kunne ikke gemme — tjek de påkrævede felter offline: Kunne ikke gemme — dine ændringer er bevaret her files_hint: Klik på Upload for at tilføje filerne diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index f40b8b5..7fffdba 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -46,6 +46,7 @@ autosave: saving: Saving… saved: Saved unsaved: Unsaved changes + required: Add a title to save. error: Couldn’t save — check the required fields offline: Save failed — your changes are kept here files_hint: Click Upload to add the files From 02d414fe33b80c27a815c40ae04f1ae1d9049eca Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 08:34:12 +0200 Subject: [PATCH 12/37] Added option to disable flying star animations --- assets/controllers/form_progress_controller.js | 11 +++++++++-- src/Controller/UserSettingsController.php | 17 +++++++++++++++++ src/Entity/User.php | 16 ++++++++++++++++ templates/base.html.twig | 5 +++++ templates/initiative/_form.html.twig | 1 + tests/Controller/UserSettingsControllerTest.php | 13 +++++++++++++ tests/Unit/Entity/UserTest.php | 15 +++++++++++++++ translations/messages.da.yaml | 2 ++ translations/messages.en.yaml | 2 ++ 9 files changed, 80 insertions(+), 2 deletions(-) diff --git a/assets/controllers/form_progress_controller.js b/assets/controllers/form_progress_controller.js index 89f8b6c..d07f0bc 100644 --- a/assets/controllers/form_progress_controller.js +++ b/assets/controllers/form_progress_controller.js @@ -19,6 +19,7 @@ export default class extends Controller { static values = { fields: { type: Array, default: [] }, + stars: { type: Boolean, default: true }, }; connect() { @@ -182,7 +183,9 @@ export default class extends Controller { const dx = toRect.left + toRect.width / 2 - startX; const dy = toRect.top + toRect.height / 2 - startY; - if (this.prefersReducedMotion) { + // No flight when the user turned stars off (or prefers reduced motion): + // the bar still advances, the star just doesn't fly. + if (!this.animateStars) { onArrive(); return; @@ -258,7 +261,7 @@ export default class extends Controller { } popTrophy() { - if (!this.hasStarTarget || this.prefersReducedMotion) { + if (!this.hasStarTarget || !this.animateStars) { return; } @@ -276,6 +279,10 @@ export default class extends Controller { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } + get animateStars() { + return this.starsValue && !this.prefersReducedMotion; + } + // "initiative[links][0]" -> "links", "initiative[funding][]" -> "funding". fieldKey(el) { const match = el.name?.match(/\[([^\]]+)\]/); diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index f52b4d8..c203f24 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -35,6 +35,23 @@ public function toggleMascot(Request $request, EntityManagerInterface $entityMan return $this->redirect($this->safeReturn($request)); } + /** + * Toggle whether the completion stars fly into the trophy. A plain redirect: + * the page re-renders with the new preference and the bar is unaffected. + */ + #[Route('/settings/stars/toggle', name: 'app_settings_stars_toggle', methods: ['POST'])] + public function toggleStars(Request $request, EntityManagerInterface $entityManager): Response + { + $user = $this->getUser(); + if ($user instanceof User + && $this->isCsrfTokenValid('toggle-stars', (string) $request->request->get('_token'))) { + $user->setStarsEnabled(!$user->isStarsEnabled()); + $entityManager->flush(); + } + + return $this->redirect($this->safeReturn($request)); + } + /** * Only follow local, relative return paths to avoid open redirects (same * guard as the locale switcher). diff --git a/src/Entity/User.php b/src/Entity/User.php index 3b96016..400fb9d 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -144,6 +144,22 @@ public function setMascotEnabled(bool $enabled): static return $this; } + /** + * The completion stars fly into the trophy unless the user turns it off; the + * udfyldningsgrad bar keeps working either way. + */ + public function isStarsEnabled(): bool + { + return (bool) ($this->userSettings['starsEnabled'] ?? true); + } + + public function setStarsEnabled(bool $enabled): static + { + $this->userSettings['starsEnabled'] = $enabled; + + return $this; + } + public function eraseCredentials(): void { } diff --git a/templates/base.html.twig b/templates/base.html.twig index 09ec060..ca7563b 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -51,6 +51,11 @@ +
+ + + +
{{ 'nav.logout'|trans }}
diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 6c79daa..a62f318 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -3,6 +3,7 @@ 'data-controller': 'form-progress', 'data-action': 'input->form-progress#recompute change->form-progress#recompute', 'data-form-progress-fields-value': constant('App\\Entity\\Initiative::COMPLETION_FIELDS')|json_encode, + 'data-form-progress-stars-value': (app.user.starsEnabled ?? true) ? 'true' : 'false', } %} {% if autosave|default(false) %} {% set form_attr = form_attr|merge({ diff --git a/tests/Controller/UserSettingsControllerTest.php b/tests/Controller/UserSettingsControllerTest.php index d256e5a..ca31211 100644 --- a/tests/Controller/UserSettingsControllerTest.php +++ b/tests/Controller/UserSettingsControllerTest.php @@ -60,6 +60,19 @@ public function testInvalidTokenIsIgnoredAndRedirectsToDashboard(): void self::assertTrue($this->reloadEditor()->isMascotEnabled()); } + public function testStarsToggleRedirectsAndFlipsThePreference(): void + { + $this->loginAsEditor(); + $crawler = $this->client->request('GET', '/'); + $this->assertResponseIsSuccessful(); + $token = (string) $crawler->filter('#starsToggleForm input[name="_token"]')->attr('value'); + + $this->client->request('POST', '/settings/stars/toggle', ['_token' => $token, 'return' => '/initiatives']); + + $this->assertResponseRedirects('/initiatives'); + self::assertFalse($this->reloadEditor()->isStarsEnabled()); + } + private function mascotToggleToken(): string { $crawler = $this->client->request('GET', '/'); diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php index 43c48a9..5e86360 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -77,6 +77,21 @@ public function testMascotPreferenceTogglesThroughSettings(): void self::assertTrue($user->isMascotEnabled()); } + public function testStarsPreferenceDefaultsEnabledAndToggles(): void + { + $user = new User(); + + // No stored preference means the stars fly. + self::assertTrue($user->isStarsEnabled()); + + $user->setStarsEnabled(false); + self::assertFalse($user->isStarsEnabled()); + self::assertSame(['starsEnabled' => false], $user->getUserSettings()); + + $user->setStarsEnabled(true); + self::assertTrue($user->isStarsEnabled()); + } + public function testEraseCredentialsDoesNothing(): void { $user = new User(); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 5de9cd4..2fe13a1 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -73,6 +73,8 @@ nav: logout: Log ud mascot_disable: Slå makker fra mascot_enable: Slå makker til + stars_disable: Slå flyvende stjerner fra + stars_enable: Slå flyvende stjerner til action: new: Opret diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 7fffdba..56eca55 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -73,6 +73,8 @@ nav: logout: Sign out mascot_disable: Disable mascot mascot_enable: Enable mascot + stars_disable: Disable flying stars + stars_enable: Enable flying stars action: new: New From 1cdae09397fd943a8d427bf8441cf1d51755f0b3 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 08:52:35 +0200 Subject: [PATCH 13/37] Changed design and translations of mascot and animation toggles --- assets/controllers/mascot_controller.js | 9 +++---- assets/styles/app.css | 36 +++++++++++++++++++++++++ templates/base.html.twig | 12 ++++++--- translations/messages.da.yaml | 6 ++--- translations/messages.en.yaml | 6 ++--- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js index 19cfd19..97dcdef 100644 --- a/assets/controllers/mascot_controller.js +++ b/assets/controllers/mascot_controller.js @@ -23,8 +23,6 @@ export default class extends Controller { enabled: { type: Boolean, default: true }, farewell: String, welcome: String, - enableLabel: String, - disableLabel: String, }; connect() { @@ -98,9 +96,10 @@ export default class extends Controller { applyEnabled(enabled) { this.enabledValue = enabled; if (this.toggleButton) { - this.toggleButton.textContent = enabled - ? this.disableLabelValue - : this.enableLabelValue; + this.toggleButton.setAttribute( + "aria-checked", + enabled ? "true" : "false", + ); } window.clearTimeout(this.toggleTimer); diff --git a/assets/styles/app.css b/assets/styles/app.css index b3ec1fa..7a11e2e 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -281,6 +281,42 @@ h3 { cursor: pointer; } +.user-menu__toggle { + display: flex; + align-items: center; + gap: var(--itk-space-2); +} + +.toggle-switch { + position: relative; + flex: none; + width: 34px; + height: 20px; + border-radius: var(--itk-radius-pill); + background-color: var(--itk-slate-300); + transition: background-color 0.15s; +} + +.toggle-switch__knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--itk-paper); + box-shadow: var(--itk-shadow-1); + transition: transform 0.15s; +} + +.user-menu__toggle[aria-checked="true"] .toggle-switch { + background-color: var(--itk-blue); +} + +.user-menu__toggle[aria-checked="true"] .toggle-switch__knob { + transform: translateX(14px); +} + .user-menu__logout { color: var(--itk-danger); } diff --git a/templates/base.html.twig b/templates/base.html.twig index ca7563b..59589e0 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -49,12 +49,18 @@
- +
- +
{{ 'nav.logout'|trans }}
@@ -108,8 +114,6 @@ data-mascot-enabled-value="{{ app.user.mascotEnabled ? 'true' : 'false' }}" data-mascot-farewell-value="{{ 'mascot.farewell'|trans }}" data-mascot-welcome-value="{{ 'mascot.welcome'|trans }}" - data-mascot-enable-label-value="{{ 'nav.mascot_enable'|trans }}" - data-mascot-disable-label-value="{{ 'nav.mascot_disable'|trans }}" data-mascot-invite-value="{{ 'mascot.play.invite'|trans }}" data-mascot-caught-value="{{ 'mascot.play.caught'|trans }}" data-mascot-gotcha-value="{{ 'mascot.play.gotcha'|trans }}" diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 2fe13a1..f439b70 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -71,10 +71,8 @@ nav: language: Sprog signed_in_as: Logget ind som logout: Log ud - mascot_disable: Slå makker fra - mascot_enable: Slå makker til - stars_disable: Slå flyvende stjerner fra - stars_enable: Slå flyvende stjerner til + show_mascot: Din ven Glimt + show_stars: Flyvende stjerner action: new: Opret diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 56eca55..0d5c56c 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -71,10 +71,8 @@ nav: language: Language signed_in_as: Signed in as logout: Sign out - mascot_disable: Disable mascot - mascot_enable: Enable mascot - stars_disable: Disable flying stars - stars_enable: Enable flying stars + show_mascot: Show mascot + show_stars: Show flying stars action: new: New From 926ff11e3932aadda783bc0b16b2c1b0a6b88955 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 09:00:22 +0200 Subject: [PATCH 14/37] Add Glimt introduction --- assets/controllers/mascot_controller.js | 11 +++++++++++ templates/base.html.twig | 1 + translations/messages.da.yaml | 1 + translations/messages.en.yaml | 1 + 4 files changed, 14 insertions(+) diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js index 97dcdef..e2d1d6f 100644 --- a/assets/controllers/mascot_controller.js +++ b/assets/controllers/mascot_controller.js @@ -23,6 +23,7 @@ export default class extends Controller { enabled: { type: Boolean, default: true }, farewell: String, welcome: String, + intro: String, }; connect() { @@ -32,6 +33,7 @@ export default class extends Controller { const state = this.loadState(); this.lastIndex = state.lastIndex ?? -1; this.lastShownAt = state.lastShownAt ?? 0; + this.introduced = state.introduced ?? false; // Only run the cheer cadence while enabled; a disabled mascot is rendered // parked off-screen (the `mascot--away` class) and stays silent until the // user turns it back on. Resume where the previous page left off so @@ -202,6 +204,14 @@ export default class extends Controller { } speak() { + // The first time it speaks in a session, Glimt introduces itself by name. + if (!this.introduced && this.introValue) { + this.introduced = true; + this.say(this.introValue, "cta"); + + return; + } + // Now and then, nudge the user to finish their least-complete initiative, // picking one of the finish lines at random for variety. const finishTexts = this.finishTextsValue; @@ -441,6 +451,7 @@ export default class extends Controller { JSON.stringify({ lastShownAt: this.lastShownAt ?? 0, lastIndex: this.lastIndex, + introduced: this.introduced, }), ); } catch { diff --git a/templates/base.html.twig b/templates/base.html.twig index 59589e0..d0200a9 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -114,6 +114,7 @@ data-mascot-enabled-value="{{ app.user.mascotEnabled ? 'true' : 'false' }}" data-mascot-farewell-value="{{ 'mascot.farewell'|trans }}" data-mascot-welcome-value="{{ 'mascot.welcome'|trans }}" + data-mascot-intro-value="{{ 'mascot.intro'|trans }}" data-mascot-invite-value="{{ 'mascot.play.invite'|trans }}" data-mascot-caught-value="{{ 'mascot.play.caught'|trans }}" data-mascot-gotcha-value="{{ 'mascot.play.gotcha'|trans }}" diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f439b70..cbdf778 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -7,6 +7,7 @@ mascot: cta: Opret nyt initiativ farewell: "Vi ses — jeg er her, hvis du får brug for mig! 👋" welcome: "Yes, jeg er tilbage! Lad os skabe noget sammen. 🌟" + intro: "Hej, jeg hedder Glimt! Skal vi være produktive i dag?" praise: "{1}Du har skabt ét initiativ — godt begyndt! 🌟|]1,Inf[Du har skabt %count% initiativer — flot arbejde! 🌟" finish: almost: "“%title%” er %percent% % færdig — skal vi gøre den helt færdig?" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 0d5c56c..d2fb29e 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -7,6 +7,7 @@ mascot: cta: Create a new initiative farewell: "See you around — I'm here whenever you need me! 👋" welcome: "Yay, I'm back! Let's make something together. 🌟" + intro: "Hi, I'm Glimt — your friend in the corner. Shall we create something?" praise: "{1}You've created one initiative — nice start! 🌟|]1,Inf[You've created %count% initiatives — great work! 🌟" finish: almost: "“%title%” is %percent%% done — want to finish it off?" From 1498f90ac51d2618a0043f72d324bc3580aa7b4a Mon Sep 17 00:00:00 2001 From: Jesper Kristensen Date: Mon, 29 Jun 2026 09:44:40 +0200 Subject: [PATCH 15/37] Updated vol mounting for prod --- docker-compose.server.prod.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.server.prod.yml b/docker-compose.server.prod.yml index bf63602..a7859b9 100644 --- a/docker-compose.server.prod.yml +++ b/docker-compose.server.prod.yml @@ -2,3 +2,4 @@ services: phpfpm: volumes: - ../../shared/.env.local:/app/.env.local + - ../../shared/uploads:/app/var/uploads From 6f5417a5797d89361639a0ba744b1dba9a8cedc8 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 10:23:27 +0200 Subject: [PATCH 16/37] Added nudge for unfinished contacts, and fixed bug regarding finding unfinished contacts and initiatives --- assets/controllers/mascot_controller.js | 38 ++++++++++++++++++++++--- assets/styles/app.css | 27 ++++++++++++++++++ src/Repository/ContactRepository.php | 21 ++++++++++++++ src/Repository/InitiativeRepository.php | 18 +++++++----- src/Twig/MascotExtension.php | 8 ++++-- templates/base.html.twig | 22 +++++++++++++- tests/Twig/MascotExtensionTest.php | 2 +- translations/messages.da.yaml | 4 +++ translations/messages.en.yaml | 4 +++ 9 files changed, 129 insertions(+), 15 deletions(-) diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js index e2d1d6f..c5c288b 100644 --- a/assets/controllers/mascot_controller.js +++ b/assets/controllers/mascot_controller.js @@ -8,7 +8,14 @@ import { Controller } from "@hotwired/stimulus"; * pointer until it tags it back. Messages arrive already translated. */ export default class extends Controller { - static targets = ["bubble", "text", "cta", "play", "finish"]; + static targets = [ + "bubble", + "text", + "cta", + "play", + "finish", + "finishContact", + ]; static values = { messages: { type: Array, default: [] }, @@ -20,6 +27,7 @@ export default class extends Controller { giveup: String, escaped: String, finishTexts: { type: Array, default: [] }, + finishContactTexts: { type: Array, default: [] }, enabled: { type: Boolean, default: true }, farewell: String, welcome: String, @@ -193,13 +201,16 @@ export default class extends Controller { this.scheduleNext(this.intervalValue); } - // Clicking the avatar only does something during the play-catch game; a plain - // idle click is intentionally inert (no message, no animation). + // Clicking the avatar plays catch mid-game; an idle click pops a fresh + // message and resets the cadence so the next auto-message isn't right behind. poke() { if ("invited" === this.mode) { this.startFlee(); } else if ("flee" === this.mode) { this.caught(); + } else if ("idle" === this.mode) { + this.speak(); + this.scheduleNext(this.intervalValue); } } @@ -212,13 +223,29 @@ export default class extends Controller { return; } + // A contact created on the fly (name only) gets a gentle reminder to + // finish it, linking straight to its edit page. + const contactTexts = this.finishContactTextsValue; + if ( + this.hasFinishContactTarget && + contactTexts.length > 0 && + Math.random() < 0.15 + ) { + this.say( + contactTexts[Math.floor(Math.random() * contactTexts.length)], + "finishContact", + ); + + return; + } + // Now and then, nudge the user to finish their least-complete initiative, // picking one of the finish lines at random for variety. const finishTexts = this.finishTextsValue; if ( this.hasFinishTarget && finishTexts.length > 0 && - Math.random() < 0.4 + Math.random() < 0.15 ) { this.say( finishTexts[Math.floor(Math.random() * finishTexts.length)], @@ -388,6 +415,9 @@ export default class extends Controller { if (this.hasFinishTarget) { this.finishTarget.hidden = "finish" !== action; } + if (this.hasFinishContactTarget) { + this.finishContactTarget.hidden = "finishContact" !== action; + } this.bubbleTarget.hidden = false; window.requestAnimationFrame(() => { this.bubbleTarget.classList.add("is-visible"); diff --git a/assets/styles/app.css b/assets/styles/app.css index 7a11e2e..7926893 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1829,6 +1829,33 @@ textarea { overflow: visible; } +.mascot__gold-stop { + animation: mascot-gold 9s ease-in-out infinite; +} + +.mascot__gold-stop:nth-child(2) { + animation-delay: -3s; +} + +.mascot__gold-stop:nth-child(3) { + animation-delay: -6s; +} + +@keyframes mascot-gold { + 0% { + stop-color: #ffe488; + } + 33% { + stop-color: #fcc24a; + } + 66% { + stop-color: #eaa326; + } + 100% { + stop-color: #ffe488; + } +} + @keyframes mascot-bob { 0%, 100% { diff --git a/src/Repository/ContactRepository.php b/src/Repository/ContactRepository.php index 9d3e8d5..a7cd004 100644 --- a/src/Repository/ContactRepository.php +++ b/src/Repository/ContactRepository.php @@ -5,6 +5,7 @@ namespace App\Repository; use App\Entity\Contact; +use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -55,4 +56,24 @@ public function findOrCreate(string $name): Contact return $contact; } + + /** + * The user's most recent contact that still lacks an email — typically one + * they created on the fly from an initiative's contact picker (name only). + * Used by the mascot to nudge them to fill in the rest. + */ + public function findIncompleteByCreator(User $user): ?Contact + { + // createdBy is a ManyToOne to the UserInterface (resolved to User via + // resolve_target_entities); binding the entity to a ULID FK doesn't match, + // so compare the raw FK against the user's id with the ulid type applied. + return $this->createQueryBuilder('c') + ->andWhere('IDENTITY(c.createdBy) = :user') + ->andWhere("(c.email IS NULL OR c.email = '')") + ->setParameter('user', $user->getId(), 'ulid') + ->orderBy('c.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } } diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 9cfa46a..af4636e 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -5,6 +5,7 @@ namespace App\Repository; use App\Entity\Initiative; +use App\Entity\User; use App\Enum\EndorsementAuthor; use App\Enum\Funding; use App\Enum\InitiativeType; @@ -14,7 +15,6 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -165,12 +165,15 @@ public function countAll(): int ->getSingleScalarResult(); } - public function countByCreator(UserInterface $user): int + public function countByCreator(User $user): int { + // createdBy is a ManyToOne to the UserInterface (resolved to User via + // resolve_target_entities); binding the entity to a ULID FK doesn't match, + // so compare the raw FK against the user's id with the ulid type applied. return (int) $this->createQueryBuilder('i') ->select('COUNT(i.id)') - ->andWhere('i.createdBy = :user') - ->setParameter('user', $user) + ->andWhere('IDENTITY(i.createdBy) = :user') + ->setParameter('user', $user->getId(), 'ulid') ->getQuery() ->getSingleScalarResult(); } @@ -180,11 +183,12 @@ public function countByCreator(UserInterface $user): int * null if none are outstanding. Completion is computed in PHP (not a stored * column), so this scans only the creator's 50 most recent initiatives. */ - public function findUnfinishedByCreator(UserInterface $user): ?Initiative + public function findUnfinishedByCreator(User $user): ?Initiative { + // See countByCreator: match the raw ULID FK, not the entity. $initiatives = $this->createQueryBuilder('i') - ->andWhere('i.createdBy = :user') - ->setParameter('user', $user) + ->andWhere('IDENTITY(i.createdBy) = :user') + ->setParameter('user', $user->getId(), 'ulid') ->orderBy('i.createdAt', 'DESC') ->setMaxResults(50) ->getQuery() diff --git a/src/Twig/MascotExtension.php b/src/Twig/MascotExtension.php index 8037634..0ae8aab 100644 --- a/src/Twig/MascotExtension.php +++ b/src/Twig/MascotExtension.php @@ -4,8 +4,10 @@ namespace App\Twig; +use App\Entity\Contact; use App\Entity\Initiative; use App\Entity\User; +use App\Repository\ContactRepository; use App\Repository\InitiativeRepository; use Symfony\Bundle\SecurityBundle\Security; use Twig\Extension\AbstractExtension; @@ -21,6 +23,7 @@ class MascotExtension extends AbstractExtension public function __construct( private readonly Security $security, private readonly InitiativeRepository $initiatives, + private readonly ContactRepository $contacts, ) { } @@ -32,18 +35,19 @@ public function getFunctions(): array } /** - * @return array{count: int, unfinished: Initiative|null} + * @return array{count: int, unfinished: Initiative|null, incompleteContact: Contact|null} */ public function context(): array { $user = $this->security->getUser(); if (!$user instanceof User) { - return ['count' => 0, 'unfinished' => null]; + return ['count' => 0, 'unfinished' => null, 'incompleteContact' => null]; } return [ 'count' => $this->initiatives->countByCreator($user), 'unfinished' => $this->initiatives->findUnfinishedByCreator($user), + 'incompleteContact' => $this->contacts->findIncompleteByCreator($user), ]; } } diff --git a/templates/base.html.twig b/templates/base.html.twig index d0200a9..177618c 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -109,6 +109,15 @@ 'mascot.finish.strong'|trans(finishParams), ] %} {% endif %} + {% set contactMessages = [] %} + {% if ctx.incompleteContact %} + {% set contactParams = {'%name%': ctx.incompleteContact.name} %} + {% set contactMessages = [ + 'mascot.contact.details'|trans(contactParams), + 'mascot.contact.quick'|trans(contactParams), + 'mascot.contact.name_only'|trans(contactParams), + ] %} + {% endif %}
diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 6acf675..f9a3232 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -131,20 +131,26 @@ dashboard: departments_involved: Afdelinger involveret departments_involved_hint: med mindst ét initiativ collaboration_count: Mulige samarbejder - collaboration_count_hint: temaer på tværs af afdelinger + collaboration_count_hint: områder på tværs af afdelinger by_status: Fordeling på status + by_status_sub: Alle initiativer fordelt på deres aktuelle status. recent: Senest oprettede no_status: Uden status empty: Der er endnu ikke oprettet nogen initiativer. - themes_heatmap: Temaer på tværs af afdelinger - themes_heatmap_sub: Mørkere = flere initiativer. Markerede temaer går på tværs af mindst tre afdelinger. + themes_heatmap: Områder på tværs af afdelinger + themes_heatmap_sub: Antal initiativer pr. område i hver afdeling. collaboration: Muligt samarbejde - collaboration_sub: Initiativer i forskellige afdelinger med fælles tema. + collaboration_sub: Initiativer i forskellige afdelinger med fælles område. status_by_dept: Status pr. afdeling + status_by_dept_sub: Initiativernes status fordelt på hver afdeling. funding_sources: Finansieringskilder + funding_sources_sub: Antal initiativer pr. finansieringskilde. budget_by_dept: Budget pr. afdeling - reach: Temaer med flest afdelinger + budget_by_dept_sub: Samlet budget for initiativerne i hver afdeling. + reach: Områder med flest afdelinger + reach_sub: Hvor mange afdelinger hvert område er repræsenteret i. timeline: Tidslinje + timeline_sub: Hvert initiativs tidsperiode på en tidslinje. initiative: title: Titel diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 92ae714..9f0e070 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -131,20 +131,26 @@ dashboard: departments_involved: Departments involved departments_involved_hint: with at least one initiative collaboration_count: Possible collaborations - collaboration_count_hint: themes across departments + collaboration_count_hint: areas across departments by_status: By status + by_status_sub: All initiatives grouped by their current status. recent: Recently created no_status: No status empty: No initiatives have been created yet. - themes_heatmap: Themes across departments - themes_heatmap_sub: Darker = more initiatives. Marked themes span at least three departments. + themes_heatmap: Areas across departments + themes_heatmap_sub: Number of initiatives per area in each department. Darker = more; marked areas span at least three departments. collaboration: Possible collaboration - collaboration_sub: Initiatives in different departments sharing a theme. + collaboration_sub: Initiatives in different departments sharing an area. status_by_dept: Status by department + status_by_dept_sub: Initiative statuses broken down by department. funding_sources: Funding sources + funding_sources_sub: Number of initiatives per funding source. budget_by_dept: Budget by department - reach: Themes by department reach + budget_by_dept_sub: Total budget of the initiatives in each department. + reach: Areas by department reach + reach_sub: How many departments each area appears in. timeline: Timeline + timeline_sub: Each initiative's time period on a timeline. initiative: title: Title From 7d087df4072ff5d05b0d44573ccbd14c5afeb7bf Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 11:57:32 +0200 Subject: [PATCH 18/37] =?UTF-8?q?Move=20Aktivitet=20up,=20and=20add=20Dit?= =?UTF-8?q?=20igangv=C3=A6rende=20arbejde=20boks,=20showing=20the=20users?= =?UTF-8?q?=20current=20unfinished=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/styles/app.css | 87 +++++++++++++++++++++++++ src/Controller/DashboardController.php | 8 ++- src/Entity/Initiative.php | 18 +++++ src/Repository/ContactRepository.php | 16 ++++- src/Repository/InitiativeRepository.php | 22 +++++-- templates/activity/_item.html.twig | 18 ++--- templates/dashboard/index.html.twig | 80 ++++++++++++++++++----- tests/Unit/Entity/InitiativeTest.php | 20 ++++++ translations/messages.da.yaml | 13 +++- translations/messages.en.yaml | 11 ++++ 10 files changed, 260 insertions(+), 33 deletions(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index 7c93f54..12dbd89 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -471,6 +471,24 @@ h3 { padding: var(--itk-space-5); } +.card--stretch { + align-self: stretch; + display: flex; + flex-direction: column; +} + +.feed-fill { + position: relative; + flex: 1; + min-height: 230px; +} + +.feed-fill .feed-scroll { + position: absolute; + inset: 0; + height: auto; +} + .card__header { padding: var(--itk-space-4) var(--itk-space-5); border-bottom: 1px solid var(--itk-slate-100); @@ -845,6 +863,75 @@ h3 { color: var(--itk-slate-500); } +a.recent-item { + color: inherit; + text-decoration: none; + transition: background-color 0.15s ease; +} + +a.recent-item:hover { + background-color: color-mix(in srgb, var(--itk-blue) 9%, transparent); +} + +.recent-item__cta { + display: none; + align-items: center; + gap: 4px; + font-size: var(--itk-text-xs); + font-weight: 600; + color: var(--itk-blue); + white-space: nowrap; +} + +a.recent-item:hover .badge { + display: none; +} + +a.recent-item:hover .recent-item__cta { + display: inline-flex; +} + +.recent-item__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.recent-item__body .recent-item__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.todo-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--itk-space-2); + height: 100%; + padding: var(--itk-space-5); + text-align: center; +} + +.todo-empty__icon { + color: var(--itk-green); + line-height: 0; +} + +.todo-empty__title { + font-weight: 600; + color: var(--itk-ink); +} + +.todo-empty__hint { + max-width: 34ch; + margin: 0 0 var(--itk-space-2); + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); +} + .table-wrap { overflow-x: auto; } diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 8d63386..2053b0d 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -4,6 +4,8 @@ namespace App\Controller; +use App\Entity\User; +use App\Repository\ContactRepository; use App\Repository\InitiativeRepository; use App\Service\DashboardData; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -13,11 +15,15 @@ class DashboardController extends AbstractController { #[Route('/', name: 'app_dashboard', methods: ['GET'])] - public function index(InitiativeRepository $initiatives, DashboardData $dashboardData): Response + public function index(InitiativeRepository $initiatives, ContactRepository $contacts, DashboardData $dashboardData): Response { + $user = $this->getUser(); + return $this->render('dashboard/index.html.twig', [ 'recent' => $initiatives->findRecent(8), 'viz' => $dashboardData->build(), + 'unfinishedInitiatives' => $user instanceof User ? $initiatives->findUnfinishedListByCreator($user) : [], + 'incompleteContacts' => $user instanceof User ? $contacts->findIncompleteListByCreator($user) : [], ]); } } diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index d9c48bd..91e5649 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -501,6 +501,24 @@ public function getCompletionPercentage(): int return (int) round(\count(array_filter($checks)) / \count($checks) * 100); } + /** + * Whether the initiative has been edited since it was created — drives the + * "updated" vs "created" label in the dashboard activity feed. Only counts as + * an edit once it's more than a day past creation, so the initial save and any + * same-day tweaks still read as "created". + */ + public function wasUpdatedAfterCreation(): bool + { + $created = $this->getCreatedAt(); + $updated = $this->getUpdatedAt(); + + if (null === $created || null === $updated) { + return false; + } + + return $updated->getTimestamp() - $created->getTimestamp() > 60 * 60 * 24; + } + public function __toString(): string { return (string) $this->title; diff --git a/src/Repository/ContactRepository.php b/src/Repository/ContactRepository.php index a7cd004..8aa5f2f 100644 --- a/src/Repository/ContactRepository.php +++ b/src/Repository/ContactRepository.php @@ -63,6 +63,18 @@ public function findOrCreate(string $name): Contact * Used by the mascot to nudge them to fill in the rest. */ public function findIncompleteByCreator(User $user): ?Contact + { + return $this->findIncompleteListByCreator($user, 1)[0] ?? null; + } + + /** + * The user's contacts that still lack an email (created name-only and not yet + * finished), most recent first, capped at $limit. Surfaced on the dashboard + * as outstanding work. + * + * @return Contact[] + */ + public function findIncompleteListByCreator(User $user, int $limit = 6): array { // createdBy is a ManyToOne to the UserInterface (resolved to User via // resolve_target_entities); binding the entity to a ULID FK doesn't match, @@ -72,8 +84,8 @@ public function findIncompleteByCreator(User $user): ?Contact ->andWhere("(c.email IS NULL OR c.email = '')") ->setParameter('user', $user->getId(), 'ulid') ->orderBy('c.createdAt', 'DESC') - ->setMaxResults(1) + ->setMaxResults($limit) ->getQuery() - ->getOneOrNullResult(); + ->getResult(); } } diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index af4636e..18ce917 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -180,10 +180,21 @@ public function countByCreator(User $user): int /** * The creator's least-complete initiative that isn't fully filled in yet, or - * null if none are outstanding. Completion is computed in PHP (not a stored - * column), so this scans only the creator's 50 most recent initiatives. + * null if none are outstanding. */ public function findUnfinishedByCreator(User $user): ?Initiative + { + return $this->findUnfinishedListByCreator($user, 1)[0] ?? null; + } + + /** + * The creator's incomplete initiatives (completion below 100 %), least-complete + * first, capped at $limit. Completion is computed in PHP (not a stored column), + * so this scans only the creator's 50 most recent initiatives. + * + * @return Initiative[] + */ + public function findUnfinishedListByCreator(User $user, int $limit = 6): array { // See countByCreator: match the raw ULID FK, not the entity. $initiatives = $this->createQueryBuilder('i') @@ -204,7 +215,7 @@ public function findUnfinishedByCreator(User $user): ?Initiative static fn (Initiative $a, Initiative $b): int => $a->getCompletionPercentage() <=> $b->getCompletionPercentage(), ); - return $unfinished[0] ?? null; + return \array_slice($unfinished, 0, $limit); } /** @@ -232,12 +243,15 @@ public function countByStatus(): array } /** + * Most recently touched initiatives (created or edited) for the activity feed, + * newest first. Ordered by updatedAt so an edit resurfaces the initiative. + * * @return Initiative[] */ public function findRecent(int $limit = 5): array { return $this->createQueryBuilder('i') - ->orderBy('i.createdAt', 'DESC') + ->orderBy('i.updatedAt', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/templates/activity/_item.html.twig b/templates/activity/_item.html.twig index 8adc949..8afa337 100644 --- a/templates/activity/_item.html.twig +++ b/templates/activity/_item.html.twig @@ -1,9 +1,9 @@ -
-
- {% if url %}{{ title }}{% else %}{{ title }}{% endif %} -
- {%- if actor %}{{ actor }} · {% endif -%} - {{ ('activity.action.' ~ action)|trans }} · {{ at|date('d.m.Y H:i') }} -
-
-
+{% set row_tag = url ? 'a' : 'div' %} +<{{ row_tag }} id="activity-item-{{ id }}" class="recent-item"{% if url %} href="{{ url }}"{% endif %}> + + {{ title }} + {%- if actor %}{{ actor }} · {% endif -%}{{ at|date('d.m.Y H:i') }} + + {{ ('activity.action.' ~ action)|trans|capitalize }} + {% if url %}{{ 'action.view'|trans }} →{% endif %} + diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index e9ad4fc..b0e2ffe 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -23,6 +23,70 @@
+
+
+ {{ 'dashboard.unfinished.title'|trans }} +

{{ 'dashboard.unfinished.sub'|trans }}

+
+
+ {% if unfinishedInitiatives is empty and incompleteContacts is empty %} +
+
{{ 'dashboard.unfinished.empty_title'|trans }}
+
+ {% else %} + {% for initiative in unfinishedInitiatives %} + + + {{ initiative.title }} + {{ 'dashboard.unfinished.initiative'|trans }} + + {{ 'dashboard.unfinished.percent_done'|trans({'%percent%': initiative.completionPercentage}) }} + {{ 'dashboard.unfinished.cta'|trans }} → + + {% endfor %} + {% for contact in incompleteContacts %} + + + {{ contact.name }} + {{ 'dashboard.unfinished.contact'|trans }} + + {{ 'dashboard.unfinished.contact_badge'|trans }} + {{ 'dashboard.unfinished.cta'|trans }} → + + {% endfor %} + {% endif %} +
+
+ +
+
+ {{ 'activity.title'|trans }} +

{{ 'activity.subtitle'|trans }}

+
+
+
+ {% if recent is empty %} +
+
{{ 'activity.empty'|trans }}
+
+ {% else %} + {% for initiative in recent %} + {% set updated = initiative.wasUpdatedAfterCreation %} + {% set actor = updated ? initiative.modifiedBy : initiative.createdBy %} + {{ include('activity/_item.html.twig', { + id: initiative.id, + action: updated ? 'updated' : 'created', + title: initiative.title, + url: path('app_initiative_show', {id: initiative.id}), + actor: actor ? actor.name : null, + at: updated ? initiative.updatedAt : initiative.createdAt, + }) }} + {% endfor %} + {% endif %} +
+
+
+
{{ 'dashboard.themes_heatmap'|trans }} @@ -43,22 +107,6 @@
-
-
{{ 'activity.title'|trans }}
-
- {% for initiative in recent %} - {{ include('activity/_item.html.twig', { - id: initiative.id, - action: 'created', - title: initiative.title, - url: path('app_initiative_show', {id: initiative.id}), - actor: initiative.createdBy ? initiative.createdBy.name : null, - at: initiative.createdAt, - }) }} - {% endfor %} -
-
-
{{ 'dashboard.status_by_dept'|trans }} diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index 97a5f2c..b522e32 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -95,6 +95,26 @@ public function testCompletionPercentage(): void self::assertSame(100, $full->getCompletionPercentage()); } + public function testWasUpdatedAfterCreation(): void + { + // No timestamps yet (entity not persisted) — treated as not updated. + self::assertFalse((new Initiative())->wasUpdatedAfterCreation()); + + $created = new \DateTimeImmutable('2025-01-01 10:00:00'); + + // Edited within a day of creation: still reads as "created". + $fresh = new Initiative(); + $fresh->setCreatedAt($created); + $fresh->setUpdatedAt($created->modify('+5 hours')); + self::assertFalse($fresh->wasUpdatedAfterCreation()); + + // Edited more than a day after creation. + $edited = new Initiative(); + $edited->setCreatedAt($created); + $edited->setUpdatedAt($created->modify('+2 days')); + self::assertTrue($edited->wasUpdatedAfterCreation()); + } + public function testFundingRoundTrip(): void { $initiative = (new Initiative())->setFunding([Funding::MunicipalBudget, Funding::EuFunds]); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f9a3232..8430eac 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -16,7 +16,7 @@ mascot: blanks: "“%title%” er %percent% % udfyldt — der mangler stadig et par felter. 📝" ending: "“%title%” er %percent% % færdig. Giv den en værdig afslutning! 🏁" strong: "“%title%” er %percent% % færdig — lad os tage den sidste bid! 💪" - finish_cta: Gør færdig + finish_cta: Gå til redigering contact: details: "Kontaktpersonen “%name%” som du har oprettet mangler stadig oplysninger — Vil du gøre den færdig?" quick: "Du tilføjede “%name%” i farten — vil du udfylde resten? ✏️" @@ -60,6 +60,8 @@ autosave: activity: title: Aktivitet + subtitle: Seneste aktivitet på platformen + empty: Ingen aktivitet at vise action: created: oprettede updated: opdaterede @@ -151,6 +153,15 @@ dashboard: reach_sub: Hvor mange afdelinger hvert område er repræsenteret i. timeline: Tidslinje timeline_sub: Hvert initiativs tidsperiode på en tidslinje. + unfinished: + title: Dit igangværende arbejde + sub: Dine initiativer og kontaktpersoner der mangler de sidste detaljer. + initiative: Initiativ + contact: Kontaktperson + contact_badge: Mangler oplysninger + percent_done: "%percent% % udfyldt" + cta: Gå til redigering + empty_title: Intet igangværende arbejde initiative: title: Titel diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 9f0e070..9b139ae 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -60,6 +60,8 @@ autosave: activity: title: Activity + subtitle: Latest activity on the platform + empty: No activity to show action: created: created updated: updated @@ -151,6 +153,15 @@ dashboard: reach_sub: How many departments each area appears in. timeline: Timeline timeline_sub: Each initiative's time period on a timeline. + unfinished: + title: Your work + sub: Your initiatives and contacts that are still missing details. + initiative: Initiative + contact: Contact + contact_badge: Missing details + percent_done: "%percent% % filled in" + cta: Finish + empty_title: You're all caught up initiative: title: Title From ea77c6ac06b1d6269c42cbb003d610062c14f3fe Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 12:12:15 +0200 Subject: [PATCH 19/37] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfae926..d16fbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-18](https://github.com/itk-dev/itk-project-database/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-16](https://github.com/itk-dev/itk-project-database/pull/16) Turn the strategies and tags fields into a searchable, shared tag pool where new entries are capitalised and reused as suggestions. From 4a036edc7e2ac756cbd96e0dec6972b81af6fae9 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 12:20:50 +0200 Subject: [PATCH 20/37] =?UTF-8?q?Display=20default=20empty=20text=20in=20O?= =?UTF-8?q?mr=C3=A5der=20p=C3=A5=20tv=C3=A6rs=20af=20afdelinger,=20until?= =?UTF-8?q?=20both=20afdelinger=20and=20omr=C3=A5der=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/dashboard_viz_controller.js | 19 +++++++++++++++++++ assets/styles/app.css | 6 ++++++ templates/dashboard/index.html.twig | 2 +- translations/messages.da.yaml | 1 + translations/messages.en.yaml | 1 + 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/assets/controllers/dashboard_viz_controller.js b/assets/controllers/dashboard_viz_controller.js index 3c49b06..bd24e8e 100644 --- a/assets/controllers/dashboard_viz_controller.js +++ b/assets/controllers/dashboard_viz_controller.js @@ -65,6 +65,10 @@ export default class extends Controller { "timeline", ]; + static values = { + empty: String, + }; + connect() { this.reduce = window.matchMedia( "(prefers-reduced-motion: reduce)", @@ -221,6 +225,18 @@ export default class extends Controller { buildHeatmap() { const d = this.viz; const el = this.heatmapTarget; + + // The heatmap only makes sense once there are both rows (departments) and + // columns (areas) to cross; until then show a placeholder, not bare headers. + if (!d.areas.length || !d.departments.length) { + el.style.gridTemplateColumns = ""; + el.classList.remove("heat--paused"); + el.innerHTML = `

${this.esc(this.emptyValue)}

`; + this.heatColHeads = []; + this.heatCells = []; + return; + } + el.style.gridTemplateColumns = `minmax(120px, 168px) repeat(${d.areas.length}, minmax(40px, 1fr))`; if (!this.reduce) { el.classList.add("heat--paused"); @@ -254,6 +270,9 @@ export default class extends Controller { updateHeatmap(initial = false) { const d = this.viz; + if (!d.areas.length || !d.departments.length) { + return; + } let max = 1; d.heatmap.forEach((row) => row.forEach((v) => { diff --git a/assets/styles/app.css b/assets/styles/app.css index 12dbd89..c26b4d4 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -628,6 +628,12 @@ h3 { min-width: 560px; } +.heat-empty { + margin: 0; + color: var(--itk-slate-500); + font-size: var(--itk-text-sm); +} + .heat__collabel { display: flex; flex-direction: column; diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index b0e2ffe..435a044 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -3,7 +3,7 @@ {% block title %}{{ 'dashboard.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block body %} -
+
- +
@@ -106,55 +108,72 @@
{{ 'initiative.section.media'|trans }}
-
- -
-
- {% for image in form.images %} -
-
- {% if image.vars.value and image.vars.value.id and image.vars.value.imageName %} - {{ image.vars.value.originalName ?: 'image' }} - {% endif %} - {{ form_widget(image) }} + +
+ +
+
+ {% for image in form.images %} + {% set uploaded = image.vars.value and image.vars.value.id and image.vars.value.imageName %} +
+
+ {% if uploaded %} +
+ + + + {{ image.vars.value.originalName ?: 'image' }} +
+ {% endif %} + {{ form_widget(image.imageFile) }} +
-
- {% endfor %} + {% endfor %} +
+
-
-
-
- -
-
- {% for attachment in form.attachments %} -
-
- {% if attachment.vars.value and attachment.vars.value.id and attachment.vars.value.fileName %} - {{ attachment.vars.value.originalName ?: 'file' }} - {% endif %} - {{ form_widget(attachment) }} +
+ +
+
+ {% for attachment in form.attachments %} + {% set uploaded = attachment.vars.value and attachment.vars.value.id and attachment.vars.value.fileName %} +
+
+ {% if uploaded %} + + {% endif %} + {{ form_widget(attachment.file) }} +
-
- {% endfor %} + {% endfor %} +
+
-
-
+
-
- {% if autosave|default(false) %} - - {% else %} + {% if not (autosave|default(false)) %} +
{{ 'action.cancel'|trans }} - {% endif %} -
+
+ {% endif %} {% do form.links.setRendered() %} {% do form.images.setRendered() %} {% do form.attachments.setRendered() %} diff --git a/templates/initiative/show.html.twig b/templates/initiative/show.html.twig index b84439e..a58eeea 100644 --- a/templates/initiative/show.html.twig +++ b/templates/initiative/show.html.twig @@ -72,7 +72,9 @@ {% if initiative.images|length > 0 %}
{% for image in initiative.images %} - + {{ image.alt }} {% endfor %} diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index 3c59420..f3b1f4f 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -6,6 +6,7 @@ use App\Entity\Initiative; use App\Entity\InitiativeAttachment; +use App\Entity\InitiativeImage; use App\Tests\FunctionalTestCase; use Symfony\Component\HttpFoundation\Response; @@ -23,8 +24,6 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): 'title' => 'Coverage initiative', // A typed name creates a new contact on the fly and attaches it. 'contacts' => 'Coverage Contact', - // An empty image row exercises the image branch of removeEmptyMedia(). - 'images' => [['alt' => 'empty image row']], '_token' => $token, ], ]); @@ -34,7 +33,6 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $em = $this->entityManager(); $initiative = $this->initiatives()->findOneBy(['title' => 'Coverage initiative']); self::assertInstanceOf(Initiative::class, $initiative); - self::assertCount(0, $initiative->getImages(), 'Empty image rows should be dropped.'); self::assertGreaterThanOrEqual(1, $initiative->getContacts()->count(), 'Inline contact should be merged in.'); $em->remove($initiative); @@ -59,8 +57,8 @@ public function testEditUpdatesInitiative(): void $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ 'initiative' => [ 'title' => 'Edited initiative', - 'images' => [['alt' => 'empty']], - 'attachments' => [[]], + 'images' => [['imageFile' => '']], + 'attachments' => [['file' => '']], '_token' => $token, ], ]); @@ -132,6 +130,37 @@ public function testEditDropsAttachmentsLeftWithoutAFile(): void $this->removeInitiative($id); } + public function testEditDropsImagesLeftWithoutAFile(): void + { + $this->loginAsAdmin(); + $initiative = $this->createInitiative('Has empty image'); + $initiative->addImage(new InitiativeImage()); + $em = $this->entityManager(); + $em->flush(); + $id = (string) $initiative->getId(); + + $crawler = $this->client->request('GET', sprintf('/initiatives/%s/edit', $id)); + $token = (string) $crawler->filter('input[name="initiative[_token]"]')->attr('value'); + $this->client->request('POST', sprintf('/initiatives/%s/edit', $id), [ + 'initiative' => [ + 'title' => 'Has empty image', + // Re-submit the file-less image so the form keeps it; the + // controller's removeEmptyMedia() then drops it. + 'images' => [['imageFile' => '']], + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects(sprintf('/initiatives/%s', $id)); + + $this->entityManager()->clear(); + $reloaded = $this->initiatives()->find($id); + self::assertNotNull($reloaded); + self::assertCount(0, $reloaded->getImages(), 'A file-less image should be dropped.'); + + $this->removeInitiative($id); + } + public function testNewAutosaveReturnsCreatedWithLocationHeader(): void { $this->loginAsAdmin(); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index cbdf778..16b4706 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -50,9 +50,7 @@ autosave: required: Tilføj en titel for at gemme. error: Kunne ikke gemme — tjek de påkrævede felter offline: Kunne ikke gemme — dine ændringer er bevaret her - files_hint: Klik på Upload for at tilføje filerne completion: Udfyldningsgrad - upload_files: Upload filer activity: title: Aktivitet @@ -102,12 +100,16 @@ common: created: Oprettet updated: Opdateret optional: valgfri + close: Luk form: choose: Vælg … terms: invalid: Ugyldige værdier. +media: + choose: Vælg fil + filter: search: Søg search_placeholder: Søg i titel, beskrivelse, forfatter … @@ -179,7 +181,6 @@ initiative: images: Billeder attachments: Filer image_file: Billedfil - image_alt: Alternativ tekst attachment_file: Fil attachment_invalid_type: "Filtypen er ikke tilladt (tilladt: pdf, doc, docx, xls, xlsx)." index: diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index d2fb29e..b2d49d6 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -50,9 +50,7 @@ autosave: required: Add a title to save. error: Couldn’t save — check the required fields offline: Save failed — your changes are kept here - files_hint: Click Upload to add the files completion: Form completion - upload_files: Upload files activity: title: Activity @@ -102,12 +100,16 @@ common: created: Created updated: Updated optional: optional + close: Close form: choose: Choose … terms: invalid: Invalid values. +media: + choose: Choose file + filter: search: Search search_placeholder: Search title, description, author … @@ -179,7 +181,6 @@ initiative: images: Images attachments: Files image_file: Image file - image_alt: Alternative text attachment_file: File attachment_invalid_type: "The file type is not allowed (allowed: pdf, doc, docx, xls, xlsx)." index: From ea5cccb5e5820007abd98e3148a0665cbb4fde6d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 13:23:03 +0200 Subject: [PATCH 22/37] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfae926..606f9b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-19](https://github.com/itk-dev/itk-project-database/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-16](https://github.com/itk-dev/itk-project-database/pull/16) Turn the strategies and tags fields into a searchable, shared tag pool where new entries are capitalised and reused as suggestions. From 539ca8b1b4b59635d3cce3da49bccc8a1175c5b1 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 13:31:21 +0200 Subject: [PATCH 23/37] Add some spacing and styling to the user menu --- assets/styles/app.css | 40 +++++++++++++++--------- templates/base.html.twig | 58 ++++++++++++++++++++++------------- translations/messages.da.yaml | 5 +-- translations/messages.en.yaml | 5 +-- 4 files changed, 67 insertions(+), 41 deletions(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index c26b4d4..2ade5d3 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -230,9 +230,7 @@ h3 { } .user-menu__header { - padding: var(--itk-space-3); - border-bottom: 1px solid var(--itk-slate-100); - margin-bottom: var(--itk-space-2); + padding: var(--itk-space-2) var(--itk-space-3); } .user-menu__eyebrow { @@ -251,6 +249,22 @@ h3 { color: var(--itk-slate-500); } +.user-menu__divider { + height: 1px; + margin: var(--itk-space-2) 0; + background-color: var(--itk-slate-100); +} + +.user-menu__group { + display: flex; + flex-direction: column; + gap: var(--itk-space-1); +} + +.user-menu__group-label { + padding: var(--itk-space-1) var(--itk-space-3); +} + .user-menu__link, .user-menu__logout, .user-menu__button { @@ -281,10 +295,14 @@ h3 { cursor: pointer; } -.user-menu__toggle { +.user-menu__pref { display: flex; align-items: center; - gap: var(--itk-space-2); + justify-content: space-between; + gap: var(--itk-space-3); + padding: var(--itk-space-2) var(--itk-space-3); + font-size: var(--itk-text-sm); + color: var(--itk-slate-700); } .toggle-switch { @@ -309,11 +327,11 @@ h3 { transition: transform 0.15s; } -.user-menu__toggle[aria-checked="true"] .toggle-switch { +.user-menu__pref[aria-checked="true"] .toggle-switch { background-color: var(--itk-blue); } -.user-menu__toggle[aria-checked="true"] .toggle-switch__knob { +.user-menu__pref[aria-checked="true"] .toggle-switch__knob { transform: translateX(14px); } @@ -321,10 +339,6 @@ h3 { color: var(--itk-danger); } -.user-menu__section { - padding: var(--itk-space-2) var(--itk-space-3); -} - .lang-toggle { display: inline-flex; border: 1px solid var(--itk-slate-200); @@ -349,10 +363,6 @@ h3 { color: #fff; } -.user-menu__section .lang-toggle { - margin-top: 6px; -} - .page { max-width: var(--itk-container); margin: 0 auto; diff --git a/templates/base.html.twig b/templates/base.html.twig index 177618c..e4181db 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -38,30 +38,44 @@
{{ app.user.name }}
-
-
{{ 'nav.language'|trans }}
-
- DA - EN + +
+ + + +
+ +
+
{{ 'nav.preferences'|trans }}
+
+ {{ 'nav.language'|trans }} +
+ DA + EN +
+
+ + + +
+
+ + + +
- {{ 'nav.admin'|trans }} -
- - - -
-
- - - -
+ +
+ {{ 'nav.logout'|trans }}
diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 9880564..f1b03f5 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -75,11 +75,12 @@ nav: areas: Områder users: Brugere admin: Administration + preferences: Indstillinger language: Sprog signed_in_as: Logget ind som logout: Log ud - show_mascot: Din ven Glimt - show_stars: Flyvende stjerner + show_mascot: Din ven Glimt ⭐ + show_stars: Flyvende stjerner 💫 action: new: Opret diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index e42c1da..e0ac7b9 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -75,11 +75,12 @@ nav: areas: Areas users: Users admin: Administration + preferences: Preferences language: Language signed_in_as: Signed in as logout: Sign out - show_mascot: Show mascot - show_stars: Show flying stars + show_mascot: Show Glimt ⭐ + show_stars: Show flying stars 💫x action: new: New From 50af132dccc82e58f6bc51641bf797969b9ad40d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 14:22:42 +0200 Subject: [PATCH 24/37] Add interactive platform tour --- assets/controllers/mascot_controller.js | 268 +++++++++++++++++- assets/styles/app.css | 72 +++++ src/Controller/UserSettingsController.php | 21 ++ src/Entity/User.php | 16 ++ templates/base.html.twig | 25 +- templates/dashboard/index.html.twig | 2 + .../Controller/UserSettingsControllerTest.php | 29 ++ tests/Unit/Entity/UserTest.php | 12 + translations/messages.da.yaml | 14 + translations/messages.en.yaml | 14 + 10 files changed, 470 insertions(+), 3 deletions(-) diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js index c5c288b..cd61cba 100644 --- a/assets/controllers/mascot_controller.js +++ b/assets/controllers/mascot_controller.js @@ -15,6 +15,8 @@ export default class extends Controller { "play", "finish", "finishContact", + "tourNext", + "tourSkip", ]; static values = { @@ -32,6 +34,15 @@ export default class extends Controller { farewell: String, welcome: String, intro: String, + tour: { type: Array, default: [] }, + tourPropose: String, + tourStart: String, + tourDecline: String, + tourNext: String, + tourSkip: String, + tourDone: String, + tourSeenUrl: String, + tourSeenToken: String, }; connect() { @@ -47,8 +58,16 @@ export default class extends Controller { // user turns it back on. Resume where the previous page left off so // navigating around doesn't pop a fresh message on every load. if (this.enabledValue) { - const sinceLast = Date.now() - this.lastShownAt; - this.scheduleNext(Math.max(1800, this.intervalValue - sinceLast)); + if (this.tourValue.length > 0) { + // A first-time visitor on the dashboard: offer Glimt's guided tour + // instead of the usual cheer cadence. + this.startTimer("cycle", () => this.proposeTour(), 1400); + } else { + const sinceLast = Date.now() - this.lastShownAt; + this.scheduleNext( + Math.max(1800, this.intervalValue - sinceLast), + ); + } } } @@ -418,6 +437,14 @@ export default class extends Controller { if (this.hasFinishContactTarget) { this.finishContactTarget.hidden = "finishContact" !== action; } + // A cheer message never carries the tour's own buttons; make sure none + // linger after the tour has ended. + if (this.hasTourNextTarget) { + this.tourNextTarget.hidden = true; + } + if (this.hasTourSkipTarget) { + this.tourSkipTarget.hidden = true; + } this.bubbleTarget.hidden = false; window.requestAnimationFrame(() => { this.bubbleTarget.classList.add("is-visible"); @@ -438,6 +465,11 @@ export default class extends Controller { // The × just closes the current bubble; the mascot stays and cheers again later. close() { + if ("tour" === this.mode) { + this.tourSkip(); + + return; + } if ("invited" === this.mode) { this.mode = "idle"; window.clearTimeout(this.inviteTimer); @@ -445,6 +477,238 @@ export default class extends Controller { this.hide(); } + // ---- Guided tour ------------------------------------------------------- + // Offered once to a first-time visitor on the dashboard. Glimt flies to each + // area, spotlights it and explains it; the user steps through with Next (or + // Skip). Either choice marks the tour seen so it is never proposed again. + proposeTour() { + this.mode = "tour"; + this.tourIndex = -1; + this.introduced = true; + this.tourSay(this.tourProposeValue, { + next: this.tourStartValue, + skip: this.tourDeclineValue, + }); + } + + tourNext() { + if ("tour" !== this.mode) { + return; + } + if (-1 === this.tourIndex) { + this.markTourSeen(); + } + this.tourIndex += 1; + if (this.tourIndex >= this.tourValue.length) { + this.endTour(); + + return; + } + this.showTourStep(); + } + + tourSkip() { + this.markTourSeen(); + this.endTour(); + } + + showTourStep() { + const step = this.tourValue[this.tourIndex]; + const last = this.tourIndex === this.tourValue.length - 1; + this.spotlight(step.targets); + if (!this.element.classList.contains("mascot--playing")) { + // Anchor at the avatar's current corner spot before switching to + // free positioning, so the first glide starts from where it sits. + const box = this.avatar.getBoundingClientRect(); + this.element.classList.add("mascot--playing"); + this.moveTo(box.left, box.top); + } + this.element.classList.add("mascot--tour"); + // The opening step has no target, so Glimt grows a little and speaks to + // the user directly. + this.element.classList.toggle("mascot--hero", 0 === this.tourIndex); + this.tourSay(step.text, { + next: last ? this.tourDoneValue : this.tourNextValue, + skip: last ? null : this.tourSkipValue, + }); + // Position after the bubble is laid out so it can be measured and kept + // clear of the spotlighted target. + window.requestAnimationFrame(() => this.positionNear(step)); + } + + endTour() { + this.spotlight(null); + if (this.spotlightEl) { + this.spotlightEl.remove(); + this.spotlightEl = null; + } + this.hide(); + this.element.classList.remove( + "mascot--playing", + "mascot--tour", + "mascot--hero", + ); + this.element.style.left = ""; + this.element.style.top = ""; + this.mode = "idle"; + this.scheduleNext(this.intervalValue); + } + + // The tour bubble carries its own Next/Skip buttons and never auto-hides — + // the user drives it. Hide the cheer-time actions while it is up. + tourSay(text, { next, skip } = {}) { + if (!this.hasTextTarget) { + return; + } + this.textTarget.textContent = text; + for (const target of [ + this.hasCtaTarget && this.ctaTarget, + this.hasPlayTarget && this.playTarget, + this.hasFinishTarget && this.finishTarget, + this.hasFinishContactTarget && this.finishContactTarget, + ]) { + if (target) { + target.hidden = true; + } + } + if (this.hasTourNextTarget) { + this.tourNextTarget.hidden = !next; + if (next) { + this.tourNextTarget.textContent = next; + } + } + if (this.hasTourSkipTarget) { + this.tourSkipTarget.hidden = !skip; + if (skip) { + this.tourSkipTarget.textContent = skip; + } + } + this.clearNamedTimer("show"); + this.bubbleTarget.hidden = false; + window.requestAnimationFrame(() => { + this.bubbleTarget.classList.add("is-visible"); + }); + } + + ensureSpotlight() { + if (!this.spotlightEl) { + this.spotlightEl = document.createElement("div"); + this.spotlightEl.className = "tour-spotlight"; + document.body.appendChild(this.spotlightEl); + } + + return this.spotlightEl; + } + + // One dimming overlay that punches a hole over each target via clip-path. + // Reusing a single element lets the holes glide (and grow/shrink) from section + // to section instead of the dim flashing off and back on. No targets = the + // whole screen dims. + spotlight(targets) { + const sp = this.ensureSpotlight(); + const first = !sp.classList.contains("is-visible"); + const rects = this.rectsFor(targets); + sp.style.clipPath = this.dimPath(rects); + if (first) { + window.requestAnimationFrame(() => sp.classList.add("is-visible")); + } + } + + rectsFor(targets) { + return (targets || []) + .map((selector) => document.querySelector(selector)) + .filter(Boolean) + .map((el) => el.getBoundingClientRect()); + } + + // A full-screen rectangle with up to two rectangular holes (even-odd fill). + // Always two holes — an unused one collapses to a point at the centre — so the + // path keeps a constant shape and animates between steps rather than jumping. + dimPath(rects) { + const w = window.innerWidth; + const h = window.innerHeight; + const pad = 6; + const cx = w / 2; + const cy = h / 2; + const hole = (rect) => { + if (!rect) { + return `M${cx} ${cy} L${cx} ${cy} L${cx} ${cy} L${cx} ${cy} Z`; + } + const x1 = rect.left - pad; + const y1 = rect.top - pad; + const x2 = rect.right + pad; + const y2 = rect.bottom + pad; + return `M${x1} ${y1} L${x2} ${y1} L${x2} ${y2} L${x1} ${y2} Z`; + }; + const outer = `M0 0 L${w} 0 L${w} ${h} L0 ${h} Z`; + + return `path(evenodd, "${outer} ${hole(rects[0])} ${hole(rects[1])}")`; + } + + unionRect(targets) { + const rects = this.rectsFor(targets); + if (0 === rects.length) { + return null; + } + const left = Math.min(...rects.map((r) => r.left)); + const top = Math.min(...rects.map((r) => r.top)); + const right = Math.max(...rects.map((r) => r.right)); + const bottom = Math.max(...rects.map((r) => r.bottom)); + + return { left, top, right, bottom, width: right - left }; + } + + // Place Glimt relative to the step's target area, per the step's placement. + // The bubble grows up-and-left from the avatar, so its measured size drives + // where the avatar lands and keeps the bubble on-screen. + positionNear(step) { + const avatar = 68; + const w = window.innerWidth; + const h = window.innerHeight; + const bubbleW = this.bubbleTarget.offsetWidth || 260; + const bubbleH = this.bubbleTarget.offsetHeight || 130; + const clampLeft = (x) => + Math.max(bubbleW - 56, Math.min(x, w - avatar - 12)); + const area = this.unionRect(step.targets); + const placement = step.placement || "below"; + + let left; + let top; + if ("corner" === placement) { + left = w - avatar - 24; + top = h - avatar - 24; + } else if (!area || "center" === placement) { + left = clampLeft(w / 2 - avatar + bubbleW / 2); + top = Math.max(bubbleH + 32, Math.round(h * 0.42)); + } else if ("right" === placement) { + left = w - avatar - 32; + top = Math.max(bubbleH + 24, Math.round(h / 2)); + } else if ("left" === placement) { + left = clampLeft(area.left - 20 - avatar); + top = Math.max(bubbleH + 16, area.bottom + 16); + } else { + const centre = area.left + area.width / 2; + left = clampLeft(centre - avatar + bubbleW / 2); + top = area.bottom + bubbleH + 22; + } + this.moveTo(left, top); + } + + markTourSeen() { + if (this.tourSeenSent || !this.tourSeenUrlValue) { + return; + } + this.tourSeenSent = true; + fetch(this.tourSeenUrlValue, { + method: "POST", + headers: { + "X-Requested-With": "fetch", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ _token: this.tourSeenTokenValue }), + }).catch(() => {}); + } + // Pick a message different from the last one shown, so it never repeats twice. nextMessage() { const messages = this.messagesValue; diff --git a/assets/styles/app.css b/assets/styles/app.css index 2ade5d3..e3e1602 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -2060,6 +2060,68 @@ textarea { color: var(--itk-ink); } +.mascot__tour-actions { + display: flex; + align-items: center; + gap: var(--itk-space-2); +} + +.mascot__tour-actions:has(> :not([hidden])) { + margin-top: var(--itk-space-3); +} + +.mascot__tour-next { + padding: var(--itk-space-1) var(--itk-space-3); + border: none; + border-radius: var(--itk-radius-2); + background-color: var(--itk-primary); + color: #fff; + font: inherit; + font-size: var(--itk-text-xs); + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s; +} + +.mascot__tour-next:hover { + background-color: var(--itk-primary-hover); +} + +.mascot__tour-skip { + padding: var(--itk-space-1) var(--itk-space-2); + border: none; + background: none; + color: var(--itk-slate-500); + font: inherit; + font-size: var(--itk-text-xs); + cursor: pointer; +} + +.mascot__tour-skip:hover { + color: var(--itk-ink); +} + +.mascot__tour-next[hidden], +.mascot__tour-skip[hidden] { + display: none; +} + +.tour-spotlight { + position: fixed; + inset: 0; + z-index: 150; + background-color: rgba(17, 19, 24, 0.55); + opacity: 0; + pointer-events: none; +} + +.tour-spotlight.is-visible { + opacity: 1; + transition: + clip-path 0.5s ease, + opacity 0.3s ease; +} + .mascot--playing { right: auto; bottom: auto; @@ -2077,6 +2139,16 @@ textarea { max-width: 280px; } +.mascot--tour { + transition: + left 0.6s ease, + top 0.6s ease; +} + +.mascot--hero .mascot__avatar { + transform: scale(1.35); +} + .mascot--chasing { animation: none; transition: none; diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index c203f24..3b7582c 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -52,6 +52,27 @@ public function toggleStars(Request $request, EntityManagerInterface $entityMana return $this->redirect($this->safeReturn($request)); } + /** + * Mark Glimt's guided tour as seen, so it is proposed only once. Answers 204 + * to the mascot's fetch and otherwise redirects back for the no-JS path. + */ + #[Route('/settings/tour/seen', name: 'app_settings_tour_seen', methods: ['POST'])] + public function markTourSeen(Request $request, EntityManagerInterface $entityManager): Response + { + $user = $this->getUser(); + if ($user instanceof User + && $this->isCsrfTokenValid('tour-seen', (string) $request->request->get('_token'))) { + $user->setTourSeen(true); + $entityManager->flush(); + } + + if ('fetch' === $request->headers->get('X-Requested-With')) { + return new Response(null, Response::HTTP_NO_CONTENT); + } + + return $this->redirect($this->safeReturn($request)); + } + /** * Only follow local, relative return paths to avoid open redirects (same * guard as the locale switcher). diff --git a/src/Entity/User.php b/src/Entity/User.php index 400fb9d..afc4327 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -160,6 +160,22 @@ public function setStarsEnabled(bool $enabled): static return $this; } + /** + * Whether the user has been offered Glimt's guided tour of the platform. It + * is proposed once, on the first dashboard visit, then never again. + */ + public function isTourSeen(): bool + { + return (bool) ($this->userSettings['tourSeen'] ?? false); + } + + public function setTourSeen(bool $seen): static + { + $this->userSettings['tourSeen'] = $seen; + + return $this; + } + public function eraseCredentials(): void { } diff --git a/templates/base.html.twig b/templates/base.html.twig index e4181db..f7e49a5 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -24,7 +24,7 @@
@@ -132,6 +132,14 @@ 'mascot.contact.name_only'|trans(contactParams), ] %} {% endif %} + {% set showTour = app.request.attributes.get('_route') == 'app_dashboard' and not app.user.tourSeen %} + {% set tourSteps = [ + {text: 'tour.steps.intro'|trans, targets: [], placement: 'center'}, + {text: 'tour.steps.dashboard'|trans, targets: ['#dashboard-content'], placement: 'right'}, + {text: 'tour.steps.create'|trans, targets: ['#navInitiatives', '.page__actions .btn--primary'], placement: 'below'}, + {text: 'tour.steps.admin'|trans, targets: ['#userMenuToggle'], placement: 'left'}, + {text: 'tour.steps.outro'|trans, targets: [], placement: 'corner'}, + ] %}
+
{% endblock %} diff --git a/tests/Controller/UserSettingsControllerTest.php b/tests/Controller/UserSettingsControllerTest.php index ca31211..3a3119b 100644 --- a/tests/Controller/UserSettingsControllerTest.php +++ b/tests/Controller/UserSettingsControllerTest.php @@ -73,6 +73,27 @@ public function testStarsToggleRedirectsAndFlipsThePreference(): void self::assertFalse($this->reloadEditor()->isStarsEnabled()); } + public function testTourSeenAjaxReturnsNoContentAndPersists(): void + { + $this->loginAsEditor(); + $token = $this->tourSeenToken(); + + $this->client->request('POST', '/settings/tour/seen', ['_token' => $token], [], ['HTTP_X-Requested-With' => 'fetch']); + $this->assertResponseStatusCodeSame(204); + self::assertTrue($this->reloadEditor()->isTourSeen()); + } + + public function testTourSeenPlainFormRedirectsToReturn(): void + { + $this->loginAsEditor(); + $token = $this->tourSeenToken(); + + $this->client->request('POST', '/settings/tour/seen', ['_token' => $token, 'return' => '/initiatives']); + + $this->assertResponseRedirects('/initiatives'); + self::assertTrue($this->reloadEditor()->isTourSeen()); + } + private function mascotToggleToken(): string { $crawler = $this->client->request('GET', '/'); @@ -81,6 +102,14 @@ private function mascotToggleToken(): string return (string) $crawler->filter('#mascotToggleForm input[name="_token"]')->attr('value'); } + private function tourSeenToken(): string + { + $crawler = $this->client->request('GET', '/'); + $this->assertResponseIsSuccessful(); + + return (string) $crawler->filter('.mascot')->attr('data-mascot-tour-seen-token-value'); + } + private function reloadEditor(): User { $this->entityManager()->clear(); diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php index 5e86360..30223ef 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -92,6 +92,18 @@ public function testStarsPreferenceDefaultsEnabledAndToggles(): void self::assertTrue($user->isStarsEnabled()); } + public function testTourSeenDefaultsFalseAndPersists(): void + { + $user = new User(); + + // The guided tour has not been offered to a fresh user yet. + self::assertFalse($user->isTourSeen()); + + $user->setTourSeen(true); + self::assertTrue($user->isTourSeen()); + self::assertSame(['tourSeen' => true], $user->getUserSettings()); + } + public function testEraseCredentialsDoesNothing(): void { $user = new User(); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f1b03f5..565d693 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -47,6 +47,20 @@ mascot: giveup: "Pyha… lad os kalde det uafgjort. 😅" escaped: "Argh, du slap væk! Du er alt for hurtig. 🏳️" +tour: + propose: "Velkommen! Skal jeg vise dig kort rundt på platformen?" + start: Ja tak, vis rundt + decline: Nej tak + next: Næste + skip: Spring over + done: Så er jeg klar + steps: + 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." + outro: "Så er du klar! Jeg er altid lige her i hjørnet, hvis du får brug for et tip. 🌟" + autosave: saving: Gemmer… saved: Gemt diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index e0ac7b9..025ea46 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -47,6 +47,20 @@ mascot: giveup: "Phew… let's call it a draw. 😅" escaped: "Argh, you got away! Too quick for me. 🏳️" +tour: + propose: "Welcome! Shall I give you a quick tour of the platform?" + start: "Yes, show me around" + decline: No thanks + next: Next + skip: Skip + done: I'm ready + steps: + 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." + outro: "You're all set! I'm always right here in the corner if you need a tip. 🌟" + autosave: saving: Saving… saved: Saved From dba97eb1a5f1c53fda214716d90f2ff789368a4b Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 14:28:04 +0200 Subject: [PATCH 25/37] Fixed spacing between Glimt and speech bubble upon scaling --- assets/styles/app.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index e3e1602..ed6fd4c 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -2146,7 +2146,11 @@ textarea { } .mascot--hero .mascot__avatar { - transform: scale(1.35); + transform: scale(1.7); +} + +.mascot--hero .mascot__bubble { + bottom: calc(100% + var(--itk-space-6)); } .mascot--chasing { From 39bb09aa1e05237b564031e23679117fc249ea86 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 14:28:13 +0200 Subject: [PATCH 26/37] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d16fbb3..34c2809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-21](https://github.com/itk-dev/itk-project-database/pull/21) + Add a first-login guided tour where Glimt walks new users through the platform, + dashboard, creating initiatives and the admin panel. * [PR-18](https://github.com/itk-dev/itk-project-database/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. From 1e6333f5b61a2c7fd66b9c397833fcd6a0ef1432 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 15:12:28 +0200 Subject: [PATCH 27/37] Added ITK logo and redesigned login screen --- assets/images/itk-logo.png | Bin 0 -> 32717 bytes assets/styles/app.css | 51 ++++++++++++++++++---------- templates/base.html.twig | 5 ++- templates/dashboard/index.html.twig | 4 +-- templates/security/login.html.twig | 12 +++++-- translations/messages.da.yaml | 6 ++-- translations/messages.en.yaml | 6 ++-- 7 files changed, 54 insertions(+), 30 deletions(-) create mode 100644 assets/images/itk-logo.png diff --git a/assets/images/itk-logo.png b/assets/images/itk-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ca95a6a221d7dedb10ee093868f1eb2f5a6db862 GIT binary patch literal 32717 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 literal 0 HcmV?d00001 diff --git a/assets/styles/app.css b/assets/styles/app.css index c26b4d4..ab827ba 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -129,13 +129,8 @@ h3 { } .top-nav__brand { - font-size: var(--itk-text-base); - font-weight: 700; - color: var(--itk-ink); display: inline-flex; align-items: center; - gap: var(--itk-space-2); - letter-spacing: -0.01em; } .top-nav__brand:hover { @@ -143,19 +138,20 @@ h3 { text-decoration: none; } -.top-nav__brand-mark { - width: 28px; - height: 28px; - border-radius: var(--itk-radius-2); - background: linear-gradient(135deg, var(--itk-blue), var(--itk-cyan)); - display: inline-block; +.top-nav__brand-logo { + height: 26px; + width: auto; + display: block; } .top-nav__menu { display: flex; align-items: center; gap: var(--itk-space-1); - margin-left: var(--itk-space-4); + margin-left: max( + var(--itk-space-4), + calc((100vw - var(--itk-container)) / 2 - 90px) + ); } .top-nav__link { @@ -1549,15 +1545,20 @@ textarea { .auth__brand { display: flex; - align-items: center; - gap: var(--itk-space-2); - font-weight: 700; - font-size: var(--itk-text-md); - margin-bottom: var(--itk-space-2); + justify-content: center; + margin-bottom: var(--itk-space-5); +} + +.auth__logo { + height: 44px; + width: auto; + display: block; } .auth__title { font-size: var(--itk-text-xl); + font-weight: 700; + text-align: center; margin-bottom: var(--itk-space-6); } @@ -1565,6 +1566,22 @@ textarea { margin-bottom: var(--itk-space-4); } +.auth__divider { + border: none; + border-top: 1px solid var(--itk-slate-200); + margin: var(--itk-space-6) 0 var(--itk-space-5); +} + +.auth__help-title { + font-size: var(--itk-text-md); + font-weight: 700; + margin-bottom: var(--itk-space-2); +} + +.auth__help p { + color: var(--itk-slate-500); +} + .admin-layout { display: grid; grid-template-columns: 220px 1fr; diff --git a/templates/base.html.twig b/templates/base.html.twig index 177618c..3f28559 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -18,9 +18,8 @@ {% if app.user %} {% set route = app.request.attributes.get('_route') %}