From 12659b405dfe36d42db5dd609379b845d96329f9 Mon Sep 17 00:00:00 2001 From: Niklan Date: Thu, 26 Mar 2026 19:42:30 +0500 Subject: [PATCH] feat: add software compatibility field with Icon API integration --- .../blog/src/Sync/ArticleSynchronizer.php | 11 ++++ app/modules/blog/src/Sync/Domain/Article.php | 2 + .../src/Sync/Domain/SoftwareCompatibility.php | 14 ++++ .../blog/src/Sync/Parser/ArticleXmlParser.php | 24 +++++++ app/modules/blog/tests/fixtures/article.xsd | 21 ++++++ .../Kernel/Sync/ArticleSynchronizerTest.php | 25 ++++++++ .../src/Unit/Sync/Domain/ArticleTest.php | 28 ++++++++ app/modules/platform/app_platform.icons.yml | 17 +++++ .../platform/assets/icons/software/docker.svg | 1 + .../platform/assets/icons/software/drupal.svg | 1 + .../platform/assets/icons/software/php.svg | 1 + .../FieldType/SoftwareCompatibilityItem.php | 64 +++++++++++++++++++ .../content/article/article.component.yml | 4 ++ .../components/content/article/article.twig | 10 ++- .../components/ui/chip/chip.component.yml | 16 +++-- app/themes/laszlo/components/ui/chip/chip.css | 24 ++++++- .../laszlo/components/ui/chip/chip.twig | 13 +++- .../components/ui/icon/icon.component.yml | 4 ++ .../laszlo/components/ui/icon/icon.twig | 2 +- app/themes/laszlo/laszlo.icons.yml | 16 +++++ .../laszlo/src/Hook/Theme/PreprocessNode.php | 2 + .../Hook/Theme/PreprocessNodeBlogEntry.php | 59 +++++++++++++++++ .../node--blog-entry--full.html.twig | 1 + .../tags/taxonomy-term--tags--chip.html.twig | 1 + config/cspell/dictionary.txt | 1 + ...ld.node.blog_entry.field_compatibility.yml | 19 ++++++ ...field.storage.node.field_compatibility.yml | 19 ++++++ 27 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 app/modules/blog/src/Sync/Domain/SoftwareCompatibility.php create mode 100644 app/modules/platform/app_platform.icons.yml create mode 100644 app/modules/platform/assets/icons/software/docker.svg create mode 100644 app/modules/platform/assets/icons/software/drupal.svg create mode 100644 app/modules/platform/assets/icons/software/php.svg create mode 100644 app/modules/platform/src/Plugin/Field/FieldType/SoftwareCompatibilityItem.php create mode 100644 app/themes/laszlo/laszlo.icons.yml create mode 100644 app/themes/laszlo/src/Hook/Theme/PreprocessNodeBlogEntry.php create mode 100644 config/sync/field.field.node.blog_entry.field_compatibility.yml create mode 100644 config/sync/field.storage.node.field_compatibility.yml diff --git a/app/modules/blog/src/Sync/ArticleSynchronizer.php b/app/modules/blog/src/Sync/ArticleSynchronizer.php index eb1ef69fb..33524b677 100644 --- a/app/modules/blog/src/Sync/ArticleSynchronizer.php +++ b/app/modules/blog/src/Sync/ArticleSynchronizer.php @@ -135,6 +135,7 @@ private function updateMetadata(Article $article, ArticleNode $entity): void { $entity->setChangedTime($updated->getTimestamp()); $this->updateTags($article, $entity); + $this->updateCompatibility($article, $entity); } private function updateTags(Article $article, ArticleNode $entity): void { @@ -149,4 +150,14 @@ private function updateTags(Article $article, ArticleNode $entity): void { } } + private function updateCompatibility(Article $article, ArticleNode $entity): void { + $entity->set('field_compatibility', NULL); + foreach ($article->compatibility as $item) { + $entity->get('field_compatibility')->appendItem([ + 'name' => $item->name, + 'constraint' => $item->constraint, + ]); + } + } + } diff --git a/app/modules/blog/src/Sync/Domain/Article.php b/app/modules/blog/src/Sync/Domain/Article.php index 35ac7e614..46e5e3a09 100644 --- a/app/modules/blog/src/Sync/Domain/Article.php +++ b/app/modules/blog/src/Sync/Domain/Article.php @@ -18,6 +18,7 @@ final class Article implements \IteratorAggregate, \Countable { /** * @param array{}|list $tags + * @param list<\Drupal\app_blog\Sync\Domain\SoftwareCompatibility> $compatibility */ public function __construct( public string $id, @@ -25,6 +26,7 @@ public function __construct( public string $updated, public array $tags, public string $directory, + public array $compatibility = [], ) {} public function addTranslation(ArticleTranslation $translation): void { diff --git a/app/modules/blog/src/Sync/Domain/SoftwareCompatibility.php b/app/modules/blog/src/Sync/Domain/SoftwareCompatibility.php new file mode 100644 index 000000000..656ad1f1f --- /dev/null +++ b/app/modules/blog/src/Sync/Domain/SoftwareCompatibility.php @@ -0,0 +1,14 @@ +getArticleNode($xpath); $tags = $this->parseTags($xpath); + $compatibility = $this->parseCompatibility($xpath); $article = new Article( id: $article_node->getAttribute('id'), created: $article_node->getAttribute('created'), updated: $article_node->getAttribute('updated'), tags: $tags, directory: \dirname($file_path), + compatibility: $compatibility, ); foreach ($this->parseTranslations($xpath, $article) as $translation) { @@ -79,6 +82,27 @@ private function parseTags(\DOMXPath $xpath): array { return $tags; } + /** + * @return list<\Drupal\app_blog\Sync\Domain\SoftwareCompatibility> + */ + private function parseCompatibility(\DOMXPath $xpath): array { + $software_list = $xpath->query('/article/compatibility/software'); + \assert($software_list instanceof \DOMNodeList); + + $compatibility = []; + foreach ($software_list as $software_node) { + \assert($software_node instanceof \DOMElement); + $compatibility[] = new SoftwareCompatibility( + name: $software_node->getAttribute('name'), + constraint: $software_node->hasAttribute('constraint') + ? $software_node->getAttribute('constraint') + : NULL, + ); + } + + return $compatibility; + } + private function parseTranslations(\DOMXPath $xpath, Article $article): array { $translation_list = $xpath->query('/article/translations/translation'); \assert($translation_list instanceof \DOMNodeList); diff --git a/app/modules/blog/tests/fixtures/article.xsd b/app/modules/blog/tests/fixtures/article.xsd index e9b2bb030..35a9f9c43 100644 --- a/app/modules/blog/tests/fixtures/article.xsd +++ b/app/modules/blog/tests/fixtures/article.xsd @@ -20,12 +20,19 @@ + + + + + + + @@ -67,6 +74,20 @@ + + + + + + + + + + + + + + diff --git a/app/modules/blog/tests/src/Kernel/Sync/ArticleSynchronizerTest.php b/app/modules/blog/tests/src/Kernel/Sync/ArticleSynchronizerTest.php index 9fd09e97a..51aab526f 100644 --- a/app/modules/blog/tests/src/Kernel/Sync/ArticleSynchronizerTest.php +++ b/app/modules/blog/tests/src/Kernel/Sync/ArticleSynchronizerTest.php @@ -87,6 +87,7 @@ protected function setUp(): void { $this->createField('field_tags', 'entity_reference', ['target_type' => 'taxonomy_term'], -1); $this->createField('field_media_image', 'entity_reference', ['target_type' => 'media']); $this->createField('field_media_attachments', 'entity_reference', ['target_type' => 'media'], -1); + $this->createField('field_compatibility', 'software_compatibility', [], -1); } #[DataProvider('sourcePathPrefixProvider')] @@ -144,6 +145,25 @@ public function testUnchangedArticleSkipped(): void { self::assertSame('Existing Title', $node->getTitle()); } + public function testCompatibilitySynced(): void { + $synchronizer = $this->buildSynchronizer(); + $context = $this->createSyncContext(); + + $synchronizer->sync($context); + + $nodes = $this->loadBlogNodes(); + self::assertCount(1, $nodes); + $node = \reset($nodes); + $field = $node->get('field_compatibility'); + self::assertCount(3, $field); + self::assertSame('drupal', $field->get(0)->get('name')->getValue()); + self::assertSame('^10.3 || ^11', $field->get(0)->get('constraint')->getValue()); + self::assertSame('php', $field->get(1)->get('name')->getValue()); + self::assertSame('^8.3', $field->get(1)->get('constraint')->getValue()); + self::assertSame('docker', $field->get(2)->get('name')->getValue()); + self::assertNull($field->get(2)->get('constraint')->getValue()); + } + public function testForcedSyncUpdatesEvenUnchanged(): void { $existing = Node::create([ 'type' => 'blog_entry', @@ -271,6 +291,11 @@ private function buildArticleXml(string $source_prefix = './'): string { + + + + + XML; } diff --git a/app/modules/blog/tests/src/Unit/Sync/Domain/ArticleTest.php b/app/modules/blog/tests/src/Unit/Sync/Domain/ArticleTest.php index 53b3a0356..819c8b316 100644 --- a/app/modules/blog/tests/src/Unit/Sync/Domain/ArticleTest.php +++ b/app/modules/blog/tests/src/Unit/Sync/Domain/ArticleTest.php @@ -6,6 +6,7 @@ use Drupal\app_blog\Sync\Domain\Article; use Drupal\app_blog\Sync\Domain\ArticleTranslation; +use Drupal\app_blog\Sync\Domain\SoftwareCompatibility; use Drupal\app_blog\Sync\Exception\PrimaryTranslationNotFoundException; use Drupal\Tests\UnitTestCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -68,6 +69,33 @@ public function testMultipleNonPrimaryTranslations(): void { self::assertFalse($article->hasPrimaryTranslation()); } + public function testCompatibilityDefaultsToEmpty(): void { + $article = $this->createArticle(); + + self::assertSame([], $article->compatibility); + } + + public function testCompatibilityPopulated(): void { + $compatibility = [ + new SoftwareCompatibility(name: 'drupal', constraint: '^10.3 || ^11'), + new SoftwareCompatibility(name: 'php', constraint: '^8.3'), + new SoftwareCompatibility(name: 'docker'), + ]; + $article = new Article( + id: 'test-article', + created: '2026-01-01T00:00:00', + updated: '2026-03-01T00:00:00', + tags: [], + directory: '/content/blog/test', + compatibility: $compatibility, + ); + + self::assertCount(3, $article->compatibility); + self::assertSame('drupal', $article->compatibility[0]->name); + self::assertSame('^10.3 || ^11', $article->compatibility[0]->constraint); + self::assertNull($article->compatibility[2]->constraint); + } + public function testIterableOverTranslations(): void { $article = $this->createArticle(); $ru = $this->createTranslation(language: 'ru'); diff --git a/app/modules/platform/app_platform.icons.yml b/app/modules/platform/app_platform.icons.yml new file mode 100644 index 000000000..0c79f2673 --- /dev/null +++ b/app/modules/platform/app_platform.icons.yml @@ -0,0 +1,17 @@ +software: + label: Software + description: Icons for software and technology platforms. + extractor: svg + config: + sources: + - assets/icons/software/*.svg + template: > + + {{ content }} + diff --git a/app/modules/platform/assets/icons/software/docker.svg b/app/modules/platform/assets/icons/software/docker.svg new file mode 100644 index 000000000..5804ba9c5 --- /dev/null +++ b/app/modules/platform/assets/icons/software/docker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/modules/platform/assets/icons/software/drupal.svg b/app/modules/platform/assets/icons/software/drupal.svg new file mode 100644 index 000000000..d8e239b66 --- /dev/null +++ b/app/modules/platform/assets/icons/software/drupal.svg @@ -0,0 +1 @@ + diff --git a/app/modules/platform/assets/icons/software/php.svg b/app/modules/platform/assets/icons/software/php.svg new file mode 100644 index 000000000..f80747880 --- /dev/null +++ b/app/modules/platform/assets/icons/software/php.svg @@ -0,0 +1 @@ + diff --git a/app/modules/platform/src/Plugin/Field/FieldType/SoftwareCompatibilityItem.php b/app/modules/platform/src/Plugin/Field/FieldType/SoftwareCompatibilityItem.php new file mode 100644 index 000000000..11e4452bb --- /dev/null +++ b/app/modules/platform/src/Plugin/Field/FieldType/SoftwareCompatibilityItem.php @@ -0,0 +1,64 @@ +get('name')->getValue(); + + return $name === NULL || $name === ''; + } + + #[\Override] + public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition): array { + $properties['name'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Software name')) + ->setRequired(TRUE); + + $properties['constraint'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Version constraint')); + + return $properties; + } + + #[\Override] + public static function schema(FieldStorageDefinitionInterface $field_definition): array { + return [ + 'columns' => [ + 'name' => [ + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + ], + 'constraint' => [ + 'type' => 'varchar', + 'length' => 255, + ], + ], + ]; + } + + #[\Override] + public static function mainPropertyName(): string { + return 'name'; + } + +} diff --git a/app/themes/laszlo/components/content/article/article.component.yml b/app/themes/laszlo/components/content/article/article.component.yml index f3a687ae0..2db865399 100644 --- a/app/themes/laszlo/components/content/article/article.component.yml +++ b/app/themes/laszlo/components/content/article/article.component.yml @@ -24,6 +24,10 @@ props: tags: title: Tags type: [ 'null', array ] + compatibility: + title: Compatibility + type: array + default: [] next_link: title: Next link type: [ 'null', array ] diff --git a/app/themes/laszlo/components/content/article/article.twig b/app/themes/laszlo/components/content/article/article.twig index b37563666..834dd2213 100644 --- a/app/themes/laszlo/components/content/article/article.twig +++ b/app/themes/laszlo/components/content/article/article.twig @@ -28,9 +28,17 @@ {% block summary %}{% endblock %} - {% if tags %} + {% if tags or compatibility is not empty %} {% endif %} diff --git a/app/themes/laszlo/components/ui/chip/chip.component.yml b/app/themes/laszlo/components/ui/chip/chip.component.yml index a8328a67d..72d47ed2a 100644 --- a/app/themes/laszlo/components/ui/chip/chip.component.yml +++ b/app/themes/laszlo/components/ui/chip/chip.component.yml @@ -5,19 +5,27 @@ group: ui props: type: object required: - - href - label properties: attributes: type: Drupal\Core\Template\Attribute title: Attributes - description: Button attributes. href: title: URL - type: string + type: ['null', string] label: title: Label type: string + icon: + title: Icon name + type: ['null', string] + icon_pack: + title: Icon pack (defaults to laszlo) + type: string + default: laszlo + suffix: + title: Suffix text + type: ['null', string] variant: title: Variant type: string @@ -35,4 +43,4 @@ props: type: string enum: - medium - default: medium \ No newline at end of file + default: medium diff --git a/app/themes/laszlo/components/ui/chip/chip.css b/app/themes/laszlo/components/ui/chip/chip.css index 36636fcb0..a5f751a81 100644 --- a/app/themes/laszlo/components/ui/chip/chip.css +++ b/app/themes/laszlo/components/ui/chip/chip.css @@ -5,7 +5,11 @@ --_chip-gap: var(--chip-gap, var(--spacing-2)); --_chip-background-color: var( --chip-background-color, - var(--color-secondary-container, transparent) + var(--color-surface-container-lowest, transparent) + ); + --_chip-border-color: var( + --chip-border-color, + var(--color-outline-variant) ); --_chip-border-radius: var(--chip-border-radius, var(--spacing-5)); --_chip-color: var( @@ -24,6 +28,7 @@ color 250ms cubic-bezier(0.39, 0.58, 0.57, 1); text-decoration: none; color: var(--_chip-color); + border: 1px solid var(--_chip-border-color); border-radius: var(--_chip-border-radius); background: var(--_chip-background-color); font: var(--_chip-font); @@ -31,6 +36,10 @@ padding-inline: var(--_chip-padding-inline); gap: var(--_chip-gap); + &:not([href]) { + cursor: default; + } + &:focus, &:hover { color: color-mix( @@ -45,4 +54,17 @@ ); } } + + .chip__icon { + --icon-wrapper-width: 16px; + --icon-wrapper-height: 16px; + + display: flex; + align-items: center; + } + + .chip__suffix { + opacity: 0.7; + font-weight: normal; + } } diff --git a/app/themes/laszlo/components/ui/chip/chip.twig b/app/themes/laszlo/components/ui/chip/chip.twig index 8a8ca59b4..af85f4f05 100644 --- a/app/themes/laszlo/components/ui/chip/chip.twig +++ b/app/themes/laszlo/components/ui/chip/chip.twig @@ -1,8 +1,17 @@ -{% do attributes.setAttribute('href', href) %} {% do attributes.addClass([ 'chip', 'chip--variant--' ~ variant|default('filled'), 'chip--size--' ~ size|default('medium'), 'chip--color--' ~ color|default('default'), ]) %} -{{ label }} \ No newline at end of file +{% set content_markup %} + {%- if icon %}{% include 'laszlo:icon' with { icon: icon, pack: icon_pack|default('laszlo'), attributes: create_attribute({ class: ['chip__icon'] }) } only %}{% endif -%} + {{- label -}} + {%- if suffix %}{{ suffix }}{% endif -%} +{% endset %} +{% if href %} + {% do attributes.setAttribute('href', href) %} + {{ content_markup }} +{% else %} + {{ content_markup }} +{% endif %} diff --git a/app/themes/laszlo/components/ui/icon/icon.component.yml b/app/themes/laszlo/components/ui/icon/icon.component.yml index d6c58b188..a3637708f 100644 --- a/app/themes/laszlo/components/ui/icon/icon.component.yml +++ b/app/themes/laszlo/components/ui/icon/icon.component.yml @@ -13,3 +13,7 @@ props: icon: title: Icon name type: string + pack: + title: Icon pack + type: string + default: laszlo diff --git a/app/themes/laszlo/components/ui/icon/icon.twig b/app/themes/laszlo/components/ui/icon/icon.twig index 5b91e5b2b..5890d1921 100644 --- a/app/themes/laszlo/components/ui/icon/icon.twig +++ b/app/themes/laszlo/components/ui/icon/icon.twig @@ -1,5 +1,5 @@ {% do attributes.setAttribute('aria-hidden', 'true') %} {% do attributes.setAttribute('data-icon', icon) %} - {{- source(componentMetadata.path ~ '/icons/' ~ icon ~ '.svg') -}} + {{- icon(pack|default('laszlo'), icon) -}} diff --git a/app/themes/laszlo/laszlo.icons.yml b/app/themes/laszlo/laszlo.icons.yml new file mode 100644 index 000000000..77ded1cef --- /dev/null +++ b/app/themes/laszlo/laszlo.icons.yml @@ -0,0 +1,16 @@ +laszlo: + label: Laszlo + extractor: svg + config: + sources: + - components/ui/icon/icons/*.svg + template: > + + {{ content }} + diff --git a/app/themes/laszlo/src/Hook/Theme/PreprocessNode.php b/app/themes/laszlo/src/Hook/Theme/PreprocessNode.php index a07abf329..68a91c498 100644 --- a/app/themes/laszlo/src/Hook/Theme/PreprocessNode.php +++ b/app/themes/laszlo/src/Hook/Theme/PreprocessNode.php @@ -4,6 +4,7 @@ namespace Drupal\laszlo\Hook\Theme; +use Drupal\app_blog\Node\ArticleBundle; use Drupal\app_contract\Contract\Node\Node; use Drupal\app_portfolio\Node\PortfolioBundle; use Drupal\Core\DependencyInjection\ClassResolverInterface; @@ -32,6 +33,7 @@ public function __invoke(array &$variables): void { $class = match ($node::class) { default => NULL, + ArticleBundle::class => PreprocessNodeBlogEntry::class, PortfolioBundle::class => PreprocessNodePortfolio::class, }; diff --git a/app/themes/laszlo/src/Hook/Theme/PreprocessNodeBlogEntry.php b/app/themes/laszlo/src/Hook/Theme/PreprocessNodeBlogEntry.php new file mode 100644 index 000000000..fff91f1d0 --- /dev/null +++ b/app/themes/laszlo/src/Hook/Theme/PreprocessNodeBlogEntry.php @@ -0,0 +1,59 @@ + 'Drupal', + 'php' => 'PHP', + 'docker' => 'Docker', + 'drush' => 'Drush', + 'twig' => 'Twig', + 'nginx' => 'Nginx', + 'apache' => 'Apache', + 'mysql' => 'MySQL', + 'mariadb' => 'MariaDB', + 'composer' => 'Composer', + 'node' => 'Node.js', + 'js' => 'JavaScript', + 'css' => 'CSS', + 'symfony' => 'Symfony', + 'linux' => 'Linux', + 'git' => 'Git', + ]; + + public function __invoke(array &$variables): void { + $article = $variables['node']; + \assert($article instanceof ArticleBundle); + + $this->addCompatibility($article, $variables); + } + + private function addCompatibility(ArticleBundle $article, array &$variables): void { + if (!$article->hasField('field_compatibility') || $article->get('field_compatibility')->isEmpty()) { + $variables['compatibility'] = []; + + return; + } + + $compatibility = []; + foreach ($article->get('field_compatibility') as $item) { + $name = $item->get('name')->getValue(); + Assert::string($name); + $compatibility[] = [ + 'name' => $name, + 'label' => self::SOFTWARE_LABELS[$name] ?? \ucfirst($name), + 'constraint' => $item->get('constraint')->getValue(), + ]; + } + + $variables['compatibility'] = $compatibility; + } + +} diff --git a/app/themes/laszlo/templates/content/node/blog-entry/node--blog-entry--full.html.twig b/app/themes/laszlo/templates/content/node/blog-entry/node--blog-entry--full.html.twig index 5cce11fcf..1db67cc5e 100644 --- a/app/themes/laszlo/templates/content/node/blog-entry/node--blog-entry--full.html.twig +++ b/app/themes/laszlo/templates/content/node/blog-entry/node--blog-entry--full.html.twig @@ -10,6 +10,7 @@ poster_uri, attachments, tags, + compatibility, next_link, previous_link, } only %} diff --git a/app/themes/laszlo/templates/content/taxonomy-term/tags/taxonomy-term--tags--chip.html.twig b/app/themes/laszlo/templates/content/taxonomy-term/tags/taxonomy-term--tags--chip.html.twig index 23015a2bb..82b71e86c 100644 --- a/app/themes/laszlo/templates/content/taxonomy-term/tags/taxonomy-term--tags--chip.html.twig +++ b/app/themes/laszlo/templates/content/taxonomy-term/tags/taxonomy-term--tags--chip.html.twig @@ -4,4 +4,5 @@ }), label: term.label(), href: url, + icon: 'hashtag-square-bold-duotone', } only %} \ No newline at end of file diff --git a/config/cspell/dictionary.txt b/config/cspell/dictionary.txt index 16321830c..7b466c1e2 100644 --- a/config/cspell/dictionary.txt +++ b/config/cspell/dictionary.txt @@ -32,5 +32,6 @@ Taskfile Twotone twotone TYPEHINT +Webmozart wght youtu diff --git a/config/sync/field.field.node.blog_entry.field_compatibility.yml b/config/sync/field.field.node.blog_entry.field_compatibility.yml new file mode 100644 index 000000000..e1118a3d6 --- /dev/null +++ b/config/sync/field.field.node.blog_entry.field_compatibility.yml @@ -0,0 +1,19 @@ +uuid: 2ce18598-b3da-4ae5-871c-1e84756cd505 +langcode: ru +status: true +dependencies: + config: + - field.storage.node.field_compatibility + - node.type.blog_entry +id: node.blog_entry.field_compatibility +field_name: field_compatibility +entity_type: node +bundle: blog_entry +label: Compatibility +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: software_compatibility diff --git a/config/sync/field.storage.node.field_compatibility.yml b/config/sync/field.storage.node.field_compatibility.yml new file mode 100644 index 000000000..b65ec53e7 --- /dev/null +++ b/config/sync/field.storage.node.field_compatibility.yml @@ -0,0 +1,19 @@ +uuid: b6b4c2ac-b21f-46bb-bea9-62c35abd5064 +langcode: ru +status: true +dependencies: + module: + - app_platform + - node +id: node.field_compatibility +field_name: field_compatibility +entity_type: node +type: software_compatibility +settings: { } +module: app_platform +locked: false +cardinality: -1 +translatable: false +indexes: { } +persist_with_no_fields: false +custom_storage: false