diff --git a/config/data_index_filters.yaml b/config/data_index_filters.yaml index d53131d39..d9bf6d586 100644 --- a/config/data_index_filters.yaml +++ b/config/data_index_filters.yaml @@ -47,6 +47,9 @@ services: Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\MultipleIdFilter: tags: [ 'pimcore.studio_backend.search_index.filter' ] + Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\WorkflowFilter: + tags: [ 'pimcore.studio_backend.search_index.filter' ] + Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\IntegerFilter: tags: [ 'pimcore.studio_backend.search_index.filter' ] diff --git a/doc/01_Architecture_Overview/01_Grid.md b/doc/01_Architecture_Overview/01_Grid.md index 7dddef69e..eeedd1ab9 100644 --- a/doc/01_Architecture_Overview/01_Grid.md +++ b/doc/01_Architecture_Overview/01_Grid.md @@ -136,6 +136,7 @@ Available filters are: | crm.consent | array | `true` or `false` | true | | dataobject.relation | array | array of `type`, `ids` objects | true | | system.unreferenced | boolean | Asset grid only — unreferenced assets | false | +| workflow | string or null | place name; omit/`null` = all states of the workflow | true | ### Examples: @@ -255,6 +256,23 @@ Filter unreferenced assets (asset grid only): ... ``` +Filter by Workflow place: +The `workflow` filter restricts an element grid to the elements currently in a given workflow place. +The `key` is the workflow name and `filterValue` is the place name; omit `filterValue` (or send `null`) +to include the elements in **all** states of the workflow. Folders are excluded, and the matching +element ids are resolved server-side (asset and data-object grids). +```json +... +"columnFilters" [ + { + "key": "product_workflow", + "type": "workflow", + "filterValue": "in_review" + } +] +... +``` + ## Advanced Columns Advanced columns combine multiple data sources and transformers. Data source types: diff --git a/src/DataIndex/Filter/WorkflowFilter.php b/src/DataIndex/Filter/WorkflowFilter.php new file mode 100644 index 000000000..0a117e0f1 --- /dev/null +++ b/src/DataIndex/Filter/WorkflowFilter.php @@ -0,0 +1,122 @@ +resolveElementType($query); + if ($elementType === null) { + return $query; + } + + foreach ($parameters->getColumnFilterByType(self::FILTER_TYPE) as $column) { + $query = $this->applyWorkflowFilter($column, $query, $elementType); + } + + return $query; + } + + private function applyWorkflowFilter( + ColumnFilter $column, + QueryInterface $query, + string $elementType + ): QueryInterface { + $workflowName = $column->getKeyWithOutLocale(); + $place = $column->getFilterValue(); + + if ($workflowName === '') { + throw new InvalidArgumentException('Workflow filter requires a workflow name (key).'); + } + + // A specific place narrows to that state; a missing place means "any place in the + // workflow" (the widget's show-all action), resolved by passing a null state name. + $stateName = is_string($place) && $place !== '' ? $place : null; + + $rows = $this->elementsRepository->fetchByWorkflowState($workflowName, $stateName, $elementType); + + $ids = []; + foreach ($rows as $row) { + $cid = (int) $row['cid']; + // Skip orphaned/invalid workflow-state rows (e.g. cid 0): the search index's + // IdsFilter requires strictly positive ids and throws otherwise. + if ($cid > 0) { + $ids[] = $cid; + } + } + + if (count($ids) > self::MAX_IDS) { + $this->logger->warning( + 'Workflow drill-down id set exceeds the search cap and was truncated.', + ['workflow' => $workflowName, 'place' => $place, 'total' => count($ids), 'cap' => self::MAX_IDS] + ); + $ids = array_slice($ids, 0, self::MAX_IDS); + } + + // Empty id set intentionally matches nothing (rather than the whole index). + return $query->searchByIds($ids); + } + + private function resolveElementType(QueryInterface $query): ?string + { + return match (true) { + $query instanceof DataObjectQueryInterface => ElementTypes::TYPE_DATA_OBJECT, + $query instanceof AssetQueryInterface => ElementTypes::TYPE_ASSET, + $query instanceof DocumentQueryInterface => ElementTypes::TYPE_DOCUMENT, + default => null, + }; + } +} diff --git a/src/Workflow/Repository/WorkflowElementsRepository.php b/src/Workflow/Repository/WorkflowElementsRepository.php index 9e1c8d580..8334dca0a 100644 --- a/src/Workflow/Repository/WorkflowElementsRepository.php +++ b/src/Workflow/Repository/WorkflowElementsRepository.php @@ -49,6 +49,12 @@ public function fetchByWorkflowState( 'ews', 'documents', 'd', "ews.ctype = 'document' AND d.id = ews.cid" ) ->where('ews.workflow = :workflow') + // Folders share their element's ctype but are never workflow subjects the widgets + // should list. Exclude only rows that positively resolve to a folder; the IS NULL + // guard keeps non-matching joins and orphaned states (element deleted) unaffected. + ->andWhere("(a.type IS NULL OR a.type != 'folder')") + ->andWhere("(o.type IS NULL OR o.type != 'folder')") + ->andWhere("(d.type IS NULL OR d.type != 'folder')") ->setParameter('workflow', $workflowName) ->orderBy('modificationDate', 'ASC') ->addOrderBy('ews.cid', 'ASC') diff --git a/tests/Unit/DataIndex/Filter/WorkflowFilterTest.php b/tests/Unit/DataIndex/Filter/WorkflowFilterTest.php new file mode 100644 index 000000000..076eb39f8 --- /dev/null +++ b/tests/Unit/DataIndex/Filter/WorkflowFilterTest.php @@ -0,0 +1,139 @@ +repository = $this->createMock(WorkflowElementsRepositoryInterface::class); + $this->filter = new WorkflowFilter($this->repository, $this->createMock(LoggerInterface::class)); + } + + public function testItIgnoresParametersThatAreNotColumnFilters(): void + { + $query = $this->createMock(DataObjectQueryInterface::class); + $query->expects($this->never())->method('searchByIds'); + $this->repository->expects($this->never())->method('fetchByWorkflowState'); + + self::assertSame($query, $this->filter->apply(new \stdClass(), $query)); + } + + public function testItResolvesObjectIdsForTheWorkflowPlaceAndSearchesByThem(): void + { + $query = $this->createMock(DataObjectQueryInterface::class); + + // Data-object query -> element type 'data-object' passed to the repository. + $this->repository->expects($this->once()) + ->method('fetchByWorkflowState') + ->with('product_workflow', 'in_review', ElementTypes::TYPE_DATA_OBJECT) + ->willReturn([ + ['cid' => 3, 'ctype' => 'object'], + ['cid' => 7, 'ctype' => 'object'], + ]); + + $query->expects($this->once())->method('searchByIds')->with([3, 7])->willReturnSelf(); + + $result = $this->filter->apply($this->parametersWith('product_workflow', 'in_review'), $query); + + self::assertSame($query, $result); + } + + public function testItSkipsOrphanedNonPositiveIds(): void + { + $query = $this->createMock(DataObjectQueryInterface::class); + + // Orphaned element_workflow_state rows can carry cid 0; the search index's IdsFilter + // requires strictly positive ids, so they must be dropped before searchByIds. + $this->repository->method('fetchByWorkflowState')->willReturn([ + ['cid' => 0, 'ctype' => 'object'], + ['cid' => 7, 'ctype' => 'object'], + ]); + + $query->expects($this->once())->method('searchByIds')->with([7])->willReturnSelf(); + + $this->filter->apply($this->parametersWith('product_workflow', 'in_review'), $query); + } + + public function testItUsesAssetElementTypeForAssetQueries(): void + { + $query = $this->createMock(AssetQueryInterface::class); + + $this->repository->expects($this->once()) + ->method('fetchByWorkflowState') + ->with('asset_workflow', 'to_review', ElementTypes::TYPE_ASSET) + ->willReturn([['cid' => 42, 'ctype' => 'asset']]); + + $query->expects($this->once())->method('searchByIds')->with([42])->willReturnSelf(); + + $this->filter->apply($this->parametersWith('asset_workflow', 'to_review'), $query); + } + + public function testItResolvesAllPlacesWhenNoPlaceIsGiven(): void + { + $query = $this->createMock(DataObjectQueryInterface::class); + + // No place -> null state name -> the whole workflow (the widget's show-all action). + $this->repository->expects($this->once()) + ->method('fetchByWorkflowState') + ->with('product_workflow', null, ElementTypes::TYPE_DATA_OBJECT) + ->willReturn([['cid' => 5, 'ctype' => 'object']]); + + $query->expects($this->once())->method('searchByIds')->with([5])->willReturnSelf(); + + $this->filter->apply($this->parametersWith('product_workflow', null), $query); + } + + public function testItSearchesByAnEmptyIdSetWhenNoElementsMatch(): void + { + $query = $this->createMock(DataObjectQueryInterface::class); + + $this->repository->method('fetchByWorkflowState')->willReturn([]); + // Empty id set -> the grid shows nothing (rather than everything). + $query->expects($this->once())->method('searchByIds')->with([])->willReturnSelf(); + + $this->filter->apply($this->parametersWith('product_workflow', 'in_review'), $query); + } + + private function parametersWith(string $workflow, ?string $place): ColumnFiltersParameterInterface&MockObject + { + // ColumnFilter is a final readonly value object -> construct it, never mock it. + $columnFilter = new ColumnFilter($workflow, WorkflowFilter::FILTER_TYPE, $place); + + $parameters = $this->createMock(ColumnFiltersParameterInterface::class); + $parameters->method('getColumnFilterByType') + ->with(WorkflowFilter::FILTER_TYPE) + ->willReturn([$columnFilter]); + + return $parameters; + } +} diff --git a/tests/Unit/Workflow/Repository/WorkflowElementsRepositoryTest.php b/tests/Unit/Workflow/Repository/WorkflowElementsRepositoryTest.php new file mode 100644 index 000000000..201d073c2 --- /dev/null +++ b/tests/Unit/Workflow/Repository/WorkflowElementsRepositoryTest.php @@ -0,0 +1,71 @@ +dbResolver = $this->createMock(DbResolverInterface::class); + $this->connection = $this->createMock(Connection::class); + $this->repository = new WorkflowElementsRepository($this->dbResolver); + } + + public function testFetchByWorkflowStateExcludesFolders(): void + { + $wheres = []; + $queryBuilder = $this->createMock(QueryBuilder::class); + foreach (['select', 'addSelect', 'from', 'where', 'setParameter', 'orderBy', 'addOrderBy'] as $method) { + $queryBuilder->method($method)->willReturnSelf(); + } + // The asset/object/document subtype tables are joined so their type can be inspected. + $queryBuilder->expects($this->exactly(3))->method('leftJoin')->willReturnSelf(); + $queryBuilder->method('andWhere')->willReturnCallback( + function (string $condition) use (&$wheres, $queryBuilder) { + $wheres[] = $condition; + + return $queryBuilder; + } + ); + $queryBuilder->method('fetchAllAssociative')->willReturn([]); + + $this->connection->method('createQueryBuilder')->willReturn($queryBuilder); + $this->dbResolver->method('get')->willReturn($this->connection); + + $this->repository->fetchByWorkflowState('product_workflow'); + + // Folder rows are filtered out in SQL for every element type. The IS NULL guard is + // essential: a plain "type != 'folder'" would drop every non-matching LEFT JOIN row + // (NULL type) via three-valued logic, emptying the result. + $this->assertContains("(a.type IS NULL OR a.type != 'folder')", $wheres); + $this->assertContains("(o.type IS NULL OR o.type != 'folder')", $wheres); + $this->assertContains("(d.type IS NULL OR d.type != 'folder')", $wheres); + } +}