From 76f0b05a096fcd06db15d505e7d1f8a7f37b7d6a Mon Sep 17 00:00:00 2001 From: buchmann Date: Wed, 11 Feb 2026 14:10:05 +0100 Subject: [PATCH 01/11] Autofocus for frequently used input fields Fixes #1157. - Focus `name` field on new part - Focus `amount` on add/withdraw modal - Focus first "number type" input on any newly added collectionType table row... (debatable) It would be even more favorable if the user could configure if they want to use autofocus and/or for which fields/dialogs it should be enabled. --- assets/controllers/elements/collection_type_controller.js | 4 ++++ assets/controllers/pages/part_withdraw_modal_controller.js | 5 +++++ src/Form/AdminPages/BaseEntityAdminForm.php | 1 + src/Form/Part/PartBaseType.php | 1 + 4 files changed, 11 insertions(+) diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index 14b683e0..19f5c531 100644 --- a/assets/controllers/elements/collection_type_controller.js +++ b/assets/controllers/elements/collection_type_controller.js @@ -79,9 +79,13 @@ export default class extends Controller { //Afterwards return the newly created row if(targetTable.tBodies[0]) { targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr); + var fields = targetTable.tBodies[0].querySelectorAll("input[type=number]"); + fields[fields.length - 1].focus(); return targetTable.tBodies[0].lastElementChild; } else { //Otherwise just insert it targetTable.insertAdjacentHTML('beforeend', newElementStr); + var fields = targetTable.querySelectorAll("input[type=number]"); + fields[fields.length - 1].focus(); return targetTable.lastElementChild; } } diff --git a/assets/controllers/pages/part_withdraw_modal_controller.js b/assets/controllers/pages/part_withdraw_modal_controller.js index 2d6742b4..0e5c0fc5 100644 --- a/assets/controllers/pages/part_withdraw_modal_controller.js +++ b/assets/controllers/pages/part_withdraw_modal_controller.js @@ -5,6 +5,7 @@ export default class extends Controller { connect() { this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event)); + this.element.addEventListener('shown.bs.modal', event => this._handleModalShown(event)); } _handleModalOpen(event) { @@ -61,4 +62,8 @@ export default class extends Controller amountInput.setAttribute('max', lotAmount); } } + + _handleModalShown(event) { + this.element.querySelector('input[name="amount"]').focus(); + } } \ No newline at end of file diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index 5a4ef5bc..64ccbdb9 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -71,6 +71,7 @@ class BaseEntityAdminForm extends AbstractType 'label' => 'name.label', 'attr' => [ 'placeholder' => 'part.name.placeholder', + 'autofocus' => true, ], 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index b8276589..0b69d477 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -115,6 +115,7 @@ class PartBaseType extends AbstractType 'label' => 'part.edit.name', 'attr' => [ 'placeholder' => 'part.edit.name.placeholder', + 'autofocus' => true, ], ]) ->add('description', RichTextEditorType::class, [ From 47c0d7898576bef007b8399cbf7ef0df7951d682 Mon Sep 17 00:00:00 2001 From: buchmann Date: Wed, 11 Feb 2026 14:26:36 +0100 Subject: [PATCH 02/11] only autofocus if new --- src/Form/AdminPages/BaseEntityAdminForm.php | 2 +- src/Form/Part/PartBaseType.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index 64ccbdb9..f4bf37f8 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -71,7 +71,7 @@ class BaseEntityAdminForm extends AbstractType 'label' => 'name.label', 'attr' => [ 'placeholder' => 'part.name.placeholder', - 'autofocus' => true, + 'autofocus' => $is_new, ], 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 0b69d477..89787f60 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -115,7 +115,7 @@ class PartBaseType extends AbstractType 'label' => 'part.edit.name', 'attr' => [ 'placeholder' => 'part.edit.name.placeholder', - 'autofocus' => true, + 'autofocus' => $new_part, ], ]) ->add('description', RichTextEditorType::class, [ From 1641708508adc6f4aedfe04d5f5ffa3cc4dda648 Mon Sep 17 00:00:00 2001 From: Niklas <44636701+MayNiklas@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:03:07 +0100 Subject: [PATCH 03/11] Added API endpoint for generating labels (#1234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init API endpoint for generating labels * Improved API docs for label endpoint * Improved LabelGenerationProcessor --------- Co-authored-by: Jan Böhmer --- src/ApiResource/LabelGenerationRequest.php | 84 ++++++++++ src/Entity/LabelSystem/LabelProfile.php | 27 ++- src/State/LabelGenerationProcessor.php | 141 ++++++++++++++++ tests/API/Endpoints/LabelEndpointTest.php | 186 +++++++++++++++++++++ 4 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 src/ApiResource/LabelGenerationRequest.php create mode 100644 src/State/LabelGenerationProcessor.php create mode 100644 tests/API/Endpoints/LabelEndpointTest.php diff --git a/src/ApiResource/LabelGenerationRequest.php b/src/ApiResource/LabelGenerationRequest.php new file mode 100644 index 00000000..9b4462a0 --- /dev/null +++ b/src/ApiResource/LabelGenerationRequest.php @@ -0,0 +1,84 @@ +. + */ + +namespace App\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model\Operation; +use ApiPlatform\OpenApi\Model\RequestBody; +use ApiPlatform\OpenApi\Model\Response; +use App\Entity\LabelSystem\LabelSupportedElement; +use App\State\LabelGenerationProcessor; +use App\Validator\Constraints\Misc\ValidRange; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * API Resource for generating PDF labels for parts, part lots, or storage locations. + * This endpoint allows generating labels using saved label profiles. + */ +#[ApiResource( + uriTemplate: '/labels/generate', + description: 'Generate PDF labels for parts, part lots, or storage locations using label profiles.', + operations: [ + new Post( + inputFormats: ['json' => ['application/json']], + outputFormats: [], + openapi: new Operation( + responses: [ + "200" => new Response(description: "PDF file containing the generated labels"), + ], + summary: 'Generate PDF labels', + description: 'Generate PDF labels for one or more elements using a label profile. Returns a PDF file.', + requestBody: new RequestBody( + description: 'Label generation request', + required: true, + ), + ), + ) + ], + processor: LabelGenerationProcessor::class, +)] +class LabelGenerationRequest +{ + /** + * @var int The ID of the label profile to use for generation + */ + #[Assert\NotBlank(message: 'Profile ID is required')] + #[Assert\Positive(message: 'Profile ID must be a positive integer')] + public int $profileId = 0; + + /** + * @var string Comma-separated list of element IDs or ranges (e.g., "1,2,5-10,15") + */ + #[Assert\NotBlank(message: 'Element IDs are required')] + #[ValidRange()] + #[ApiProperty(example: "1,2,5-10,15")] + public string $elementIds = ''; + + /** + * @var LabelSupportedElement|null Optional: Override the element type. If not provided, uses profile's default. + */ + public ?LabelSupportedElement $elementType = null; +} diff --git a/src/Entity/LabelSystem/LabelProfile.php b/src/Entity/LabelSystem/LabelProfile.php index d3616c34..236c07f7 100644 --- a/src/Entity/LabelSystem/LabelProfile.php +++ b/src/Entity/LabelSystem/LabelProfile.php @@ -41,6 +41,12 @@ declare(strict_types=1); namespace App\Entity\LabelSystem; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\OpenApi\Model\Operation; use Doctrine\Common\Collections\Criteria; use App\Entity\Attachments\Attachment; use App\Repository\LabelProfileRepository; @@ -58,6 +64,22 @@ use Symfony\Component\Validator\Constraints as Assert; /** * @extends AttachmentContainingDBElement */ +#[ApiResource( + operations: [ + new Get( + normalizationContext: ['groups' => ['label_profile:read', 'simple']], + security: "is_granted('read', object)", + openapi: new Operation(summary: 'Get a label profile by ID') + ), + new GetCollection( + normalizationContext: ['groups' => ['label_profile:read', 'simple']], + security: "is_granted('@labels.create_labels')", + openapi: new Operation(summary: 'List all available label profiles') + ), + ], + paginationEnabled: false, +)] +#[ApiFilter(SearchFilter::class, properties: ['options.supported_element' => 'exact', 'show_in_dropdown' => 'exact'])] #[UniqueEntity(['name', 'options.supported_element'])] #[ORM\Entity(repositoryClass: LabelProfileRepository::class)] #[ORM\EntityListeners([TreeCacheInvalidationListener::class])] @@ -80,20 +102,21 @@ class LabelProfile extends AttachmentContainingDBElement */ #[Assert\Valid] #[ORM\Embedded(class: 'LabelOptions')] - #[Groups(["extended", "full", "import"])] + #[Groups(["extended", "full", "import", "label_profile:read"])] protected LabelOptions $options; /** * @var string The comment info for this element */ #[ORM\Column(type: Types::TEXT)] + #[Groups(["extended", "full", "import", "label_profile:read"])] protected string $comment = ''; /** * @var bool determines, if this label profile should be shown in the dropdown quick menu */ #[ORM\Column(type: Types::BOOLEAN)] - #[Groups(["extended", "full", "import"])] + #[Groups(["extended", "full", "import", "label_profile:read"])] protected bool $show_in_dropdown = true; public function __construct() diff --git a/src/State/LabelGenerationProcessor.php b/src/State/LabelGenerationProcessor.php new file mode 100644 index 00000000..0472bbbd --- /dev/null +++ b/src/State/LabelGenerationProcessor.php @@ -0,0 +1,141 @@ +. + */ + +namespace App\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use App\ApiResource\LabelGenerationRequest; +use App\Entity\Base\AbstractDBElement; +use App\Entity\LabelSystem\LabelProfile; +use App\Repository\DBElementRepository; +use App\Repository\LabelProfileRepository; +use App\Services\ElementTypeNameGenerator; +use App\Services\LabelSystem\LabelGenerator; +use App\Services\Misc\RangeParser; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +class LabelGenerationProcessor implements ProcessorInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly LabelGenerator $labelGenerator, + private readonly RangeParser $rangeParser, + private readonly ElementTypeNameGenerator $elementTypeNameGenerator, + private readonly Security $security, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Response + { + // Check if user has permission to create labels + if (!$this->security->isGranted('@labels.create_labels')) { + throw new AccessDeniedHttpException('You do not have permission to generate labels.'); + } + + if (!$data instanceof LabelGenerationRequest) { + throw new BadRequestHttpException('Invalid request data for label generation.'); + } + + /** @var LabelGenerationRequest $request */ + $request = $data; + + // Fetch the label profile + /** @var LabelProfileRepository $profileRepo */ + $profileRepo = $this->entityManager->getRepository(LabelProfile::class); + $profile = $profileRepo->find($request->profileId); + if (!$profile instanceof LabelProfile) { + throw new NotFoundHttpException(sprintf('Label profile with ID %d not found.', $request->profileId)); + } + + // Check if user has read permission for the profile + if (!$this->security->isGranted('read', $profile)) { + throw new AccessDeniedHttpException('You do not have permission to access this label profile.'); + } + + // Get label options from profile + $options = $profile->getOptions(); + + // Override element type if provided, otherwise use profile's default + if ($request->elementType !== null) { + $options->setSupportedElement($request->elementType); + } + + // Parse element IDs from the range string + try { + $idArray = $this->rangeParser->parse($request->elementIds); + } catch (\InvalidArgumentException $e) { + throw new BadRequestHttpException('Invalid element IDs format: ' . $e->getMessage()); + } + + if (empty($idArray)) { + throw new BadRequestHttpException('No valid element IDs provided.'); + } + + // Fetch the target entities + /** @var DBElementRepository $repo */ + $repo = $this->entityManager->getRepository($options->getSupportedElement()->getEntityClass()); + + $elements = $repo->getElementsFromIDArray($idArray); + + if (empty($elements)) { + throw new NotFoundHttpException('No elements found with the provided IDs.'); + } + + // Generate the PDF + try { + $pdfContent = $this->labelGenerator->generateLabel($options, $elements); + } catch (\Exception $e) { + throw new BadRequestHttpException('Failed to generate label: ' . $e->getMessage()); + } + + // Generate filename + $filename = $this->generateFilename($elements[0], $profile); + + + // Return PDF as response + return new Response( + $pdfContent, + Response::HTTP_OK, + [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + 'Content-Length' => (string) strlen($pdfContent), + ] + ); + } + + private function generateFilename(AbstractDBElement $element, LabelProfile $profile): string + { + $ret = 'label_' . $this->elementTypeNameGenerator->typeLabel($element); + $ret .= $element->getID(); + $ret .= '_' . preg_replace('/[^a-z0-9_\-]/i', '_', $profile->getName()); + + return $ret . '.pdf'; + } +} diff --git a/tests/API/Endpoints/LabelEndpointTest.php b/tests/API/Endpoints/LabelEndpointTest.php new file mode 100644 index 00000000..338af836 --- /dev/null +++ b/tests/API/Endpoints/LabelEndpointTest.php @@ -0,0 +1,186 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\API\Endpoints; + +use App\Tests\API\AuthenticatedApiTestCase; + +class LabelEndpointTest extends AuthenticatedApiTestCase +{ + public function testGetLabelProfiles(): void + { + $response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + + // Check that we get an array of label profiles + $json = $response->toArray(); + self::assertIsArray($json['hydra:member']); + self::assertNotEmpty($json['hydra:member']); + + // Check the structure of the first profile + $firstProfile = $json['hydra:member'][0]; + self::assertArrayHasKey('@id', $firstProfile); + self::assertArrayHasKey('name', $firstProfile); + self::assertArrayHasKey('options', $firstProfile); + self::assertArrayHasKey('show_in_dropdown', $firstProfile); + } + + public function testGetSingleLabelProfile(): void + { + $response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles/1'); + + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + '@id' => '/api/label_profiles/1', + ]); + + $json = $response->toArray(); + self::assertArrayHasKey('name', $json); + self::assertArrayHasKey('options', $json); + // Note: options is serialized but individual fields like width/height + // are only available in 'extended' or 'full' serialization groups + self::assertIsArray($json['options']); + } + + public function testFilterLabelProfilesByElementType(): void + { + $response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles?options.supported_element=part'); + + self::assertResponseIsSuccessful(); + + $json = $response->toArray(); + // Check that we get results - the filter should work even if the field isn't in response + self::assertIsArray($json['hydra:member']); + // verify we got profiles + self::assertNotEmpty($json['hydra:member']); + } + + public function testGenerateLabelPdf(): void + { + $response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [ + 'json' => [ + 'profileId' => 1, + 'elementIds' => '1', + ], + ]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/pdf'); + + // Check that the response contains PDF data + $content = $response->getContent(); + self::assertStringStartsWith('%PDF-', $content); + + // Check Content-Disposition header contains attachment and .pdf + $headers = $response->getHeaders(); + self::assertArrayHasKey('content-disposition', $headers); + $disposition = $headers['content-disposition'][0]; + self::assertStringContainsString('attachment', $disposition); + self::assertStringContainsString('.pdf', $disposition); + } + + public function testGenerateLabelPdfWithMultipleElements(): void + { + $response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [ + 'json' => [ + 'profileId' => 1, + 'elementIds' => '1,2,3', + ], + ]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', $response->getContent()); + } + + public function testGenerateLabelPdfWithRange(): void + { + $response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [ + 'json' => [ + 'profileId' => 1, + 'elementIds' => '1-3', + ], + ]); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/pdf'); + self::assertStringStartsWith('%PDF-', $response->getContent()); + } + + public function testGenerateLabelPdfWithInvalidProfileId(): void + { + self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [ + 'json' => [ + 'profileId' => 99999, + 'elementIds' => '1', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + public function testGenerateLabelPdfWithInvalidElementIds(): void + { + $client = self::createAuthenticatedClient(); + $client->request('POST', '/api/labels/generate', [ + 'json' => [ + 'profileId' => 1, + 'elementIds' => 'invalid', + ], + ]); + + // Should return 400 or 422 (validation error) + $response = $client->getResponse(); + $statusCode = $response->getStatusCode(); + self::assertTrue( + $statusCode === 400 || $statusCode === 422, + "Expected status code 400 or 422, got {$statusCode}" + ); + } + + public function testGenerateLabelPdfWithNonExistentElements(): void + { + self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [ + 'json' => [ + 'profileId' => 1, + 'elementIds' => '99999', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + public function testGenerateLabelPdfRequiresAuthentication(): void + { + // Create a non-authenticated client + self::createClient()->request('POST', '/api/labels/generate', [ + 'json' => [ + 'profileId' => 1, + 'elementIds' => '1', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } +} From f6764170e1c3666b3f6b2201d5d44ec67bca52ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 16:16:15 +0100 Subject: [PATCH 04/11] Fixed phpstan issues --- src/Twig/Sandbox/SandboxedLabelExtension.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Twig/Sandbox/SandboxedLabelExtension.php b/src/Twig/Sandbox/SandboxedLabelExtension.php index 3b8eeed2..6fe85e80 100644 --- a/src/Twig/Sandbox/SandboxedLabelExtension.php +++ b/src/Twig/Sandbox/SandboxedLabelExtension.php @@ -65,28 +65,28 @@ class SandboxedLabelExtension extends AbstractExtension */ public function associatedParts(AbstractPartsContainingDBElement $element): array { - /** @var AbstractPartsContainingRepository $repo */ + /** @var AbstractPartsContainingRepository $repo */ $repo = $this->em->getRepository($element::class); return $repo->getParts($element); } public function associatedPartsCount(AbstractPartsContainingDBElement $element): int { - /** @var AbstractPartsContainingRepository $repo */ + /** @var AbstractPartsContainingRepository $repo */ $repo = $this->em->getRepository($element::class); return $repo->getPartsCount($element); } public function associatedPartsRecursive(AbstractPartsContainingDBElement $element): array { - /** @var AbstractPartsContainingRepository $repo */ + /** @var AbstractPartsContainingRepository $repo */ $repo = $this->em->getRepository($element::class); return $repo->getPartsRecursive($element); } public function associatedPartsCountRecursive(AbstractPartsContainingDBElement $element): int { - /** @var AbstractPartsContainingRepository $repo */ + /** @var AbstractPartsContainingRepository $repo */ $repo = $this->em->getRepository($element::class); return $repo->getPartsCountRecursive($element); } From e19929249ffb731cfa410b6b27e8d35009dcc705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 19:30:53 +0100 Subject: [PATCH 05/11] Mark parts datatables query as read only for some memory optimizations --- src/DataTables/PartsDataTable.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index fbc5211d..e6ed7c10 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -47,6 +47,7 @@ use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; use App\Settings\BehaviorSettings\TableSettings; use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; use Omines\DataTablesBundle\Column\TextColumn; @@ -364,7 +365,10 @@ final class PartsDataTable implements DataTableTypeInterface ->addGroupBy('attachments') ->addGroupBy('partUnit') ->addGroupBy('partCustomState') - ->addGroupBy('parameters'); + ->addGroupBy('parameters') + + ->setHint(Query::HINT_READ_ONLY, true) + ; //Get the results in the same order as the IDs were passed FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids'); From 8e0fcdb73bde8a909aae3c5eb8c74fe292f7664f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 20:07:38 +0100 Subject: [PATCH 06/11] Added some part datatables optimization --- src/DataTables/PartsDataTable.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index e6ed7c10..d2faba76 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -334,6 +334,7 @@ final class PartsDataTable implements DataTableTypeInterface ->addSelect('orderdetails') ->addSelect('attachments') ->addSelect('storelocations') + ->addSelect('projectBomEntries') ->from(Part::class, 'part') ->leftJoin('part.category', 'category') ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') @@ -348,6 +349,7 @@ final class PartsDataTable implements DataTableTypeInterface ->leftJoin('part.partUnit', 'partUnit') ->leftJoin('part.partCustomState', 'partCustomState') ->leftJoin('part.parameters', 'parameters') + ->leftJoin('part.project_bom_entries', 'projectBomEntries') ->where('part.id IN (:ids)') ->setParameter('ids', $ids) @@ -366,8 +368,10 @@ final class PartsDataTable implements DataTableTypeInterface ->addGroupBy('partUnit') ->addGroupBy('partCustomState') ->addGroupBy('parameters') + ->addGroupBy('projectBomEntries') ->setHint(Query::HINT_READ_ONLY, true) + ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false) ; //Get the results in the same order as the IDs were passed From c00556829a4dc5c58483568e3509c46aef1cda9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 21:43:47 +0100 Subject: [PATCH 07/11] Focus the first newly created number input for collection_types Improves PR #1240 --- .../elements/collection_type_controller.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index 06815a7c..647ed5e5 100644 --- a/assets/controllers/elements/collection_type_controller.js +++ b/assets/controllers/elements/collection_type_controller.js @@ -81,23 +81,29 @@ export default class extends Controller { //Afterwards return the newly created row if(targetTable.tBodies[0]) { targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr); - var fields = targetTable.tBodies[0].querySelectorAll("input[type=number]"); - fields[fields.length - 1].focus(); ret = targetTable.tBodies[0].lastElementChild; } else { //Otherwise just insert it targetTable.insertAdjacentHTML('beforeend', newElementStr); - var fields = targetTable.querySelectorAll("input[type=number]"); - fields[fields.length - 1].focus(); ret = targetTable.lastElementChild; } //Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true})); + this.focusNumberInput(ret); + return ret; } + focusNumberInput(element) { + const fields = element.querySelectorAll("input[type=number]"); + //Focus the first available number input field to open the numeric keyboard on mobile devices + if(fields.length > 0) { + fields[0].focus(); + } + } + /** * This action opens a file dialog to select multiple files and then creates a new element for each file, where * the file is assigned to the input field. From c17cf2baa1350701ef6d8547fdade52fea7ad238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 21:49:18 +0100 Subject: [PATCH 08/11] Fixed rendering of tristate checkboxes --- templates/form/extended_bootstrap_layout.html.twig | 5 +++++ templates/parts/edit/_eda.html.twig | 8 ++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/form/extended_bootstrap_layout.html.twig b/templates/form/extended_bootstrap_layout.html.twig index ecd7caf0..1227750c 100644 --- a/templates/form/extended_bootstrap_layout.html.twig +++ b/templates/form/extended_bootstrap_layout.html.twig @@ -155,3 +155,8 @@ {{- parent() -}} {% endif %} {% endblock %} + +{% block boolean_constraint_widget %} + {{ form_widget(form.value) }} + {{ form_errors(form.value) }} +{% endblock %} diff --git a/templates/parts/edit/_eda.html.twig b/templates/parts/edit/_eda.html.twig index 4df675c4..1383871e 100644 --- a/templates/parts/edit/_eda.html.twig +++ b/templates/parts/edit/_eda.html.twig @@ -1,11 +1,7 @@ {{ form_row(form.eda_info.reference_prefix) }} {{ form_row(form.eda_info.value) }} -
-
- {{ form_row(form.eda_info.visibility) }} -
-
+{{ form_row(form.eda_info.visibility) }}
@@ -21,4 +17,4 @@
{{ form_row(form.eda_info.kicad_symbol) }} -{{ form_row(form.eda_info.kicad_footprint) }} \ No newline at end of file +{{ form_row(form.eda_info.kicad_footprint) }} From 6afca44897b2cc7a9eab442fdb12cd0f00a0269e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 22:19:44 +0100 Subject: [PATCH 09/11] Use xxh3 hashes instead of encoding for info provider cache keys --- src/Services/InfoProviderSystem/PartInfoRetriever.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 0eb74642..9a24f3ae 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -82,7 +82,7 @@ final class PartInfoRetriever protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array { //Generate key and escape reserved characters from the provider id - $escaped_keyword = urlencode($keyword); + $escaped_keyword = hash('xxh3', $keyword); return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) { //Set the expiration time $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1); @@ -108,7 +108,7 @@ final class PartInfoRetriever } //Generate key and escape reserved characters from the provider id - $escaped_part_id = urlencode($part_id); + $escaped_part_id = hash('xxh3', $part_id); return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) { //Set the expiration time $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1); @@ -145,4 +145,4 @@ final class PartInfoRetriever return $this->dto_to_entity_converter->convertPart($details); } -} \ No newline at end of file +} From 7354b37ef6b29bdf8d09984232e3bad51742b8b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 22:24:00 +0100 Subject: [PATCH 10/11] New Crowdin updates (#1228) * New translations messages.en.xlf (German) * New translations messages.en.xlf (German) * New translations validators.en.xlf (Polish) * New translations security.en.xlf (Danish) * New translations security.en.xlf (Ukrainian) * New translations security.en.xlf (German) * New translations security.en.xlf (Hungarian) * New translations security.en.xlf (Dutch) * New translations security.en.xlf (Chinese Simplified) * New translations messages.en.xlf (English) * New translations validators.en.xlf (English) * New translations security.en.xlf (English) * New translations frontend.en.xlf (Danish) * New translations frontend.en.xlf (German) * New translations frontend.en.xlf (Hungarian) * New translations frontend.en.xlf (Ukrainian) * New translations frontend.en.xlf (Chinese Simplified) * New translations frontend.en.xlf (English) * New translations messages.en.xlf (German) --- translations/frontend.da.xlf | 2 +- translations/frontend.de.xlf | 2 +- translations/frontend.en.xlf | 2 +- translations/frontend.hu.xlf | 2 +- translations/frontend.uk.xlf | 2 +- translations/frontend.zh.xlf | 2 +- translations/messages.de.xlf | 120 +++++++++++++++++++++++++++++++-- translations/messages.en.xlf | 36 +++++----- translations/security.da.xlf | 2 +- translations/security.de.xlf | 2 +- translations/security.en.xlf | 2 +- translations/security.hu.xlf | 2 +- translations/security.nl.xlf | 2 +- translations/security.uk.xlf | 2 +- translations/security.zh.xlf | 2 +- translations/validators.en.xlf | 2 +- translations/validators.pl.xlf | 16 ++++- 17 files changed, 160 insertions(+), 40 deletions(-) diff --git a/translations/frontend.da.xlf b/translations/frontend.da.xlf index 9c6b3129..4b6a15b9 100644 --- a/translations/frontend.da.xlf +++ b/translations/frontend.da.xlf @@ -56,4 +56,4 @@ - \ No newline at end of file + diff --git a/translations/frontend.de.xlf b/translations/frontend.de.xlf index 4eaded60..9ebd0d32 100644 --- a/translations/frontend.de.xlf +++ b/translations/frontend.de.xlf @@ -56,4 +56,4 @@ - \ No newline at end of file + diff --git a/translations/frontend.en.xlf b/translations/frontend.en.xlf index aa3cf2d9..91617f79 100644 --- a/translations/frontend.en.xlf +++ b/translations/frontend.en.xlf @@ -56,4 +56,4 @@ - \ No newline at end of file + diff --git a/translations/frontend.hu.xlf b/translations/frontend.hu.xlf index bdcda170..c303dedc 100644 --- a/translations/frontend.hu.xlf +++ b/translations/frontend.hu.xlf @@ -56,4 +56,4 @@ - \ No newline at end of file + diff --git a/translations/frontend.uk.xlf b/translations/frontend.uk.xlf index 86f51f95..fee1b03e 100644 --- a/translations/frontend.uk.xlf +++ b/translations/frontend.uk.xlf @@ -56,4 +56,4 @@ - \ No newline at end of file + diff --git a/translations/frontend.zh.xlf b/translations/frontend.zh.xlf index d2ea6fd0..8bb063b8 100644 --- a/translations/frontend.zh.xlf +++ b/translations/frontend.zh.xlf @@ -56,4 +56,4 @@ - \ No newline at end of file + diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index c4f2d5d8..c20e8152 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -11868,7 +11868,7 @@ Buerklin-API-Authentication-Server: update_manager.view_release - update_manager.view_release + Release ansehen @@ -11964,7 +11964,7 @@ Buerklin-API-Authentication-Server: update_manager.view_release_notes - update_manager.view_release_notes + Release notes ansehen @@ -12102,7 +12102,7 @@ Buerklin-API-Authentication-Server: perm.system.manage_updates - perm.system.manage_updates + Part-DB Updated verwalten @@ -12354,13 +12354,13 @@ Buerklin-API-Authentication-Server: settings.ips.generic_web_provider.enabled.help - settings.ips.generic_web_provider.enabled.help + Wenn der Anbieter aktiviert ist, können Benutzer im Namen des Part-DB-Servers Anfragen an beliebige Websites stellen. Aktivieren Sie diese Option nur, wenn Sie sich der möglichen Folgen bewusst sind. info_providers.from_url.title - Erstelle [part] aus URL + Erstelle [Part] aus URL @@ -12399,5 +12399,113 @@ Buerklin-API-Authentication-Server: Update zu + + + part.gtin + GTIN / EAN + + + + + info_providers.capabilities.gtin + GTIN / EAN + + + + + part.table.gtin + GTIN + + + + + scan_dialog.mode.gtin + GTIN / EAN Barcode + + + + + attachment_type.edit.allowed_targets + Nur verwenden für + + + + + attachment_type.edit.allowed_targets.help + Machen Sie diesen Anhangstyp nur für bestimmte Elementtypen verfügbar. Leer lassen, um diesen Anhangstyp für alle Elementtypen anzuzeigen. + + + + + orderdetails.edit.prices_includes_vat + Preise einschl. MwSt. + + + + + prices.incl_vat + Inkl. MwSt. + + + + + prices.excl_vat + Exkl. MwSt. + + + + + settings.system.localization.prices_include_tax_by_default + Preise enthalten standardmäßig Mehrwertsteuer + + + + + settings.system.localization.prices_include_tax_by_default.description + Der Standardwert für neu erstellte Einkaufinformationen, ob die Preise Mehrwertsteuer enthalten oder nicht. + + + + + part_lot.edit.last_stocktake_at + Letzte Inventur + + + + + perm.parts_stock.stocktake + Inventur + + + + + part.info.stocktake_modal.title + Inventur des Bestandes + + + + + part.info.stocktake_modal.expected_amount + Erwartete Menge + + + + + part.info.stocktake_modal.actual_amount + Tatsächliche Menge + + + + + log.part_stock_changed.stock_take + Inventur + + + + + log.element_edited.changed_fields.last_stocktake_at + Letzte Inventur + + - \ No newline at end of file + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index bbd96ac6..d9418563 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12402,109 +12402,109 @@ Buerklin-API Authentication server: - + part.gtin GTIN / EAN - + info_providers.capabilities.gtin GTIN / EAN - + part.table.gtin GTIN - + scan_dialog.mode.gtin GTIN / EAN barcode - + attachment_type.edit.allowed_targets Use only for - + attachment_type.edit.allowed_targets.help Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes. - + orderdetails.edit.prices_includes_vat Prices include VAT - + prices.incl_vat Incl. VAT - + prices.excl_vat Excl. VAT - + settings.system.localization.prices_include_tax_by_default Prices include VAT by default - + settings.system.localization.prices_include_tax_by_default.description The default value for newly created purchase infos, if prices include VAT or not. - + part_lot.edit.last_stocktake_at Last stocktake - + perm.parts_stock.stocktake Stocktake - + part.info.stocktake_modal.title Stocktake lot - + part.info.stocktake_modal.expected_amount Expected amount - + part.info.stocktake_modal.actual_amount Actual amount - + log.part_stock_changed.stock_take Stocktake - + log.element_edited.changed_fields.last_stocktake_at Last stocktake diff --git a/translations/security.da.xlf b/translations/security.da.xlf index 99329533..ab35c605 100644 --- a/translations/security.da.xlf +++ b/translations/security.da.xlf @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/translations/security.de.xlf b/translations/security.de.xlf index 2a357094..927f8f9c 100644 --- a/translations/security.de.xlf +++ b/translations/security.de.xlf @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/translations/security.en.xlf b/translations/security.en.xlf index 5a79d6ec..0b0b4569 100644 --- a/translations/security.en.xlf +++ b/translations/security.en.xlf @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/translations/security.hu.xlf b/translations/security.hu.xlf index 3c885815..7c448da0 100644 --- a/translations/security.hu.xlf +++ b/translations/security.hu.xlf @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/translations/security.nl.xlf b/translations/security.nl.xlf index 0e4ecc41..7ba9fcc1 100644 --- a/translations/security.nl.xlf +++ b/translations/security.nl.xlf @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/translations/security.uk.xlf b/translations/security.uk.xlf index 03be9410..12737cf3 100644 --- a/translations/security.uk.xlf +++ b/translations/security.uk.xlf @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/translations/security.zh.xlf b/translations/security.zh.xlf index 181c9c0f..58fbb26f 100644 --- a/translations/security.zh.xlf +++ b/translations/security.zh.xlf @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index ed824f0b..624c6a89 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -248,7 +248,7 @@ - + validator.invalid_gtin This is not an valid GTIN / EAN! diff --git a/translations/validators.pl.xlf b/translations/validators.pl.xlf index 03942667..e5392d76 100644 --- a/translations/validators.pl.xlf +++ b/translations/validators.pl.xlf @@ -148,7 +148,7 @@ project.bom_has_to_include_all_subelement_parts - BOM projektu musi zawierać wszystkie komponenty produkcyjne podprojektów. Brakuje komponentu %part_name% projektu %project_name%! + BOM projektu musi zawierać wszystkie komponenty produkcyjne pod projektów @@ -223,6 +223,12 @@ Ze względu na ograniczenia techniczne nie jest możliwe wybranie daty po 19 stycznia 2038 w systemach 32-bitowych! + + + validator.fileSize.invalidFormat + Niewłaściwy format + + validator.invalid_range @@ -235,5 +241,11 @@ Nieprawidłowy kod. Sprawdź, czy aplikacja uwierzytelniająca jest poprawnie skonfigurowana i czy zarówno serwer, jak i urządzenie uwierzytelniające mają poprawnie ustawiony czas. + + + settings.synonyms.type_synonyms.collection_type.duplicate + Duplikuj + + - \ No newline at end of file + From be808e28bcaeb4d60f4b076fb3cd4336dcbb6690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 15 Feb 2026 22:29:16 +0100 Subject: [PATCH 11/11] Updated dependencies --- composer.lock | 16 ++++++++++------ yarn.lock | 12 ++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/composer.lock b/composer.lock index 6566ebbc..2db828a5 100644 --- a/composer.lock +++ b/composer.lock @@ -16558,16 +16558,16 @@ }, { "name": "thecodingmachine/safe", - "version": "v3.3.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", "shasum": "" }, "require": { @@ -16677,7 +16677,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" }, "funding": [ { @@ -16688,12 +16688,16 @@ "url": "https://github.com/shish", "type": "github" }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, { "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-05-14T06:15:44+00:00" + "time": "2026-02-04T18:08:13+00:00" }, { "name": "tiendanube/gtinvalidation", diff --git a/yarn.lock b/yarn.lock index e890f0fe..e3d72ad7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2800,9 +2800,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001759: - version "1.0.30001769" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2" - integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg== + version "1.0.30001770" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84" + integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw== ccount@^2.0.0: version "2.0.1" @@ -7855,9 +7855,9 @@ webpack-sources@^2.0.1, webpack-sources@^2.2.0: source-map "^0.6.1" webpack-sources@^3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" - integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== + version "3.3.4" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891" + integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q== webpack@^5.74.0: version "5.105.2"