mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-22 03:11:30 +00:00
Merge 6fd2bb9cf3 into e1418dfdc1
This commit is contained in:
commit
30dcba48a2
8 changed files with 107 additions and 26 deletions
|
|
@ -27,7 +27,7 @@ import * as bootbox from "bootbox";
|
||||||
*/
|
*/
|
||||||
export default class extends DatatablesController {
|
export default class extends DatatablesController {
|
||||||
|
|
||||||
static targets = ['dt', 'selectPanel', 'selectIDs', 'selectCount', 'selectTargetPicker'];
|
static targets = ['dt', 'selectPanel', 'selectIDs', 'selectCount', 'selectTargetPicker', 'selectTargetPickerTags'];
|
||||||
|
|
||||||
_confirmed = false;
|
_confirmed = false;
|
||||||
|
|
||||||
|
|
@ -60,6 +60,7 @@ export default class extends DatatablesController {
|
||||||
).join(",");
|
).join(",");
|
||||||
|
|
||||||
this.selectIDsTarget.value = selected_ids_string;
|
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)
|
updateOptions(select_element, json)
|
||||||
|
|
@ -69,7 +70,19 @@ export default class extends DatatablesController {
|
||||||
//$(select_element).selectpicker('destroy');
|
//$(select_element).selectpicker('destroy');
|
||||||
|
|
||||||
//Retrieve the select controller instance
|
//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 */
|
/** @var {TomSelect} tom_select */
|
||||||
const tom_select = select_controller.getTomSelect();
|
const tom_select = select_controller.getTomSelect();
|
||||||
|
|
||||||
|
|
@ -83,20 +96,24 @@ export default class extends DatatablesController {
|
||||||
tom_select.setValue(json[0].value);
|
tom_select.setValue(json[0].value);
|
||||||
}
|
}
|
||||||
|
|
||||||
select_element.nextElementSibling.classList.remove('d-none');
|
|
||||||
|
|
||||||
//$(select_element).selectpicker('show');
|
//$(select_element).selectpicker('show');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTargetPicker(event) {
|
updateTargetPicker(event, items) {
|
||||||
const element = event.target;
|
const element = event.target;
|
||||||
|
|
||||||
//Extract the url from the selected option
|
//Extract the url from the selected option
|
||||||
const selected_option = element.options[element.options.selectedIndex];
|
const selected_option = element.options[element.options.selectedIndex];
|
||||||
const url = selected_option.dataset.url;
|
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) {
|
if (url) {
|
||||||
fetch(url)
|
fetch(url)
|
||||||
|
|
@ -106,8 +123,9 @@ export default class extends DatatablesController {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} 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');
|
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
|
//If the selected option has a data-turbo attribute, set it to the form
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class PartListsController extends AbstractController
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
$parts = $actionHandler->idStringToArray($ids);
|
$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
|
//Save changes
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ use App\Entity\Parts\MeasurementUnit;
|
||||||
use App\Entity\Parts\StorageLocation;
|
use App\Entity\Parts\StorageLocation;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
|
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
|
||||||
|
use App\Services\Tools\TagFinder;
|
||||||
use App\Services\Trees\NodesListBuilder;
|
use App\Services\Trees\NodesListBuilder;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
|
@ -136,6 +137,27 @@ class SelectAPIController extends AbstractController
|
||||||
return $this->json($nodes);
|
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
|
protected function getResponseForClass(string $class, bool $include_empty = false): Response
|
||||||
{
|
{
|
||||||
$test_obj = new $class();
|
$test_obj = new $class();
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ class SIFormatter
|
||||||
*/
|
*/
|
||||||
public function getMagnitude(float $value): int
|
public function getMagnitude(float $value): int
|
||||||
{
|
{
|
||||||
return (int) floor(log10(abs($value)));
|
return intval(floor(log10(abs($value))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,13 @@ final class PartsTableActionHandler
|
||||||
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
|
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
|
||||||
* //@param-out list<array{'part': Part, 'message': string|TranslatableInterface}>|array<void> $errors
|
* //@param-out list<array{'part': Part, 'message': string|TranslatableInterface}>|array<void> $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') {
|
if ($action === 'add_to_project') {
|
||||||
return new RedirectResponse(
|
return new RedirectResponse(
|
||||||
$this->urlGenerator->generate('project_add_parts', [
|
$this->urlGenerator->generate('project_add_parts', [
|
||||||
|
|
@ -87,7 +92,7 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RedirectResponse(
|
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,
|
'profile' => $target_id,
|
||||||
'target_id' => $targets,
|
'target_id' => $targets,
|
||||||
'generate' => '1',
|
'generate' => '1',
|
||||||
|
|
@ -100,7 +105,7 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||||
$matches = [];
|
$matches = [];
|
||||||
if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) {
|
if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) {
|
||||||
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
||||||
$level = match ($target_id) {
|
$level = match (intval($target_id)) {
|
||||||
2 => 'extended',
|
2 => 'extended',
|
||||||
3 => 'full',
|
3 => 'full',
|
||||||
default => 'simple',
|
default => 'simple',
|
||||||
|
|
@ -138,6 +143,26 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||||
$this->denyAccessUnlessGranted('edit', $part);
|
$this->denyAccessUnlessGranted('edit', $part);
|
||||||
|
|
||||||
switch ($action) {
|
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':
|
case 'favorite':
|
||||||
$this->denyAccessUnlessGranted('change_favorite', $part);
|
$this->denyAccessUnlessGranted('change_favorite', $part);
|
||||||
$part->setFavorite(true);
|
$part->setFavorite(true);
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,27 @@ class TagFinder
|
||||||
public function searchTags(string $keyword, array $options = []): array
|
public function searchTags(string $keyword, array $options = []): array
|
||||||
{
|
{
|
||||||
$results = [];
|
$results = [];
|
||||||
$keyword_regex = '/^'.preg_quote($keyword, '/').'/';
|
|
||||||
|
|
||||||
|
$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();
|
$resolver = new OptionsResolver();
|
||||||
$this->configureOptions($resolver);
|
$this->configureOptions($resolver);
|
||||||
|
|
||||||
|
|
@ -71,19 +90,10 @@ class TagFinder
|
||||||
//->orderBy('RAND()')
|
//->orderBy('RAND()')
|
||||||
->setParameter(1, '%'.$keyword.'%');
|
->setParameter(1, '%'.$keyword.'%');
|
||||||
|
|
||||||
$possible_tags = $qb->getQuery()->getArrayResult();
|
return $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']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
protected function configureOptions(OptionsResolver $resolver): void
|
protected function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@
|
||||||
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="set_needs_review">{% trans %}part_list.action.action.set_needs_review{% endtrans %}</option>
|
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="set_needs_review">{% trans %}part_list.action.action.set_needs_review{% endtrans %}</option>
|
||||||
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="unset_needs_review">{% trans %}part_list.action.action.unset_needs_review{% endtrans %}</option>
|
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="unset_needs_review">{% trans %}part_list.action.action.unset_needs_review{% endtrans %}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
<optgroup label="{% trans %}part_list.action.action.group.tags{% endtrans %}">
|
||||||
|
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="add_tag" data-url="{{ path('select_tag') }}">{% trans %}part_list.action.action.add_tag{% endtrans %}</option>
|
||||||
|
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="remove_tag" data-url="{{ path('select_tag') }}">{% trans %}part_list.action.action.remove_tag{% endtrans %}</option>
|
||||||
|
</optgroup>
|
||||||
<optgroup label="{% trans %}part_list.action.action.group.change_field{% endtrans %}">
|
<optgroup label="{% trans %}part_list.action.action.group.change_field{% endtrans %}">
|
||||||
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_category" data-url="{{ path('select_category') }}">{% trans %}part_list.action.action.change_category{% endtrans %}</option>
|
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_category" data-url="{{ path('select_category') }}">{% trans %}part_list.action.action.change_category{% endtrans %}</option>
|
||||||
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_footprint" data-url="{{ path('select_footprint') }}">{% trans %}part_list.action.action.change_footprint{% endtrans %}</option>
|
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_footprint" data-url="{{ path('select_footprint') }}">{% trans %}part_list.action.action.change_footprint{% endtrans %}</option>
|
||||||
|
|
@ -80,6 +84,9 @@
|
||||||
<select class="form-select d-none" data-controller="elements--structural-entity-select" name="target" {{ stimulus_target('elements/datatables/parts', 'selectTargetPicker') }}>
|
<select class="form-select d-none" data-controller="elements--structural-entity-select" name="target" {{ stimulus_target('elements/datatables/parts', 'selectTargetPicker') }}>
|
||||||
{# This is left empty, as this will be filled by Javascript #}
|
{# This is left empty, as this will be filled by Javascript #}
|
||||||
</select>
|
</select>
|
||||||
|
<input class="tagsinput d-none" data-controller="elements--tagsinput" name="target" {{ stimulus_target('elements/datatables/parts', 'selectTargetPickerTags') }}>
|
||||||
|
{# This is left empty, as this will be filled by Javascript #}
|
||||||
|
</input>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">{% trans %}part_list.action.submit{% endtrans %}</button>
|
<button type="submit" class="btn btn-primary">{% trans %}part_list.action.submit{% endtrans %}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,7 @@ class PartsTableActionHandlerTest extends WebTestCase
|
||||||
|
|
||||||
foreach ($formats as $format) {
|
foreach ($formats as $format) {
|
||||||
$action = "export_{$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->assertInstanceOf(RedirectResponse::class, $result);
|
||||||
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
|
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
|
||||||
$this->assertStringContainsString("format={$format}", $result->getTargetUrl());
|
$this->assertStringContainsString("format={$format}", $result->getTargetUrl());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue