diff --git a/assets/controllers/elements/datatables/parts_controller.js b/assets/controllers/elements/datatables/parts_controller.js index c43fa2765..0db56d713 100644 --- a/assets/controllers/elements/datatables/parts_controller.js +++ b/assets/controllers/elements/datatables/parts_controller.js @@ -27,7 +27,7 @@ import * as bootbox from "bootbox"; */ export default class extends DatatablesController { - static targets = ['dt', 'selectPanel', 'selectIDs', 'selectCount', 'selectTargetPicker']; + static targets = ['dt', 'selectPanel', 'selectIDs', 'selectCount', 'selectTargetPicker', 'selectTargetPickerTags']; _confirmed = false; @@ -60,6 +60,7 @@ export default class extends DatatablesController { ).join(","); this.selectIDsTarget.value = selected_ids_string; + //updateTargetPicker(e, items); // to enable automatic update of tags that belong to the currently selected parts } updateOptions(select_element, json) @@ -69,7 +70,19 @@ export default class extends DatatablesController { //$(select_element).selectpicker('destroy'); //Retrieve the select controller instance - const select_controller = this.application.getControllerForElementAndIdentifier(select_element, 'elements--structural-entity-select'); + var select_controller; + if (false && select_element.classList.contains('tagsinput')) + { + select_controller = this.application.getControllerForElementAndIdentifier(select_element, 'elements--tagsinput'); + const selectPanel = this.selectPanelTarget; + selectPanel.querySelector('.tagsinput').classList.remove('d-none'); + } + else + { + select_controller = this.application.getControllerForElementAndIdentifier(select_element, 'elements--structural-entity-select'); + select_element.nextElementSibling.classList.remove('d-none'); + } + /** @var {TomSelect} tom_select */ const tom_select = select_controller.getTomSelect(); @@ -83,20 +96,24 @@ export default class extends DatatablesController { tom_select.setValue(json[0].value); } - select_element.nextElementSibling.classList.remove('d-none'); //$(select_element).selectpicker('show'); - + } - updateTargetPicker(event) { + updateTargetPicker(event, items) { const element = event.target; //Extract the url from the selected option const selected_option = element.options[element.options.selectedIndex]; const url = selected_option.dataset.url; - const select_target = this.selectTargetPickerTarget; + var select_target; + if (url && url.endsWith('tag')){ + select_target = this.selectTargetPickerTagsTarget; + } + else + select_target = this.selectTargetPickerTarget; if (url) { fetch(url) @@ -106,8 +123,9 @@ export default class extends DatatablesController { }); }); } else { - //Hide the select element (the tomselect button is the sibling of the select element) + //Hide the select elements (the tomselect button is the sibling of the select element) select_target.nextElementSibling.classList.add('d-none'); + this.selectPanelTarget.querySelector('.tagsinput').classList.add('d-none'); } //If the selected option has a data-turbo attribute, set it to the form diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 8ea218f40..a8957e1a4 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -84,7 +84,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..2dcb07fd1 100644 --- a/src/Controller/SelectAPIController.php +++ b/src/Controller/SelectAPIController.php @@ -32,6 +32,7 @@ use App\Entity\Parts\StorageLocation; use App\Entity\ProjectSystem\Project; use App\Form\Type\Helper\StructuralEntityChoiceHelper; +use App\Services\Tools\TagFinder; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -135,6 +136,27 @@ public function labelProfilesLot(EntityManagerInterface $entityManager): Respons return $this->json($nodes); } + + #[Route(path: '/tag', name: 'select_tag')] + public function getResponseForTags(EntityManagerInterface $entityManager): Response + { + $tf = new TagFinder($entityManager); + $list = $tf->listTags('__', ['min_keyword_length' => 2, 'query_limit' => 250]); // return every tag with at least two characters! + + $entries = []; + + foreach($list as $d) + { + + //if ($entries[$d] === null) + $entries[$d['tags']] = $d['tags']; + } + + return $this->json(array_map(static fn($key, $value) => [ + 'text' => $value, + 'value' => $key, + ], array_keys($entries), $entries)); + } protected function getResponseForClass(string $class, bool $include_empty = false): Response { 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/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 945cff7b7..bdd554429 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -65,8 +65,13 @@ 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 { + // 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 ($action === 'add_to_project') { return new RedirectResponse( $this->urlGenerator->generate('project_add_parts', [ @@ -87,7 +92,7 @@ public function handleAction(string $action, array $selected_parts, ?int $target } 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', @@ -100,7 +105,7 @@ public function handleAction(string $action, array $selected_parts, ?int $target $matches = []; if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $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', @@ -138,6 +143,26 @@ public function handleAction(string $action, array $selected_parts, ?int $target $this->denyAccessUnlessGranted('edit', $part); switch ($action) { + case "add_tag": + 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": + 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); $part->setFavorite(true); diff --git a/src/Services/Tools/TagFinder.php b/src/Services/Tools/TagFinder.php index 80c89e0fc..ed5302b3d 100644 --- a/src/Services/Tools/TagFinder.php +++ b/src/Services/Tools/TagFinder.php @@ -49,8 +49,27 @@ 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); + + //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 +90,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([ diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index d78734988..7c63ee79b 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -47,6 +47,10 @@ + + + + @@ -80,6 +84,9 @@ + + {# This is left empty, as this will be filled by Javascript #} + diff --git a/tests/Services/Parts/PartsTableActionHandlerTest.php b/tests/Services/Parts/PartsTableActionHandlerTest.php index c5105cd7b..0840c39f4 100644 --- a/tests/Services/Parts/PartsTableActionHandlerTest.php +++ b/tests/Services/Parts/PartsTableActionHandlerTest.php @@ -51,8 +51,7 @@ public function testExportActionsRedirectToExportController(): void foreach ($formats as $format) { $action = "export_{$format}"; - $result = $this->service->handleAction($action, $selected_parts, 1, '/test'); - + $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());