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..396f7100d 100644 --- a/src/Entity/Parts/PartCustomState.php +++ b/src/Entity/Parts/PartCustomState.php @@ -50,6 +50,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 +109,14 @@ class PartCustomState extends AbstractPartsContainingDBElement #[Groups(['part_custom_state:read', 'part_custom_state:write', 'full', 'import'])] protected string $comment = ''; + /** + * @var PartCustomStateColor|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: PartCustomStateColor::class)] + #[Groups(['part_custom_state:read', 'part_custom_state:write', 'full', 'import'])] + protected ?PartCustomStateColor $color = null; + #[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent', cascade: ['persist'])] #[ORM\OrderBy(['name' => 'ASC'])] protected Collection $children; @@ -152,4 +161,26 @@ public function __construct() $this->attachments = new ArrayCollection(); $this->parameters = new ArrayCollection(); } + + public function getColor(): ?PartCustomStateColor + { + return $this->color; + } + + public function setColor(?PartCustomStateColor $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/Entity/Parts/PartCustomStateColor.php b/src/Entity/Parts/PartCustomStateColor.php new file mode 100644 index 000000000..d5093aed1 --- /dev/null +++ b/src/Entity/Parts/PartCustomStateColor.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\Parts; + +/** + * 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 PartCustomStateColor: 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/Form/AdminPages/PartCustomStateAdminForm.php b/src/Form/AdminPages/PartCustomStateAdminForm.php index b8bb2815e..cba043e9c 100644 --- a/src/Form/AdminPages/PartCustomStateAdminForm.php +++ b/src/Form/AdminPages/PartCustomStateAdminForm.php @@ -22,6 +22,29 @@ namespace App\Form\AdminPages; +use App\Entity\Base\AbstractNamedDBElement; +use App\Entity\Parts\PartCustomState; +use App\Entity\Parts\PartCustomStateColor; +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', EnumType::class, [ + 'class' => PartCustomStateColor::class, + 'choice_label' => fn (PartCustomStateColor $color) => $color->toTranslationKey(), + '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/Serializer/APIPlatform/SkippableItemNormalizer.php b/src/Serializer/APIPlatform/SkippableItemNormalizer.php index 618736152..169520e14 100644 --- a/src/Serializer/APIPlatform/SkippableItemNormalizer.php +++ b/src/Serializer/APIPlatform/SkippableItemNormalizer.php @@ -26,6 +26,7 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Serializer\ItemNormalizer; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -53,6 +54,7 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf public function __construct( private readonly ItemNormalizer $inner, private readonly IriConverterInterface $iriConverter, + private readonly ResourceClassResolverInterface $resourceClassResolver, ) { } @@ -63,7 +65,13 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a // 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 - if (is_string($data) || (is_array($data) && isset($data['@id']) && is_string($data['@id']))) { + // + // $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'])))) { if (is_array($data)) { $iri = $data['@id']; } else { 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..06d2a3b3e 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 %}