Skip to content

Create custom attribute type

Besides the built-in attribute types, you can also create custom ones.

The example below shows how to add a Percentage attribute type.

Select attribute type class

First, you need to register the type class that the attribute uses:

1
2
3
4
5
6
7
8
services:
    app.product_catalog.attribute_type.percent:
        class: Ibexa\ProductCatalog\Local\Repository\Attribute\AttributeType
        arguments:
            $identifier: 'percent'
        tags:
            -   name: ibexa.product_catalog.attribute_type
                alias: percent

Use the ibexa.product_catalog.attribute_type tag to indicate the use as a product attribute type. The custom attribute type has the identifier percent.

Create value form mapper

A form mapper maps the data entered in an editing form into an attribute value.

The form mapper must implement Ibexa\Contracts\ProductCatalog\Local\Attribute\ValueFormMapperInterface.

In this example, you can use the Symfony's built-in PercentType class (line 40).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

declare(strict_types=1);

namespace App\Attribute\Percent\Form;

use Ibexa\Bundle\ProductCatalog\Validator\Constraints\AttributeValue;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\ValueFormMapperInterface;
use Ibexa\Contracts\ProductCatalog\Values\AttributeDefinitionAssignmentInterface;
use Symfony\Component\Form\Extension\Core\Type\PercentType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

final class PercentValueFormMapper implements ValueFormMapperInterface
{
    public function createValueForm(
        string $name,
        FormBuilderInterface $builder,
        AttributeDefinitionAssignmentInterface $assignment,
        array $context = []
    ): void {
        $definition = $assignment->getAttributeDefinition();

        $options = [
            'disabled' => $context['translation_mode'] ?? false,
            'label' => $definition->getName(),
            'block_prefix' => 'percentage_attribute_value',
            'required' => $assignment->isRequired(),
            'constraints' => [
                new AttributeValue([
                    'definition' => $definition,
                ]),
            ],
        ];

        if ($assignment->isRequired()) {
            $options['constraints'][] = new Assert\NotBlank();
        }

        $builder->add($name, PercentType::class, $options);
    }
}

The options array contains additional options for the form, including options resulting from the selected form type.

Register the form mapper as a service and tag it with ibexa.product_catalog.attribute.form_mapper.value:

1
2
3
4
    App\Attribute\Percent\Form\PercentValueFormMapper:
        tags:
            -   name: ibexa.product_catalog.attribute.form_mapper.value
                type: percent

Create value formatter

A value formatter prepares the attribute value for rendering in the proper format.

In this example, you can use the NumberFormatter to ensure the number is rendered in the percentage form (line 22).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

declare(strict_types=1);

namespace App\Attribute\Percent;

use Ibexa\Contracts\ProductCatalog\Local\Attribute\ValueFormatterInterface;
use Ibexa\Contracts\ProductCatalog\Values\AttributeInterface;
use NumberFormatter;

final class PercentValueFormatter implements ValueFormatterInterface
{
    public function formatValue(AttributeInterface $attribute, array $parameters = []): ?string
    {
        $value = $attribute->getValue();
        if ($value === null) {
            return null;
        }

        $formatter = $parameters['formatter'] ?? null;
        if ($formatter === null) {
            $formatter = new NumberFormatter('', NumberFormatter::PERCENT);
        }

        return $formatter->format($value);
    }
}

Register the value formatter as a service and tag it with ibexa.product_catalog.attribute.formatter.value:

1
2
3
4
    App\Attribute\Percent\PercentValueFormatter:
        tags:
            -   name: ibexa.product_catalog.attribute.formatter.value
                type: percent

Add attribute options

You can also add options specific for the attribute type that the user selects when creating an attribute.

In this example, you can set the minimum and maximum allowed percentage.

Options type

First, create PercentAttributeOptionsType that defines two options, min and max. Both those options need to be of PercentType.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

declare(strict_types=1);

namespace App\Attribute\Percent;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PercentType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class PercentAttributeOptionsType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('min', PercentType::class, [
            'disabled' => $options['translation_mode'],
            'label' => 'Minimum Value',
            'required' => false,
        ]);

        $builder->add('max', PercentType::class, [
            'disabled' => $options['translation_mode'],
            'label' => 'Maximum Value',
            'required' => false,
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'translation_mode' => false,
        ]);
        $resolver->setAllowedTypes('translation_mode', 'bool');
    }
}

Options form mapper

Next, create a PercentOptionsFormMapper that maps the information that the user inputs in the form into attribute definition.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

declare(strict_types=1);

namespace App\Attribute\Percent;

use Ibexa\Bundle\ProductCatalog\Validator\Constraints\AttributeDefinitionOptions;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\OptionsFormMapperInterface;
use Symfony\Component\Form\FormBuilderInterface;

final class PercentOptionsFormMapper implements OptionsFormMapperInterface
{
    public function createOptionsForm(string $name, FormBuilderInterface $builder, array $context = []): void
    {
        $builder->add($name, PercentAttributeOptionsType::class, [
            'constraints' => [
                new AttributeDefinitionOptions(['type' => $context['type']]),
            ],
            'translation_mode' => $context['translation_mode'],
        ]);
    }
}

Register the options form mapper as a service and tag it with ibexa.product_catalog.attribute.form_mapper.options:

1
2
3
4
5
    app.product_catalog.attribute.percent.form_mapper.options:
        class: App\Attribute\Percent\PercentOptionsFormMapper
        tags:
            -   name: ibexa.product_catalog.attribute.form_mapper.options
                type: percent

Options validator

Create a PercentOptionsValidator that implements Ibexa\Contracts\ProductCatalog\Local\Attribute\OptionsValidatorInterface. It validates the options that the user sets while creating the attribute definition.

In this example, the validator verifies whether the minimum percentage is lower than the maximum.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

declare(strict_types=1);

namespace App\Attribute\Percent;

use Ibexa\Contracts\Core\Options\OptionsBag;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\OptionsValidatorError;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\OptionsValidatorInterface;

final class PercentOptionsValidator implements OptionsValidatorInterface
{
    public function validateOptions(OptionsBag $options): array
    {
        $min = $options->get('min');
        $max = $options->get('max');

        if ($min !== null && $max !== null && $min > $max) {
            return [
                new OptionsValidatorError('[max]', 'Maximum value should be greater than minimum value'),
            ];
        }

        return [];
    }
}

Register the options validator as a service and tag it with ibexa.product_catalog.attribute.validator.options:

1
2
3
4
5
    app.product_catalog.attribute.options_validator.percent:
        class: App\Attribute\Percent\PercentOptionsValidator
        tags:
            -   name: ibexa.product_catalog.attribute.validator.options
                type: percent

Value validator

Finally, make sure the data provided by the user is validated. To do that, create PercentValueValidator that checks the values against min and max and dispatches an error when needed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php

declare(strict_types=1);

namespace App\Attribute\Percent;

use Ibexa\Contracts\ProductCatalog\Local\Attribute\ValueValidationError;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\ValueValidatorInterface;
use Ibexa\Contracts\ProductCatalog\Values\AttributeDefinitionInterface;

final class PercentValueValidator implements ValueValidatorInterface
{
    public function validateValue(AttributeDefinitionInterface $attributeDefinition, $value): iterable
    {
        if ($value === null) {
            return [];
        }

        $errors = [];
        $options = $attributeDefinition->getOptions();

        $min = $options->get('min');
        if ($min !== null && $value < $min) {
            $errors[] = new ValueValidationError(null, 'Percentage should be greater or equal to %min%', [
                '%min%' => $min,
            ]);
        }

        $max = $options->get('max');
        if ($max !== null && $value > $max) {
            $errors[] = new ValueValidationError(null, 'Percentage should be lesser or equal to %max%', [
                '%max%' => $max,
            ]);
        }

        return $errors;
    }
}

Register the validator as a service and tag it with ibexa.product_catalog.attribute.validator.value:

1
2
3
4
5
    app.product_catalog.attribute.value_validator.percent:
        class: App\Attribute\Percent\PercentValueValidator
        tags:
            -   name: ibexa.product_catalog.attribute.validator.value
                type: percent

Storage

To ensure that values of the new attributes are stored correctly, you need to provide a storage converter and storage definition services.

Storage converter

Start by creating a PercentStorageConverter class, which implements Ibexa\Contracts\ProductCatalog\Local\Attribute\StorageConverterInterface. This converter is responsible for converting database results into an attribute type instance:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

declare(strict_types=1);

namespace App\Attribute\Percent\Storage;

use Ibexa\Contracts\ProductCatalog\Local\Attribute\StorageConverterInterface;
use Ibexa\ProductCatalog\Local\Persistence\Legacy\Attribute\Float\StorageSchema;
use Webmozart\Assert\Assert;

final class PercentStorageConverter implements StorageConverterInterface
{
    public function fromPersistence(array $data)
    {
        $value = $data[StorageSchema::COLUMN_VALUE];
        Assert::nullOrNumeric($value);

        return $value;
    }

    public function toPersistence($value): array
    {
        Assert::nullOrNumeric($value);

        return [
            StorageSchema::COLUMN_VALUE => $value,
        ];
    }
}

Register the converter as a service and tag it with ibexa.product_catalog.attribute.storage_converter:

1
2
3
    App\Attribute\Percent\Storage\PercentStorageConverter:
        tags:
            - { name: 'ibexa.product_catalog.attribute.storage_converter', type: 'percent' }

Storage definition

Next, prepare a PercentStorageDefinition class, which implements Ibexa\Contracts\ProductCatalog\Local\Attribute\StorageDefinitionInterface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

/**
 * @copyright Copyright (C) Ibexa AS. All rights reserved.
 * @license For full copyright and license information view LICENSE file distributed with this source code.
 */
declare(strict_types=1);

namespace App\Attribute\Percent\Storage;

use Doctrine\DBAL\Types\Types;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\StorageDefinitionInterface;
use Ibexa\ProductCatalog\Local\Persistence\Legacy\Attribute\Float\StorageSchema;

final class PercentStorageDefinition implements StorageDefinitionInterface
{
    public function getColumns(): array
    {
        return [
            StorageSchema::COLUMN_VALUE => Types::FLOAT,
        ];
    }

    public function getTableName(): string
    {
        return StorageSchema::TABLE_NAME;
    }
}

Register the storage definition as a service and tag it with ibexa.product_catalog.attribute.storage_definition:

1
2
3
    App\Attribute\Percent\Storage\PercentStorageDefinition:
        tags:
            - { name: 'ibexa.product_catalog.attribute.storage_definition', type: 'percent' }

Use new attribute type

In the back office you can now add a new Percent attribute to your product type and create a product with it.

Creating a product with a custom Percent attribute