Add filter to include/exclude tags

[Added]
- Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes.
This commit is contained in:
Beda Schmid
2026-06-24 10:36:42 -03:00
committed by Gregor Vostrak
parent 50f57f6997
commit 58d7b33366
17 changed files with 146 additions and 6 deletions

View File

@@ -59,7 +59,7 @@ class ReportController extends Controller
$filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagFilter);
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();

View File

@@ -96,6 +96,7 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagFilter($request->input('properties.tag_filter', null));
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;

View File

@@ -203,7 +203,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->input('tag_filter'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
@@ -559,7 +559,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->input('tag_filter'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));

View File

@@ -124,6 +124,11 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_filter' => [
'nullable',
'string',
'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS,
],
'properties.task_ids' => [
'nullable',
'array',

View File

@@ -139,6 +139,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_filter' => [
'string',
'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS,
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',

View File

@@ -125,6 +125,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_filter' => [
'string',
'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS,
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',

View File

@@ -110,6 +110,10 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_filter' => [
'string',
'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS,
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',

View File

@@ -103,6 +103,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_filter' => [
'string',
'in:'.TimeEntryFilter::TAG_FILTER_CONTAINS.','.TimeEntryFilter::TAG_FILTER_NOT_CONTAINS,
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',

View File

@@ -56,6 +56,8 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_filter Tag filter mode */
'tag_filter' => $this->resource->properties->tagFilter,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -56,6 +56,8 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $tagIds = null;
public ?string $tagFilter = null;
/**
* @var Collection<int, string>|null
*/
@@ -115,6 +117,7 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagFilter = isset($data->tagFilter) ? ReportPropertiesDto::tagFilterValue($data->tagFilter) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -144,6 +147,7 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagFilter' => $value->tagFilter,
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
@@ -184,6 +188,24 @@ class ReportPropertiesDto implements Castable
return $collection;
}
/**
* @return 'contains'|'not_contains'|null
*/
public static function tagFilterValue(mixed $tagFilter): ?string
{
if ($tagFilter === null) {
return null;
}
if (! is_string($tagFilter)) {
throw new \InvalidArgumentException('The given tag filter is not a string');
}
if (! in_array($tagFilter, [TimeEntryFilter::TAG_FILTER_CONTAINS, TimeEntryFilter::TAG_FILTER_NOT_CONTAINS], true)) {
throw new \InvalidArgumentException('The given tag filter is not valid');
}
return $tagFilter;
}
/**
* @param array<mixed>|null $memberIds
*/
@@ -216,6 +238,11 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
public function setTagFilter(mixed $tagFilter): void
{
$this->tagFilter = ReportPropertiesDto::tagFilterValue($tagFilter);
}
/**
* @param array<mixed>|null $taskIds
*/

View File

@@ -14,6 +14,10 @@ class TimeEntryFilter
{
public const string NONE_VALUE = 'none';
public const string TAG_FILTER_CONTAINS = 'contains';
public const string TAG_FILTER_NOT_CONTAINS = 'not_contains';
/**
* @var Builder<TimeEntry>
*/
@@ -192,15 +196,22 @@ class TimeEntryFilter
/**
* @param array<string>|null $tagIds
*/
public function addTagIdsFilter(?array $tagIds): self
public function addTagIdsFilter(?array $tagIds, ?string $tagFilter = self::TAG_FILTER_CONTAINS): self
{
if ($tagIds === null) {
return $this;
}
if ($tagFilter === null) {
$tagFilter = self::TAG_FILTER_CONTAINS;
}
if (! in_array($tagFilter, [self::TAG_FILTER_CONTAINS, self::TAG_FILTER_NOT_CONTAINS], true)) {
Log::warning('Invalid tag filter value', ['value' => $tagFilter]);
$tagFilter = self::TAG_FILTER_CONTAINS;
}
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
@@ -209,7 +220,13 @@ class TimeEntryFilter
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
});
}
});
};
if ($tagFilter === self::TAG_FILTER_NOT_CONTAINS) {
$this->builder->whereNot($tagCondition);
} else {
$this->builder->where($tagCondition);
}
return $this;
}