mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 11:42:15 +01:00
Compare commits
14 Commits
dependabot
...
630fecce4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
630fecce4c | ||
|
|
981bcbe091 | ||
|
|
ccf3cec8c0 | ||
|
|
23ea1500b0 | ||
|
|
4bbde04e28 | ||
|
|
caa36d2875 | ||
|
|
58d7b33366 | ||
|
|
50f57f6997 | ||
|
|
ab9f6e6afb | ||
|
|
2a40bb7edb | ||
|
|
365f672cfc | ||
|
|
25f30fa820 | ||
|
|
37a8f43b23 | ||
|
|
c94aa8038d |
16
app/Enums/TagMatchType.php
Normal file
16
app/Enums/TagMatchType.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TagMatchType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Contains = 'contains';
|
||||
|
||||
case NotContains = 'not_contains';
|
||||
}
|
||||
@@ -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->tagMatchType);
|
||||
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
||||
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
||||
$timeEntriesQuery = $filter->get();
|
||||
|
||||
@@ -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->setTagMatchType($request->getPropertyTagMatchType());
|
||||
$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;
|
||||
|
||||
@@ -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->getTagMatchType());
|
||||
$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->getTagMatchType());
|
||||
$filter->addTaskIdsFilter($request->input('task_ids'));
|
||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||
$filter->addBillableFilter($request->input('billable'));
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
@@ -124,6 +125,11 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
}
|
||||
},
|
||||
],
|
||||
'properties.tag_match_type' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
'properties.task_ids' => [
|
||||
'nullable',
|
||||
'array',
|
||||
@@ -249,6 +255,15 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
|
||||
}
|
||||
|
||||
public function getPropertyTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->input('properties.tag_match_type'));
|
||||
}
|
||||
|
||||
public function getPropertyRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
@@ -139,6 +140,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -246,6 +251,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
@@ -125,6 +126,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -208,6 +213,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
@@ -110,6 +111,10 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -215,6 +220,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
return ExportFormat::from($this->validated('format'));
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Client;
|
||||
@@ -103,6 +104,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
'tag_match_type' => [
|
||||
'string',
|
||||
Rule::enum(TagMatchType::class),
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
'array',
|
||||
@@ -190,6 +195,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
|
||||
}
|
||||
|
||||
public function getTagMatchType(): ?TagMatchType
|
||||
{
|
||||
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TagMatchType::from($this->validated('tag_match_type'));
|
||||
}
|
||||
|
||||
public function getRoundingType(): ?TimeEntryRoundingType
|
||||
{
|
||||
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
|
||||
|
||||
@@ -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_match_type Tag match type */
|
||||
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
||||
/** @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 */
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
@@ -56,6 +57,8 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public ?Collection $tagIds = null;
|
||||
|
||||
public ?TagMatchType $tagMatchType = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, string>|null
|
||||
*/
|
||||
@@ -115,6 +118,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->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
|
||||
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
|
||||
$dto->group = TimeEntryAggregationType::from($data->group);
|
||||
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
|
||||
@@ -144,6 +148,7 @@ class ReportPropertiesDto implements Castable
|
||||
'clientIds' => $value->clientIds?->toArray(),
|
||||
'projectIds' => $value->projectIds?->toArray(),
|
||||
'tagIds' => $value->tagIds?->toArray(),
|
||||
'tagMatchType' => $value->tagMatchType?->value,
|
||||
'taskIds' => $value->taskIds?->toArray(),
|
||||
'group' => $value->group->value,
|
||||
'subGroup' => $value->subGroup->value,
|
||||
@@ -216,6 +221,11 @@ class ReportPropertiesDto implements Castable
|
||||
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
|
||||
}
|
||||
|
||||
public function setTagMatchType(?TagMatchType $tagMatchType): void
|
||||
{
|
||||
$this->tagMatchType = $tagMatchType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $taskIds
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
@@ -24,6 +25,7 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$billableRateKey = $this->getBillableRateKey($header);
|
||||
$tasksKey = $this->getTasksKey($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$clientId = null;
|
||||
@@ -44,11 +46,12 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
'is_billable' => $record['Billability'] === 'Yes',
|
||||
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
||||
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
||||
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($record['Task'] !== '') {
|
||||
$tasks = explode(', ', $record['Task']);
|
||||
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
||||
$tasks = explode(', ', $record[$tasksKey]);
|
||||
foreach ($tasks as $task) {
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task,
|
||||
@@ -81,13 +84,33 @@ class ClockifyProjectsImporter extends DefaultImporter
|
||||
'Status',
|
||||
'Visibility',
|
||||
'Billability',
|
||||
'Task',
|
||||
];
|
||||
foreach ($requiredFields as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
|
||||
if ($this->getTasksKey($header) === null) {
|
||||
throw new ImportException('Invalid CSV header, missing field: Tasks');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clockify names the tasks column differently depending on the export
|
||||
* version: "Task" (older), "Tasks" (newer) or "Activities".
|
||||
*
|
||||
* @param array<string> $header
|
||||
*/
|
||||
private function getTasksKey(array $header): ?string
|
||||
{
|
||||
foreach (['Tasks', 'Task', 'Activities'] as $field) {
|
||||
if (in_array($field, $header, true)) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$reader->setEscape('');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$taskKey = $this->getTaskKey($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
@@ -96,9 +97,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['Task'] !== '') {
|
||||
if ($taskKey !== null && $record[$taskKey] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['Task'],
|
||||
'name' => $record[$taskKey],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
@@ -116,10 +117,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Description'];
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
if (isset($record['Billable'])) {
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
@@ -214,12 +217,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
'Project',
|
||||
'Client',
|
||||
'Description',
|
||||
'Task',
|
||||
'User',
|
||||
'Group',
|
||||
'Email',
|
||||
'Tags',
|
||||
'Billable',
|
||||
'Start Date',
|
||||
'Start Time',
|
||||
'End Date',
|
||||
@@ -230,6 +231,26 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
|
||||
if ($this->getTaskKey($header) === null) {
|
||||
throw new ImportException('Invalid CSV header, missing field: Task');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clockify names the task column "Task" or "Activity" depending on the export version.
|
||||
*
|
||||
* @param array<string> $header
|
||||
*/
|
||||
private function getTaskKey(array $header): ?string
|
||||
{
|
||||
foreach (['Task', 'Activity'] as $field) {
|
||||
if (in_array($field, $header, true)) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -192,15 +193,21 @@ class TimeEntryFilter
|
||||
/**
|
||||
* @param array<string>|null $tagIds
|
||||
*/
|
||||
public function addTagIdsFilter(?array $tagIds): self
|
||||
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
|
||||
{
|
||||
if ($tagIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$tagMatchType ??= TagMatchType::Contains;
|
||||
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
||||
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
|
||||
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
|
||||
if (count($tagIds) === 0 && ! $includeNone) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$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 +216,18 @@ class TimeEntryFilter
|
||||
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if ($tagMatchType === TagMatchType::NotContains) {
|
||||
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
|
||||
$builder->whereNot($tagCondition);
|
||||
if (! $includeNone) {
|
||||
$builder->orWhereNull('tags');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$this->builder->where($tagCondition);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
328
composer.lock
generated
328
composer.lock
generated
@@ -3020,26 +3020,25 @@
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "7.12.1",
|
||||
"version": "7.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/guzzle.git",
|
||||
"reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425"
|
||||
"reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425",
|
||||
"reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86",
|
||||
"reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/promises": "^2.5",
|
||||
"guzzlehttp/psr7": "^2.12.1",
|
||||
"guzzlehttp/promises": "^2.3",
|
||||
"guzzlehttp/psr7": "^2.8",
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.24"
|
||||
"symfony/deprecation-contracts": "^2.2 || ^3.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-client-implementation": "1.0"
|
||||
@@ -3048,7 +3047,7 @@
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"ext-curl": "*",
|
||||
"guzzle/client-integration-tests": "3.0.2",
|
||||
"guzzlehttp/test-server": "^0.5.1",
|
||||
"guzzlehttp/test-server": "^0.3.2",
|
||||
"php-http/message-factory": "^1.1",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
|
||||
"psr/log": "^1.1 || ^2.0 || ^3.0"
|
||||
@@ -3128,7 +3127,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.12.1"
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.10.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3144,25 +3143,24 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-18T14:12:49+00:00"
|
||||
"time": "2026-05-20T22:59:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/promises",
|
||||
"version": "2.5.0",
|
||||
"version": "2.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/promises.git",
|
||||
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e"
|
||||
"reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e",
|
||||
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e",
|
||||
"url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2",
|
||||
"reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0"
|
||||
"php": "^7.2.5 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
@@ -3212,7 +3210,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/promises/issues",
|
||||
"source": "https://github.com/guzzle/promises/tree/2.5.0"
|
||||
"source": "https://github.com/guzzle/promises/tree/2.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3228,29 +3226,27 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-02T12:23:43+00:00"
|
||||
"time": "2026-05-20T22:57:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.12.1",
|
||||
"version": "2.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7"
|
||||
"reference": "73ab136360b5dfd858006eae9795e8fe43c80361"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7",
|
||||
"reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361",
|
||||
"reference": "73ab136360b5dfd858006eae9795e8fe43c80361",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0",
|
||||
"ralouphie/getallheaders": "^3.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.24"
|
||||
"ralouphie/getallheaders": "^3.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
@@ -3331,7 +3327,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.12.1"
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.10.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3347,20 +3343,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-18T09:49:37+00:00"
|
||||
"time": "2026-05-20T09:27:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/uri-template",
|
||||
"version": "v1.0.7",
|
||||
"version": "v1.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/uri-template.git",
|
||||
"reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529"
|
||||
"reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/uri-template/zipball/7fe811c23a9e3cd712b4389eaeb50b5456d8c529",
|
||||
"reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529",
|
||||
"url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1",
|
||||
"reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3369,7 +3365,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
|
||||
"phpunit/phpunit": "^8.5.44 || ^9.6.25",
|
||||
"uri-template/tests": "1.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
@@ -3417,7 +3413,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/uri-template/issues",
|
||||
"source": "https://github.com/guzzle/uri-template/tree/v1.0.7"
|
||||
"source": "https://github.com/guzzle/uri-template/tree/v1.0.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3433,7 +3429,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-12T21:33:43+00:00"
|
||||
"time": "2025-08-22T14:27:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "inertiajs/inertia-laravel",
|
||||
@@ -4197,16 +4193,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.61.1",
|
||||
"version": "v12.60.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d"
|
||||
"reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d",
|
||||
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39",
|
||||
"reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4415,7 +4411,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-06-04T14:22:52+00:00"
|
||||
"time": "2026-05-20T11:48:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/octane",
|
||||
@@ -6676,16 +6672,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.13.0",
|
||||
"version": "3.11.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "40f6618f052df16b545f626fbf9a878e6497d16a"
|
||||
"reference": "e890471a3494740f7d9326d72ce6a8c559ffee60"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a",
|
||||
"reference": "40f6618f052df16b545f626fbf9a878e6497d16a",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60",
|
||||
"reference": "e890471a3494740f7d9326d72ce6a8c559ffee60",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6777,7 +6773,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-18T13:49:15+00:00"
|
||||
"time": "2026-04-07T09:57:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nette/schema",
|
||||
@@ -7796,16 +7792,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "1.30.5",
|
||||
"version": "1.30.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "97bcabd32a64924688487dcd64aceaf158affb5c"
|
||||
"reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c",
|
||||
"reference": "97bcabd32a64924688487dcd64aceaf158affb5c",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
|
||||
"reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7898,9 +7894,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5"
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.4"
|
||||
},
|
||||
"time": "2026-05-31T05:13:11+00:00"
|
||||
"time": "2026-04-19T06:00:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
@@ -7979,16 +7975,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpseclib/phpseclib",
|
||||
"version": "3.0.55",
|
||||
"version": "3.0.52",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpseclib/phpseclib.git",
|
||||
"reference": "db9744e6d47e742b1f974e965ad49bdd041105af"
|
||||
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af",
|
||||
"reference": "db9744e6d47e742b1f974e965ad49bdd041105af",
|
||||
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
|
||||
"reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -8069,7 +8065,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/phpseclib/phpseclib/issues",
|
||||
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.55"
|
||||
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -8085,7 +8081,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-14T23:24:10+00:00"
|
||||
"time": "2026-04-27T07:02:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
@@ -9023,20 +9019,20 @@
|
||||
},
|
||||
{
|
||||
"name": "ramsey/uuid",
|
||||
"version": "4.9.3",
|
||||
"version": "4.9.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ramsey/uuid.git",
|
||||
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8"
|
||||
"reference": "8429c78ca35a09f27565311b98101e2826affde0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8",
|
||||
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8",
|
||||
"url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
|
||||
"reference": "8429c78ca35a09f27565311b98101e2826affde0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": ">=0.8.16 <=0.18",
|
||||
"brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
|
||||
"php": "^8.0",
|
||||
"ramsey/collection": "^1.2 || ^2.0"
|
||||
},
|
||||
@@ -9095,9 +9091,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/ramsey/uuid/issues",
|
||||
"source": "https://github.com/ramsey/uuid/tree/4.9.3"
|
||||
"source": "https://github.com/ramsey/uuid/tree/4.9.2"
|
||||
},
|
||||
"time": "2026-06-18T03:57:49+00:00"
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/promise",
|
||||
@@ -10134,16 +10130,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217"
|
||||
"reference": "ed0107e43ab452aa77ae99e005b95e56b556e075"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217",
|
||||
"reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075",
|
||||
"reference": "ed0107e43ab452aa77ae99e005b95e56b556e075",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10208,7 +10204,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.11"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10228,7 +10224,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T08:56:14+00:00"
|
||||
"time": "2026-05-13T12:04:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/css-selector",
|
||||
@@ -10757,16 +10753,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/html-sanitizer",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.12",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/html-sanitizer.git",
|
||||
"reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9"
|
||||
"reference": "51cb4f68195883f7ac403abb58ecf7adc74e0f9e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/761f6c49dfd103ee08b3cd09ece588b069e18ec9",
|
||||
"reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9",
|
||||
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/51cb4f68195883f7ac403abb58ecf7adc74e0f9e",
|
||||
"reference": "51cb4f68195883f7ac403abb58ecf7adc74e0f9e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10807,7 +10803,7 @@
|
||||
"sanitizer"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.12"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10827,20 +10823,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T11:20:33+00:00"
|
||||
"time": "2026-05-20T07:20:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-foundation.git",
|
||||
"reference": "bc354f47c62301e990b7874fa662326368508e2c"
|
||||
"reference": "9381209597ec66c25be154cbf2289076e64d1eab"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c",
|
||||
"reference": "bc354f47c62301e990b7874fa662326368508e2c",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab",
|
||||
"reference": "9381209597ec66c25be154cbf2289076e64d1eab",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10889,7 +10885,7 @@
|
||||
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10909,20 +10905,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T11:20:33+00:00"
|
||||
"time": "2026-03-24T13:12:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-kernel",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.12",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-kernel.git",
|
||||
"reference": "9df847980c436451f4f51d1284491bb4356dd989"
|
||||
"reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989",
|
||||
"reference": "9df847980c436451f4f51d1284491bb4356dd989",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d",
|
||||
"reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11008,7 +11004,7 @@
|
||||
"description": "Provides a structured process for converting a Request into a Response",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.12"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11028,7 +11024,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-27T08:31:43+00:00"
|
||||
"time": "2026-05-20T09:27:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mailer",
|
||||
@@ -11116,16 +11112,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.12",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mime.git",
|
||||
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770"
|
||||
"reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770",
|
||||
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470",
|
||||
"reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11181,7 +11177,7 @@
|
||||
"mime-type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mime/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/mime/tree/v7.4.12"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11201,7 +11197,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-23T16:22:37+00:00"
|
||||
"time": "2026-05-20T07:20:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
@@ -11288,16 +11284,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-grapheme",
|
||||
"version": "v1.38.1",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
|
||||
"reference": "e9247d281d694a5120554d9afaf54e070e88a603"
|
||||
"reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603",
|
||||
"reference": "e9247d281d694a5120554d9afaf54e070e88a603",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
|
||||
"reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11346,7 +11342,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1"
|
||||
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11366,20 +11362,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T05:58:03+00:00"
|
||||
"time": "2026-04-26T13:13:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-idn",
|
||||
"version": "v1.38.1",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||
"reference": "dc21118016c039a66235cf93d96b435ffb282412"
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
|
||||
"reference": "dc21118016c039a66235cf93d96b435ffb282412",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11433,7 +11429,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11453,20 +11449,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-25T15:22:23+00:00"
|
||||
"time": "2024-09-10T14:38:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-normalizer",
|
||||
"version": "v1.38.0",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
|
||||
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
|
||||
"reference": "3833d7255cc303546435cb650316bff708a1c75c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
||||
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
|
||||
"reference": "3833d7255cc303546435cb650316bff708a1c75c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11518,7 +11514,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
|
||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11538,20 +11534,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-25T13:48:31+00:00"
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.38.2",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
|
||||
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
|
||||
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11603,7 +11599,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11623,7 +11619,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-27T06:59:30+00:00"
|
||||
"time": "2026-04-10T17:25:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php73",
|
||||
@@ -11791,16 +11787,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php81",
|
||||
"version": "v1.38.1",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php81.git",
|
||||
"reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7"
|
||||
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
|
||||
"reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
|
||||
"reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11847,7 +11843,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1"
|
||||
"source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11867,20 +11863,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T12:45:58+00:00"
|
||||
"time": "2024-09-09T11:45:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php83",
|
||||
"version": "v1.38.2",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php83.git",
|
||||
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8"
|
||||
"reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8",
|
||||
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149",
|
||||
"reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11927,7 +11923,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2"
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11947,20 +11943,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-27T06:51:48+00:00"
|
||||
"time": "2026-04-10T17:25:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php84",
|
||||
"version": "v1.38.1",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php84.git",
|
||||
"reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
|
||||
"reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
|
||||
"reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06",
|
||||
"reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12007,7 +12003,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
|
||||
"source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12027,20 +12023,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T12:51:13+00:00"
|
||||
"time": "2026-04-10T18:47:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php85",
|
||||
"version": "v1.38.1",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php85.git",
|
||||
"reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1"
|
||||
"reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
|
||||
"reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee",
|
||||
"reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12087,7 +12083,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1"
|
||||
"source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12107,7 +12103,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T02:25:22+00:00"
|
||||
"time": "2026-04-26T13:10:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-uuid",
|
||||
@@ -12194,16 +12190,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/process.git",
|
||||
"reference": "f5804be144caceb570f6747519999636b664f24c"
|
||||
"reference": "d9593c9efa40499eb078b81144de42cbc28a31f0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c",
|
||||
"reference": "f5804be144caceb570f6747519999636b664f24c",
|
||||
"url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0",
|
||||
"reference": "d9593c9efa40499eb078b81144de42cbc28a31f0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12235,7 +12231,7 @@
|
||||
"description": "Executes commands in sub-processes",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/process/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/process/tree/v7.4.11"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12255,7 +12251,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-23T16:05:06+00:00"
|
||||
"time": "2026-05-11T16:55:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/property-access",
|
||||
@@ -12518,16 +12514,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.12",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/routing.git",
|
||||
"reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d"
|
||||
"reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d",
|
||||
"reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
|
||||
"reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12579,7 +12575,7 @@
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/routing/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/routing/tree/v7.4.12"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12599,7 +12595,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T11:20:33+00:00"
|
||||
"time": "2026-05-20T07:20:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/serializer",
|
||||
@@ -12794,16 +12790,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/string",
|
||||
"version": "v7.4.13",
|
||||
"version": "v7.4.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/string.git",
|
||||
"reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde"
|
||||
"reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
|
||||
"reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
|
||||
"url": "https://api.github.com/repos/symfony/string/zipball/965f7306a43383d02c6aca1e3f3bd2f0ea5dee15",
|
||||
"reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12861,7 +12857,7 @@
|
||||
"utf8"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/string/tree/v7.4.13"
|
||||
"source": "https://github.com/symfony/string/tree/v7.4.11"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12881,7 +12877,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-23T15:23:29+00:00"
|
||||
"time": "2026-05-13T12:04:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation",
|
||||
@@ -13827,16 +13823,16 @@
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
|
||||
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
|
||||
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
|
||||
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -13887,9 +13883,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/webmozarts/assert/issues",
|
||||
"source": "https://github.com/webmozarts/assert/tree/2.4.1"
|
||||
"source": "https://github.com/webmozarts/assert/tree/2.4.0"
|
||||
},
|
||||
"time": "2026-06-15T15:31:57+00:00"
|
||||
"time": "2026-05-20T13:07:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "wikimedia/composer-merge-plugin",
|
||||
|
||||
69
e2e/reporting-tag-match.spec.ts
Normal file
69
e2e/reporting-tag-match.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
|
||||
import { createTimeEntryWithTagViaApi } from './utils/api';
|
||||
|
||||
// Each test registers a new user and creates test data via the API
|
||||
test.describe.configure({ timeout: 30000 });
|
||||
|
||||
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
|
||||
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
|
||||
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
||||
|
||||
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await Promise.all([
|
||||
waitForDetailedReportingUpdate(page),
|
||||
page.getByRole('option').filter({ hasText: tagA }).click(),
|
||||
]);
|
||||
await Promise.all([
|
||||
waitForDetailedReportingUpdate(page),
|
||||
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// The entry with tagA is excluded; the entry with tagB remains
|
||||
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
|
||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
|
||||
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
|
||||
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
|
||||
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
|
||||
|
||||
await goToReportingDetailed(page);
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await Promise.all([
|
||||
waitForDetailedReportingUpdate(page),
|
||||
page.getByRole('option').filter({ hasText: tagA }).click(),
|
||||
]);
|
||||
|
||||
// "Contains" tagA -> only the tagA entry is listed
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
|
||||
|
||||
// "Does Not Contain" tagA -> flips to the tagB entry
|
||||
await page.getByRole('button', { name: 'Tags' }).click();
|
||||
await Promise.all([
|
||||
waitForDetailedReportingUpdate(page),
|
||||
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
|
||||
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
|
||||
});
|
||||
@@ -5,7 +5,8 @@ declare(strict_types=1);
|
||||
return [
|
||||
'clockify_time_entries' => [
|
||||
'name' => 'Clockify Time Entries',
|
||||
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
|
||||
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'.
|
||||
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
|
||||
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
|
||||
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
|
||||
'4. Now select the date range that you want to export in the right top. '.
|
||||
@@ -61,7 +62,8 @@ return [
|
||||
],
|
||||
'harvest_time_entries' => [
|
||||
'name' => 'Harvest Time Entries',
|
||||
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
||||
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'.
|
||||
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
||||
'<br>3. Now click on "Export all time" '.
|
||||
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
||||
],
|
||||
|
||||
1621
package-lock.json
generated
1621
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -40,14 +40,14 @@
|
||||
"axios": "^1.16.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"happy-dom": "^20.8.9",
|
||||
"laravel-vite-plugin": "^2.1.0",
|
||||
"laravel-vite-plugin": "^3.1.0",
|
||||
"openapi-zod-client": "^1.18.3",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-nesting": "^12.1.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite": "^8.1.2",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vitest": "^4.1.4",
|
||||
"vue": "^3.5.34",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import { Check } from '@lucide/vue';
|
||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||
@@ -14,6 +16,7 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { useTagsQuery } from '@/utils/useTagsQuery';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
@@ -22,6 +25,7 @@ const selectedProjects = defineModel<string[]>('selectedProjects', { required: t
|
||||
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
|
||||
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
|
||||
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
|
||||
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
|
||||
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
|
||||
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
|
||||
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
|
||||
@@ -35,6 +39,16 @@ const emit = defineEmits<{
|
||||
|
||||
const { tags } = useTagsQuery();
|
||||
|
||||
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
|
||||
{ value: 'contains', label: 'Contains' },
|
||||
{ value: 'not_contains', label: 'Does Not Contain' },
|
||||
];
|
||||
|
||||
function selectTagMatchType(value: AcceptableValue) {
|
||||
tagMatchType.value = value as TagMatchType;
|
||||
emit('submit');
|
||||
}
|
||||
|
||||
async function createTag(name: string) {
|
||||
return await useTagsStore().createTag(name);
|
||||
}
|
||||
@@ -93,6 +107,34 @@ async function createTag(name: string) {
|
||||
title="Tags"
|
||||
:icon="TagIcon" />
|
||||
</template>
|
||||
<template #content-before-list>
|
||||
<div class="mt-2 border-b border-card-background-separator pb-2">
|
||||
<div
|
||||
id="tag-match-type-label"
|
||||
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
|
||||
Match
|
||||
</div>
|
||||
<RadioGroupRoot
|
||||
:model-value="tagMatchType"
|
||||
aria-labelledby="tag-match-type-label"
|
||||
class="space-y-1"
|
||||
@update:model-value="selectTagMatchType">
|
||||
<RadioGroupItem
|
||||
v-for="option in tagMatchOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
|
||||
{{ option.label }}
|
||||
<span
|
||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadioGroupIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
</RadioGroupIndicator>
|
||||
</span>
|
||||
</RadioGroupItem>
|
||||
</RadioGroupRoot>
|
||||
</div>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
|
||||
<Select v-model="billable" @update:model-value="emit('submit')">
|
||||
|
||||
@@ -49,6 +49,7 @@ import type { ExportFormat } from '@/types/reporting';
|
||||
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
|
||||
@@ -67,6 +68,7 @@ const selectedProjects = ref<string[]>([]);
|
||||
const selectedMembers = ref<string[]>([]);
|
||||
const selectedTasks = ref<string[]>([]);
|
||||
const selectedClients = ref<string[]>([]);
|
||||
const tagMatchType = ref<TagMatchType>('contains');
|
||||
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
@@ -122,6 +124,7 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
||||
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
||||
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
|
||||
billable: billable.value !== null ? billable.value : undefined,
|
||||
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
@@ -366,6 +369,7 @@ const tableData = computed(() => {
|
||||
v-model:selected-tasks="selectedTasks"
|
||||
v-model:selected-clients="selectedClients"
|
||||
v-model:selected-tags="selectedTags"
|
||||
v-model:tag-match-type="tagMatchType"
|
||||
v-model:billable="billable"
|
||||
v-model:rounding-enabled="roundingEnabled"
|
||||
v-model:rounding-type="roundingType"
|
||||
|
||||
@@ -67,6 +67,7 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
|
||||
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
|
||||
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import type { TagMatchType } from '@/types/reporting';
|
||||
|
||||
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
|
||||
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
||||
@@ -84,6 +85,7 @@ const selectedProjects = ref<string[]>([]);
|
||||
const selectedMembers = ref<string[]>([]);
|
||||
const selectedTasks = ref<string[]>([]);
|
||||
const selectedClients = ref<string[]>([]);
|
||||
const tagMatchType = ref<TagMatchType>('contains');
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
||||
@@ -115,6 +117,7 @@ function getFilterAttributes() {
|
||||
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
|
||||
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
|
||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
|
||||
billable: billable.value !== null ? billable.value : undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||
@@ -337,6 +340,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
v-model:selected-tasks="selectedTasks"
|
||||
v-model:selected-clients="selectedClients"
|
||||
v-model:selected-tags="selectedTags"
|
||||
v-model:tag-match-type="tagMatchType"
|
||||
v-model:billable="billable"
|
||||
v-model:rounding-enabled="roundingEnabled"
|
||||
v-model:rounding-type="roundingType"
|
||||
|
||||
@@ -448,6 +448,7 @@ const ReportStoreRequest = z
|
||||
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
tag_match_type: z.enum(['contains', 'not_contains']).optional(),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
|
||||
group: TimeEntryAggregationType,
|
||||
sub_group: TimeEntryAggregationType,
|
||||
@@ -481,6 +482,7 @@ const DetailedReportResource = z
|
||||
client_ids: z.union([z.array(z.string()), z.null()]),
|
||||
project_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_ids: z.union([z.array(z.string()), z.null()]),
|
||||
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
|
||||
task_ids: z.union([z.array(z.string()), z.null()]),
|
||||
rounding_type: z.union([z.string(), z.null()]),
|
||||
rounding_minutes: z.union([z.number(), z.null()]),
|
||||
@@ -3784,6 +3786,11 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
@@ -4165,6 +4172,11 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
@@ -4359,6 +4371,11 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
@@ -4487,6 +4504,11 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
type: 'Query',
|
||||
schema: z.array(z.string()).min(1).optional(),
|
||||
},
|
||||
{
|
||||
name: 'tag_match_type',
|
||||
type: 'Query',
|
||||
schema: z.enum(['contains', 'not_contains']).optional(),
|
||||
},
|
||||
{
|
||||
name: 'task_ids',
|
||||
type: 'Query',
|
||||
|
||||
@@ -114,6 +114,7 @@ const showCreateTagModal = ref(false);
|
||||
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||
placeholder="Search for a Tag..." />
|
||||
</ComboboxAnchor>
|
||||
<slot name="content-before-list"></slot>
|
||||
<ComboboxContent
|
||||
:dismiss-able="false"
|
||||
position="inline"
|
||||
|
||||
@@ -199,7 +199,7 @@ body {
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--color-bg-tertiary);
|
||||
--accent-foreground: var(--color-text-primary);
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive: 0 72% 60%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
|
||||
export type TagMatchType = 'contains' | 'not_contains';
|
||||
|
||||
3
resources/testfiles/clockify_projects_import_test_2.csv
Normal file
3
resources/testfiles/clockify_projects_import_test_2.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
"Project","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
|
||||
"Active Project","Big Company","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","","","","","","0.00","0.00","100.01","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
|
||||
"Archived Project","","Archived","Public","Yes","","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
|
||||
|
2
resources/testfiles/clockify_projects_import_test_3.csv
Normal file
2
resources/testfiles/clockify_projects_import_test_3.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
"Project","Client","Status","Visibility","Billability","Activities","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
|
||||
"Project With Activities","","Active","Public","Yes","Activity A, Activity B","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","","",""
|
||||
|
@@ -0,0 +1,3 @@
|
||||
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
||||
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
|
||||
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
|
||||
|
@@ -0,0 +1,3 @@
|
||||
"Project","Client","Description","Activity","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
||||
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
|
||||
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
|
||||
|
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1\Public;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\Weekday;
|
||||
@@ -667,4 +668,58 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_show_applies_not_contains_tag_match_type(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$tagA = Tag::factory()->forOrganization($organization)->create();
|
||||
$tagB = Tag::factory()->forOrganization($organization)->create();
|
||||
|
||||
// Entry with tagA (should be excluded by "does not contain tagA")
|
||||
TimeEntry::factory()->forOrganization($organization)
|
||||
->startWithDuration(now()->subDay(), 100)
|
||||
->create([
|
||||
'tags' => [$tagA->getKey()],
|
||||
]);
|
||||
// Entry with a different tag (should be included)
|
||||
TimeEntry::factory()->forOrganization($organization)
|
||||
->startWithDuration(now()->subDay(), 200)
|
||||
->create([
|
||||
'tags' => [$tagB->getKey()],
|
||||
]);
|
||||
// Entry without tags (should be included)
|
||||
TimeEntry::factory()->forOrganization($organization)
|
||||
->startWithDuration(now()->subDay(), 50)
|
||||
->create();
|
||||
|
||||
$reportDto = new ReportPropertiesDto;
|
||||
$reportDto->start = now()->subDays(2);
|
||||
$reportDto->end = now();
|
||||
$reportDto->group = TimeEntryAggregationType::Project;
|
||||
$reportDto->subGroup = TimeEntryAggregationType::Task;
|
||||
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
|
||||
$reportDto->weekStart = Weekday::Monday;
|
||||
$reportDto->timezone = 'Europe/Vienna';
|
||||
$reportDto->setTagIds([$tagA->getKey()]);
|
||||
$reportDto->setTagMatchType(TagMatchType::NotContains);
|
||||
$report = Report::factory()->forOrganization($organization)->public()->create([
|
||||
'public_until' => null,
|
||||
'properties' => $reportDto,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.public.reports.show'), [
|
||||
'X-Api-Key' => $report->share_secret,
|
||||
]);
|
||||
|
||||
// Assert: tagA entry (100s) excluded; tagB (200s) + untagged (50s) included
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'data' => [
|
||||
'seconds' => 250,
|
||||
'grouped_type' => TimeEntryAggregationType::Project->value,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
@@ -685,4 +686,64 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
|
||||
'id' => $report->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_persists_tag_match_type(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'reports:create',
|
||||
]);
|
||||
$tag = Tag::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
|
||||
'name' => 'Report with tag match type',
|
||||
'is_public' => false,
|
||||
'properties' => [
|
||||
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||
'group' => TimeEntryAggregationType::Project->value,
|
||||
'sub_group' => TimeEntryAggregationType::Task->value,
|
||||
'history_group' => TimeEntryAggregationType::Day->value,
|
||||
'tag_ids' => [$tag->getKey()],
|
||||
'tag_match_type' => TagMatchType::NotContains->value,
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
/** @var Report $report */
|
||||
$report = Report::query()->findOrFail($response->json('data.id'));
|
||||
$this->assertSame(TagMatchType::NotContains, $report->properties->tagMatchType);
|
||||
// DetailedReportResource exposes the match type in the response
|
||||
$response->assertJsonPath('data.properties.tag_match_type', TagMatchType::NotContains->value);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_rejects_invalid_tag_match_type(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'reports:create',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
|
||||
'name' => 'Report with invalid tag match type',
|
||||
'is_public' => false,
|
||||
'properties' => [
|
||||
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||
'group' => TimeEntryAggregationType::Project->value,
|
||||
'sub_group' => TimeEntryAggregationType::Task->value,
|
||||
'history_group' => TimeEntryAggregationType::Day->value,
|
||||
'tag_match_type' => 'invalid_value',
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertInvalid(['properties.tag_match_type']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
@@ -4351,4 +4352,153 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey());
|
||||
}
|
||||
|
||||
public function test_index_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
$tag = Tag::factory()->forOrganization($data->organization)->create();
|
||||
$timeEntryWithTag = TimeEntry::factory()
|
||||
->forOrganization($data->organization)
|
||||
->forMember($data->member)
|
||||
->create([
|
||||
'start' => Carbon::now()->subHour(),
|
||||
'tags' => [$tag->getKey()],
|
||||
]);
|
||||
$timeEntryWithEmptyTags = TimeEntry::factory()
|
||||
->forOrganization($data->organization)
|
||||
->forMember($data->member)
|
||||
->create([
|
||||
'start' => Carbon::now()->subHour(),
|
||||
'tags' => [],
|
||||
]);
|
||||
$timeEntryWithNullTags = TimeEntry::factory()
|
||||
->forOrganization($data->organization)
|
||||
->forMember($data->member)
|
||||
->create([
|
||||
'start' => Carbon::now()->subHour(),
|
||||
'tags' => null,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
||||
$data->organization->getKey(),
|
||||
'tag_ids' => [$tag->getKey()],
|
||||
'tag_match_type' => TagMatchType::NotContains->value,
|
||||
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert: the tagged entry is excluded; the untagged (empty + null) entries remain
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertJsonCount(2, 'data');
|
||||
$returnedIds = collect($response->json('data'))->pluck('id');
|
||||
$this->assertTrue($returnedIds->contains($timeEntryWithEmptyTags->getKey()));
|
||||
$this->assertTrue($returnedIds->contains($timeEntryWithNullTags->getKey()));
|
||||
$this->assertFalse($returnedIds->contains($timeEntryWithTag->getKey()));
|
||||
}
|
||||
|
||||
public function test_index_endpoint_with_contains_tag_match_type_returns_only_entries_with_tag(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
$tag = Tag::factory()->forOrganization($data->organization)->create();
|
||||
$timeEntryWithTag = TimeEntry::factory()
|
||||
->forOrganization($data->organization)
|
||||
->forMember($data->member)
|
||||
->create([
|
||||
'start' => Carbon::now()->subHour(),
|
||||
'tags' => [$tag->getKey()],
|
||||
]);
|
||||
TimeEntry::factory()
|
||||
->forOrganization($data->organization)
|
||||
->forMember($data->member)
|
||||
->create([
|
||||
'start' => Carbon::now()->subHour(),
|
||||
'tags' => [],
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
||||
$data->organization->getKey(),
|
||||
'tag_ids' => [$tag->getKey()],
|
||||
'tag_match_type' => TagMatchType::Contains->value,
|
||||
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert: only the entry that has the tag
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $timeEntryWithTag->getKey());
|
||||
}
|
||||
|
||||
public function test_index_endpoint_rejects_invalid_tag_match_type(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
$tag = Tag::factory()->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
||||
$data->organization->getKey(),
|
||||
'tag_ids' => [$tag->getKey()],
|
||||
'tag_match_type' => 'invalid_value',
|
||||
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 422);
|
||||
$response->assertInvalid(['tag_match_type']);
|
||||
}
|
||||
|
||||
public function test_aggregate_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
$tag = Tag::factory()->forOrganization($data->organization)->create();
|
||||
TimeEntry::factory()
|
||||
->forOrganization($data->organization)
|
||||
->forMember($data->member)
|
||||
->startWithDuration(Carbon::now()->subHour(), 100)
|
||||
->create([
|
||||
'tags' => [$tag->getKey()],
|
||||
]);
|
||||
TimeEntry::factory()
|
||||
->forOrganization($data->organization)
|
||||
->forMember($data->member)
|
||||
->startWithDuration(Carbon::now()->subHour(), 200)
|
||||
->create([
|
||||
'tags' => [],
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||
$data->organization->getKey(),
|
||||
'tag_ids' => [$tag->getKey()],
|
||||
'tag_match_type' => TagMatchType::NotContains->value,
|
||||
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert: only the untagged entry (200s) is aggregated
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertJsonPath('data.seconds', 200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Service\Import\Importers\ClockifyProjectsImporter;
|
||||
use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
@@ -50,4 +52,68 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
||||
// Assert
|
||||
$this->checkTestScenarioProjectsOnlyAfterImport();
|
||||
}
|
||||
|
||||
public function test_import_sets_archived_at_based_on_status_column(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyProjectsImporter;
|
||||
$importer->init($organization);
|
||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
|
||||
// Assert
|
||||
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
|
||||
$this->assertNull($activeProject->archived_at);
|
||||
$this->assertFalse($activeProject->is_archived);
|
||||
|
||||
$archivedProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Archived Project')->firstOrFail();
|
||||
$this->assertNotNull($archivedProject->archived_at);
|
||||
$this->assertTrue($archivedProject->is_archived);
|
||||
}
|
||||
|
||||
public function test_import_supports_renamed_tasks_column(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyProjectsImporter;
|
||||
$importer->init($organization);
|
||||
// Newer Clockify exports rename the "Task" column to "Tasks".
|
||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
|
||||
// Assert
|
||||
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
|
||||
$this->assertEqualsCanonicalizing(
|
||||
['Task 1', 'Task 2'],
|
||||
Task::query()->where('project_id', $activeProject->id)->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_import_supports_activities_column_alias_for_tasks(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyProjectsImporter;
|
||||
$importer->init($organization);
|
||||
// Some Clockify exports name the tasks column "Activities".
|
||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_3.csv');
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
|
||||
// Assert
|
||||
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project With Activities')->firstOrFail();
|
||||
$this->assertEqualsCanonicalizing(
|
||||
['Activity A', 'Activity B'],
|
||||
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,30 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
|
||||
$this->checkTimeEntries($testScenario, false, true);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_with_special_characters_description_succeeds(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -93,6 +117,25 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(0, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_supports_activity_column_alias_for_task(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
// Some Clockify exports name the task column "Activity".
|
||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_5.csv');
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
}
|
||||
|
||||
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
|
||||
/**
|
||||
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
|
||||
*/
|
||||
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object
|
||||
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object
|
||||
{
|
||||
$users = User::all();
|
||||
$this->assertCount(2, $users);
|
||||
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
|
||||
$this->assertSame('#ef5350', $project1->color);
|
||||
$this->assertSame(null, $project1->billable_rate);
|
||||
// Project for Big Company
|
||||
$this->assertSame(true, $project2->is_billable);
|
||||
$this->assertSame(! $billableDefault, $project2->is_billable);
|
||||
$this->assertSame(false, $project2->is_public);
|
||||
$this->assertSame('#ec407a', $project2->color);
|
||||
$this->assertSame(10001, $project2->billable_rate);
|
||||
// Project (Archived)
|
||||
$this->assertSame(true, $project3->is_billable);
|
||||
$this->assertSame(! $billableDefault, $project3->is_billable);
|
||||
$this->assertSame(true, $project3->is_public);
|
||||
$this->assertSame('#6a407f', $project3->color);
|
||||
$this->assertSame(null, $project3->billable_rate);
|
||||
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
|
||||
/**
|
||||
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
|
||||
*/
|
||||
protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void
|
||||
protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void
|
||||
{
|
||||
$timeEntries = TimeEntry::all();
|
||||
if ($secondRun) {
|
||||
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertSame(! $billableDefault, $timeEntry2->billable);
|
||||
$this->assertTrue($timeEntry2->is_imported);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service;
|
||||
|
||||
use App\Enums\TagMatchType;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
@@ -250,4 +251,188 @@ class TimeEntryFilterTest extends TestCaseWithDatabase
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithNoTags));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
|
||||
}
|
||||
|
||||
public function test_add_tag_ids_filter_not_contains_includes_entries_without_matching_tag(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag1 = Tag::factory()->create();
|
||||
$tag2 = Tag::factory()->create();
|
||||
$timeEntryWithTag1 = TimeEntry::factory()->create([
|
||||
'tags' => [$tag1->getKey()],
|
||||
]);
|
||||
$timeEntryWithTag2 = TimeEntry::factory()->create([
|
||||
'tags' => [$tag2->getKey()],
|
||||
]);
|
||||
$timeEntryWithAllTags = TimeEntry::factory()->create([
|
||||
'tags' => [$tag1->getKey(), $tag2->getKey()],
|
||||
]);
|
||||
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
|
||||
'tags' => [],
|
||||
]);
|
||||
$timeEntryWithNullTags = TimeEntry::factory()->create([
|
||||
'tags' => null,
|
||||
]);
|
||||
|
||||
$builder = TimeEntry::query();
|
||||
$filter = new TimeEntryFilter($builder);
|
||||
|
||||
// Act
|
||||
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::NotContains);
|
||||
|
||||
// Assert
|
||||
$timeEntries = $builder->get();
|
||||
$this->assertCount(3, $timeEntries);
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithAllTags));
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags));
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithNullTags));
|
||||
}
|
||||
|
||||
public function test_add_tag_ids_filter_not_contains_with_none_excludes_entries_without_tags(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag = Tag::factory()->create();
|
||||
$timeEntryWithTag = TimeEntry::factory()->create([
|
||||
'tags' => [$tag->getKey()],
|
||||
]);
|
||||
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
|
||||
'tags' => [],
|
||||
]);
|
||||
$timeEntryWithNullTags = TimeEntry::factory()->create([
|
||||
'tags' => null,
|
||||
]);
|
||||
|
||||
$builder = TimeEntry::query();
|
||||
$filter = new TimeEntryFilter($builder);
|
||||
|
||||
// Act
|
||||
$filter->addTagIdsFilter([TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
|
||||
|
||||
// Assert
|
||||
$timeEntries = $builder->get();
|
||||
$this->assertCount(1, $timeEntries);
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
|
||||
}
|
||||
|
||||
public function test_add_tag_ids_filter_not_contains_with_multiple_tags_excludes_entries_with_any_of_them(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag1 = Tag::factory()->create();
|
||||
$tag2 = Tag::factory()->create();
|
||||
$tag3 = Tag::factory()->create();
|
||||
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
|
||||
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
|
||||
$timeEntryWithTag3 = TimeEntry::factory()->create(['tags' => [$tag3->getKey()]]);
|
||||
// a filtered tag (tag1) mixed with an unrelated one (tag3): still excluded
|
||||
$timeEntryWithTag1AndTag3 = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag3->getKey()]]);
|
||||
$timeEntryWithoutTags = TimeEntry::factory()->create(['tags' => null]);
|
||||
|
||||
$builder = TimeEntry::query();
|
||||
$filter = new TimeEntryFilter($builder);
|
||||
|
||||
// Act: "does not contain tag1 or tag2" (NOT (has tag1 OR has tag2))
|
||||
$filter->addTagIdsFilter([$tag1->getKey(), $tag2->getKey()], TagMatchType::NotContains);
|
||||
|
||||
// Assert: only entries that have neither tag1 nor tag2 remain
|
||||
$timeEntries = $builder->get();
|
||||
$this->assertCount(2, $timeEntries);
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithTag3));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithTag1AndTag3));
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithoutTags));
|
||||
}
|
||||
|
||||
public function test_add_tag_ids_filter_contains_mode_returns_only_entries_with_tag(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag1 = Tag::factory()->create();
|
||||
$tag2 = Tag::factory()->create();
|
||||
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
|
||||
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
|
||||
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
|
||||
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
|
||||
|
||||
$builder = TimeEntry::query();
|
||||
$filter = new TimeEntryFilter($builder);
|
||||
|
||||
// Act: explicit contains mode
|
||||
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::Contains);
|
||||
|
||||
// Assert: only the entry that has tag1
|
||||
$timeEntries = $builder->get();
|
||||
$this->assertCount(1, $timeEntries);
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithTag1));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
|
||||
}
|
||||
|
||||
public function test_add_tag_ids_filter_not_contains_with_none_and_tag_excludes_tagged_and_untagged(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag1 = Tag::factory()->create();
|
||||
$tag2 = Tag::factory()->create();
|
||||
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
|
||||
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
|
||||
$timeEntryWithBothTags = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag2->getKey()]]);
|
||||
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
|
||||
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
|
||||
|
||||
$builder = TimeEntry::query();
|
||||
$filter = new TimeEntryFilter($builder);
|
||||
|
||||
// Act: NOT (has tag1 OR has no tags) => has at least one tag and not tag1
|
||||
$filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
|
||||
|
||||
// Assert
|
||||
$timeEntries = $builder->get();
|
||||
$this->assertCount(1, $timeEntries);
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithBothTags));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
|
||||
}
|
||||
|
||||
public function test_add_tag_ids_filter_with_empty_array_applies_no_filter(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag = Tag::factory()->create();
|
||||
TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
|
||||
TimeEntry::factory()->create(['tags' => []]);
|
||||
TimeEntry::factory()->create(['tags' => null]);
|
||||
|
||||
// Act + Assert: an empty selection is no constraint in either mode
|
||||
$builderNotContains = TimeEntry::query();
|
||||
(new TimeEntryFilter($builderNotContains))->addTagIdsFilter([], TagMatchType::NotContains);
|
||||
$this->assertCount(3, $builderNotContains->get());
|
||||
|
||||
$builderContains = TimeEntry::query();
|
||||
(new TimeEntryFilter($builderContains))->addTagIdsFilter([], TagMatchType::Contains);
|
||||
$this->assertCount(3, $builderContains->get());
|
||||
}
|
||||
|
||||
public function test_add_tag_ids_filter_with_null_match_type_defaults_to_contains(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag = Tag::factory()->create();
|
||||
$timeEntryWithTag = TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
|
||||
$timeEntryWithoutTag = TimeEntry::factory()->create(['tags' => null]);
|
||||
|
||||
$builder = TimeEntry::query();
|
||||
$filter = new TimeEntryFilter($builder);
|
||||
|
||||
// Act: a null match type falls back to "contains"
|
||||
$filter->addTagIdsFilter([$tag->getKey()], null);
|
||||
|
||||
// Assert
|
||||
$timeEntries = $builder->get();
|
||||
$this->assertCount(1, $timeEntries);
|
||||
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
|
||||
$this->assertFalse($timeEntries->contains($timeEntryWithoutTag));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ async function getConfig() {
|
||||
const additionalPlugins = await collectModulePlugins('extensions');
|
||||
|
||||
return defineConfig({
|
||||
base: './',
|
||||
build: {
|
||||
sourcemap: true, // Source map generation must be turned on
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user