diff --git a/README.md b/README.md index 6c67b20..f20a919 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The last SEO Bundle for pimcore you'll ever need! ```json "require" : { - "dachcom-digital/seo" : "~3.0.0", + "dachcom-digital/seo" : "~3.1.0", } ``` diff --git a/UPGRADE.md b/UPGRADE.md index b7b6808..cd18097 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,8 @@ # Upgrade Notes +## 3.1.0 +- **[NEW FEATURE]** Add Release Type to allow draft/public states [@64](https://github.com/dachcom-digital/pimcore-seo/issues/64) + ## 3.0.3 - Fix Symfony Console deprecation in QueuedIndexDataCommand [@NiklasBr](https://github.com/dachcom-digital/pimcore-seo/pull/63) diff --git a/config/doctrine/model/ElementMetaData.orm.yml b/config/doctrine/model/ElementMetaData.orm.yml index 1a49940..d3437c5 100644 --- a/config/doctrine/model/ElementMetaData.orm.yml +++ b/config/doctrine/model/ElementMetaData.orm.yml @@ -24,6 +24,12 @@ SeoBundle\Model\ElementMetaData: column: data nullable: false type: array + releaseType: + column: release_type + nullable: false + type: string + options: + default: 'public' uniqueConstraints: element_type_id_integrator: - columns: [element_type, element_id, integrator] \ No newline at end of file + columns: [element_type, element_id, integrator, release_type] \ No newline at end of file diff --git a/public/js/metaData/abstractMetaDataPanel.js b/public/js/metaData/abstractMetaDataPanel.js index b3b242a..d9639a7 100755 --- a/public/js/metaData/abstractMetaDataPanel.js +++ b/public/js/metaData/abstractMetaDataPanel.js @@ -5,6 +5,7 @@ Seo.MetaData.AbstractMetaDataPanel = Class.create({ element: null, integrator: [], + draftNode: null, layout: null, tabPanel: null, renderAsTab: false, @@ -67,7 +68,24 @@ Seo.MetaData.AbstractMetaDataPanel = Class.create({ return; } + this.draftNode = new Ext.form.FieldContainer({ + xtype: 'container', + flex: 1, + hidden: resp.isDraft === false, + html: t('seo_bundle.panel.draft_note'), + style: { + padding: '5px', + border: '1px solid #6428b4', + background: '#6428b45c', + margin: '0 0 10px 0', + color: 'black' + } + }); + + this.layout.insert(0, this.draftNode) + this.buildMetaDataIntegrator(resp.data, resp.configuration, resp.availableLocales); + }.bind(this), failure: function () { Ext.Msg.alert(t('error'), t('seo_bundle.panel.error_fetch_data')); @@ -99,14 +117,17 @@ Seo.MetaData.AbstractMetaDataPanel = Class.create({ } }, - save: function () { + save: function (task) { var integratorValues = this.getIntegratorValues(); + this.draftNode.setHidden(task === 'publish'); + Ext.Ajax.request({ url: '/admin/seo/meta-data/set-element-meta-data-configuration', method: 'POST', params: { + task: task, integratorValues: Ext.encode(integratorValues), elementType: this.getElementType(), elementId: this.getElementId() diff --git a/public/js/plugin.js b/public/js/plugin.js index 864978d..7ceca08 100755 --- a/public/js/plugin.js +++ b/public/js/plugin.js @@ -62,12 +62,12 @@ class SeoCore { const document = ev.detail.document; - if (ev.detail.task === 'autoSave' || ev.detail.task === 'version') { + if (ev.detail.task === 'autoSave') { return; } if (document.hasOwnProperty('seoPanel')) { - document.seoPanel.save(); + document.seoPanel.save(ev.detail.task); } } @@ -75,12 +75,12 @@ class SeoCore { const object = ev.detail.object; - if (ev.detail.task === 'autoSave' || ev.detail.task === 'version') { + if (ev.detail.task === 'autoSave') { return; } if (object.hasOwnProperty('seoPanel')) { - object.seoPanel.save(); + object.seoPanel.save(ev.detail.task); } } diff --git a/src/Controller/Admin/MetaDataController.php b/src/Controller/Admin/MetaDataController.php index 77c847c..05daa8d 100644 --- a/src/Controller/Admin/MetaDataController.php +++ b/src/Controller/Admin/MetaDataController.php @@ -6,6 +6,7 @@ use Pimcore\Model\Document; use Pimcore\Bundle\AdminBundle\Controller\AdminAbstractController; use SeoBundle\Manager\ElementMetaDataManagerInterface; +use SeoBundle\Model\ElementMetaDataInterface; use SeoBundle\Tool\LocaleProviderInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -45,14 +46,18 @@ public function getElementMetaDataConfigurationAction(Request $request): JsonRes } $configuration = $this->elementMetaDataManager->getMetaDataIntegratorBackendConfiguration($element); - $data = $this->elementMetaDataManager->getElementDataForBackend($elementType, $elementId); - - return $this->adminJson([ - 'success' => true, - 'data' => $data, - 'availableLocales' => $availableLocales, - 'configuration' => $configuration, - ]); + $elementBackendData = $this->elementMetaDataManager->getElementDataForBackend($elementType, $elementId); + + return $this->adminJson( + array_merge( + [ + 'success' => true, + 'availableLocales' => $availableLocales, + 'configuration' => $configuration, + ], + $elementBackendData + ) + ); } /** @@ -62,6 +67,7 @@ public function setElementMetaDataConfigurationAction(Request $request): JsonRes { $elementId = (int) $request->request->get('elementId', 0); $elementType = $request->request->get('elementType'); + $task = $request->request->get('task', 'publish'); $integratorValues = json_decode($request->request->get('integratorValues'), true, 512, JSON_THROW_ON_ERROR); if (!is_array($integratorValues)) { @@ -70,7 +76,8 @@ public function setElementMetaDataConfigurationAction(Request $request): JsonRes foreach ($integratorValues as $integratorName => $integratorData) { $sanitizedData = is_array($integratorData) ? $integratorData : []; - $this->elementMetaDataManager->saveElementData($elementType, $elementId, $integratorName, $sanitizedData); + $releaseType = $task === 'publish' ? ElementMetaDataInterface::RELEASE_TYPE_PUBLIC : ElementMetaDataInterface::RELEASE_TYPE_DRAFT; + $this->elementMetaDataManager->saveElementData($elementType, $elementId, $integratorName, $sanitizedData, false, $releaseType); } return $this->adminJson([ diff --git a/src/EventListener/ElementMetaDataListener.php b/src/EventListener/ElementMetaDataListener.php index 96e859b..9fa3462 100644 --- a/src/EventListener/ElementMetaDataListener.php +++ b/src/EventListener/ElementMetaDataListener.php @@ -25,11 +25,11 @@ public static function getSubscribedEvents(): array public function handleDocumentDeletion(DocumentEvent $event): void { - $this->elementMetaDataManager->deleteElementData('document', $event->getDocument()->getId()); + $this->elementMetaDataManager->deleteElementData('document', $event->getDocument()->getId(), null); } public function handleObjectDeletion(DataObjectEvent $event): void { - $this->elementMetaDataManager->deleteElementData('object', $event->getObject()->getId()); + $this->elementMetaDataManager->deleteElementData('object', $event->getObject()->getId(), null); } } diff --git a/src/Manager/ElementMetaDataManager.php b/src/Manager/ElementMetaDataManager.php index 9dbf368..4fad64b 100644 --- a/src/Manager/ElementMetaDataManager.php +++ b/src/Manager/ElementMetaDataManager.php @@ -41,22 +41,33 @@ public function getMetaDataIntegratorBackendConfiguration(mixed $correspondingEl return $configuration; } - public function getElementData(string $elementType, int $elementId): array + public function getElementData(string $elementType, int $elementId, bool $allowDraftReleaseType = false): array { - $elementValues = $this->elementMetaDataRepository->findAll($elementType, $elementId); + $fetchingReleaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC; + if ($allowDraftReleaseType === true && $this->elementMetaDataExistsWithReleaseType($elementType, $elementId, ElementMetaDataInterface::RELEASE_TYPE_DRAFT)) { + $fetchingReleaseType = ElementMetaDataInterface::RELEASE_TYPE_DRAFT; + } + + $elementValues = $this->elementMetaDataRepository->findAll($elementType, $elementId, $fetchingReleaseType); // BC Reason: If old document metadata is available, use it! // @todo: make this decision configurable? We don't need this within fresh installations! - return $this->checkForLegacyData($elementValues, $elementType, $elementId); + return $this->checkForLegacyData($elementValues, $elementType, $elementId, $fetchingReleaseType); } public function getElementDataForBackend(string $elementType, int $elementId): array { + $isDraft = false; $parsedData = []; - $data = $this->getElementData($elementType, $elementId); + $data = $this->getElementData($elementType, $elementId, true); foreach ($data as $element) { + + if ($element->getReleaseType() === ElementMetaDataInterface::RELEASE_TYPE_DRAFT) { + $isDraft = true; + } + $metaDataIntegrator = $this->metaDataIntegratorRegistry->get($element->getIntegrator()); $parsedData[$element->getIntegrator()] = $metaDataIntegrator->validateBeforeBackend($elementType, $elementId, $element->getData()); } @@ -64,7 +75,10 @@ public function getElementDataForBackend(string $elementType, int $elementId): a // BC Reason: If old document metadata is available, use it! // @todo: make this decision configurable? We don't need this within fresh installations! - return $this->checkForLegacyBackendData($parsedData, $elementType, $elementId); + return [ + 'isDraft' => $isDraft, + 'data' => $this->checkForLegacyBackendData($parsedData, $elementType, $elementId) + ]; } public function getElementDataForXliffExport(string $elementType, int $elementId, string $locale): array @@ -114,15 +128,23 @@ public function saveElementDataFromXliffImport(string $elementType, int $element } } - public function saveElementData(string $elementType, int $elementId, string $integratorName, array $data, bool $merge = false): void - { - $elementMetaData = $this->elementMetaDataRepository->findByIntegrator($elementType, $elementId, $integratorName); + public function saveElementData( + string $elementType, + int $elementId, + string $integratorName, + array $data, + bool $merge = false, + string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC + ): void { + + $elementMetaData = $this->determinateElementMetaEntity($elementType, $elementId, $integratorName, $releaseType); if (!$elementMetaData instanceof ElementMetaDataInterface) { $elementMetaData = new ElementMetaData(); $elementMetaData->setElementType($elementType); $elementMetaData->setElementId($elementId); $elementMetaData->setIntegrator($integratorName); + $elementMetaData->setReleaseType($releaseType); } $metaDataIntegrator = $this->metaDataIntegratorRegistry->get($integratorName); @@ -130,6 +152,22 @@ public function saveElementData(string $elementType, int $elementId, string $int // remove empty meta data if ($sanitizedData === null) { + + if ($releaseType === ElementMetaDataInterface::RELEASE_TYPE_DRAFT) { + + // if draft, we still persist an empty element + // to determinate reset when publish state is incoming + + if ( + $elementMetaData->getId() > 0 || + $this->elementMetaDataExistsWithReleaseType($elementType, $elementId, ElementMetaDataInterface::RELEASE_TYPE_PUBLIC, $integratorName) + ) { + $this->persistElementMetaData($elementMetaData, []); + } + + return; + } + if ($elementMetaData->getId() > 0) { $this->entityManager->remove($elementMetaData); $this->entityManager->flush(); @@ -138,8 +176,12 @@ public function saveElementData(string $elementType, int $elementId, string $int return; } - $elementMetaData->setData($sanitizedData); + $this->persistElementMetaData($elementMetaData, $sanitizedData); + } + private function persistElementMetaData(ElementMetaDataInterface $elementMetaData, array $data): void + { + $elementMetaData->setData($data); $this->entityManager->persist($elementMetaData); $this->entityManager->flush(); } @@ -157,9 +199,9 @@ public function generatePreviewDataForElement(string $elementType, int $elementI return $metaDataIntegrator->getPreviewParameter($element, $template, $data); } - public function deleteElementData(string $elementType, int $elementId): void + public function deleteElementData(string $elementType, int $elementId, ?string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): void { - $elementData = $this->elementMetaDataRepository->findAll($elementType, $elementId); + $elementData = $this->elementMetaDataRepository->findAll($elementType, $elementId, $releaseType); if (count($elementData) === 0) { return; @@ -175,7 +217,7 @@ public function deleteElementData(string $elementType, int $elementId): void /** * @return array */ - protected function checkForLegacyData(array $elements, string $elementType, int $elementId): array + protected function checkForLegacyData(array $elements, string $elementType, int $elementId, string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): array { // as soon we have configured seo elements, // we'll never check the document again. It's all about performance. @@ -197,6 +239,7 @@ protected function checkForLegacyData(array $elements, string $elementType, int $legacyTitleDescription->setElementType($elementType); $legacyTitleDescription->setElementId($elementId); $legacyTitleDescription->setIntegrator('title_description'); + $legacyTitleDescription->setReleaseType($releaseType); $legacyTitleDescription->setData(['title' => $legacyData['title'], 'description' => $legacyData['description']]); $elements[] = $legacyTitleDescription; } @@ -264,4 +307,49 @@ protected function getDocumentLegacyData(int $documentId): ?array 'hasTitleDescriptionIntegrator' => $hasTitleDescriptionIntegrator !== false ]; } + + private function determinateElementMetaEntity( + string $elementType, + int $elementId, + string $integratorName, + string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC + ): ?ElementMetaDataInterface { + + $hasDraft = $this->elementMetaDataExistsWithReleaseType($elementType, $elementId, ElementMetaDataInterface::RELEASE_TYPE_DRAFT); + + if ($releaseType === ElementMetaDataInterface::RELEASE_TYPE_PUBLIC && $hasDraft === true) { + + // delete draft + $this->deleteElementData($elementType, $elementId, ElementMetaDataInterface::RELEASE_TYPE_DRAFT); + + return $this->elementMetaDataRepository->findByIntegrator($elementType, $elementId, $integratorName, $releaseType); + } + + return $this->elementMetaDataRepository->findByIntegrator($elementType, $elementId, $integratorName, $releaseType); + } + + private function elementMetaDataExistsWithReleaseType(string $elementType, int $elementId, string $releaseType, ?string $integratorName = null): bool + { + $qb = $this->elementMetaDataRepository->getQueryBuilder(); + + $qb + ->select('COUNT(e.id)') + ->andWhere('e.elementType = :elementType') + ->andWhere('e.elementId = :elementId') + ->andWhere('e.releaseType = :releaseType') + ->setParameter('elementType', $elementType) + ->setParameter('elementId', $elementId) + ->setParameter('releaseType', $releaseType); + + if ($integratorName !== null) { + $qb + ->andWhere('e.integrator = :integratorName') + ->setParameter('integratorName', $integratorName); + } + + return $qb + ->getQuery() + ->getSingleScalarResult() > 0; + } + } diff --git a/src/Manager/ElementMetaDataManagerInterface.php b/src/Manager/ElementMetaDataManagerInterface.php index 2dd8788..1127c3a 100644 --- a/src/Manager/ElementMetaDataManagerInterface.php +++ b/src/Manager/ElementMetaDataManagerInterface.php @@ -13,7 +13,7 @@ public function getMetaDataIntegratorBackendConfiguration(mixed $correspondingEl /** * @return array */ - public function getElementData(string $elementType, int $elementId): array; + public function getElementData(string $elementType, int $elementId, bool $allowDraftReleaseType = false): array; public function getElementDataForBackend(string $elementType, int $elementId): array; @@ -21,9 +21,16 @@ public function getElementDataForXliffExport(string $elementType, int $elementId public function saveElementDataFromXliffImport(string $elementType, int $elementId, array $rawData, string $locale): void; - public function saveElementData(string $elementType, int $elementId, string $integratorName, array $data, bool $merge = false): void; + public function saveElementData( + string $elementType, + int $elementId, + string $integratorName, + array $data, + bool $merge = false, + string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC + ): void; public function generatePreviewDataForElement(string $elementType, int $elementId, string $integratorName, ?string $template, array $data): array; - public function deleteElementData(string $elementType, int $elementId): void; + public function deleteElementData(string $elementType, int $elementId, ?string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): void; } diff --git a/src/Migrations/Version20240809095425.php b/src/Migrations/Version20240809095425.php new file mode 100644 index 0000000..220db3d --- /dev/null +++ b/src/Migrations/Version20240809095425.php @@ -0,0 +1,27 @@ +addSql('ALTER TABLE seo_element_meta_data ADD release_type VARCHAR(255) DEFAULT "public" NOT NULL;'); + $this->addSql('DROP INDEX element_type_id_integrator ON seo_element_meta_data;'); + $this->addSql('CREATE UNIQUE INDEX element_type_id_integrator ON seo_element_meta_data (element_type, element_id, integrator, release_type);'); + } + + public function down(Schema $schema): void + { + } +} diff --git a/src/Model/ElementMetaData.php b/src/Model/ElementMetaData.php index f0935ee..186550f 100644 --- a/src/Model/ElementMetaData.php +++ b/src/Model/ElementMetaData.php @@ -9,6 +9,7 @@ class ElementMetaData implements ElementMetaDataInterface protected int $elementId; protected string $integrator; protected array $data = []; + protected string $releaseType; public function setId(int $id): void { @@ -59,4 +60,14 @@ public function getData(): array { return $this->data; } + + public function getReleaseType(): string + { + return $this->releaseType; + } + + public function setReleaseType(string $releaseType): void + { + $this->releaseType = $releaseType; + } } diff --git a/src/Model/ElementMetaDataInterface.php b/src/Model/ElementMetaDataInterface.php index 77328db..fd4d1fb 100644 --- a/src/Model/ElementMetaDataInterface.php +++ b/src/Model/ElementMetaDataInterface.php @@ -4,6 +4,9 @@ interface ElementMetaDataInterface { + public const RELEASE_TYPE_PUBLIC = 'public'; + public const RELEASE_TYPE_DRAFT = 'draft'; + public function getId(): ?int; public function setElementType(string $elementType): void; @@ -21,4 +24,8 @@ public function getIntegrator(): string; public function setData(array $data): void; public function getData(): array; + + public function getReleaseType(): string; + + public function setReleaseType(string $releaseType): void; } diff --git a/src/Repository/ElementMetaDataRepository.php b/src/Repository/ElementMetaDataRepository.php index dfcf967..05dc934 100644 --- a/src/Repository/ElementMetaDataRepository.php +++ b/src/Repository/ElementMetaDataRepository.php @@ -4,6 +4,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\QueryBuilder; use SeoBundle\Model\ElementMetaData; use SeoBundle\Model\ElementMetaDataInterface; @@ -16,20 +17,36 @@ public function __construct(EntityManagerInterface $entityManager) $this->repository = $entityManager->getRepository(ElementMetaData::class); } - public function findAll(string $elementType, int $elementId): array + public function getQueryBuilder(): QueryBuilder { - return $this->repository->findBy([ + return $this->repository->createQueryBuilder('e'); + } + + public function findAll(string $elementType, int $elementId, ?string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): array + { + $conditions = [ 'elementType' => $elementType, 'elementId' => $elementId - ]); + ]; + + if ($releaseType !== null) { + $conditions['releaseType'] = $releaseType; + } + + return $this->repository->findBy($conditions); } - public function findByIntegrator(string $elementType, int $elementId, string $integrator): ?ElementMetaDataInterface - { + public function findByIntegrator( + string $elementType, + int $elementId, + string $integrator, + string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC + ): ?ElementMetaDataInterface { return $this->repository->findOneBy([ 'elementType' => $elementType, 'elementId' => $elementId, - 'integrator' => $integrator + 'integrator' => $integrator, + 'releaseType' => $releaseType, ]); } } diff --git a/src/Repository/ElementMetaDataRepositoryInterface.php b/src/Repository/ElementMetaDataRepositoryInterface.php index d68fc02..c9288eb 100644 --- a/src/Repository/ElementMetaDataRepositoryInterface.php +++ b/src/Repository/ElementMetaDataRepositoryInterface.php @@ -2,14 +2,17 @@ namespace SeoBundle\Repository; +use Doctrine\ORM\QueryBuilder; use SeoBundle\Model\ElementMetaDataInterface; interface ElementMetaDataRepositoryInterface { + public function getQueryBuilder(): QueryBuilder; + /** * @return array */ - public function findAll(string $elementType, int $elementId): array; + public function findAll(string $elementType, int $elementId, ?string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): array; - public function findByIntegrator(string $elementType, int $elementId, string $integrator): ?ElementMetaDataInterface; + public function findByIntegrator(string $elementType, int $elementId, string $integrator, string $releaseType = ElementMetaDataInterface::RELEASE_TYPE_PUBLIC): ?ElementMetaDataInterface; } diff --git a/translations/admin.de.yml b/translations/admin.de.yml index e14baae..61716f4 100755 --- a/translations/admin.de.yml +++ b/translations/admin.de.yml @@ -1,4 +1,5 @@ seo_bundle.panel_title: 'SEO' +seo_bundle.panel.draft_note: 'Unveröffentlichte Version!' seo_bundle.panel.error_fetch_data: 'Fehler beim Abrufen der SEO-Metadaten.' seo_bundle.panel.error_save_data: 'Fehler beim Speichern der SEO-Metadaten.' seo_bundle.panel.default_pimcore_disabled: 'Der Standard-SEO-Bereich wurde deaktiviert. Verwenden Sie stattdessen das "SEO"-Panel.' diff --git a/translations/admin.en.yml b/translations/admin.en.yml index 9cf1187..5a8ce30 100755 --- a/translations/admin.en.yml +++ b/translations/admin.en.yml @@ -1,4 +1,5 @@ seo_bundle.panel_title: 'SEO' +seo_bundle.panel.draft_note: 'Unpublished Version!' seo_bundle.panel.error_fetch_data: 'Error while fetching seo metadata.' seo_bundle.panel.error_save_data: 'Error while saving seo metadata.' seo_bundle.panel.default_pimcore_disabled: 'The default SEO Section has been disabled. Use the "SEO" Panel instead.'