diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b75412..1db9880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-25](https://github.com/itk-dev/itk-projects/pull/25) + Add partners to initiatives, with an admin CRUD and CSV export. Deleting a + partner now confirms in a dialog that lists and links the initiatives it would + be detached from. + ## [0.2.0] - 2026-06-30 * [PR-23](https://github.com/itk-dev/itk-projects/pull/23) diff --git a/assets/app.js b/assets/app.js index 1c342a4..b1a4922 100644 --- a/assets/app.js +++ b/assets/app.js @@ -117,6 +117,14 @@ function initContactSelect() { ); } +function initPartnerSelect() { + initCreatableSelect( + "[data-partner-select]", + "partnerPool", + (value) => value, + ); +} + // One delegated handler on the document (which survives Turbo navigations and // cache restores) both opens the menu — when the click lands on the toggle — // and closes it on any outside click. Delegation avoids per-page binding, which @@ -142,6 +150,7 @@ document.addEventListener("click", (event) => { document.addEventListener("turbo:load", () => { initCollections(); initContactSelect(); + initPartnerSelect(); initTermSelect(); }); diff --git a/assets/controllers/confirm_delete_controller.js b/assets/controllers/confirm_delete_controller.js new file mode 100644 index 0000000..02be1e7 --- /dev/null +++ b/assets/controllers/confirm_delete_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Guards a destructive submit behind a modal that spells out the consequences — + * a plain confirm() can only carry text, and the point here is to link the + * records that would be affected. Attached to the form; the trigger is a real + * submit button, so with JavaScript off the form still posts unguarded rather + * than the button going dead. showModal() brings Escape and focus trapping. + */ +export default class extends Controller { + static targets = ["dialog"]; + + open(event) { + event.preventDefault(); + this.dialogTarget.showModal(); + } + + cancel() { + this.dialogTarget.close(); + } + + // A modal dialog fills the top layer, so a click on the backdrop reports the + // dialog itself as the target; anything inside reports a descendant. + backdrop(event) { + if (event.target === this.dialogTarget) { + this.dialogTarget.close(); + } + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index f26daac..28119b3 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1595,6 +1595,65 @@ body.is-lightbox-open { overflow: hidden; } +.confirm { + width: min(520px, calc(100vw - var(--itk-space-6))); + margin: auto; + padding: 0; + color: var(--itk-ink); + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + box-shadow: var(--itk-shadow-3); +} + +.confirm::backdrop { + background-color: rgba(17, 19, 24, 0.55); +} + +.confirm__body { + padding: var(--itk-space-5); + font-size: var(--itk-text-sm); +} + +.confirm__body p { + margin: 0; +} + +.confirm__title { + margin: 0 0 var(--itk-space-3); + font-size: var(--itk-text-md); +} + +.confirm__list { + max-height: 240px; + margin: var(--itk-space-3) 0 0; + padding-left: var(--itk-space-5); + overflow-y: auto; +} + +.confirm__list li + li { + margin-top: var(--itk-space-1); +} + +.confirm__body p.confirm__warning { + margin-top: var(--itk-space-4); + color: var(--itk-accent); +} + +.confirm__body p.confirm__warning + p.confirm__warning { + margin-top: var(--itk-space-1); +} + +.confirm__actions { + display: flex; + gap: var(--itk-space-3); + justify-content: flex-end; + padding: var(--itk-space-4) var(--itk-space-5); + background-color: var(--itk-slate-50); + border-top: 1px solid var(--itk-slate-200); + border-radius: 0 0 var(--itk-radius-3) var(--itk-radius-3); +} + .form-actions { display: flex; gap: var(--itk-space-3); diff --git a/migrations/Version20260813090155.php b/migrations/Version20260813090155.php new file mode 100644 index 0000000..705ac50 --- /dev/null +++ b/migrations/Version20260813090155.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE initiative_partner (initiative_id BINARY(16) NOT NULL, partner_id BINARY(16) NOT NULL, INDEX IDX_12D1DC4CAB7D9771 (initiative_id), INDEX IDX_12D1DC4C9393F8FE (partner_id), PRIMARY KEY (initiative_id, partner_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE partner (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, name VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, website VARCHAR(255) DEFAULT NULL, created_by_id BINARY(16) DEFAULT NULL, modified_by_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_312B3E165E237E06 (name), INDEX IDX_312B3E16B03A8386 (created_by_id), INDEX IDX_312B3E1699049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE initiative_partner ADD CONSTRAINT FK_12D1DC4CAB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_partner ADD CONSTRAINT FK_12D1DC4C9393F8FE FOREIGN KEY (partner_id) REFERENCES partner (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE partner ADD CONSTRAINT FK_312B3E16B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE partner ADD CONSTRAINT FK_312B3E1699049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE initiative_partner DROP FOREIGN KEY FK_12D1DC4CAB7D9771'); + $this->addSql('ALTER TABLE initiative_partner DROP FOREIGN KEY FK_12D1DC4C9393F8FE'); + $this->addSql('ALTER TABLE partner DROP FOREIGN KEY FK_312B3E16B03A8386'); + $this->addSql('ALTER TABLE partner DROP FOREIGN KEY FK_312B3E1699049ECE'); + $this->addSql('DROP TABLE initiative_partner'); + $this->addSql('DROP TABLE partner'); + } +} diff --git a/src/Controller/Admin/PartnerController.php b/src/Controller/Admin/PartnerController.php new file mode 100644 index 0000000..0c0897f --- /dev/null +++ b/src/Controller/Admin/PartnerController.php @@ -0,0 +1,97 @@ +findInitiativeUsage(); + + // Pair each partner with its initiatives here rather than looking the usage + // up per row, which would mean keying a Twig array by a Ulid object. + $rows = []; + foreach ($partners->findAllOrdered() as $partner) { + $rows[] = [ + 'partner' => $partner, + 'initiatives' => $usage[(string) $partner->getId()] ?? [], + ]; + } + + return $this->render('admin/partners/index.html.twig', ['rows' => $rows]); + } + + #[Route('/new', name: 'admin_partner_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $partner = new Partner(); + $form = $this->createForm(PartnerType::class, $partner); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->persist($partner); + $entityManager->flush(); + $this->addFlash('success', 'flash.partner.created'); + + return $this->redirectToRoute('admin_partners'); + } + + return $this->render('admin/partners/new.html.twig', ['form' => $form]); + } + + #[Route('/{id}/edit', name: 'admin_partner_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])] + public function edit(Request $request, Partner $partner, EntityManagerInterface $entityManager, PartnerRepository $partners): Response + { + $form = $this->createForm(PartnerType::class, $partner); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + $this->addFlash('success', 'flash.partner.updated'); + + return $this->redirectToRoute('admin_partners'); + } + + return $this->render('admin/partners/edit.html.twig', [ + 'form' => $form, + 'partner' => $partner, + 'initiatives' => $partners->findInitiativesUsing($partner), + ]); + } + + /** + * Deleting a partner also pulls it off every initiative that referenced it — + * `initiative_partner` is cleared by the join table's ON DELETE CASCADE, which + * Doctrine never sees because the association is unidirectional. The admin is + * told which initiatives are affected before confirming. Recording who went + * ahead anyway is still to come: that belongs here, and needs + * findInitiativesUsing() called before the flush destroys the evidence. + */ + #[Route('/{id}/delete', name: 'admin_partner_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])] + public function delete(Request $request, Partner $partner, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-partner-'.$partner->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($partner); + $entityManager->flush(); + $this->addFlash('success', 'flash.partner.deleted'); + } + + return $this->redirectToRoute('admin_partners'); + } +} diff --git a/src/Controller/InitiativeController.php b/src/Controller/InitiativeController.php index 7b2f8d0..7f81362 100644 --- a/src/Controller/InitiativeController.php +++ b/src/Controller/InitiativeController.php @@ -71,7 +71,8 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran $translator->trans('initiative.funding'), $translator->trans('initiative.stakeholders'), $translator->trans('initiative.strategies'), $translator->trans('initiative.tags'), $translator->trans('initiative.time_period_start'), $translator->trans('initiative.time_period_end'), - $translator->trans('initiative.contacts'), $translator->trans('initiative.author'), + $translator->trans('initiative.contacts'), $translator->trans('initiative.partners'), + $translator->trans('initiative.author'), ]); $names = static fn (iterable $items): string => implode(', ', array_map('strval', \is_array($items) ? $items : iterator_to_array($items))); @@ -94,6 +95,7 @@ public function export(Request $request, InitiativeRepository $initiatives, Tran $row->getTimePeriodStart()?->format('Y-m-d'), $row->getTimePeriodEnd()?->format('Y-m-d'), $names($row->getContacts()), + $names($row->getPartners()), ($creator = $row->getCreatedBy()) instanceof User ? $creator->getName() : null, ]); } diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index aa04607..8b118ca 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -8,6 +8,7 @@ use App\Entity\Contact; use App\Entity\Department; use App\Entity\Initiative; +use App\Entity\Partner; use App\Entity\Term; use App\Entity\User; use App\Enum\EndorsementAuthor; @@ -25,6 +26,7 @@ class AppFixtures extends Fixture private const array STAKEHOLDERS = ['Aarhus Kommune', 'Region Midtjylland', 'Aarhus Universitet', 'Erhverv Aarhus', 'Lokale foreninger', 'Boligforeninger', 'VIA University College', 'Business Region Aarhus']; private const array STRATEGIES = ['Klimaplan 2030', 'Erhvervsplan', 'Børn- og ungepolitik', 'Mobilitetsplan', 'Digitaliseringsstrategi', 'Sundhedspolitik']; private const array DEPARTMENTS = ['ITK Development', 'CFIA', 'Aarhus CityLab', 'Stab', 'OS2', 'AI Lab', 'IOT Lab', 'GTM', 'Fut Lab']; + private const array PARTNERS = ['Aarhus Universitet', 'VIA University College', 'Alexandra Instituttet', 'Teknologisk Institut', 'Region Midtjylland', 'Erhverv Aarhus', 'Danmarks Tekniske Universitet', 'Aarhus Vand', 'AffaldVarme Aarhus', 'Dansk Industri']; private const array AREAS = ['Klima og miljø', 'Mobilitet', 'Velfærd', 'Kultur og fritid', 'Uddannelse', 'Erhverv', 'Digitalisering', 'Byudvikling']; public function __construct(private readonly UserPasswordHasherInterface $hasher) @@ -69,6 +71,18 @@ public function load(ObjectManager $manager): void $areas[] = $area; } + $partners = []; + foreach (self::PARTNERS as $name) { + $partner = (new Partner()) + ->setName($name) + ->setDescription($name.' samarbejder med kommunen om udvikling, viden og afprøvning i konkrete initiativer.') + // ascii() turns spaces into dots (it also builds e-mail addresses), which + // a domain does not want, so drop them again. + ->setWebsite('https://www.'.strtolower(str_replace('.', '', $this->ascii($name))).'.dk'); + $manager->persist($partner); + $partners[] = $partner; + } + $contacts = []; $firstNames = ['Anne', 'Mette', 'Lars', 'Søren', 'Camilla', 'Jens', 'Ida', 'Mads', 'Sofie', 'Peter', 'Louise', 'Thomas']; $lastNames = ['Jensen', 'Nielsen', 'Hansen', 'Pedersen', 'Andersen', 'Christensen', 'Larsen', 'Sørensen']; @@ -149,6 +163,9 @@ public function load(ObjectManager $manager): void foreach (\array_slice($this->shuffleCopy($contacts), 0, mt_rand(1, 3)) as $contact) { $initiative->addContact($contact); } + foreach (\array_slice($this->shuffleCopy($partners), 0, mt_rand(1, 3)) as $partner) { + $initiative->addPartner($partner); + } $initiative->setLinks(['https://www.aarhus.dk']); diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index a274d9c..e2f995c 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -71,6 +71,12 @@ class Initiative extends AbstractEntity #[ORM\JoinTable(name: 'initiative_contact')] private Collection $contacts; + /** @var Collection */ + #[Assert\Valid] + #[ORM\ManyToMany(targetEntity: Partner::class, cascade: ['persist'])] + #[ORM\JoinTable(name: 'initiative_partner')] + private Collection $partners; + /** @var Collection */ #[ORM\OneToMany(targetEntity: InitiativeImage::class, mappedBy: 'initiative', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $images; @@ -116,6 +122,7 @@ public function __construct() parent::__construct(); $this->strategies = new ArrayCollection(); $this->contacts = new ArrayCollection(); + $this->partners = new ArrayCollection(); $this->stakeholders = new ArrayCollection(); $this->tags = new ArrayCollection(); $this->images = new ArrayCollection(); @@ -285,6 +292,28 @@ public function removeContact(Contact $contact): static return $this; } + /** @return Collection */ + public function getPartners(): Collection + { + return $this->partners; + } + + public function addPartner(Partner $partner): static + { + if (!$this->partners->contains($partner)) { + $this->partners->add($partner); + } + + return $this; + } + + public function removePartner(Partner $partner): static + { + $this->partners->removeElement($partner); + + return $this; + } + /** @return Collection */ public function getImages(): Collection { diff --git a/src/Entity/Partner.php b/src/Entity/Partner.php new file mode 100644 index 0000000..cbdd072 --- /dev/null +++ b/src/Entity/Partner.php @@ -0,0 +1,74 @@ +name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getWebsite(): ?string + { + return $this->website; + } + + public function setWebsite(?string $website): static + { + $this->website = $website; + + return $this; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Form/DataTransformer/PartnersTextTransformer.php b/src/Form/DataTransformer/PartnersTextTransformer.php new file mode 100644 index 0000000..635b4af --- /dev/null +++ b/src/Form/DataTransformer/PartnersTextTransformer.php @@ -0,0 +1,68 @@ + + */ +final readonly class PartnersTextTransformer implements DataTransformerInterface +{ + public function __construct(private PartnerRepository $partnerRepository) + { + } + + public function transform(mixed $value): string + { + if (!is_iterable($value)) { + return ''; + } + + $names = []; + foreach ($value as $partner) { + if ($partner instanceof Partner) { + $names[] = $partner->getName(); + } + } + + return implode(', ', $names); + } + + /** + * @return Collection + */ + public function reverseTransform(mixed $value): Collection + { + $partners = new ArrayCollection(); + + if (!\is_string($value) || '' === trim($value)) { + return $partners; + } + + $seen = []; + foreach (explode(',', $value) as $name) { + $name = trim($name); + $key = mb_strtolower($name); + if ('' === $name || isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $partners->add($this->partnerRepository->findOrCreate($name)); + } + + return $partners; + } +} diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index 29a8d35..df9453d 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -164,6 +164,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'initiative.terms_help', ]) + ->add('partners', PartnersTextType::class, [ + 'label' => 'initiative.partners', + 'required' => false, + 'help' => 'initiative.partners_help', + ]) ->add('images', CollectionType::class, [ 'label' => 'initiative.images', 'entry_type' => InitiativeImageType::class, diff --git a/src/Form/PartnerType.php b/src/Form/PartnerType.php new file mode 100644 index 0000000..b04b014 --- /dev/null +++ b/src/Form/PartnerType.php @@ -0,0 +1,47 @@ + + */ +class PartnerType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('name', TextType::class, [ + 'label' => 'partner.name', + 'help' => 'partner.name_help', + ]) + ->add('description', TextareaType::class, [ + 'label' => 'partner.description', + 'required' => false, + 'attr' => ['rows' => 4], + 'help' => 'partner.description_help', + ]) + ->add('website', UrlType::class, [ + 'label' => 'partner.website', + 'required' => false, + 'default_protocol' => 'https', + 'help' => 'partner.website_help', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Partner::class, + ]); + } +} diff --git a/src/Form/PartnersTextType.php b/src/Form/PartnersTextType.php new file mode 100644 index 0000000..004dc2c --- /dev/null +++ b/src/Form/PartnersTextType.php @@ -0,0 +1,63 @@ + + */ +final class PartnersTextType extends AbstractType +{ + public function __construct(private readonly PartnerRepository $partnerRepository) + { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new PartnersTextTransformer($this->partnerRepository)); + } + + /** + * Expose the existing partners so the client can offer them as a searchable + * pool (and let new ones join it). The names are rendered as a JSON data + * attribute the Tom Select initialiser reads. + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $pool = array_map( + static fn (Partner $partner): string => (string) $partner->getName(), + $this->partnerRepository->findAllOrdered(), + ); + + $view->vars['attr'] = array_merge($view->vars['attr'], [ + 'data-partner-select' => '', + 'data-partner-pool' => json_encode($pool, \JSON_THROW_ON_ERROR), + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'invalid_message' => 'form.terms.invalid', + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 18ce917..13a93cc 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -144,7 +144,7 @@ public function findForExport(InitiativeFilter $filter): array return []; } - foreach (['strategies', 'stakeholders', 'tags', 'contacts'] as $association) { + foreach (['strategies', 'stakeholders', 'tags', 'contacts', 'partners'] as $association) { $this->createQueryBuilder('i') ->addSelect('rel') ->leftJoin('i.'.$association, 'rel') diff --git a/src/Repository/PartnerRepository.php b/src/Repository/PartnerRepository.php new file mode 100644 index 0000000..28ae748 --- /dev/null +++ b/src/Repository/PartnerRepository.php @@ -0,0 +1,121 @@ + + * + * @extends ServiceEntityRepository + */ +class PartnerRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Partner::class); + } + + /** + * @return Partner[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('p') + ->orderBy('p.name', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Which initiatives reference each partner, keyed by partner id. Answers the + * whole admin list in one query so the delete confirmation can name the + * initiatives a partner would be pulled off. + * + * @return array + */ + public function findInitiativeUsage(): array + { + // Initiative owns the (unidirectional) association, so usage can only be + // asked from that side — Partner has no inverse collection to traverse. + // Selecting the joined p.id (rather than IDENTITY()) is what makes Doctrine + // apply the ULID type instead of handing back the raw binary FK. + /** @var list $rows */ + $rows = $this->getEntityManager()->createQueryBuilder() + ->select('p.id AS partnerId', 'i.id AS id', 'i.title AS title') + ->from(Initiative::class, 'i') + ->innerJoin('i.partners', 'p') + ->orderBy('i.title', 'ASC') + ->getQuery() + ->getArrayResult(); + + $usage = []; + foreach ($rows as $row) { + $usage[(string) $row['partnerId']][] = [ + 'id' => (string) $row['id'], + 'title' => $row['title'], + ]; + } + + return $usage; + } + + /** + * The initiatives referencing a single partner. Read this *before* removing the + * partner: `initiative_partner` is cleared by the join table's ON DELETE + * CASCADE, so after the flush there is nothing left to report. + * + * @return PartnerUsage + */ + public function findInitiativesUsing(Partner $partner): array + { + /** @var list $rows */ + $rows = $this->getEntityManager()->createQueryBuilder() + ->select('i.id AS id', 'i.title AS title') + ->from(Initiative::class, 'i') + ->innerJoin('i.partners', 'p') + ->andWhere('p.id = :partner') + ->setParameter('partner', $partner->getId(), 'ulid') + ->orderBy('i.title', 'ASC') + ->getQuery() + ->getArrayResult(); + + return array_map(static fn (array $row): array => [ + 'id' => (string) $row['id'], + 'title' => $row['title'], + ], $rows); + } + + /** + * Return an existing partner matched on name (case-insensitive) or a new, + * unflushed one. Lets partners be picked from the shared pool or typed in on + * the fly; the extra fields (description, website) are filled in later under + * the partners admin. + */ + public function findOrCreate(string $name): Partner + { + $name = trim($name); + + $existing = $this->createQueryBuilder('p') + ->andWhere('LOWER(p.name) = :name') + ->setParameter('name', mb_strtolower($name)) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if ($existing instanceof Partner) { + return $existing; + } + + $partner = (new Partner())->setName($name); + $this->getEntityManager()->persist($partner); + + return $partner; + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 6fc9042..878155e 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -8,6 +8,7 @@ {{ 'nav.users'|trans }} {% endif %} {{ 'nav.contacts'|trans }} + {{ 'nav.partners'|trans }} {{ 'nav.departments'|trans }} {{ 'nav.areas'|trans }} diff --git a/templates/admin/partners/_delete.html.twig b/templates/admin/partners/_delete.html.twig new file mode 100644 index 0000000..5213437 --- /dev/null +++ b/templates/admin/partners/_delete.html.twig @@ -0,0 +1,28 @@ +{% set count = initiatives|length %} +
+ + {# A real submit button, so the form still posts if the dialog never opens. #} + + + +
+

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

+ {% if count > 0 %} +

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

+ +

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

+

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

+ {% else %} +

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

+ {% endif %} +
+
+ + +
+
+
diff --git a/templates/admin/partners/_form.html.twig b/templates/admin/partners/_form.html.twig new file mode 100644 index 0000000..6bcad63 --- /dev/null +++ b/templates/admin/partners/_form.html.twig @@ -0,0 +1,12 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.name) }} + {{ form_row(form.description) }} + {{ form_row(form.website) }} +
+
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/admin/partners/edit.html.twig b/templates/admin/partners/edit.html.twig new file mode 100644 index 0000000..bcad6ab --- /dev/null +++ b/templates/admin/partners/edit.html.twig @@ -0,0 +1,13 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + {{ include('admin/partners/_delete.html.twig', {partner: partner, initiatives: initiatives}) }} + + {{ include('admin/partners/_form.html.twig', {button_label: 'action.save'}) }} +{% endblock %} diff --git a/templates/admin/partners/index.html.twig b/templates/admin/partners/index.html.twig new file mode 100644 index 0000000..f9f1b53 --- /dev/null +++ b/templates/admin/partners/index.html.twig @@ -0,0 +1,44 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ 'action.new'|trans }} + + +
+ {% if rows is empty %} + + {% else %} +
+ + + + + + + + + + + {% for row in rows %} + {% set partner = row.partner %} + + + + + + + {% endfor %} + +
{{ 'partner.name'|trans }}{{ 'partner.website'|trans }}{{ 'partner.usage'|trans }}
{{ partner.name }}{% if partner.website %}{{ partner.website }}{% else %}—{% endif %}{% if row.initiatives is empty %}{{ 'partner.usage_none'|trans }}{% else %}{{ 'partner.usage_count'|trans({'%count%': row.initiatives|length}) }}{% endif %} +
+ {{ 'action.edit'|trans }} + {{ include('admin/partners/_delete.html.twig', {partner: partner, initiatives: row.initiatives, small: true}) }} +
+
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/partners/new.html.twig b/templates/admin/partners/new.html.twig new file mode 100644 index 0000000..0cee7c0 --- /dev/null +++ b/templates/admin/partners/new.html.twig @@ -0,0 +1,12 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'partner.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + + {{ include('admin/partners/_form.html.twig', {button_label: 'action.create'}) }} +{% endblock %} diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 3cecdae..ef27bd9 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -102,6 +102,8 @@
{{ 'initiative.section.relations'|trans }}
{{ form_row(form.stakeholders) }} + {{ form_row(form.partners) }} + {{ form_row(form.contacts) }} diff --git a/templates/initiative/show.html.twig b/templates/initiative/show.html.twig index 1102366..df1c2bd 100644 --- a/templates/initiative/show.html.twig +++ b/templates/initiative/show.html.twig @@ -62,6 +62,25 @@ +
+ +
+ {% if initiative.partners|length > 0 %} + {% for partner in initiative.partners %} +
+
{{ partner.name }}
+ {% if partner.description %}
{{ partner.description }}
{% endif %} + {% if partner.website %} + + {% endif %} +
+ {% endfor %} + {% else %} +

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

+ {% endif %} +
+
+ {% if initiative.images|length > 0 or initiative.attachments|length > 0 %}
diff --git a/tests/Controller/Admin/PartnerControllerTest.php b/tests/Controller/Admin/PartnerControllerTest.php new file mode 100644 index 0000000..a34ed6e --- /dev/null +++ b/tests/Controller/Admin/PartnerControllerTest.php @@ -0,0 +1,222 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/partners'); + + $this->assertResponseIsSuccessful(); + } + + public function testIndexDeleteDialogLinksTheAffectedInitiatives(): void + { + $this->loginAsAdmin(); + $partner = $this->createPartner('Linked Partner '.uniqid()); + $initiative = $this->createInitiativeUsing($partner, 'Linked Initiative '.uniqid()); + + $crawler = $this->client->request('GET', '/admin/partners'); + $this->assertResponseIsSuccessful(); + + // The confirmation has to name what deleting would strip the partner off, and + // link straight to it — the count alone doesn't tell the admin what breaks. + $link = $crawler->filter(sprintf('dialog a[href="/initiatives/%s"]', $initiative->getId())); + self::assertCount(1, $link); + self::assertSame($initiative->getTitle(), trim($link->text())); + + $this->removeInitiative((string) $initiative->getId()); + $this->removePartner((string) $partner->getId()); + } + + public function testNewCreatesPartner(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/partners/new'); + $this->assertResponseIsSuccessful(); + + $name = 'Test Partner '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form([ + 'partner[name]' => $name, + 'partner[description]' => 'A partner created in a test.', + 'partner[website]' => 'https://example.com', + ]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/partners'); + + $partner = $this->partners()->findOneBy(['name' => $name]); + self::assertInstanceOf(Partner::class, $partner); + self::assertSame('A partner created in a test.', $partner->getDescription()); + self::assertSame('https://example.com', $partner->getWebsite()); + + $this->removePartner((string) $partner->getId()); + } + + public function testNewRejectsADuplicateName(): void + { + $this->loginAsAdmin(); + $name = 'Duplicate Partner '.uniqid(); + $id = (string) $this->createPartner($name)->getId(); + + $crawler = $this->client->request('GET', '/admin/partners/new'); + $form = $crawler->filter('button.btn--primary')->form(['partner[name]' => $name]); + $this->client->submit($form); + + // UniqueEntity rejects the second one: the form redisplays with a 422 + // (Symfony's status for an invalid submitted form) and nothing is saved. + $this->assertResponseStatusCodeSame(422); + self::assertCount(1, $this->partners()->findBy(['name' => $name])); + + $this->removePartner($id); + } + + public function testNewRejectsANonHttpWebsite(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/partners/new'); + + $name = 'Bad Website Partner '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form([ + 'partner[name]' => $name, + 'partner[website]' => 'javascript:alert(1)', + ]); + $this->client->submit($form); + + $this->assertResponseStatusCodeSame(422); + self::assertCount(0, $this->partners()->findBy(['name' => $name])); + } + + public function testNewRejectsANameContainingAComma(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/partners/new'); + + // Comma is the separator of the free-tagging field on the initiative form, + // so such a name would later be split into two partners. + $name = 'Aarhus Kommune, Teknik og Miljø '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form(['partner[name]' => $name]); + $this->client->submit($form); + + $this->assertResponseStatusCodeSame(422); + self::assertCount(0, $this->partners()->findBy(['name' => $name])); + } + + public function testEditUpdatesPartner(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createPartner('Editable Partner '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/partners/%s/edit', $id)); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form(['partner[name]' => 'Edited Partner '.uniqid()]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/partners'); + $this->removePartner($id); + } + + public function testDeleteRemovesPartnerWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createPartner('Deletable Partner '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/partners/%s/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/partners'); + $this->entityManager()->clear(); + self::assertNull($this->partners()->find($id)); + } + + public function testDeleteDetachesThePartnerButKeepsTheInitiative(): void + { + $this->loginAsAdmin(); + $partner = $this->createPartner('Detachable Partner '.uniqid()); + $partnerId = (string) $partner->getId(); + $initiative = $this->createInitiativeUsing($partner, 'Surviving Initiative '.uniqid()); + $initiativeId = (string) $initiative->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/partners/%s/edit', $partnerId)); + self::assertStringContainsString($initiative->getTitle(), (string) $this->client->getResponse()->getContent()); + + $this->client->submit($crawler->filter('form[action$="/delete"]')->form()); + $this->assertResponseRedirects('/admin/partners'); + + // The join table is cleared by its ON DELETE CASCADE rather than by Doctrine, + // so pin both halves: the partner is gone, the initiative is not. + $this->entityManager()->clear(); + self::assertNull($this->partners()->find($partnerId)); + $survivor = $this->initiatives()->find($initiativeId); + self::assertInstanceOf(Initiative::class, $survivor); + self::assertCount(0, $survivor->getPartners()); + + $this->removeInitiative($initiativeId); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createPartner('Surviving Partner '.uniqid())->getId(); + + $this->client->request('POST', sprintf('/admin/partners/%s/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/admin/partners'); + $this->entityManager()->clear(); + self::assertNotNull($this->partners()->find($id)); + $this->removePartner($id); + } + + private function createPartner(string $name): Partner + { + $partner = (new Partner())->setName($name); + $em = $this->entityManager(); + $em->persist($partner); + $em->flush(); + + return $partner; + } + + private function createInitiativeUsing(Partner $partner, string $title): Initiative + { + $initiative = (new Initiative())->setTitle($title); + $initiative->addPartner($partner); + $em = $this->entityManager(); + $em->persist($initiative); + $em->flush(); + + return $initiative; + } + + private function removePartner(string $id): void + { + $this->entityManager()->clear(); + $partner = $this->partners()->find($id); + if (null !== $partner) { + $em = $this->entityManager(); + $em->remove($partner); + $em->flush(); + } + } + + private function removeInitiative(string $id): void + { + $this->entityManager()->clear(); + $initiative = $this->initiatives()->find($id); + if (null !== $initiative) { + $em = $this->entityManager(); + $em->remove($initiative); + $em->flush(); + } + } +} diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index f3b1f4f..a47d03d 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -24,6 +24,8 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): 'title' => 'Coverage initiative', // A typed name creates a new contact on the fly and attaches it. 'contacts' => 'Coverage Contact', + // Same free-tagging behaviour for partners. + 'partners' => 'Coverage Partner', '_token' => $token, ], ]); @@ -34,6 +36,7 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): $initiative = $this->initiatives()->findOneBy(['title' => 'Coverage initiative']); self::assertInstanceOf(Initiative::class, $initiative); self::assertGreaterThanOrEqual(1, $initiative->getContacts()->count(), 'Inline contact should be merged in.'); + self::assertGreaterThanOrEqual(1, $initiative->getPartners()->count(), 'Inline partner should be merged in.'); $em->remove($initiative); $em->flush(); @@ -41,6 +44,9 @@ public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): foreach ($this->contacts()->findBy(['name' => 'Coverage Contact']) as $contact) { $em->remove($contact); } + foreach ($this->partners()->findBy(['name' => 'Coverage Partner']) as $partner) { + $em->remove($partner); + } $em->flush(); } diff --git a/tests/Controller/SmokeTest.php b/tests/Controller/SmokeTest.php index 02abec7..02eb87a 100644 --- a/tests/Controller/SmokeTest.php +++ b/tests/Controller/SmokeTest.php @@ -96,6 +96,8 @@ public static function authenticatedPages(): iterable yield 'admin user new' => ['/admin/users/new']; yield 'admin contacts' => ['/admin/contacts']; yield 'admin contact new' => ['/admin/contacts/new']; + yield 'admin partners' => ['/admin/partners']; + yield 'admin partner new' => ['/admin/partners/new']; yield 'admin departments' => ['/admin/departments']; yield 'admin department new' => ['/admin/departments/new']; } diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 0e1d48a..a235d81 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -9,6 +9,7 @@ use App\Repository\ContactRepository; use App\Repository\DepartmentRepository; use App\Repository\InitiativeRepository; +use App\Repository\PartnerRepository; use App\Repository\TermRepository; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; @@ -62,6 +63,14 @@ protected function contacts(): ContactRepository return $repository; } + protected function partners(): PartnerRepository + { + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + + return $repository; + } + protected function departments(): DepartmentRepository { $repository = static::getContainer()->get(DepartmentRepository::class); diff --git a/tests/Repository/PartnerRepositoryTest.php b/tests/Repository/PartnerRepositoryTest.php new file mode 100644 index 0000000..f4f5f7e --- /dev/null +++ b/tests/Repository/PartnerRepositoryTest.php @@ -0,0 +1,122 @@ +get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + // A shared prefix keeps the two apart from whatever else the fixtures hold, + // and they are persisted in reverse so the ordering cannot come from + // insertion order. + $prefix = 'Ordered '.uniqid().' '; + $second = (new Partner())->setName($prefix.'B'); + $first = (new Partner())->setName($prefix.'A'); + $em->persist($second); + $em->persist($first); + $em->flush(); + + $names = array_values(array_filter( + array_map(static fn (Partner $partner): string => (string) $partner->getName(), $repository->findAllOrdered()), + static fn (string $name): bool => str_starts_with($name, $prefix), + )); + self::assertSame([$prefix.'A', $prefix.'B'], $names); + + $em->remove($first); + $em->remove($second); + $em->flush(); + } + + public function testFindOrCreateReturnsAnExistingPartnerCaseInsensitively(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $name = 'Findme Partner '.uniqid(); + $partner = (new Partner())->setName($name); + $em->persist($partner); + $em->flush(); + + $found = $repository->findOrCreate(mb_strtolower($name)); + self::assertSame($partner->getId(), $found->getId()); + + $em->remove($partner); + $em->flush(); + } + + public function testFindInitiativeUsageNamesTheReferencingInitiatives(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $partner = (new Partner())->setName('Usage Partner '.uniqid()); + $initiative = (new Initiative())->setTitle('Usage Initiative '.uniqid()); + $initiative->addPartner($partner); + $em->persist($partner); + $em->persist($initiative); + $em->flush(); + + $expected = [['id' => (string) $initiative->getId(), 'title' => $initiative->getTitle()]]; + self::assertSame($expected, $repository->findInitiativesUsing($partner)); + + // The bulk variant backing the admin list must agree with the single lookup. + $usage = $repository->findInitiativeUsage(); + self::assertSame($expected, $usage[(string) $partner->getId()] ?? []); + + $em->remove($initiative); + $em->remove($partner); + $em->flush(); + } + + public function testFindInitiativeUsageOmitsUnusedPartners(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $partner = (new Partner())->setName('Unused Partner '.uniqid()); + $em->persist($partner); + $em->flush(); + + self::assertSame([], $repository->findInitiativesUsing($partner)); + self::assertArrayNotHasKey((string) $partner->getId(), $repository->findInitiativeUsage()); + + $em->remove($partner); + $em->flush(); + } + + public function testFindOrCreateBuildsANewUnflushedPartner(): void + { + self::bootKernel(); + $repository = static::getContainer()->get(PartnerRepository::class); + \assert($repository instanceof PartnerRepository); + + $name = 'BrandNewPartner-'.uniqid(); + $partner = $repository->findOrCreate($name); + + self::assertSame($name, $partner->getName()); + self::assertCount(0, $repository->findBy(['name' => $name]), 'A freshly created partner is not yet flushed to the database.'); + } +} diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index 9613b3f..7a65c6c 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -10,6 +10,7 @@ use App\Entity\Initiative; use App\Entity\InitiativeAttachment; use App\Entity\InitiativeImage; +use App\Entity\Partner; use App\Entity\Term; use App\Enum\EndorsementAuthor; use App\Enum\Funding; @@ -32,6 +33,7 @@ public function testDefaults(): void self::assertCount(0, $initiative->getStakeholders()); self::assertCount(0, $initiative->getTags()); self::assertCount(0, $initiative->getContacts()); + self::assertCount(0, $initiative->getPartners()); self::assertCount(0, $initiative->getImages()); self::assertCount(0, $initiative->getAttachments()); self::assertNull($initiative->getCreatedAt()); @@ -203,6 +205,19 @@ public function testContactCollection(): void self::assertCount(0, $initiative->getContacts()); } + public function testPartnerCollection(): void + { + $initiative = new Initiative(); + $partner = (new Partner())->setName('Aarhus Universitet'); + + $initiative->addPartner($partner); + $initiative->addPartner($partner); + self::assertCount(1, $initiative->getPartners()); + + $initiative->removePartner($partner); + self::assertCount(0, $initiative->getPartners()); + } + public function testImageCollectionLinksBackToInitiative(): void { $initiative = new Initiative(); diff --git a/tests/Unit/Entity/PartnerTest.php b/tests/Unit/Entity/PartnerTest.php new file mode 100644 index 0000000..1a51a87 --- /dev/null +++ b/tests/Unit/Entity/PartnerTest.php @@ -0,0 +1,37 @@ +getName()); + self::assertNull($partner->getDescription()); + self::assertNull($partner->getWebsite()); + // Timestamps are populated by the bundle's listener on flush, so they + // are still null on a freshly constructed (unpersisted) entity. + self::assertNull($partner->getCreatedAt()); + self::assertSame('', (string) $partner); + } + + public function testAccessors(): void + { + $partner = (new Partner()) + ->setName('Aarhus Universitet') + ->setDescription('Forsknings- og uddannelsesinstitution.') + ->setWebsite('https://www.au.dk'); + + self::assertSame('Aarhus Universitet', $partner->getName()); + self::assertSame('Forsknings- og uddannelsesinstitution.', $partner->getDescription()); + self::assertSame('https://www.au.dk', $partner->getWebsite()); + self::assertSame('Aarhus Universitet', (string) $partner); + } +} diff --git a/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php new file mode 100644 index 0000000..15dda9b --- /dev/null +++ b/tests/Unit/Form/DataTransformer/PartnersTextTransformerTest.php @@ -0,0 +1,56 @@ +transformer()->transform(null)); + } + + public function testTransformJoinsPartnerNames(): void + { + $partners = [ + (new Partner())->setName('Aarhus Universitet'), + (new Partner())->setName('Alexandra Instituttet'), + ]; + + self::assertSame('Aarhus Universitet, Alexandra Instituttet', $this->transformer()->transform($partners)); + } + + public function testReverseTransformOfNonStringReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(null)); + } + + public function testReverseTransformOfBlankReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(' ')); + } + + public function testReverseTransformTrimsDeduplicatesAndResolvesPartners(): void + { + $repository = $this->createMock(PartnerRepository::class); + $repository->expects(self::exactly(2)) + ->method('findOrCreate') + ->willReturnCallback(static fn (string $name): Partner => (new Partner())->setName($name)); + + $transformer = new PartnersTextTransformer($repository); + + // "aarhus universitet" duplicates "Aarhus Universitet" (case-insensitive) and the empty segment is skipped. + self::assertCount(2, $transformer->reverseTransform('Aarhus Universitet, Alexandra Instituttet, , aarhus universitet')); + } + + private function transformer(): PartnersTextTransformer + { + return new PartnersTextTransformer($this->createStub(PartnerRepository::class)); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index e1cdcbe..f1da462 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -58,7 +58,7 @@ tour: intro: "Det her er Projektdatabasen — her samler vi afdelingernes initiativer og projekter ét sted, så vi kan se, hvad der sker på tværs." dashboard: "På overblikket ser du nøgletal, status og eventuelle muligheder for samarbejds på tværs af afdelinger." create: "Se alle initiativer via menupunktet Initiativer — eller opret et nyt med det samme via Opret-knappen." - admin: "Inde i menuen her finder du menupunktet Administration, hvor du kan oprette, se og redigere afdelinger, områder og kontaktpersoner." + admin: "Inde i menuen her finder du menupunktet Administration, hvor du kan oprette, se og redigere afdelinger, områder, samarbejdspartnere og kontaktpersoner." outro: "Så er du klar! Jeg er altid lige her i hjørnet, hvis du får brug for et tip. 🌟" autosave: @@ -83,6 +83,7 @@ nav: dashboard: Overblik initiatives: Initiativer contacts: Kontaktpersoner + partners: Samarbejdspartnere departments: Afdelinger areas: Områder users: Brugere @@ -212,6 +213,8 @@ initiative: time_period: Tidshorisont links: Relevante links contacts: Kontaktpersoner + partners: Samarbejdspartnere + partners_help: Vælg en eksisterende samarbejdspartner, eller skriv en ny og tryk Enter. author: Udfyldt af terms_help: Vælg eksisterende, eller skriv en ny og tryk Enter. completion_field_hint: Dette felt tæller med i udfyldningsgraden. @@ -230,9 +233,11 @@ initiative: show: details: Detaljer contacts: Kontaktpersoner + partners: Samarbejdspartnere classification: Klassificering media: Filer og billeder no_contacts: Ingen kontaktpersoner tilknyttet. + no_partners: Ingen samarbejdspartnere tilknyttet. empty: title: Ingen initiativer fundet hint: Prøv at justere dine filtre, eller opret et nyt initiativ. @@ -241,7 +246,7 @@ initiative: classification: Klassificering endorsement: Vedtagelse economy: Økonomi og tid - relations: Interessenter og kontakter + relations: Interessenter, samarbejdspartnere og kontakter media: Filer og billeder meta: Øvrigt @@ -265,6 +270,36 @@ contact: title: Ingen kontaktpersoner endnu hint: Opret en kontaktperson, eller tilføj en direkte på et initiativ. +partner: + name: Navn + name_help: Samarbejdspartnerens navn, som det vises på initiativer. + name_duplicate: Der findes allerede en samarbejdspartner med dette navn. + name_comma: Navnet må ikke indeholde komma. + description: Beskrivelse + description_help: En kort beskrivelse af samarbejdspartneren. + website: Hjemmeside + website_help: Link til samarbejdspartnerens hjemmeside, hvis den findes. + usage: Bruges i + usage_none: Ikke i brug + usage_count: "{1}1 initiativ|]1,Inf[%count% initiativer" + delete: + title: 'Slet "%name%"?' + in_use: "{1}Samarbejdspartneren er tilknyttet ét initiativ:|]1,Inf[Samarbejdspartneren er tilknyttet %count% initiativer:" + unused: Samarbejdspartneren er ikke tilknyttet nogen initiativer. + consequence: Sletningen fjerner samarbejdspartneren fra initiativerne ovenfor. + irreversible: Denne handling kan ikke fortrydes. + confirm: Slet alligevel + index: + title: Samarbejdspartnere + subtitle: Samarbejdspartnere der kan tilknyttes initiativer. + new: + title: Ny samarbejdspartner + edit: + title: Rediger samarbejdspartner + empty: + title: Ingen samarbejdspartnere endnu + hint: Opret en samarbejdspartner, eller tilføj en direkte på et initiativ. + department: name: Navn name_help: Afdelingens navn, som det vises på initiativer. @@ -334,6 +369,10 @@ flash: created: Kontaktpersonen blev oprettet. updated: Kontaktpersonen blev opdateret. deleted: Kontaktpersonen blev slettet. + partner: + created: Samarbejdspartneren blev oprettet. + updated: Samarbejdspartneren blev opdateret. + deleted: Samarbejdspartneren blev slettet. department: created: Afdelingen blev oprettet. updated: Afdelingen blev opdateret. diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 607bf6d..b0e08ea 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -58,7 +58,7 @@ tour: intro: "This is the Project database — where we gather the departments' initiatives and projects in one place, so we can see what's happening across them." dashboard: "The overview shows key figures, status and any opportunities for collaboration across departments." create: "Browse every initiative from the Initiativer menu — or create a new one right away with the Opret button." - admin: "Inside this menu you'll find Administration, where you can create, view and edit departments, areas and contacts." + admin: "Inside this menu you'll find Administration, where you can create, view and edit departments, areas, partners and contacts." outro: "You're all set! I'm always right here in the corner if you need a tip. 🌟" autosave: @@ -83,6 +83,7 @@ nav: dashboard: Overview initiatives: Initiatives contacts: Contacts + partners: Partners departments: Departments areas: Areas users: Users @@ -212,6 +213,8 @@ initiative: time_period: Time period links: Relevant links contacts: Contacts + partners: Partners + partners_help: Choose an existing partner, or type a new and press Enter. author: Filled out by terms_help: Choose an existing one, or type a new and press Enter. completion_field_hint: This field counts towards the completion rate. @@ -230,9 +233,11 @@ initiative: show: details: Details contacts: Contacts + partners: Partners classification: Classification media: Files and images no_contacts: No contacts attached. + no_partners: No partners attached. empty: title: No initiatives found hint: Try adjusting your filters, or create a new initiative. @@ -241,7 +246,7 @@ initiative: classification: Classification endorsement: Endorsement economy: Economy and time - relations: Stakeholders and contacts + relations: Stakeholders, partners and contacts media: Files and images meta: Other @@ -265,6 +270,36 @@ contact: title: No contacts yet hint: Create a contact, or add one directly on an initiative. +partner: + name: Name + name_help: The partner's name as shown on initiatives. + name_duplicate: A partner with this name already exists. + name_comma: The name cannot contain a comma. + description: Description + description_help: A brief description of the partner. + website: Website + website_help: Link to the partner's website, if any. + usage: Used in + usage_none: Not in use + usage_count: "{1}1 initiative|]1,Inf[%count% initiatives" + delete: + title: 'Delete "%name%"?' + in_use: "{1}This partner is attached to one initiative:|]1,Inf[This partner is attached to %count% initiatives:" + unused: This partner is not attached to any initiatives. + consequence: Deleting removes the partner from the initiatives above. + irreversible: This action cannot be undone. + confirm: Delete anyway + index: + title: Partners + subtitle: Partners that can be attached to initiatives. + new: + title: New partner + edit: + title: Edit partner + empty: + title: No partners yet + hint: Create a partner, or add one directly on an initiative. + department: name: Name name_help: The department name as shown on initiatives. @@ -334,6 +369,10 @@ flash: created: The contact was created. updated: The contact was updated. deleted: The contact was deleted. + partner: + created: The partner was created. + updated: The partner was updated. + deleted: The partner was deleted. department: created: The department was created. updated: The department was updated.