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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/data_index_filters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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' ]

Expand Down
18 changes: 18 additions & 0 deletions doc/01_Architecture_Overview/01_Grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
122 changes: 122 additions & 0 deletions src/DataIndex/Filter/WorkflowFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter;

use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\AssetQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\DataObjectQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\DocumentQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\QueryInterface;
use Pimcore\Bundle\StudioBackendBundle\Exception\Api\InvalidArgumentException;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFilter;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFiltersParameterInterface;
use Pimcore\Bundle\StudioBackendBundle\Util\Constant\ElementTypes;
use Pimcore\Bundle\StudioBackendBundle\Workflow\Repository\WorkflowElementsRepositoryInterface;
use Psr\Log\LoggerInterface;
use function array_slice;
use function count;
use function is_string;

/**
* Restricts an element grid query to the elements in a given workflow place. Reuses the
* workflow-state repository (folders already excluded) to resolve the matching element ids and
* applies the standard `searchByIds` modifier, so the native listing filters server-side without
* enumerating ids on the client.
*
* @internal
*/
final readonly class WorkflowFilter implements FilterInterface
{
public const string FILTER_TYPE = 'workflow';

/**
* Upper bound on ids handed to the search index, guarding against the OpenSearch terms limit
* for pathologically large states. Realistic states stay far below this; if a state ever
* exceeds it the excess is dropped and a warning is logged (not surfaced to the user).
*/
private const int MAX_IDS = 10000;

public function __construct(

Check notice on line 49 in src/DataIndex/Filter/WorkflowFilter.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/DataIndex/Filter/WorkflowFilter.php#L49

Expected 2 blank lines before function; 1 found
private WorkflowElementsRepositoryInterface $elementsRepository,
private LoggerInterface $logger,
) {
}

Check notice on line 53 in src/DataIndex/Filter/WorkflowFilter.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/DataIndex/Filter/WorkflowFilter.php#L53

Expected 1 blank line before closing function brace; 0 found

Check notice on line 53 in src/DataIndex/Filter/WorkflowFilter.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/DataIndex/Filter/WorkflowFilter.php#L53

Expected 2 blank lines after function; 1 found

public function apply(mixed $parameters, QueryInterface $query): QueryInterface
{
if (!$parameters instanceof ColumnFiltersParameterInterface) {
return $query;
}

$elementType = $this->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,
};
}
}
140 changes: 140 additions & 0 deletions tests/Unit/DataIndex/Filter/WorkflowFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\Tests\Unit\DataIndex\Filter;

use Codeception\Test\Unit;
use PHPUnit\Framework\MockObject\MockObject;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\WorkflowFilter;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\AssetQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\DataObjectQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFilter;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFiltersParameterInterface;
use Pimcore\Bundle\StudioBackendBundle\Util\Constant\ElementTypes;
use Pimcore\Bundle\StudioBackendBundle\Workflow\Repository\WorkflowElementsRepositoryInterface;
use Psr\Log\LoggerInterface;
use stdClass;

/**
* @internal
*/
final class WorkflowFilterTest extends Unit
{
private MockObject|WorkflowElementsRepositoryInterface $repository;

Check notice on line 33 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L33

Expected 1 blank line(s) before first member var; 0 found

private WorkflowFilter $filter;

protected function _before(): void

Check notice on line 37 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L37

Expected 2 blank lines before function; 1 found

Check notice on line 37 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L37

Method name "_before" should not be prefixed with an underscore to indicate visibility

Check notice on line 37 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L37

The method _before is not named in camelCase.
{
$this->repository = $this->createMock(WorkflowElementsRepositoryInterface::class);
$this->filter = new WorkflowFilter($this->repository, $this->createMock(LoggerInterface::class));
}

Check notice on line 41 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L41

Expected 1 blank line before closing function brace; 0 found

Check notice on line 41 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L41

Expected 2 blank lines after function; 1 found

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([

Check notice on line 60 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L60

Opening parenthesis of a multi-line function call must be the last content on the line
['cid' => 3, 'ctype' => 'object'],
['cid' => 7, 'ctype' => 'object'],
]);

Check notice on line 63 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L63

Closing parenthesis of a multi-line function call must be on a line by itself

$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([

Check notice on line 78 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L78

Opening parenthesis of a multi-line function call must be the last content on the line
['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;
}
}
Loading