From 46cd337f9ea4137e6e1855884295b6416dc9fc21 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 18:43:35 +0200 Subject: [PATCH 01/13] 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 02/13] 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 191436806e7ad928c484fd5f32a09058f581ef1d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Sat, 27 Jun 2026 20:44:23 +0200 Subject: [PATCH 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 f800a4f8b35f4eb5221436c4b0cadc80d43d125b Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 13:20:13 +0200 Subject: [PATCH 11/13] Enable live upload and overhauled upload form and image preview --- assets/app.js | 15 +- assets/controllers/autosave_controller.js | 152 ++++++++---- assets/controllers/file_field_controller.js | 92 +++++++ assets/controllers/lightbox_controller.js | 141 +++++++++++ assets/styles/app.css | 231 +++++++++++++++++- src/Form/InitiativeImageType.php | 5 - templates/form/fields.html.twig | 17 ++ templates/initiative/_form.html.twig | 99 +++++--- templates/initiative/show.html.twig | 4 +- tests/Controller/InitiativeControllerTest.php | 39 ++- translations/messages.da.yaml | 7 +- translations/messages.en.yaml | 7 +- 12 files changed, 699 insertions(+), 110 deletions(-) create mode 100644 assets/controllers/file_field_controller.js create mode 100644 assets/controllers/lightbox_controller.js diff --git a/assets/app.js b/assets/app.js index 5295070..1c342a4 100644 --- a/assets/app.js +++ b/assets/app.js @@ -24,7 +24,14 @@ function initCollections() { remove.className = "btn btn--danger btn--sm"; remove.dataset.collectionRemove = ""; remove.textContent = collection.dataset.removeLabel || "Remove"; - remove.addEventListener("click", () => item.remove()); + remove.addEventListener("click", () => { + item.remove(); + // Autosave is the only save path now — tell it the form changed so + // the removed row is persisted (and the media frame re-renders). + collection.dispatchEvent( + new Event("change", { bubbles: true }), + ); + }); item.appendChild(remove); }; @@ -137,3 +144,9 @@ document.addEventListener("turbo:load", () => { initContactSelect(); initTermSelect(); }); + +// A turbo-frame swap (the media section reloading after a file upload) replaces +// its collections, so re-bind the add/remove buttons on the fresh markup. +document.addEventListener("turbo:frame-load", () => { + initCollections(); +}); diff --git a/assets/controllers/autosave_controller.js b/assets/controllers/autosave_controller.js index fc20068..3af38c6 100644 --- a/assets/controllers/autosave_controller.js +++ b/assets/controllers/autosave_controller.js @@ -6,11 +6,13 @@ import { Controller } from "@hotwired/stimulus"; * On the edit form it posts changes to the edit endpoint without re-rendering, * so focus and caret are never lost. On the new form (isNew) the first valid * save creates the initiative and the controller swaps to editing that record - * in place (URL + action). File uploads are skipped on autosave and left for an - * explicit Save, which keeps the request equivalent to a manual save. + * in place (URL + action). Picking a file uploads it straight away via the same + * POST; afterwards the media turbo-frame is reloaded so the stored file shows as + * a link and its (now redundant) input is cleared — without that the file would + * linger in the input and re-upload on every later keystroke. */ export default class extends Controller { - static targets = ["status", "statusText", "save"]; + static targets = ["status", "statusText"]; static values = { debounce: { type: Number, default: 800 }, @@ -26,27 +28,25 @@ export default class extends Controller { 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 }, }; initialize() { this.timer = null; - this.inFlight = null; - this.creating = false; + this.xhr = null; + this.busy = false; } disconnect() { window.clearTimeout(this.timer); - this.inFlight?.abort(); + this.xhr?.abort(); } schedule(event) { - // Picking a file can't autosave, but it reveals the Save button to upload it. - this.revealSaveForFiles(); - - // Files are uploaded only on an explicit Save. + // A picked file uploads on its own — save right away, no debounce. if (event?.target?.type === "file") { + window.clearTimeout(this.timer); + this.save(); return; } // No "unsaved" text — the animated pen icon carries that state. @@ -64,25 +64,16 @@ export default class extends Controller { return; } - // A picked-but-unsaved file is left for Save so we never half-upload it. - if (this.hasPendingFile()) { - this.setStatus(this.filesHintTextValue, "unsaved"); - return; - } - - // Hold off while a brand-new initiative is being created so we never - // POST the create twice; the next change saves it once it exists. - if (this.creating) { + // A create or a file upload must run to completion; don't start a second + // save on top of one (it would double-create the draft or cut the upload). + if (this.busy) { return; } - // Edits can supersede an in-flight request; a create must run to completion. - const creating = this.isNewValue; - if (!creating) { - this.inFlight?.abort(); - } - this.inFlight = new AbortController(); - this.creating = creating; + const withFiles = this.hasPendingFile(); + // Supersede any in-flight plain edit; the new POST carries the whole form. + this.xhr?.abort(); + this.busy = this.isNewValue || withFiles; // Reveal the saving spinner only for slow saves (> 2s); a quick save jumps // straight to "Gemt" with no flicker. @@ -91,21 +82,26 @@ export default class extends Controller { 2000, ); - try { - const response = await fetch(this.element.action, { - method: "POST", - body: new FormData(this.element), - headers: { - "X-Autosave": "1", - "X-Requested-With": "XMLHttpRequest", - }, - credentials: "same-origin", - signal: this.inFlight.signal, - }); + // Picking a file drives a progress bar in the upload field via these events. + if (withFiles) { + this.dispatch("uploadstart", { target: document }); + } + let ok = false; - if (201 === response.status) { + try { + const { status, location } = await this.request( + withFiles + ? (percent) => + this.dispatch("uploadprogress", { + target: document, + detail: { percent }, + }) + : null, + ); + + if (201 === status) { + ok = true; // The draft now exists — edit it in place from here on. - const location = response.headers.get("X-Initiative-Location"); if (location) { this.element.action = location; this.isNewValue = false; @@ -120,12 +116,19 @@ export default class extends Controller { `${this.savedTextValue} · ${this.timestamp()}`, "saved", ); - } else if (204 === response.status) { + if (withFiles) { + this.refreshMedia(); + } + } else if (204 === status) { + ok = true; this.setStatus( `${this.savedTextValue} · ${this.timestamp()}`, "saved", ); - } else if (422 === response.status) { + if (withFiles) { + this.refreshMedia(); + } + } else if (422 === status) { this.setStatus(this.errorTextValue, "error"); } else { this.setStatus(this.offlineTextValue, "error"); @@ -136,10 +139,57 @@ export default class extends Controller { } } finally { window.clearTimeout(savingTimer); - this.creating = false; + this.busy = false; + this.xhr = null; + if (withFiles) { + this.dispatch("uploadend", { + target: document, + detail: { ok }, + }); + } } } + // POST the whole form via XHR so file uploads can report real progress. + // Resolves with the status and the X-Initiative-Location header (if any); + // rejects with an AbortError when superseded. + request(onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + this.xhr = xhr; + xhr.open("POST", this.element.action, true); + xhr.setRequestHeader("X-Autosave", "1"); + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + + if (onProgress && xhr.upload) { + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + onProgress( + Math.round((event.loaded / event.total) * 100), + ); + } + }); + } + + xhr.addEventListener("load", () => + resolve({ + status: xhr.status, + location: xhr.getResponseHeader("X-Initiative-Location"), + }), + ); + xhr.addEventListener("error", () => + reject(new Error("Network error")), + ); + xhr.addEventListener("abort", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }); + + xhr.send(new FormData(this.element)); + }); + } + hasPendingFile() { return Array.from( this.element.querySelectorAll('input[type="file"]'), @@ -152,10 +202,18 @@ export default class extends Controller { ).some((field) => "" === field.value.trim()); } - // Files only upload on an explicit Save, so surface that button while one is staged. - revealSaveForFiles() { - if (this.hasSaveTarget) { - this.saveTarget.hidden = !this.hasPendingFile(); + // Reload just the files/images section so a freshly uploaded file shows as a + // link and its input is reset — the rest of the form keeps its state. + refreshMedia() { + const frame = document.getElementById("initiative-media"); + if (!frame) { + return; + } + if (frame.src) { + frame.reload(); + } else { + // First reload also gives the frame its source (the edit URL). + frame.src = this.element.action; } } diff --git a/assets/controllers/file_field_controller.js b/assets/controllers/file_field_controller.js new file mode 100644 index 0000000..8558c11 --- /dev/null +++ b/assets/controllers/file_field_controller.js @@ -0,0 +1,92 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Drives a single upload field's transient UI: once a file is picked it shows + * the name and a progress bar that the autosave controller fills as the form + * (with the file) uploads. On success the media turbo-frame reloads and the + * server-rendered "uploaded" view replaces this; on failure the bar is hidden + * so the user can try again. + */ +export default class extends Controller { + static targets = ["filename", "progress", "bar"]; + + connect() { + this.onStart = this.onStart.bind(this); + this.onProgress = this.onProgress.bind(this); + this.onEnd = this.onEnd.bind(this); + document.addEventListener("autosave:uploadstart", this.onStart); + document.addEventListener("autosave:uploadprogress", this.onProgress); + document.addEventListener("autosave:uploadend", this.onEnd); + } + + disconnect() { + document.removeEventListener("autosave:uploadstart", this.onStart); + document.removeEventListener( + "autosave:uploadprogress", + this.onProgress, + ); + document.removeEventListener("autosave:uploadend", this.onEnd); + } + + get input() { + return this.element.querySelector('input[type="file"]'); + } + + get pending() { + const input = this.input; + return Boolean(input && input.files && input.files.length > 0); + } + + picked() { + if (!this.pending) { + return; + } + if (this.hasFilenameTarget) { + this.filenameTarget.textContent = this.input.files[0].name; + } + this.show(0); + } + + onStart() { + if (this.pending) { + this.show(0); + } + } + + onProgress(event) { + if (this.pending) { + this.show(event.detail.percent); + } + } + + onEnd(event) { + if (!this.pending) { + return; + } + if (event.detail && event.detail.ok) { + // Hold full while the media frame reloads and replaces this field. + this.setBar(100); + } else { + this.hide(); + } + } + + show(percent) { + if (this.hasProgressTarget) { + this.progressTarget.hidden = false; + } + this.setBar(percent); + } + + setBar(percent) { + if (this.hasBarTarget) { + this.barTarget.style.width = `${percent}%`; + } + } + + hide() { + if (this.hasProgressTarget) { + this.progressTarget.hidden = true; + } + } +} diff --git a/assets/controllers/lightbox_controller.js b/assets/controllers/lightbox_controller.js new file mode 100644 index 0000000..58130d6 --- /dev/null +++ b/assets/controllers/lightbox_controller.js @@ -0,0 +1,141 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Opens an image preview in an in-page overlay instead of a new tab. Attached to + * the image link; the href is the full-size source. The scroll wheel zooms in and + * out; once zoomed, the image can be dragged to pan. Closes on the backdrop, the + * close button or Escape, and restores focus to the link that opened it. + */ +export default class extends Controller { + static values = { + src: String, + closeLabel: { type: String, default: "Close" }, + }; + + open(event) { + event.preventDefault(); + const src = this.srcValue || this.element.getAttribute("href"); + if (!src) { + return; + } + + const overlay = document.createElement("div"); + overlay.className = "lightbox"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const close = document.createElement("button"); + close.type = "button"; + close.className = "lightbox__close"; + close.setAttribute("aria-label", this.closeLabelValue); + close.textContent = "×"; + + const img = document.createElement("img"); + img.className = "lightbox__img"; + img.src = src; + img.alt = ""; + + overlay.append(close, img); + + const min = 1; + const max = 4; + let zoom = 1; + + const applyZoom = () => { + if (zoom <= 1) { + img.classList.remove("lightbox__img--zoomed"); + img.style.removeProperty("width"); + img.style.removeProperty("max-width"); + img.style.removeProperty("max-height"); + return; + } + // Capture the fitted width before lifting the size constraints, so the + // zoom factor is relative to what the user actually sees. + if (!img.dataset.baseWidth) { + img.dataset.baseWidth = String(img.clientWidth); + } + img.classList.add("lightbox__img--zoomed"); + img.style.maxWidth = "none"; + img.style.maxHeight = "none"; + img.style.width = `${Number(img.dataset.baseWidth) * zoom}px`; + }; + + const onWheel = (event) => { + event.preventDefault(); + // Normalise wheel deltas (line/page modes report small integers) and + // zoom multiplicatively, so a trackpad gesture doesn't jump to max. + let delta = event.deltaY; + if (1 === event.deltaMode) { + delta *= 16; + } else if (2 === event.deltaMode) { + delta *= window.innerHeight; + } + const previous = zoom; + zoom = Math.min( + max, + Math.max(min, zoom * Math.exp(-delta * 0.001)), + ); + if (zoom !== previous) { + applyZoom(); + } + }; + + let dragging = false; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + const onDown = (event) => { + if (zoom <= 1 || 0 !== event.button) { + return; + } + dragging = true; + startX = event.clientX; + startY = event.clientY; + startLeft = overlay.scrollLeft; + startTop = overlay.scrollTop; + img.style.cursor = "grabbing"; + event.preventDefault(); + }; + const onMove = (event) => { + if (!dragging) { + return; + } + overlay.scrollLeft = startLeft - (event.clientX - startX); + overlay.scrollTop = startTop - (event.clientY - startY); + }; + const onUp = () => { + dragging = false; + img.style.removeProperty("cursor"); + }; + + const dismiss = () => { + overlay.remove(); + document.removeEventListener("keydown", onKey); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + document.body.classList.remove("is-lightbox-open"); + this.element.focus(); + }; + const onKey = (event) => { + if ("Escape" === event.key) { + dismiss(); + } + }; + + overlay.addEventListener("click", (event) => { + if (event.target === overlay || event.target === close) { + dismiss(); + } + }); + overlay.addEventListener("wheel", onWheel, { passive: false }); + img.addEventListener("mousedown", onDown); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + document.addEventListener("keydown", onKey); + + document.body.classList.add("is-lightbox-open"); + document.body.appendChild(overlay); + close.focus(); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 7a11e2e..8180910 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1242,14 +1242,54 @@ textarea { gap: var(--itk-space-3); } +.btn-add { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: var(--itk-space-2); + padding: var(--itk-space-2) var(--itk-space-3); + font: inherit; + font-size: var(--itk-text-sm); + font-weight: 600; + color: var(--itk-slate-600); + background: none; + border: 1px dashed var(--itk-slate-300); + border-radius: var(--itk-radius-2); + cursor: pointer; + transition: + border-color 0.15s, + background-color 0.15s, + color 0.15s; +} + +.btn-add:hover { + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 5%, transparent); + border-color: var(--itk-blue); +} + +.btn-add svg { + width: 16px; + height: 16px; +} + .collection__item { display: flex; gap: var(--itk-space-3); - align-items: flex-start; - padding: var(--itk-space-3); - border: 1px solid var(--itk-slate-200); - border-radius: var(--itk-radius-2); - background-color: var(--itk-slate-50); + align-items: center; +} + +[data-collection-remove] { + flex: none; + color: var(--itk-slate-500); + background: none; + border-color: transparent; +} + +[data-collection-remove]:hover { + color: var(--itk-danger); + background-color: color-mix(in srgb, var(--itk-danger) 8%, transparent); + border-color: transparent; } .collection__item .form-grid { @@ -1264,6 +1304,187 @@ textarea { flex: 1; } +.upload-field { + display: flex; + flex-direction: column; + gap: var(--itk-space-2); +} + +.collection__item > .upload-field { + flex: 1; + min-width: 0; +} + +.upload-field__drop { + display: flex; + align-items: center; + gap: var(--itk-space-2); + min-height: 64px; + padding: var(--itk-space-3); + border: 1px dashed var(--itk-slate-300); + border-radius: var(--itk-radius-2); + background-color: var(--itk-surface); + color: var(--itk-slate-500); + font-size: var(--itk-text-sm); + cursor: pointer; + transition: + border-color 0.15s, + background-color 0.15s, + color 0.15s; +} + +.upload-field__drop:hover { + border-color: var(--itk-blue); + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 5%, transparent); +} + +.upload-field__icon { + width: 18px; + height: 18px; + flex: none; +} + +.upload-field__cta { + font-weight: 600; +} + +.upload-field__filename { + overflow: hidden; + color: var(--itk-slate-700); + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-field__drop input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.upload-field__progress { + height: 6px; + overflow: hidden; + background-color: var(--itk-slate-100); + border-radius: var(--itk-radius-pill); +} + +.upload-field__bar { + width: 0; + height: 100%; + background-color: var(--itk-blue); + border-radius: inherit; + transition: width 0.2s ease; +} + +.upload-done { + display: flex; + align-items: center; + gap: var(--itk-space-3); + min-height: 64px; + padding: var(--itk-space-2); + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); +} + +.collection__item--uploaded .upload-field { + display: none; +} + +.upload-done__thumb { + display: block; + flex: none; + width: 48px; + height: 48px; + overflow: hidden; + cursor: pointer; + background-color: var(--itk-slate-100); + border-radius: var(--itk-radius-1); + transition: opacity 0.15s; +} + +.upload-done__thumb:hover { + opacity: 0.85; +} + +.upload-done__thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.upload-done__name { + overflow: hidden; + font-size: var(--itk-text-sm); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-done__name:first-child { + margin-left: var(--itk-space-2); +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 300; + padding: var(--itk-space-6); + overflow: auto; + text-align: center; + white-space: nowrap; + background-color: rgba(17, 19, 24, 0.82); +} + +.lightbox::before { + display: inline-block; + height: 100%; + vertical-align: middle; + content: ""; +} + +.lightbox__img { + display: inline-block; + max-width: 100%; + max-height: 100%; + vertical-align: middle; + border-radius: var(--itk-radius-2); + box-shadow: var(--itk-shadow-3); +} + +.lightbox__img--zoomed { + cursor: grab; +} + +.lightbox__close { + position: fixed; + top: var(--itk-space-4); + right: var(--itk-space-5); + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + font-size: 28px; + line-height: 1; + color: #fff; + cursor: pointer; + background: none; + border: none; +} + +body.is-lightbox-open { + overflow: hidden; +} + .form-actions { display: flex; gap: var(--itk-space-3); diff --git a/src/Form/InitiativeImageType.php b/src/Form/InitiativeImageType.php index 0dba605..8447153 100644 --- a/src/Form/InitiativeImageType.php +++ b/src/Form/InitiativeImageType.php @@ -8,7 +8,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\FileType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; @@ -34,10 +33,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'constraints' => [ new Assert\Image(maxSize: $this->maxImageSize), ], - ]) - ->add('alt', TextType::class, [ - 'label' => 'initiative.image_alt', - 'required' => false, ]); } diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig index e280112..653fc1d 100644 --- a/templates/form/fields.html.twig +++ b/templates/form/fields.html.twig @@ -54,3 +54,20 @@ {%- endif -%} {% endblock %} + +{% block file_widget %} +
+ + +
+{% endblock %} diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index a62f318..3cecdae 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -16,7 +16,6 @@ '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', }) %} {% endif %} @@ -91,7 +90,10 @@ {% endfor %} - + @@ -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 12/13] 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 7f023619a6bd3cb84af5affd786a7819a1b67571 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 15:41:32 +0200 Subject: [PATCH 13/13] Exclude Vedtagelse from udfyldningsgrad, and unselect Er initiativet vedtaget by default --- src/Entity/Initiative.php | 5 ++--- tests/Unit/Entity/InitiativeTest.php | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index d9c48bd..559dcf7 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -27,7 +27,7 @@ class Initiative extends AbstractEntity */ public const array COMPLETION_FIELDS = [ 'title', 'area', 'description', 'initiativeType', 'status', - 'organizationalAnchoring', 'endorsementAuthor', + 'organizationalAnchoring', 'budget', 'funding', 'timePeriodStart', 'timePeriodEnd', ]; @@ -61,7 +61,7 @@ class Initiative extends AbstractEntity private ?Department $organizationalAnchoring = null; #[ORM\Column] - private bool $endorsement = true; + private bool $endorsement = false; #[ORM\Column(length: 32, nullable: true, enumType: EndorsementAuthor::class)] private ?EndorsementAuthor $endorsementAuthor = null; @@ -491,7 +491,6 @@ public function getCompletionPercentage(): int null !== $this->initiativeType, null !== $this->status, null !== $this->organizationalAnchoring, - null !== $this->endorsementAuthor, null !== $this->budget, [] !== $this->funding, null !== $this->timePeriodStart, diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index 97a5f2c..9925ac1 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -25,7 +25,7 @@ public function testDefaults(): void $initiative = new Initiative(); self::assertNull($initiative->getTitle()); - self::assertTrue($initiative->isEndorsement()); + self::assertFalse($initiative->isEndorsement()); self::assertSame([], $initiative->getFunding()); self::assertSame([], $initiative->getLinks()); self::assertCount(0, $initiative->getStrategies()); @@ -86,12 +86,13 @@ public function testCompletionPercentage(): void ->setInitiativeType(InitiativeType::Project) ->setStatus(Status::Active) ->setOrganizationalAnchoring((new Department())->setName('Teknik og Miljø')) - ->setEndorsementAuthor(EndorsementAuthor::CityCouncil) ->setBudget(1000) ->setFunding([Funding::EuFunds]) ->setTimePeriodStart(new \DateTimeImmutable()) ->setTimePeriodEnd(new \DateTimeImmutable()); + // The Vedtagelse (endorsement) fields are intentionally excluded, so this + // reaches 100% without setting an endorsement author. self::assertSame(100, $full->getCompletionPercentage()); }