';
+ }
}
disconnect() {
diff --git a/migrations/Version20260903120000.php b/migrations/Version20260903120000.php
new file mode 100644
index 000000000..d9c730301
--- /dev/null
+++ b/migrations/Version20260903120000.php
@@ -0,0 +1,46 @@
+addSql('ALTER TABLE part_custom_states ADD color VARCHAR(20) DEFAULT NULL');
+ }
+
+ public function mySQLDown(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE part_custom_states DROP COLUMN color');
+ }
+
+ public function sqLiteUp(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE part_custom_states ADD COLUMN color VARCHAR(20) DEFAULT NULL');
+ }
+
+ public function sqLiteDown(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE part_custom_states DROP COLUMN color');
+ }
+
+ public function postgreSQLUp(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE part_custom_states ADD color VARCHAR(20) DEFAULT NULL');
+ }
+
+ public function postgreSQLDown(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE part_custom_states DROP COLUMN color');
+ }
+}
diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php
index 065e1201c..80a962e06 100644
--- a/src/DataTables/Helpers/PartDataTableHelper.php
+++ b/src/DataTables/Helpers/PartDataTableHelper.php
@@ -27,6 +27,7 @@
use App\Entity\ProjectSystem\Project;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
+use App\Entity\Parts\PartCustomState;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\EntityURLGenerator;
@@ -170,6 +171,22 @@ public function renderEdaStatus(Part $context): string
return sprintf('%s', $editUrl, $statusIcon);
}
+ /**
+ * Renders the custom state of a part as the colored badge it is configured with.
+ * Returns an empty string if the part has no custom state.
+ */
+ public function renderPartCustomState(?PartCustomState $state): string
+ {
+ if ($state === null) {
+ return '';
+ }
+
+ return sprintf('%s',
+ htmlspecialchars($state->getBadgeClass()),
+ htmlspecialchars($state->getName())
+ );
+ }
+
public function renderAmount(Part $context): string
{
$amount = $context->getAmountSum();
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index c15468de5..a43a1939e 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -198,18 +198,11 @@ public function configure(DataTable $dataTable, array $options): void
return $tmp;
}
])
- ->add('partCustomState', TextColumn::class, [
+ ->add('partCustomState', HTMLColumn::class, [
'label' => $this->translator->trans('part.table.partCustomState'),
'orderField' => 'NATSORT(_partCustomState.name)',
- 'data' => function(Part $context): string {
- $partCustomState = $context->getPartCustomState();
-
- if ($partCustomState === null) {
- return '';
- }
-
- return $partCustomState->getName();
- }
+ 'data' => fn(Part $context): string
+ => $this->partDataTableHelper->renderPartCustomState($context->getPartCustomState()),
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),
diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php
index 0f7af87c9..3245d097e 100644
--- a/src/DataTables/ProjectBomEntriesDataTable.php
+++ b/src/DataTables/ProjectBomEntriesDataTable.php
@@ -177,6 +177,14 @@ public function configure(DataTable $dataTable, array $options): void
},
])
+ ->add('partCustomState', HTMLColumn::class, [
+ 'label' => $this->translator->trans('part.table.partCustomState'),
+ 'orderField' => 'NATSORT(partCustomState.name)',
+ 'visible' => false,
+ 'data' => fn (ProjectBOMEntry $context): string
+ => $this->partDataTableHelper->renderPartCustomState($context->getPart()?->getPartCustomState()),
+ ])
+
->add('mountnames', HTMLColumn::class, [
'label' => 'project.bom.mountnames',
'data' => function (ProjectBOMEntry $context) {
diff --git a/src/Entity/Parts/PartCustomState.php b/src/Entity/Parts/PartCustomState.php
index 29a96c007..fb11921a8 100644
--- a/src/Entity/Parts/PartCustomState.php
+++ b/src/Entity/Parts/PartCustomState.php
@@ -22,13 +22,11 @@
namespace App\Entity\Parts;
-use ApiPlatform\Metadata\ApiProperty;
-use App\Entity\Attachments\Attachment;
-use App\Entity\Attachments\PartCustomStateAttachment;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -39,9 +37,12 @@
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\PartCustomStateAttachment;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\PartCustomStateParameter;
+use App\Helpers\BootstrapColor;
use App\Mcp\DTO\ElementByIdInput;
use App\Mcp\DTO\StructuralElementOverview;
use App\Mcp\DTO\StructuralElementSearchInput;
@@ -50,6 +51,7 @@
use App\State\Mcp\ListStructuralElementsProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
+use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -108,6 +110,14 @@ class PartCustomState extends AbstractPartsContainingDBElement
#[Groups(['part_custom_state:read', 'part_custom_state:write', 'full', 'import'])]
protected string $comment = '';
+ /**
+ * @var BootstrapColor|null The semantic color this state is rendered as a badge with.
+ * Null keeps the default, uncolored appearance Part-DB used before this field existed.
+ */
+ #[ORM\Column(type: Types::STRING, length: 20, nullable: true, enumType: BootstrapColor::class)]
+ #[Groups(['part_custom_state:read', 'part_custom_state:write', 'full', 'import'])]
+ protected ?BootstrapColor $color = null;
+
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent', cascade: ['persist'])]
#[ORM\OrderBy(['name' => 'ASC'])]
protected Collection $children;
@@ -152,4 +162,26 @@ public function __construct()
$this->attachments = new ArrayCollection();
$this->parameters = new ArrayCollection();
}
+
+ public function getColor(): ?BootstrapColor
+ {
+ return $this->color;
+ }
+
+ public function setColor(?BootstrapColor $color): self
+ {
+ $this->color = $color;
+
+ return $this;
+ }
+
+ /**
+ * Returns the CSS class this state is rendered as a badge with, everywhere it is shown.
+ * Without a configured color this is the color Part-DB used before the color existed, so an unconfigured
+ * state keeps looking exactly the way it did.
+ */
+ public function getBadgeClass(): string
+ {
+ return $this->color?->toBadgeClass() ?? 'bg-primary';
+ }
}
diff --git a/src/Form/AdminPages/PartCustomStateAdminForm.php b/src/Form/AdminPages/PartCustomStateAdminForm.php
index b8bb2815e..154e4d86a 100644
--- a/src/Form/AdminPages/PartCustomStateAdminForm.php
+++ b/src/Form/AdminPages/PartCustomStateAdminForm.php
@@ -22,6 +22,28 @@
namespace App\Form\AdminPages;
+use App\Entity\Base\AbstractNamedDBElement;
+use App\Entity\Parts\PartCustomState;
+use App\Form\Type\BootstrapColorType;
+use App\Helpers\BootstrapColor;
+use Symfony\Component\Form\Extension\Core\Type\EnumType;
+use Symfony\Component\Form\FormBuilderInterface;
+
class PartCustomStateAdminForm extends BaseEntityAdminForm
{
+ protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void
+ {
+ if (!$entity instanceof PartCustomState) {
+ return;
+ }
+
+ $is_new = null === $entity->getID();
+
+ $builder->add('color', BootstrapColorType::class, [
+ 'required' => false,
+ 'label' => 'part_custom_state.color.label',
+ 'help' => 'part_custom_state.color.help',
+ 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
+ ]);
+ }
}
diff --git a/src/Form/Type/BootstrapColorType.php b/src/Form/Type/BootstrapColorType.php
new file mode 100644
index 000000000..331fc0f5b
--- /dev/null
+++ b/src/Form/Type/BootstrapColorType.php
@@ -0,0 +1,46 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Type;
+
+use App\Helpers\BootstrapColor;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\EnumType;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class BootstrapColorType extends AbstractType
+{
+ public function getParent(): string
+ {
+ return EnumType::class;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'class' => BootstrapColor::class,
+ 'choice_label' => fn (BootstrapColor $color) => $color->toTranslationKey(),
+ 'choice_attr' => fn (BootstrapColor $color) => ['data-class' => 'badge '. $color->toBadgeClass()],
+ ]);
+ }
+}
diff --git a/src/Helpers/BootstrapColor.php b/src/Helpers/BootstrapColor.php
new file mode 100644
index 000000000..5a5eb690b
--- /dev/null
+++ b/src/Helpers/BootstrapColor.php
@@ -0,0 +1,53 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Helpers;
+
+/**
+ * The semantic Bootstrap color a PartCustomState can be rendered with.
+ * This is a closed whitelist: no free-form CSS classes or colors can be stored.
+ */
+enum BootstrapColor: string
+{
+ case PRIMARY = 'primary';
+ case SECONDARY = 'secondary';
+ case INFO = 'info';
+ case SUCCESS = 'success';
+ case WARNING = 'warning';
+ case DANGER = 'danger';
+ case LIGHT = 'light';
+ case DARK = 'dark';
+
+ public function toTranslationKey(): string
+ {
+ return 'part_custom_state.color.' . $this->value;
+ }
+
+ /**
+ * Maps this color to the fixed Bootstrap badge class it is rendered with.
+ * This is the only place that translates a stored color into a CSS class.
+ */
+ public function toBadgeClass(): string
+ {
+ return 'text-bg-' . $this->value;
+ }
+}
diff --git a/src/Serializer/APIPlatform/AbstractResourceIriDenormalizationTrait.php b/src/Serializer/APIPlatform/AbstractResourceIriDenormalizationTrait.php
index a8981d26f..3ada629e3 100644
--- a/src/Serializer/APIPlatform/AbstractResourceIriDenormalizationTrait.php
+++ b/src/Serializer/APIPlatform/AbstractResourceIriDenormalizationTrait.php
@@ -29,8 +29,9 @@
/**
* Shared denormalize()/supportsDenormalization() implementation for AbstractResourceIriNormalizer and
- * AbstractResourceIriDenormalizer. The using class must provide an $inner (de)normalizer and an
- * IriConverterInterface $iriConverter property.
+ * AbstractResourceIriDenormalizer. The using class must provide an $inner (de)normalizer, an
+ * IriConverterInterface $iriConverter property and a ResourceClassResolverInterface $resourceClassResolver
+ * property.
*
* It works around a bug in API Platform's AbstractItemNormalizer where IRI strings for abstract resource classes
* with a discriminator map fail deserialization when objectToPopulate is null (the discriminator is checked before
@@ -40,7 +41,18 @@ trait AbstractResourceIriDenormalizationTrait
{
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
- if (is_string($data) || (is_array($data) && isset($data['@id']) && is_string($data['@id']))) {
+ // API Platform's AbstractItemNormalizer has a bug: when objectToPopulate is null and data is an IRI
+ // string, it tries to resolve the discriminator class from [$iri_string] before reaching the IRI
+ // check (line 271). For abstract resource classes with a discriminator map (e.g. Attachment), this
+ // fails because the array has no _type key. Fix by resolving IRI strings directly.
+ // See: https://github.com/Part-DB/Part-DB-server/issues/1370
+ //
+ // $type must actually be an API resource for this to make sense: this normalizer also runs for plain
+ // value objects (e.g. backed enums) nested inside a resource, and a string value there is the enum's
+ // scalar value, not an IRI - treating it as one silently swallows the value instead of letting the
+ // regular (enum) normalizer handle it.
+ if ($this->resourceClassResolver->isResourceClass($type)
+ && (is_string($data) || (is_array($data) && isset($data['@id']) && is_string($data['@id'])))) {
$iri = is_array($data) ? $data['@id'] : $data;
try {
diff --git a/src/Serializer/APIPlatform/AbstractResourceIriDenormalizer.php b/src/Serializer/APIPlatform/AbstractResourceIriDenormalizer.php
index bce4ec903..9cc124132 100644
--- a/src/Serializer/APIPlatform/AbstractResourceIriDenormalizer.php
+++ b/src/Serializer/APIPlatform/AbstractResourceIriDenormalizer.php
@@ -24,6 +24,7 @@
namespace App\Serializer\APIPlatform;
use ApiPlatform\Metadata\IriConverterInterface;
+use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Serializer\ItemDenormalizer;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
@@ -45,6 +46,7 @@ class AbstractResourceIriDenormalizer implements DenormalizerInterface, Serializ
public function __construct(
private readonly ItemDenormalizer $inner,
private readonly IriConverterInterface $iriConverter,
+ private readonly ResourceClassResolverInterface $resourceClassResolver,
) {
}
diff --git a/src/Serializer/APIPlatform/AbstractResourceIriNormalizer.php b/src/Serializer/APIPlatform/AbstractResourceIriNormalizer.php
index b9d2e0fae..0cb01a5f5 100644
--- a/src/Serializer/APIPlatform/AbstractResourceIriNormalizer.php
+++ b/src/Serializer/APIPlatform/AbstractResourceIriNormalizer.php
@@ -24,6 +24,7 @@
namespace App\Serializer\APIPlatform;
use ApiPlatform\Metadata\IriConverterInterface;
+use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Serializer\ItemNormalizer;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
@@ -52,6 +53,7 @@ class AbstractResourceIriNormalizer implements NormalizerInterface, Denormalizer
public function __construct(
private readonly ItemNormalizer $inner,
private readonly IriConverterInterface $iriConverter,
+ private readonly ResourceClassResolverInterface $resourceClassResolver,
) {
}
diff --git a/templates/admin/part_custom_state_admin.html.twig b/templates/admin/part_custom_state_admin.html.twig
index 9d8576468..dac799e00 100644
--- a/templates/admin/part_custom_state_admin.html.twig
+++ b/templates/admin/part_custom_state_admin.html.twig
@@ -12,3 +12,7 @@
{% trans %}part_custom_state.new{% endtrans %}
{% endblock %}
+{% block additional_controls %}
+ {{ form_row(form.color) }}
+{% endblock %}
+
diff --git a/templates/parts/info/_sidebar.html.twig b/templates/parts/info/_sidebar.html.twig
index 120602418..cd5d922ba 100644
--- a/templates/parts/info/_sidebar.html.twig
+++ b/templates/parts/info/_sidebar.html.twig
@@ -47,7 +47,7 @@
{% if part.partCustomState is not null %}
- {{ part.partCustomState.name }}
+ {{ part.partCustomState.name }}
{% if part.partCustomState is not null and part.partCustomState.masterPictureAttachment and attachment_manager.fileExisting(part.partCustomState.masterPictureAttachment) %}
diff --git a/tests/API/Endpoints/PartCustomStateEndpointTest.php b/tests/API/Endpoints/PartCustomStateEndpointTest.php
index 8d1253f30..def1d991a 100644
--- a/tests/API/Endpoints/PartCustomStateEndpointTest.php
+++ b/tests/API/Endpoints/PartCustomStateEndpointTest.php
@@ -66,4 +66,22 @@ public function testDeleteItem(): void
{
$this->_testDeleteItem(4);
}
+
+ public function testColorIsWritableAndReadable(): void
+ {
+ $this->_testPatchItem(5, [
+ 'color' => 'warning',
+ ]);
+ self::assertJsonContains([
+ 'color' => 'warning',
+ ]);
+
+ //Unsetting the color must be possible again (back to the default, uncolored appearance).
+ //Like every other null-valued field in this API, an unset color is omitted from the response entirely
+ //rather than being serialized as an explicit "color": null (see e.g. InfoProviderEndpointTest).
+ $response = $this->_testPatchItem(5, [
+ 'color' => null,
+ ]);
+ self::assertArrayNotHasKey('color', json_decode($response->getContent(), true));
+ }
}
diff --git a/tests/Helpers/BootstrapColorTest.php b/tests/Helpers/BootstrapColorTest.php
new file mode 100644
index 000000000..42e1ff8fa
--- /dev/null
+++ b/tests/Helpers/BootstrapColorTest.php
@@ -0,0 +1,106 @@
+.
+ */
+
+declare(strict_types=1);
+
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+namespace App\Tests\Helpers;
+
+use App\Entity\Parts\PartCustomState;
+use App\Helpers\BootstrapColor;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+final class BootstrapColorTest extends TestCase
+{
+ public static function colorProvider(): array
+ {
+ return [
+ [BootstrapColor::PRIMARY, 'text-bg-primary'],
+ [BootstrapColor::SECONDARY, 'text-bg-secondary'],
+ [BootstrapColor::INFO, 'text-bg-info'],
+ [BootstrapColor::SUCCESS, 'text-bg-success'],
+ [BootstrapColor::WARNING, 'text-bg-warning'],
+ [BootstrapColor::DANGER, 'text-bg-danger'],
+ [BootstrapColor::LIGHT, 'text-bg-light'],
+ [BootstrapColor::DARK, 'text-bg-dark'],
+ ];
+ }
+
+ #[DataProvider('colorProvider')]
+ public function testToBadgeClassMapsEveryColorToAFixedClass(BootstrapColor $color, string $expectedClass): void
+ {
+ $this->assertSame($expectedClass, $color->toBadgeClass());
+ }
+
+ public function testUnknownColorValueIsRejected(): void
+ {
+ $this->assertNull(BootstrapColor::tryFrom('not-a-real-color'));
+ }
+
+ public function testPartCustomStateDefaultsToNoColor(): void
+ {
+ $state = new PartCustomState();
+
+ $this->assertNull($state->getColor());
+ }
+
+ public function testPartCustomStateColorCanBeSetAndRetrieved(): void
+ {
+ $state = new PartCustomState();
+ $state->setColor(BootstrapColor::WARNING);
+
+ $this->assertSame(BootstrapColor::WARNING, $state->getColor());
+
+ $state->setColor(null);
+ $this->assertNull($state->getColor());
+ }
+
+ public function testBadgeClassFallsBackToThePreviousAppearance(): void
+ {
+ //A state without a configured color has to keep looking exactly the way it did before colors existed
+ $this->assertSame('bg-primary', (new PartCustomState())->getBadgeClass());
+ }
+
+ #[DataProvider('colorProvider')]
+ public function testBadgeClassUsesTheConfiguredColor(BootstrapColor $color, string $expectedClass): void
+ {
+ $state = (new PartCustomState())->setColor($color);
+
+ $this->assertSame($expectedClass, $state->getBadgeClass());
+ }
+}
diff --git a/tests/Serializer/APIPlatform/AbstractResourceIriNormalizerTest.php b/tests/Serializer/APIPlatform/AbstractResourceIriNormalizerTest.php
new file mode 100644
index 000000000..abcbae4bd
--- /dev/null
+++ b/tests/Serializer/APIPlatform/AbstractResourceIriNormalizerTest.php
@@ -0,0 +1,111 @@
+.
+ */
+namespace App\Tests\Serializer\APIPlatform;
+
+use ApiPlatform\Metadata\IriConverterInterface;
+use ApiPlatform\Metadata\ResourceClassResolverInterface;
+use ApiPlatform\Serializer\ItemNormalizer;
+use App\Entity\Attachments\Attachment;
+use App\Serializer\APIPlatform\AbstractResourceIriNormalizer;
+use PHPUnit\Framework\TestCase;
+
+final class AbstractResourceIriNormalizerTest extends TestCase
+{
+ private ItemNormalizer&\PHPUnit\Framework\MockObject\MockObject $inner;
+ private IriConverterInterface&\PHPUnit\Framework\MockObject\MockObject $iriConverter;
+ private ResourceClassResolverInterface&\PHPUnit\Framework\MockObject\MockObject $resourceClassResolver;
+ private AbstractResourceIriNormalizer $normalizer;
+
+ protected function setUp(): void
+ {
+ $this->inner = $this->createMock(ItemNormalizer::class);
+ $this->iriConverter = $this->createMock(IriConverterInterface::class);
+ $this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
+ $this->normalizer = new AbstractResourceIriNormalizer($this->inner, $this->iriConverter, $this->resourceClassResolver);
+ }
+
+ public function testStringIsResolvedAsIriForResourceClass(): void
+ {
+ //Regression guard for https://github.com/Part-DB/Part-DB-server/issues/1370: an IRI string for a resource
+ //class (like Attachment, which has a discriminator map) must still be resolved via the IriConverter.
+ $this->resourceClassResolver->method('isResourceClass')->with(Attachment::class)->willReturn(true);
+
+ $attachment = $this->createMock(Attachment::class);
+ $this->iriConverter->expects($this->once())
+ ->method('getResourceFromIri')
+ ->with('/api/attachments/1')
+ ->willReturn($attachment);
+
+ $result = $this->normalizer->denormalize('/api/attachments/1', Attachment::class);
+
+ $this->assertSame($attachment, $result);
+ }
+
+ public function testStringIsNotResolvedAsIriForNonResourceClass(): void
+ {
+ //A backed enum (or any other plain value object) is not an API resource, so a string value denormalized
+ //into it is the enum's scalar value, not an IRI - it must be passed through to the inner normalizer
+ //(which delegates to Symfony's BackedEnumNormalizer) instead of being swallowed by a failed IRI lookup.
+ $this->resourceClassResolver->method('isResourceClass')->willReturn(false);
+
+ $this->iriConverter->expects($this->never())->method('getResourceFromIri');
+ $this->inner->expects($this->once())
+ ->method('denormalize')
+ ->with('warning', 'SomeEnum', null, [])
+ ->willReturn('warning-denormalized');
+
+ $result = $this->normalizer->denormalize('warning', 'SomeEnum');
+
+ $this->assertSame('warning-denormalized', $result);
+ }
+
+ public function testArrayWithIriIsResolvedForResourceClass(): void
+ {
+ $this->resourceClassResolver->method('isResourceClass')->with(Attachment::class)->willReturn(true);
+
+ $attachment = $this->createMock(Attachment::class);
+ $this->iriConverter->expects($this->once())
+ ->method('getResourceFromIri')
+ ->with('/api/attachments/1')
+ ->willReturn($attachment);
+
+ $result = $this->normalizer->denormalize(['@id' => '/api/attachments/1'], Attachment::class);
+
+ $this->assertSame($attachment, $result);
+ }
+
+ public function testArrayWithoutIriIsPassedThrough(): void
+ {
+ $this->resourceClassResolver->method('isResourceClass')->willReturn(true);
+
+ $this->iriConverter->expects($this->never())->method('getResourceFromIri');
+ $this->inner->expects($this->once())
+ ->method('denormalize')
+ ->with(['name' => 'Test'], Attachment::class, null, [])
+ ->willReturn('denormalized');
+
+ $result = $this->normalizer->denormalize(['name' => 'Test'], Attachment::class);
+
+ $this->assertSame('denormalized', $result);
+ }
+}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index d5907fbc7..d370721c6 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -15158,5 +15158,65 @@ Buerklin-API Authentication server:
Edit BOM entry #%id% of project
+
+
+ part_custom_state.color.label
+ Color
+
+
+
+
+ part_custom_state.color.help
+ Optional semantic color this custom state is shown with wherever it appears as a badge (e.g. on the part detail page and in part tables). Without a color, the previous default appearance is used.
+
+
+
+
+ part_custom_state.color.primary
+ Primary
+
+
+
+
+ part_custom_state.color.secondary
+ Secondary
+
+
+
+
+ part_custom_state.color.info
+ Info
+
+
+
+
+ part_custom_state.color.success
+ Success
+
+
+
+
+ part_custom_state.color.warning
+ Warning
+
+
+
+
+ part_custom_state.color.danger
+ Danger
+
+
+
+
+ part_custom_state.color.light
+ Light
+
+
+
+
+ part_custom_state.color.dark
+ Dark
+
+