mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Added more imports
This commit is contained in:
@@ -8,6 +8,7 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Contracts\Validation\Rule;
|
use Illuminate\Contracts\Validation\Rule;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
@@ -59,7 +60,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules for adding a team member.
|
* Get the validation rules for adding a team member.
|
||||||
*
|
*
|
||||||
* @return array<string, array<Rule|string>>
|
* @return array<string, array<ValidationRule|Rule|string>>
|
||||||
*/
|
*/
|
||||||
protected function rules(): array
|
protected function rules(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
|||||||
|
|
||||||
InvitingTeamMember::dispatch($organization, $email, $role);
|
InvitingTeamMember::dispatch($organization, $email, $role);
|
||||||
|
|
||||||
/** @var TeamInvitation $invitation */
|
/** @var OrganizationInvitation $invitation */
|
||||||
$invitation = $organization->teamInvitations()->create([
|
$invitation = $organization->teamInvitations()->create([
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use LogicException;
|
|||||||
|
|
||||||
abstract class ApiException extends Exception
|
abstract class ApiException extends Exception
|
||||||
{
|
{
|
||||||
|
public const string KEY = 'api_exception';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the exception into an HTTP response.
|
* Render the exception into an HTTP response.
|
||||||
*/
|
*/
|
||||||
@@ -29,11 +31,13 @@ abstract class ApiException extends Exception
|
|||||||
*/
|
*/
|
||||||
public function getKey(): string
|
public function getKey(): string
|
||||||
{
|
{
|
||||||
if (defined(static::class.'::KEY')) {
|
$key = static::KEY;
|
||||||
return static::KEY;
|
|
||||||
|
if ($key === ApiException::KEY) {
|
||||||
|
throw new LogicException('API exceptions need the KEY constant defined.');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new LogicException('API exceptions need the KEY constant defined.');
|
return $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ namespace App\Exceptions\Api;
|
|||||||
|
|
||||||
class TimeEntryStillRunningApiException extends ApiException
|
class TimeEntryStillRunningApiException extends ApiException
|
||||||
{
|
{
|
||||||
const string KEY = 'time_entry_still_running';
|
public const string KEY = 'time_entry_still_running';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ namespace App\Exceptions\Api;
|
|||||||
|
|
||||||
class UserNotPlaceholderApiException extends ApiException
|
class UserNotPlaceholderApiException extends ApiException
|
||||||
{
|
{
|
||||||
const string KEY = 'user_not_placeholder';
|
public const string KEY = 'user_not_placeholder';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class OrganizationResource extends Resource
|
|||||||
// TODO: different disk!
|
// TODO: different disk!
|
||||||
try {
|
try {
|
||||||
/** @var ReportDto $report */
|
/** @var ReportDto $report */
|
||||||
$report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file']), []);
|
$report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file']));
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Import successful')
|
->title('Import successful')
|
||||||
->success()
|
->success()
|
||||||
@@ -98,9 +98,10 @@ class OrganizationResource extends Resource
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->tooltip(fn (Organization $record): string => "Import into {$record->name}")
|
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\FileUpload::make('file')
|
Forms\Components\FileUpload::make('file')
|
||||||
|
// TODO: disk!
|
||||||
->label('File')
|
->label('File')
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('type')
|
Select::make('type')
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Resources\TaskResource\Pages;
|
use App\Filament\Resources\TaskResource\Pages;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
class TaskResource extends Resource
|
class TaskResource extends Resource
|
||||||
@@ -25,7 +28,18 @@ class TaskResource extends Resource
|
|||||||
{
|
{
|
||||||
return $form
|
return $form
|
||||||
->schema([
|
->schema([
|
||||||
//
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Select::make('project_id')
|
||||||
|
->relationship(name: 'project', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->required(),
|
||||||
|
Select::make('organization_id')
|
||||||
|
->relationship(name: 'organization', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->required(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +60,9 @@ class TaskResource extends Resource
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
//
|
SelectFilter::make('organization')
|
||||||
|
->relationship('organization', 'name')
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->actions([
|
->actions([
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Filament\Forms\Form;
|
|||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
class TimeEntryResource extends Resource
|
class TimeEntryResource extends Resource
|
||||||
@@ -67,6 +68,7 @@ class TimeEntryResource extends Resource
|
|||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('description')
|
TextColumn::make('description')
|
||||||
|
->searchable()
|
||||||
->label('Description'),
|
->label('Description'),
|
||||||
TextColumn::make('user.email')
|
TextColumn::make('user.email')
|
||||||
->label('User'),
|
->label('User'),
|
||||||
@@ -89,7 +91,9 @@ class TimeEntryResource extends Resource
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
//
|
SelectFilter::make('organization')
|
||||||
|
->relationship('organization', 'name')
|
||||||
|
->searchable(),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->actions([
|
->actions([
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ class ImportController extends Controller
|
|||||||
$report = $importService->import(
|
$report = $importService->import(
|
||||||
$organization,
|
$organization,
|
||||||
$request->input('type'),
|
$request->input('type'),
|
||||||
$request->input('data'),
|
$request->input('data')
|
||||||
$request->input('options')
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project;
|
|||||||
|
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Rules\ColorRule;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@@ -35,6 +36,7 @@ class ProjectStoreRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'max:255',
|
'max:255',
|
||||||
|
new ColorRule(),
|
||||||
],
|
],
|
||||||
'client_id' => [
|
'client_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project;
|
|||||||
|
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Rules\ColorRule;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@@ -34,6 +35,7 @@ class ProjectUpdateRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'max:255',
|
'max:255',
|
||||||
|
new ColorRule(),
|
||||||
],
|
],
|
||||||
'client_id' => [
|
'client_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
'uuid',
|
'uuid',
|
||||||
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->whereHas('organizations', function (Builder $builder) {
|
return $builder->belongsToOrganization($this->organization);
|
||||||
/** @var Builder<Organization> $builder */
|
|
||||||
return $builder->whereKey($this->organization->getKey());
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31)
|
// Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31)
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ class TimeEntryStoreRequest extends FormRequest
|
|||||||
'uuid',
|
'uuid',
|
||||||
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->whereHas('organizations', function (Builder $builder) {
|
return $builder->belongsToOrganization($this->organization);
|
||||||
/** @var Builder<Organization> $builder */
|
|
||||||
return $builder->whereKey($this->organization->getKey());
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Laravel\Jetstream\Events\TeamCreated;
|
use Laravel\Jetstream\Events\TeamCreated;
|
||||||
use Laravel\Jetstream\Events\TeamDeleted;
|
use Laravel\Jetstream\Events\TeamDeleted;
|
||||||
use Laravel\Jetstream\Events\TeamUpdated;
|
use Laravel\Jetstream\Events\TeamUpdated;
|
||||||
|
use Laravel\Jetstream\Jetstream;
|
||||||
use Laravel\Jetstream\Team as JetstreamTeam;
|
use Laravel\Jetstream\Team as JetstreamTeam;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +22,7 @@ use Laravel\Jetstream\Team as JetstreamTeam;
|
|||||||
* @property bool $personal_team
|
* @property bool $personal_team
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Collection<User> $users
|
* @property Collection<User> $users
|
||||||
|
* @property Collection<string, User> $realUsers
|
||||||
*
|
*
|
||||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||||
* @method static OrganizationFactory factory()
|
* @method static OrganizationFactory factory()
|
||||||
@@ -64,7 +66,7 @@ class Organization extends JetstreamTeam
|
|||||||
/**
|
/**
|
||||||
* Get all the non-placeholder users of the organization including its owner.
|
* Get all the non-placeholder users of the organization including its owner.
|
||||||
*
|
*
|
||||||
* @return Collection<User>
|
* @return Collection<string, User>
|
||||||
*/
|
*/
|
||||||
public function allRealUsers(): Collection
|
public function allRealUsers(): Collection
|
||||||
{
|
{
|
||||||
@@ -78,6 +80,19 @@ class Organization extends JetstreamTeam
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the users that belong to the team.
|
||||||
|
*
|
||||||
|
* @return BelongsToMany<User>
|
||||||
|
*/
|
||||||
|
public function users(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
|
||||||
|
->withPivot('role')
|
||||||
|
->withTimestamps()
|
||||||
|
->as('membership');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsToMany<User>
|
* @return BelongsToMany<User>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
Model::preventLazyLoading(! $this->app->isProduction());
|
Model::preventLazyLoading(! $this->app->isProduction());
|
||||||
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
|
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
|
||||||
|
Model::preventAccessingMissingAttributes(! $this->app->isProduction());
|
||||||
Relation::enforceMorphMap([
|
Relation::enforceMorphMap([
|
||||||
'membership' => Membership::class,
|
'membership' => Membership::class,
|
||||||
'organization' => Organization::class,
|
'organization' => Organization::class,
|
||||||
|
|||||||
@@ -109,5 +109,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'time-entries:delete:own',
|
'time-entries:delete:own',
|
||||||
'organizations:view',
|
'organizations:view',
|
||||||
])->description('Editor users have the ability to read, create, and update.');
|
])->description('Editor users have the ability to read, create, and update.');
|
||||||
|
|
||||||
|
Jetstream::role('placeholder', 'Placeholder', [
|
||||||
|
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Rules/ColorRule.php
Normal file
32
app/Rules/ColorRule.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Service\ColorService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||||
|
|
||||||
|
class ColorRule implements ValidationRule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the validation rule.
|
||||||
|
*
|
||||||
|
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||||
|
*/
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
$fail(__('validation.string'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! app(ColorService::class)->isValid($value)) {
|
||||||
|
$fail(__('validation.color'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,15 @@ class ColorService
|
|||||||
'#78909c',
|
'#78909c',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
|
||||||
|
|
||||||
public function getRandomColor(): string
|
public function getRandomColor(): string
|
||||||
{
|
{
|
||||||
return self::COLORS[array_rand(self::COLORS)];
|
return self::COLORS[array_rand(self::COLORS)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isValid(string $color): bool
|
||||||
|
{
|
||||||
|
return preg_match(self::VALID_REGEX, $color) === 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service\Import;
|
namespace App\Service\Import;
|
||||||
|
|
||||||
|
use App\Service\Import\Importers\ImportException;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @template TModel of Model
|
* @template TModel of Model
|
||||||
@@ -23,9 +25,15 @@ class ImportDatabaseHelper
|
|||||||
*/
|
*/
|
||||||
private array $identifiers;
|
private array $identifiers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>|null
|
||||||
|
*/
|
||||||
private ?array $mapIdentifierToKey = null;
|
private ?array $mapIdentifierToKey = null;
|
||||||
|
|
||||||
private array $mapNewAttach = [];
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private array $mapExternalIdentifierToInternalIdentifier = [];
|
||||||
|
|
||||||
private bool $attachToExisting;
|
private bool $attachToExisting;
|
||||||
|
|
||||||
@@ -57,7 +65,11 @@ class ImportDatabaseHelper
|
|||||||
return (new $this->model)->query();
|
return (new $this->model)->query();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createEntity(array $identifierData, array $createValues): string
|
/**
|
||||||
|
* @param array<string, mixed> $identifierData
|
||||||
|
* @param array<string, mixed> $createValues
|
||||||
|
*/
|
||||||
|
private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string
|
||||||
{
|
{
|
||||||
$model = new $this->model();
|
$model = new $this->model();
|
||||||
foreach ($identifierData as $identifier => $identifierValue) {
|
foreach ($identifierData as $identifier => $identifierValue) {
|
||||||
@@ -72,34 +84,97 @@ class ImportDatabaseHelper
|
|||||||
($this->afterCreate)($model);
|
($this->afterCreate)($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mapIdentifierToKey[$this->getHash($identifierData)] = $model->getKey();
|
$hash = $this->getHash($identifierData);
|
||||||
|
$this->mapIdentifierToKey[$hash] = $model->getKey();
|
||||||
$this->createdCount++;
|
$this->createdCount++;
|
||||||
|
|
||||||
|
if ($externalIdentifier !== null) {
|
||||||
|
$this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;
|
||||||
|
}
|
||||||
|
|
||||||
return $model->getKey();
|
return $model->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
private function getHash(array $data): string
|
private function getHash(array $data): string
|
||||||
{
|
{
|
||||||
return md5(json_encode($data));
|
$jsonData = json_encode($data);
|
||||||
|
if ($jsonData === false) {
|
||||||
|
throw new \RuntimeException('Failed to encode data to JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
return md5($jsonData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getKey(array $identifierData, array $createValues = []): string
|
/**
|
||||||
|
* @param array<string, mixed> $identifierData
|
||||||
|
* @param array<string, mixed> $createValues
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
public function getKey(array $identifierData, array $createValues = [], ?string $externalIdentifier = null): string
|
||||||
{
|
{
|
||||||
$this->checkMap();
|
$this->checkMap();
|
||||||
|
|
||||||
|
$this->validateIdentifierData($identifierData);
|
||||||
|
|
||||||
$hash = $this->getHash($identifierData);
|
$hash = $this->getHash($identifierData);
|
||||||
if ($this->attachToExisting) {
|
if ($this->attachToExisting) {
|
||||||
$key = $this->mapIdentifierToKey[$hash] ?? null;
|
$key = $this->mapIdentifierToKey[$hash] ?? null;
|
||||||
if ($key !== null) {
|
if ($key !== null) {
|
||||||
|
if ($externalIdentifier !== null) {
|
||||||
|
$this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;
|
||||||
|
}
|
||||||
|
Log::debug('HIT', [
|
||||||
|
'class' => $this->model,
|
||||||
|
]);
|
||||||
|
|
||||||
return $key;
|
return $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->createEntity($identifierData, $createValues);
|
Log::debug('MISS', [
|
||||||
|
'class' => $this->model,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->createEntity($identifierData, $createValues, $externalIdentifier);
|
||||||
} else {
|
} else {
|
||||||
throw new \RuntimeException('Not implemented');
|
throw new \RuntimeException('Not implemented');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $identifierData
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function validateIdentifierData(array $identifierData): void
|
||||||
|
{
|
||||||
|
if (array_keys($identifierData) !== $this->identifiers) {
|
||||||
|
throw new ImportException('Invalid identifier data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKeyByExternalIdentifier(string $externalIdentifier): ?string
|
||||||
|
{
|
||||||
|
$hash = $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] ?? null;
|
||||||
|
if ($hash === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mapIdentifierToKey[$hash] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getExternalIds(): array
|
||||||
|
{
|
||||||
|
// Note: Otherwise the external ids are integers
|
||||||
|
return array_map(fn ($value) => (string) $value, array_keys($this->mapExternalIdentifierToInternalIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
private function checkMap(): void
|
private function checkMap(): void
|
||||||
{
|
{
|
||||||
if ($this->mapIdentifierToKey === null) {
|
if ($this->mapIdentifierToKey === null) {
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ class ImportService
|
|||||||
/**
|
/**
|
||||||
* @throws ImportException
|
* @throws ImportException
|
||||||
*/
|
*/
|
||||||
public function import(Organization $organization, string $importerType, string $data, array $options): ReportDto
|
public function import(Organization $organization, string $importerType, string $data): ReportDto
|
||||||
{
|
{
|
||||||
/** @var ImporterContract $importer */
|
/** @var ImporterContract $importer */
|
||||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
DB::transaction(function () use (&$importer, &$data, &$options, &$organization) {
|
DB::transaction(function () use (&$importer, &$data) {
|
||||||
$importer->importData($data, $options);
|
$importer->importData($data);
|
||||||
});
|
});
|
||||||
|
|
||||||
return $importer->getReport();
|
return $importer->getReport();
|
||||||
|
|||||||
143
app/Service/Import/Importers/ClockifyProjectsImporter.php
Normal file
143
app/Service/Import/Importers/ClockifyProjectsImporter.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Service\ColorService;
|
||||||
|
use App\Service\Import\ImportDatabaseHelper;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use League\Csv\Exception as CsvException;
|
||||||
|
use League\Csv\Reader;
|
||||||
|
|
||||||
|
class ClockifyProjectsImporter implements ImporterContract
|
||||||
|
{
|
||||||
|
private Organization $organization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Project>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $projectImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Client>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $clientImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Task>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $taskImportHelper;
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function init(Organization $organization): void
|
||||||
|
{
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
#[\Override]
|
||||||
|
public function importData(string $data): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$colorService = app(ColorService::class);
|
||||||
|
$reader = Reader::createFromString($data);
|
||||||
|
$reader->setHeaderOffset(0);
|
||||||
|
$reader->setDelimiter(',');
|
||||||
|
$header = $reader->getHeader();
|
||||||
|
$this->validateHeader($header);
|
||||||
|
$records = $reader->getRecords();
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$clientId = null;
|
||||||
|
if ($record['Client'] !== '') {
|
||||||
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
|
'name' => $record['Client'],
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$projectId = null;
|
||||||
|
if ($record['Name'] !== '') {
|
||||||
|
$projectId = $this->projectImportHelper->getKey([
|
||||||
|
'name' => $record['Name'],
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
], [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'color' => $colorService->getRandomColor(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record['Tasks'] !== '') {
|
||||||
|
$tasks = explode(', ', $record['Tasks']);
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
if (strlen($task) > 255) {
|
||||||
|
throw new ImportException('Task is too long');
|
||||||
|
}
|
||||||
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
|
'name' => $task,
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ImportException $exception) {
|
||||||
|
throw $exception;
|
||||||
|
} catch (CsvException $exception) {
|
||||||
|
throw new ImportException('Invalid CSV data');
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
report($exception);
|
||||||
|
throw new ImportException('Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string> $header
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function validateHeader(array $header): void
|
||||||
|
{
|
||||||
|
$requiredFields = [
|
||||||
|
'Name',
|
||||||
|
'Client',
|
||||||
|
'Status',
|
||||||
|
'Visibility',
|
||||||
|
'Billability',
|
||||||
|
'Tasks',
|
||||||
|
];
|
||||||
|
foreach ($requiredFields as $requiredField) {
|
||||||
|
if (! in_array($requiredField, $header, true)) {
|
||||||
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function getReport(): ReportDto
|
||||||
|
{
|
||||||
|
return new ReportDto(
|
||||||
|
clientsCreated: $this->clientImportHelper->getCreatedCount(),
|
||||||
|
projectsCreated: $this->projectImportHelper->getCreatedCount(),
|
||||||
|
tasksCreated: $this->taskImportHelper->getCreatedCount(),
|
||||||
|
timeEntriesCreated: 0,
|
||||||
|
tagsCreated: 0,
|
||||||
|
usersCreated: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
227
app/Service/Import/Importers/ClockifyTimeEntriesImporter.php
Normal file
227
app/Service/Import/Importers/ClockifyTimeEntriesImporter.php
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\ColorService;
|
||||||
|
use App\Service\Import\ImportDatabaseHelper;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use League\Csv\Exception as CsvException;
|
||||||
|
use League\Csv\Reader;
|
||||||
|
|
||||||
|
class ClockifyTimeEntriesImporter implements ImporterContract
|
||||||
|
{
|
||||||
|
private Organization $organization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<User>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $userImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Project>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $projectImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Tag>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $tagImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Client>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $clientImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Task>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $taskImportHelper;
|
||||||
|
|
||||||
|
private int $timeEntriesCreated;
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function init(Organization $organization): void
|
||||||
|
{
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||||
|
/** @var Builder<User> $builder */
|
||||||
|
return $builder->belongsToOrganization($this->organization);
|
||||||
|
}, function (User $user) {
|
||||||
|
$user->organizations()->attach($this->organization, [
|
||||||
|
'role' => 'placeholder',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->timeEntriesCreated = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function getTags(string $tags): array
|
||||||
|
{
|
||||||
|
if (trim($tags) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$tagsParsed = explode(', ', $tags);
|
||||||
|
$tagIds = [];
|
||||||
|
foreach ($tagsParsed as $tagParsed) {
|
||||||
|
if (strlen($tagParsed) > 255) {
|
||||||
|
throw new ImportException('Tag is too long');
|
||||||
|
}
|
||||||
|
$tagId = $this->tagImportHelper->getKey([
|
||||||
|
'name' => $tagParsed,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
]);
|
||||||
|
$tagIds[] = $tagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tagIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
#[\Override]
|
||||||
|
public function importData(string $data): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$colorService = app(ColorService::class);
|
||||||
|
$reader = Reader::createFromString($data);
|
||||||
|
$reader->setHeaderOffset(0);
|
||||||
|
$reader->setDelimiter(',');
|
||||||
|
$header = $reader->getHeader();
|
||||||
|
$this->validateHeader($header);
|
||||||
|
$records = $reader->getRecords();
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$userId = $this->userImportHelper->getKey([
|
||||||
|
'email' => $record['Email'],
|
||||||
|
], [
|
||||||
|
'name' => $record['User'],
|
||||||
|
'is_placeholder' => true,
|
||||||
|
]);
|
||||||
|
$clientId = null;
|
||||||
|
if ($record['Client'] !== '') {
|
||||||
|
$clientId = $this->clientImportHelper->getKey([
|
||||||
|
'name' => $record['Client'],
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$projectId = null;
|
||||||
|
if ($record['Project'] !== '') {
|
||||||
|
$projectId = $this->projectImportHelper->getKey([
|
||||||
|
'name' => $record['Project'],
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
], [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'color' => $colorService->getRandomColor(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$taskId = null;
|
||||||
|
if ($record['Task'] !== '') {
|
||||||
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
|
'name' => $record['Task'],
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$timeEntry = new TimeEntry();
|
||||||
|
$timeEntry->user_id = $userId;
|
||||||
|
$timeEntry->task_id = $taskId;
|
||||||
|
$timeEntry->project_id = $projectId;
|
||||||
|
$timeEntry->organization_id = $this->organization->id;
|
||||||
|
$timeEntry->description = $record['Description'];
|
||||||
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
|
throw new ImportException('Invalid billable value');
|
||||||
|
}
|
||||||
|
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||||
|
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||||
|
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], 'UTC');
|
||||||
|
if ($start === false) {
|
||||||
|
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
||||||
|
}
|
||||||
|
$timeEntry->start = $start;
|
||||||
|
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], 'UTC');
|
||||||
|
if ($end === false) {
|
||||||
|
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
||||||
|
}
|
||||||
|
$timeEntry->end = $end;
|
||||||
|
$timeEntry->save();
|
||||||
|
$this->timeEntriesCreated++;
|
||||||
|
}
|
||||||
|
} catch (ImportException $exception) {
|
||||||
|
throw $exception;
|
||||||
|
} catch (CsvException $exception) {
|
||||||
|
throw new ImportException('Invalid CSV data');
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
report($exception);
|
||||||
|
throw new ImportException('Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string> $header
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
private function validateHeader(array $header): void
|
||||||
|
{
|
||||||
|
$requiredFields = [
|
||||||
|
'Project',
|
||||||
|
'Client',
|
||||||
|
'Description',
|
||||||
|
'Task',
|
||||||
|
'User',
|
||||||
|
'Group',
|
||||||
|
'Email',
|
||||||
|
'Tags',
|
||||||
|
'Billable',
|
||||||
|
'Start Date',
|
||||||
|
'Start Time',
|
||||||
|
'End Date',
|
||||||
|
'End Time',
|
||||||
|
];
|
||||||
|
foreach ($requiredFields as $requiredField) {
|
||||||
|
if (! in_array($requiredField, $header, true)) {
|
||||||
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function getReport(): ReportDto
|
||||||
|
{
|
||||||
|
return new ReportDto(
|
||||||
|
clientsCreated: $this->clientImportHelper->getCreatedCount(),
|
||||||
|
projectsCreated: $this->projectImportHelper->getCreatedCount(),
|
||||||
|
tasksCreated: $this->taskImportHelper->getCreatedCount(),
|
||||||
|
timeEntriesCreated: $this->timeEntriesCreated,
|
||||||
|
tagsCreated: $this->tagImportHelper->getCreatedCount(),
|
||||||
|
usersCreated: $this->userImportHelper->getCreatedCount(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ interface ImporterContract
|
|||||||
{
|
{
|
||||||
public function init(Organization $organization): void;
|
public function init(Organization $organization): void;
|
||||||
|
|
||||||
public function importData(string $data, array $options): void;
|
public function importData(string $data): void;
|
||||||
|
|
||||||
public function getReport(): ReportDto;
|
public function getReport(): ReportDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class ImporterProvider
|
|||||||
*/
|
*/
|
||||||
private array $importers = [
|
private array $importers = [
|
||||||
'toggl_time_entries' => TogglTimeEntriesImporter::class,
|
'toggl_time_entries' => TogglTimeEntriesImporter::class,
|
||||||
|
'toggl_data_importer' => TogglDataImporter::class,
|
||||||
|
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
|
||||||
|
'clockify_projects' => ClockifyProjectsImporter::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
194
app/Service/Import/Importers/TogglDataImporter.php
Normal file
194
app/Service/Import/Importers/TogglDataImporter.php
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\Task;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\ColorService;
|
||||||
|
use App\Service\Import\ImportDatabaseHelper;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class TogglDataImporter implements ImporterContract
|
||||||
|
{
|
||||||
|
private Organization $organization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<User>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $userImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Project>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $projectImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Tag>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $tagImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Client>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $clientImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Task>
|
||||||
|
*/
|
||||||
|
private ImportDatabaseHelper $taskImportHelper;
|
||||||
|
|
||||||
|
private ColorService $colorService;
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function init(Organization $organization): void
|
||||||
|
{
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||||
|
/** @var Builder<User> $builder */
|
||||||
|
return $builder->belongsToOrganization($this->organization);
|
||||||
|
}, function (User $user) {
|
||||||
|
$user->organizations()->attach($this->organization, [
|
||||||
|
'role' => 'placeholder',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
|
||||||
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
|
});
|
||||||
|
$this->colorService = app(ColorService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
|
#[\Override]
|
||||||
|
public function importData(string $data): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
|
file_put_contents($temporaryDirectory->path('import.zip'), $data);
|
||||||
|
$zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY);
|
||||||
|
$temporaryDirectory = TemporaryDirectory::make();
|
||||||
|
$zip->extractTo($temporaryDirectory->path());
|
||||||
|
$zip->close();
|
||||||
|
$clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json'));
|
||||||
|
if ($clientsFileContent === false) {
|
||||||
|
throw new ImportException('File clients.json missing in ZIP');
|
||||||
|
}
|
||||||
|
$clients = json_decode($clientsFileContent);
|
||||||
|
$projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json'));
|
||||||
|
if ($projectsFileContent === false) {
|
||||||
|
throw new ImportException('File projects.json missing in ZIP');
|
||||||
|
}
|
||||||
|
$projects = json_decode($projectsFileContent);
|
||||||
|
$tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json'));
|
||||||
|
if ($tagsFileContent === false) {
|
||||||
|
throw new ImportException('File tags.json missing in ZIP');
|
||||||
|
}
|
||||||
|
$tags = json_decode($tagsFileContent);
|
||||||
|
$workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json'));
|
||||||
|
if ($workspaceUsersFileContent === false) {
|
||||||
|
throw new ImportException('File workspace_users.json missing in ZIP');
|
||||||
|
}
|
||||||
|
$workspaceUsers = json_decode($workspaceUsersFileContent);
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
$this->clientImportHelper->getKey([
|
||||||
|
'name' => $client->name,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
], [], (string) $client->id);
|
||||||
|
}
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$this->tagImportHelper->getKey([
|
||||||
|
'name' => $tag->name,
|
||||||
|
'organization_id' => $this->organization->id,
|
||||||
|
], [], (string) $tag->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$clientId = null;
|
||||||
|
if ($project->client_id !== null) {
|
||||||
|
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
||||||
|
if ($clientId === null) {
|
||||||
|
throw new Exception('Client does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->colorService->isValid($project->color)) {
|
||||||
|
throw new ImportException('Invalid color');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->projectImportHelper->getKey([
|
||||||
|
'name' => $project->name,
|
||||||
|
'organization_id' => $this->organization->getKey(),
|
||||||
|
], [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'color' => $project->color,
|
||||||
|
], (string) $project->id);
|
||||||
|
}
|
||||||
|
foreach ($workspaceUsers as $workspaceUser) {
|
||||||
|
$this->userImportHelper->getKey([
|
||||||
|
'email' => $workspaceUser->email,
|
||||||
|
], [
|
||||||
|
'name' => $workspaceUser->name,
|
||||||
|
'is_placeholder' => true,
|
||||||
|
], (string) $workspaceUser->id);
|
||||||
|
}
|
||||||
|
$projectIds = $this->projectImportHelper->getExternalIds();
|
||||||
|
foreach ($projectIds as $projectIdExternal) {
|
||||||
|
$tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'));
|
||||||
|
if ($tasksFileContent === false) {
|
||||||
|
throw new ImportException('File tasks/'.$projectIdExternal.'.json missing in ZIP');
|
||||||
|
}
|
||||||
|
$tasks = json_decode($tasksFileContent);
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal);
|
||||||
|
|
||||||
|
if ($projectId === null) {
|
||||||
|
throw new Exception('Project does not exist');
|
||||||
|
}
|
||||||
|
$this->taskImportHelper->getKey([
|
||||||
|
'name' => $task->name,
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'organization_id' => $this->organization->getKey(),
|
||||||
|
], [], (string) $task->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ImportException $exception) {
|
||||||
|
throw $exception;
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
report($exception);
|
||||||
|
throw new ImportException('Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function getReport(): ReportDto
|
||||||
|
{
|
||||||
|
return new ReportDto(
|
||||||
|
clientsCreated: $this->clientImportHelper->getCreatedCount(),
|
||||||
|
projectsCreated: $this->projectImportHelper->getCreatedCount(),
|
||||||
|
tasksCreated: $this->taskImportHelper->getCreatedCount(),
|
||||||
|
timeEntriesCreated: 0,
|
||||||
|
tagsCreated: $this->tagImportHelper->getCreatedCount(),
|
||||||
|
usersCreated: $this->userImportHelper->getCreatedCount(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Models\TimeEntry;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\ColorService;
|
use App\Service\ColorService;
|
||||||
use App\Service\Import\ImportDatabaseHelper;
|
use App\Service\Import\ImportDatabaseHelper;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
@@ -22,14 +23,29 @@ class TogglTimeEntriesImporter implements ImporterContract
|
|||||||
{
|
{
|
||||||
private Organization $organization;
|
private Organization $organization;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<User>
|
||||||
|
*/
|
||||||
private ImportDatabaseHelper $userImportHelper;
|
private ImportDatabaseHelper $userImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Project>
|
||||||
|
*/
|
||||||
private ImportDatabaseHelper $projectImportHelper;
|
private ImportDatabaseHelper $projectImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Tag>
|
||||||
|
*/
|
||||||
private ImportDatabaseHelper $tagImportHelper;
|
private ImportDatabaseHelper $tagImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Client>
|
||||||
|
*/
|
||||||
private ImportDatabaseHelper $clientImportHelper;
|
private ImportDatabaseHelper $clientImportHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ImportDatabaseHelper<Task>
|
||||||
|
*/
|
||||||
private ImportDatabaseHelper $taskImportHelper;
|
private ImportDatabaseHelper $taskImportHelper;
|
||||||
|
|
||||||
private int $timeEntriesCreated;
|
private int $timeEntriesCreated;
|
||||||
@@ -39,14 +55,13 @@ class TogglTimeEntriesImporter implements ImporterContract
|
|||||||
{
|
{
|
||||||
$this->organization = $organization;
|
$this->organization = $organization;
|
||||||
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||||
return $builder->whereHas('organizations', function (Builder $builder): Builder {
|
/** @var Builder<User> $builder */
|
||||||
/** @var Builder<Organization> $builder */
|
return $builder->belongsToOrganization($this->organization);
|
||||||
return $builder->whereKey($this->organization->getKey());
|
|
||||||
});
|
|
||||||
}, function (User $user) {
|
}, function (User $user) {
|
||||||
$user->organizations()->attach([$this->organization->id]);
|
$user->organizations()->attach($this->organization, [
|
||||||
|
'role' => 'placeholder',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
// TODO: user special after import
|
|
||||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||||
return $builder->where('organization_id', $this->organization->id);
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
});
|
});
|
||||||
@@ -62,6 +77,11 @@ class TogglTimeEntriesImporter implements ImporterContract
|
|||||||
$this->timeEntriesCreated = 0;
|
$this->timeEntriesCreated = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
private function getTags(string $tags): array
|
private function getTags(string $tags): array
|
||||||
{
|
{
|
||||||
if (trim($tags) === '') {
|
if (trim($tags) === '') {
|
||||||
@@ -87,7 +107,7 @@ class TogglTimeEntriesImporter implements ImporterContract
|
|||||||
* @throws ImportException
|
* @throws ImportException
|
||||||
*/
|
*/
|
||||||
#[\Override]
|
#[\Override]
|
||||||
public function importData(string $data, array $options): void
|
public function importData(string $data): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$colorService = app(ColorService::class);
|
$colorService = app(ColorService::class);
|
||||||
@@ -140,17 +160,34 @@ class TogglTimeEntriesImporter implements ImporterContract
|
|||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||||
$timeEntry->start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC');
|
$start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC');
|
||||||
$timeEntry->end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC');
|
if ($start === false) {
|
||||||
|
throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid');
|
||||||
|
}
|
||||||
|
$timeEntry->start = $start;
|
||||||
|
$end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC');
|
||||||
|
if ($end === false) {
|
||||||
|
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
|
||||||
|
}
|
||||||
|
$timeEntry->end = $end;
|
||||||
$timeEntry->save();
|
$timeEntry->save();
|
||||||
$this->timeEntriesCreated++;
|
$this->timeEntriesCreated++;
|
||||||
}
|
}
|
||||||
|
} catch (ImportException $exception) {
|
||||||
|
throw $exception;
|
||||||
} catch (CsvException $exception) {
|
} catch (CsvException $exception) {
|
||||||
throw new ImportException('Invalid CSV data');
|
throw new ImportException('Invalid CSV data');
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
report($exception);
|
||||||
|
throw new ImportException('Unknown error');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string> $header
|
||||||
|
*
|
||||||
|
* @throws ImportException
|
||||||
|
*/
|
||||||
private function validateHeader(array $header): void
|
private function validateHeader(array $header): void
|
||||||
{
|
{
|
||||||
$requiredFields = [
|
$requiredFields = [
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ class UserService
|
|||||||
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
|
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
|
||||||
{
|
{
|
||||||
// Time entries
|
// Time entries
|
||||||
dump(TimeEntry::query()
|
TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->whereBelongsTo($fromUser, 'user')
|
->whereBelongsTo($fromUser, 'user')
|
||||||
->update([
|
->update([
|
||||||
'user_id' => $toUser->getKey(),
|
'user_id' => $toUser->getKey(),
|
||||||
]));
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "8.3.*",
|
"php": "8.3.*",
|
||||||
|
"ext-zip": "*",
|
||||||
"dedoc/scramble": "^0.8.5",
|
"dedoc/scramble": "^0.8.5",
|
||||||
"filament/filament": "^3.2",
|
"filament/filament": "^3.2",
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"laravel/passport": "^11.10.2",
|
"laravel/passport": "^11.10.2",
|
||||||
"laravel/tinker": "^2.8",
|
"laravel/tinker": "^2.8",
|
||||||
"pxlrbt/filament-environment-indicator": "^2.0",
|
"pxlrbt/filament-environment-indicator": "^2.0",
|
||||||
|
"spatie/temporary-directory": "^2.2",
|
||||||
"tightenco/ziggy": "^1.0",
|
"tightenco/ziggy": "^1.0",
|
||||||
"tpetry/laravel-postgresql-enhanced": "^0.33.0"
|
"tpetry/laravel-postgresql-enhanced": "^0.33.0"
|
||||||
},
|
},
|
||||||
|
|||||||
66
composer.lock
generated
66
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "e83929e68d256367652d91e43a79288e",
|
"content-hash": "9e9c41ae5787e1aa711b04cc019cb7e7",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -6542,6 +6542,67 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-01-11T08:43:00+00:00"
|
"time": "2024-01-11T08:43:00+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/temporary-directory",
|
||||||
|
"version": "2.2.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/temporary-directory.git",
|
||||||
|
"reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/temporary-directory/zipball/76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a",
|
||||||
|
"reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\TemporaryDirectory\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Alex Vanderbist",
|
||||||
|
"email": "alex@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Easily create, use and destroy temporary directories",
|
||||||
|
"homepage": "https://github.com/spatie/temporary-directory",
|
||||||
|
"keywords": [
|
||||||
|
"php",
|
||||||
|
"spatie",
|
||||||
|
"temporary-directory"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/temporary-directory/issues",
|
||||||
|
"source": "https://github.com/spatie/temporary-directory/tree/2.2.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://spatie.be/open-source/support-us",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2023-12-25T11:46:58+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/console",
|
"name": "symfony/console",
|
||||||
"version": "v6.4.4",
|
"version": "v6.4.4",
|
||||||
@@ -12424,7 +12485,8 @@
|
|||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.3.*"
|
"php": "8.3.*",
|
||||||
|
"ext-zip": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": [],
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ return [
|
|||||||
'throw' => false,
|
'throw' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'testfiles' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('tests'),
|
||||||
|
'throw' => false,
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Database\Factories;
|
|||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Service\ColorService;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +24,7 @@ class ProjectFactory extends Factory
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->faker->company(),
|
'name' => $this->faker->company(),
|
||||||
'color' => $this->faker->hexColor(),
|
'color' => app(ColorService::class)->getRandomColor(),
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
'client_id' => null,
|
'client_id' => null,
|
||||||
];
|
];
|
||||||
|
|||||||
22
lang/en/auth.php
Normal file
22
lang/en/auth.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used during authentication for various
|
||||||
|
| messages that we need to display to the user. You are free to modify
|
||||||
|
| these language lines according to your application's requirements.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => 'These credentials do not match our records.',
|
||||||
|
'password' => 'The provided password is incorrect.',
|
||||||
|
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||||
|
|
||||||
|
];
|
||||||
21
lang/en/pagination.php
Normal file
21
lang/en/pagination.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pagination Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used by the paginator library to build
|
||||||
|
| the simple pagination links. You are free to change them to anything
|
||||||
|
| you want to customize your views to better match your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'previous' => '« Previous',
|
||||||
|
'next' => 'Next »',
|
||||||
|
|
||||||
|
];
|
||||||
24
lang/en/passwords.php
Normal file
24
lang/en/passwords.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Reset Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are the default lines which match reasons
|
||||||
|
| that are given by the password broker for a password update attempt
|
||||||
|
| has failed, such as for an invalid token or invalid new password.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'reset' => 'Your password has been reset.',
|
||||||
|
'sent' => 'We have emailed your password reset link.',
|
||||||
|
'throttled' => 'Please wait before retrying.',
|
||||||
|
'token' => 'This password reset token is invalid.',
|
||||||
|
'user' => "We can't find a user with that email address.",
|
||||||
|
|
||||||
|
];
|
||||||
199
lang/en/validation.php
Normal file
199
lang/en/validation.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines contain the default error messages used by
|
||||||
|
| the validator class. Some of these rules have multiple versions such
|
||||||
|
| as the size rules. Feel free to tweak each of these messages here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'accepted' => 'The :attribute field must be accepted.',
|
||||||
|
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||||
|
'active_url' => 'The :attribute field must be a valid URL.',
|
||||||
|
'after' => 'The :attribute field must be a date after :date.',
|
||||||
|
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||||
|
'alpha' => 'The :attribute field must only contain letters.',
|
||||||
|
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||||
|
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||||
|
'array' => 'The :attribute field must be an array.',
|
||||||
|
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||||
|
'before' => 'The :attribute field must be a date before :date.',
|
||||||
|
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||||
|
'between' => [
|
||||||
|
'array' => 'The :attribute field must have between :min and :max items.',
|
||||||
|
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||||
|
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||||
|
],
|
||||||
|
'boolean' => 'The :attribute field must be true or false.',
|
||||||
|
'can' => 'The :attribute field contains an unauthorized value.',
|
||||||
|
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||||
|
'current_password' => 'The password is incorrect.',
|
||||||
|
'date' => 'The :attribute field must be a valid date.',
|
||||||
|
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||||
|
'date_format' => 'The :attribute field must match the format :format.',
|
||||||
|
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||||
|
'declined' => 'The :attribute field must be declined.',
|
||||||
|
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||||
|
'different' => 'The :attribute field and :other must be different.',
|
||||||
|
'digits' => 'The :attribute field must be :digits digits.',
|
||||||
|
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||||
|
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||||
|
'distinct' => 'The :attribute field has a duplicate value.',
|
||||||
|
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||||
|
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||||
|
'email' => 'The :attribute field must be a valid email address.',
|
||||||
|
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||||
|
'enum' => 'The selected :attribute is invalid.',
|
||||||
|
'exists' => 'The selected :attribute is invalid.',
|
||||||
|
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||||
|
'file' => 'The :attribute field must be a file.',
|
||||||
|
'filled' => 'The :attribute field must have a value.',
|
||||||
|
'gt' => [
|
||||||
|
'array' => 'The :attribute field must have more than :value items.',
|
||||||
|
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be greater than :value.',
|
||||||
|
'string' => 'The :attribute field must be greater than :value characters.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'array' => 'The :attribute field must have :value items or more.',
|
||||||
|
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||||
|
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||||
|
],
|
||||||
|
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||||
|
'image' => 'The :attribute field must be an image.',
|
||||||
|
'in' => 'The selected :attribute is invalid.',
|
||||||
|
'in_array' => 'The :attribute field must exist in :other.',
|
||||||
|
'integer' => 'The :attribute field must be an integer.',
|
||||||
|
'ip' => 'The :attribute field must be a valid IP address.',
|
||||||
|
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||||
|
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||||
|
'json' => 'The :attribute field must be a valid JSON string.',
|
||||||
|
'lowercase' => 'The :attribute field must be lowercase.',
|
||||||
|
'lt' => [
|
||||||
|
'array' => 'The :attribute field must have less than :value items.',
|
||||||
|
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be less than :value.',
|
||||||
|
'string' => 'The :attribute field must be less than :value characters.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'array' => 'The :attribute field must not have more than :value items.',
|
||||||
|
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||||
|
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||||
|
'max' => [
|
||||||
|
'array' => 'The :attribute field must not have more than :max items.',
|
||||||
|
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||||
|
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||||
|
],
|
||||||
|
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||||
|
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||||
|
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||||
|
'min' => [
|
||||||
|
'array' => 'The :attribute field must have at least :min items.',
|
||||||
|
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be at least :min.',
|
||||||
|
'string' => 'The :attribute field must be at least :min characters.',
|
||||||
|
],
|
||||||
|
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||||
|
'missing' => 'The :attribute field must be missing.',
|
||||||
|
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||||
|
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||||
|
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||||
|
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||||
|
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||||
|
'not_in' => 'The selected :attribute is invalid.',
|
||||||
|
'not_regex' => 'The :attribute field format is invalid.',
|
||||||
|
'numeric' => 'The :attribute field must be a number.',
|
||||||
|
'password' => [
|
||||||
|
'letters' => 'The :attribute field must contain at least one letter.',
|
||||||
|
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||||
|
'numbers' => 'The :attribute field must contain at least one number.',
|
||||||
|
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||||
|
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||||
|
],
|
||||||
|
'present' => 'The :attribute field must be present.',
|
||||||
|
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||||
|
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||||
|
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||||
|
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||||
|
'prohibited' => 'The :attribute field is prohibited.',
|
||||||
|
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||||
|
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||||
|
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||||
|
'regex' => 'The :attribute field format is invalid.',
|
||||||
|
'required' => 'The :attribute field is required.',
|
||||||
|
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||||
|
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||||
|
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||||
|
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||||
|
'required_with' => 'The :attribute field is required when :values is present.',
|
||||||
|
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||||
|
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||||
|
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||||
|
'same' => 'The :attribute field must match :other.',
|
||||||
|
'size' => [
|
||||||
|
'array' => 'The :attribute field must contain :size items.',
|
||||||
|
'file' => 'The :attribute field must be :size kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be :size.',
|
||||||
|
'string' => 'The :attribute field must be :size characters.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||||
|
'string' => 'The :attribute field must be a string.',
|
||||||
|
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||||
|
'unique' => 'The :attribute has already been taken.',
|
||||||
|
'uploaded' => 'The :attribute failed to upload.',
|
||||||
|
'uppercase' => 'The :attribute field must be uppercase.',
|
||||||
|
'url' => 'The :attribute field must be a valid URL.',
|
||||||
|
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||||
|
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify custom validation messages for attributes using the
|
||||||
|
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||||
|
| specify a specific custom language line for a given attribute rule.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Attributes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used to swap our attribute placeholder
|
||||||
|
| with something more reader friendly such as "E-Mail Address" instead
|
||||||
|
| of "email". This simply helps us make our message more expressive.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'attributes' => [],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Custom validation rules
|
||||||
|
*/
|
||||||
|
|
||||||
|
'color' => 'The :attribute field must be a valid color.',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -1 +0,0 @@
|
|||||||
"Project","Client","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
|
||||||
|
3
storage/tests/clockify_projects_import_test_1.csv
Normal file
3
storage/tests/clockify_projects_import_test_1.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"Name","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note"
|
||||||
|
"Project for Big Company","Big Company","Active","Public","Yes","Task 1, Task 2, Task 3","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","",""
|
||||||
|
"Project without Client","","Active","Public","Yes","","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","",""
|
||||||
|
3
storage/tests/clockify_time_entries_import_test_1.csv
Normal file
3
storage/tests/clockify_time_entries_import_test_1.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"Project","Client","Description","Task","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:00 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
|
||||||
|
9
storage/tests/toggl_data_import_test_1/clients.json
Normal file
9
storage/tests/toggl_data_import_test_1/clients.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"archived": false,
|
||||||
|
"creator_id": 201,
|
||||||
|
"id": 301,
|
||||||
|
"name": "Big Company",
|
||||||
|
"wid": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
58
storage/tests/toggl_data_import_test_1/projects.json
Normal file
58
storage/tests/toggl_data_import_test_1/projects.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"actual_hours": null,
|
||||||
|
"actual_seconds": null,
|
||||||
|
"auto_estimates": false,
|
||||||
|
"billable": true,
|
||||||
|
"cid": null,
|
||||||
|
"client_id": null,
|
||||||
|
"color": "#ef5350",
|
||||||
|
"currency": "EUR",
|
||||||
|
"estimated_hours": null,
|
||||||
|
"estimated_seconds": null,
|
||||||
|
"fixed_fee": null,
|
||||||
|
"guid": "",
|
||||||
|
"id": 401,
|
||||||
|
"is_private": true,
|
||||||
|
"name": "Project without Client",
|
||||||
|
"rate": null,
|
||||||
|
"rate_last_updated": null,
|
||||||
|
"recurring": false,
|
||||||
|
"recurring_parameters": null,
|
||||||
|
"start_date": "2020-01-01",
|
||||||
|
"status": "active",
|
||||||
|
"template": false,
|
||||||
|
"template_id": null,
|
||||||
|
"wid": 0,
|
||||||
|
"workspace_id": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"actual_hours": null,
|
||||||
|
"actual_seconds": null,
|
||||||
|
"auto_estimates": false,
|
||||||
|
"billable": false,
|
||||||
|
"cid": 301,
|
||||||
|
"client_id": 301,
|
||||||
|
"color": "#ec407a",
|
||||||
|
"currency": null,
|
||||||
|
"estimated_hours": null,
|
||||||
|
"estimated_seconds": null,
|
||||||
|
"fixed_fee": null,
|
||||||
|
"guid": "",
|
||||||
|
"id": 402,
|
||||||
|
"is_private": true,
|
||||||
|
"name": "Project for Big Company",
|
||||||
|
"rate": null,
|
||||||
|
"rate_last_updated": null,
|
||||||
|
"recurring": false,
|
||||||
|
"recurring_parameters": null,
|
||||||
|
"start_date": "2020-01-01",
|
||||||
|
"status": "active",
|
||||||
|
"template": false,
|
||||||
|
"template_id": null,
|
||||||
|
"wid": 0,
|
||||||
|
"workspace_id": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
14
storage/tests/toggl_data_import_test_1/tags.json
Normal file
14
storage/tests/toggl_data_import_test_1/tags.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"creator_id": 0,
|
||||||
|
"id": 501,
|
||||||
|
"name": "Development",
|
||||||
|
"workspace_id": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"creator_id": 0,
|
||||||
|
"id": 502,
|
||||||
|
"name": "Backend",
|
||||||
|
"workspace_id": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
1
storage/tests/toggl_data_import_test_1/tasks/401.json
Normal file
1
storage/tests/toggl_data_import_test_1/tasks/401.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
13
storage/tests/toggl_data_import_test_1/tasks/402.json
Normal file
13
storage/tests/toggl_data_import_test_1/tasks/402.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"estimated_seconds": 0,
|
||||||
|
"id": 601,
|
||||||
|
"name": "Task 1",
|
||||||
|
"project_id": 402,
|
||||||
|
"recurring": false,
|
||||||
|
"tracked_seconds": 0,
|
||||||
|
"user_id": null,
|
||||||
|
"workspace_id": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
19
storage/tests/toggl_data_import_test_1/workspace_users.json
Normal file
19
storage/tests/toggl_data_import_test_1/workspace_users.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"admin": true,
|
||||||
|
"email": "peter.test@email.test",
|
||||||
|
"group_ids": [],
|
||||||
|
"id": 201,
|
||||||
|
"inactive": false,
|
||||||
|
"labour_cost": null,
|
||||||
|
"name": "Peter Tester",
|
||||||
|
"rate": null,
|
||||||
|
"rate_last_updated": null,
|
||||||
|
"role": "admin",
|
||||||
|
"timezone": "Europe/Vienna",
|
||||||
|
"uid": 0,
|
||||||
|
"wid": 0,
|
||||||
|
"working_hours_in_minutes": null
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR)
|
User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR)
|
||||||
Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,Development,
|
Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,"Development, Backend",
|
||||||
Peter Tester,peter.test@email.test,Big Company,Project for Big Company,Task 1,Working hard,Yes,2024-03-04,10:23:00,2024-03-04,11:23:01,01:00:01,,111.11
|
Peter Tester,peter.test@email.test,Big Company,Project for Big Company,Task 1,Working hard,Yes,2024-03-04,10:23:00,2024-03-04,11:23:01,01:00:01,,111.11
|
||||||
|
66
tests/Unit/Rules/ColorRuleTest.php
Normal file
66
tests/Unit/Rules/ColorRuleTest.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Rules;
|
||||||
|
|
||||||
|
use App\Rules\ColorRule;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ColorRuleTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_validation_passes_if_value_is_valid_color(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$validator = Validator::make([
|
||||||
|
'color' => '#ef5350',
|
||||||
|
], [
|
||||||
|
'color' => [new ColorRule()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$isValid = $validator->passes();
|
||||||
|
$messages = $validator->messages()->toArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertTrue($isValid);
|
||||||
|
$this->assertArrayNotHasKey('color', $messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_validation_fails_if_value_is_not_a_string(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$validator = Validator::make([
|
||||||
|
'color' => true,
|
||||||
|
], [
|
||||||
|
'color' => [new ColorRule()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$isValid = $validator->passes();
|
||||||
|
$messages = $validator->messages()->toArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertFalse($isValid);
|
||||||
|
$this->assertEquals('The color field must be a string.', $messages['color'][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_validation_fails_if_value_is_not_a_valid_color(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$validator = Validator::make([
|
||||||
|
'color' => 'rgb(0,0,0)',
|
||||||
|
], [
|
||||||
|
'color' => [new ColorRule()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$isValid = $validator->passes();
|
||||||
|
$messages = $validator->messages()->toArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertFalse($isValid);
|
||||||
|
$this->assertEquals('The color field must be a valid color.', $messages['color'][0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Service\Import;
|
namespace Tests\Unit\Service\Import;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\Import\ImportDatabaseHelper;
|
use App\Service\Import\ImportDatabaseHelper;
|
||||||
@@ -74,4 +75,64 @@ class ImportDatabaseHelperTest extends TestCase
|
|||||||
// Assert
|
// Assert
|
||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_get_key_by_external_identifier_returns_key_for_external_identifier(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$project = Project::factory()->forOrganization($organization)->create();
|
||||||
|
$externalIdentifier1 = '12345';
|
||||||
|
$externalIdentifier2 = '54321';
|
||||||
|
$helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true);
|
||||||
|
$helper->getKey([
|
||||||
|
'name' => $project->name,
|
||||||
|
'organization_id' => $organization->getKey(),
|
||||||
|
], [
|
||||||
|
'color' => '#000000',
|
||||||
|
], $externalIdentifier1);
|
||||||
|
$helper->getKey([
|
||||||
|
'name' => 'Not existing project',
|
||||||
|
'organization_id' => $organization->getKey(),
|
||||||
|
], [
|
||||||
|
'color' => '#000000',
|
||||||
|
], $externalIdentifier2);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$key1 = $helper->getKeyByExternalIdentifier($externalIdentifier1);
|
||||||
|
$key2 = $helper->getKeyByExternalIdentifier($externalIdentifier2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame($project->getKey(), $key1);
|
||||||
|
$this->assertSame(Project::where('name', '=', 'Not existing project')->first()->getKey(), $key2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_external_ids_returns_all_external_ids_that_were_temporary_stored_via_get_key(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$project = Project::factory()->forOrganization($organization)->create();
|
||||||
|
$externalIdentifier1 = '12345';
|
||||||
|
$externalIdentifier2 = '54321';
|
||||||
|
$helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true);
|
||||||
|
$helper->getKey([
|
||||||
|
'name' => $project->name,
|
||||||
|
'organization_id' => $organization->getKey(),
|
||||||
|
], [
|
||||||
|
'color' => '#000000',
|
||||||
|
], $externalIdentifier1);
|
||||||
|
$helper->getKey([
|
||||||
|
'name' => 'Not existing project',
|
||||||
|
'organization_id' => $organization->getKey(),
|
||||||
|
], [
|
||||||
|
'color' => '#000000',
|
||||||
|
], $externalIdentifier2);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$externalKeys = $helper->getExternalIds();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertCount(2, $externalKeys);
|
||||||
|
$this->assertContains($externalIdentifier1, $externalKeys);
|
||||||
|
$this->assertContains($externalIdentifier2, $externalKeys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service\Import\Importer;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\Import\Importers\ClockifyProjectsImporter;
|
||||||
|
|
||||||
|
class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
||||||
|
{
|
||||||
|
public function test_import_of_test_file_succeeds(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new ClockifyProjectsImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv'));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, []);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->checkTestScenarioProjectsOnlyAfterImport();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_import_of_test_file_twice_succeeds(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new ClockifyProjectsImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv'));
|
||||||
|
$importer->importData($data, []);
|
||||||
|
$importer = new ClockifyProjectsImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, []);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->checkTestScenarioProjectsOnlyAfterImport();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service\Import\Importer;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
|
use App\Service\Import\Importers\ClockifyTimeEntriesImporter;
|
||||||
|
|
||||||
|
class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||||
|
{
|
||||||
|
public function test_import_of_test_file_succeeds(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new ClockifyTimeEntriesImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv'));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, []);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||||
|
$timeEntries = TimeEntry::all();
|
||||||
|
$this->assertCount(2, $timeEntries);
|
||||||
|
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
||||||
|
$this->assertNotNull($timeEntry1);
|
||||||
|
$this->assertSame('', $timeEntry1->description);
|
||||||
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString());
|
||||||
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString());
|
||||||
|
$this->assertFalse($timeEntry1->billable);
|
||||||
|
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||||
|
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||||
|
$this->assertNotNull($timeEntry2);
|
||||||
|
$this->assertSame('Working hard', $timeEntry2->description);
|
||||||
|
$this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString());
|
||||||
|
$this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString());
|
||||||
|
$this->assertTrue($timeEntry2->billable);
|
||||||
|
$this->assertSame([], $timeEntry2->tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_import_of_test_file_twice_succeeds(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new ClockifyTimeEntriesImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv'));
|
||||||
|
$importer->importData($data, []);
|
||||||
|
$importer = new ClockifyTimeEntriesImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, []);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||||
|
$timeEntries = TimeEntry::all();
|
||||||
|
$this->assertCount(4, $timeEntries);
|
||||||
|
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
||||||
|
$this->assertNotNull($timeEntry1);
|
||||||
|
$this->assertSame('', $timeEntry1->description);
|
||||||
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString());
|
||||||
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString());
|
||||||
|
$this->assertFalse($timeEntry1->billable);
|
||||||
|
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||||
|
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||||
|
$this->assertNotNull($timeEntry2);
|
||||||
|
$this->assertSame('Working hard', $timeEntry2->description);
|
||||||
|
$this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString());
|
||||||
|
$this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString());
|
||||||
|
$this->assertTrue($timeEntry2->billable);
|
||||||
|
$this->assertSame([], $timeEntry2->tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Service\Import\Importer;
|
namespace Tests\Unit\Service\Import\Importer;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
@@ -16,7 +17,7 @@ class ImporterTestAbstract extends TestCase
|
|||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag}
|
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
|
||||||
*/
|
*/
|
||||||
protected function checkTestScenarioAfterImportExcludingTimeEntries(): object
|
protected function checkTestScenarioAfterImportExcludingTimeEntries(): object
|
||||||
{
|
{
|
||||||
@@ -27,20 +28,27 @@ class ImporterTestAbstract extends TestCase
|
|||||||
$this->assertSame(null, $user1->password);
|
$this->assertSame(null, $user1->password);
|
||||||
$this->assertSame('Peter Tester', $user1->name);
|
$this->assertSame('Peter Tester', $user1->name);
|
||||||
$this->assertSame('peter.test@email.test', $user1->email);
|
$this->assertSame('peter.test@email.test', $user1->email);
|
||||||
|
$clients = Client::all();
|
||||||
|
$this->assertCount(1, $clients);
|
||||||
|
$client1 = $clients->firstWhere('name', 'Big Company');
|
||||||
|
$this->assertNotNull($client1);
|
||||||
$projects = Project::all();
|
$projects = Project::all();
|
||||||
$this->assertCount(2, $projects);
|
$this->assertCount(2, $projects);
|
||||||
$project1 = $projects->firstWhere('name', 'Project without Client');
|
$project1 = $projects->firstWhere('name', 'Project without Client');
|
||||||
$this->assertNotNull($project1);
|
$this->assertNotNull($project1);
|
||||||
|
$this->assertNull($project1->client_id);
|
||||||
$project2 = $projects->firstWhere('name', 'Project for Big Company');
|
$project2 = $projects->firstWhere('name', 'Project for Big Company');
|
||||||
$this->assertNotNull($project2);
|
$this->assertNotNull($project2);
|
||||||
|
$this->assertSame($client1->getKey(), $project2->client_id);
|
||||||
$tasks = Task::all();
|
$tasks = Task::all();
|
||||||
$this->assertCount(1, $tasks);
|
$this->assertCount(1, $tasks);
|
||||||
$task1 = $tasks->firstWhere('name', 'Task 1');
|
$task1 = $tasks->firstWhere('name', 'Task 1');
|
||||||
$this->assertNotNull($task1);
|
$this->assertNotNull($task1);
|
||||||
$this->assertSame($project2->getKey(), $task1->project_id);
|
$this->assertSame($project2->getKey(), $task1->project_id);
|
||||||
$tags = Tag::all();
|
$tags = Tag::all();
|
||||||
$this->assertCount(1, $tags);
|
$this->assertCount(2, $tags);
|
||||||
$tag1 = $tags->firstWhere('name', 'Development');
|
$tag1 = $tags->firstWhere('name', 'Development');
|
||||||
|
$tag2 = $tags->firstWhere('name', 'Backend');
|
||||||
$this->assertNotNull($tag1);
|
$this->assertNotNull($tag1);
|
||||||
|
|
||||||
return (object) [
|
return (object) [
|
||||||
@@ -48,6 +56,44 @@ class ImporterTestAbstract extends TestCase
|
|||||||
'project1' => $project1,
|
'project1' => $project1,
|
||||||
'project2' => $project2,
|
'project2' => $project2,
|
||||||
'tag1' => $tag1,
|
'tag1' => $tag1,
|
||||||
|
'tag2' => $tag2,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return object{client1: Client, project1: Project, project2: Project, task1: Task}
|
||||||
|
*/
|
||||||
|
protected function checkTestScenarioProjectsOnlyAfterImport(): object
|
||||||
|
{
|
||||||
|
$clients = Client::all();
|
||||||
|
$this->assertCount(1, $clients);
|
||||||
|
$client1 = $clients->firstWhere('name', 'Big Company');
|
||||||
|
$this->assertNotNull($client1);
|
||||||
|
$projects = Project::all();
|
||||||
|
$this->assertCount(2, $projects);
|
||||||
|
$project1 = $projects->firstWhere('name', 'Project without Client');
|
||||||
|
$this->assertNotNull($project1);
|
||||||
|
$this->assertNull($project1->client_id);
|
||||||
|
$project2 = $projects->firstWhere('name', 'Project for Big Company');
|
||||||
|
$this->assertNotNull($project2);
|
||||||
|
$this->assertSame($client1->getKey(), $project2->client_id);
|
||||||
|
$tasks = Task::all();
|
||||||
|
$this->assertCount(3, $tasks);
|
||||||
|
$task1 = $tasks->firstWhere('name', 'Task 1');
|
||||||
|
$this->assertNotNull($task1);
|
||||||
|
$this->assertSame($project2->getKey(), $task1->project_id);
|
||||||
|
$task2 = $tasks->firstWhere('name', 'Task 2');
|
||||||
|
$this->assertNotNull($task2);
|
||||||
|
$this->assertSame($project2->getKey(), $task2->project_id);
|
||||||
|
$task3 = $tasks->firstWhere('name', 'Task 3');
|
||||||
|
$this->assertNotNull($task3);
|
||||||
|
$this->assertSame($project2->getKey(), $task3->project_id);
|
||||||
|
|
||||||
|
return (object) [
|
||||||
|
'client1' => $client1,
|
||||||
|
'project1' => $project1,
|
||||||
|
'project2' => $project2,
|
||||||
|
'task1' => $task1,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
tests/Unit/Service/Import/Importer/TogglDataImporterTest.php
Normal file
64
tests/Unit/Service/Import/Importer/TogglDataImporterTest.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service\Import\Importer;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Service\Import\Importers\TogglDataImporter;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class TogglDataImporterTest extends ImporterTestAbstract
|
||||||
|
{
|
||||||
|
private function createTestZip(string $folder): string
|
||||||
|
{
|
||||||
|
$tempDir = TemporaryDirectory::make();
|
||||||
|
$zipPath = $tempDir->path('test.zip');
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach (Storage::disk('testfiles')->allFiles($folder) as $file) {
|
||||||
|
$zip->addFile(Storage::disk('testfiles')->path($file), Str::of($file)->after($folder.'/')->value());
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_import_of_test_file_succeeds(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createTestZip('toggl_data_import_test_1');
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new TogglDataImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents($zipPath);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_import_of_test_file_twice_succeeds(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createTestZip('toggl_data_import_test_1');
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new TogglDataImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents($zipPath);
|
||||||
|
$importer->importData($data);
|
||||||
|
$importer = new TogglDataImporter();
|
||||||
|
$importer->init($organization);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,13 +16,29 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
$importer = new TogglTimeEntriesImporter();
|
$importer = new TogglTimeEntriesImporter();
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
$data = file_get_contents(storage_path('tests/toggl_import_test_1.csv'));
|
$data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv'));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
$importer->importData($data, []);
|
$importer->importData($data, []);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->checkTestScenarioAfterImportExcludingTimeEntries();
|
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||||
|
$timeEntries = TimeEntry::all();
|
||||||
|
$this->assertCount(2, $timeEntries);
|
||||||
|
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
||||||
|
$this->assertNotNull($timeEntry1);
|
||||||
|
$this->assertSame('', $timeEntry1->description);
|
||||||
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString());
|
||||||
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString());
|
||||||
|
$this->assertFalse($timeEntry1->billable);
|
||||||
|
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||||
|
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||||
|
$this->assertNotNull($timeEntry2);
|
||||||
|
$this->assertSame('Working hard', $timeEntry2->description);
|
||||||
|
$this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString());
|
||||||
|
$this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString());
|
||||||
|
$this->assertTrue($timeEntry2->billable);
|
||||||
|
$this->assertSame([], $timeEntry2->tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_twice_succeeds(): void
|
public function test_import_of_test_file_twice_succeeds(): void
|
||||||
@@ -31,7 +47,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$organization = Organization::factory()->create();
|
$organization = Organization::factory()->create();
|
||||||
$importer = new TogglTimeEntriesImporter();
|
$importer = new TogglTimeEntriesImporter();
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
$data = file_get_contents(storage_path('tests/toggl_import_test_1.csv'));
|
$data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv'));
|
||||||
$importer->importData($data, []);
|
$importer->importData($data, []);
|
||||||
$importer = new TogglTimeEntriesImporter();
|
$importer = new TogglTimeEntriesImporter();
|
||||||
$importer->init($organization);
|
$importer->init($organization);
|
||||||
@@ -49,7 +65,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString());
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString());
|
||||||
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString());
|
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString());
|
||||||
$this->assertFalse($timeEntry1->billable);
|
$this->assertFalse($timeEntry1->billable);
|
||||||
$this->assertSame([$testScenario->tag1->getKey()], $timeEntry1->tags);
|
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||||
$this->assertNotNull($timeEntry2);
|
$this->assertNotNull($timeEntry2);
|
||||||
$this->assertSame('Working hard', $timeEntry2->description);
|
$this->assertSame('Working hard', $timeEntry2->description);
|
||||||
|
|||||||
Reference in New Issue
Block a user