From 334a37a9d01f9c98dd300eac96b1304332a6f7c5 Mon Sep 17 00:00:00 2001 From: d-buchmann Date: Thu, 22 May 2025 14:43:05 +0200 Subject: [PATCH 1/9] Preparations for bulk editing of tags Made "target" parameter of handleAction in parts table a string Sanitize input for exisitng action which expect an int Add tag (dummy) to SelectAPI Add tag option to twig template --- src/Controller/PartListsController.php | 2 +- src/Controller/SelectAPIController.php | 15 +++++++++++++ .../Parts/PartsTableActionHandler.php | 21 ++++++++++++++++++- .../components/datatables.macro.html.twig | 4 ++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 48995228f..171d19993 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -78,7 +78,7 @@ public function tableAction(Request $request, PartsTableActionHandler $actionHan $errors = []; $parts = $actionHandler->idStringToArray($ids); - $redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect, $errors); + $redirectResponse = $actionHandler->handleAction($action, $parts, $target ? $target : null, $redirect, $errors); //Save changes $this->entityManager->flush(); diff --git a/src/Controller/SelectAPIController.php b/src/Controller/SelectAPIController.php index c1e682c88..03224634a 100644 --- a/src/Controller/SelectAPIController.php +++ b/src/Controller/SelectAPIController.php @@ -33,6 +33,7 @@ use App\Entity\ProjectSystem\Project; use App\Form\Type\Helper\StructuralEntityChoiceHelper; use App\Services\Trees\NodesListBuilder; +use App\ApiPlatform\Filter\TagFilter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -49,6 +50,20 @@ public function __construct(private readonly NodesListBuilder $nodesListBuilder, { } + #[Route(path: '/tag', name: 'select_tag')] + public function tag(): Response + { + $tags = [ + 'text' => 'test', + 'value' => 'test', + ]; + $this->addEmptyNode($tags); + // pseudocode: + // for each part in selection + // use TagFilter to find tags + // dedupe + return $this->json($tags); + #[Route(path: '/category', name: 'select_category')] public function category(): Response { diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 616df2293..affaa06bd 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -67,8 +67,15 @@ public function idStringToArray(string $ids): array * @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null * //@param-out list|array $errors */ - public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse + public function handleAction(string $action, array $selected_parts, ?string $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse { + // sanitize target_id + if (!str_contains($action, 'tag') && $target_id !== null) + { + if (!is_numeric($target_id) + throw new InvalidArgumentException('$target_id must be an integer for action'. $action.'!'); + } + if ($action === 'add_to_project') { return new RedirectResponse( $this->urlGenerator->generate('project_add_parts', [ @@ -130,6 +137,18 @@ public function handleAction(string $action, array $selected_parts, ?int $target $this->denyAccessUnlessGranted('edit', $part); switch ($action) { + case "add_tag": + $this->denyAccessUnlessGranted('edit', $part); + $tags = $part->getTags(); + $part->setTags($tags . ',' . $target_id); // simple append + break; + case "remove_tag": + $this->denyAccessUnlessGranted('edit', $part); + $tags = $part->getTags(); + $tags = str_replace($target_id, '', $tags); + // sanitize $tags (remove leading, trailing and double commas) + $part->setTags($tags); + break; case 'favorite': $this->denyAccessUnlessGranted('change_favorite', $part); $part->setFavorite(true); diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index 5ce0f23f6..d790ffe57 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -41,6 +41,10 @@ - - - - @@ -53,6 +49,10 @@ + + + + @@ -82,6 +82,9 @@ + + {# This is left empty, as this will be filled by Javascript #} + From 8b2d45c5b13b65215f3be3394905e47a5c83ce1a Mon Sep 17 00:00:00 2001 From: buchmann Date: Tue, 7 Oct 2025 08:50:51 +0200 Subject: [PATCH 6/9] Update PartsTableActionHandler.php --- .../Parts/PartsTableActionHandler.php | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 584c41e24..7a737c7d9 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -70,8 +70,8 @@ public function idStringToArray(string $ids): array public function handleAction(string $action, array $selected_parts, ?string $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse { // validate target_id - if (!str_contains($action, 'tag') && $target_id !== null && !is_numeric($target_id)) - throw new InvalidArgumentException('$target_id must be an integer for action'. $action.'!'); + if (!str_contains($action, 'tag') && $target_id !== null && !is_numeric($target_id)) { + throw new InvalidArgumentException('$target_id must be an integer for action '. $action.'!'); } if ($action === 'add_to_project') { @@ -136,17 +136,24 @@ public function handleAction(string $action, array $selected_parts, ?string $tar switch ($action) { case "add_tag": - $this->denyAccessUnlessGranted('edit', $part); - $tags = $part->getTags(); - // simple append - $part->setTags($tags.','.$target_id); + if ($target_id !== null) + { + $this->denyAccessUnlessGranted('edit', $part); + $tags = $part->getTags(); + // simply append the tag but and avoid duplicates + if (!str_contains($tags, $target_id)) + $part->setTags($tags.','.$target_id); + } break; case "remove_tag": - $this->denyAccessUnlessGranted('edit', $part); - // remove any matching tag at start or end - $tags = preg_replace('/(^'.$target_id.',|,'.$target_id.'$)/', '', $part->getTags()); - // remove any matching tags in the middle, retaining one comma, and commit - $part->setTags(str_replace(','.$target_id.',', ',' $tags); + if ($target_id !== null) + { + $this->denyAccessUnlessGranted('edit', $part); + // remove any matching tag at start or end + $tags = preg_replace('/(^'.$target_id.',|,'.$target_id.'$)/', '', $part->getTags()); + // remove any matching tags in the middle, retaining one comma, and commit + $part->setTags(str_replace(','.$target_id.',', ',', $tags)); + } break; case 'favorite': $this->denyAccessUnlessGranted('change_favorite', $part); From 3a3ad1e1474b96a5b03f4a2e759d945334cf4ae3 Mon Sep 17 00:00:00 2001 From: buchmann Date: Tue, 7 Oct 2025 09:06:06 +0200 Subject: [PATCH 7/9] Fix test --- .../Parts/PartsTableActionHandlerTest.php | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/Services/Parts/PartsTableActionHandlerTest.php diff --git a/tests/Services/Parts/PartsTableActionHandlerTest.php b/tests/Services/Parts/PartsTableActionHandlerTest.php new file mode 100644 index 000000000..4653a2aa5 --- /dev/null +++ b/tests/Services/Parts/PartsTableActionHandlerTest.php @@ -0,0 +1,62 @@ +. + */ +namespace App\Tests\Services\Parts; + +use App\Entity\Parts\Part; +use App\Services\Parts\PartsTableActionHandler; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\HttpFoundation\RedirectResponse; + +class PartsTableActionHandlerTest extends WebTestCase +{ + private PartsTableActionHandler $service; + + protected function setUp(): void + { + self::bootKernel(); + $this->service = self::getContainer()->get(PartsTableActionHandler::class); + } + + public function testExportActionsRedirectToExportController(): void + { + // Mock a Part entity with required properties + $part = $this->createMock(Part::class); + $part->method('getId')->willReturn(1); + $part->method('getName')->willReturn('Test Part'); + + $selected_parts = [$part]; + + // Test each export format, focusing on our new xlsx format + $formats = ['json', 'csv', 'xml', 'yaml', 'xlsx']; + + foreach ($formats as $format) { + $action = "export_{$format}"; + $result = $this->service->handleAction($action, $selected_parts, '1', '/test'); + + $this->assertInstanceOf(RedirectResponse::class, $result); + $this->assertStringContainsString('parts/export', $result->getTargetUrl()); + $this->assertStringContainsString("format={$format}", $result->getTargetUrl()); + } + } + +} \ No newline at end of file From adf95f6f84affb0f0dd0d791c8f9918435921dae Mon Sep 17 00:00:00 2001 From: buchmann Date: Tue, 7 Oct 2025 09:41:55 +0200 Subject: [PATCH 8/9] Handle linter errors, add missing changes in TagFinder --- src/Controller/SelectAPIController.php | 4 +-- .../Parts/PartsTableActionHandler.php | 4 +-- src/Services/Tools/TagFinder.php | 27 +++++++++++-------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/Controller/SelectAPIController.php b/src/Controller/SelectAPIController.php index 8910b935f..2dcb07fd1 100644 --- a/src/Controller/SelectAPIController.php +++ b/src/Controller/SelectAPIController.php @@ -140,8 +140,8 @@ public function labelProfilesLot(EntityManagerInterface $entityManager): Respons #[Route(path: '/tag', name: 'select_tag')] public function getResponseForTags(EntityManagerInterface $entityManager): Response { - $tf = new TagFinder($entityManager, ['min_keyword_length' => 2, 'query_limit' => 250]); - $list = $tf->listTags('__'); // return every tag with at least two characters! + $tf = new TagFinder($entityManager); + $list = $tf->listTags('__', ['min_keyword_length' => 2, 'query_limit' => 250]); // return every tag with at least two characters! $entries = []; diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 7a737c7d9..083c378bb 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -94,7 +94,7 @@ public function handleAction(string $action, array $selected_parts, ?string $tar } return new RedirectResponse( - $this->urlGenerator->generate($target_id !== 0 && $target_id !== null ? 'label_dialog_profile' : 'label_dialog', [ + $this->urlGenerator->generate($target_id !== null && intval($target_id) !== 0 ? 'label_dialog_profile' : 'label_dialog', [ 'profile' => $target_id, 'target_id' => $targets, 'generate' => '1', @@ -107,7 +107,7 @@ public function handleAction(string $action, array $selected_parts, ?string $tar $matches = []; if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) { $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); - $level = match ($target_id) { + $level = match (intval($target_id)) { 2 => 'extended', 3 => 'full', default => 'simple', diff --git a/src/Services/Tools/TagFinder.php b/src/Services/Tools/TagFinder.php index 80c89e0fc..20374ed76 100644 --- a/src/Services/Tools/TagFinder.php +++ b/src/Services/Tools/TagFinder.php @@ -50,7 +50,21 @@ public function searchTags(string $keyword, array $options = []): array { $results = []; $keyword_regex = '/^'.preg_quote($keyword, '/').'/'; + $possible_tags = $this->listTags($keyword, $options); + //Iterate over each possible tags (which are comma separated) and extract tags which match our keyword + foreach ($possible_tags as $tags) { + $tags = explode(',', (string) $tags['tags']); + $results = array_merge($results, preg_grep($keyword_regex, $tags)); + } + + $results = array_unique($results); + //Limit the returned tag count to specified value. + return array_slice($results, 0, $options['return_limit']); + } + + public function listTags(string $keyword, array $options = []): array + { $resolver = new OptionsResolver(); $this->configureOptions($resolver); @@ -71,19 +85,10 @@ public function searchTags(string $keyword, array $options = []): array //->orderBy('RAND()') ->setParameter(1, '%'.$keyword.'%'); - $possible_tags = $qb->getQuery()->getArrayResult(); - - //Iterate over each possible tags (which are comma separated) and extract tags which match our keyword - foreach ($possible_tags as $tags) { - $tags = explode(',', (string) $tags['tags']); - $results = array_merge($results, preg_grep($keyword_regex, $tags)); - } - - $results = array_unique($results); - //Limit the returned tag count to specified value. - return array_slice($results, 0, $options['return_limit']); + return $qb->getQuery()->getArrayResult(); } + protected function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ From 6fd2bb9cf3e20af885a53ceedff6ed05f66b872f Mon Sep 17 00:00:00 2001 From: buchmann Date: Tue, 7 Oct 2025 09:53:03 +0200 Subject: [PATCH 9/9] Fix unit tests --- src/Services/Formatters/SIFormatter.php | 2 +- src/Services/Tools/TagFinder.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Services/Formatters/SIFormatter.php b/src/Services/Formatters/SIFormatter.php index a6325987c..956a72f03 100644 --- a/src/Services/Formatters/SIFormatter.php +++ b/src/Services/Formatters/SIFormatter.php @@ -38,7 +38,7 @@ class SIFormatter */ public function getMagnitude(float $value): int { - return (int) floor(log10(abs($value))); + return intval(floor(log10(abs($value)))); } /** diff --git a/src/Services/Tools/TagFinder.php b/src/Services/Tools/TagFinder.php index 20374ed76..ed5302b3d 100644 --- a/src/Services/Tools/TagFinder.php +++ b/src/Services/Tools/TagFinder.php @@ -49,6 +49,11 @@ public function __construct(protected EntityManagerInterface $em) public function searchTags(string $keyword, array $options = []): array { $results = []; + + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); + $keyword_regex = '/^'.preg_quote($keyword, '/').'/'; $possible_tags = $this->listTags($keyword, $options);