Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions cms/contents/article/admin/_tags_field.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
{% trans_default_domain 'sfs_cms_contents' %}

<div class="mb-3" data-article-tags-widget data-field-id="{{ form.tags.vars.id }}">
{{ form_label(form.tags) }}
{{ form_errors(form.tags) }}
{{ form_widget(form.tags) }}

<div class="border rounded p-2 bg-white" data-tags-container>
<div class="d-flex flex-wrap gap-2 mb-2" data-tags-list></div>
<input
type="text"
class="form-control border-0 shadow-none px-0"
data-tags-input
list="{{ form.tags.vars.id }}_suggestions"
placeholder="{{ 'admin_article.form.tags.placeholder'|trans }}"
autocomplete="off"
/>
</div>

<datalist id="{{ form.tags.vars.id }}_suggestions">
{% for tag in form.tags.vars.tag_suggestions %}
<option value="{{ tag }}"></option>
{% endfor %}
</datalist>

<div class="form-text">{{ 'admin_article.form.tags.help'|trans }}</div>
</div>

<script>
(() => {
const widget = document.querySelector('[data-article-tags-widget][data-field-id="{{ form.tags.vars.id }}"]');

if (!widget || widget.dataset.initialized === 'true') {
return;
}

widget.dataset.initialized = 'true';

const hiddenInput = widget.querySelector('input[type="hidden"]');
const textInput = widget.querySelector('[data-tags-input]');
const tagsList = widget.querySelector('[data-tags-list]');
const suggestions = {{ form.tags.vars.tag_suggestions|json_encode|raw }};

const canonicalSuggestions = suggestions.reduce((carry, tag) => {
carry[tag.toLocaleLowerCase()] = tag;
return carry;
}, {});

const parseTags = () => {
try {
const parsedTags = JSON.parse(hiddenInput.value || '[]');
return Array.isArray(parsedTags) ? parsedTags : [];
} catch (e) {
return [];
}
};

const normalizeTag = (tag) => {
const compactTag = tag.trim().replace(/\s+/g, ' ');
if (!compactTag) {
return '';
}

return canonicalSuggestions[compactTag.toLocaleLowerCase()] || compactTag;
};

const storeTags = (tags) => {
hiddenInput.value = JSON.stringify(tags);
};

const renderTags = () => {
const tags = parseTags();

tagsList.innerHTML = '';

tags.forEach((tag, index) => {
const badge = document.createElement('span');
badge.className = 'badge text-bg-secondary d-inline-flex align-items-center gap-2';
badge.textContent = tag;

const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'btn-close btn-close-white btn-sm';
removeButton.setAttribute('aria-label', 'Remove');
removeButton.addEventListener('click', () => {
const nextTags = parseTags();
nextTags.splice(index, 1);
storeTags(nextTags);
renderTags();
});

badge.appendChild(removeButton);
tagsList.appendChild(badge);
});
};

const addTag = (tag) => {
const normalizedTag = normalizeTag(tag);

if (!normalizedTag) {
return;
}

const tags = parseTags();
const normalizedTags = new Set(tags.map(tag => tag.toLocaleLowerCase()));

if (normalizedTags.has(normalizedTag.toLocaleLowerCase())) {
textInput.value = '';
return;
}

tags.push(normalizedTag);
storeTags(tags);
renderTags();
textInput.value = '';
};

textInput.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
addTag(textInput.value);
}
});

textInput.addEventListener('change', () => addTag(textInput.value));
textInput.addEventListener('blur', () => addTag(textInput.value));

renderTags();
})();
</script>
2 changes: 1 addition & 1 deletion cms/contents/article/admin/create.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@


{{ form_rest(form) }}
{% endblock form_fields %}
{% endblock form_fields %}
3 changes: 2 additions & 1 deletion cms/contents/article/admin/update.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
{{ form_row(form.name) }}
{% if form.extraData is defined %}{{ form_widget(form.extraData) }}{% endif %}
{{ form_row(form.publishedAt) }}
{% if form.tags is defined %}{% include '@content/article/admin/_tags_field.html.twig' with {'form': form} %}{% endif %}
{% if form.author is defined %}{{ form_row(form.author) }}{% endif %}
{{ form_rest(form) }}
{% endblock form_fields %}
{% endblock form_fields %}
4 changes: 4 additions & 0 deletions cms/contents/article/translations/sfs_cms_contents.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ admin_article:
status: "Status"
publishDate: "Publish date"
author: "Author"
tags: "Tags"
indexing:
title: "Indexing"
noIndex:
Expand Down Expand Up @@ -318,6 +319,9 @@ admin_article:
description.label: "Excerpt"
publishedAt.label: "Visible published date time"
author.label: "Author"
tags.label: "Tags"
tags.help: "Press Enter or comma to add each tag. Existing similar tags are reused."
tags.placeholder: "Write a tag"

version_form:
note.label: "Note"
Expand Down
4 changes: 4 additions & 0 deletions cms/contents/article/translations/sfs_cms_contents.es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ admin_article:
status: "Estado"
publishDate: "Fecha de publicación"
author: "Autor"
tags: "Etiquetas"
indexing:
title: "Indexado"
noIndex:
Expand Down Expand Up @@ -317,6 +318,9 @@ admin_article:
description.label: "Excerpt"
publishedAt.label: "Fecha de publicación visible"
author.label: "Autor"
tags.label: "Etiquetas"
tags.help: "Pulsa Enter o coma para añadir cada etiqueta. Si ya existe una parecida, se reutiliza la misma."
tags.placeholder: "Escribe una etiqueta"

version_form:
note.label: "Nota"
Expand Down
2 changes: 2 additions & 0 deletions config/doctrine-mapping/entities/ArticleContent.orm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
</options>
</field>

<field name="tags" column="tags" type="json" nullable="true" />

<many-to-one field="author" target-entity="Softspring\CmsBlogPlugin\Model\AuthorInterface">
<join-column name="author_id" on-delete="SET NULL" />
</many-to-one>
Expand Down
10 changes: 10 additions & 0 deletions config/services/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ services:
resource: '../../src/Form/*'
tags: ['form.type']

Softspring\CmsBlogPlugin\Form\Admin\Article\ArticleCreateForm:
arguments:
$translatableContext: '@Softspring\CmsBundle\Translator\TranslatableContext'
tags: ['form.type']

Softspring\CmsBlogPlugin\Form\Admin\Article\ArticleUpdateForm:
arguments:
$translatableContext: '@Softspring\CmsBundle\Translator\TranslatableContext'
tags: ['form.type']

Softspring\CmsBlogPlugin\Manager\:
resource: '../../src/Manager/*'

Expand Down
2 changes: 1 addition & 1 deletion src/Data/EntityTransformer/ArticleEntityTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

use DateTime;
use Softspring\CmsBlogPlugin\Model\ArticleContentInterface;
use Softspring\CmsBundle\Model\ContentInterface;
use Softspring\CmsDataPlugin\Data\EntityTransformer\ContentEntityTransformer;
use Softspring\CmsDataPlugin\Data\Exception\InvalidElementException;
use Softspring\CmsDataPlugin\Data\ReferencesRepository;
use Softspring\CmsBundle\Model\ContentInterface;

class ArticleEntityTransformer extends ContentEntityTransformer
{
Expand Down
13 changes: 7 additions & 6 deletions src/Form/Admin/Article/ArticleCreateForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,35 @@

namespace Softspring\CmsBlogPlugin\Form\Admin\Article;

use ReflectionClass;
use Softspring\CmsBlogPlugin\Form\Type\ArticleTagsType;
use Softspring\CmsBlogPlugin\Model\ArticleAuthorInterface;
use Softspring\CmsBundle\Form\Admin\Content\ContentCreateForm;
use Softspring\CmsBundle\Form\Type\UserType;
use Softspring\CmsBundle\Manager\ContentManagerInterface;
use Softspring\CmsBundle\Translator\TranslatableContext;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormBuilderInterface;

class ArticleCreateForm extends ContentCreateForm
{
public function __construct(protected ContentManagerInterface $contentManager, TranslatableContext $translatableContext)
public function __construct(TranslatableContext $translatableContext)
{
parent::__construct($translatableContext);
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$entityClass = new ReflectionClass($this->contentManager->getTypeClass($options['content_config']['_id']));

parent::buildForm($builder, $options);

$builder->add('publishedAt', DateTimeType::class, [
'required' => false,
'widget' => 'single_text',
]);

if ($entityClass->implementsInterface(ArticleAuthorInterface::class)) {
$builder->add('tags', ArticleTagsType::class);

$dataClass = $options['data_class'] ?? null;

if (is_string($dataClass) && is_a($dataClass, ArticleAuthorInterface::class, true)) {
$builder->add('author', UserType::class, [
'required' => true,
]);
Expand Down
13 changes: 7 additions & 6 deletions src/Form/Admin/Article/ArticleUpdateForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,35 @@

namespace Softspring\CmsBlogPlugin\Form\Admin\Article;

use ReflectionClass;
use Softspring\CmsBlogPlugin\Form\Type\ArticleTagsType;
use Softspring\CmsBlogPlugin\Model\ArticleAuthorInterface;
use Softspring\CmsBundle\Form\Admin\Content\ContentUpdateForm;
use Softspring\CmsBundle\Form\Type\UserType;
use Softspring\CmsBundle\Manager\ContentManagerInterface;
use Softspring\CmsBundle\Translator\TranslatableContext;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormBuilderInterface;

class ArticleUpdateForm extends ContentUpdateForm
{
public function __construct(protected ContentManagerInterface $contentManager, TranslatableContext $translatableContext)
public function __construct(TranslatableContext $translatableContext)
{
parent::__construct($translatableContext);
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$entityClass = new ReflectionClass($this->contentManager->getTypeClass($options['content_config']['_id']));

parent::buildForm($builder, $options);

$builder->add('publishedAt', DateTimeType::class, [
'required' => false,
'widget' => 'single_text',
]);

if ($entityClass->implementsInterface(ArticleAuthorInterface::class)) {
$builder->add('tags', ArticleTagsType::class);

$dataClass = $options['data_class'] ?? null;

if (is_string($dataClass) && is_a($dataClass, ArticleAuthorInterface::class, true)) {
$builder->add('author', UserType::class, [
'required' => true,
]);
Expand Down
56 changes: 56 additions & 0 deletions src/Form/Type/ArticleTagsType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Softspring\CmsBlogPlugin\Form\Type;

use Softspring\CmsBlogPlugin\Manager\ArticleTagManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ArticleTagsType extends AbstractType
{
public function __construct(protected ArticleTagManager $articleTagManager)
{
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new CallbackTransformer(
fn (?array $tags): string => json_encode($tags ?? [], JSON_THROW_ON_ERROR),
function (?string $tags): array {
if (empty($tags)) {
return [];
}

$decodedTags = json_decode($tags, true);

return is_array($decodedTags) ? $this->articleTagManager->canonicalizeTags($decodedTags) : [];
}
));
}

public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['tag_suggestions'] = $options['tag_suggestions'];
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'required' => false,
'empty_data' => '[]',
'tag_suggestions' => fn (Options $options): array => $this->articleTagManager->getExistingTags(),
]);
$resolver->setAllowedTypes('tag_suggestions', 'array');
}

public function getParent(): string
{
return HiddenType::class;
}
}
1 change: 1 addition & 0 deletions src/Manager/ArticleEntityDuplicator.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ public function duplicateData(ContentInterface $oldContent, ContentInterface $ne
{
$newContent->setAuthor($oldContent->getAuthor());
$newContent->setPublishedAt($oldContent->getPublishedAt());
$newContent->setTags($oldContent->getTags());
}
}
Loading