diff --git a/CHANGELOG.md b/CHANGELOG.md index d397b7c..5d9f1b8 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-41](https://github.com/itk-dev/itk-projects/pull/41) + Let a project have several types, picked from an admin-managed pool + instead of a fixed list. * [PR-33](https://github.com/itk-dev/itk-projects/pull/33) Let a project be anchored in several departments, picked with a searchable multiselect. diff --git a/assets/app.js b/assets/app.js index 4b9e80e..4d83cea 100644 --- a/assets/app.js +++ b/assets/app.js @@ -180,23 +180,26 @@ function initPartnerSelect() { ); } -// Departments are a fixed, admin-managed pool, so the project form's -// becomes a searchable chip multiselect +// without `create`. // Tom Select reads the options and the selection from the select itself and // fires input/change on it, which is what autosave and the progress bar listen for. -function initDepartmentSelect() { - document.querySelectorAll("[data-department-select]").forEach((select) => { - if (select.dataset.bound) { - return; - } - select.dataset.bound = "1"; +function initPoolSelect() { + document + .querySelectorAll("[data-department-select], [data-type-select]") + .forEach((select) => { + if (select.dataset.bound) { + return; + } + select.dataset.bound = "1"; - new TomSelect(select, { - plugins: ["remove_button"], - hideSelected: true, - maxOptions: null, + new TomSelect(select, { + plugins: ["remove_button"], + hideSelected: true, + maxOptions: null, + }); }); - }); } // One delegated handler on the document (which survives Turbo navigations and @@ -225,7 +228,7 @@ document.addEventListener("turbo:load", () => { initCollections(); initContactSelect(); initPartnerSelect(); - initDepartmentSelect(); + initPoolSelect(); initTermSelect(); }); diff --git a/migrations/Version20260930065548.php b/migrations/Version20260930065548.php new file mode 100644 index 0000000..5ca695d --- /dev/null +++ b/migrations/Version20260930065548.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE project_type (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, name VARCHAR(255) NOT NULL, created_by_id BINARY(16) DEFAULT NULL, modified_by_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_B54F9F315E237E06 (name), INDEX IDX_B54F9F31B03A8386 (created_by_id), INDEX IDX_B54F9F3199049ECE (modified_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE project_project_type (project_id BINARY(16) NOT NULL, project_type_id BINARY(16) NOT NULL, INDEX IDX_9FE87B52166D1F9C (project_id), INDEX IDX_9FE87B52535280F6 (project_type_id), PRIMARY KEY (project_id, project_type_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE project_type ADD CONSTRAINT FK_B54F9F31B03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE project_type ADD CONSTRAINT FK_B54F9F3199049ECE FOREIGN KEY (modified_by_id) REFERENCES `user` (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE project_project_type ADD CONSTRAINT FK_9FE87B52166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_project_type ADD CONSTRAINT FK_9FE87B52535280F6 FOREIGN KEY (project_type_id) REFERENCES project_type (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project DROP project_type'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_project_type DROP FOREIGN KEY FK_9FE87B52166D1F9C'); + $this->addSql('ALTER TABLE project_project_type DROP FOREIGN KEY FK_9FE87B52535280F6'); + $this->addSql('ALTER TABLE project_type DROP FOREIGN KEY FK_B54F9F31B03A8386'); + $this->addSql('ALTER TABLE project_type DROP FOREIGN KEY FK_B54F9F3199049ECE'); + $this->addSql('DROP TABLE project_project_type'); + $this->addSql('DROP TABLE project_type'); + $this->addSql('ALTER TABLE project ADD project_type VARCHAR(32) DEFAULT NULL'); + } +} diff --git a/src/Controller/Admin/ProjectTypeController.php b/src/Controller/Admin/ProjectTypeController.php new file mode 100644 index 0000000..fe77077 --- /dev/null +++ b/src/Controller/Admin/ProjectTypeController.php @@ -0,0 +1,78 @@ +render('admin/project_types/index.html.twig', [ + 'types' => $types->findAllOrdered(), + ]); + } + + #[Route('/new', name: 'admin_project_type_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $type = new ProjectType(); + $form = $this->createForm(ProjectTypeType::class, $type); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->persist($type); + $entityManager->flush(); + $this->addFlash('success', 'flash.project_type.created'); + + return $this->redirectToRoute('admin_project_types'); + } + + return $this->render('admin/project_types/new.html.twig', ['form' => $form]); + } + + #[Route('/{id}/edit', name: 'admin_project_type_edit', requirements: ['id' => Requirement::ULID], methods: ['GET', 'POST'])] + public function edit(Request $request, ProjectType $type, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(ProjectTypeType::class, $type); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + $this->addFlash('success', 'flash.project_type.updated'); + + return $this->redirectToRoute('admin_project_types'); + } + + return $this->render('admin/project_types/edit.html.twig', [ + 'form' => $form, + 'type' => $type, + ]); + } + + #[Route('/{id}/delete', name: 'admin_project_type_delete', requirements: ['id' => Requirement::ULID], methods: ['POST'])] + public function delete(Request $request, ProjectType $type, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-project-type-'.$type->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($type); + $entityManager->flush(); + $this->addFlash('success', 'flash.project_type.deleted'); + } + + return $this->redirectToRoute('admin_project_types'); + } +} diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 1d5e1db..25b1d4e 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -69,7 +69,7 @@ public function export(Request $request, ProjectRepository $projects, Translator $translator->trans('project.topic'), $translator->trans('project.status'), $translator->trans('project.area'), - $translator->trans('project.project_type'), + $translator->trans('project.types'), $translator->trans('project.organizational_anchoring'), $translator->trans('project.endorsement'), $translator->trans('project.endorsement_author'), @@ -93,7 +93,7 @@ public function export(Request $request, ProjectRepository $projects, Translator $row->getTopic(), $translate($row->getStatus()), $row->getArea()?->getName(), - $translate($row->getProjectType()), + $names($row->getTypes()), $names($row->getOrganizationalAnchoring()), $row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'), $translate($row->getEndorsementAuthor()), diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index f982053..9812f0b 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -9,11 +9,11 @@ use App\Entity\Department; use App\Entity\Partner; use App\Entity\Project; +use App\Entity\ProjectType; use App\Entity\Term; use App\Entity\User; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\Vocabulary; use Doctrine\Bundle\FixturesBundle\Fixture; @@ -27,6 +27,7 @@ class AppFixtures extends Fixture private const array DEPARTMENTS = ['ITK Development', 'CFIA', 'Aarhus CityLab', 'Stab', 'OS2', 'AI Lab', 'IOT Lab', 'GTM', 'Fut Lab']; private const array PARTNERS = ['Aarhus Universitet', 'VIA University College', 'Alexandra Instituttet', 'Teknologisk Institut', 'Region Midtjylland', 'Erhverv Aarhus', 'Danmarks Tekniske Universitet', 'Aarhus Vand', 'AffaldVarme Aarhus', 'Dansk Industri']; private const array AREAS = ['Klima og miljø', 'Mobilitet', 'Velfærd', 'Kultur og fritid', 'Uddannelse', 'Erhverv', 'Digitalisering', 'Byudvikling']; + private const array TYPES = ['Projekt', 'Program', 'Politik', 'Pilot', 'Drift']; private const array TOPICS = [ 'Digital Europe Blueprint for Data Space for smart and sustainable cities and communities.', 'Horizon Europe — Climate-neutral and smart cities mission.', @@ -75,6 +76,13 @@ public function load(ObjectManager $manager): void $areas[] = $area; } + $types = []; + foreach (self::TYPES as $name) { + $type = (new ProjectType())->setName($name); + $manager->persist($type); + $types[] = $type; + } + $partners = []; foreach (self::PARTNERS as $name) { $partner = (new Partner()) @@ -129,7 +137,6 @@ public function load(ObjectManager $manager): void ]; $statuses = Status::cases(); - $types = ProjectType::cases(); $endorsers = EndorsementAuthor::cases(); $fundings = Funding::cases(); @@ -137,7 +144,6 @@ public function load(ObjectManager $manager): void $project = (new Project()) ->setTitle($title) ->setArea($areas[array_rand($areas)]) - ->setProjectType($types[array_rand($types)]) ->setStatus($statuses[array_rand($statuses)]) ->setSummary('Projektet arbejder med '.mb_strtolower($title).' gennem en tværgående indsats med fokus på borgernes hverdag og kommunens strategiske mål.') ->setDescription('Projektet er sat i gang, fordi kommunen har brug for at styrke indsatsen omkring '.mb_strtolower($title).".\n\nDet har ophæng i byrådets vedtagne strategier og i afdelingens handleplaner og gennemføres i samarbejde med relevante fagområder og eksterne partnere.") @@ -156,6 +162,12 @@ public function load(ObjectManager $manager): void $project->setFunding(\array_slice($this->shuffleCopy($fundings), 0, mt_rand(1, 3))); + // Most projects are one thing; every fourth is two, e.g. a pilot that is + // also operations, so the multiselect has something to show. + foreach (\array_slice($this->shuffleCopy($types), 0, 0 === $index % 4 ? 2 : 1) as $type) { + $project->addType($type); + } + // Roughly a third of the projects span two departments so the dashboard's // cross-department views have something to show. foreach (\array_slice($this->shuffleCopy($departments), 0, 0 === $index % 3 ? 2 : 1) as $department) { diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 73476b4..73adbb2 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -6,7 +6,6 @@ use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Repository\ProjectRepository; use Doctrine\Common\Collections\ArrayCollection; @@ -22,13 +21,14 @@ class Project extends AbstractEntity * Fields that count toward {@see getCompletionPercentage()} and the client-side * progress bar. Limited to the project's own columns so list rendering stays * query-free; the booleans and the free-tagging lists are intentionally excluded. - * Departments are the one collection counted: they are a fixed, admin-managed - * pool and the anchoring is core to what a project is. + * Types and departments are the two collections counted: both are fixed, + * admin-managed pools, and what a project is and where it is anchored are + * core to describing it. * * @var list */ public const array COMPLETION_FIELDS = [ - 'title', 'area', 'summary', 'description', 'projectType', 'status', + 'title', 'area', 'summary', 'description', 'types', 'status', 'organizationalAnchoring', 'budget', 'funding', 'timePeriodStart', 'timePeriodEnd', ]; @@ -62,8 +62,15 @@ class Project extends AbstractEntity #[ORM\JoinTable(name: 'project_strategy')] private Collection $strategies; - #[ORM\Column(length: 32, nullable: true, enumType: ProjectType::class)] - private ?ProjectType $projectType = null; + /** + * What kind of thing the project is ("Projekt", "Drift", …). A project can be + * several at once, e.g. a pilot that is also operations. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: ProjectType::class)] + #[ORM\JoinTable(name: 'project_project_type')] + private Collection $types; #[ORM\Column(length: 32, nullable: true, enumType: Status::class)] private ?Status $status = null; @@ -133,6 +140,7 @@ public function __construct() { parent::__construct(); $this->organizationalAnchoring = new ArrayCollection(); + $this->types = new ArrayCollection(); $this->strategies = new ArrayCollection(); $this->contacts = new ArrayCollection(); $this->partners = new ArrayCollection(); @@ -232,14 +240,35 @@ public function setStrategies(iterable $terms): static return $this; } - public function getProjectType(): ?ProjectType + /** @return Collection */ + public function getTypes(): Collection { - return $this->projectType; + return $this->types; } - public function setProjectType(?ProjectType $projectType): static + public function addType(ProjectType $type): static { - $this->projectType = $projectType; + if (!$this->types->contains($type)) { + $this->types->add($type); + } + + return $this; + } + + public function removeType(ProjectType $type): static + { + $this->types->removeElement($type); + + return $this; + } + + /** @param iterable $types */ + public function setTypes(iterable $types): static + { + $this->types->clear(); + foreach ($types as $type) { + $this->addType($type); + } return $this; } @@ -485,8 +514,8 @@ static function (string $link): bool { /** * Share of {@see COMPLETION_FIELDS} that are filled in, as a 0–100 percentage. - * Reads own columns plus the department collection (one lazy load), so it is - * cheap enough to call per row in a listing. + * Reads own columns plus the type and department collections (one lazy + * load each), so it is cheap enough to call per row in a listing. */ public function getCompletionPercentage(): int { @@ -495,7 +524,7 @@ public function getCompletionPercentage(): int null !== $this->area, null !== $this->summary && '' !== $this->summary, null !== $this->description && '' !== $this->description, - null !== $this->projectType, + !$this->types->isEmpty(), null !== $this->status, !$this->organizationalAnchoring->isEmpty(), null !== $this->budget, diff --git a/src/Entity/ProjectType.php b/src/Entity/ProjectType.php new file mode 100644 index 0000000..3621587 --- /dev/null +++ b/src/Entity/ProjectType.php @@ -0,0 +1,41 @@ +name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Enum/ProjectType.php b/src/Enum/ProjectType.php deleted file mode 100644 index 5166b24..0000000 --- a/src/Enum/ProjectType.php +++ /dev/null @@ -1,23 +0,0 @@ -value; - } -} diff --git a/src/Form/ProjectFilterType.php b/src/Form/ProjectFilterType.php index 1b7be1a..d1132ef 100644 --- a/src/Form/ProjectFilterType.php +++ b/src/Form/ProjectFilterType.php @@ -6,7 +6,7 @@ use App\Entity\Area; use App\Entity\Department; -use App\Enum\ProjectType as ProjectTypeEnum; +use App\Entity\ProjectType; use App\Enum\Status; use App\Model\ProjectFilter; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -46,12 +46,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'placeholder' => 'filter.all', ]) - ->add('projectType', EnumType::class, [ - 'label' => 'project.project_type', - 'class' => ProjectTypeEnum::class, + ->add('type', EntityType::class, [ + 'label' => 'project.types', + 'class' => ProjectType::class, + 'choice_label' => 'name', 'required' => false, 'placeholder' => 'filter.all', - 'choice_label' => static fn (ProjectTypeEnum $value): string => $value->labelKey(), ]) ->add('organizationalAnchoring', EntityType::class, [ 'label' => 'project.organizational_anchoring', diff --git a/src/Form/ProjectType.php b/src/Form/ProjectType.php index 149c2ea..813e714 100644 --- a/src/Form/ProjectType.php +++ b/src/Form/ProjectType.php @@ -7,9 +7,9 @@ use App\Entity\Area; use App\Entity\Department; use App\Entity\Project; +use App\Entity\ProjectType as ProjectTypeEntity; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType as ProjectTypeEnum; use App\Enum\Status; use App\Enum\Vocabulary; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -72,13 +72,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'project.terms_help', ]) - ->add('projectType', EnumType::class, [ - 'label' => 'project.project_type', - 'class' => ProjectTypeEnum::class, + ->add('types', EntityType::class, [ + 'label' => 'project.types', + 'class' => ProjectTypeEntity::class, + 'choice_label' => 'name', + 'multiple' => true, 'required' => false, - 'placeholder' => 'form.choose', - 'choice_label' => static fn (ProjectTypeEnum $value): string => $value->labelKey(), - 'help' => 'project.project_type_help', + // Admin-managed pool, so a searchable multiselect without on-the-fly + // creation, like the department field (Tom Select, see app.js). + 'attr' => ['data-type-select' => true, 'placeholder' => 'form.choose'], + 'help' => 'project.types_help', ]) ->add('status', EnumType::class, [ 'label' => 'project.status', diff --git a/src/Form/ProjectTypeType.php b/src/Form/ProjectTypeType.php new file mode 100644 index 0000000..fe953a0 --- /dev/null +++ b/src/Form/ProjectTypeType.php @@ -0,0 +1,32 @@ + + */ +class ProjectTypeType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'label' => 'project_type.name', + 'help' => 'project_type.name_help', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ProjectType::class, + ]); + } +} diff --git a/src/Model/ProjectFilter.php b/src/Model/ProjectFilter.php index 875a593..b3944dc 100644 --- a/src/Model/ProjectFilter.php +++ b/src/Model/ProjectFilter.php @@ -6,7 +6,7 @@ use App\Entity\Area; use App\Entity\Department; -use App\Enum\ProjectType; +use App\Entity\ProjectType; use App\Enum\Status; /** @@ -21,7 +21,7 @@ class ProjectFilter public ?Area $area = null; - public ?ProjectType $projectType = null; + public ?ProjectType $type = null; public ?Department $organizationalAnchoring = null; diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 1181091..9afe010 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -8,7 +8,6 @@ use App\Entity\User; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\TranslatableEnum; use App\Model\ProjectFilter; @@ -53,17 +52,17 @@ public function search(ProjectFilter $filter): QueryBuilder sprintf('i.id IN (SELECT istr.id FROM %s istr JOIN istr.strategies st WHERE LOWER(st.name) LIKE :q)', Project::class), sprintf('i.id IN (SELECT icon.id FROM %s icon JOIN icon.contacts co WHERE LOWER(co.name) LIKE :q)', Project::class), sprintf('i.id IN (SELECT ipar.id FROM %s ipar JOIN ipar.partners pa WHERE LOWER(pa.name) LIKE :q)', Project::class), - // Department and area are related entities searched by their stored - // name ("nik" should find "Teknik og Miljø"). + // Department, area and type are related entities searched by + // their stored name ("nik" should find "Teknik og Miljø"). sprintf('i.id IN (SELECT idep.id FROM %s idep JOIN idep.organizationalAnchoring dep WHERE LOWER(dep.name) LIKE :q)', Project::class), sprintf('i.id IN (SELECT iare.id FROM %s iare JOIN iare.area ar WHERE LOWER(ar.name) LIKE :q)', Project::class), + sprintf('i.id IN (SELECT ityp.id FROM %s ityp JOIN ityp.types ty WHERE LOWER(ty.name) LIKE :q)', Project::class), ]; // Enum columns store slugs, but the user searches their translated - // labels ("nik" should find "Teknik og Miljø"); map labels to values. + // labels ("bevilliget" should find granted); map labels to values. $enumFields = [ 'status' => Status::cases(), - 'projectType' => ProjectType::cases(), 'endorsementAuthor' => EndorsementAuthor::cases(), ]; foreach ($enumFields as $field => $cases) { @@ -92,8 +91,9 @@ public function search(ProjectFilter $filter): QueryBuilder $qb->andWhere('i.area = :area')->setParameter('area', $filter->area->getId(), 'ulid'); } - if (null !== $filter->projectType) { - $qb->andWhere('i.projectType = :projectType')->setParameter('projectType', $filter->projectType->value); + if (null !== $filter->type) { + $qb->andWhere(':type MEMBER OF i.types') + ->setParameter('type', $filter->type->getId(), 'ulid'); } if (null !== $filter->organizationalAnchoring) { @@ -147,7 +147,7 @@ public function findForExport(ProjectFilter $filter): array return []; } - foreach (['organizationalAnchoring', 'strategies', 'tags', 'contacts', 'partners'] as $association) { + foreach (['types', 'organizationalAnchoring', 'strategies', 'tags', 'contacts', 'partners'] as $association) { $this->createQueryBuilder('i') ->addSelect('rel') ->leftJoin('i.'.$association, 'rel') diff --git a/src/Repository/ProjectTypeRepository.php b/src/Repository/ProjectTypeRepository.php new file mode 100644 index 0000000..cebcd7f --- /dev/null +++ b/src/Repository/ProjectTypeRepository.php @@ -0,0 +1,31 @@ + + */ +class ProjectTypeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ProjectType::class); + } + + /** + * @return ProjectType[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('c') + ->orderBy('c.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 878155e..ec59f9a 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -11,6 +11,7 @@ {{ 'nav.partners'|trans }} {{ 'nav.departments'|trans }} {{ 'nav.areas'|trans }} + {{ 'nav.project_types'|trans }}
{% block admin_content %}{% endblock %} diff --git a/templates/admin/project_types/_form.html.twig b/templates/admin/project_types/_form.html.twig new file mode 100644 index 0000000..04a7d3f --- /dev/null +++ b/templates/admin/project_types/_form.html.twig @@ -0,0 +1,10 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.name) }} +
+
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/admin/project_types/edit.html.twig b/templates/admin/project_types/edit.html.twig new file mode 100644 index 0000000..73d6c68 --- /dev/null +++ b/templates/admin/project_types/edit.html.twig @@ -0,0 +1,16 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'project_type.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + + + {{ include('admin/project_types/_form.html.twig', {button_label: 'action.save'}) }} +{% endblock %} diff --git a/templates/admin/project_types/index.html.twig b/templates/admin/project_types/index.html.twig new file mode 100644 index 0000000..9a4f02e --- /dev/null +++ b/templates/admin/project_types/index.html.twig @@ -0,0 +1,43 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'project_type.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + {{ 'action.new'|trans }} + + +
+ {% if types is empty %} + + {% else %} +
+ + + + + + + + + {% for type in types %} + + + + + {% endfor %} + +
{{ 'project_type.name'|trans }}
{{ type.name }} + +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/project_types/new.html.twig b/templates/admin/project_types/new.html.twig new file mode 100644 index 0000000..a19dbcb --- /dev/null +++ b/templates/admin/project_types/new.html.twig @@ -0,0 +1,12 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'project_type.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + + + + {{ include('admin/project_types/_form.html.twig', {button_label: 'action.create'}) }} +{% endblock %} diff --git a/templates/project/_form.html.twig b/templates/project/_form.html.twig index 495f8bb..db2001a 100644 --- a/templates/project/_form.html.twig +++ b/templates/project/_form.html.twig @@ -49,7 +49,7 @@ {{ form_row(form.topic) }}
{{ form_row(form.area) }} - {{ form_row(form.projectType) }} + {{ form_row(form.types) }}
{{ form_row(form.summary) }} {{ form_row(form.description) }} diff --git a/templates/project/_results.html.twig b/templates/project/_results.html.twig index a54112b..43940dd 100644 --- a/templates/project/_results.html.twig +++ b/templates/project/_results.html.twig @@ -27,7 +27,7 @@ {{ h.sortlink('title', 'project.title', sort, direction) }} {{ 'project.status'|trans }} - {{ 'project.project_type'|trans }} + {{ 'project.types'|trans }} {{ 'project.organizational_anchoring'|trans }} {{ h.sortlink('timePeriodStart', 'project.time_period', sort, direction) }} {{ 'project.author'|trans }} @@ -47,7 +47,7 @@ — {% endif %} - {{ project.projectType ? project.projectType.labelKey|trans : '—' }} + {{ project.types|length > 0 ? project.types|map(t => t.name)|join(', ') : '—' }} {{ project.organizationalAnchoring|length > 0 ? project.organizationalAnchoring|map(d => d.name)|join(', ') : '—' }} {% if project.timePeriodStart %} diff --git a/templates/project/index.html.twig b/templates/project/index.html.twig index ee905a4..3204de1 100644 --- a/templates/project/index.html.twig +++ b/templates/project/index.html.twig @@ -24,7 +24,7 @@
{{ form_row(form.status) }} {{ form_row(form.area) }} - {{ form_row(form.projectType) }} + {{ form_row(form.type) }} {{ form_row(form.organizationalAnchoring) }} {{ form_row(form.endorsement) }}
diff --git a/templates/project/show.html.twig b/templates/project/show.html.twig index 8b56a0c..59da553 100644 --- a/templates/project/show.html.twig +++ b/templates/project/show.html.twig @@ -97,8 +97,8 @@
{{ 'project.area'|trans }}
{{ project.area ? project.area.name : '—' }}
-
{{ 'project.project_type'|trans }}
-
{{ project.projectType ? project.projectType.labelKey|trans : '—' }}
+
{{ 'project.types'|trans }}
+
{{ h.term_list(project.types) }}
{{ 'project.organizational_anchoring'|trans }}
{{ h.term_list(project.organizationalAnchoring) }}
diff --git a/tests/Controller/Admin/ProjectTypeControllerTest.php b/tests/Controller/Admin/ProjectTypeControllerTest.php new file mode 100644 index 0000000..3031508 --- /dev/null +++ b/tests/Controller/Admin/ProjectTypeControllerTest.php @@ -0,0 +1,117 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/project-types'); + + $this->assertResponseIsSuccessful(); + } + + public function testNewCreatesType(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/project-types/new'); + $this->assertResponseIsSuccessful(); + + $name = 'Test Type '.uniqid(); + $form = $crawler->filter('button.btn--primary')->form(['project_type[name]' => $name]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/project-types'); + + $type = $this->projectTypes()->findOneBy(['name' => $name]); + self::assertInstanceOf(ProjectType::class, $type); + $this->removeType((string) $type->getId()); + } + + public function testNewRejectsADuplicateName(): void + { + $this->loginAsAdmin(); + $name = 'Duplicate Type '.uniqid(); + $id = (string) $this->createType($name)->getId(); + + $crawler = $this->client->request('GET', '/admin/project-types/new'); + $form = $crawler->filter('button.btn--primary')->form(['project_type[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->projectTypes()->findBy(['name' => $name])); + + $this->removeType($id); + } + + public function testEditUpdatesType(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createType('Editable Type '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/project-types/%s/edit', $id)); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form(['project_type[name]' => 'Edited Type '.uniqid()]); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/project-types'); + $this->removeType($id); + } + + public function testDeleteRemovesTypeWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createType('Deletable Type '.uniqid())->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/project-types/%s/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/project-types'); + $this->entityManager()->clear(); + self::assertNull($this->projectTypes()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (string) $this->createType('Surviving Type '.uniqid())->getId(); + + $this->client->request('POST', sprintf('/admin/project-types/%s/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/admin/project-types'); + $this->entityManager()->clear(); + self::assertNotNull($this->projectTypes()->find($id)); + $this->removeType($id); + } + + private function createType(string $name): ProjectType + { + $type = (new ProjectType())->setName($name); + $em = $this->entityManager(); + $em->persist($type); + $em->flush(); + + return $type; + } + + private function removeType(string $id): void + { + $this->entityManager()->clear(); + $type = $this->projectTypes()->find($id); + if (null !== $type) { + $em = $this->entityManager(); + $em->remove($type); + $em->flush(); + } + } +} diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php index 1a2556e..4b28d4c 100644 --- a/tests/Controller/ProjectControllerTest.php +++ b/tests/Controller/ProjectControllerTest.php @@ -7,6 +7,7 @@ use App\Entity\Contact; use App\Entity\Department; use App\Entity\Project; +use App\Entity\ProjectType; use App\Enum\Status; use App\Tests\FunctionalTestCase; use Symfony\Component\DomCrawler\Crawler; @@ -243,6 +244,65 @@ public function testDepartmentsAreSavedShownFilteredAndExported(): void $em->flush(); } + public function testTypesAreSavedShownFilteredAndExported(): void + { + $this->loginAsAdmin(); + $em = $this->entityManager(); + $first = (new ProjectType())->setName('Type first '.uniqid()); + $second = (new ProjectType())->setName('Type second '.uniqid()); + $em->persist($first); + $em->persist($second); + $em->flush(); + $plain = $this->createProject('Plain project '.uniqid()); + $title = 'Typed project '.uniqid(); + + $crawler = $this->client->request('GET', '/projects/new'); + self::assertCount(1, $crawler->filter('select[name="project[types][]"][multiple][data-type-select]')); + $token = (string) $crawler->filter('input[name="project[_token]"]')->attr('value'); + $this->client->request('POST', '/projects/new', [ + 'project' => [ + 'title' => $title, + 'types' => [(string) $first->getId(), (string) $second->getId()], + '_token' => $token, + ], + ]); + $this->assertResponseRedirects(); + + $project = $this->projects()->findOneBy(['title' => $title]); + self::assertInstanceOf(Project::class, $project); + self::assertCount(2, $project->getTypes()); + $id = (string) $project->getId(); + + // Both types are listed on the project page … + $crawler = $this->client->request('GET', '/projects/'.$id); + $details = $crawler->filter('.detail-list')->text(); + self::assertStringContainsString((string) $first->getName(), $details); + self::assertStringContainsString((string) $second->getName(), $details); + + // … the type filter narrows the list to projects of that type … + $crawler = $this->client->request('GET', '/projects?type='.$first->getId()); + $results = $crawler->filter('#project-results')->text(); + self::assertStringContainsString($title, $results); + self::assertStringContainsString($first->getName().', '.$second->getName(), $results); + self::assertStringNotContainsString((string) $plain->getTitle(), $results); + + // … and the export lists every type of the project. + $this->client->request('GET', '/projects/export?type='.$first->getId()); + $csv = (string) $this->client->getInternalResponse()->getContent(); + self::assertStringContainsString($first->getName().', '.$second->getName(), $csv); + + $this->removeProject($id); + $this->removeProject((string) $plain->getId()); + $em = $this->entityManager(); + foreach ([$first->getId(), $second->getId()] as $typeId) { + $type = $this->projectTypes()->find($typeId); + if (null !== $type) { + $em->remove($type); + } + } + $em->flush(); + } + public function testSummaryAndDescriptionAreSavedShownAndSearchable(): void { $this->loginAsAdmin(); diff --git a/tests/Controller/SmokeTest.php b/tests/Controller/SmokeTest.php index a9706bb..24a731f 100644 --- a/tests/Controller/SmokeTest.php +++ b/tests/Controller/SmokeTest.php @@ -69,5 +69,7 @@ public static function authenticatedPages(): iterable yield 'admin partner new' => ['/admin/partners/new']; yield 'admin departments' => ['/admin/departments']; yield 'admin department new' => ['/admin/departments/new']; + yield 'admin project types' => ['/admin/project-types']; + yield 'admin project type new' => ['/admin/project-types/new']; } } diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 6b97e18..c59eb3d 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -10,6 +10,7 @@ use App\Repository\DepartmentRepository; use App\Repository\PartnerRepository; use App\Repository\ProjectRepository; +use App\Repository\ProjectTypeRepository; use App\Repository\TermRepository; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; @@ -87,6 +88,14 @@ protected function areas(): AreaRepository return $repository; } + protected function projectTypes(): ProjectTypeRepository + { + $repository = static::getContainer()->get(ProjectTypeRepository::class); + \assert($repository instanceof ProjectTypeRepository); + + return $repository; + } + protected function terms(): TermRepository { $repository = static::getContainer()->get(TermRepository::class); diff --git a/tests/Repository/AreaRepositoryTest.php b/tests/Repository/AreaRepositoryTest.php index c9615eb..20c5eed 100644 --- a/tests/Repository/AreaRepositoryTest.php +++ b/tests/Repository/AreaRepositoryTest.php @@ -20,6 +20,6 @@ public function testFindAllOrderedReturnsAreas(): void // Ordering is delegated to the database collation, so we only assert the // method returns the persisted areas. self::assertNotEmpty($areas); - self::assertNotNull($areas[0]->getId()); + self::assertNotNull($areas[0]->getName()); } } diff --git a/tests/Repository/DepartmentRepositoryTest.php b/tests/Repository/DepartmentRepositoryTest.php index af77590..89a0fbe 100644 --- a/tests/Repository/DepartmentRepositoryTest.php +++ b/tests/Repository/DepartmentRepositoryTest.php @@ -20,6 +20,6 @@ public function testFindAllOrderedReturnsDepartments(): void // Ordering is delegated to the database collation, so we only assert the // method returns the persisted departments. self::assertNotEmpty($departments); - self::assertNotNull($departments[0]->getId()); + self::assertNotNull($departments[0]->getName()); } } diff --git a/tests/Repository/ProjectRepositoryTest.php b/tests/Repository/ProjectRepositoryTest.php index a5f24f0..191ba6c 100644 --- a/tests/Repository/ProjectRepositoryTest.php +++ b/tests/Repository/ProjectRepositoryTest.php @@ -7,12 +7,13 @@ use App\Entity\Area; use App\Entity\Department; use App\Entity\Project; -use App\Enum\ProjectType; +use App\Entity\ProjectType; use App\Enum\Status; use App\Model\ProjectFilter; use App\Repository\AreaRepository; use App\Repository\DepartmentRepository; use App\Repository\ProjectRepository; +use App\Repository\ProjectTypeRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -34,12 +35,14 @@ public function testSearchAppliesEveryFilterBranch(): void \assert($departments instanceof DepartmentRepository); $areas = static::getContainer()->get(AreaRepository::class); \assert($areas instanceof AreaRepository); + $types = static::getContainer()->get(ProjectTypeRepository::class); + \assert($types instanceof ProjectTypeRepository); $filter = new ProjectFilter(); $filter->q = '100%_'; // also exercises LIKE wildcard escaping $filter->status = Status::Granted; $filter->area = $areas->findAllOrdered()[0]; - $filter->projectType = ProjectType::Project; + $filter->type = $types->findAllOrdered()[0]; $filter->organizationalAnchoring = $departments->findAllOrdered()[0]; $filter->endorsement = true; $filter->sort = 'title'; @@ -48,19 +51,21 @@ public function testSearchAppliesEveryFilterBranch(): void self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); } - public function testSearchByDepartmentOrAreaMatchesOnlyProjectsWithThem(): void + public function testSearchByDepartmentAreaOrTypeMatchesOnlyProjectsWithThem(): void { $em = static::getContainer()->get(EntityManagerInterface::class); \assert($em instanceof EntityManagerInterface); $department = (new Department())->setName('Filter dept '.uniqid()); $area = (new Area())->setName('Filter area '.uniqid()); + $type = (new ProjectType())->setName('Filter type '.uniqid()); $anchored = (new Project()) ->setTitle('Anchored '.uniqid()) ->addOrganizationalAnchoring($department) + ->addType($type) ->setArea($area); $loose = (new Project())->setTitle('Loose '.uniqid()); - foreach ([$department, $area, $anchored, $loose] as $entity) { + foreach ([$department, $area, $type, $anchored, $loose] as $entity) { $em->persist($entity); } $em->flush(); @@ -69,15 +74,20 @@ public function testSearchByDepartmentOrAreaMatchesOnlyProjectsWithThem(): void $byDepartment->organizationalAnchoring = $department; $byArea = new ProjectFilter(); $byArea->area = $area; + $byType = new ProjectFilter(); + $byType->type = $type; + // The free-text search matches the type by its stored name. + $byTypeName = new ProjectFilter(); + $byTypeName->q = (string) $type->getName(); - // Both filters must actually narrow the list: the binary ULID foreign keys + // Every filter must actually narrow the list: the binary ULID foreign keys // only match when the id is bound with the ulid type, so an entity bound // as-is would silently return nothing. - foreach ([$byDepartment, $byArea] as $filter) { + foreach ([$byDepartment, $byArea, $byType, $byTypeName] as $filter) { self::assertSame([$anchored->getTitle()], $this->titles($filter)); } - foreach ([$anchored, $loose, $department, $area] as $entity) { + foreach ([$anchored, $loose, $department, $area, $type] as $entity) { $em->remove($entity); } $em->flush(); @@ -105,9 +115,9 @@ public function testSearchMatchesTranslatedFundingLabel(): void public function testSearchMatchesTranslatedEnumLabel(): void { $filter = new ProjectFilter(); - // "projekt" is the Danish label for the Project case of the project type, so the - // search maps it to the project slug and matches the enum column. - $filter->q = 'projekt'; + // "bevilliget" is the Danish label for the Granted status, so the search + // maps it to the granted slug and matches the enum column. + $filter->q = 'bevilliget'; self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); } diff --git a/tests/Repository/ProjectTypeRepositoryTest.php b/tests/Repository/ProjectTypeRepositoryTest.php new file mode 100644 index 0000000..5023535 --- /dev/null +++ b/tests/Repository/ProjectTypeRepositoryTest.php @@ -0,0 +1,25 @@ +get(ProjectTypeRepository::class); + \assert($repository instanceof ProjectTypeRepository); + + $types = $repository->findAllOrdered(); + + // Ordering is delegated to the database collation, so we only assert the + // method returns the persisted types. + self::assertNotEmpty($types); + self::assertNotNull($types[0]->getName()); + } +} diff --git a/tests/Unit/Entity/ProjectTest.php b/tests/Unit/Entity/ProjectTest.php index 2a80fce..bb11385 100644 --- a/tests/Unit/Entity/ProjectTest.php +++ b/tests/Unit/Entity/ProjectTest.php @@ -9,10 +9,10 @@ use App\Entity\Department; use App\Entity\Partner; use App\Entity\Project; +use App\Entity\ProjectType; use App\Entity\Term; use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\Vocabulary; use PHPUnit\Framework\TestCase; @@ -28,6 +28,7 @@ public function testDefaults(): void self::assertSame([], $project->getFunding()); self::assertSame([], $project->getLinks()); self::assertCount(0, $project->getOrganizationalAnchoring()); + self::assertCount(0, $project->getTypes()); self::assertCount(0, $project->getStrategies()); self::assertCount(0, $project->getTags()); self::assertCount(0, $project->getContacts()); @@ -49,7 +50,6 @@ public function testScalarAccessors(): void ->setArea($area) ->setSummary('Opsummering') ->setDescription('Beskrivelse') - ->setProjectType(ProjectType::Project) ->setStatus(Status::Granted) ->setStatusAdditional('Igangsat') ->setEndorsement(false) @@ -63,7 +63,6 @@ public function testScalarAccessors(): void self::assertSame($area, $project->getArea()); self::assertSame('Opsummering', $project->getSummary()); self::assertSame('Beskrivelse', $project->getDescription()); - self::assertSame(ProjectType::Project, $project->getProjectType()); self::assertSame(Status::Granted, $project->getStatus()); self::assertSame('Igangsat', $project->getStatusAdditional()); self::assertFalse($project->isEndorsement()); @@ -83,7 +82,7 @@ public function testCompletionPercentage(): void ->setArea((new Area())->setName('Klima og miljø')) ->setSummary('S') ->setDescription('D') - ->setProjectType(ProjectType::Project) + ->addType((new ProjectType())->setName('Projekt')) ->setStatus(Status::Granted) ->addOrganizationalAnchoring((new Department())->setName('Teknik og Miljø')) ->setBudget(1000) @@ -157,6 +156,27 @@ public function testOrganizationalAnchoringCollection(): void self::assertCount(0, $project->getOrganizationalAnchoring()); } + public function testTypeCollection(): void + { + $project = new Project(); + $type = (new ProjectType())->setName('Pilot'); + + $project->addType($type); + $project->addType($type); + self::assertCount(1, $project->getTypes()); + + $project->removeType($type); + self::assertCount(0, $project->getTypes()); + + $project->setTypes([ + (new ProjectType())->setName('Projekt'), + (new ProjectType())->setName('Drift'), + ]); + self::assertCount(2, $project->getTypes()); + $project->setTypes([]); + self::assertCount(0, $project->getTypes()); + } + public function testStrategyCollection(): void { $project = new Project(); diff --git a/tests/Unit/Entity/ProjectTypeTest.php b/tests/Unit/Entity/ProjectTypeTest.php new file mode 100644 index 0000000..7dd3da4 --- /dev/null +++ b/tests/Unit/Entity/ProjectTypeTest.php @@ -0,0 +1,27 @@ +getName()); + self::assertSame('', (string) $type); + } + + public function testAccessors(): void + { + $type = (new ProjectType())->setName('Drift'); + + self::assertSame('Drift', $type->getName()); + self::assertSame('Drift', (string) $type); + } +} diff --git a/tests/Unit/Enum/TranslatableEnumTest.php b/tests/Unit/Enum/TranslatableEnumTest.php index 88495a2..8d04c37 100644 --- a/tests/Unit/Enum/TranslatableEnumTest.php +++ b/tests/Unit/Enum/TranslatableEnumTest.php @@ -6,7 +6,6 @@ use App\Enum\EndorsementAuthor; use App\Enum\Funding; -use App\Enum\ProjectType; use App\Enum\Status; use App\Enum\TranslatableEnum; use App\Enum\Vocabulary; @@ -18,7 +17,6 @@ public function testEveryCaseExposesAPrefixedLabelKey(): void { $this->assertLabelKeys(EndorsementAuthor::cases(), 'enum.endorsement_author.'); $this->assertLabelKeys(Funding::cases(), 'enum.funding.'); - $this->assertLabelKeys(ProjectType::cases(), 'enum.project_type.'); $this->assertLabelKeys(Status::cases(), 'enum.status.'); $this->assertLabelKeys(Vocabulary::cases(), 'enum.vocabulary.'); } diff --git a/tests/Unit/Model/ProjectFilterTest.php b/tests/Unit/Model/ProjectFilterTest.php index ad76ac1..ba0e2f1 100644 --- a/tests/Unit/Model/ProjectFilterTest.php +++ b/tests/Unit/Model/ProjectFilterTest.php @@ -6,7 +6,7 @@ use App\Entity\Area; use App\Entity\Department; -use App\Enum\ProjectType; +use App\Entity\ProjectType; use App\Enum\Status; use App\Model\ProjectFilter; use PHPUnit\Framework\TestCase; @@ -20,7 +20,7 @@ public function testDefaults(): void self::assertNull($filter->q); self::assertNull($filter->status); self::assertNull($filter->area); - self::assertNull($filter->projectType); + self::assertNull($filter->type); self::assertNull($filter->organizationalAnchoring); self::assertNull($filter->endorsement); self::assertSame('createdAt', $filter->sort); @@ -33,7 +33,7 @@ public function testIsMutable(): void $filter->q = 'klima'; $filter->status = Status::Granted; $filter->area = (new Area())->setName('Klima og miljø'); - $filter->projectType = ProjectType::Project; + $filter->type = (new ProjectType())->setName('Drift'); $filter->organizationalAnchoring = (new Department())->setName('Sundhed og Omsorg'); $filter->endorsement = true; $filter->sort = 'title'; diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index ab9483b..feaf840 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -86,6 +86,7 @@ nav: partners: Samarbejdspartnere departments: Afdelinger areas: Områder + project_types: Projekttyper users: Brugere admin: Administration preferences: Indstillinger @@ -173,8 +174,8 @@ project: description: Beskrivelse description_help: Hvorfor vi gør det, og hvad projektet har ophæng i. strategies: Strategier og planer - project_type: Projektets karakter - project_type_help: Projektets karakter, fx projekt eller drift. + types: Type + types_help: Fx projekt eller drift. status: Status status_help: Hvor langt projektet er. status_additional: Status – supplerende tekst @@ -304,6 +305,21 @@ area: title: Ingen områder endnu hint: Opret et område, så det kan vælges på projekter. +project_type: + name: Navn + name_help: Typens navn, som det vises på projekter. + name_duplicate: Der findes allerede en projekttype med dette navn. + index: + title: Projekttyper + subtitle: Projektets type, fx projekt eller drift, der kan vælges på projekter. + new: + title: Ny projekttype + edit: + title: Rediger projekttype + empty: + title: Ingen projekttyper endnu + hint: Opret en projekttype, så den kan vælges på projekter. + user: email: E-mail email_help: Bruges som login og til notifikationer. @@ -355,6 +371,10 @@ flash: created: Området blev oprettet. updated: Området blev opdateret. deleted: Området blev slettet. + project_type: + created: Projekttypen blev oprettet. + updated: Projekttypen blev opdateret. + deleted: Projekttypen blev slettet. user: created: Brugeren blev oprettet. updated: Brugeren blev opdateret. @@ -371,12 +391,6 @@ enum: on_hold: Sat i bero rejected: Afvist completed: Afsluttet - project_type: - project: Projekt - programme: Program - policy: Politik - pilot: Pilot - operation: Drift endorsement_author: city_council: Byrådet magistrate: Magistraten diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index dade48e..bf693bb 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -86,6 +86,7 @@ nav: partners: Partners departments: Departments areas: Areas + project_types: Project types users: Users admin: Administration preferences: Preferences @@ -173,8 +174,8 @@ project: description: Description description_help: Why we are doing it, and what the project is anchored in. strategies: Strategies and plans - project_type: Type of project - project_type_help: The nature of the project, e.g. project or operation. + types: Type + types_help: E.g. project or operation. status: Status status_help: How far along the project is. status_additional: Status – additional text @@ -304,6 +305,21 @@ area: title: No areas yet hint: Create an area so it can be chosen on projects. +project_type: + name: Name + name_help: The type name as shown on projects. + name_duplicate: A project type with this name already exists. + index: + title: Project types + subtitle: The type of a project, e.g. project or operation, that can be chosen on projects. + new: + title: New project type + edit: + title: Edit project type + empty: + title: No project types yet + hint: Create a project type so it can be chosen on projects. + user: email: Email email_help: Used to sign in and for notifications. @@ -355,6 +371,10 @@ flash: created: The area was created. updated: The area was updated. deleted: The area was deleted. + project_type: + created: The project type was created. + updated: The project type was updated. + deleted: The project type was deleted. user: created: The user was created. updated: The user was updated. @@ -371,12 +391,6 @@ enum: on_hold: On hold rejected: Rejected completed: Completed - project_type: - project: Project - programme: Programme - policy: Policy - pilot: Pilot - operation: Operation endorsement_author: city_council: City Council magistrate: The Magistrate