From db2e201e4594dd4b50da96f3b138f90c4a996b41 Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Sun, 29 Jun 2025 18:45:03 +0200 Subject: [PATCH 1/8] refactor(Node): Moved node status enum to date-based NodesSources publication status --- ...s the collection of Article resources-.bru | 6 +- .../Core/AbstractEntities/NodeInterface.php | 6 - lib/RoadizCoreBundle/config/services.yaml | 3 +- .../migrations/Version20250629125716.php | 29 +++ .../migrations/Version20250629130118.php | 119 ++++++++++ .../migrations/Version20250629150839.php | 41 ++++ .../AttributeValueQueryExtension.php | 29 ++- .../src/Api/Extension/NodeQueryExtension.php | 18 +- .../Extension/NodesSourcesQueryExtension.php | 27 +-- .../NodesSourcesStatusExtensionTrait.php | 36 +++ .../Api/Extension/NodesTagsQueryExtension.php | 29 ++- .../src/Api/Filter/NodesTagsFilter.php | 19 +- .../src/Console/NodesEmptyTrashCommand.php | 52 ++--- .../Compiler/NodeWorkflowCompilerPass.php | 14 +- .../NodesSourcesInheritanceSubscriber.php | 43 +++- lib/RoadizCoreBundle/src/Entity/Node.php | 210 +++++++----------- .../src/Entity/NodesSources.php | 56 ++--- .../src/Entity/StatusAwareEntityInterface.php | 22 ++ .../src/Entity/StatusAwareEntityTrait.php | 87 ++++++++ .../src/EntityHandler/NodeHandler.php | 24 -- lib/RoadizCoreBundle/src/Enum/NodeStatus.php | 3 + .../EventSubscriber/NodeNameSubscriber.php | 2 +- .../src/Form/NodeStatesType.php | 3 + .../src/Model/NodeTreeDto.php | 40 ++-- .../src/Model/NodesSourcesTreeDto.php | 41 +++- .../src/Node/NodeDuplicator.php | 5 +- lib/RoadizCoreBundle/src/Node/NodeFactory.php | 4 +- .../src/Node/UniqueNodeGenerator.php | 3 +- .../src/Repository/NodeRepository.php | 31 ++- .../src/Repository/NodesSourcesRepository.php | 70 +++--- .../src/Repository/StatusAwareRepository.php | 43 ++-- .../StatusAwareRepositoryInterface.php | 2 +- .../src/Routing/NodeRouteHelper.php | 7 +- .../GlobalNodeSourceSearchHandler.php | 7 +- ... => NodesSourcesWorkflowGuardListener.php} | 34 ++- .../StatusAwareEntityMarkingStore.php | 60 +++++ .../src/Workflow/NodeWorkflow.php | 37 ++- .../src/Workflow/NodesSourcesWorkflow.php | 36 +++ .../config/routing/ajax.yml | 6 + .../Node/NodesSourcesStatusController.php | 70 ++++++ .../templates/nodes/actionsMenu.html.twig | 96 ++++---- .../src/NodeSourceSearchHandler.php | 164 ++++++++------ .../DefaultNodesSourcesIndexingSubscriber.php | 8 +- .../AjaxControllers/AjaxNodesController.php | 6 +- .../AjaxNodesExplorerController.php | 5 +- .../Nodes/NodesAttributesController.php | 6 +- .../Nodes/NodesBulkActionsTrait.php | 7 +- .../src/Controllers/Nodes/NodesController.php | 21 +- .../Nodes/NodesSourcesController.php | 27 +-- .../Nodes/NodesTreesController.php | 3 +- .../custom-elements/NodesSourcesStatuses.js | 80 +++++++ .../views/nodes/editSource.html.twig | 2 +- .../views/nodes/translationBar.html.twig | 10 +- src/DataFixtures/ArticleFixtures.php | 3 - src/DataFixtures/OfferFixtures.php | 3 - 55 files changed, 1171 insertions(+), 644 deletions(-) create mode 100644 lib/RoadizCoreBundle/migrations/Version20250629125716.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20250629130118.php create mode 100644 lib/RoadizCoreBundle/migrations/Version20250629150839.php create mode 100644 lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesStatusExtensionTrait.php create mode 100644 lib/RoadizCoreBundle/src/Entity/StatusAwareEntityInterface.php create mode 100644 lib/RoadizCoreBundle/src/Entity/StatusAwareEntityTrait.php rename lib/RoadizCoreBundle/src/Workflow/Event/{NodeStatusGuardListener.php => NodesSourcesWorkflowGuardListener.php} (72%) create mode 100644 lib/RoadizCoreBundle/src/Workflow/MarkingStore/StatusAwareEntityMarkingStore.php create mode 100644 lib/RoadizCoreBundle/src/Workflow/NodesSourcesWorkflow.php create mode 100644 lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php create mode 100644 lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js diff --git a/bruno/Roadiz development app/Article/Retrieves the collection of Article resources-.bru b/bruno/Roadiz development app/Article/Retrieves the collection of Article resources-.bru index add2579b3..bf3fde17d 100644 --- a/bruno/Roadiz development app/Article/Retrieves the collection of Article resources-.bru +++ b/bruno/Roadiz development app/Article/Retrieves the collection of Article resources-.bru @@ -5,21 +5,21 @@ meta { } get { - url: {{baseUrl}}/api/articles?_locale=fr&publishedAt[archive]=2023-06 + url: {{baseUrl}}/api/articles?_locale=fr&order[publishedAt]=desc body: none auth: none } params:query { _locale: fr - publishedAt[archive]: 2023-06 + order[publishedAt]: desc + ~publishedAt[archive]: 2023-06 ~tagGroup[0]: quis,ut ~tagGroup[1]: modi-en ~page: ~itemsPerPage: ~properties[]: ~order[unpublishedAt]: - ~order[publishedAt]: ~unpublishedAt[before]: ~unpublishedAt[strictly_before]: ~unpublishedAt[after]: diff --git a/lib/Models/src/Core/AbstractEntities/NodeInterface.php b/lib/Models/src/Core/AbstractEntities/NodeInterface.php index 421c0d77a..e5a740fe7 100644 --- a/lib/Models/src/Core/AbstractEntities/NodeInterface.php +++ b/lib/Models/src/Core/AbstractEntities/NodeInterface.php @@ -16,11 +16,5 @@ public function getChildrenOrder(): string; public function getChildrenOrderDirection(): string; - public function isDraft(): bool; - - public function isPending(): bool; - - public function isPublished(): bool; - public function getNodeTypeName(): string; } diff --git a/lib/RoadizCoreBundle/config/services.yaml b/lib/RoadizCoreBundle/config/services.yaml index 65d8d6f34..b02c5ad5f 100644 --- a/lib/RoadizCoreBundle/config/services.yaml +++ b/lib/RoadizCoreBundle/config/services.yaml @@ -655,9 +655,8 @@ services: # # Workflows # - state_machine.node: + RZ\Roadiz\CoreBundle\Workflow\NodesSourcesWorkflow: public: true - alias: RZ\Roadiz\CoreBundle\Workflow\NodeWorkflow RZ\TreeWalker\WalkerContextInterface: factory: [ '@RZ\Roadiz\CoreBundle\Api\TreeWalker\NodeSourceWalkerContextFactory', 'createWalkerContext' ] diff --git a/lib/RoadizCoreBundle/migrations/Version20250629125716.php b/lib/RoadizCoreBundle/migrations/Version20250629125716.php new file mode 100644 index 000000000..8fe10345c --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20250629125716.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE nodes_sources ADD created_at DATETIME DEFAULT NULL, ADD updated_at DATETIME DEFAULT NULL, ADD deleted_at DATETIME DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE nodes_sources DROP created_at, DROP updated_at, DROP deleted_at'); + } +} diff --git a/lib/RoadizCoreBundle/migrations/Version20250629130118.php b/lib/RoadizCoreBundle/migrations/Version20250629130118.php new file mode 100644 index 000000000..ef3f6ab4b --- /dev/null +++ b/lib/RoadizCoreBundle/migrations/Version20250629130118.php @@ -0,0 +1,119 @@ +connection->executeQuery(<<fetchFirstColumn()); + + $publishedIds = implode(',', $this->connection->executeQuery(<<fetchFirstColumn()); + + $deletedIds = implode(',', $this->connection->executeQuery(<<fetchFirstColumn()); + + // Set nodes sources created_at and update_at from node created_at and update_at + $this->addSql(<<addSql(<<addSql(<< NOW()) + AND id IN ({$publishedIds}); +SQL); + + // Delete nodes sources with node status DELETED (50) and ARCHIVED (40) + $this->addSql(<< NOW()) + AND id IN ({$deletedIds}); +SQL); + } + + public function down(Schema $schema): void + { + // Set nodes created_at and update_at from nodes_sources created_at and update_at + $this->addSql(<<connection->executeQuery(<< NOW() +SQL)->fetchFirstColumn()); + + $this->warnIf( + count($draftIds) > 0, + 'Some nodes_sources are marked draft, this will force their node to be draft too.' + ); + + $publishedIds = implode(',', $this->connection->executeQuery(<<fetchFirstColumn()); + + $deletedIds = implode(',', $this->connection->executeQuery(<<fetchFirstColumn()); + + $this->warnIf( + count($deletedIds) > 0, + 'Some nodes_sources are marked deleted, this will not be reverted!' + ); + + // Draft nodes status DRAFT (10) when nodes_sources published_at is NULL or in the future + $this->addSql(<<addSql(<<addSql('CREATE INDEX ns_created_at ON nodes_sources (created_at)'); + $this->addSql('CREATE INDEX ns_deleted_at ON nodes_sources (deleted_at)'); + $this->addSql('CREATE INDEX ns_updated_at ON nodes_sources (updated_at)'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX idx_7c7ded6d4ad26064 TO ns_discr'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX idx_7c7ded6de0d4fde1 TO ns_published_at'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX idx_7c7ded6d2b36786b TO ns_title'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX ns_created_at ON nodes_sources'); + $this->addSql('DROP INDEX ns_deleted_at ON nodes_sources'); + $this->addSql('DROP INDEX ns_updated_at ON nodes_sources'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_discr TO IDX_7C7DED6D4AD26064'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_title TO IDX_7C7DED6D2B36786B'); + $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_published_at TO IDX_7C7DED6DE0D4FDE1'); + $this->addSql('CREATE INDEX IDX_1483A5E98B8E8428 ON users (created_at)'); + $this->addSql('CREATE INDEX IDX_1483A5E943625D9F ON users (updated_at)'); + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Extension/AttributeValueQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/AttributeValueQueryExtension.php index 2633666b2..226e0581f 100644 --- a/lib/RoadizCoreBundle/src/Api/Extension/AttributeValueQueryExtension.php +++ b/lib/RoadizCoreBundle/src/Api/Extension/AttributeValueQueryExtension.php @@ -9,13 +9,15 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use RZ\Roadiz\CoreBundle\Entity\AttributeValue; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; final readonly class AttributeValueQueryExtension implements QueryItemExtensionInterface, QueryCollectionExtensionInterface { + use NodesSourcesStatusExtensionTrait; + public function __construct( private PreviewResolverInterface $previewResolver, ) { @@ -30,7 +32,7 @@ public function applyToItem( ?Operation $operation = null, array $context = [], ): void { - $this->apply($queryBuilder, $resourceClass); + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass); } #[\Override] @@ -41,11 +43,12 @@ public function applyToCollection( ?Operation $operation = null, array $context = [], ): void { - $this->apply($queryBuilder, $resourceClass); + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass); } private function apply( QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ): void { if ( @@ -68,18 +71,14 @@ private function apply( $joinAlias = $existingNodeJoin->getAlias(); } - if ($this->previewResolver->isPreview()) { - $queryBuilder - ->andWhere($queryBuilder->expr()->lte($joinAlias.'.status', ':status')) - ->setParameter(':status', NodeStatus::PUBLISHED); - - return; - } - - $queryBuilder - ->andWhere($queryBuilder->expr()->eq($joinAlias.'.status', ':status')) - ->setParameter(':status', NodeStatus::PUBLISHED); + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + $joinAlias, + 'nodeSources', + Join::INNER_JOIN + ); - return; + $this->alterQueryBuilderWithStatus($queryBuilder, $alias); } } diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php index 72e0a1ba6..158137696 100644 --- a/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodeQueryExtension.php @@ -12,11 +12,12 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use RZ\Roadiz\CoreBundle\Entity\Node; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; final readonly class NodeQueryExtension implements QueryItemExtensionInterface, QueryCollectionExtensionInterface { + use NodesSourcesStatusExtensionTrait; + public function __construct( private PreviewResolverInterface $previewResolver, ) { @@ -43,14 +44,6 @@ private function apply( return; } - if ($this->previewResolver->isPreview()) { - $queryBuilder - ->andWhere($queryBuilder->expr()->lte('o.status', ':status')) - ->setParameter(':status', NodeStatus::PUBLISHED); - - return; - } - $alias = QueryBuilderHelper::addJoinOnce( $queryBuilder, $queryNameGenerator, @@ -58,13 +51,8 @@ private function apply( 'nodeSources', Join::INNER_JOIN ); - $queryBuilder - ->andWhere($queryBuilder->expr()->lte($alias.'.publishedAt', ':lte_published_at')) - ->andWhere($queryBuilder->expr()->eq('o.status', ':status')) - ->setParameter(':lte_published_at', new \DateTime()) - ->setParameter(':status', NodeStatus::PUBLISHED); - return; + $this->alterQueryBuilderWithStatus($queryBuilder, $alias); } #[\Override] diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php index 76458d9d3..fa4427bbf 100644 --- a/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesQueryExtension.php @@ -6,17 +6,16 @@ use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; -use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; -use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use RZ\Roadiz\CoreBundle\Entity\NodesSources; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; final readonly class NodesSourcesQueryExtension implements QueryItemExtensionInterface, QueryCollectionExtensionInterface { + use NodesSourcesStatusExtensionTrait; + public function __construct( private PreviewResolverInterface $previewResolver, private string $generatedEntityNamespacePattern = '#^App\\\GeneratedEntity\\\NS(?:[a-zA-Z]+)$#', @@ -65,26 +64,6 @@ private function apply( $queryBuilder->andWhere($queryBuilder->expr()->isInstanceOf('o', $resourceClass)); } - $alias = QueryBuilderHelper::addJoinOnce( - $queryBuilder, - $queryNameGenerator, - 'o', - 'node', - Join::INNER_JOIN - ); - - if ($this->previewResolver->isPreview()) { - $queryBuilder - ->andWhere($queryBuilder->expr()->lte($alias.'.status', ':status')) - ->setParameter(':status', NodeStatus::PUBLISHED); - - return; - } - - $queryBuilder - ->andWhere($queryBuilder->expr()->lte('o.publishedAt', ':lte_published_at')) - ->andWhere($queryBuilder->expr()->eq($alias.'.status', ':status')) - ->setParameter(':lte_published_at', new \DateTime()) - ->setParameter(':status', NodeStatus::PUBLISHED); + $this->alterQueryBuilderWithStatus($queryBuilder, 'o'); } } diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesStatusExtensionTrait.php b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesStatusExtensionTrait.php new file mode 100644 index 000000000..40a7357b2 --- /dev/null +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodesSourcesStatusExtensionTrait.php @@ -0,0 +1,36 @@ +previewResolver->isPreview()) { + $queryBuilder->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->isNull($alias.'.deletedAt'), + $queryBuilder->expr()->gt($alias.'.deletedAt', ':gt_deleted_at') + ))->setParameter(':gt_deleted_at', new \DateTime()); + + return $queryBuilder; + } + + /* + * Filter nodes sources by their status. + */ + $queryBuilder + ->andWhere($queryBuilder->expr()->lte($alias.'.publishedAt', ':lte_published_at')) + ->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->isNull($alias.'.deletedAt'), + $queryBuilder->expr()->gt($alias.'.deletedAt', ':gt_deleted_at') + )) + ->setParameter(':lte_published_at', new \DateTime()) + ->setParameter(':gt_deleted_at', new \DateTime()); + + return $queryBuilder; + } +} diff --git a/lib/RoadizCoreBundle/src/Api/Extension/NodesTagsQueryExtension.php b/lib/RoadizCoreBundle/src/Api/Extension/NodesTagsQueryExtension.php index ce6821416..3565b02c6 100644 --- a/lib/RoadizCoreBundle/src/Api/Extension/NodesTagsQueryExtension.php +++ b/lib/RoadizCoreBundle/src/Api/Extension/NodesTagsQueryExtension.php @@ -9,13 +9,15 @@ use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use RZ\Roadiz\CoreBundle\Entity\Tag; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; final readonly class NodesTagsQueryExtension implements QueryItemExtensionInterface, QueryCollectionExtensionInterface { + use NodesSourcesStatusExtensionTrait; + public function __construct( private PreviewResolverInterface $previewResolver, ) { @@ -30,7 +32,7 @@ public function applyToItem( ?Operation $operation = null, array $context = [], ): void { - $this->apply($queryBuilder, $resourceClass); + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass); } #[\Override] @@ -41,11 +43,12 @@ public function applyToCollection( ?Operation $operation = null, array $context = [], ): void { - $this->apply($queryBuilder, $resourceClass); + $this->apply($queryBuilder, $queryNameGenerator, $resourceClass); } private function apply( QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ): void { if ( @@ -73,18 +76,14 @@ private function apply( return; } - if ($this->previewResolver->isPreview()) { - $queryBuilder - ->andWhere($queryBuilder->expr()->lte($existingNodeJoin->getAlias().'.status', ':status')) - ->setParameter(':status', NodeStatus::PUBLISHED); - - return; - } - - $queryBuilder - ->andWhere($queryBuilder->expr()->eq($existingNodeJoin->getAlias().'.status', ':status')) - ->setParameter(':status', NodeStatus::PUBLISHED); + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + $existingJoin->getAlias(), + 'nodeSources', + Join::INNER_JOIN + ); - return; + $this->alterQueryBuilderWithStatus($queryBuilder, $alias); } } diff --git a/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php b/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php index 8c4092370..6d3a63d8b 100644 --- a/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php +++ b/lib/RoadizCoreBundle/src/Api/Filter/NodesTagsFilter.php @@ -11,7 +11,7 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; -use RZ\Roadiz\CoreBundle\Entity\Node; +use RZ\Roadiz\CoreBundle\Api\Extension\NodesSourcesStatusExtensionTrait; use RZ\Roadiz\CoreBundle\Entity\NodesTags; use RZ\Roadiz\CoreBundle\Entity\Tag; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; @@ -19,6 +19,8 @@ final class NodesTagsFilter extends AbstractFilter { + use NodesSourcesStatusExtensionTrait; + public const string PROPERTY_PARAMETER = 'nodesTags'; public const array TRUE_VALUES = [true, '1', 1, 'true', 'on']; public const array FALSE_VALUES = [false, '0', 0, 'false', 'off']; @@ -122,19 +124,8 @@ private function alterQueryBuilder(QueryBuilder $queryBuilder, array $parameters ->select('DISTINCT(IDENTITY(ntg.tag))') ->innerJoin('ntg.node', 'n') ; - - if ($this->previewResolver->isPreview()) { - $ntgQb->andWhere($ntgQb->expr()->lte('n.status', ':status')); - $queryBuilder->setParameter(':status', Node::PUBLISHED); - } else { - $ntgQb - ->innerJoin('n.nodeSources', 'ns') - ->andWhere($ntgQb->expr()->lte('ns.publishedAt', ':lte_published_at')) - ->andWhere($ntgQb->expr()->eq('n.status', ':status')); - $queryBuilder - ->setParameter(':lte_published_at', new \DateTime()) - ->setParameter(':status', Node::PUBLISHED); - } + $ntgQb->innerJoin('n.nodeSources', 'ns'); + $this->alterQueryBuilderWithStatus($ntgQb, 'ns'); if (true === $parameters['withoutNodes']) { $queryBuilder->andWhere($queryBuilder->expr()->notIn( diff --git a/lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php b/lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php index 6b2de1cec..08fed9ccd 100644 --- a/lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php +++ b/lib/RoadizCoreBundle/src/Console/NodesEmptyTrashCommand.php @@ -4,12 +4,11 @@ namespace RZ\Roadiz\CoreBundle\Console; -use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; -use RZ\Roadiz\CoreBundle\Entity\Node; +use RZ\Roadiz\CoreBundle\Entity\NodesSources; use RZ\Roadiz\CoreBundle\EntityHandler\HandlerFactory; use RZ\Roadiz\CoreBundle\EntityHandler\NodeHandler; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; +use RZ\Roadiz\CoreBundle\Repository\AllStatusesNodesSourcesRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -21,6 +20,7 @@ final class NodesEmptyTrashCommand extends Command public function __construct( private readonly ManagerRegistry $managerRegistry, private readonly HandlerFactory $handlerFactory, + private readonly AllStatusesNodesSourcesRepository $nodesSourcesRepository, ?string $name = null, ) { parent::__construct($name); @@ -31,7 +31,7 @@ protected function configure(): void { $this ->setName('nodes:empty-trash') - ->setDescription('Remove definitely deleted nodes.') + ->setDescription('Remove definitely deleted nodes-sources.') ; } @@ -40,21 +40,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $em = $this->managerRegistry->getManagerForClass(Node::class); - $countQb = $this->createNodeQueryBuilder(); - $countQuery = $countQb->select($countQb->expr()->count('n')) - ->andWhere($countQb->expr()->eq('n.status', ':status')) - ->setParameter('status', NodeStatus::DELETED) - ->getQuery(); - $emptiedCount = $countQuery->getSingleScalarResult(); + $emptiedCount = $this->nodesSourcesRepository->countDeleted(); if (0 == $emptiedCount) { - $io->success('Nodes trashcan is already empty.'); + $io->success('Nodes-sources trashcan is already empty.'); return 0; } $confirmation = new ConfirmationQuestion( - sprintf('Are you sure to empty nodes trashcan, %d nodes will be lost forever? [y/N]: ', $emptiedCount), + sprintf('Are you sure to empty nodes-sources trashcan, %d nodes-sources will be lost forever? [y/N]: ', $emptiedCount), false ); @@ -66,16 +60,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int $batchSize = 100; $io->progressStart((int) $emptiedCount); - $qb = $this->createNodeQueryBuilder(); - $q = $qb->select('n') - ->andWhere($countQb->expr()->eq('n.status', ':status')) - ->setParameter('status', NodeStatus::DELETED) - ->getQuery(); + $em = $this->managerRegistry->getManagerForClass(NodesSources::class); + $q = $this->nodesSourcesRepository->findAllDeletedQuery(); + /** @var NodesSources $row */ foreach ($q->toIterable() as $row) { - /** @var NodeHandler $nodeHandler */ - $nodeHandler = $this->handlerFactory->getHandler($row); - $nodeHandler->removeWithChildrenAndAssociations(); + $node = $row->getNode(); + /* + * If all nodes-sources are deleted for one node, + * delete the node and all its associations. + */ + if ($node->isDeleted()) { + /** @var NodeHandler $nodeHandler */ + $nodeHandler = $this->handlerFactory->getHandler($row); + $nodeHandler->removeWithChildrenAndAssociations(); + } else { + // Otherwise, just delete this nodes-source + $em->remove($row); + } + $io->progressAdvance(); ++$i; // Call flush time to times @@ -94,11 +97,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - - protected function createNodeQueryBuilder(): QueryBuilder - { - return $this->managerRegistry - ->getRepository(Node::class) - ->createQueryBuilder('n'); - } } diff --git a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php index 1759e5d97..13206c6f2 100644 --- a/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php +++ b/lib/RoadizCoreBundle/src/DependencyInjection/Compiler/NodeWorkflowCompilerPass.php @@ -5,6 +5,9 @@ namespace RZ\Roadiz\CoreBundle\DependencyInjection\Compiler; use RZ\Roadiz\Core\AbstractEntities\NodeInterface; +use RZ\Roadiz\CoreBundle\Entity\NodesSources; +use RZ\Roadiz\CoreBundle\Workflow\NodesSourcesWorkflow; +use RZ\Roadiz\CoreBundle\Workflow\NodeWorkflow; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -21,11 +24,14 @@ public function process(ContainerBuilder $container): void throw new LogicException('Workflow support cannot be enabled as the Workflow component is not installed. Try running "composer require symfony/workflow".'); } - $workflowId = 'state_machine.node'; $registryDefinition = $container->getDefinition('workflow.registry'); - $strategyDefinition = new Definition(InstanceOfSupportStrategy::class, [NodeInterface::class]); - $strategyDefinition->setPublic(false); - $registryDefinition->addMethodCall('addWorkflow', [new Reference($workflowId), $strategyDefinition]); + $nodeStrategy = new Definition(InstanceOfSupportStrategy::class, [NodeInterface::class]); + $nodeStrategy->setPublic(false); + $registryDefinition->addMethodCall('addWorkflow', [new Reference(NodeWorkflow::class), $nodeStrategy]); + + $nodesSourcesStrategy = new Definition(InstanceOfSupportStrategy::class, [NodesSources::class]); + $nodesSourcesStrategy->setPublic(false); + $registryDefinition->addMethodCall('addWorkflow', [new Reference(NodesSourcesWorkflow::class), $nodesSourcesStrategy]); } } diff --git a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php index 596c0af79..e6b8341d7 100644 --- a/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php +++ b/lib/RoadizCoreBundle/src/Doctrine/EventSubscriber/NodesSourcesInheritanceSubscriber.php @@ -68,18 +68,39 @@ public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void $nodeSourceTableAnnotation = [ 'name' => $metadata->getTableName(), 'indexes' => [ - ['columns' => ['discr']], - ['columns' => ['title']], - ['columns' => ['published_at']], + 'ns_created_at' => ['columns' => ['created_at']], + 'ns_deleted_at' => ['columns' => ['deleted_at']], + 'ns_discr_translation_published' => [ + 'columns' => ['discr', 'translation_id', 'published_at'], + ], + 'ns_discr_translation' => [ + 'columns' => ['discr', 'translation_id'], + ], + 'ns_discr' => ['columns' => ['discr']], 'ns_no_index' => ['columns' => ['no_index']], - 'ns_node_translation_published' => ['columns' => ['node_id', 'translation_id', 'published_at']], - 'ns_node_discr_translation' => ['columns' => ['node_id', 'discr', 'translation_id']], - 'ns_node_discr_translation_published' => ['columns' => ['node_id', 'discr', 'translation_id', 'published_at']], - 'ns_translation_published' => ['columns' => ['translation_id', 'published_at']], - 'ns_discr_translation' => ['columns' => ['discr', 'translation_id']], - 'ns_discr_translation_published' => ['columns' => ['discr', 'translation_id', 'published_at']], - 'ns_title_published' => ['columns' => ['title', 'published_at']], - 'ns_title_translation_published' => ['columns' => ['title', 'translation_id', 'published_at']], + 'ns_node_discr_translation_published' => [ + 'columns' => ['node_id', 'discr', 'translation_id', 'published_at'], + ], + 'ns_node_discr_translation' => [ + 'columns' => ['node_id', 'discr', 'translation_id'], + ], + 'ns_node_translation_published' => [ + 'columns' => ['node_id', 'translation_id', 'published_at'], + ], + 'ns_published_at' => ['columns' => ['published_at']], + 'ns_title_published' => [ + 'columns' => ['title', 'published_at'], + ], + 'ns_title_translation_published' => [ + 'columns' => ['title', 'translation_id', 'published_at'], + ], + 'ns_title' => [ + 'columns' => ['title'], + ], + 'ns_translation_published' => [ + 'columns' => ['translation_id', 'published_at'], + ], + 'ns_updated_at' => ['columns' => ['updated_at']], ], 'uniqueConstraints' => [ ['columns' => ['node_id', 'translation_id']], diff --git a/lib/RoadizCoreBundle/src/Entity/Node.php b/lib/RoadizCoreBundle/src/Entity/Node.php index e78424508..0be6a1c70 100644 --- a/lib/RoadizCoreBundle/src/Entity/Node.php +++ b/lib/RoadizCoreBundle/src/Entity/Node.php @@ -16,18 +16,16 @@ use Gedmo\Loggable\Loggable; use Gedmo\Mapping\Annotation as Gedmo; use RZ\Roadiz\Contracts\NodeType\NodeTypeFieldInterface; -use RZ\Roadiz\Core\AbstractEntities\DateTimedInterface; -use RZ\Roadiz\Core\AbstractEntities\DateTimedTrait; use RZ\Roadiz\Core\AbstractEntities\LeafInterface; use RZ\Roadiz\Core\AbstractEntities\LeafTrait; use RZ\Roadiz\Core\AbstractEntities\NodeInterface; +use RZ\Roadiz\Core\AbstractEntities\PersistableInterface; use RZ\Roadiz\Core\AbstractEntities\SequentialIdTrait; use RZ\Roadiz\Core\AbstractEntities\TranslationInterface; use RZ\Roadiz\CoreBundle\Api\Filter as RoadizFilter; use RZ\Roadiz\CoreBundle\Api\Filter\NodeTypePublishableFilter; use RZ\Roadiz\CoreBundle\Api\Filter\NodeTypeReachableFilter; use RZ\Roadiz\CoreBundle\Api\Filter\TagGroupFilter; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Model\AttributableInterface; use RZ\Roadiz\CoreBundle\Model\AttributableTrait; use RZ\Roadiz\CoreBundle\Repository\NodeRepository; @@ -44,27 +42,20 @@ #[ ORM\Entity(repositoryClass: NodeRepository::class), ORM\Table(name: 'nodes'), - ORM\Index(columns: ['visible']), - ORM\Index(columns: ['status']), - ORM\Index(columns: ['locked']), - ORM\Index(columns: ['sterile']), - ORM\Index(columns: ['position']), - ORM\Index(columns: ['created_at']), - ORM\Index(columns: ['updated_at']), ORM\Index(columns: ['hide_children']), ORM\Index(columns: ['home']), - ORM\Index(columns: ['node_name', 'status']), - ORM\Index(columns: ['visible', 'status']), - ORM\Index(columns: ['visible', 'status', 'parent_node_id'], name: 'node_visible_status_parent'), - ORM\Index(columns: ['status', 'parent_node_id'], name: 'node_status_parent'), + ORM\Index(columns: ['locked']), + ORM\Index(columns: ['node_name'], name: 'node_name'), + ORM\Index(columns: ['nodetype_name', 'parent_node_id', 'position'], name: 'node_ntname_parent_position'), + ORM\Index(columns: ['nodetype_name', 'parent_node_id'], name: 'node_ntname_parent'), ORM\Index(columns: ['nodetype_name'], name: 'node_ntname'), - ORM\Index(columns: ['nodetype_name', 'status'], name: 'node_ntname_status'), - ORM\Index(columns: ['nodetype_name', 'status', 'parent_node_id'], name: 'node_ntname_status_parent'), - ORM\Index(columns: ['nodetype_name', 'status', 'parent_node_id', 'position'], name: 'node_ntname_status_parent_position'), - ORM\Index(columns: ['visible', 'parent_node_id'], name: 'node_visible_parent'), ORM\Index(columns: ['parent_node_id', 'position'], name: 'node_parent_position'), + ORM\Index(columns: ['parent_node_id'], name: 'node_parent'), + ORM\Index(columns: ['position']), + ORM\Index(columns: ['sterile']), ORM\Index(columns: ['visible', 'parent_node_id', 'position'], name: 'node_visible_parent_position'), - ORM\Index(columns: ['status', 'visible', 'parent_node_id', 'position'], name: 'node_status_visible_parent_position'), + ORM\Index(columns: ['visible', 'parent_node_id'], name: 'node_visible_parent'), + ORM\Index(columns: ['visible'], name: 'node_visible'), ORM\HasLifecycleCallbacks, Gedmo\Loggable(logEntryClass: UserLogEntry::class), // Need to override repository method to see all nodes @@ -78,24 +69,12 @@ ApiFilter(PropertyFilter::class), ApiFilter(TagGroupFilter::class) ] -class Node implements DateTimedInterface, LeafInterface, AttributableInterface, Loggable, NodeInterface, \Stringable +class Node implements PersistableInterface, StatusAwareEntityInterface, AttributableInterface, Loggable, LeafInterface, NodeInterface, \Stringable { use SequentialIdTrait; - use DateTimedTrait; use LeafTrait; use AttributableTrait; - /** @deprecated Use NodeStatus enum */ - public const int DRAFT = 10; - /** @deprecated Use NodeStatus enum */ - public const int PENDING = 20; - /** @deprecated Use NodeStatus enum */ - public const int PUBLISHED = 30; - /** @deprecated Use NodeStatus enum */ - public const int ARCHIVED = 40; - /** @deprecated Use NodeStatus enum */ - public const int DELETED = 50; - #[SymfonySerializer\Ignore] public static array $orderingFields = [ 'position' => 'position', @@ -134,18 +113,6 @@ class Node implements DateTimedInterface, LeafInterface, AttributableInterface, )] private bool $visible = true; - /** - * @internal you should use node Workflow to perform change on status - */ - #[ORM\Column( - name: 'status', - type: Types::SMALLINT, - enumType: NodeStatus::class, - options: ['default' => NodeStatus::DRAFT] - )] - #[SymfonySerializer\Ignore] - private NodeStatus $status = NodeStatus::DRAFT; - #[ORM\Column( type: Types::INTEGER, nullable: false, @@ -332,17 +299,6 @@ public function __construct() $this->aNodes = new ArrayCollection(); $this->bNodes = new ArrayCollection(); $this->attributeValues = new ArrayCollection(); - $this->initDateTimedTrait(); - } - - /** - * @deprecated Use NodeStatus enum getLabel method - */ - public static function getStatusLabel(int|string $status): string - { - $status = NodeStatus::tryFrom((int) $status); - - return $status->getLabel(); } /** @@ -382,41 +338,6 @@ public function setHome(bool $home): Node return $this; } - public function getStatus(): NodeStatus - { - return $this->status; - } - - /** - * @param int|string|NodeStatus $status Workflow only use marking places - * - * @return $this - * - * @internal you should use node Workflow to perform change on status - */ - public function setStatus(int|string|NodeStatus $status): Node - { - if ($status instanceof NodeStatus) { - $this->status = $status; - } else { - $this->status = NodeStatus::tryFrom((int) $status) ?? NodeStatus::DRAFT; - } - - return $this; - } - - public function setStatusAsString(string $name): Node - { - $this->status = NodeStatus::fromName($name); - - return $this; - } - - public function getStatusAsString(): string - { - return $this->status->name; - } - public function getTtl(): int { return $this->ttl ?? 0; @@ -429,34 +350,6 @@ public function setTtl(?int $ttl): Node return $this; } - #[\Override] - public function isPublished(): bool - { - return $this->status->isPublished(); - } - - #[\Override] - public function isPending(): bool - { - return $this->status->isPending(); - } - - #[\Override] - public function isDraft(): bool - { - return $this->status->isDraft(); - } - - public function isDeleted(): bool - { - return $this->status->isDeleted(); - } - - public function isArchived(): bool - { - return $this->status->isArchived(); - } - public function isLocked(): bool { return $this->locked; @@ -712,7 +605,9 @@ public function addStackType(NodeType $nodeType): static #[SymfonySerializer\Ignore] public function getNodeSourcesByTranslation(TranslationInterface $translation): Collection { - return $this->nodeSources->filter(fn (NodesSources $nodeSource) => $nodeSource->getTranslation()->getLocale() === $translation->getLocale()); + return $this + ->getNodeSources() + ->filter(fn (NodesSources $nodeSource) => $nodeSource->getTranslation()->getLocale() === $translation->getLocale()); } /** @@ -720,8 +615,8 @@ public function getNodeSourcesByTranslation(TranslationInterface $translation): */ public function removeNodeSources(NodesSources $ns): static { - if ($this->getNodeSources()->contains($ns)) { - $this->getNodeSources()->removeElement($ns); + if ($this->nodeSources->contains($ns)) { + $this->nodeSources->removeElement($ns); } return $this; @@ -732,7 +627,7 @@ public function removeNodeSources(NodesSources $ns): static */ public function getNodeSources(): Collection { - return $this->nodeSources; + return $this->nodeSources->filter(fn (NodesSources $nodeSource) => !$nodeSource->isDeleted()); } /** @@ -740,8 +635,8 @@ public function getNodeSources(): Collection */ public function addNodeSources(NodesSources $ns): static { - if (!$this->getNodeSources()->contains($ns)) { - $this->getNodeSources()->add($ns); + if (!$this->nodeSources->contains($ns)) { + $this->nodeSources->add($ns); } return $this; @@ -904,6 +799,8 @@ public function __clone() /** @var NodesSources $nodeSource */ foreach ($nodeSources as $nodeSource) { $cloneNodeSource = clone $nodeSource; + $cloneNodeSource->setCreatedAt(new \DateTime()); + $cloneNodeSource->setUpdatedAt(new \DateTime()); $cloneNodeSource->setNode($this); } @@ -928,8 +825,6 @@ public function __clone() $namePrefix = $this->nodeName; } $this->setNodeName($namePrefix.'-'.uniqid()); - $this->setCreatedAt(new \DateTime()); - $this->setUpdatedAt(new \DateTime()); } } @@ -948,6 +843,69 @@ public function setParent(?LeafInterface $parent = null): static return $this; } + /** + * At least one node-source must be published. + */ + #[\Override] + public function isPublished(): bool + { + return $this->nodeSources->exists(fn (int $key, NodesSources $nodeSource) => $nodeSource->isPublished()); + } + + /** + * All node-sources must be in draft state. + */ + #[\Override] + public function isDraft(): bool + { + return $this->nodeSources->forAll(fn (int $key, NodesSources $nodeSource) => $nodeSource->isDraft()); + } + + /** + * All node-sources must be in deleted state. + */ + #[\Override] + public function isDeleted(): bool + { + return $this->nodeSources->forAll(fn (int $key, NodesSources $nodeSource) => $nodeSource->isDeleted()); + } + + #[\Override] + public function getPublishedAt(): ?\DateTime + { + return $this->nodeSources->filter(fn (NodesSources $nodeSource) => $nodeSource->isPublished()) + ->map(fn (NodesSources $nodeSource) => $nodeSource->getPublishedAt()) + ->first() ?: null; + } + + #[\Override] + public function getDeletedAt(): ?\DateTime + { + return $this->nodeSources->filter(fn (NodesSources $nodeSource) => $nodeSource->isDeleted()) + ->map(fn (NodesSources $nodeSource) => $nodeSource->getDeletedAt()) + ->first() ?: null; + } + + #[\Override] + public function setPublishedAt(?\DateTime $publishedAt): StatusAwareEntityInterface + { + foreach ($this->nodeSources as $nodeSource) { + $nodeSource->setPublishedAt($publishedAt); + } + + return $this; + } + + #[\Override] + public function setDeletedAt(?\DateTime $deletedAt): StatusAwareEntityInterface + { + foreach ($this->nodeSources as $nodeSource) { + $nodeSource->setDeletedAt($deletedAt); + } + + return $this; + } + #[\Override] public function __toString(): string { diff --git a/lib/RoadizCoreBundle/src/Entity/NodesSources.php b/lib/RoadizCoreBundle/src/Entity/NodesSources.php index 83761142b..22213f375 100644 --- a/lib/RoadizCoreBundle/src/Entity/NodesSources.php +++ b/lib/RoadizCoreBundle/src/Entity/NodesSources.php @@ -16,6 +16,8 @@ use Gedmo\Loggable\Loggable; use Gedmo\Mapping\Annotation as Gedmo; use RZ\Roadiz\Contracts\NodeType\NodeTypeFieldInterface; +use RZ\Roadiz\Core\AbstractEntities\DateTimedInterface; +use RZ\Roadiz\Core\AbstractEntities\DateTimedTrait; use RZ\Roadiz\Core\AbstractEntities\PersistableInterface; use RZ\Roadiz\Core\AbstractEntities\SequentialIdTrait; use RZ\Roadiz\Core\AbstractEntities\TranslationInterface; @@ -34,18 +36,21 @@ #[ ORM\Entity(repositoryClass: NodesSourcesRepository::class), ORM\Table(name: 'nodes_sources'), - ORM\Index(columns: ['discr']), - ORM\Index(columns: ['title']), - ORM\Index(columns: ['published_at']), + ORM\Index(columns: ['created_at'], name: 'ns_created_at'), + ORM\Index(columns: ['deleted_at'], name: 'ns_deleted_at'), + ORM\Index(columns: ['discr', 'translation_id', 'published_at'], name: 'ns_discr_translation_published'), + ORM\Index(columns: ['discr', 'translation_id'], name: 'ns_discr_translation'), + ORM\Index(columns: ['discr'], name: 'ns_discr'), ORM\Index(columns: ['no_index'], name: 'ns_no_index'), - ORM\Index(columns: ['node_id', 'translation_id', 'published_at'], name: 'ns_node_translation_published'), - ORM\Index(columns: ['node_id', 'discr', 'translation_id'], name: 'ns_node_discr_translation'), ORM\Index(columns: ['node_id', 'discr', 'translation_id', 'published_at'], name: 'ns_node_discr_translation_published'), - ORM\Index(columns: ['translation_id', 'published_at'], name: 'ns_translation_published'), - ORM\Index(columns: ['discr', 'translation_id'], name: 'ns_discr_translation'), - ORM\Index(columns: ['discr', 'translation_id', 'published_at'], name: 'ns_discr_translation_published'), + ORM\Index(columns: ['node_id', 'discr', 'translation_id'], name: 'ns_node_discr_translation'), + ORM\Index(columns: ['node_id', 'translation_id', 'published_at'], name: 'ns_node_translation_published'), + ORM\Index(columns: ['published_at'], name: 'ns_published_at'), ORM\Index(columns: ['title', 'published_at'], name: 'ns_title_published'), ORM\Index(columns: ['title', 'translation_id', 'published_at'], name: 'ns_title_translation_published'), + ORM\Index(columns: ['title'], name: 'ns_title'), + ORM\Index(columns: ['translation_id', 'published_at'], name: 'ns_translation_published'), + ORM\Index(columns: ['updated_at'], name: 'ns_updated_at'), ORM\UniqueConstraint(columns: ['node_id', 'translation_id']), ORM\InheritanceType('JOINED'), // Limit discriminator column to 30 characters for indexing optimization @@ -59,9 +64,11 @@ ApiFilter(RoadizFilter\LocaleFilter::class), ApiFilter(RoadizFilter\TagGroupFilter::class), ] -class NodesSources implements PersistableInterface, Loggable, \Stringable +class NodesSources implements PersistableInterface, DateTimedInterface, StatusAwareEntityInterface, Loggable, \Stringable { use SequentialIdTrait; + use DateTimedTrait; + use StatusAwareEntityTrait; #[SymfonySerializer\Ignore] protected ?ObjectManager $objectManager = null; @@ -84,17 +91,6 @@ class NodesSources implements PersistableInterface, Loggable, \Stringable )] protected ?string $title = null; - #[ApiFilter(BaseFilter\DateFilter::class)] - #[ApiFilter(BaseFilter\OrderFilter::class)] - #[ApiFilter(RoadizFilter\ArchiveFilter::class)] - #[ORM\Column(name: 'published_at', type: 'datetime', unique: false, nullable: true)] - #[SymfonySerializer\Groups(['nodes_sources', 'nodes_sources_base'])] - #[Gedmo\Versioned] - #[ApiProperty( - description: 'Content publication date and time', - )] - protected ?\DateTime $publishedAt = null; - #[ApiFilter(BaseFilter\SearchFilter::class, strategy: 'partial')] #[ORM\Column(name: 'meta_title', type: 'string', length: 150, unique: false)] #[SymfonySerializer\Groups(['nodes_sources'])] @@ -209,6 +205,7 @@ public function __construct( $this->urlAliases = new ArrayCollection(); $this->documentsByFields = new ArrayCollection(); $this->redirections = new ArrayCollection(); + $this->initDateTimedTrait(); } public function injectObjectManager(ObjectManager $objectManager): void @@ -216,12 +213,6 @@ public function injectObjectManager(ObjectManager $objectManager): void $this->objectManager = $objectManager; } - #[ORM\PreUpdate] - public function preUpdate(): void - { - $this->getNode()->setUpdatedAt(new \DateTime('now')); - } - public function getNode(): Node { return $this->node; @@ -373,18 +364,6 @@ public function setRedirections(Collection $redirections): NodesSources return $this; } - public function getPublishedAt(): ?\DateTime - { - return $this->publishedAt; - } - - public function setPublishedAt(?\DateTime $publishedAt = null): NodesSources - { - $this->publishedAt = $publishedAt; - - return $this; - } - public function getMetaTitle(): string { return $this->metaTitle; @@ -540,6 +519,7 @@ public function withNodesSources(NodesSources $nodesSources): self { $this->setTitle($nodesSources->getTitle()); $this->setPublishedAt($nodesSources->getPublishedAt()); + $this->setDeletedAt($nodesSources->getDeletedAt()); $this->setMetaTitle($nodesSources->getMetaTitle()); $this->setMetaDescription($nodesSources->getMetaDescription()); $this->setNoIndex($nodesSources->isNoIndex()); diff --git a/lib/RoadizCoreBundle/src/Entity/StatusAwareEntityInterface.php b/lib/RoadizCoreBundle/src/Entity/StatusAwareEntityInterface.php new file mode 100644 index 000000000..d9c946a2f --- /dev/null +++ b/lib/RoadizCoreBundle/src/Entity/StatusAwareEntityInterface.php @@ -0,0 +1,22 @@ +publishedAt && $this->publishedAt <= new \DateTime(); + } + + #[\Override] + public function isDraft(): bool + { + return !$this->isPublished() && !$this->isDeleted(); + } + + public function isDeleted(): bool + { + return null !== $this->deletedAt && $this->deletedAt <= new \DateTime(); + } + + public function getPublishedAt(): ?\DateTime + { + return $this->publishedAt; + } + + /** + * @internal use workflow with StatusAwareEntityMarkingStore instead + * + * @return $this + */ + public function setPublishedAt(?\DateTime $publishedAt): self + { + $this->publishedAt = $publishedAt; + + return $this; + } + + public function getDeletedAt(): ?\DateTime + { + return $this->deletedAt; + } + + /** + * @internal use workflow with StatusAwareEntityMarkingStore instead + * + * @return $this + */ + public function setDeletedAt(?\DateTime $deletedAt): self + { + $this->deletedAt = $deletedAt; + + return $this; + } +} diff --git a/lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php b/lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php index c25249915..f95ae86d3 100644 --- a/lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php +++ b/lib/RoadizCoreBundle/src/EntityHandler/NodeHandler.php @@ -317,30 +317,6 @@ public function publishWithChildren(): self return $this; } - /** - * Archive node and its children. - * - * **This method does not flush!** - * - * @return $this - */ - public function archiveWithChildren(): self - { - $workflow = $this->getWorkflow(); - if ($workflow->can($this->getNode(), 'archive')) { - $workflow->apply($this->getNode(), 'archive'); - } - - /** @var Node $node */ - foreach ($this->getNode()->getChildren() as $node) { - $handler = $this->createSelf(); - $handler->setNode($node); - $handler->archiveWithChildren(); - } - - return $this; - } - /** * Return if part of Node offspring. */ diff --git a/lib/RoadizCoreBundle/src/Enum/NodeStatus.php b/lib/RoadizCoreBundle/src/Enum/NodeStatus.php index 5593658ba..aba6727f2 100644 --- a/lib/RoadizCoreBundle/src/Enum/NodeStatus.php +++ b/lib/RoadizCoreBundle/src/Enum/NodeStatus.php @@ -7,6 +7,9 @@ use Symfony\Contracts\Translation\TranslatableInterface; use Symfony\Contracts\Translation\TranslatorInterface; +/** + * @deprecated nodeStatus is deprecated, use NodesSourceWorkflow instead + */ enum NodeStatus: int implements TranslatableInterface { case DRAFT = 10; diff --git a/lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php b/lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php index 4ae5b8a8b..ea9d4b126 100644 --- a/lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php +++ b/lib/RoadizCoreBundle/src/EventSubscriber/NodeNameSubscriber.php @@ -68,7 +68,7 @@ public function onBeforeUpdate( try { if ($nodeSource->isReachable()) { $oldPaths = $this->nodeMover->getNodeSourcesUrls($nodeSource->getNode()); - $oldUpdateAt = $nodeSource->getNode()->getUpdatedAt(); + $oldUpdateAt = $nodeSource->getUpdatedAt(); } } catch (SameNodeUrlException) { $oldPaths = []; diff --git a/lib/RoadizCoreBundle/src/Form/NodeStatesType.php b/lib/RoadizCoreBundle/src/Form/NodeStatesType.php index 8cb8253d3..9f7203b9a 100644 --- a/lib/RoadizCoreBundle/src/Form/NodeStatesType.php +++ b/lib/RoadizCoreBundle/src/Form/NodeStatesType.php @@ -9,6 +9,9 @@ use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\OptionsResolver\OptionsResolver; +/** + * @deprecated nodeStatus is deprecated, use NodesSourceWorkflow instead + */ final class NodeStatesType extends AbstractType { #[\Override] diff --git a/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php b/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php index bda4a0f94..657a1576e 100644 --- a/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php +++ b/lib/RoadizCoreBundle/src/Model/NodeTreeDto.php @@ -5,7 +5,6 @@ namespace RZ\Roadiz\CoreBundle\Model; use RZ\Roadiz\Core\AbstractEntities\NodeInterface; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; /** * Doctrine Data transfer object to represent a Node in a tree. @@ -21,7 +20,6 @@ public function __construct( private readonly bool $hideChildren, private readonly bool $home, private readonly bool $visible, - private readonly NodeStatus $status, private readonly ?int $parentId, private readonly string $childrenOrder, private readonly string $childrenOrderDirection, @@ -32,11 +30,13 @@ public function __construct( ?int $sourceId, ?string $title, ?\DateTime $publishedAt, + ?\DateTime $deletedAt, ) { $this->nodeSource = new NodesSourcesTreeDto( $sourceId, $title, $publishedAt, + $deletedAt, ); } @@ -83,42 +83,44 @@ public function isVisible(): bool return $this->visible; } - public function getStatus(): NodeStatus + public function isLocked(): bool { - return $this->status; + return $this->locked; } - public function getStatusAsString(): string + public function isPublished(): bool { - return $this->status->name; + return $this->nodeSource->isPublished(); } - public function isLocked(): bool + public function isDraft(): bool { - return $this->locked; + return $this->nodeSource->isDraft(); } - #[\Override] - public function isPublished(): bool + public function isDeleted(): bool { - return $this->status->isPublished(); + return $this->nodeSource->isDeleted(); } - #[\Override] - public function isPending(): bool + public function getPublishedAt(): ?\DateTime { - return $this->status->isPending(); + return $this->nodeSource->getPublishedAt(); } - #[\Override] - public function isDraft(): bool + public function getDeletedAt(): ?\DateTime { - return $this->status->isDraft(); + return $this->nodeSource->getDeletedAt(); } - public function isDeleted(): bool + public function setPublishedAt(?\DateTime $publishedAt): self + { + throw new \LogicException('Node status dates must be set on their nodes-sources.'); + } + + public function setDeletedAt(?\DateTime $deletedAt): self { - return $this->status->isDeleted(); + throw new \LogicException('Node status dates must be set on their nodes-sources.'); } public function getNodeSource(): NodesSourcesTreeDto diff --git a/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php b/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php index 452e928a2..e1bf488e8 100644 --- a/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php +++ b/lib/RoadizCoreBundle/src/Model/NodesSourcesTreeDto.php @@ -5,13 +5,15 @@ namespace RZ\Roadiz\CoreBundle\Model; use RZ\Roadiz\Core\AbstractEntities\PersistableInterface; +use RZ\Roadiz\CoreBundle\Entity\StatusAwareEntityInterface; -final readonly class NodesSourcesTreeDto implements PersistableInterface +final readonly class NodesSourcesTreeDto implements PersistableInterface, StatusAwareEntityInterface { public function __construct( private ?int $id, private ?string $title, private ?\DateTime $publishedAt, + private ?\DateTime $deletedAt, ) { } @@ -26,8 +28,45 @@ public function getTitle(): ?string return $this->title; } + #[\Override] + public function isPublished(): bool + { + return null !== $this->publishedAt && $this->publishedAt <= new \DateTime(); + } + + #[\Override] + public function isDraft(): bool + { + return !$this->isPublished() && !$this->isDeleted(); + } + + #[\Override] + public function isDeleted(): bool + { + return null !== $this->deletedAt && $this->deletedAt <= new \DateTime(); + } + + #[\Override] public function getPublishedAt(): ?\DateTime { return $this->publishedAt; } + + #[\Override] + public function getDeletedAt(): ?\DateTime + { + return $this->deletedAt; + } + + #[\Override] + public function setPublishedAt(?\DateTime $publishedAt): self + { + return $this; + } + + #[\Override] + public function setDeletedAt(?\DateTime $deletedAt): self + { + return $this; + } } diff --git a/lib/RoadizCoreBundle/src/Node/NodeDuplicator.php b/lib/RoadizCoreBundle/src/Node/NodeDuplicator.php index 55fd9ddb4..93eb9abec 100644 --- a/lib/RoadizCoreBundle/src/Node/NodeDuplicator.php +++ b/lib/RoadizCoreBundle/src/Node/NodeDuplicator.php @@ -10,7 +10,6 @@ use RZ\Roadiz\CoreBundle\Entity\NodesSources; use RZ\Roadiz\CoreBundle\Entity\NodesSourcesDocuments; use RZ\Roadiz\CoreBundle\Entity\NodesToNodes; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use Symfony\Component\DependencyInjection\Attribute\Exclude; /** @@ -49,8 +48,6 @@ public function duplicate(): Node $parent = $this->objectManager->find(Node::class, $parent->getId()); $node->setParent($parent); } - // Demote cloned node to draft - $node->setStatus(NodeStatus::DRAFT); $node = $this->doDuplicate($node); $this->objectManager->flush(); @@ -80,6 +77,8 @@ private function doDuplicate(Node &$node): Node /** @var NodesSources $nodeSource */ foreach ($node->getNodeSources() as $nodeSource) { + // Set new nodes-sources to draft status + $nodeSource->setPublishedAt(null); $this->objectManager->persist($nodeSource); /** @var NodesSourcesDocuments $nsDoc */ diff --git a/lib/RoadizCoreBundle/src/Node/NodeFactory.php b/lib/RoadizCoreBundle/src/Node/NodeFactory.php index 63c625a5f..73b383691 100644 --- a/lib/RoadizCoreBundle/src/Node/NodeFactory.php +++ b/lib/RoadizCoreBundle/src/Node/NodeFactory.php @@ -65,7 +65,9 @@ public function create( $manager = $this->managerRegistry->getManagerForClass(NodesSources::class); $source->injectObjectManager($manager); $source->setTitle($title); - $source->setPublishedAt(new \DateTime()); + // Draft status by default + $source->setPublishedAt(null); + $source->setDeletedAt(null); /* * Name node against policy diff --git a/lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php b/lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php index 83eb20351..985f28f49 100644 --- a/lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php +++ b/lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php @@ -65,7 +65,8 @@ public function generate( $source = new $sourceClass($node, $translation); $source->setTitle($name); - $source->setPublishedAt(new \DateTime()); + $source->setPublishedAt(null); + $source->setDeletedAt(null); $node->setNodeName($this->nodeNamePolicy->getCanonicalNodeName($source)); $manager = $this->managerRegistry->getManagerForClass(Node::class); diff --git a/lib/RoadizCoreBundle/src/Repository/NodeRepository.php b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php index 11a90cd2c..9bb1aca6d 100644 --- a/lib/RoadizCoreBundle/src/Repository/NodeRepository.php +++ b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php @@ -7,7 +7,8 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; -use Doctrine\ORM\Query\Expr; +use Doctrine\ORM\Query; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\Paginator; use Doctrine\Persistence\ManagerRegistry; @@ -101,6 +102,22 @@ public function countBy( return (int) $qb->getQuery()->getSingleScalarResult(); } + #[\Override] + public function alterQueryBuilderWithAuthorizationChecker( + QueryBuilder $queryBuilder, + string $prefix = EntityRepository::NODE_ALIAS, + ): QueryBuilder { + if (true === $this->isDisplayingAllNodesStatuses()) { + return $queryBuilder; + } + + if (!$this->hasJoinedNodesSources($queryBuilder, $prefix)) { + $queryBuilder->innerJoin($prefix.'.nodeSources', self::NODESSOURCES_ALIAS); + } + + return parent::alterQueryBuilderWithAuthorizationChecker($queryBuilder, self::NODESSOURCES_ALIAS); + } + /** * Create filters according to any translation criteria OR argument. */ @@ -405,7 +422,6 @@ protected function alterQueryBuilderAsNodeTreeDto(QueryBuilder $qb, string $alia %s.hideChildren, %s.home, %s.visible, - %s.status, IDENTITY(%s.parent), %s.childrenOrder, %s.childrenOrderDirection, @@ -413,7 +429,8 @@ protected function alterQueryBuilderAsNodeTreeDto(QueryBuilder $qb, string $alia %s.nodeTypeName, %s.id, %s.title, - %s.publishedAt + %s.publishedAt, + %s.deletedAt ) EOT, NodeTreeDto::class, @@ -427,7 +444,7 @@ protected function alterQueryBuilderAsNodeTreeDto(QueryBuilder $qb, string $alia $alias, $alias, $alias, - $alias, + self::NODESSOURCES_ALIAS, self::NODESSOURCES_ALIAS, self::NODESSOURCES_ALIAS, self::NODESSOURCES_ALIAS, @@ -917,21 +934,21 @@ protected function createSearchBy( $qb->innerJoin( $alias.'.nodesTags', 'ntg', - Expr\Join::WITH, + Join::WITH, $qb->expr()->eq('ntg.tag', $criteria['tags']->getId()) ); } elseif (is_array($criteria['tags'])) { $qb->innerJoin( $alias.'.nodesTags', 'ntg', - Expr\Join::WITH, + Join::WITH, $qb->expr()->in('ntg.tag', $criteria['tags']) ); } elseif (is_integer($criteria['tags'])) { $qb->innerJoin( $alias.'.nodesTags', 'ntg', - Expr\Join::WITH, + Join::WITH, $qb->expr()->eq('ntg.tag', $criteria['tags']) ); } diff --git a/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php index 55e46fd6e..56da7b9a8 100644 --- a/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php +++ b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php @@ -16,7 +16,6 @@ use RZ\Roadiz\CoreBundle\Doctrine\ORM\SimpleQueryBuilder; use RZ\Roadiz\CoreBundle\Entity\Node; use RZ\Roadiz\CoreBundle\Entity\NodesSources; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\EventDispatcher\Event; @@ -198,38 +197,6 @@ public function findOneByIdentifierAndTranslation( return $query->getOneOrNullResult(); } - #[\Override] - public function alterQueryBuilderWithAuthorizationChecker( - QueryBuilder $qb, - string $prefix = EntityRepository::NODESSOURCES_ALIAS, - ): QueryBuilder { - if (true === $this->isDisplayingAllNodesStatuses()) { - return $qb; - } - - $this->joinNodeOnce($qb, $prefix); - - if (true === $this->isDisplayingNotPublishedNodes() || $this->previewResolver->isPreview()) { - /* - * Forbid deleted node for backend user when authorizationChecker not null. - */ - $qb->andWhere($qb->expr()->lte(static::NODE_ALIAS.'.status', ':node_status')); - $qb->setParameter('node_status', NodeStatus::PUBLISHED); - - return $qb; - } - - /* - * Forbid unpublished node for anonymous and not backend users. - */ - $qb->andWhere($qb->expr()->lte($prefix.'.publishedAt', ':now')); - $qb->andWhere($qb->expr()->eq(static::NODE_ALIAS.'.status', ':node_status')); - $qb->setParameter('node_status', NodeStatus::PUBLISHED); - $qb->setParameter('now', new \DateTime('now')); - - return $qb; - } - public function joinNodeOnce(QueryBuilder $qb, string $prefix = EntityRepository::NODESSOURCES_ALIAS): QueryBuilder { if (!$this->hasJoinedNode($qb, $prefix)) { @@ -322,6 +289,43 @@ public function countBy(mixed $criteria): int return (int) $query->getQuery()->getSingleScalarResult(); } + public function countDeleted(): int + { + $qb = $this->createQueryBuilder('o'); + + return (int) $qb->select($qb->expr()->count('o')) + ->andWhere($qb->expr()->isNotNull('o.deletedAt')) + ->andWhere($qb->expr()->lte('o.deletedAt', ':deleted_at')) + ->setParameter('deleted_at', new \DateTime()) + ->getQuery() + ->getSingleScalarResult(); + } + + public function findAllDeletedQuery(): Query + { + $qb = $this->createQueryBuilder('o'); + + return $qb->select('o') + ->andWhere($qb->expr()->isNotNull('o.deletedAt')) + ->andWhere($qb->expr()->lte('o.deletedAt', ':deleted_at')) + ->setParameter('deleted_at', new \DateTime()) + ->getQuery(); + } + + public function findAllDeletedInParentQuery(array $parentNodeId): Query + { + $qb = $this->createQueryBuilder('o'); + + return $qb->select('o') + ->innerJoin('o.node', 'n') + ->andWhere($qb->expr()->in('n.parent', ':parentNodeId')) + ->andWhere($qb->expr()->isNotNull('o.deletedAt')) + ->andWhere($qb->expr()->lte('o.deletedAt', ':deleted_at')) + ->setParameter('deleted_at', new \DateTime()) + ->setParameter('parentNodeId', $parentNodeId) + ->getQuery(); + } + /** * A secure findBy with which user must be a backend user * to see unpublished nodes. diff --git a/lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php b/lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php index 9a853e79b..27a228431 100644 --- a/lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php +++ b/lib/RoadizCoreBundle/src/Repository/StatusAwareRepository.php @@ -6,7 +6,6 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -84,29 +83,39 @@ public function setDisplayingAllNodesStatuses(bool $displayAllNodesStatuses): se return $this; } - /** - * @deprecated do not use repository stateful methods in services - */ #[\Override] public function alterQueryBuilderWithAuthorizationChecker( - QueryBuilder $qb, - string $prefix = EntityRepository::NODE_ALIAS, + QueryBuilder $queryBuilder, + string $prefix = EntityRepository::NODESSOURCES_ALIAS, ): QueryBuilder { if (true === $this->isDisplayingAllNodesStatuses()) { - // do not filter on status - return $qb; + return $queryBuilder; } - /* - * Check if user can see not-published node based on its Token - * and context. - */ + if (true === $this->isDisplayingNotPublishedNodes() || $this->previewResolver->isPreview()) { - $qb->andWhere($qb->expr()->lte($prefix.'.status', ':status')); - } else { - $qb->andWhere($qb->expr()->eq($prefix.'.status', ':status')); + /* + * Forbid deleted node for backend user when authorizationChecker not null. + */ + $queryBuilder->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->isNull($prefix.'.deletedAt'), + $queryBuilder->expr()->gt($prefix.'.deletedAt', ':gt_deleted_at') + ))->setParameter(':gt_deleted_at', new \DateTime()); + + return $queryBuilder; } - $qb->setParameter('status', NodeStatus::PUBLISHED); - return $qb; + /* + * Filter nodes sources by their status. + */ + $queryBuilder + ->andWhere($queryBuilder->expr()->lte($prefix.'.publishedAt', ':lte_published_at')) + ->andWhere($queryBuilder->expr()->orX( + $queryBuilder->expr()->isNull($prefix.'.deletedAt'), + $queryBuilder->expr()->gt($prefix.'.deletedAt', ':gt_deleted_at') + )) + ->setParameter(':lte_published_at', new \DateTime()) + ->setParameter(':gt_deleted_at', new \DateTime()); + + return $queryBuilder; } } diff --git a/lib/RoadizCoreBundle/src/Repository/StatusAwareRepositoryInterface.php b/lib/RoadizCoreBundle/src/Repository/StatusAwareRepositoryInterface.php index a706e805d..0dd39c4c7 100644 --- a/lib/RoadizCoreBundle/src/Repository/StatusAwareRepositoryInterface.php +++ b/lib/RoadizCoreBundle/src/Repository/StatusAwareRepositoryInterface.php @@ -13,7 +13,7 @@ public function isDisplayingNotPublishedNodes(): bool; public function isDisplayingAllNodesStatuses(): bool; public function alterQueryBuilderWithAuthorizationChecker( - QueryBuilder $qb, + QueryBuilder $queryBuilder, string $prefix = EntityRepository::NODE_ALIAS, ): QueryBuilder; } diff --git a/lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php b/lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php index 4a6084d77..d1118aef3 100644 --- a/lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php +++ b/lib/RoadizCoreBundle/src/Routing/NodeRouteHelper.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use RZ\Roadiz\Core\AbstractEntities\NodeInterface; use RZ\Roadiz\CoreBundle\Bag\NodeTypes; +use RZ\Roadiz\CoreBundle\Entity\StatusAwareEntityInterface; use RZ\Roadiz\CoreBundle\Preview\PreviewResolverInterface; use RZ\Roadiz\Utils\StringHandler; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -98,8 +99,12 @@ public function isViewable(): bool return false; } + if (!$this->node instanceof StatusAwareEntityInterface) { + return false; + } + if ($this->previewResolver->isPreview()) { - return $this->node->isDraft() || $this->node->isPending() || $this->node->isPublished(); + return $this->node->isDraft() || $this->node->isPublished(); } /* diff --git a/lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php b/lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php index 3f7b1da4b..fdcb99cae 100644 --- a/lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php +++ b/lib/RoadizCoreBundle/src/SearchEngine/GlobalNodeSourceSearchHandler.php @@ -8,7 +8,6 @@ use Doctrine\Persistence\ManagerRegistry; use RZ\Roadiz\CoreBundle\Entity\NodesSources; use RZ\Roadiz\CoreBundle\Entity\Translation; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Repository\NodesSourcesRepository; final readonly class GlobalNodeSourceSearchHandler @@ -45,11 +44,7 @@ public function getNodeSourcesBySearchTerm( if (null !== $this->nodeSourceSearchHandler) { try { $this->nodeSourceSearchHandler->boostByUpdateDate(); - $arguments = [ - 'status' => ['<=', NodeStatus::PUBLISHED], - ]; - - $nodesSources = $this->nodeSourceSearchHandler->search($safeSearchTerms, $arguments, $resultCount)->getResultItems(); + $nodesSources = $this->nodeSourceSearchHandler->search(q: $safeSearchTerms, rows: $resultCount)->getResultItems(); } catch (SearchEngineServerException) { } } diff --git a/lib/RoadizCoreBundle/src/Workflow/Event/NodeStatusGuardListener.php b/lib/RoadizCoreBundle/src/Workflow/Event/NodesSourcesWorkflowGuardListener.php similarity index 72% rename from lib/RoadizCoreBundle/src/Workflow/Event/NodeStatusGuardListener.php rename to lib/RoadizCoreBundle/src/Workflow/Event/NodesSourcesWorkflowGuardListener.php index 9c23945ea..c48c2ed3c 100644 --- a/lib/RoadizCoreBundle/src/Workflow/Event/NodeStatusGuardListener.php +++ b/lib/RoadizCoreBundle/src/Workflow/Event/NodesSourcesWorkflowGuardListener.php @@ -5,13 +5,14 @@ namespace RZ\Roadiz\CoreBundle\Workflow\Event; use RZ\Roadiz\CoreBundle\Entity\Node; +use RZ\Roadiz\CoreBundle\Entity\NodesSources; use RZ\Roadiz\CoreBundle\Security\Authorization\Voter\NodeVoter; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Workflow\Event\GuardEvent; use Symfony\Component\Workflow\TransitionBlocker; -final readonly class NodeStatusGuardListener implements EventSubscriberInterface +final readonly class NodesSourcesWorkflowGuardListener implements EventSubscriberInterface { public function __construct(private Security $security) { @@ -21,9 +22,11 @@ public function __construct(private Security $security) public static function getSubscribedEvents(): array { return [ + 'workflow.nodesSources.guard' => ['guard'], + 'workflow.nodesSources.guard.publish' => ['guardPublish'], + 'workflow.nodesSources.guard.delete' => ['guardDelete'], 'workflow.node.guard' => ['guard'], 'workflow.node.guard.publish' => ['guardPublish'], - 'workflow.node.guard.archive' => ['guardArchive'], 'workflow.node.guard.delete' => ['guardDelete'], ]; } @@ -48,34 +51,23 @@ public function guardPublish(GuardEvent $event): void } } - public function guardArchive(GuardEvent $event): void + public function guardDelete(GuardEvent $event): void { - /** @var Node $node */ - $node = $event->getSubject(); - if ($node->isLocked()) { - $event->addTransitionBlocker(new TransitionBlocker( - 'A locked node cannot be archived.', - '1' - )); - } - if (!$this->security->isGranted(NodeVoter::EDIT_STATUS, $event->getSubject())) { + /** @var Node|NodesSources $object */ + $object = $event->getSubject(); + + if ($object instanceof NodesSources && $object->getNode()->isLocked()) { $event->addTransitionBlocker(new TransitionBlocker( - 'User is not allowed to archive this node.', + 'A locked node cannot be deleted.', '1' )); - } - } - - public function guardDelete(GuardEvent $event): void - { - /** @var Node $node */ - $node = $event->getSubject(); - if ($node->isLocked()) { + } elseif ($object instanceof Node && $object->isLocked()) { $event->addTransitionBlocker(new TransitionBlocker( 'A locked node cannot be deleted.', '1' )); } + if (!$this->security->isGranted(NodeVoter::DELETE, $event->getSubject())) { $event->addTransitionBlocker(new TransitionBlocker( 'User is not allowed to delete this node.', diff --git a/lib/RoadizCoreBundle/src/Workflow/MarkingStore/StatusAwareEntityMarkingStore.php b/lib/RoadizCoreBundle/src/Workflow/MarkingStore/StatusAwareEntityMarkingStore.php new file mode 100644 index 000000000..bd923458c --- /dev/null +++ b/lib/RoadizCoreBundle/src/Workflow/MarkingStore/StatusAwareEntityMarkingStore.php @@ -0,0 +1,60 @@ +isPublished() => new Marking([self::PUBLISHED => 1]), + $subject->isDeleted() => new Marking([self::DELETED => 1]), + default => new Marking([self::DRAFT => 1]), // Default to draft if no other state is set + }; + } + + #[\Override] + public function setMarking(object $subject, Marking $marking, array $context = []): void + { + if (!$subject instanceof StatusAwareEntityInterface) { + throw new \InvalidArgumentException('Subject must implement StatusAwareEntityInterface.'); + } + + switch (key($marking->getPlaces())) { + case self::PUBLISHED: + if (!$subject->isPublished()) { + // Only set publishedAt if it was not already set + $subject->setPublishedAt(new \DateTime()); + } + $subject->setDeletedAt(null); + break; + case self::DRAFT: + $subject->setPublishedAt(null); + $subject->setDeletedAt(null); + break; + case self::DELETED: + if (!$subject->isDeleted()) { + // Only set deletedAd if it was not already set + $subject->setDeletedAt(new \DateTime()); + } + break; + default: + throw new \InvalidArgumentException('Invalid marking state: '.key($marking->getPlaces())); + } + } +} diff --git a/lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php b/lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php index e88c50fab..0b0250cbb 100644 --- a/lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php +++ b/lib/RoadizCoreBundle/src/Workflow/NodeWorkflow.php @@ -4,44 +4,33 @@ namespace RZ\Roadiz\CoreBundle\Workflow; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; +use RZ\Roadiz\CoreBundle\Workflow\MarkingStore\StatusAwareEntityMarkingStore; use Symfony\Component\Workflow\DefinitionBuilder; -use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\Workflow; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -class NodeWorkflow extends Workflow +final class NodeWorkflow extends Workflow { public function __construct(EventDispatcherInterface $dispatcher) { $definitionBuilder = new DefinitionBuilder(); $definition = $definitionBuilder - ->setInitialPlaces(NodeStatus::DRAFT->name) + ->setInitialPlaces(StatusAwareEntityMarkingStore::DRAFT) ->addPlaces([ - NodeStatus::DRAFT->name, - NodeStatus::PENDING->name, - NodeStatus::PUBLISHED->name, - NodeStatus::ARCHIVED->name, - NodeStatus::DELETED->name, + StatusAwareEntityMarkingStore::DRAFT, + StatusAwareEntityMarkingStore::PUBLISHED, + StatusAwareEntityMarkingStore::DELETED, ]) - ->addTransition(new Transition('review', NodeStatus::DRAFT->name, NodeStatus::PENDING->name)) - ->addTransition(new Transition('review', NodeStatus::PUBLISHED->name, NodeStatus::PENDING->name)) - ->addTransition(new Transition('reject', NodeStatus::PENDING->name, NodeStatus::DRAFT->name)) - ->addTransition(new Transition('reject', NodeStatus::PUBLISHED->name, NodeStatus::DRAFT->name)) - ->addTransition(new Transition('publish', NodeStatus::DRAFT->name, NodeStatus::PUBLISHED->name)) - ->addTransition(new Transition('publish', NodeStatus::PENDING->name, NodeStatus::PUBLISHED->name)) - ->addTransition(new Transition('publish', NodeStatus::PUBLISHED->name, NodeStatus::PUBLISHED->name)) - ->addTransition(new Transition('archive', NodeStatus::PUBLISHED->name, NodeStatus::ARCHIVED->name)) - ->addTransition(new Transition('unarchive', NodeStatus::ARCHIVED->name, NodeStatus::DRAFT->name)) - ->addTransition(new Transition('delete', NodeStatus::DRAFT->name, NodeStatus::DELETED->name)) - ->addTransition(new Transition('delete', NodeStatus::PENDING->name, NodeStatus::DELETED->name)) - ->addTransition(new Transition('delete', NodeStatus::PUBLISHED->name, NodeStatus::DELETED->name)) - ->addTransition(new Transition('delete', NodeStatus::ARCHIVED->name, NodeStatus::DELETED->name)) - ->addTransition(new Transition('undelete', NodeStatus::DELETED->name, NodeStatus::DRAFT->name)) + ->addTransition(new Transition('reject', StatusAwareEntityMarkingStore::PUBLISHED, StatusAwareEntityMarkingStore::DRAFT)) + ->addTransition(new Transition('publish', StatusAwareEntityMarkingStore::DRAFT, StatusAwareEntityMarkingStore::PUBLISHED)) + ->addTransition(new Transition('publish', StatusAwareEntityMarkingStore::PUBLISHED, StatusAwareEntityMarkingStore::PUBLISHED)) + ->addTransition(new Transition('delete', StatusAwareEntityMarkingStore::DRAFT, StatusAwareEntityMarkingStore::DELETED)) + ->addTransition(new Transition('delete', StatusAwareEntityMarkingStore::PUBLISHED, StatusAwareEntityMarkingStore::DELETED)) + ->addTransition(new Transition('undelete', StatusAwareEntityMarkingStore::DELETED, StatusAwareEntityMarkingStore::DRAFT)) ->build() ; - $markingStore = new MethodMarkingStore(true, 'statusAsString'); + $markingStore = new StatusAwareEntityMarkingStore(); parent::__construct($definition, $markingStore, $dispatcher, 'node'); } } diff --git a/lib/RoadizCoreBundle/src/Workflow/NodesSourcesWorkflow.php b/lib/RoadizCoreBundle/src/Workflow/NodesSourcesWorkflow.php new file mode 100644 index 000000000..fca1b1b0e --- /dev/null +++ b/lib/RoadizCoreBundle/src/Workflow/NodesSourcesWorkflow.php @@ -0,0 +1,36 @@ +setInitialPlaces(StatusAwareEntityMarkingStore::DRAFT) + ->addPlaces([ + StatusAwareEntityMarkingStore::DRAFT, + StatusAwareEntityMarkingStore::PUBLISHED, + StatusAwareEntityMarkingStore::DELETED, + ]) + ->addTransition(new Transition('reject', StatusAwareEntityMarkingStore::PUBLISHED, StatusAwareEntityMarkingStore::DRAFT)) + ->addTransition(new Transition('publish', StatusAwareEntityMarkingStore::DRAFT, StatusAwareEntityMarkingStore::PUBLISHED)) + ->addTransition(new Transition('publish', StatusAwareEntityMarkingStore::PUBLISHED, StatusAwareEntityMarkingStore::PUBLISHED)) + ->addTransition(new Transition('delete', StatusAwareEntityMarkingStore::DRAFT, StatusAwareEntityMarkingStore::DELETED)) + ->addTransition(new Transition('delete', StatusAwareEntityMarkingStore::PUBLISHED, StatusAwareEntityMarkingStore::DELETED)) + ->addTransition(new Transition('undelete', StatusAwareEntityMarkingStore::DELETED, StatusAwareEntityMarkingStore::DRAFT)) + ->build() + ; + $markingStore = new StatusAwareEntityMarkingStore(); + parent::__construct($definition, $markingStore, $dispatcher, 'nodesSources'); + } +} diff --git a/lib/RoadizRozierBundle/config/routing/ajax.yml b/lib/RoadizRozierBundle/config/routing/ajax.yml index 20af6c256..030b2bd8d 100644 --- a/lib/RoadizRozierBundle/config/routing/ajax.yml +++ b/lib/RoadizRozierBundle/config/routing/ajax.yml @@ -26,6 +26,12 @@ nodesStatusesAjax: defaults: _controller: Themes\Rozier\AjaxControllers\AjaxNodesController::statusesAction _format: json +nodesSourcesStatusesAjax: + path: /nodes-sources/statuses/{nodeSource} + defaults: + _controller: RZ\Roadiz\RozierBundle\Controller\Node\NodesSourcesStatusController + _format: json + requirements: { nodeSource: "[0-9]+" } nodesTreeAjax: path: /nodes/tree methods: [GET] diff --git a/lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php b/lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php new file mode 100644 index 000000000..380ae98d7 --- /dev/null +++ b/lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php @@ -0,0 +1,70 @@ +validateRequest($request); + + $workflow = $this->workflowRegistry->get($nodesSources); + + if (!is_string($request->get('statusValue'))) { + throw new BadRequestHttpException('Status value is not specified.'); + } + + $workflow->apply($nodesSources, $request->get('statusValue')); + $this->managerRegistry->getManager()->flush(); + $msg = $this->translator->trans('node.%name%.status_changed_to.%status%', [ + '%name%' => $nodesSources->getTitle(), + '%status%' => $this->translator->trans(match (true) { + $nodesSources->isPublished() => 'published', + $nodesSources->isDeleted() => 'deleted', + default => 'draft', + }), + ]); + $this->logTrail->publishConfirmMessage($request, $msg, $nodesSources); + + return new JsonResponse( + [ + 'statusCode' => Response::HTTP_PARTIAL_CONTENT, + 'status' => 'success', + 'responseText' => $msg, + 'name' => 'status', + 'value' => $request->get('statusValue'), + ], + Response::HTTP_PARTIAL_CONTENT + ); + } +} diff --git a/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig index 999b53f08..73e1276c4 100644 --- a/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig +++ b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig @@ -4,14 +4,14 @@ {% if node and not readOnly and is_granted('DELETE', node) %} {# Delete current node #} {% if workflow_can(node, 'undelete') %} - + {% trans %}undelete{% endtrans %} {% endif %} {% if workflow_can(node, 'delete') and (node.nodeSources|length == 1 or source.translation.defaultTranslation) %} {% trans %}delete.node{% endtrans %} {% endif %} - {% if workflow_can(node, 'delete') and node.nodeSources|length > 1 and source %} + {% if workflow_can(source, 'delete') and node.nodeSources|length > 1 and source %} {% trans %}delete.translation{% endtrans %} @@ -54,7 +54,7 @@ {% endif %} - {% if node and not node.isDeleted %} + {% if source and not source.isDeleted %} {# # Node actions #} @@ -74,7 +74,7 @@ '_no_cache': 1 }) %} {% endif %} - {% if node.published and publicUrl%} + {% if source.published and publicUrl%} @@ -82,7 +82,7 @@ {% trans %}see.page{% endtrans %} {% endif %} - {% if node.published or node.draft or node.pending %} + {% if source.published or source.draft %} {% if bags.settings.get('custom_preview_scheme') %} {% set previewUrl = url(source, { 'canonicalScheme': bags.settings.get('custom_preview_scheme'), @@ -117,7 +117,7 @@ {% if is_granted('DUPLICATE', node) %} {% trans %}duplicate{% endtrans %} @@ -125,19 +125,24 @@ {% if is_granted('EDIT_SETTING', node) %} {% trans %}transtype{% endtrans %} {% endif %} {% endif %} - {% if workflow_can(node, 'publish') %} - {% trans %}publish_node_offspring{% endtrans %} + {% if workflow_can(source, 'publish') %} + + + {% trans %}publish_node_offspring{% endtrans %} + {% endif %} {% block node_add_child %} {% trans %}add.child{% endtrans %} {% endblock %} {% block node_export %} @@ -153,80 +158,57 @@ {% endblock %} - {% if node %} + {% if source %} {# - # Node statuses + # Node source statuses #} -
- {% if node.isDraft %} + + {% if source.isDraft %} {% set iconClass = 'uk-icon-rz-draft-nodes' %} - {% elseif node.isPending %} - {% set iconClass = 'uk-icon-rz-pending-nodes' %} - {% elseif node.isPublished %} + {% elseif source.isPublished %} {% set iconClass = 'uk-icon-rz-published' %} - {% elseif node.isArchived %} - {% set iconClass = 'uk-icon-rz-archive-mini' %} {% endif %} -
{% trans %}statuses{% endtrans %}
    - {% if workflow_can(node, 'reject') or node.isDraft %} + {% if workflow_can(source, 'reject') or source.isDraft %}
  • {% trans %}draft{% endtrans %} - + {% if source.isDraft %}checked{% endif %} />
  • {% endif %} - {% if workflow_can(node, 'unarchive') %} -
  • - - {% trans %}draft{% endtrans %} - -
  • - {% endif %} - {% if workflow_can(node, 'review') or node.isPending %} -
  • - - {% trans %}pending{% endtrans %} - -
  • - {% endif %} - {% if workflow_can(node, 'publish') or node.isPublished %} + {% if workflow_can(source, 'publish') or source.isPublished %}
  • {% trans %}published{% endtrans %} - + {% if source.isPublished %}checked{% endif %} />
  • {% endif %} - {% if workflow_can(node, 'archive') or node.isArchived %} -
  • - - {% trans %}archived{% endtrans %} - + + {% trans %}delete{% endtrans %} + + value="publish" + {% if source.isDeleted %}checked{% endif %} />
  • {% endif %}
-
+ {% endif %} {% endif %} diff --git a/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php b/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php index 3e7fec5c6..735ae5550 100644 --- a/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php +++ b/lib/RoadizSolrBundle/src/NodeSourceSearchHandler.php @@ -9,7 +9,6 @@ use RZ\Roadiz\Core\AbstractEntities\TranslationInterface; use RZ\Roadiz\CoreBundle\Entity\Node; use RZ\Roadiz\CoreBundle\Entity\Tag; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\SearchEngine\NodeSourceSearchHandlerInterface; use RZ\Roadiz\SolrBundle\Event\NodeSourceSearchQueryEvent; use RZ\Roadiz\SolrBundle\Solarium\SolariumNodeSource; @@ -79,6 +78,87 @@ protected function nativeSearch( return $solrRequest->getData(); } + protected function filterByStatus(array &$args): array + { + /* + * Handle publication date-time filtering + */ + $fq = []; + $now = new \DateTime(); + + if (!isset($args['publishedAt'])) { + $fq['published_at_dt'] = 'published_at_dt:[* TO NOW]'; + + return $fq; + } + + if ( + !is_array($args['publishedAt']) + && $args['publishedAt'] instanceof \DateTimeInterface + && $args['publishedAt'] < $now + ) { + $fq['published_at_dt'] = sprintf( + 'published_at_dt:%s', + $this->formatDateTimeToUTC($args['publishedAt']), + ); + } elseif ( + isset($args['publishedAt'][0]) + && 'BETWEEN' === $args['publishedAt'][0] + && isset($args['publishedAt'][1]) + && $args['publishedAt'][1] instanceof \DateTimeInterface + && isset($args['publishedAt'][2]) + && $args['publishedAt'][2] instanceof \DateTimeInterface + && $args['publishedAt'][1] < $args['publishedAt'][2] + && $args['publishedAt'][2] < $now + ) { + $fq['published_at_dt'] = sprintf( + 'published_at_dt:[%s TO %s]', + $this->formatDateTimeToUTC($args['publishedAt'][1]), + $this->formatDateTimeToUTC($args['publishedAt'][2]).']', + ); + } elseif ( + isset($args['publishedAt'][0]) + && $args['publishedAt'][0] instanceof \DateTimeInterface + && isset($args['publishedAt'][1]) + && $args['publishedAt'][1] instanceof \DateTimeInterface + && $args['publishedAt'][0] < $args['publishedAt'][1] + && $args['publishedAt'][1] < $now + ) { + $fq['published_at_dt'] = sprintf( + 'published_at_dt:[%s TO %s]', + $this->formatDateTimeToUTC($args['publishedAt'][0]), + $this->formatDateTimeToUTC($args['publishedAt'][1]).']', + ); + } elseif ( + isset($args['publishedAt'][0]) + && '<=' === $args['publishedAt'][0] + && isset($args['publishedAt'][1]) + && $args['publishedAt'][1] instanceof \DateTimeInterface + && $args['publishedAt'][1] < $now + ) { + $fq['published_at_dt'] = sprintf( + 'published_at_dt:[* TO %s]', + $this->formatDateTimeToUTC($args['publishedAt'][1]), + ); + } elseif ( + isset($args['publishedAt'][0]) + && '>=' === $args['publishedAt'][0] + && isset($args['publishedAt'][1]) + && $args['publishedAt'][1] instanceof \DateTimeInterface + && $args['publishedAt'][1] < $now + ) { + $fq['published_at_dt'] = sprintf( + 'published_at_dt:[%s TO NOW]', + $this->formatDateTimeToUTC($args['publishedAt'][1]), + ); + } else { + $fq['published_at_dt'] = 'published_at_dt:[* TO NOW]'; + } + unset($args['publishedAt']); + + return $fq; + } + #[\Override] protected function argFqProcess(array &$args): array { @@ -86,12 +166,17 @@ protected function argFqProcess(array &$args): array $args['fq'] = []; } + $args['fq'] = [ + ...$args['fq'], + ...$this->filterByStatus($args), + ]; + $visible = $args['visible'] ?? $args['node.visible'] ?? null; if (isset($visible)) { $tmp = 'node_visible_b:'.(($visible) ? 'true' : 'false'); unset($args['visible']); unset($args['node.visible']); - $args['fq'][] = $tmp; + $args['fq']['node_visible_b'] = $tmp; } /* @@ -104,7 +189,7 @@ protected function argFqProcess(array &$args): array } elseif (is_array($args['tags'])) { foreach ($args['tags'] as $tag) { if ($tag instanceof Tag) { - $args['fq'][] = sprintf('all_tags_slugs_ss:"%s"', $tag->getTagName()); + $args['fq']['tags'] = sprintf('all_tags_slugs_ss:"%s"', $tag->getTagName()); } } } @@ -125,11 +210,11 @@ protected function argFqProcess(array &$args): array $orQuery[] = $singleNodeType; } } - $args['fq'][] = 'node_type_s:('.implode(' OR ', $orQuery).')'; + $args['fq']['node_type_s'] = 'node_type_s:('.implode(' OR ', $orQuery).')'; } elseif ($nodeType instanceof NodeTypeInterface) { - $args['fq'][] = 'node_type_s:'.$nodeType->getName(); + $args['fq']['node_type_s'] = 'node_type_s:'.$nodeType->getName(); } else { - $args['fq'][] = 'node_type_s:'.$nodeType; + $args['fq']['node_type_s'] = 'node_type_s:'.$nodeType; } unset($args['nodeType']); unset($args['node.nodeType']); @@ -143,81 +228,24 @@ protected function argFqProcess(array &$args): array $parent = $args['parent'] ?? $args['node.parent'] ?? null; if (!empty($parent)) { if ($parent instanceof Node) { - $args['fq'][] = 'node_parent_i:'.$parent->getId(); + $args['fq']['node_parent'] = 'node_parent_i:'.$parent->getId(); } elseif (is_string($parent)) { - $args['fq'][] = 'node_parent_s:'.trim($parent); + $args['fq']['node_parent'] = 'node_parent_s:'.trim($parent); } elseif (is_numeric($parent)) { - $args['fq'][] = 'node_parent_i:'.(int) $parent; + $args['fq']['node_parent'] = 'node_parent_i:'.(int) $parent; } unset($args['parent']); unset($args['node.parent']); } - /* - * Handle publication date-time filtering - */ - if (isset($args['publishedAt'])) { - $tmp = 'published_at_dt:'; - if (!is_array($args['publishedAt']) && $args['publishedAt'] instanceof \DateTimeInterface) { - $tmp .= $this->formatDateTimeToUTC($args['publishedAt']); - } elseif ( - isset($args['publishedAt'][0]) - && 'BETWEEN' === $args['publishedAt'][0] - && isset($args['publishedAt'][1]) - && $args['publishedAt'][1] instanceof \DateTimeInterface - && isset($args['publishedAt'][2]) - && $args['publishedAt'][2] instanceof \DateTimeInterface - ) { - $tmp .= '['. - $this->formatDateTimeToUTC($args['publishedAt'][1]). - ' TO '. - $this->formatDateTimeToUTC($args['publishedAt'][2]).']'; - } elseif ( - isset($args['publishedAt'][0]) - && '<=' === $args['publishedAt'][0] - && isset($args['publishedAt'][1]) - && $args['publishedAt'][1] instanceof \DateTimeInterface - ) { - $tmp .= '[* TO '.$this->formatDateTimeToUTC($args['publishedAt'][1]).']'; - } elseif ( - isset($args['publishedAt'][0]) - && '>=' === $args['publishedAt'][0] - && isset($args['publishedAt'][1]) - && $args['publishedAt'][1] instanceof \DateTimeInterface - ) { - $tmp .= '['.$this->formatDateTimeToUTC($args['publishedAt'][1]).' TO *]'; - } - unset($args['publishedAt']); - $args['fq'][] = $tmp; - } - - $status = $args['status'] ?? $args['node.status'] ?? null; - if (isset($status)) { - $tmp = 'node_status_i:'; - if ($status instanceof NodeStatus) { - $tmp .= (string) $status->value; - } elseif (is_numeric($status)) { - $tmp .= (string) $status; - } elseif (is_array($status) && '<=' == $status[0] && $status[1] instanceof NodeStatus) { - $tmp .= '[* TO '.(string) $status[1]->value.']'; - } elseif (is_array($status) && '>=' == $status[0]->value && $status[1] instanceof NodeStatus) { - $tmp .= '['.(string) $status[1]->value.' TO *]'; - } - unset($args['status']); - unset($args['node.status']); - $args['fq'][] = $tmp; - } else { - $args['fq'][] = 'node_status_i:'.(string) NodeStatus::PUBLISHED->value; - } - /* * Filter by translation or locale */ if (isset($args['translation']) && $args['translation'] instanceof TranslationInterface) { - $args['fq'][] = 'locale_s:'.$args['translation']->getLocale(); + $args['fq']['locale'] = 'locale_s:'.$args['translation']->getLocale(); } if (isset($args['locale']) && is_string($args['locale'])) { - $args['fq'][] = 'locale_s:'.$args['locale']; + $args['fq']['locale'] = 'locale_s:'.$args['locale']; } return $args; diff --git a/lib/RoadizSolrBundle/src/Subscriber/DefaultNodesSourcesIndexingSubscriber.php b/lib/RoadizSolrBundle/src/Subscriber/DefaultNodesSourcesIndexingSubscriber.php index 3ce322b16..9a895b075 100644 --- a/lib/RoadizSolrBundle/src/Subscriber/DefaultNodesSourcesIndexingSubscriber.php +++ b/lib/RoadizSolrBundle/src/Subscriber/DefaultNodesSourcesIndexingSubscriber.php @@ -63,15 +63,17 @@ public function onIndexing(NodesSourcesIndexingEvent $event): void $assoc['node_type_s'] = $nodeSource->getNodeTypeName(); $assoc['node_name_s'] = $node->getNodeName(); $assoc['slug_s'] = $node->getNodeName(); - $assoc['node_status_i'] = $node->getStatus()->value; $assoc['node_visible_b'] = $node->isVisible(); $assoc['node_reachable_b'] = $nodeSource->isReachable(); - $assoc['created_at_dt'] = $this->formatDateTimeToUTC($node->getCreatedAt()); - $assoc['updated_at_dt'] = $this->formatDateTimeToUTC($node->getUpdatedAt()); + $assoc['created_at_dt'] = $this->formatDateTimeToUTC($nodeSource->getCreatedAt()); + $assoc['updated_at_dt'] = $this->formatDateTimeToUTC($nodeSource->getUpdatedAt()); if (null !== $nodeSource->getPublishedAt()) { $assoc['published_at_dt'] = $this->formatDateTimeToUTC($nodeSource->getPublishedAt()); } + if (null !== $nodeSource->getDeletedAt()) { + $assoc['deleted_at_dt'] = $this->formatDateTimeToUTC($nodeSource->getDeletedAt()); + } if ($this->canIndexTitleInCollection($nodeSource)) { $collection[] = $title; diff --git a/lib/Rozier/src/AjaxControllers/AjaxNodesController.php b/lib/Rozier/src/AjaxControllers/AjaxNodesController.php index 73a1e5cf8..dc29fedbf 100644 --- a/lib/Rozier/src/AjaxControllers/AjaxNodesController.php +++ b/lib/Rozier/src/AjaxControllers/AjaxNodesController.php @@ -314,7 +314,11 @@ protected function changeNodeStatus(Request $request, Node $node, string $transi $this->managerRegistry->getManager()->flush(); $msg = $this->translator->trans('node.%name%.status_changed_to.%status%', [ '%name%' => $node->getNodeName(), - '%status%' => $node->getStatus()->trans($this->translator), + '%status%' => $this->translator->trans(match (true) { + $node->isPublished() => 'published', + $node->isDeleted() => 'deleted', + default => 'draft', + }), ]); $this->logTrail->publishConfirmMessage($request, $msg, $node->getNodeSources()->first() ?: $node); diff --git a/lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php b/lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php index 9c91db572..4e1d3a8c3 100644 --- a/lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php +++ b/lib/Rozier/src/AjaxControllers/AjaxNodesExplorerController.php @@ -10,7 +10,6 @@ use RZ\Roadiz\CoreBundle\Entity\NodesSources; use RZ\Roadiz\CoreBundle\Entity\NodeType; use RZ\Roadiz\CoreBundle\Entity\Tag; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Explorer\AbstractExplorerItem; use RZ\Roadiz\CoreBundle\Explorer\ExplorerItemFactoryInterface; use RZ\Roadiz\CoreBundle\ListManager\EntityListManagerFactoryInterface; @@ -82,9 +81,7 @@ public function indexAction(Request $request): JsonResponse protected function parseFilterFromRequest(Request $request): array { - $arrayFilter = [ - 'status' => ['<=', NodeStatus::ARCHIVED], - ]; + $arrayFilter = []; if ($request->query->has('tagId') && $request->get('tagId') > 0) { $tag = $this->managerRegistry->getRepository(Tag::class)->find($request->get('tagId')); diff --git a/lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php b/lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php index 2bb97282f..c817ea0f5 100644 --- a/lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php +++ b/lib/Rozier/src/Controllers/Nodes/NodesAttributesController.php @@ -172,17 +172,13 @@ public function editAction(Request $request, int $nodeId, int $translationId): R $assignation['attribute_value_translation_forms'][] = $attributeValueTranslationForm->createView(); } - $availableTranslations = $this->managerRegistry - ->getRepository(Translation::class) - ->findAvailableTranslationsForNode($node); - return $this->render('@RoadizRozier/nodes/attributes/edit.html.twig', [ ...$assignation, 'source' => $nodeSource, 'translation' => $translation, 'node' => $node, 'order_by_weight' => $orderByWeight, - 'available_translations' => $availableTranslations, + 'availableNodesSources' => $node->getNodeSources(), ]); } diff --git a/lib/Rozier/src/Controllers/Nodes/NodesBulkActionsTrait.php b/lib/Rozier/src/Controllers/Nodes/NodesBulkActionsTrait.php index fcdb168aa..4e6c88ac9 100644 --- a/lib/Rozier/src/Controllers/Nodes/NodesBulkActionsTrait.php +++ b/lib/Rozier/src/Controllers/Nodes/NodesBulkActionsTrait.php @@ -7,7 +7,6 @@ use RZ\Roadiz\CoreBundle\Entity\Node; use RZ\Roadiz\CoreBundle\Entity\Tag; use RZ\Roadiz\CoreBundle\EntityHandler\NodeHandler; -use RZ\Roadiz\CoreBundle\Enum\NodeStatus; use RZ\Roadiz\CoreBundle\Security\Authorization\Voter\NodeVoter; use Symfony\Component\Form\ClickableInterface; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -383,10 +382,8 @@ private function buildBulkStatusForm( 'label' => false, 'data' => $status, 'choices' => [ - NodeStatus::DRAFT->getLabel() => 'reject', - NodeStatus::PENDING->getLabel() => 'review', - NodeStatus::PUBLISHED->getLabel() => 'publish', - NodeStatus::ARCHIVED->getLabel() => 'archive', + 'draft' => 'reject', + 'published' => 'publish', ], 'constraints' => [ new NotNull(), diff --git a/lib/Rozier/src/Controllers/Nodes/NodesController.php b/lib/Rozier/src/Controllers/Nodes/NodesController.php index e105fbf0d..842050043 100644 --- a/lib/Rozier/src/Controllers/Nodes/NodesController.php +++ b/lib/Rozier/src/Controllers/Nodes/NodesController.php @@ -9,6 +9,7 @@ use RZ\Roadiz\Core\Handlers\HandlerFactoryInterface; use RZ\Roadiz\CoreBundle\Bag\DecoratedNodeTypes; use RZ\Roadiz\CoreBundle\Entity\Node; +use RZ\Roadiz\CoreBundle\Entity\NodesSources; use RZ\Roadiz\CoreBundle\Entity\NodeType; use RZ\Roadiz\CoreBundle\Entity\Translation; use RZ\Roadiz\CoreBundle\Entity\User; @@ -28,6 +29,7 @@ use RZ\Roadiz\CoreBundle\Node\NodeOffspringResolverInterface; use RZ\Roadiz\CoreBundle\Node\UniqueNodeGenerator; use RZ\Roadiz\CoreBundle\Repository\AllStatusesNodeRepository; +use RZ\Roadiz\CoreBundle\Repository\AllStatusesNodesSourcesRepository; use RZ\Roadiz\CoreBundle\Repository\TranslationRepository; use RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootResolver; use RZ\Roadiz\CoreBundle\Security\Authorization\Voter\NodeVoter; @@ -76,6 +78,7 @@ public function __construct( private readonly EventDispatcherInterface $eventDispatcher, private readonly FormFactoryInterface $formFactory, private readonly AllStatusesNodeRepository $allStatusesNodeRepository, + private readonly AllStatusesNodesSourcesRepository $allStatusesNodesSourcesRepository, private readonly TranslationRepository $translationRepository, private readonly string $nodeFormTypeClass, private readonly string $addNodeFormTypeClass, @@ -269,10 +272,7 @@ public function editAction(Request $request, int $nodeId, ?int $translationId = $translation = $this->translationRepository->findDefault(); $source = $node->getNodeSourcesByTranslation($translation)->first() ?: null; - if (null === $source) { - $availableTranslations = $this->translationRepository->findAvailableTranslationsForNode($node); - $assignation['available_translations'] = $availableTranslations; - } + $assignation['availableNodesSources'] = $node->getNodeSources(); $assignation['node'] = $node; $assignation['source'] = $source; $assignation['translation'] = $translation; @@ -528,17 +528,18 @@ public function emptyTrashAction(Request $request): Response $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $criteria = ['status' => NodeStatus::DELETED]; /** @var Node|null $chroot */ $chroot = $this->nodeChrootResolver->getChroot($this->getUser()); if (null !== $chroot) { - $criteria['parent'] = $this->nodeOffspringResolver->getAllOffspringIds($chroot); + $parentsIds = $this->nodeOffspringResolver->getAllOffspringIds($chroot); + $query = $this->allStatusesNodesSourcesRepository->findAllDeletedInParentQuery($parentsIds); + } else { + $query = $this->allStatusesNodeRepository->findAllDeletedQuery(); } - $nodes = $this->allStatusesNodeRepository->findBy($criteria); - - /** @var Node $node */ - foreach ($nodes as $node) { + /** @var NodesSources $row */ + foreach ($query->toIterable() as $row) { + $node = $row->getNode(); /** @var NodeHandler $nodeHandler */ $nodeHandler = $this->handlerFactory->getHandler($node); $nodeHandler->removeWithChildrenAndAssociations(); diff --git a/lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php b/lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php index cc7159be4..e83ceb91f 100644 --- a/lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php +++ b/lib/Rozier/src/Controllers/Nodes/NodesSourcesController.php @@ -20,6 +20,7 @@ use RZ\Roadiz\CoreBundle\Security\Authorization\Voter\NodeVoter; use RZ\Roadiz\CoreBundle\Security\LogTrail; use RZ\Roadiz\CoreBundle\TwigExtension\JwtExtension; +use RZ\Roadiz\CoreBundle\Workflow\NodesSourcesWorkflow; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; @@ -36,6 +37,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Workflow\WorkflowInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Themes\Rozier\Forms\NodeSource\NodeSourceType; @@ -58,6 +60,7 @@ public function __construct( private readonly FormFactoryInterface $formFactory, private readonly LogTrail $logTrail, private readonly AllStatusesNodesSourcesRepository $allStatusesNodesSourcesRepository, + private readonly NodesSourcesWorkflow $nodesSourcesWorkflow, ) { } @@ -215,10 +218,6 @@ public function editSourceAction(Request $request, int $nodeId, int $translation } } - $availableTranslations = $this->managerRegistry - ->getRepository(Translation::class) - ->findAvailableTranslationsForNode($gNode); - return $this->render('@RoadizRozier/nodes/editSource.html.twig', [ ...$assignation, 'translation' => $translation, @@ -226,7 +225,7 @@ public function editSourceAction(Request $request, int $nodeId, int $translation 'source' => $source, 'form' => $form->createView(), 'readOnly' => $this->isReadOnly, - 'available_translations' => $availableTranslations, + 'availableNodesSources' => $node->getNodeSources(), ]); } @@ -273,20 +272,16 @@ public function removeAction(Request $request, int $nodeSourceId): Response $form = $builder->getForm(); $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { - $node = $ns->getNode(); + if (!$this->nodesSourcesWorkflow->can($ns, 'delete')) { + $form->addError(new FormError('NodesSourcesWorkflow cannot delete this node-source.')); + } + if ($form->isSubmitted() && $form->isValid()) { $this->eventDispatcher->dispatch(new NodesSourcesDeletedEvent($ns)); + $this->nodesSourcesWorkflow->apply($ns, 'delete'); - $manager->remove($ns); $manager->flush(); - $ns = $node->getNodeSources()->first() ?: null; - - if (null === $ns) { - throw new ResourceNotFoundException('No more node-source available for this node.'); - } - $msg = $this->translator->trans('node_source.%node_source%.deleted.%translation%', [ '%node_source%' => $node->getNodeName(), '%translation%' => $ns->getTranslation()->getName(), @@ -295,8 +290,8 @@ public function removeAction(Request $request, int $nodeSourceId): Response $this->logTrail->publishConfirmMessage($request, $msg, $node); return $this->redirectToRoute( - 'nodesEditSourcePage', - ['nodeId' => $node->getId(), 'translationId' => $ns->getTranslation()->getId()] + 'nodesEditPage', + ['nodeId' => $node->getId()] ); } diff --git a/lib/Rozier/src/Controllers/Nodes/NodesTreesController.php b/lib/Rozier/src/Controllers/Nodes/NodesTreesController.php index 291050038..b1a5e98d4 100644 --- a/lib/Rozier/src/Controllers/Nodes/NodesTreesController.php +++ b/lib/Rozier/src/Controllers/Nodes/NodesTreesController.php @@ -102,8 +102,7 @@ public function treeAction(Request $request, ?int $nodeId = null, ?int $translat ); } $assignation['source'] = $node->getNodeSourcesByTranslation($translation)->first(); - $availableTranslations = $this->translationRepository->findAvailableTranslationsForNode($node); - $assignation['available_translations'] = $availableTranslations; + $assignation['availableNodesSources'] = $node->getNodeSources(); } $assignation['translation'] = $translation; $assignation['specificNodeTree'] = $widget; diff --git a/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js new file mode 100644 index 000000000..96621b7e1 --- /dev/null +++ b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js @@ -0,0 +1,80 @@ +export default class NodesSourcesStatuses extends HTMLElement { + connectedCallback() { + this.reverseSubNav = this.reverseSubNav.bind(this) + window.addEventListener('resize', this.reverseSubNav) + + this.reverseSubNav() + } + + disconnectedCallback() { + window.removeEventListener('resize', this.reverseSubNav) + } + + reverseSubNav() { + /** @var {HTMLElement} element */ + this.querySelectorAll('.uk-nav-sub').forEach((element) => { + element.style.display = 'block' + const top = element.getBoundingClientRect().top + const height = element.getBoundingClientRect().height + element.style.display = null + + if (top + height + 20 > window.innerHeight) { + element.parentElement.classList.add('reversed-nav') + } + }) + } + + async onChange(event) { + event.stopPropagation() + if (this.locked === true) { + return false + } + + this.locked = true + const input = event.currentTarget + + if (!input) { + return false + } + + let statusValue = null + + if (input instanceof HTMLInputElement && input.type === 'checkbox') { + statusValue = Number(input.checked) + } else if (input instanceof HTMLInputElement && input.type === 'radio') { + if (this.icon) { + this.icon.className = input.parentElement.querySelector('i').className + } + statusValue = input.value + } + + window.dispatchEvent(new CustomEvent('requestLoaderShow')) + const response = await fetch(this.getAttribute('data-update-url'), { + method: 'POST', + headers: { + Accept: 'application/json', + }, + body: new URLSearchParams({ + _token: window.RozierConfig.ajaxToken, + statusValue: statusValue, + }), + }) + if (!response.ok) { + const data = await response.json() + window.dispatchEvent( + new CustomEvent('pushToast', { + detail: { + message: data.error_message, + status: 'danger', + }, + }) + ) + } else { + window.Rozier.refreshMainNodeTree() + window.Rozier.getMessages() + window.dispatchEvent(new CustomEvent('requestAllNodeTreeRefresh')) + } + this.locked = false + window.dispatchEvent(new CustomEvent('requestLoaderHide')) + } +} diff --git a/lib/Rozier/src/Resources/views/nodes/editSource.html.twig b/lib/Rozier/src/Resources/views/nodes/editSource.html.twig index c79f40814..4deb750cf 100644 --- a/lib/Rozier/src/Resources/views/nodes/editSource.html.twig +++ b/lib/Rozier/src/Resources/views/nodes/editSource.html.twig @@ -21,7 +21,7 @@ {% include '@RoadizRozier/nodes/navBack.html.twig' %} {% include '@RoadizRozier/nodes/navBar.html.twig' with {"current": 'source'} %} - {% include '@RoadizRozier/nodes/translationBar.html.twig' with {"current": translation.getId} %} + {% include '@RoadizRozier/nodes/translationBar.html.twig' with {"current": translation.id} %} diff --git a/lib/Rozier/src/Resources/views/nodes/translationBar.html.twig b/lib/Rozier/src/Resources/views/nodes/translationBar.html.twig index 7aca90c6b..511a84933 100644 --- a/lib/Rozier/src/Resources/views/nodes/translationBar.html.twig +++ b/lib/Rozier/src/Resources/views/nodes/translationBar.html.twig @@ -1,18 +1,18 @@ -{% if available_translations %} +{% if availableNodesSources %} diff --git a/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js index 96621b7e1..7eec8b4d7 100644 --- a/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js +++ b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js @@ -1,54 +1,47 @@ export default class NodesSourcesStatuses extends HTMLElement { connectedCallback() { - this.reverseSubNav = this.reverseSubNav.bind(this) - window.addEventListener('resize', this.reverseSubNav) + this.onChange = this.onChange.bind(this) - this.reverseSubNav() - } - - disconnectedCallback() { - window.removeEventListener('resize', this.reverseSubNav) - } + this.icon = this.querySelector('.node-status header i') - reverseSubNav() { - /** @var {HTMLElement} element */ - this.querySelectorAll('.uk-nav-sub').forEach((element) => { - element.style.display = 'block' - const top = element.getBoundingClientRect().top - const height = element.getBoundingClientRect().height - element.style.display = null - - if (top + height + 20 > window.innerHeight) { - element.parentElement.classList.add('reversed-nav') - } + this.querySelectorAll('input').forEach((input) => { + input.removeEventListener('change', this.onChange) + input.addEventListener('change', this.onChange) + }) + this.querySelectorAll('li.node-statuses-item').forEach((item) => { + item.removeEventListener('click', this.onChange) + item.addEventListener('click', this.onChange) }) } + disconnectedCallback() {} + async onChange(event) { + event.preventDefault() event.stopPropagation() + if (this.locked === true) { return false } this.locked = true - const input = event.currentTarget + let input = event.currentTarget + + if (!input instanceof HTMLInputElement) { + input = input.querySelector('input') + } if (!input) { return false } - let statusValue = null - - if (input instanceof HTMLInputElement && input.type === 'checkbox') { - statusValue = Number(input.checked) - } else if (input instanceof HTMLInputElement && input.type === 'radio') { - if (this.icon) { - this.icon.className = input.parentElement.querySelector('i').className - } - statusValue = input.value - } + let statusValue = input.value window.dispatchEvent(new CustomEvent('requestLoaderShow')) + console.log(this.getAttribute('data-update-url'), { + _token: window.RozierConfig.ajaxToken, + statusValue: statusValue, + }) const response = await fetch(this.getAttribute('data-update-url'), { method: 'POST', headers: { @@ -70,11 +63,10 @@ export default class NodesSourcesStatuses extends HTMLElement { }) ) } else { - window.Rozier.refreshMainNodeTree() - window.Rozier.getMessages() window.dispatchEvent(new CustomEvent('requestAllNodeTreeRefresh')) } this.locked = false window.dispatchEvent(new CustomEvent('requestLoaderHide')) + return false } } diff --git a/lib/Rozier/src/Resources/app/main.js b/lib/Rozier/src/Resources/app/main.js index 23424b9cf..22eb68fd3 100644 --- a/lib/Rozier/src/Resources/app/main.js +++ b/lib/Rozier/src/Resources/app/main.js @@ -40,6 +40,7 @@ import TagEditPage from './custom-elements/TagEditPage' import AdminMenuNav from './custom-elements/AdminMenuNav' import FolderAutocomplete from './custom-elements/FolderAutocomplete' import TagAutocomplete from './custom-elements/TagAutocomplete' +import NodesSourcesStatuses from './custom-elements/NodesSourcesStatuses' import Rozier from './Rozier' window.CodeMirror = CodeMirror @@ -58,7 +59,6 @@ const ready = (callback) => { ready(() => { window.Rozier.onDocumentReady() }) - ;(function () { /* * Defining custom HTML elements @@ -69,6 +69,7 @@ ready(() => { customElements.define('admin-menu-nav', AdminMenuNav) customElements.define('folder-autocomplete', FolderAutocomplete) customElements.define('tag-autocomplete', TagAutocomplete) + customElements.define('nodes-sources-statuses', NodesSourcesStatuses) /* * init generic bulk actions widget From b083016d541847e99f64578a80a9b280b4cec79b Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Sun, 29 Jun 2025 23:42:16 +0200 Subject: [PATCH 3/8] refactor: Add ranking for sorting by publishedAt null values in Node and NodesSources repositories --- lib/RoadizCoreBundle/src/Repository/NodeRepository.php | 9 +++++++++ .../src/Repository/NodesSourcesRepository.php | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/lib/RoadizCoreBundle/src/Repository/NodeRepository.php b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php index 9bb1aca6d..826f562a3 100644 --- a/lib/RoadizCoreBundle/src/Repository/NodeRepository.php +++ b/lib/RoadizCoreBundle/src/Repository/NodeRepository.php @@ -450,6 +450,9 @@ protected function alterQueryBuilderAsNodeTreeDto(QueryBuilder $qb, string $alia self::NODESSOURCES_ALIAS, )); + // Add a rank to sort by publishedAt null values + $qb->addSelect('CASE WHEN ns.publishedAt IS NULL THEN 0 ELSE 1 END AS HIDDEN _ns_publishedAt_null_rank'); + return $qb; } @@ -480,6 +483,12 @@ protected function getContextualQueryWithTranslation( // Add ordering if (null !== $orderBy) { foreach ($orderBy as $key => $value) { + // Add a rank to sort by publishedAt null values + if ('ns.publishedAt' === $key) { + $qb->addSelect('CASE WHEN ns.publishedAt IS NULL THEN 0 ELSE 1 END AS HIDDEN _ns_publishedAt_null_rank'); + $qb->addOrderBy('_ns_publishedAt_null_rank', 'ASC'); + } + if (str_starts_with($key, self::NODESSOURCES_ALIAS.'.')) { $qb->addOrderBy($key, $value); } else { diff --git a/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php index 56da7b9a8..a9ace09f1 100644 --- a/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php +++ b/lib/RoadizCoreBundle/src/Repository/NodesSourcesRepository.php @@ -230,6 +230,12 @@ protected function getContextualQuery( // Add ordering if (null !== $orderBy) { foreach ($orderBy as $key => $value) { + // Add a rank to sort by publishedAt null values + if ('ns.publishedAt' === $key) { + $qb->addSelect('CASE WHEN ns.publishedAt IS NULL THEN 0 ELSE 1 END AS HIDDEN _ns_publishedAt_null_rank'); + $qb->addOrderBy('_ns_publishedAt_null_rank', 'ASC'); + } + if (\str_contains($key, 'node.')) { $simpleKey = str_replace('node.', '', $key); $qb->addOrderBy(static::NODE_ALIAS.'.'.$simpleKey, $value); From 30e9db95786d65ab0da345a9f415b1b505d6b3b7 Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Sun, 29 Jun 2025 23:42:30 +0200 Subject: [PATCH 4/8] refactor: Update actionsMenu and NodeSourceType to conditionally handle publishable states --- .../templates/nodes/actionsMenu.html.twig | 12 +----------- lib/Rozier/src/Forms/NodeSource/NodeSourceType.php | 4 +++- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig index c5e2c5a4f..4f4b3720d 100644 --- a/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig +++ b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig @@ -158,7 +158,7 @@ {% endblock %} - {% if source %} + {% if source and not source.Publishable %} {# # Node source statuses #} @@ -194,16 +194,6 @@ {% if source.isPublished %}checked{% endif %} /> {% endif %} - {% if workflow_can(source, 'delete') or source.isDeleted %} -
  • - - {% trans %}delete{% endtrans %} - -
  • - {% endif %} {% endif %} diff --git a/lib/Rozier/src/Forms/NodeSource/NodeSourceType.php b/lib/Rozier/src/Forms/NodeSource/NodeSourceType.php index 2dd1bb637..5bcda082c 100644 --- a/lib/Rozier/src/Forms/NodeSource/NodeSourceType.php +++ b/lib/Rozier/src/Forms/NodeSource/NodeSourceType.php @@ -19,6 +19,7 @@ use RZ\Roadiz\CoreBundle\Form\YamlType; use RZ\Roadiz\CoreBundle\Repository\AllStatusesNodesSourcesRepository; use RZ\Roadiz\CoreBundle\Security\Authorization\Voter\NodeTypeFieldVoter; +use RZ\Roadiz\CoreBundle\Workflow\NodesSourcesWorkflow; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -49,6 +50,7 @@ public function __construct( private readonly ManagerRegistry $managerRegistry, private readonly Security $security, private readonly AllStatusesNodesSourcesRepository $allStatusesNodesSourcesRepository, + private readonly NodesSourcesWorkflow $nodesSourcesWorkflow, ) { } @@ -62,7 +64,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void if (true === $options['withTitle']) { $builder->add('base', NodeSourceBaseType::class, [ - 'publishable' => $options['nodeType']->isPublishable(), + 'publishable' => $options['nodeType']->isPublishable() && $this->nodesSourcesWorkflow->can($builder->getData(), 'publish'), 'translation' => $builder->getData()->getTranslation(), ]); } From 402203170fbab0702fb5e801342d6a7d266fc347 Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Sun, 29 Jun 2025 23:42:42 +0200 Subject: [PATCH 5/8] refactor(StatusAwareEntityTrait): Enhance OrderFilter properties for publishedAt and deletedAt fields --- .../src/Entity/StatusAwareEntityTrait.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/RoadizCoreBundle/src/Entity/StatusAwareEntityTrait.php b/lib/RoadizCoreBundle/src/Entity/StatusAwareEntityTrait.php index a26f20de2..319a1ceec 100644 --- a/lib/RoadizCoreBundle/src/Entity/StatusAwareEntityTrait.php +++ b/lib/RoadizCoreBundle/src/Entity/StatusAwareEntityTrait.php @@ -4,6 +4,7 @@ namespace RZ\Roadiz\CoreBundle\Entity; +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; use ApiPlatform\Doctrine\Orm\Filter as BaseFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; @@ -16,7 +17,9 @@ trait StatusAwareEntityTrait { #[ApiFilter(BaseFilter\DateFilter::class)] - #[ApiFilter(BaseFilter\OrderFilter::class)] + #[ApiFilter(BaseFilter\OrderFilter::class, properties: [ + 'publishedAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST, 'default_direction' => 'DESC'], + ])] #[ApiFilter(ArchiveFilter::class)] #[Column(name: 'published_at', type: 'datetime', unique: false, nullable: true)] #[Groups(['nodes_sources', 'nodes_sources_base'])] @@ -26,8 +29,9 @@ trait StatusAwareEntityTrait )] protected ?\DateTime $publishedAt = null; - #[ApiFilter(BaseFilter\DateFilter::class)] - #[ApiFilter(BaseFilter\OrderFilter::class)] + #[ApiFilter(BaseFilter\DateFilter::class)] #[ApiFilter(BaseFilter\OrderFilter::class, properties: [ + 'deletedAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST, 'default_direction' => 'DESC'], + ])] #[ApiFilter(ArchiveFilter::class)] #[Column(name: 'deleted_at', type: 'datetime', unique: false, nullable: true)] #[Ignore] From 1b2b82e6913a3a5334e33f5037789eac2244b58b Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Sun, 29 Jun 2025 23:52:34 +0200 Subject: [PATCH 6/8] refactor(NodesSourcesStatuses): Improve event handling and streamline item click logic --- lib/Rozier/src/Resources/app/Lazyload.js | 2 - .../custom-elements/NodesSourcesStatuses.js | 24 +++- .../src/Resources/app/widgets/NodeStatuses.js | 134 ------------------ 3 files changed, 18 insertions(+), 142 deletions(-) delete mode 100644 lib/Rozier/src/Resources/app/widgets/NodeStatuses.js diff --git a/lib/Rozier/src/Resources/app/Lazyload.js b/lib/Rozier/src/Resources/app/Lazyload.js index b98703ef0..228086e6b 100644 --- a/lib/Rozier/src/Resources/app/Lazyload.js +++ b/lib/Rozier/src/Resources/app/Lazyload.js @@ -6,7 +6,6 @@ import InputLengthWatcher from './widgets/InputLengthWatcher' import ChildrenNodesField from './widgets/ChildrenNodesField' import StackNodeTree from './widgets/StackNodeTree' import SettingsSaveButtons from './widgets/SettingsSaveButtons' -import NodeStatuses from './widgets/NodeStatuses' import YamlEditor from './widgets/YamlEditor' import MarkdownEditor from './widgets/MarkdownEditor' import JsonEditor from './widgets/JsonEditor' @@ -337,7 +336,6 @@ export default class Lazyload { window.Rozier.initNestables() window.Rozier.bindMainTrees() - window.Rozier.nodeStatuses = new NodeStatuses() window.Rozier.getMessages() } diff --git a/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js index 7eec8b4d7..9fbdf7d71 100644 --- a/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js +++ b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js @@ -1,6 +1,7 @@ export default class NodesSourcesStatuses extends HTMLElement { connectedCallback() { this.onChange = this.onChange.bind(this) + this.itemClick = this.itemClick.bind(this) this.icon = this.querySelector('.node-status header i') @@ -9,13 +10,24 @@ export default class NodesSourcesStatuses extends HTMLElement { input.addEventListener('change', this.onChange) }) this.querySelectorAll('li.node-statuses-item').forEach((item) => { - item.removeEventListener('click', this.onChange) - item.addEventListener('click', this.onChange) + item.removeEventListener('click', this.itemClick) + item.addEventListener('click', this.itemClick) }) } disconnectedCallback() {} + itemClick(event) { + event.stopPropagation() + + const input = event.currentTarget.querySelector('input[type="radio"]') + + if (input) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + } + async onChange(event) { event.preventDefault() event.stopPropagation() @@ -27,16 +39,16 @@ export default class NodesSourcesStatuses extends HTMLElement { this.locked = true let input = event.currentTarget - if (!input instanceof HTMLInputElement) { - input = input.querySelector('input') - } - if (!input) { return false } let statusValue = input.value + if (this.icon) { + this.icon.className = input.parentElement.querySelector('i').className + } + window.dispatchEvent(new CustomEvent('requestLoaderShow')) console.log(this.getAttribute('data-update-url'), { _token: window.RozierConfig.ajaxToken, diff --git a/lib/Rozier/src/Resources/app/widgets/NodeStatuses.js b/lib/Rozier/src/Resources/app/widgets/NodeStatuses.js deleted file mode 100644 index cf372f9a5..000000000 --- a/lib/Rozier/src/Resources/app/widgets/NodeStatuses.js +++ /dev/null @@ -1,134 +0,0 @@ -export default class NodeStatuses { - constructor() { - this.itemClick = this.itemClick.bind(this) - this.containerEnter = this.containerEnter.bind(this) - this.containerLeave = this.containerLeave.bind(this) - this.onChange = this.onChange.bind(this) - - const containers = document.querySelectorAll('.node-statuses, .node-actions') - this.icon = document.querySelector('.node-status header i') - this.locked = false - - containers.forEach((container) => { - const items = container.querySelectorAll('.node-statuses-item') - const inputs = container.querySelectorAll('input[type="checkbox"], input[type="radio"]') - this.init(container, items, inputs) - }) - } - - /** - * @param {HTMLElement} container - * @param {NodeList} items - * @param {NodeList} inputs - */ - init(container, items, inputs) { - items.forEach((item) => { - item.removeEventListener('click', this.itemClick) - item.addEventListener('click', this.itemClick) - }) - - container.addEventListener('mouseenter', this.containerEnter) - container.addEventListener('mouseleave', this.containerLeave) - - inputs.forEach((input) => { - input.removeEventListener('change', this.onChange) - input.addEventListener('change', this.onChange) - }) - - container.querySelectorAll('.rz-boolean-checkbox').forEach((checkbox) => { - checkbox.addEventListener('change', this.onChange) - }) - } - - containerEnter(event) { - event.stopPropagation() - - const container = event.currentTarget - const list = container.querySelector('ul, nav') - const containerHeight = container.offsetHeight - const listHeight = list ? list.offsetHeight : 0 - const containerOffsetTop = container.getBoundingClientRect().top + window.scrollY - const windowHeight = window.innerHeight - const fullHeight = containerOffsetTop + listHeight + containerHeight - - if (windowHeight < fullHeight) { - container.classList.add('reverse') - } - } - - containerLeave(event) { - event.stopPropagation() - - let container = event.currentTarget - container.classList.remove('reverse') - } - - itemClick(event) { - event.stopPropagation() - - const input = event.currentTarget.querySelector('input[type="radio"]') - - if (input) { - input.checked = true - input.dispatchEvent(new Event('change', { bubbles: true })) - } - } - - async onChange(event) { - event.stopPropagation() - if (this.locked === true) { - return false - } - - this.locked = true - const input = event.currentTarget - - if (!input) { - return false - } - - const statusName = input.getAttribute('name') - let statusValue = null - - if (input instanceof HTMLInputElement && input.type === 'checkbox') { - statusValue = Number(input.checked) - } else if (input instanceof HTMLInputElement && input.type === 'radio') { - if (this.icon) { - this.icon.className = input.parentElement.querySelector('i').className - } - statusValue = input.value - } - - window.dispatchEvent(new CustomEvent('requestLoaderShow')) - const response = await fetch(window.RozierConfig.routes.nodesStatusesAjax, { - method: 'POST', - headers: { - Accept: 'application/json', - }, - body: new URLSearchParams({ - _token: window.RozierConfig.ajaxToken, - _action: 'nodeChangeStatus', - nodeId: parseInt(input.getAttribute('data-node-id')), - statusName: statusName, - statusValue: statusValue, - }), - }) - if (!response.ok) { - const data = await response.json() - window.dispatchEvent( - new CustomEvent('pushToast', { - detail: { - message: data.error_message, - status: 'danger', - }, - }) - ) - } else { - window.Rozier.refreshMainNodeTree() - window.Rozier.getMessages() - window.dispatchEvent(new CustomEvent('requestAllNodeTreeRefresh')) - } - this.locked = false - window.dispatchEvent(new CustomEvent('requestLoaderHide')) - } -} From 20805c4f01fa69336ee557437bf1893cc239005a Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Mon, 30 Jun 2025 00:03:53 +0200 Subject: [PATCH 7/8] refactor: Update nodes-sources-statuses references and improve AJAX handling --- config/packages/roadiz_rozier.yaml | 13 +++----- .../config/routing/ajax.yml | 4 +-- .../config/routing/nodes.yml | 11 ++----- .../Node/NodesSourcesStatusController.php | 8 ++++- .../templates/nodes/actionsMenu.html.twig | 2 +- .../custom-elements/NodesSourcesStatuses.js | 31 ++++++++++++------- .../app/less/actions_menu/actions_menu.less | 2 +- 7 files changed, 37 insertions(+), 34 deletions(-) diff --git a/config/packages/roadiz_rozier.yaml b/config/packages/roadiz_rozier.yaml index 174ca7eec..32a5b8702 100644 --- a/config/packages/roadiz_rozier.yaml +++ b/config/packages/roadiz_rozier.yaml @@ -54,15 +54,10 @@ roadiz_rozier: route: nodesHomeDraftPage icon: uk-icon-rz-draft-nodes roles: ~ - pending_nodes: - name: 'pending.nodes' - route: nodesHomePendingPage - icon: uk-icon-rz-pending-nodes - roles: ~ - archived_nodes: - name: 'archived.nodes' - route: nodesHomeArchivedPage - icon: uk-icon-rz-archives-nodes + published_nodes: + name: 'published.nodes' + route: nodesHomePublishedPage + icon: uk-icon-rz-published-nodes roles: ~ deleted_nodes: name: 'deleted.nodes' diff --git a/lib/RoadizRozierBundle/config/routing/ajax.yml b/lib/RoadizRozierBundle/config/routing/ajax.yml index 030b2bd8d..203307d97 100644 --- a/lib/RoadizRozierBundle/config/routing/ajax.yml +++ b/lib/RoadizRozierBundle/config/routing/ajax.yml @@ -27,11 +27,11 @@ nodesStatusesAjax: _controller: Themes\Rozier\AjaxControllers\AjaxNodesController::statusesAction _format: json nodesSourcesStatusesAjax: - path: /nodes-sources/statuses/{nodeSource} + path: /nodes-sources/statuses/{nodesSources} defaults: _controller: RZ\Roadiz\RozierBundle\Controller\Node\NodesSourcesStatusController _format: json - requirements: { nodeSource: "[0-9]+" } + requirements: { nodesSources: "[0-9]+" } nodesTreeAjax: path: /nodes/tree methods: [GET] diff --git a/lib/RoadizRozierBundle/config/routing/nodes.yml b/lib/RoadizRozierBundle/config/routing/nodes.yml index f69c5d06f..d49558056 100644 --- a/lib/RoadizRozierBundle/config/routing/nodes.yml +++ b/lib/RoadizRozierBundle/config/routing/nodes.yml @@ -7,16 +7,11 @@ nodesHomeDraftPage: defaults: _controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction filter: 'draft' -nodesHomePendingPage: - path: /pending +nodesHomePublishedPage: + path: /published defaults: _controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction - filter: 'pending' -nodesHomeArchivedPage: - path: /archived - defaults: - _controller: Themes\Rozier\Controllers\Nodes\NodesController::indexAction - filter: 'archived' + filter: 'published' nodesHomeDeletedPage: path: /deleted defaults: diff --git a/lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php b/lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php index 380ae98d7..dabbee61a 100644 --- a/lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php +++ b/lib/RoadizRozierBundle/src/Controller/Node/NodesSourcesStatusController.php @@ -36,7 +36,13 @@ public function __construct( */ public function __invoke(Request $request, NodesSources $nodesSources): Response { - $this->validateRequest($request); + if (!$this->isCsrfTokenValid(self::AJAX_TOKEN_INTENTION, $request->get('_token'))) { + throw new BadRequestHttpException('Bad CSRF token'); + } + + if ('post' !== \mb_strtolower($request->getMethod())) { + throw new BadRequestHttpException('Bad method'); + } $workflow = $this->workflowRegistry->get($nodesSources); diff --git a/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig index 4f4b3720d..7e9245450 100644 --- a/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig +++ b/lib/RoadizRozierBundle/templates/nodes/actionsMenu.html.twig @@ -165,7 +165,7 @@ {% if source.isDraft %} {% set iconClass = 'uk-icon-rz-draft-nodes' %} diff --git a/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js index 9fbdf7d71..7f09ee7b7 100644 --- a/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js +++ b/lib/Rozier/src/Resources/app/custom-elements/NodesSourcesStatuses.js @@ -3,7 +3,7 @@ export default class NodesSourcesStatuses extends HTMLElement { this.onChange = this.onChange.bind(this) this.itemClick = this.itemClick.bind(this) - this.icon = this.querySelector('.node-status header i') + this.icon = this.querySelector('header i') this.querySelectorAll('input').forEach((input) => { input.removeEventListener('change', this.onChange) @@ -45,15 +45,7 @@ export default class NodesSourcesStatuses extends HTMLElement { let statusValue = input.value - if (this.icon) { - this.icon.className = input.parentElement.querySelector('i').className - } - window.dispatchEvent(new CustomEvent('requestLoaderShow')) - console.log(this.getAttribute('data-update-url'), { - _token: window.RozierConfig.ajaxToken, - statusValue: statusValue, - }) const response = await fetch(this.getAttribute('data-update-url'), { method: 'POST', headers: { @@ -74,11 +66,26 @@ export default class NodesSourcesStatuses extends HTMLElement { }, }) ) - } else { - window.dispatchEvent(new CustomEvent('requestAllNodeTreeRefresh')) + this.locked = false + window.dispatchEvent(new CustomEvent('requestLoaderHide')) + return false } + + const data = await response.json() + if (this.icon) { + this.icon.className = input.parentElement.querySelector('i').className + } + window.dispatchEvent( + new CustomEvent('pushToast', { + detail: { + message: data.responseText, + status: 'success', + }, + }) + ) + window.dispatchEvent(new CustomEvent('requestAllNodeTreeRefresh')) + this.locked = false window.dispatchEvent(new CustomEvent('requestLoaderHide')) - return false } } diff --git a/lib/Rozier/src/Resources/app/less/actions_menu/actions_menu.less b/lib/Rozier/src/Resources/app/less/actions_menu/actions_menu.less index 4f2c72f9b..e41fcca94 100644 --- a/lib/Rozier/src/Resources/app/less/actions_menu/actions_menu.less +++ b/lib/Rozier/src/Resources/app/less/actions_menu/actions_menu.less @@ -233,7 +233,7 @@ margin: 0; height: @action-menu-H; - &.node-status header { + &.nodes-sources-statuses header { background-color: #676767; &:before, .label { From 591300e52a5927ebdef0bda2f27e03cafa5f55ea Mon Sep 17 00:00:00 2001 From: Ambroise Maupate Date: Fri, 4 Jul 2025 15:18:40 +0200 Subject: [PATCH 8/8] chore: Fixed migration down --- .../migrations/Version20250629130118.php | 10 ++++++---- .../migrations/Version20250629150839.php | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/RoadizCoreBundle/migrations/Version20250629130118.php b/lib/RoadizCoreBundle/migrations/Version20250629130118.php index ef3f6ab4b..c19ca7bb5 100644 --- a/lib/RoadizCoreBundle/migrations/Version20250629130118.php +++ b/lib/RoadizCoreBundle/migrations/Version20250629130118.php @@ -76,28 +76,30 @@ public function down(Schema $schema): void WHERE nodes_sources.node_id=nodes.id SQL); - $draftIds = implode(',', $this->connection->executeQuery(<<connection->executeQuery(<< NOW() -SQL)->fetchFirstColumn()); +SQL)->fetchFirstColumn(); $this->warnIf( count($draftIds) > 0, 'Some nodes_sources are marked draft, this will force their node to be draft too.' ); + $draftIds = implode(',', $draftIds); + $publishedIds = implode(',', $this->connection->executeQuery(<<fetchFirstColumn()); - $deletedIds = implode(',', $this->connection->executeQuery(<<connection->executeQuery(<<fetchFirstColumn()); +SQL)->fetchFirstColumn(); $this->warnIf( count($deletedIds) > 0, diff --git a/lib/RoadizCoreBundle/migrations/Version20250629150839.php b/lib/RoadizCoreBundle/migrations/Version20250629150839.php index fce46761d..9a6f69f8d 100644 --- a/lib/RoadizCoreBundle/migrations/Version20250629150839.php +++ b/lib/RoadizCoreBundle/migrations/Version20250629150839.php @@ -35,7 +35,5 @@ public function down(Schema $schema): void $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_discr TO IDX_7C7DED6D4AD26064'); $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_title TO IDX_7C7DED6D2B36786B'); $this->addSql('ALTER TABLE nodes_sources RENAME INDEX ns_published_at TO IDX_7C7DED6DE0D4FDE1'); - $this->addSql('CREATE INDEX IDX_1483A5E98B8E8428 ON users (created_at)'); - $this->addSql('CREATE INDEX IDX_1483A5E943625D9F ON users (updated_at)'); } }