From 1f7c217011fe76ce0964842c24e3d10d9a081264 Mon Sep 17 00:00:00 2001 From: Tine <42359615+xIrusux@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:25:50 +0200 Subject: [PATCH 1/2] [Workflow] Add WorkflowFilter to scope element grids by workflow place (#1938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Workflow] Exclude folders from workflow element queries (#1930) * [Workflow] exclude folders from workflow element queries Folders share their element's ctype (asset/object/document) in element_workflow_state, so fetchByWorkflowState listed asset folders alongside real assets in the workflow pending-items widget and the workflow_get_elements click-through list (pimcore/studio-dashboards-bundle#301). Filter out folder subtypes in SQL via the already-present assets/objects/ documents joins. The IS NULL guard per table is required: a plain "type != 'folder'" would drop every non-matching LEFT JOIN row (NULL type) through three-valued logic, emptying the result. Orphaned states (element deleted) are preserved unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * Address static-analysis finding: drop unused leftJoin callback params Verify the join count via expects(exactly(3)) instead of a capturing callback with unused $fromAlias/$join parameters (Codacy/PHPMD UnusedFormalParameter). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) * [Workflow] add WorkflowFilter to scope element grids by workflow place New search-index filter (type `workflow`, key=workflow name, value=place) that resolves the matching element ids via WorkflowElementsRepository (folders already excluded) and applies the standard searchByIds modifier. Lets the native element listing be filtered to a workflow state server-side — the basis for the state distribution donut drill-down opening the native grid instead of a bespoke list. Element type is derived from the query (data-object / asset / document). Empty id set matches nothing; a MAX_IDS ceiling guards the OpenSearch terms limit (logged). Registered with the pimcore.studio_backend.search_index.filter tag. Co-Authored-By: Claude Opus 4.8 (1M context) * [Workflow] document the workflow grid filter Add the `workflow` column filter to the Grid architecture docs (filters table + example): key = workflow name, value = place (omit/null = all states), folders excluded, ids resolved server-side. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- config/data_index_filters.yaml | 3 + doc/01_Architecture_Overview/01_Grid.md | 18 +++ src/DataIndex/Filter/WorkflowFilter.php | 122 +++++++++++++++ .../DataIndex/Filter/WorkflowFilterTest.php | 139 ++++++++++++++++++ 4 files changed, 282 insertions(+) create mode 100644 src/DataIndex/Filter/WorkflowFilter.php create mode 100644 tests/Unit/DataIndex/Filter/WorkflowFilterTest.php 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/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; + } +} From 6fb7ebc41e015d7d5e1834862abe4c823801781e Mon Sep 17 00:00:00 2001 From: xIrusux <42359615+xIrusux@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:26:24 +0000 Subject: [PATCH 2/2] Apply php-cs-fixer changes --- tests/Unit/DataIndex/Filter/WorkflowFilterTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/DataIndex/Filter/WorkflowFilterTest.php b/tests/Unit/DataIndex/Filter/WorkflowFilterTest.php index 076eb39f8..012f6cd67 100644 --- a/tests/Unit/DataIndex/Filter/WorkflowFilterTest.php +++ b/tests/Unit/DataIndex/Filter/WorkflowFilterTest.php @@ -23,6 +23,7 @@ use Pimcore\Bundle\StudioBackendBundle\Util\Constant\ElementTypes; use Pimcore\Bundle\StudioBackendBundle\Workflow\Repository\WorkflowElementsRepositoryInterface; use Psr\Log\LoggerInterface; +use stdClass; /** * @internal @@ -45,7 +46,7 @@ public function testItIgnoresParametersThatAreNotColumnFilters(): void $query->expects($this->never())->method('searchByIds'); $this->repository->expects($this->never())->method('fetchByWorkflowState'); - self::assertSame($query, $this->filter->apply(new \stdClass(), $query)); + self::assertSame($query, $this->filter->apply(new stdClass(), $query)); } public function testItResolvesObjectIdsForTheWorkflowPlaceAndSearchesByThem(): void