From b74e14f06ff90e63a0f5de833e1da52aab480b27 Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Tue, 5 Mar 2024 19:19:07 +0100 Subject: [PATCH 01/20] Added import system --- .../Resources/OrganizationResource.php | 54 +++++ .../Controllers/Api/V1/ImportController.php | 64 ++++++ app/Http/Middleware/ValidateSignature.php | 2 +- app/Http/Requests/V1/Import/ImportRequest.php | 30 +++ .../V1/Project/ProjectStoreRequest.php | 1 + .../V1/Project/ProjectUpdateRequest.php | 1 + app/Http/Requests/V1/Tag/TagStoreRequest.php | 1 + app/Http/Requests/V1/Tag/TagUpdateRequest.php | 1 + app/Models/Organization.php | 2 + app/Models/User.php | 3 + app/Providers/AppServiceProvider.php | 1 + app/Providers/JetstreamServiceProvider.php | 1 + app/Service/ColorService.php | 38 ++++ app/Service/Import/ImportDatabaseHelper.php | 132 ++++++++++++ app/Service/Import/ImportService.php | 30 +++ .../Import/Importers/ImportException.php | 9 + .../Import/Importers/ImporterContract.php | 16 ++ .../Import/Importers/ImporterProvider.php | 37 ++++ app/Service/Import/Importers/ReportDto.php | 76 +++++++ .../Importers/TogglTimeEntriesImporter.php | 189 ++++++++++++++++++ config/telescope.php | 1 - .../2014_10_12_000000_create_users_table.php | 8 +- routes/api.php | 6 + storage/tests/clockify_import_test_1.csv | 1 + storage/tests/toggl_import_test_1.csv | 3 + .../Endpoint/Api/V1/ImportEndpointTest.php | 58 ++++++ .../Import/ImportDatabaseHelperTest.php | 71 +++++++ .../Import/Importer/ImporterTestAbstract.php | 53 +++++ .../Importer/TogglTimeEntriesImporterTest.php | 61 ++++++ 29 files changed, 946 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/ImportController.php create mode 100644 app/Http/Requests/V1/Import/ImportRequest.php create mode 100644 app/Service/ColorService.php create mode 100644 app/Service/Import/ImportDatabaseHelper.php create mode 100644 app/Service/Import/ImportService.php create mode 100644 app/Service/Import/Importers/ImportException.php create mode 100644 app/Service/Import/Importers/ImporterContract.php create mode 100644 app/Service/Import/Importers/ImporterProvider.php create mode 100644 app/Service/Import/Importers/ReportDto.php create mode 100644 app/Service/Import/Importers/TogglTimeEntriesImporter.php create mode 100644 storage/tests/clockify_import_test_1.csv create mode 100644 storage/tests/toggl_import_test_1.csv create mode 100644 tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php create mode 100644 tests/Unit/Service/Import/ImportDatabaseHelperTest.php create mode 100644 tests/Unit/Service/Import/Importer/ImporterTestAbstract.php create mode 100644 tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index 07664a41..3c14a313 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -6,11 +6,19 @@ use App\Filament\Resources\OrganizationResource\Pages; use App\Models\Organization; +use App\Service\Import\Importers\ImporterProvider; +use App\Service\Import\Importers\ImportException; +use App\Service\Import\Importers\ReportDto; +use App\Service\Import\ImportService; use Filament\Forms; +use Filament\Forms\Components\Select; use Filament\Forms\Form; +use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Actions\Action; use Filament\Tables\Table; +use Illuminate\Support\Facades\Storage; class OrganizationResource extends Resource { @@ -60,6 +68,52 @@ public static function table(Table $table): Table ]) ->actions([ Tables\Actions\EditAction::make(), + Action::make('Import') + ->icon('heroicon-o-inbox-arrow-down') + ->action(function (Organization $record, array $data) { + // TODO: different disk! + try { + /** @var ReportDto $report */ + $report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file']), []); + Notification::make() + ->title('Import successful') + ->success() + ->body( + 'Imported time entries: '.$report->timeEntriesCreated.'
'. + 'Imported clients: '.$report->clientsCreated.'
'. + 'Imported projects: '.$report->projectsCreated.'
'. + 'Imported tasks: '.$report->tasksCreated.'
'. + 'Imported tags: '.$report->tagsCreated.'
'. + 'Imported users: '.$report->usersCreated + ) + ->persistent() + ->send(); + } catch (ImportException $exception) { + report($exception); + Notification::make() + ->title('Import failed, changes rolled back') + ->danger() + ->body('Message: '.$exception->getMessage()) + ->persistent() + ->send(); + } + }) + ->tooltip(fn (Organization $record): string => "Import into {$record->name}") + ->form([ + Forms\Components\FileUpload::make('file') + ->label('File') + ->required(), + Select::make('type') + ->required() + ->options(function (): array { + $select = []; + foreach (app(ImporterProvider::class)->getImporterKeys() as $key) { + $select[$key] = $key; + } + + return $select; + }), + ]), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ diff --git a/app/Http/Controllers/Api/V1/ImportController.php b/app/Http/Controllers/Api/V1/ImportController.php new file mode 100644 index 00000000..877c8c61 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ImportController.php @@ -0,0 +1,64 @@ +checkPermission($organization, 'import'); + + try { + $report = $importService->import( + $organization, + $request->input('type'), + $request->input('data'), + $request->input('options') + ); + + return new JsonResponse([ + /** @var array{ + * clients: array{ + * created: int, + * }, + * projects: array{ + * created: int, + * }, + * tasks: array{ + * created: int, + * }, + * time-entries: array{ + * created: int, + * }, + * tags: array{ + * created: int, + * }, + * users: array{ + * created: int, + * } + * } $report Import report */ + 'report' => $report->toArray(), + ], 200); + } catch (ImportException $exception) { + report($exception); + + return new JsonResponse([ + 'message' => $exception->getMessage(), + ], 400); + } + } +} diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php index 0b3a971f..d979b895 100644 --- a/app/Http/Middleware/ValidateSignature.php +++ b/app/Http/Middleware/ValidateSignature.php @@ -13,7 +13,7 @@ class ValidateSignature extends Middleware * * @var array */ - protected $except = [ + protected array $except = [ // 'fbclid', // 'utm_campaign', // 'utm_content', diff --git a/app/Http/Requests/V1/Import/ImportRequest.php b/app/Http/Requests/V1/Import/ImportRequest.php new file mode 100644 index 00000000..523324b7 --- /dev/null +++ b/app/Http/Requests/V1/Import/ImportRequest.php @@ -0,0 +1,30 @@ +> + */ + public function rules(): array + { + return [ + 'type' => [ + 'required', + 'string', + ], + 'data' => [ + 'required', + 'string', + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index d608e0c4..09bf72dc 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -25,6 +25,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'min:1', diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 69b6661e..28ed09ad 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -25,6 +25,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'max:255', diff --git a/app/Http/Requests/V1/Tag/TagStoreRequest.php b/app/Http/Requests/V1/Tag/TagStoreRequest.php index 27955e19..07373f3e 100644 --- a/app/Http/Requests/V1/Tag/TagStoreRequest.php +++ b/app/Http/Requests/V1/Tag/TagStoreRequest.php @@ -18,6 +18,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'min:1', diff --git a/app/Http/Requests/V1/Tag/TagUpdateRequest.php b/app/Http/Requests/V1/Tag/TagUpdateRequest.php index a0f5a1db..a54a31ee 100644 --- a/app/Http/Requests/V1/Tag/TagUpdateRequest.php +++ b/app/Http/Requests/V1/Tag/TagUpdateRequest.php @@ -18,6 +18,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'min:1', diff --git a/app/Models/Organization.php b/app/Models/Organization.php index c44a485f..62bd33b7 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -5,6 +5,7 @@ namespace App\Models; use Database\Factories\OrganizationFactory; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +19,7 @@ * @property string $name * @property bool $personal_team * @property User $owner + * @property Collection $users * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() diff --git a/app/Models/User.php b/app/Models/User.php index 891b3be0..19e6bc8f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -64,8 +64,11 @@ class User extends Authenticatable * @var array */ protected $casts = [ + 'name' => 'string', + 'email' => 'string', 'email_verified_at' => 'datetime', 'is_admin' => 'boolean', + 'is_placeholder' => 'boolean', ]; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b027f23f..1aa92645 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -74,6 +74,7 @@ public function boot(): void if (config('app.force_https', false) || App::isProduction()) { URL::forceScheme('https'); + request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off'); } } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index e8e5bfb0..1de94d23 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -74,6 +74,7 @@ protected function configurePermissions(): void 'clients:delete', 'organizations:view', 'organizations:update', + 'import', ])->description('Administrator users can perform any action.'); Jetstream::role('manager', 'Manager', [ diff --git a/app/Service/ColorService.php b/app/Service/ColorService.php new file mode 100644 index 00000000..a58e459b --- /dev/null +++ b/app/Service/ColorService.php @@ -0,0 +1,38 @@ + + */ + private const array COLORS = [ + '#ef5350', + '#ec407a', + '#ab47bc', + '#7e57c2', + '#5c6bc0', + '#42a5f5', + '#29b6f6', + '#26c6da', + '#26a69a', + '#66bb6a', + '#9ccc65', + '#d4e157', + '#ffee58', + '#ffca28', + '#ffa726', + '#ff7043', + '#8d6e63', + '#bdbdbd', + '#78909c', + ]; + + public function getRandomColor(): string + { + return self::COLORS[array_rand(self::COLORS)]; + } +} diff --git a/app/Service/Import/ImportDatabaseHelper.php b/app/Service/Import/ImportDatabaseHelper.php new file mode 100644 index 00000000..fa5551ae --- /dev/null +++ b/app/Service/Import/ImportDatabaseHelper.php @@ -0,0 +1,132 @@ + + */ + private string $model; + + /** + * @var string[] + */ + private array $identifiers; + + private ?array $mapIdentifierToKey = null; + + private array $mapNewAttach = []; + + private bool $attachToExisting; + + private ?Closure $queryModifier; + + private ?Closure $afterCreate; + + private int $createdCount; + + /** + * @param class-string $model + * @param array $identifiers + */ + public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null) + { + $this->model = $model; + $this->identifiers = $identifiers; + $this->attachToExisting = $attachToExisting; + $this->queryModifier = $queryModifier; + $this->afterCreate = $afterCreate; + $this->createdCount = 0; + } + + /** + * @return Builder + */ + private function getModelInstance(): Builder + { + return (new $this->model)->query(); + } + + private function createEntity(array $identifierData, array $createValues): string + { + $model = new $this->model(); + foreach ($identifierData as $identifier => $identifierValue) { + $model->{$identifier} = $identifierValue; + } + foreach ($createValues as $key => $value) { + $model->{$key} = $value; + } + $model->save(); + + if ($this->afterCreate !== null) { + ($this->afterCreate)($model); + } + + $this->mapIdentifierToKey[$this->getHash($identifierData)] = $model->getKey(); + $this->createdCount++; + + return $model->getKey(); + } + + private function getHash(array $data): string + { + return md5(json_encode($data)); + } + + public function getKey(array $identifierData, array $createValues = []): string + { + $this->checkMap(); + + $hash = $this->getHash($identifierData); + if ($this->attachToExisting) { + $key = $this->mapIdentifierToKey[$hash] ?? null; + if ($key !== null) { + return $key; + } + + return $this->createEntity($identifierData, $createValues); + } else { + throw new \RuntimeException('Not implemented'); + } + } + + private function checkMap(): void + { + if ($this->mapIdentifierToKey === null) { + $select = $this->identifiers; + $select[] = (new $this->model())->getKeyName(); + $builder = $this->getModelInstance(); + + if ($this->queryModifier !== null) { + $builder = ($this->queryModifier)($builder); + } + + $databaseEntries = $builder->select($select) + ->get(); + $this->mapIdentifierToKey = []; + foreach ($databaseEntries as $databaseEntry) { + $identifierData = []; + foreach ($this->identifiers as $identifier) { + $identifierData[$identifier] = $databaseEntry->{$identifier}; + } + $hash = $this->getHash($identifierData); + $this->mapIdentifierToKey[$hash] = $databaseEntry->getKey(); + } + } + } + + public function getCreatedCount(): int + { + return $this->createdCount; + } +} diff --git a/app/Service/Import/ImportService.php b/app/Service/Import/ImportService.php new file mode 100644 index 00000000..0c442c15 --- /dev/null +++ b/app/Service/Import/ImportService.php @@ -0,0 +1,30 @@ +getImporter($importerType); + $importer->init($organization); + DB::transaction(function () use (&$importer, &$data, &$options, &$organization) { + $importer->importData($data, $options); + }); + + return $importer->getReport(); + } +} diff --git a/app/Service/Import/Importers/ImportException.php b/app/Service/Import/Importers/ImportException.php new file mode 100644 index 00000000..00588701 --- /dev/null +++ b/app/Service/Import/Importers/ImportException.php @@ -0,0 +1,9 @@ + TogglTimeEntriesImporter::class, + ]; + + /** + * @param class-string $importer + */ + public function registerImporter(string $type, string $importer): void + { + $this->importers[$type] = $importer; + } + + /** + * @return array + */ + public function getImporterKeys(): array + { + return array_keys($this->importers); + } + + public function getImporter(string $type): ImporterContract + { + if (! array_key_exists($type, $this->importers)) { + throw new \InvalidArgumentException('Invalid importer type'); + } + + return new $this->importers[$type]; + } +} diff --git a/app/Service/Import/Importers/ReportDto.php b/app/Service/Import/Importers/ReportDto.php new file mode 100644 index 00000000..5f4748af --- /dev/null +++ b/app/Service/Import/Importers/ReportDto.php @@ -0,0 +1,76 @@ +clientsCreated = $clientsCreated; + $this->projectsCreated = $projectsCreated; + $this->tasksCreated = $tasksCreated; + $this->timeEntriesCreated = $timeEntriesCreated; + $this->tagsCreated = $tagsCreated; + $this->usersCreated = $usersCreated; + } + + /** + * @return array{ + * clients: array{ + * created: int, + * }, + * projects: array{ + * created: int, + * }, + * tasks: array{ + * created: int, + * }, + * time-entries: array{ + * created: int, + * }, + * tags: array{ + * created: int, + * }, + * users: array{ + * created: int, + * } + * } + */ + public function toArray(): array + { + return [ + 'clients' => [ + 'created' => $this->clientsCreated, + ], + 'projects' => [ + 'created' => $this->projectsCreated, + ], + 'tasks' => [ + 'created' => $this->tasksCreated, + ], + 'time-entries' => [ + 'created' => $this->timeEntriesCreated, + ], + 'tags' => [ + 'created' => $this->tagsCreated, + ], + 'users' => [ + 'created' => $this->usersCreated, + ], + ]; + } +} diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php new file mode 100644 index 00000000..0021bfb6 --- /dev/null +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -0,0 +1,189 @@ +organization = $organization; + $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { + return $builder->whereHas('organizations', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereKey($this->organization->getKey()); + }); + }, function (User $user) { + $user->organizations()->attach([$this->organization->id]); + }); + // TODO: user special after import + $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; + } + + 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, array $options): 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']); + $timeEntry->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'); + $timeEntry->save(); + $this->timeEntriesCreated++; + } + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } + + } + + private function validateHeader(array $header): void + { + $requiredFields = [ + 'User', + 'Email', + 'Client', + 'Project', + 'Task', + 'Description', + 'Billable', + 'Start date', + 'Start time', + 'End date', + 'End time', + 'Tags', + ]; + 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(), + ); + } +} diff --git a/config/telescope.php b/config/telescope.php index 312fdf26..21d8ddda 100644 --- a/config/telescope.php +++ b/config/telescope.php @@ -98,7 +98,6 @@ ], 'ignore_paths' => [ - 'livewire*', 'nova-api*', 'pulse*', ], diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index 490d4d25..ffa23ea9 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -16,13 +16,17 @@ public function up(): void Schema::create('users', function (Blueprint $table) { $table->uuid('id')->primary(); $table->string('name'); - $table->string('email')->unique(); + $table->string('email'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->string('password')->nullable(); $table->rememberToken(); + $table->boolean('is_placeholder')->default(false); $table->foreignUuid('current_team_id')->nullable(); $table->string('profile_photo_path', 2048)->nullable(); $table->timestamps(); + + $table->uniqueIndex('email') + ->where('is_placeholder = false'); }); } diff --git a/routes/api.php b/routes/api.php index cccee762..a75683a9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Controllers\Api\V1\ClientController; +use App\Http\Controllers\Api\V1\ImportController; use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\TagController; @@ -60,6 +61,11 @@ Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update'); Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy'); }); + + // Import routes + Route::name('import.')->group(static function () { + Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import'); + }); }); /** diff --git a/storage/tests/clockify_import_test_1.csv b/storage/tests/clockify_import_test_1.csv new file mode 100644 index 00000000..66b59c18 --- /dev/null +++ b/storage/tests/clockify_import_test_1.csv @@ -0,0 +1 @@ +"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)" diff --git a/storage/tests/toggl_import_test_1.csv b/storage/tests/toggl_import_test_1.csv new file mode 100644 index 00000000..1fdfc8d6 --- /dev/null +++ b/storage/tests/toggl_import_test_1.csv @@ -0,0 +1,3 @@ +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,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 diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php new file mode 100644 index 00000000..c53b2fde --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -0,0 +1,58 @@ +createUserWithPermission([ + ]); + + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.import', ['organization' => $data->organization->id]), [ + 'type' => 'toggl_time_entries', + 'data' => 'some data', + 'options' => [], + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_import_calls_import_service_if_user_has_permission(): void + { + // Arrange + $user = $this->createUserWithPermission([ + 'import', + ]); + $this->mock(ImportService::class, function (MockInterface $mock) use (&$user): void { + $mock->shouldReceive('import') + ->withArgs(function (Organization $organization, string $importerType, string $data, array $options) use (&$user): bool { + return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data' && $options === []; + }) + ->once(); + }); + Passport::actingAs($user->user); + + // Act + $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [ + 'type' => 'toggl_time_entries', + 'data' => 'some data', + 'options' => [], + ]); + + // Assert + $response->assertStatus(200); + } +} diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php new file mode 100644 index 00000000..f0a03d9b --- /dev/null +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -0,0 +1,71 @@ +create(); + $helper = new ImportDatabaseHelper(User::class, ['email'], true); + + // Act + $key = $helper->getKey([ + 'email' => $user->email, + ], [ + 'name' => 'Test', + ]); + + // Assert + $this->assertSame($user->getKey(), $key); + } + + public function test_get_key_attach_to_existing_creates_model_if_not_existing(): void + { + // Arrange + $helper = new ImportDatabaseHelper(User::class, ['email'], true); + + // Act + $key = $helper->getKey([ + 'email' => 'test@mail.test', + ], [ + 'name' => 'Test', + ]); + + // Assert + $this->assertNotNull($key); + $this->assertDatabaseHas(User::class, [ + 'email' => 'test@mail.test', + 'name' => 'Test', + ]); + } + + public function test_get_key_not_attach_to_existing_returns_key_for_identifier_without_creating_model(): void + { + // Arrange + $project = Project::factory()->create(); + $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false); + + // Act + $key = $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $project->organization_id, + ], [ + 'color' => '#000000', + ]); + + // Assert + $this->assertNotSame($project->getKey(), $key); + } +} diff --git a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php new file mode 100644 index 00000000..7b9c44ab --- /dev/null +++ b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php @@ -0,0 +1,53 @@ +assertCount(2, $users); + $user1 = $users->firstWhere('name', 'Peter Tester'); + $this->assertNotNull($user1); + $this->assertSame(null, $user1->password); + $this->assertSame('Peter Tester', $user1->name); + $this->assertSame('peter.test@email.test', $user1->email); + $projects = Project::all(); + $this->assertCount(2, $projects); + $project1 = $projects->firstWhere('name', 'Project without Client'); + $this->assertNotNull($project1); + $project2 = $projects->firstWhere('name', 'Project for Big Company'); + $this->assertNotNull($project2); + $tasks = Task::all(); + $this->assertCount(1, $tasks); + $task1 = $tasks->firstWhere('name', 'Task 1'); + $this->assertNotNull($task1); + $this->assertSame($project2->getKey(), $task1->project_id); + $tags = Tag::all(); + $this->assertCount(1, $tags); + $tag1 = $tags->firstWhere('name', 'Development'); + $this->assertNotNull($tag1); + + return (object) [ + 'user1' => $user1, + 'project1' => $project1, + 'project2' => $project2, + 'tag1' => $tag1, + ]; + } +} diff --git a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php new file mode 100644 index 00000000..0e0f9ccf --- /dev/null +++ b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php @@ -0,0 +1,61 @@ +create(); + $importer = new TogglTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + + // Act + $importer->importData($data, []); + + // Assert + $this->checkTestScenarioAfterImportExcludingTimeEntries(); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $organization = Organization::factory()->create(); + $importer = new TogglTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + $importer->importData($data, []); + $importer = new TogglTimeEntriesImporter(); + $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()], $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); + } +} From 14e3017cb245e50d733c0e432d17d9f58ab1609d Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Fri, 8 Mar 2024 13:31:49 +0100 Subject: [PATCH 02/20] Added placeholder users; Better exception handling; Enhanced local setup --- .env.example | 2 +- .gitignore | 1 + README.md | 1 + app/Actions/Fortify/CreateNewUser.php | 22 ++- .../Jetstream/AddOrganizationMember.php | 31 ++-- .../Jetstream/InviteOrganizationMember.php | 11 +- app/Exceptions/Api/ApiException.php | 46 ++++++ .../Api/TimeEntryStillRunningApiException.php | 10 ++ .../Api/UserNotPlaceholderApiException.php | 10 ++ app/Exceptions/ApiException.php | 24 --- app/Exceptions/TimeEntryStillRunning.php | 9 -- .../Api/V1/TimeEntryController.php | 7 +- .../Controllers/Api/V1/UserController.php | 56 +++++++ .../Requests/V1/User/UserIndexRequest.php | 26 ++++ app/Http/Resources/V1/User/UserCollection.php | 17 +++ app/Http/Resources/V1/User/UserResource.php | 40 +++++ app/Listeners/RemovePlaceholder.php | 30 ++++ app/Models/Organization.php | 27 ++++ app/Models/User.php | 32 ++++ app/Providers/EventServiceProvider.php | 6 +- app/Providers/JetstreamServiceProvider.php | 3 + .../Import/Importers/ImporterProvider.php | 3 + app/Service/UserService.php | 23 +++ config/telescope.php | 2 +- database/factories/OrganizationFactory.php | 4 +- database/factories/UserFactory.php | 10 ++ database/seeders/DatabaseSeeder.php | 54 +++++-- docker-compose.yml | 35 +++++ lang/en/exceptions.php | 13 ++ phpunit.xml | 1 + routes/api.php | 7 + tests/Feature/InviteTeamMemberTest.php | 138 ++++++++++++++++++ tests/Feature/RegistrationTest.php | 44 +++++- .../Endpoint/Api/V1/ImportEndpointTest.php | 33 ++++- .../Unit/Endpoint/Api/V1/UserEndpointTest.php | 85 +++++++++++ tests/Unit/Model/UserModelTest.php | 45 ++++++ .../Import/ImportDatabaseHelperTest.php | 22 ++- tests/Unit/Service/UserServiceTest.php | 37 +++++ 38 files changed, 880 insertions(+), 87 deletions(-) create mode 100644 app/Exceptions/Api/ApiException.php create mode 100644 app/Exceptions/Api/TimeEntryStillRunningApiException.php create mode 100644 app/Exceptions/Api/UserNotPlaceholderApiException.php delete mode 100644 app/Exceptions/ApiException.php delete mode 100644 app/Exceptions/TimeEntryStillRunning.php create mode 100644 app/Http/Controllers/Api/V1/UserController.php create mode 100644 app/Http/Requests/V1/User/UserIndexRequest.php create mode 100644 app/Http/Resources/V1/User/UserCollection.php create mode 100644 app/Http/Resources/V1/User/UserResource.php create mode 100644 app/Listeners/RemovePlaceholder.php create mode 100644 app/Service/UserService.php create mode 100644 lang/en/exceptions.php create mode 100644 tests/Unit/Endpoint/Api/V1/UserEndpointTest.php create mode 100644 tests/Unit/Service/UserServiceTest.php diff --git a/.env.example b/.env.example index d3e03c26..ed50bd57 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,7 @@ MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_NAME="${APP_NAME}" AWS_ACCESS_KEY_ID= diff --git a/.gitignore b/.gitignore index e6028879..ffdee140 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ yarn-error.log /blob-report/ /playwright/.cache/ /coverage +/extensions/* diff --git a/README.md b/README.md index eb0c4796..4f7a64f7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Add the following entry to your `/etc/hosts` ``` 127.0.0.1 solidtime.test 127.0.0.1 playwright.solidtime.test +127.0.0.1 mail.solidtime.test ``` ## Running E2E Tests diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 292463ef..3702cf5a 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -6,9 +6,12 @@ use App\Models\Organization; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Laravel\Fortify\Contracts\CreatesNewUsers; use Laravel\Jetstream\Jetstream; @@ -20,12 +23,27 @@ class CreateNewUser implements CreatesNewUsers * Create a newly registered user. * * @param array $input + * + * @throws ValidationException */ public function create(array $input): User { Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'name' => [ + 'required', + 'string', + 'max:255', + ], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->where('is_placeholder', '=', false); + }), + ], 'password' => $this->passwordRules(), 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', ])->validate(); diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 47f9ccfa..7a84d53f 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -8,8 +8,10 @@ use App\Models\User; use Closure; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; +use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Laravel\Jetstream\Contracts\AddsTeamMembers; use Laravel\Jetstream\Events\AddingTeamMember; use Laravel\Jetstream\Events\TeamMemberAdded; @@ -21,21 +23,24 @@ class AddOrganizationMember implements AddsTeamMembers /** * Add a new team member to the given team. */ - public function add(User $user, Organization $organization, string $email, ?string $role = null): void + public function add(User $owner, Organization $organization, string $email, ?string $role = null): void { - Gate::forUser($user)->authorize('addTeamMember', $organization); + Gate::forUser($owner)->authorize('addTeamMember', $organization); $this->validate($organization, $email, $role); - $newTeamMember = Jetstream::findUserByEmailOrFail($email); + $newOrganizationMember = User::query() + ->where('email', $email) + ->where('is_placeholder', '=', false) + ->firstOrFail(); - AddingTeamMember::dispatch($organization, $newTeamMember); + AddingTeamMember::dispatch($organization, $newOrganizationMember); $organization->users()->attach( - $newTeamMember, ['role' => $role] + $newOrganizationMember, ['role' => $role] ); - TeamMemberAdded::dispatch($organization, $newTeamMember); + TeamMemberAdded::dispatch($organization, $newOrganizationMember); } /** @@ -46,9 +51,7 @@ protected function validate(Organization $organization, string $email, ?string $ Validator::make([ 'email' => $email, 'role' => $role, - ], $this->rules(), [ - 'email.exists' => __('We were unable to find a registered user with this email address.'), - ])->after( + ], $this->rules())->after( $this->ensureUserIsNotAlreadyOnTeam($organization, $email) )->validateWithBag('addTeamMember'); } @@ -61,7 +64,13 @@ protected function validate(Organization $organization, string $email, ?string $ protected function rules(): array { return array_filter([ - 'email' => ['required', 'email', 'exists:users'], + 'email' => [ + 'required', + 'email', + (new ExistsEloquent(User::class, 'email', function (Builder $builder) { + return $builder->where('is_placeholder', '=', false); + }))->withMessage(__('We were unable to find a registered user with this email address.')), + ], 'role' => Jetstream::hasRoles() ? ['required', 'string', new Role] : null, @@ -75,7 +84,7 @@ protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $emai { return function ($validator) use ($team, $email) { $validator->errors()->addIf( - $team->hasUserWithEmail($email), + $team->hasRealUserWithEmail($email), 'email', __('This user already belongs to the team.') ); diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php index a688fffa..686a9978 100644 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ b/app/Actions/Jetstream/InviteOrganizationMember.php @@ -34,6 +34,7 @@ public function invite(User $user, Organization $organization, string $email, ?s InvitingTeamMember::dispatch($organization, $email, $role); + /** @var TeamInvitation $invitation */ $invitation = $organization->teamInvitations()->create([ 'email' => $email, 'role' => $role, @@ -50,9 +51,7 @@ protected function validate(Organization $organization, string $email, ?string $ Validator::make([ 'email' => $email, 'role' => $role, - ], $this->rules($organization), [ - 'email.unique' => __('This user has already been invited to the team.'), - ])->after( + ], $this->rules($organization))->after( $this->ensureUserIsNotAlreadyOnTeam($organization, $email) )->validateWithBag('addTeamMember'); } @@ -68,10 +67,10 @@ protected function rules(Organization $organization): array 'email' => [ 'required', 'email', - new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) { + (new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) { /** @var Builder $builder */ return $builder->whereBelongsTo($organization, 'organization'); - }), + }))->withMessage(__('This user has already been invited to the team.')), ], 'role' => Jetstream::hasRoles() ? ['required', 'string', new Role] @@ -86,7 +85,7 @@ protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, stri { return function ($validator) use ($organization, $email) { $validator->errors()->addIf( - $organization->hasUserWithEmail($email), + $organization->hasRealUserWithEmail($email), 'email', __('This user already belongs to the team.') ); diff --git a/app/Exceptions/Api/ApiException.php b/app/Exceptions/Api/ApiException.php new file mode 100644 index 00000000..bcd5ae01 --- /dev/null +++ b/app/Exceptions/Api/ApiException.php @@ -0,0 +1,46 @@ +json([ + 'error' => true, + 'key' => $this->getKey(), + 'message' => $this->getTranslatedMessage(), + ], 400); + } + + /** + * Get the key for the exception. + */ + public function getKey(): string + { + if (defined(static::class.'::KEY')) { + return static::KEY; + } + + throw new LogicException('API exceptions need the KEY constant defined.'); + } + + /** + * Get the translated message for the exception. + */ + public function getTranslatedMessage(): string + { + return __('exceptions.api.'.$this->getKey()); + } +} diff --git a/app/Exceptions/Api/TimeEntryStillRunningApiException.php b/app/Exceptions/Api/TimeEntryStillRunningApiException.php new file mode 100644 index 00000000..e110a9ae --- /dev/null +++ b/app/Exceptions/Api/TimeEntryStillRunningApiException.php @@ -0,0 +1,10 @@ +json([ - 'error' => true, - 'message' => $this->getMessage(), - ], 400); - } -} diff --git a/app/Exceptions/TimeEntryStillRunning.php b/app/Exceptions/TimeEntryStillRunning.php deleted file mode 100644 index a4ee00a2..00000000 --- a/app/Exceptions/TimeEntryStillRunning.php +++ /dev/null @@ -1,9 +0,0 @@ -get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) { // TODO: API documentation - // TODO: Create concept for api exceptions - throw new TimeEntryStillRunning('User already has an active time entry'); + throw new TimeEntryStillRunningApiException(); } $timeEntry = new TimeEntry(); diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php new file mode 100644 index 00000000..94ff6f2e --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -0,0 +1,56 @@ +checkPermission($organization, 'users:view'); + + $users = $organization->users() + ->paginate(); + + return UserCollection::make($users); + } + + /** + * Invite a placeholder user to become a real user in the organization + * + * @throws AuthorizationException|UserNotPlaceholderApiException + */ + public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse + { + $this->checkPermission($organization, 'users:invite-placeholder'); + + if (! $user->is_placeholder) { + throw new UserNotPlaceholderApiException(); + } + + app(InvitesTeamMembers::class)->invite( + $request->user(), + $organization, + $user->email, + 'employee' + ); + + return response()->json($user); + } +} diff --git a/app/Http/Requests/V1/User/UserIndexRequest.php b/app/Http/Requests/V1/User/UserIndexRequest.php new file mode 100644 index 00000000..f600d01b --- /dev/null +++ b/app/Http/Requests/V1/User/UserIndexRequest.php @@ -0,0 +1,26 @@ +> + */ + public function rules(): array + { + return [ + ]; + } +} diff --git a/app/Http/Resources/V1/User/UserCollection.php b/app/Http/Resources/V1/User/UserCollection.php new file mode 100644 index 00000000..e9461a8f --- /dev/null +++ b/app/Http/Resources/V1/User/UserCollection.php @@ -0,0 +1,17 @@ +> + */ + public function toArray(Request $request): array + { + /** @var Membership $membership */ + $membership = $this->resource->getRelationValue('membership'); + + return [ + /** @var string $id ID */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string $email Email */ + 'email' => $this->resource->email, + /** @var string $role Role */ + 'role' => $membership->role, + /** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */ + 'is_placeholder' => $this->resource->is_placeholder, + ]; + } +} diff --git a/app/Listeners/RemovePlaceholder.php b/app/Listeners/RemovePlaceholder.php new file mode 100644 index 00000000..4ba70a9d --- /dev/null +++ b/app/Listeners/RemovePlaceholder.php @@ -0,0 +1,30 @@ +where('is_placeholder', '=', true) + ->where('email', '=', $event->user->email) + ->belongsToOrganization($event->team) + ->get(); + + foreach ($placeholders as $placeholder) { + $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user); + } + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 62bd33b7..284675d4 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamDeleted; @@ -59,4 +60,30 @@ class Organization extends JetstreamTeam 'updated' => TeamUpdated::class, 'deleted' => TeamDeleted::class, ]; + + /** + * Get all the non-placeholder users of the organization including its owner. + * + * @return Collection + */ + public function allRealUsers(): Collection + { + return $this->realUsers->merge([$this->owner]); + } + + public function hasRealUserWithEmail(string $email): bool + { + return $this->allRealUsers()->contains(function (User $user) use ($email): bool { + return $user->email === $email; + }); + } + + /** + * @return BelongsToMany + */ + public function realUsers(): BelongsToMany + { + return $this->users() + ->where('is_placeholder', false); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 19e6bc8f..a58a737f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,8 @@ use Database\Factories\UserFactory; use Filament\Panel; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -21,9 +23,16 @@ * @property string $id * @property string $name * @property string $email + * @property string|null $email_verified_at + * @property string|null $password + * @property bool $is_placeholder + * @property Collection $organizations + * @property Collection $timeEntries * * @method HasMany ownedTeams() * @method static UserFactory factory() + * @method static Builder query() + * @method Builder belongsToOrganization(Organization $organization) */ class User extends Authenticatable { @@ -97,4 +106,27 @@ public function organizations(): BelongsToMany ->withTimestamps() ->as('membership'); } + + /** + * @return HasMany + */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class); + } + + /** + * @param Builder $builder + * @return Builder + */ + public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder + { + return $builder->where(function (Builder $builder) use ($organization): Builder { + return $builder->whereHas('organizations', function (Builder $query) use ($organization): void { + $query->whereKey($organization->getKey()); + })->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void { + $query->whereKey($organization->getKey()); + }); + }); + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index ee09f108..4dc848b5 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,10 +4,11 @@ namespace App\Providers; +use App\Listeners\RemovePlaceholder; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Illuminate\Support\Facades\Event; +use Laravel\Jetstream\Events\TeamMemberAdded; class EventServiceProvider extends ServiceProvider { @@ -20,6 +21,9 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], + TeamMemberAdded::class => [ + RemovePlaceholder::class, + ], ]; /** diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 1de94d23..e3757c7c 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -75,6 +75,8 @@ protected function configurePermissions(): void 'organizations:view', 'organizations:update', 'import', + 'users:invite-placeholder', + 'users:view', ])->description('Administrator users can perform any action.'); Jetstream::role('manager', 'Manager', [ @@ -95,6 +97,7 @@ protected function configurePermissions(): void 'tags:update', 'tags:delete', 'organizations:view', + 'users:view', ])->description('Editor users have the ability to read, create, and update.'); Jetstream::role('employee', 'Employee', [ diff --git a/app/Service/Import/Importers/ImporterProvider.php b/app/Service/Import/Importers/ImporterProvider.php index 4ed1033f..ed413566 100644 --- a/app/Service/Import/Importers/ImporterProvider.php +++ b/app/Service/Import/Importers/ImporterProvider.php @@ -6,6 +6,9 @@ class ImporterProvider { + /** + * @var array> + */ private array $importers = [ 'toggl_time_entries' => TogglTimeEntriesImporter::class, ]; diff --git a/app/Service/UserService.php b/app/Service/UserService.php new file mode 100644 index 00000000..e554e078 --- /dev/null +++ b/app/Service/UserService.php @@ -0,0 +1,23 @@ +whereBelongsTo($organization, 'organization') + ->whereBelongsTo($fromUser, 'user') + ->update([ + 'user_id' => $toUser->getKey(), + ])); + } +} diff --git a/config/telescope.php b/config/telescope.php index 21d8ddda..d4057545 100644 --- a/config/telescope.php +++ b/config/telescope.php @@ -155,7 +155,7 @@ Watchers\LogWatcher::class => [ 'enabled' => env('TELESCOPE_LOG_WATCHER', true), - 'level' => 'error', + 'level' => 'debug', ], Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index e0d629e4..e1efdea6 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -27,10 +27,10 @@ public function definition(): array ]; } - public function withOwner(): self + public function withOwner(?User $owner = null): self { return $this->state(fn (array $attributes) => [ - 'user_id' => User::factory(), + 'user_id' => $owner === null ? User::factory() : $owner, ]); } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 60f31ffb..893c7e65 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -31,9 +31,19 @@ public function definition(): array 'remember_token' => Str::random(10), 'profile_photo_path' => null, 'current_team_id' => null, + 'is_placeholder' => false, ]; } + public function placeholder(bool $placeholder = true): static + { + return $this->state(function (array $attributes) use ($placeholder): array { + return [ + 'is_placeholder' => $placeholder, + ]; + }); + } + /** * Indicate that the model's email address should be unverified. */ diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7dd88fa8..b59a486d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -22,31 +22,57 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->deleteAll(); - $organization1 = Organization::factory()->create([ + $userAcmeOwner = User::factory()->create([ + 'name' => 'ACME Admin', + 'email' => 'owner@acme.test', + ]); + $organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([ 'name' => 'ACME Corp', ]); - $user1 = User::factory()->withPersonalOrganization()->create([ + $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); - $employee1 = User::factory()->withPersonalOrganization()->create([ - 'name' => 'Test User', - 'email' => 'employee@example.com', - ]); - $userAcmeAdmin = User::factory()->create([ + $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([ 'name' => 'ACME Admin', 'email' => 'admin@acme.test', ]); - $user1->organizations()->attach($organization1, [ + $userAcmeEmployee = User::factory()->withPersonalOrganization()->create([ + 'name' => 'Max Mustermann', + 'email' => 'max.mustermann@acme.test', + ]); + $userAcmePlaceholder = User::factory()->placeholder()->create([ + 'name' => 'Old Employee', + 'email' => 'old.employee@acme.test', + 'password' => null, + ]); + $userAcmeManager->organizations()->attach($organizationAcme, [ 'role' => 'manager', ]); - $userAcmeAdmin->organizations()->attach($organization1, [ + $userAcmeAdmin->organizations()->attach($organizationAcme, [ 'role' => 'admin', ]); - $timeEntriesEmployees = TimeEntry::factory() + $userAcmeEmployee->organizations()->attach($organizationAcme, [ + 'role' => 'employee', + ]); + $userAcmePlaceholder->organizations()->attach($organizationAcme, [ + 'role' => 'employee', + ]); + + $timeEntriesAcmeAdmin = TimeEntry::factory() + ->count(10) + ->forUser($userAcmeAdmin) + ->forOrganization($organizationAcme) + ->create(); + $timeEntriesAcmePlaceholder = TimeEntry::factory() + ->count(10) + ->forUser($userAcmePlaceholder) + ->forOrganization($organizationAcme) + ->create(); + $timeEntriesAcmePlaceholder = TimeEntry::factory() ->count(10) - ->forUser($employee1) - ->forOrganization($organization1) + ->forUser($userAcmeEmployee) + ->forOrganization($organizationAcme) ->create(); $client = Client::factory()->create([ 'name' => 'Big Company', @@ -63,11 +89,11 @@ public function run(): void $organization2 = Organization::factory()->create([ 'name' => 'Rival Corp', ]); - $user1 = User::factory()->withPersonalOrganization()->create([ + $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Other User', 'email' => 'test@rival-company.test', ]); - $user1->organizations()->attach($organization2, [ + $userAcmeManager->organizations()->attach($organization2, [ 'role' => 'admin', ]); $otherCompanyProject = Project::factory()->forClient($client)->create([ diff --git a/docker-compose.yml b/docker-compose.yml index 876bcfe0..d4faaf13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,10 +57,43 @@ services: - '${DB_USERNAME}' retries: 3 timeout: 5s + pgsql_test: + image: 'postgres:15' + environment: + PGPASSWORD: '${DB_PASSWORD:-secret}' + POSTGRES_DB: '${DB_DATABASE}' + POSTGRES_USER: '${DB_USERNAME}' + POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' + volumes: + - 'sail-pgsql-test:/var/lib/postgresql/data' + - './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' + networks: + - sail + healthcheck: + test: + - CMD + - pg_isready + - '-q' + - '-d' + - '${DB_DATABASE}' + - '-U' + - '${DB_USERNAME}' + retries: 3 + timeout: 5s mailpit: image: 'axllent/mailpit:latest' + labels: + - "traefik.enable=true" + - "traefik.docker.network=${NETWORK_NAME}" + - "traefik.http.routers.solidtime-mailpit.rule=Host(`mail.${NGINX_HOST_NAME}`)" + - "traefik.http.routers.solidtime-mailpit.entrypoints=web" + - "traefik.http.services.solidtime-mailpit.loadbalancer.server.port=8025" + - "traefik.http.routers.solidtime-mailpit-https.rule=Host(`mail.${NGINX_HOST_NAME}`)" + - "traefik.http.routers.solidtime-mailpit-https.entrypoints=websecure" + - "traefik.http.routers.solidtime-mailpit-https.tls=true" networks: - sail + - reverse-proxy playwright: image: mcr.microsoft.com/playwright:v1.41.1-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] @@ -88,3 +121,5 @@ networks: volumes: sail-pgsql: driver: local + sail-pgsql-test: + driver: local diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php new file mode 100644 index 00000000..706574fb --- /dev/null +++ b/lang/en/exceptions.php @@ -0,0 +1,13 @@ + [ + TimeEntryStillRunningApiException::KEY => 'Time entry is still running', + UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder', + ], +]; diff --git a/phpunit.xml b/phpunit.xml index 82235621..f5ab132a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,7 @@ + diff --git a/routes/api.php b/routes/api.php index a75683a9..ca46d271 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TimeEntryController; +use App\Http\Controllers\Api\V1\UserController; use Illuminate\Support\Facades\Route; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -29,6 +30,12 @@ Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update'); }); + // User routes + Route::name('users.')->group(static function () { + Route::get('/organizations/{organization}/users', [UserController::class, 'index'])->name('index'); + Route::post('/organizations/{organization}/users/{user}/invite-placeholder', [UserController::class, 'invitePlaceholder'])->name('invite-placeholder'); + }); + // Project routes Route::name('projects.')->group(static function () { Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index'); diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index a4f14b29..60bffb6b 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -4,9 +4,11 @@ namespace Tests\Feature; +use App\Models\TimeEntry; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\URL; use Laravel\Jetstream\Mail\TeamInvitation; use Tests\TestCase; @@ -31,6 +33,49 @@ public function test_team_members_can_be_invited_to_team(): void $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); } + public function test_team_member_can_not_be_invited_to_team_if_already_on_team(): void + { + // Arrange + Mail::fake(); + $user = User::factory()->withPersonalOrganization()->create(); + $existingUser = User::factory()->create(); + $user->currentTeam->users()->attach($existingUser, ['role' => 'admin']); + $this->actingAs($user); + + // Act + $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ + 'email' => $existingUser->email, + 'role' => 'admin', + ]); + + // Assert + $response->assertInvalid(['email'], 'addTeamMember'); + Mail::assertNotSent(TeamInvitation::class); + $this->assertCount(0, $user->currentTeam->fresh()->teamInvitations); + } + + public function test_team_member_can_be_invited_to_team_if_already_on_team_as_placeholder(): void + { + // Arrange + Mail::fake(); + $user = User::factory()->withPersonalOrganization()->create(); + $existingUser = User::factory()->create([ + 'is_placeholder' => true, + ]); + $user->currentTeam->users()->attach($existingUser, ['role' => 'employee']); + $this->actingAs($user); + + // Act + $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ + 'email' => $existingUser->email, + 'role' => 'employee', + ]); + + // Assert + Mail::assertSent(TeamInvitation::class); + $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); + } + public function test_team_member_invitations_can_be_cancelled(): void { // Arrange @@ -49,4 +94,97 @@ public function test_team_member_invitations_can_be_cancelled(): void // Assert $this->assertCount(0, $user->currentTeam->fresh()->teamInvitations); } + + public function test_team_member_invitations_can_be_accepted(): void + { + // Arrange + Mail::fake(); + $owner = User::factory()->withPersonalOrganization()->create(); + $user = User::factory()->withPersonalOrganization()->create(); + $invitation = $owner->currentTeam->teamInvitations()->create([ + 'email' => $user->email, + 'role' => 'employee', + ]); + $this->actingAs($user); + + // Act + $acceptUrl = URL::temporarySignedRoute( + 'team-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()] + ); + $response = $this->get($acceptUrl); + + // Assert + $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); + $user->refresh(); + $this->assertCount(1, $user->organizations); + $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); + } + + public function test_team_member_invitations_of_placeholder_can_be_accepted_and_migrates_date_to_real_user(): void + { + // Arrange + Mail::fake(); + $placeholder = User::factory()->withPersonalOrganization()->create([ + 'is_placeholder' => true, + ]); + + $owner = User::factory()->withPersonalOrganization()->create(); + $owner->currentTeam->users()->attach($placeholder, ['role' => 'employee']); + $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->createMany(5); + + $user = User::factory()->withPersonalOrganization()->create([ + 'email' => $placeholder->email, + ]); + + $invitation = $owner->currentTeam->teamInvitations()->create([ + 'email' => $user->email, + 'role' => 'employee', + ]); + $this->actingAs($user); + + // Act + $acceptUrl = URL::temporarySignedRoute( + 'team-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()] + ); + $response = $this->get($acceptUrl); + + // Assert + $user->refresh(); + $placeholder->refresh(); + $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); + $this->assertCount(1, $user->organizations); + $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); + $this->assertCount(5, $user->timeEntries); + $this->assertCount(0, $placeholder->timeEntries); + } + + public function test_team_member_accept_fails_if_user_with_that_email_does_not_exist(): void + { + // Arrange + Mail::fake(); + $owner = User::factory()->withPersonalOrganization()->create(); + $user = User::factory()->withPersonalOrganization()->create(); + $invitation = $owner->currentTeam->teamInvitations()->create([ + 'email' => 'firstname.lastname@mail.test', + 'role' => 'employee', + ]); + $this->actingAs($user); + + // Act + $acceptUrl = URL::temporarySignedRoute( + 'team-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()] + ); + $response = $this->get($acceptUrl); + + // Assert + $this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations); + $user->refresh(); + $this->assertCount(0, $user->organizations); + } } diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 23ab3426..4373809f 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; +use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Fortify\Features; @@ -38,10 +39,47 @@ public function test_registration_screen_cannot_be_rendered_if_support_is_disabl public function test_new_users_can_register(): void { - if (! Features::enabled(Features::registration())) { - $this->markTestSkipped('Registration support is not enabled.'); - } + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + } + + public function test_new_users_can_not_register_if_user_with_email_already_exists(): void + { + // Arrange + $user = User::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + $this->assertFalse($this->isAuthenticated(), 'The user is authenticated'); + $response->assertInvalid(['email']); + } + + public function test_new_users_can_register_if_placeholder_user_with_email_already_exists(): void + { + // Arrange + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'is_placeholder' => true, + ]); + // Act $response = $this->post('/register', [ 'name' => 'Test User', 'email' => 'test@example.com', diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php index c53b2fde..5534cf14 100644 --- a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Models\Organization; +use App\Service\Import\Importers\ReportDto; use App\Service\Import\ImportService; use Laravel\Passport\Passport; use Mockery\MockInterface; @@ -20,7 +21,7 @@ public function test_import_fails_if_user_does_not_have_permission() Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.import', ['organization' => $data->organization->id]), [ + $response = $this->postJson(route('api.v1.import.import', ['organization' => $data->organization->id]), [ 'type' => 'toggl_time_entries', 'data' => 'some data', 'options' => [], @@ -41,6 +42,14 @@ public function test_import_calls_import_service_if_user_has_permission(): void ->withArgs(function (Organization $organization, string $importerType, string $data, array $options) use (&$user): bool { return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data' && $options === []; }) + ->andReturn(new ReportDto( + clientsCreated: 1, + projectsCreated: 2, + tasksCreated: 3, + timeEntriesCreated: 4, + tagsCreated: 5, + usersCreated: 6, + )) ->once(); }); Passport::actingAs($user->user); @@ -54,5 +63,27 @@ public function test_import_calls_import_service_if_user_has_permission(): void // Assert $response->assertStatus(200); + $response->assertExactJson([ + 'report' => [ + 'clients' => [ + 'created' => 1, + ], + 'projects' => [ + 'created' => 2, + ], + 'tasks' => [ + 'created' => 3, + ], + 'time-entries' => [ + 'created' => 4, + ], + 'tags' => [ + 'created' => 5, + ], + 'users' => [ + 'created' => 6, + ], + ], + ]); } } diff --git a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php new file mode 100644 index 00000000..9cbe27a2 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php @@ -0,0 +1,85 @@ +createUserWithPermission([ + 'users:view', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.users.index', $data->organization->id)); + + // Assert + $response->assertStatus(200); + } + + public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + $user = User::factory()->create([ + 'is_placeholder' => true, + ]); + $data->organization->users()->attach($user); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id])); + + // Assert + $response->assertStatus(403); + } + + public function test_invite_placeholder_fails_if_user_is_not_part_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'users:invite-placeholder', + ]); + $otherOrganization = Organization::factory()->create(); + $user = User::factory()->create([ + 'is_placeholder' => true, + ]); + $otherOrganization->users()->attach($user); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id])); + + // Assert + $response->assertStatus(403); + } + + public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'users:invite-placeholder', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $data->user->id])); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'user_not_placeholder', + 'message' => 'The given user is not a placeholder', + ]); + } +} diff --git a/tests/Unit/Model/UserModelTest.php b/tests/Unit/Model/UserModelTest.php index c0d72d37..228284cf 100644 --- a/tests/Unit/Model/UserModelTest.php +++ b/tests/Unit/Model/UserModelTest.php @@ -4,6 +4,8 @@ namespace Tests\Unit\Model; +use App\Models\Organization; +use App\Models\TimeEntry; use App\Models\User; use App\Providers\Filament\AdminPanelProvider; use Filament\Panel; @@ -42,4 +44,47 @@ public function test_user_in_super_admin_config_can_access_admin_panel(): void // Assert $this->assertTrue($canAccess); } + + public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void + { + // Arrange + $owner = User::factory()->create(); + $organization = Organization::factory()->withOwner($owner)->create(); + $user = User::factory()->create(); + $user->organizations()->attach($organization, [ + 'role' => 'employee', + ]); + $otherOrganization = Organization::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->organizations()->attach($otherOrganization, [ + 'role' => 'employee', + ]); + + // Act + $users = User::query() + ->belongsToOrganization($organization) + ->get(); + + // Assert + $this->assertCount(2, $users); + $userIds = $users->pluck('id')->toArray(); + $this->assertContains($user->getKey(), $userIds); + $this->assertContains($owner->getKey(), $userIds); + } + + public function test_it_has_many_time_entries(): void + { + // Arrange + $user = User::factory()->create(); + $timeEntries = TimeEntry::factory()->forUser($user)->createMany(3); + + // Act + $user->refresh(); + $timeEntriesRel = $user->timeEntries; + + // Assert + $this->assertNotNull($timeEntriesRel); + $this->assertCount(3, $timeEntriesRel); + $this->assertTrue($timeEntriesRel->first()->is($timeEntries->first())); + } } diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php index f0a03d9b..d1d156f2 100644 --- a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -51,21 +51,27 @@ public function test_get_key_attach_to_existing_creates_model_if_not_existing(): ]); } - public function test_get_key_not_attach_to_existing_returns_key_for_identifier_without_creating_model(): void + public function test_get_key_not_attach_to_existing_is_not_implemented_yet(): void { // Arrange $project = Project::factory()->create(); $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false); // Act - $key = $helper->getKey([ - 'name' => $project->name, - 'organization_id' => $project->organization_id, - ], [ - 'color' => '#000000', - ]); + try { + $key = $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $project->organization_id, + ], [ + 'color' => '#000000', + ]); + } catch (\Exception $e) { + $this->assertSame('Not implemented', $e->getMessage()); + + return; + } // Assert - $this->assertNotSame($project->getKey(), $key); + $this->fail(); } } diff --git a/tests/Unit/Service/UserServiceTest.php b/tests/Unit/Service/UserServiceTest.php new file mode 100644 index 00000000..79494844 --- /dev/null +++ b/tests/Unit/Service/UserServiceTest.php @@ -0,0 +1,37 @@ +create(); + $otherUser = User::factory()->create(); + $fromUser = User::factory()->create(); + $toUser = User::factory()->create(); + TimeEntry::factory()->forOrganization($organization)->forUser($otherUser)->createMany(3); + TimeEntry::factory()->forOrganization($organization)->forUser($fromUser)->createMany(3); + + // Act + $userService = app(UserService::class); + $userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser); + + // Assert + $this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count()); + $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count()); + $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count()); + } +} From b1d4a47c0306735050bf78eced17a2eb276ebdf5 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 11 Mar 2024 18:02:54 +0100 Subject: [PATCH 03/20] add dashboard frontend --- .eslintrc.cjs | 6 +- README.md | 15 + .../Controllers/Api/V1/ProjectController.php | 10 + app/Http/Controllers/Api/V1/TagController.php | 8 + .../Api/V1/TimeEntryController.php | 11 +- .../V1/TimeEntry/TimeEntryUpdateRequest.php | 2 +- .../V1/TimeEntry/TimeEntryResource.php | 6 +- app/Providers/JetstreamServiceProvider.php | 4 +- config/scramble.php | 6 +- docker-compose.yml | 2 + e2e/auth.spec.ts | 17 +- e2e/organization.spec.ts | 24 +- e2e/timetracker.spec.ts | 507 ++++++++ openapi.json | 1 + openapi.json.client.ts | 833 +++++++++++++ package-lock.json | 1082 ++++++++++++++++- package.json | 14 +- playwright/config.ts | 2 +- playwright/fixtures.ts | 7 +- public/fonts/Outfit-VariableFont_wght.ttf | Bin 0 -> 110572 bytes resources/css/app.css | 12 + resources/js/Components/ActionMessage.vue | 2 +- resources/js/Components/ActionSection.vue | 2 +- resources/js/Components/ConfirmationModal.vue | 5 +- .../js/Components/CurrentSidebarTimer.vue | 13 + .../Dashboard/ActivityGraphCard.vue | 87 ++ .../js/Components/Dashboard/DashboardCard.vue | 25 + .../Dashboard/DayOverviewCardEntry.vue | 103 ++ .../Dashboard/LastSevenDaysCard.vue | 22 + .../Dashboard/ProjectsChartCard.vue | 105 ++ .../Dashboard/RecentlyTrackedTasksCard.vue | 23 + .../RecentlyTrackedTasksCardEntry.vue | 23 + .../Components/Dashboard/TeamActivityCard.vue | 27 + .../Dashboard/TeamActivityCardEntry.vue | 31 + .../Components/Dashboard/ThisWeekOverview.vue | 171 +++ resources/js/Components/DialogModal.vue | 4 +- resources/js/Components/Dropdown.vue | 61 +- resources/js/Components/DropdownLink.vue | 16 +- resources/js/Components/FormSection.vue | 4 +- resources/js/Components/InputLabel.vue | 2 +- .../js/Components/NavigationSidebarItem.vue | 34 + .../js/Components/OrganizationSwitcher.vue | 134 ++ resources/js/Components/ResponsiveNavLink.vue | 2 +- resources/js/Components/SecondaryButton.vue | 3 +- resources/js/Components/SectionBorder.vue | 2 +- resources/js/Components/SectionTitle.vue | 4 +- resources/js/Components/TextInput.vue | 4 +- resources/js/Components/TimeTracker.vue | 178 +++ resources/js/Components/UserSettingsIcon.vue | 89 ++ resources/js/Components/Welcome.vue | 177 --- .../common/BillableToggleButton.vue | 42 + resources/js/Components/common/CardTitle.vue | 22 + .../js/Components/common/ProjectBadge.vue | 51 + .../js/Components/common/ProjectDropdown.vue | 162 +++ .../Components/common/ProjectDropdownItem.vue | 19 + resources/js/Components/common/StatCard.vue | 18 + .../js/Components/common/TagDropdown.vue | 220 ++++ .../js/Components/common/TagDropdownItem.vue | 28 + .../common/TimeTrackerStartStop.vue | 89 ++ resources/js/Layouts/AppLayout.vue | 567 ++------- .../js/Pages/API/Partials/ApiTokenManager.vue | 14 +- resources/js/Pages/Auth/ConfirmPassword.vue | 2 +- resources/js/Pages/Auth/ForgotPassword.vue | 2 +- resources/js/Pages/Auth/Login.vue | 6 +- resources/js/Pages/Auth/Register.vue | 6 +- .../js/Pages/Auth/TwoFactorChallenge.vue | 4 +- resources/js/Pages/Auth/VerifyEmail.vue | 6 +- resources/js/Pages/Dashboard.vue | 106 +- .../Pages/Profile/Partials/DeleteUserForm.vue | 2 +- .../LogoutOtherBrowserSessionsForm.vue | 8 +- .../Partials/TwoFactorAuthenticationForm.vue | 14 +- .../Partials/UpdateProfileInformationForm.vue | 2 +- .../Pages/Teams/Partials/DeleteTeamForm.vue | 2 +- .../Teams/Partials/TeamMemberManager.vue | 14 +- resources/js/app.ts | 3 + resources/js/types/models.ts | 11 - resources/js/utils/api.ts | 4 + resources/js/utils/color.ts | 25 + resources/js/utils/money.ts | 6 + resources/js/utils/time.ts | 8 + resources/js/utils/useCurrentTimeEntry.ts | 151 +++ resources/js/utils/useProjects.ts | 24 + resources/js/utils/useTags.ts | 53 + resources/js/utils/useUser.ts | 17 + routes/web.php | 305 ++++- tailwind.config.js | 36 +- 86 files changed, 5123 insertions(+), 848 deletions(-) create mode 100644 e2e/timetracker.spec.ts create mode 100644 openapi.json create mode 100644 openapi.json.client.ts create mode 100644 public/fonts/Outfit-VariableFont_wght.ttf create mode 100644 resources/js/Components/CurrentSidebarTimer.vue create mode 100644 resources/js/Components/Dashboard/ActivityGraphCard.vue create mode 100644 resources/js/Components/Dashboard/DashboardCard.vue create mode 100644 resources/js/Components/Dashboard/DayOverviewCardEntry.vue create mode 100644 resources/js/Components/Dashboard/LastSevenDaysCard.vue create mode 100644 resources/js/Components/Dashboard/ProjectsChartCard.vue create mode 100644 resources/js/Components/Dashboard/RecentlyTrackedTasksCard.vue create mode 100644 resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue create mode 100644 resources/js/Components/Dashboard/TeamActivityCard.vue create mode 100644 resources/js/Components/Dashboard/TeamActivityCardEntry.vue create mode 100644 resources/js/Components/Dashboard/ThisWeekOverview.vue create mode 100644 resources/js/Components/NavigationSidebarItem.vue create mode 100644 resources/js/Components/OrganizationSwitcher.vue create mode 100644 resources/js/Components/TimeTracker.vue create mode 100644 resources/js/Components/UserSettingsIcon.vue delete mode 100644 resources/js/Components/Welcome.vue create mode 100644 resources/js/Components/common/BillableToggleButton.vue create mode 100644 resources/js/Components/common/CardTitle.vue create mode 100644 resources/js/Components/common/ProjectBadge.vue create mode 100644 resources/js/Components/common/ProjectDropdown.vue create mode 100644 resources/js/Components/common/ProjectDropdownItem.vue create mode 100644 resources/js/Components/common/StatCard.vue create mode 100644 resources/js/Components/common/TagDropdown.vue create mode 100644 resources/js/Components/common/TagDropdownItem.vue create mode 100644 resources/js/Components/common/TimeTrackerStartStop.vue create mode 100644 resources/js/utils/api.ts create mode 100644 resources/js/utils/color.ts create mode 100644 resources/js/utils/money.ts create mode 100644 resources/js/utils/time.ts create mode 100644 resources/js/utils/useCurrentTimeEntry.ts create mode 100644 resources/js/utils/useProjects.ts create mode 100644 resources/js/utils/useTags.ts create mode 100644 resources/js/utils/useUser.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 93cf0f8a..2bb9b68b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -2,11 +2,7 @@ require("@rushstack/eslint-patch/modern-module-resolution") module.exports = { - extends: [ - 'plugin:vue/vue3-essential', - '@vue/eslint-config-typescript/recommended', - '@vue/eslint-config-prettier' - ], + extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'], rules: { 'vue/multi-word-component-names': 'off', } diff --git a/README.md b/README.md index eb0c4796..08b42048 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ cp .env.example .env ./vendor/bin/sail artisan migrate:fresh --seed +./vendor/bin/sail php artisan passport:install + ./vendor/bin/sail npm install ./vendor/bin/sail npm run build @@ -52,6 +54,19 @@ npx playwright install npx playwright codegen solidtime.test ``` +## E2E Troubleshooting + +If the E2E tests are not working consistently and fail with a timeout during the authentication, you might want to delete the `test-results/.auth` directory to force new test accounts to be created. + +## Generate ZOD Client + +The Zodius HTTP client is generated using the following command: + +```bash + +npm run generate:zod +``` + ## Contributing This project is in a very early stage. The structure and APIs are still subject to change and not stable. diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 5ab9ceeb..bbb40d48 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -28,6 +28,8 @@ protected function checkPermission(Organization $organization, string $permissio * Get projects * * @throws AuthorizationException + * + * @operationId getProjects */ public function index(Organization $organization): JsonResource { @@ -43,6 +45,8 @@ public function index(Organization $organization): JsonResource * Get project * * @throws AuthorizationException + * + * @operationId getProject */ public function show(Organization $organization, Project $project): JsonResource { @@ -57,6 +61,8 @@ public function show(Organization $organization, Project $project): JsonResource * Create project * * @throws AuthorizationException + * + * @operationId createProject */ public function store(Organization $organization, ProjectStoreRequest $request): JsonResource { @@ -75,6 +81,8 @@ public function store(Organization $organization, ProjectStoreRequest $request): * Update project * * @throws AuthorizationException + * + * @operationId updateProject */ public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource { @@ -90,6 +98,8 @@ public function update(Organization $organization, Project $project, ProjectUpda * Delete project * * @throws AuthorizationException + * + * @operationId deleteProject */ public function destroy(Organization $organization, Project $project): JsonResponse { diff --git a/app/Http/Controllers/Api/V1/TagController.php b/app/Http/Controllers/Api/V1/TagController.php index 58db6784..4e5bcfaa 100644 --- a/app/Http/Controllers/Api/V1/TagController.php +++ b/app/Http/Controllers/Api/V1/TagController.php @@ -27,6 +27,8 @@ protected function checkPermission(Organization $organization, string $permissio * Get tags * * @throws AuthorizationException + * + * @operationId getTags */ public function index(Organization $organization): TagCollection { @@ -44,6 +46,8 @@ public function index(Organization $organization): TagCollection * Create tag * * @throws AuthorizationException + * + * @operationId createTag */ public function store(Organization $organization, TagStoreRequest $request): TagResource { @@ -61,6 +65,8 @@ public function store(Organization $organization, TagStoreRequest $request): Tag * Update tag * * @throws AuthorizationException + * + * @operationId updateTag */ public function update(Organization $organization, Tag $tag, TagUpdateRequest $request): TagResource { @@ -76,6 +82,8 @@ public function update(Organization $organization, Tag $tag, TagUpdateRequest $r * Delete tag * * @throws AuthorizationException + * + * @operationId deleteTag */ public function destroy(Organization $organization, Tag $tag): JsonResponse { diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 0b627b1d..6919faad 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -32,6 +32,8 @@ protected function checkPermission(Organization $organization, string $permissio * Get time entries * * @throws AuthorizationException + * + * @operationId getTimeEntries */ public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource { @@ -103,6 +105,8 @@ public function index(Organization $organization, TimeEntryIndexRequest $request * Create time entry * * @throws AuthorizationException|TimeEntryStillRunning + * + * @operationId createTimeEntry */ public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource { @@ -120,7 +124,7 @@ public function store(Organization $organization, TimeEntryStoreRequest $request $timeEntry = new TimeEntry(); $timeEntry->fill($request->validated()); - $timeEntry->description = $request->get('description', ''); + $timeEntry->description = $request->get('description') ?? ''; $timeEntry->organization()->associate($organization); $timeEntry->save(); @@ -131,6 +135,8 @@ public function store(Organization $organization, TimeEntryStoreRequest $request * Update time entry * * @throws AuthorizationException + * + * @operationId updateTimeEntry */ public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource { @@ -141,6 +147,7 @@ public function update(Organization $organization, TimeEntry $timeEntry, TimeEnt } $timeEntry->fill($request->validated()); + $timeEntry->description = $request->get('description', $timeEntry->description) ?? ''; $timeEntry->save(); return new TimeEntryResource($timeEntry); @@ -150,6 +157,8 @@ public function update(Organization $organization, TimeEntry $timeEntry, TimeEnt * Delete time entry * * @throws AuthorizationException + * + * @operationId deleteTimeEntry */ public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse { diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php index f044ce72..f9c1bf4c 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php @@ -42,7 +42,7 @@ public function rules(): array ], // End of time entry (ISO 8601 format, UTC timezone) 'end' => [ - 'required', + 'present', 'nullable', 'date', // TODO 'after:start', diff --git a/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php b/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php index 9003dae1..146ba44f 100644 --- a/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php +++ b/app/Http/Resources/V1/TimeEntry/TimeEntryResource.php @@ -30,8 +30,8 @@ public function toArray(Request $request): array /** * @var string|null $end End of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */ - 'end' => $this->formatDateTime($this->resource->start), - /** @var int $duration Duration of time entry in seconds */ + 'end' => $this->formatDateTime($this->resource->end), + /** @var int|null $duration Duration of time entry in seconds */ 'duration' => $this->resource->getDuration()?->seconds, /** @var string|null $description Description of time entry */ 'description' => $this->resource->description, @@ -42,7 +42,7 @@ public function toArray(Request $request): array /** @var string $user_id ID of user */ 'user_id' => $this->resource->user_id, /** @var array $tags List of tag IDs */ - 'tags' => $this->resource->tags, + 'tags' => $this->resource->tags ?? [], ]; } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index e8e5bfb0..f1762c10 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -94,7 +94,7 @@ protected function configurePermissions(): void 'tags:update', 'tags:delete', 'organizations:view', - ])->description('Editor users have the ability to read, create, and update.'); + ])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.'); Jetstream::role('employee', 'Employee', [ 'projects:view', @@ -104,6 +104,6 @@ protected function configurePermissions(): void 'time-entries:update:own', 'time-entries:delete:own', 'organizations:view', - ])->description('Editor users have the ability to read, create, and update.'); + ])->description('Employees have the ability to read, create, and update their own time entries.'); } } diff --git a/config/scramble.php b/config/scramble.php index 6bc087ff..eb01031c 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -64,9 +64,9 @@ * ``` */ 'servers' => [ - 'Production' => 'https://app.solidtime.io', - 'Staging' => 'https://app.staging.solidtime.io', - 'Local' => 'https://soldtime.test', + 'Production' => 'https://app.solidtime.io/api', + 'Staging' => 'https://app.staging.solidtime.io/api', + 'Local' => 'https://soldtime.test/api', ], 'middleware' => [ diff --git a/docker-compose.yml b/docker-compose.yml index 876bcfe0..3874cd43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,8 @@ services: image: mcr.microsoft.com/playwright:v1.41.1-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] working_dir: /src + extra_hosts: + - "solidtime.test:${REVERSE_PROXY_IP:-10.100.100.10}" labels: - "traefik.enable=true" - "traefik.docker.network=${NETWORK_NAME}" diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index d3837545..2051647e 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -8,9 +8,7 @@ async function registerNewUser(page, email, password) { await page.getByLabel('Password', { exact: true }).fill(password); await page.getByLabel('Confirm Password').fill(password); await page.getByRole('button', { name: 'Register' }).click(); - await expect( - page.getByRole('heading', { name: 'Dashboard' }) - ).toBeVisible(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); } test('can register, logout and log back in', async ({ page }) => { @@ -18,20 +16,15 @@ test('can register, logout and log back in', async ({ page }) => { const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; const password = 'suchagreatpassword123'; await registerNewUser(page, email, password); - await expect( - page.getByRole('button', { name: "John's Organization" }) - ).toBeVisible(); - await page.locator('#currentUserButton').click(); - - await page.getByRole('button', { name: 'Log Out' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/'); await page.goto(PLAYWRIGHT_BASE_URL + '/login'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Log in' }).click(); - await expect( - page.getByRole('heading', { name: 'Dashboard' }) - ).toBeVisible(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); }); test('can register and delete account', async ({ page }) => { diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts index a61fb985..d079f8e6 100644 --- a/e2e/organization.spec.ts +++ b/e2e/organization.spec.ts @@ -3,8 +3,8 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; async function goToOrganizationSettings(page) { await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); - await page.locator('#currentTeamButton').click(); - await page.getByRole('link', { name: 'Team Settings' }).click(); + await page.getByTestId('organization_switcher').click(); + await page.getByText('Team Settings').click(); } test('test that organization name can be updated', async ({ page }) => { @@ -12,14 +12,28 @@ test('test that organization name can be updated', async ({ page }) => { await page.getByLabel('Team Name').fill('NEW ORG NAME'); await page.getByLabel('Team Name').press('Enter'); await page.getByLabel('Team Name').press('Meta+r'); - await expect(page.getByRole('navigation')).toContainText('NEW ORG NAME'); + await expect(page.getByTestId('organization_switcher')).toContainText( + 'NEW ORG NAME' + ); +}); + +test('test that new manager can be invited', async ({ page }) => { + await goToOrganizationSettings(page); + const editorId = Math.round(Math.random() * 10000); + await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); + await page.getByRole('button', { name: 'Manager' }).click(); + await page.getByRole('button', { name: 'Add' }).click(); + await page.reload(); + await expect(page.getByRole('main')).toContainText( + `new+${editorId}@editor.test` + ); }); -test('test that new editor can be invited', async ({ page }) => { +test('test that new employee can be invited', async ({ page }) => { await goToOrganizationSettings(page); const editorId = Math.round(Math.random() * 10000); await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); - await page.getByRole('button', { name: 'Editor' }).click(); + await page.getByRole('button', { name: 'Employee' }).click(); await page.getByRole('button', { name: 'Add' }).click(); await page.reload(); await expect(page.getByRole('main')).toContainText( diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts new file mode 100644 index 00000000..ec0789b0 --- /dev/null +++ b/e2e/timetracker.spec.ts @@ -0,0 +1,507 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; + +async function goToDashboard(page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); +} + +async function startOrStopTimerWithButton(page) { + await page + .locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]') + .click(); +} + +async function assertThatTimerHasStarted(page) { + await page.locator( + '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80' + ); +} + +async function assertNewTimeEntryResponse(page) { + await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); +} + +async function assertThatTimerIsStoped(page) { + await page.locator( + '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' + ); +} + +test('test that starting and stopping a timer without description and project works', async ({ + page, +}) => { + await goToDashboard(page); + await startOrStopTimerWithButton(page); + await assertNewTimeEntryResponse(page); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(1500); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerIsStoped(page); +}); + +test('test that starting and stopping a timer with a description works', async ({ + page, +}) => { + await goToDashboard(page); + await page + .getByTestId('time_entry_description') + .fill('New Time Entry Description'); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === + 'New Time Entry Description' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(500); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === + 'New Time Entry Description' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerIsStoped(page); +}); + +test('test that starting and updating the description while running works', async ({ + page, +}) => { + await goToDashboard(page); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(500); + await page + .getByTestId('time_entry_description') + .fill('New Time Entry Description'); + await page.getByTestId('time_entry_description').press('Tab'); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === + 'New Time Entry Description' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await page.waitForTimeout(500); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === + 'New Time Entry Description' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerIsStoped(page); +}); + +test('test that starting and updating the description while running works', async ({ + page, +}) => { + await goToDashboard(page); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(500); + await page + .getByTestId('time_entry_description') + .fill('New Time Entry Description'); + await page.getByTestId('time_entry_description').press('Tab'); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === + 'New Time Entry Description' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await page.waitForTimeout(500); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === + 'New Time Entry Description' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerIsStoped(page); +}); + +test('test that starting and updating the time while running works', async ({ + page, +}) => { + await goToDashboard(page); + await startOrStopTimerWithButton(page); + const createResponse = await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerHasStarted(page); + await page.waitForTimeout(500); + await page.getByTestId('time_entry_time').fill('20min'); + await page.getByTestId('time_entry_time').press('Tab'); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.start !== + (await createResponse.json()).data.start && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/); + await page.waitForTimeout(500); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerIsStoped(page); +}); + +test('test that entering a time starts the timer on blur', async ({ page }) => { + await goToDashboard(page); + await page.getByTestId('time_entry_time').fill('20min'); + await page.getByTestId('time_entry_time').press('Tab'); + await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerHasStarted(page); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await page.locator( + '[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70' + ); +}); + +test('test that entering a time starts the timer on enter', async ({ + page, +}) => { + await goToDashboard(page); + await page.getByTestId('time_entry_time').fill('20min'); + await page.getByTestId('time_entry_time').press('Enter'); + await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerHasStarted(page); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([]) + ); + }); + await assertThatTimerIsStoped(page); +}); + +test('test that adding a new tag works', async ({ page }) => { + const newTagName = 'New Tag' + Math.floor(Math.random() * 10000); + await goToDashboard(page); + await page.getByTestId('tag_dropdown').click(); + await page.getByTestId('tag_dropdown_search').fill(newTagName); + await page.getByTestId('tag_dropdown_search').press('Enter'); + await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.name === newTagName + ); + }); + await expect(page.getByTestId('tag_dropdown_search')).toHaveValue(''); + await expect(page.getByTestId('tag_dropdown_entries')).toHaveText( + newTagName + ); +}); + +test('test that adding a new tag when the timer is running', async ({ + page, +}) => { + const newTagName = 'New Tag' + Math.floor(Math.random() * 10000); + await goToDashboard(page); + await startOrStopTimerWithButton(page); + await assertNewTimeEntryResponse(page); + await assertThatTimerHasStarted(page); + await page.getByTestId('tag_dropdown').click(); + await page.getByTestId('tag_dropdown_search').fill(newTagName); + await page.getByTestId('tag_dropdown_search').press('Enter'); + const tagCreateResponse = await page.waitForResponse(async (response) => { + return ( + response.status() === 201 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.name === newTagName + ); + }); + await expect(page.getByTestId('tag_dropdown_search')).toHaveValue(''); + await expect(page.getByTestId('tag_dropdown_entries')).toHaveText( + newTagName + ); + + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end === null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration === null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([(await tagCreateResponse.json()).data.id]) + ); + }); + await page.getByTestId('tag_dropdown_search').press('Escape'); + await page.waitForTimeout(1000); + await startOrStopTimerWithButton(page); + await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + (await response.headerValue('Content-Type')) === + 'application/json' && + (await response.json()).data.id !== null && + (await response.json()).data.start !== null && + (await response.json()).data.end !== null && + (await response.json()).data.project_id === null && + (await response.json()).data.description === '' && + (await response.json()).data.task_id === null && + (await response.json()).data.duration !== null && + (await response.json()).data.user_id !== null && + JSON.stringify((await response.json()).data.tags) === + JSON.stringify([(await tagCreateResponse.json()).data.id]) + ); + }); + await assertThatTimerIsStoped(page); +}); + +// test that adding a new tag when the timer is running + +// test that search is working diff --git a/openapi.json b/openapi.json new file mode 100644 index 00000000..0d424c08 --- /dev/null +++ b/openapi.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Laravel","version":"0.0.1"},"servers":[{"url":"https:\/\/app.solidtime.io\/api","description":"Production"},{"url":"https:\/\/app.staging.solidtime.io\/api","description":"Staging"},{"url":"https:\/\/soldtime.test\/api","description":"Local"}],"security":[{"oauth2":[]}],"paths":{"\/v1\/organization\/{organization}\/projects":{"get":{"operationId":"getProjects","summary":"Get projects","tags":["Project"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"`ProjectCollection`","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#\/components\/schemas\/ProjectCollection"}},"required":["data"]}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"}}},"post":{"operationId":"createProject","summary":"Create project","tags":["Project"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application\/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"color":{"type":"string"}},"required":["name","color"]}}}},"responses":{"200":{"description":"`ProjectResource`","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#\/components\/schemas\/ProjectResource"}},"required":["data"]}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"},"422":{"$ref":"#\/components\/responses\/ValidationException"}}}},"\/v1\/organization\/{organization}\/projects\/{project}":{"get":{"operationId":"getProject","summary":"Get project","tags":["Project"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}},{"name":"project","in":"path","required":true,"description":"The project ID","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"`ProjectResource`","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#\/components\/schemas\/ProjectResource"}},"required":["data"]}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"}}},"put":{"operationId":"updateProject","summary":"Update project","tags":["Project"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}},{"name":"project","in":"path","required":true,"description":"The project ID","schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application\/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"color":{"type":"string"}},"required":["name","color"]}}}},"responses":{"200":{"description":"`ProjectResource`","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#\/components\/schemas\/ProjectResource"}},"required":["data"]}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"},"422":{"$ref":"#\/components\/responses\/ValidationException"}}},"delete":{"operationId":"v1.projects.destroy","summary":"Delete project","tags":["Project"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}},{"name":"project","in":"path","required":true,"description":"The project ID","schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application\/json":{"schema":{"type":"object"}}}},"responses":{"204":{"description":"No content","content":{"application\/json":{"schema":{"type":"null"}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"}}}},"\/v1\/organization\/{organization}\/time-entries":{"get":{"operationId":"v1.time-entries.index","summary":"Get time entries","tags":["TimeEntry"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"query","description":"Filter by user ID","schema":{"type":"string","format":"uuid"}},{"name":"before","in":"query","description":"Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31)","schema":{"type":["string","null"]}},{"name":"after","in":"query","description":"Filter only time entries that have a start date after (not including) the given date (example: 2021-12-31)","schema":{"type":["string","null"]}},{"name":"active","in":"query","description":"Filter only time entries that are active (have no end date, are still running)","schema":{"type":"boolean"}},{"name":"limit","in":"query","description":"Limit the number of returned time entries","schema":{"type":"integer","minimum":1,"maximum":500}},{"name":"only_full_dates","in":"query","description":"Filter makes sure that only time entries of a whole date are returned","schema":{"type":"boolean"}}],"responses":{"200":{"description":"`TimeEntryCollection`","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#\/components\/schemas\/TimeEntryCollection"}},"required":["data"]}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"},"422":{"$ref":"#\/components\/responses\/ValidationException"}}},"post":{"operationId":"v1.time-entries.store","summary":"Create time entry","tags":["TimeEntry"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application\/json":{"schema":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"ID of the user that the time entry should belong to"},"task_id":{"type":["string","null"],"format":"uuid","description":"ID of the task that the time entry should belong to"},"start":{"type":"string","description":"Start of time entry (ISO 8601 format, UTC timezone)"},"end":{"type":["string","null"],"description":"End of time entry (ISO 8601 format, UTC timezone)"},"description":{"type":["string","null"],"description":"Description of time entry"},"tags":{"type":["array","null"],"description":"List of tag IDs","items":{"type":"string","format":"uuid"}}},"required":["user_id","start","end"]}}}},"responses":{"200":{"description":"`TimeEntryResource`","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#\/components\/schemas\/TimeEntryResource"}},"required":["data"]}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"},"422":{"$ref":"#\/components\/responses\/ValidationException"}}}},"\/v1\/organization\/{organization}\/time-entries\/{timeEntry}":{"put":{"operationId":"v1.time-entries.update","summary":"Update time entry","tags":["TimeEntry"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}},{"name":"timeEntry","in":"path","required":true,"description":"The time entry ID","schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application\/json":{"schema":{"type":"object","properties":{"task_id":{"type":["string","null"],"format":"uuid","description":"ID of the task that the time entry should belong to"},"start":{"type":"string","description":"Start of time entry (ISO 8601 format, UTC timezone)"},"end":{"type":["string","null"],"description":"End of time entry (ISO 8601 format, UTC timezone)"},"description":{"type":["string","null"],"description":"Description of time entry"},"tags":{"type":["array","null"],"description":"List of tag IDs","items":{"type":"string","format":"uuid"}}},"required":["start","end"]}}}},"responses":{"200":{"description":"`TimeEntryResource`","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#\/components\/schemas\/TimeEntryResource"}},"required":["data"]}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"},"422":{"$ref":"#\/components\/responses\/ValidationException"}}},"delete":{"operationId":"v1.time-entries.destroy","summary":"Delete time entry","tags":["TimeEntry"],"parameters":[{"name":"organization","in":"path","required":true,"description":"The organization ID","schema":{"type":"string","format":"uuid"}},{"name":"timeEntry","in":"path","required":true,"description":"The time entry ID","schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application\/json":{"schema":{"type":"object"}}}},"responses":{"204":{"description":"No content","content":{"application\/json":{"schema":{"type":"null"}}}},"403":{"$ref":"#\/components\/responses\/AuthorizationException"},"404":{"$ref":"#\/components\/responses\/ModelNotFoundException"}}}}},"components":{"securitySchemes":{"oauth2":{"type":"oauth2","flows":{"authorizationCode":{"authorizationUrl":"https:\/\/solidtime.test\/oauth\/authorize"}}}},"schemas":{"ProjectCollection":{"type":"array","items":{"$ref":"#\/components\/schemas\/ProjectResource"},"title":"ProjectCollection"},"ProjectResource":{"type":"object","properties":{"id":{"type":"string","description":"ID of project"},"name":{"type":"string","description":"Name of project"},"color":{"type":"string","description":"Color of project"},"client_id":{"type":["string","null"],"description":"ID of client"}},"required":["id","name","color","client_id"],"title":"ProjectResource"},"TimeEntryCollection":{"type":"array","items":{"$ref":"#\/components\/schemas\/TimeEntryResource"},"title":"TimeEntryCollection"},"TimeEntryResource":{"type":"object","properties":{"id":{"type":"string","description":"ID of time entry"},"start":{"type":"string","description":"Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)"},"end":{"type":["string","null"],"description":"End of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)"},"duration":{"type":"integer","description":"Duration of time entry in seconds"},"description":{"type":["string","null"],"description":"Description of time entry"},"task_id":{"type":["string","null"],"description":"ID of task"},"project_id":{"type":["string","null"],"description":"ID of project"},"user_id":{"type":"string","description":"ID of user"},"tags":{"type":"array","description":"List of tag IDs","items":{"type":"string"}}},"required":["id","start","end","duration","description","task_id","project_id","user_id","tags"],"title":"TimeEntryResource"}},"responses":{"AuthorizationException":{"description":"Authorization error","content":{"application\/json":{"schema":{"type":"object","properties":{"message":{"type":"string","description":"Error overview."}},"required":["message"]}}}},"ModelNotFoundException":{"description":"Not found","content":{"application\/json":{"schema":{"type":"object","properties":{"message":{"type":"string","description":"Error overview."}},"required":["message"]}}}},"ValidationException":{"description":"Validation error","content":{"application\/json":{"schema":{"type":"object","properties":{"message":{"type":"string","description":"Errors overview."},"errors":{"type":"object","description":"A detailed description of each field that failed validation.","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["message","errors"]}}}}}}} diff --git a/openapi.json.client.ts b/openapi.json.client.ts new file mode 100644 index 00000000..5366233e --- /dev/null +++ b/openapi.json.client.ts @@ -0,0 +1,833 @@ +import { makeApi, Zodios, type ZodiosOptions } from '@zodios/core'; +import { z } from 'zod'; + +const ClientResource = z + .object({ + id: z.string(), + name: z.string(), + created_at: z.string(), + updated_at: z.string(), + }) + .passthrough(); +const ClientCollection = z.array(ClientResource); +const OrganizationResource = z + .object({ id: z.string(), name: z.string(), is_personal: z.string() }) + .passthrough(); +const ProjectResource = z + .object({ + id: z.string(), + name: z.string(), + color: z.string(), + client_id: z.union([z.string(), z.null()]), + }) + .passthrough(); +const ProjectCollection = z.array(ProjectResource); +const createProject_Body = z + .object({ + name: z.string(), + color: z.string(), + client_id: z.union([z.string(), z.null()]).optional(), + }) + .passthrough(); +const TagResource = z + .object({ + id: z.string(), + name: z.string(), + created_at: z.string(), + updated_at: z.string(), + }) + .passthrough(); +const TagCollection = z.array(TagResource); +const before = z.union([z.string(), z.null()]).optional(); +const TimeEntryResource = z + .object({ + id: z.string(), + start: z.string(), + end: z.union([z.string(), z.null()]), + duration: z.union([z.number(), z.null()]), + description: z.union([z.string(), z.null()]), + task_id: z.union([z.string(), z.null()]), + project_id: z.union([z.string(), z.null()]), + user_id: z.string(), + tags: z.array(z.string()), + }) + .passthrough(); +const TimeEntryCollection = z.array(TimeEntryResource); +const createTimeEntry_Body = z + .object({ + user_id: z.string().uuid(), + task_id: z.union([z.string(), z.null()]).optional(), + start: z.string(), + end: z.union([z.string(), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + tags: z.union([z.array(z.string()), z.null()]).optional(), + }) + .passthrough(); +const updateTimeEntry_Body = z + .object({ + task_id: z.union([z.string(), z.null()]).optional(), + start: z.string(), + end: z.union([z.string(), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + tags: z.union([z.array(z.string()), z.null()]).optional(), + }) + .passthrough(); + +export const schemas = { + ClientResource, + ClientCollection, + OrganizationResource, + ProjectResource, + ProjectCollection, + createProject_Body, + TagResource, + TagCollection, + before, + TimeEntryResource, + TimeEntryCollection, + createTimeEntry_Body, + updateTimeEntry_Body, +}; + +const endpoints = makeApi([ + { + method: 'get', + path: '/v1/organizations/:organization', + alias: 'v1.organizations.show', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: OrganizationResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'put', + path: '/v1/organizations/:organization', + alias: 'v1.organizations.update', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ name: z.string() }).passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: OrganizationResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'get', + path: '/v1/organizations/:organization/clients', + alias: 'v1.clients.index', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ClientCollection }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/clients', + alias: 'v1.clients.store', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ name: z.string() }).passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ClientResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'put', + path: '/v1/organizations/:organization/clients/:client', + alias: 'v1.clients.update', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ name: z.string() }).passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'client', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ClientResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'delete', + path: '/v1/organizations/:organization/clients/:client', + alias: 'v1.clients.destroy', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'client', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'get', + path: '/v1/organizations/:organization/projects', + alias: 'getProjects', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ProjectCollection }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/projects', + alias: 'createProject', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: createProject_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ProjectResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'get', + path: '/v1/organizations/:organization/projects/:project', + alias: 'getProject', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'project', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ProjectResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'put', + path: '/v1/organizations/:organization/projects/:project', + alias: 'updateProject', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: createProject_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'project', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: ProjectResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'delete', + path: '/v1/organizations/:organization/projects/:project', + alias: 'deleteProject', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'project', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'get', + path: '/v1/organizations/:organization/tags', + alias: 'getTags', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: TagCollection }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/tags', + alias: 'createTag', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ name: z.string() }).passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: TagResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'put', + path: '/v1/organizations/:organization/tags/:tag', + alias: 'updateTag', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({ name: z.string() }).passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'tag', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: TagResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'delete', + path: '/v1/organizations/:organization/tags/:tag', + alias: 'deleteTag', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'tag', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, + { + method: 'get', + path: '/v1/organizations/:organization/time-entries', + alias: 'getTimeEntries', + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'user_id', + type: 'Query', + schema: z.string().uuid().optional(), + }, + { + name: 'before', + type: 'Query', + schema: before, + }, + { + name: 'after', + type: 'Query', + schema: before, + }, + { + name: 'active', + type: 'Query', + schema: z.string().optional(), + }, + { + name: 'limit', + type: 'Query', + schema: z.number().int().gte(1).lte(500).optional(), + }, + { + name: 'only_full_dates', + type: 'Query', + schema: z.boolean().optional(), + }, + ], + response: z.object({ data: TimeEntryCollection }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'post', + path: '/v1/organizations/:organization/time-entries', + alias: 'createTimeEntry', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: createTimeEntry_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: TimeEntryResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'put', + path: '/v1/organizations/:organization/time-entries/:timeEntry', + alias: 'updateTimeEntry', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: updateTimeEntry_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'timeEntry', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.object({ data: TimeEntryResource }).passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, + { + method: 'delete', + path: '/v1/organizations/:organization/time-entries/:timeEntry', + alias: 'deleteTimeEntry', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: z.object({}).partial().passthrough(), + }, + { + name: 'organization', + type: 'Path', + schema: z.string().uuid(), + }, + { + name: 'timeEntry', + type: 'Path', + schema: z.string().uuid(), + }, + ], + response: z.null(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + ], + }, +]); + +export const api = new Zodios('http://solidtime.test/api', endpoints); + +export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); +} diff --git a/package-lock.json b/package-lock.json index a5469820..f1d717a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,17 @@ "packages": { "": { "dependencies": { + "@heroicons/vue": "^2.1.1", "@rushstack/eslint-patch": "^1.7.0", "@vue/eslint-config-prettier": "^9.0.0", - "@vue/eslint-config-typescript": "^12.0.0" + "@vue/eslint-config-typescript": "^12.0.0", + "dayjs": "^1.11.10", + "echarts": "^5.5.0", + "parse-duration": "^1.1.0", + "pinia": "^2.1.7", + "radix-vue": "^1.4.9", + "tailwind-merge": "^2.2.1", + "vue-echarts": "^6.6.9" }, "devDependencies": { "@inertiajs/vue3": "^1.0.0", @@ -20,6 +28,7 @@ "autoprefixer": "^10.4.7", "axios": "^1.6.4", "laravel-vite-plugin": "^1.0.0", + "openapi-zod-client": "^1.16.2", "postcss": "^8.4.14", "tailwindcss": "^3.1.0", "typescript": "^5.3.3", @@ -51,6 +60,121 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz", + "integrity": "sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg==", + "dev": true, + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "js-yaml": "^3.13.1" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.0.tgz", + "integrity": "sha512-9Kt7EuS/7WbMAUv2gSziqjvxwDbFSg3Xeyfuj5laUODX8o/k/CpsAKiQ8W7/R88eXFTMbJYg6+7uAmOWNKmwnw==", + "dev": true, + "dependencies": { + "@apidevtools/json-schema-ref-parser": "9.0.6", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "ajv": "^8.6.3", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -135,6 +259,207 @@ "node": ">=4" } }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", + "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.24.0", + "@babel/parser": "^7.24.0", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@babel/types": "^7.24.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", @@ -144,6 +469,29 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz", + "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/highlight": { "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", @@ -230,10 +578,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", - "dev": true, + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", + "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", "bin": { "parser": "bin/babel-parser.js" }, @@ -241,6 +588,75 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz", + "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", @@ -685,6 +1101,71 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, + "node_modules/@floating-ui/vue": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.0.6.tgz", + "integrity": "sha512-EdrOljjkpkkqZnrpqUcPoz9NvHxuTjUtSInh6GMv3+Mcy+giY2cE2pHh9rpacRcZ2eMSCxel9jWkWXTjLmY55w==", + "dependencies": { + "@floating-ui/dom": "^1.6.1", + "@floating-ui/utils": "^0.2.1", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@heroicons/vue": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.1.1.tgz", + "integrity": "sha512-Yi5nh/89L193ALgHyJUQUdNLsKXPrrE3yj5yiR8WAlo7nZyXGxGauQcEAmBsa2XJGMhBMuEdoOiuZ8wEwTBxVQ==", + "peerDependencies": { + "vue": ">= 3" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -784,14 +1265,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -807,9 +1288,9 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -818,19 +1299,48 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.21.tgz", - "integrity": "sha512-SRfKmRe1KvYnxjEMtxEr+J4HIeMX5YBg/qhRHpxEIGjhX1rshcHlnFUE9K0GazhVKWM7B+nARSkV8LuvJdJ5/g==", + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.24.tgz", + "integrity": "sha512-+VaWXDa6+l6MhflBvVXjIEAzb59nQ2JUK3bwRp2zRpPtU+8TFRy9Gg/5oIcNlkEL5PGlBFGfemUVvIgLnTzq7Q==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true + }, + "node_modules/@liuli-util/fs-extra": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@liuli-util/fs-extra/-/fs-extra-0.1.0.tgz", + "integrity": "sha512-eaAyDyMGT23QuRGbITVY3SOJff3G9ekAAyGqB9joAnTBmqvFN+9a1FazOdO70G6IUqgpKV451eBHYSRcOJ/FNQ==", + "dev": true, + "dependencies": { + "@types/fs-extra": "^9.0.13", + "fs-extra": "^10.1.0" + } + }, + "node_modules/@liuli-util/fs-extra/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1106,6 +1616,15 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1358,7 +1877,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.14.tgz", "integrity": "sha512-ro4Zzl/MPdWs7XwxT7omHRxAjMbDFRZEEjD+2m3NBf8YzAe3HuoSEZosXQo+m1GQ1G3LQ1LdmNh1RKTYe+ssEg==", - "dev": true, "dependencies": { "@babel/parser": "^7.23.6", "@vue/shared": "3.4.14", @@ -1371,7 +1889,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.14.tgz", "integrity": "sha512-nOZTY+veWNa0DKAceNWxorAbWm0INHdQq7cejFaWM1WYnoNSJbSEKYtE7Ir6lR/+mo9fttZpPVI9ZFGJ1juUEQ==", - "dev": true, "dependencies": { "@vue/compiler-core": "3.4.14", "@vue/shared": "3.4.14" @@ -1381,7 +1898,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.14.tgz", "integrity": "sha512-1vHc9Kv1jV+YBZC/RJxQJ9JCxildTI+qrhtDh6tPkR1O8S+olBUekimY0km0ZNn8nG1wjtFAe9XHij+YLR8cRQ==", - "dev": true, "dependencies": { "@babel/parser": "^7.23.6", "@vue/compiler-core": "3.4.14", @@ -1398,12 +1914,16 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.14.tgz", "integrity": "sha512-bXT6+oAGlFjTYVOTtFJ4l4Jab1wjsC0cfSfOe2B4Z0N2vD2zOBSQ9w694RsCfhjk+bC2DY5Gubb1rHZVii107Q==", - "dev": true, "dependencies": { "@vue/compiler-dom": "3.4.14", "@vue/shared": "3.4.14" } }, + "node_modules/@vue/devtools-api": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.1.tgz", + "integrity": "sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA==" + }, "node_modules/@vue/eslint-config-prettier": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", @@ -1469,7 +1989,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.14.tgz", "integrity": "sha512-xRYwze5Q4tK7tT2J4uy4XLhK/AIXdU5EBUu9PLnIHcOKXO0uyXpNNMzlQKuq7B+zwtq6K2wuUL39pHA6ZQzObw==", - "dev": true, "dependencies": { "@vue/shared": "3.4.14" } @@ -1478,7 +1997,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.14.tgz", "integrity": "sha512-qu+NMkfujCoZL6cfqK5NOfxgXJROSlP2ZPs4CTcVR+mLrwl4TtycF5Tgo0QupkdBL+2kigc6EsJlTcuuZC1NaQ==", - "dev": true, "dependencies": { "@vue/reactivity": "3.4.14", "@vue/shared": "3.4.14" @@ -1488,7 +2006,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.14.tgz", "integrity": "sha512-B85XmcR4E7XsirEHVqhmy4HPbRT9WLFWV9Uhie3OapV9m1MEN9+Er6hmUIE6d8/l2sUygpK9RstFM2bmHEUigA==", - "dev": true, "dependencies": { "@vue/runtime-core": "3.4.14", "@vue/shared": "3.4.14", @@ -1499,7 +2016,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.14.tgz", "integrity": "sha512-pwSKXQfYdJBTpvWHGEYI+akDE18TXAiLcGn+Q/2Fj8wQSHWztoo7PSvfMNqu6NDhp309QXXbPFEGCU5p85HqkA==", - "dev": true, "dependencies": { "@vue/compiler-ssr": "3.4.14", "@vue/shared": "3.4.14" @@ -1511,8 +2027,7 @@ "node_modules/@vue/shared": { "version": "3.4.14", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.14.tgz", - "integrity": "sha512-nmi3BtLpvqXAWoRZ6HQ+pFJOHBU4UnH3vD3opgmwXac7vhaHKA9nj1VeGjMggdB9eLtW83eHyPCmOU1qzdsC7Q==", - "dev": true + "integrity": "sha512-nmi3BtLpvqXAWoRZ6HQ+pFJOHBU4UnH3vD3opgmwXac7vhaHKA9nj1VeGjMggdB9eLtW83eHyPCmOU1qzdsC7Q==" }, "node_modules/@vue/tsconfig": { "version": "0.5.1", @@ -1520,6 +2035,16 @@ "integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==", "dev": true }, + "node_modules/@zodios/core": { + "version": "10.9.6", + "resolved": "https://registry.npmjs.org/@zodios/core/-/core-10.9.6.tgz", + "integrity": "sha512-aH4rOdb3AcezN7ws8vDgBfGboZMk2JGGzEq/DtW65MhnRxyTGRuLJRWVQ/2KxDgWvV2F5oTkAS+5pnjKbl0n+A==", + "dev": true, + "peerDependencies": { + "axios": "^0.x || ^1.0.0", + "zod": "^3.x" + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -1767,7 +2292,16 @@ "browserslist": "cli.js" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" } }, "node_modules/call-bind": { @@ -1784,6 +2318,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1938,6 +2478,12 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1965,8 +2511,12 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "node_modules/de-indent": { "version": "1.0.2", @@ -2069,6 +2619,20 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/echarts": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.0.tgz", + "integrity": "sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.5.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, "node_modules/electron-to-chromium": { "version": "1.4.632", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.632.tgz", @@ -2085,7 +2649,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "engines": { "node": ">=0.12" }, @@ -2366,6 +2929,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", @@ -2399,8 +2975,7 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/esutils": { "version": "2.0.3", @@ -2411,11 +2986,16 @@ "node": ">=0.10.0" } }, + "node_modules/eval-estree-expression": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eval-estree-expression/-/eval-estree-expression-1.1.0.tgz", + "integrity": "sha512-6ZAHSb0wsqxutjk2lXZcW7btSc51I8BhlIetit0wIf5sOb5xDNBrIqe0g8RFyQ/EW6Xwn1szrtButztU7Vdj1Q==", + "dev": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "peer": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -2633,6 +3213,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", @@ -2738,6 +3327,27 @@ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2971,6 +3581,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2989,6 +3611,18 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "peer": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -3131,7 +3765,6 @@ "version": "0.30.5", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, @@ -3203,6 +3836,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", @@ -3238,7 +3880,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -3257,6 +3898,12 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -3347,6 +3994,61 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true + }, + "node_modules/openapi-zod-client": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/openapi-zod-client/-/openapi-zod-client-1.16.2.tgz", + "integrity": "sha512-cN8rb7bzWqvsJzzDGoi9sUJJNid8triuH5ry2dYhJ++Xw/TStkCaOwe2NaNnIrLp7uVgIu+cEIE608Y9YS3/aw==", + "dev": true, + "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", + "@liuli-util/fs-extra": "^0.1.0", + "@zodios/core": "^10.3.1", + "axios": "^1.6.0", + "cac": "^6.7.14", + "handlebars": "^4.7.7", + "openapi-types": "^12.0.2", + "openapi3-ts": "3.1.0", + "pastable": "^2.2.1", + "prettier": "^2.7.1", + "tanu": "^0.1.13", + "ts-pattern": "^5.0.1", + "whence": "^2.0.0", + "zod": "^3.19.1" + }, + "bin": { + "openapi-zod-client": "bin.js" + } + }, + "node_modules/openapi-zod-client/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/openapi3-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-3.1.0.tgz", + "integrity": "sha512-1qKTvCCVoV0rkwUh1zq5o8QyghmwYPuhdvtjv1rFjuOnJToXhQyF8eGjNETQ8QmGjr9Jz/tkAKLITIl2s7dw3A==", + "dev": true, + "dependencies": { + "yaml": "^2.1.3" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -3406,6 +4108,49 @@ "node": ">=6" } }, + "node_modules/parse-duration": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.1.0.tgz", + "integrity": "sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==" + }, + "node_modules/pastable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/pastable/-/pastable-2.2.1.tgz", + "integrity": "sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==", + "dev": true, + "dependencies": { + "@babel/core": "^7.20.12", + "ts-toolbelt": "^9.6.0", + "type-fest": "^3.5.3" + }, + "engines": { + "node": ">=14.x" + }, + "peerDependencies": { + "react": ">=17", + "xstate": ">=4.32.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "xstate": { + "optional": true + } + } + }, + "node_modules/pastable/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -3471,8 +4216,7 @@ "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -3494,6 +4238,56 @@ "node": ">=0.10.0" } }, + "node_modules/pinia": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz", + "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "dependencies": { + "@vue/devtools-api": "^6.5.0", + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -3551,7 +4345,6 @@ "version": "8.4.33", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3751,7 +4544,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "peer": true, "engines": { "node": ">=6" } @@ -3790,6 +4582,16 @@ } ] }, + "node_modules/radix-vue": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.4.9.tgz", + "integrity": "sha512-xGY29nUqaAJTncubdhevwGuv5ZSHGvZjUinWBXVrwHvo6oeJ/SLudxYuc3qRcAU+DK+OcthEQFq255wLJJe4Rw==", + "dependencies": { + "@floating-ui/dom": "^1.5.4", + "@floating-ui/vue": "^1.0.4", + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3811,6 +4613,25 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-detector": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/resize-detector/-/resize-detector-0.3.0.tgz", + "integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4051,15 +4872,29 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -4228,6 +5063,18 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tailwind-merge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.1.tgz", + "integrity": "sha512-o+2GTLkthfa5YUt4JxPfzMIpQzZ3adD1vLVkvKE1Twl9UAhGsEbIZhHHZVRttyW177S8PDJI3bTQNaebyofK3Q==", + "dependencies": { + "@babel/runtime": "^7.23.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", @@ -4278,6 +5125,29 @@ "node": ">=4" } }, + "node_modules/tanu": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tanu/-/tanu-0.1.13.tgz", + "integrity": "sha512-UbRmX7ccZ4wMVOY/Uw+7ji4VOkEYSYJG1+I4qzbnn4qh/jtvVbrm6BFnF12NQQ4+jGv21wKmjb1iFyUSVnBWcQ==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0", + "typescript": "^4.7.4" + } + }, + "node_modules/tanu/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4311,6 +5181,15 @@ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "dev": true }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4339,6 +5218,18 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/ts-pattern": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.8.tgz", + "integrity": "sha512-aafbuAQOTEeWmA7wtcL94w6I89EgLD7F+IlWkr596wYxeb0oveWDO5dQpv85YP0CGbxXT/qXBIeV6IYLcoZ2uA==", + "dev": true + }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dev": true + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -4380,6 +5271,19 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -4429,7 +5333,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -4688,7 +5591,6 @@ "version": "3.4.14", "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.14.tgz", "integrity": "sha512-Rop5Al/ZcBbBz+KjPZaZDgHDX0kUP4duEzDbm+1o91uxYUNmJrZSBuegsNIJvUGy+epLevNRNhLjm08VKTgGyw==", - "dev": true, "dependencies": { "@vue/compiler-dom": "3.4.14", "@vue/compiler-sfc": "3.4.14", @@ -4705,6 +5607,55 @@ } } }, + "node_modules/vue-echarts": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.6.9.tgz", + "integrity": "sha512-mojIq3ZvsjabeVmDthhAUDV8Kgf2Rr/X4lV4da7gEFd1fP05gcSJ0j7wa7HQkW5LlFmF2gdCJ8p4Chas6NNIQQ==", + "hasInstallScript": true, + "dependencies": { + "resize-detector": "^0.3.0", + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.5", + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.4.1", + "vue": "^2.6.12 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/vue-eslint-parser": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.0.tgz", @@ -4755,6 +5706,19 @@ "typescript": "*" } }, + "node_modules/whence": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whence/-/whence-2.0.0.tgz", + "integrity": "sha512-exmM13v2lg8juBbfS2tao/alV68jyryPXS+jf29NBNGLzE2hRgmzvQFQGX5CxNfH4Ag9qRqd6gGpXTH2JxqKHg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.15.7", + "eval-estree-expression": "^1.0.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4769,6 +5733,12 @@ "node": ">= 8" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -4921,6 +5891,28 @@ "funding": { "url": "https://github.com/sponsors/ljharb" } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zrender": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.5.0.tgz", + "integrity": "sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" } } } diff --git a/package.json b/package.json index 1e06d963..9ecfa5fa 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "lint": "eslint --ext .js,.vue,.ts --ignore-path .gitignore .", "lint:fix": "eslint --fix --ext .js,.vue,.ts --ignore-path .gitignore .", "type-check": "vue-tsc --noEmit", - "test:e2e": "npx playwright test" + "test:e2e": "npx playwright test", + "generate:zod": "npx openapi-zod-client http://localhost:80/docs/api.json --output openapi.json.client.ts --base-url http://solidtime.test/api" }, "devDependencies": { "@inertiajs/vue3": "^1.0.0", @@ -20,6 +21,7 @@ "autoprefixer": "^10.4.7", "axios": "^1.6.4", "laravel-vite-plugin": "^1.0.0", + "openapi-zod-client": "^1.16.2", "postcss": "^8.4.14", "tailwindcss": "^3.1.0", "typescript": "^5.3.3", @@ -30,8 +32,16 @@ "ziggy-js": "^1.8.1" }, "dependencies": { + "@heroicons/vue": "^2.1.1", "@rushstack/eslint-patch": "^1.7.0", "@vue/eslint-config-prettier": "^9.0.0", - "@vue/eslint-config-typescript": "^12.0.0" + "@vue/eslint-config-typescript": "^12.0.0", + "dayjs": "^1.11.10", + "echarts": "^5.5.0", + "parse-duration": "^1.1.0", + "pinia": "^2.1.7", + "radix-vue": "^1.4.9", + "tailwind-merge": "^2.2.1", + "vue-echarts": "^6.6.9" } } diff --git a/playwright/config.ts b/playwright/config.ts index 9942dfd7..06ada1f8 100644 --- a/playwright/config.ts +++ b/playwright/config.ts @@ -1,2 +1,2 @@ export const PLAYWRIGHT_BASE_URL = - process.env.PLAYWRIGHT_BASE_URL ?? 'http://laravel.test'; + process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test'; diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts index 7d34ba60..92bcf4ce 100644 --- a/playwright/fixtures.ts +++ b/playwright/fixtures.ts @@ -1,4 +1,4 @@ -import { expect, test as baseTest } from '@playwright/test'; +import { test as baseTest } from '@playwright/test'; import fs from 'fs'; import path from 'path'; import { PLAYWRIGHT_BASE_URL } from './config'; @@ -55,11 +55,6 @@ export const test = baseTest.extend({ // Wait for the final URL to ensure that the cookies are actually set. await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard'); - // Alternatively, you can wait until the page reaches a state where all cookies are set. - await expect( - page.getByRole('heading', { name: 'Dashboard' }) - ).toBeVisible(); - // End of authentication steps. await page.context().storageState({ path: fileName }); diff --git a/public/fonts/Outfit-VariableFont_wght.ttf b/public/fonts/Outfit-VariableFont_wght.ttf new file mode 100644 index 0000000000000000000000000000000000000000..96106f09d6bc9fb0a188518655acdcf7e73df399 GIT binary patch literal 110572 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 -

+
diff --git a/resources/js/Components/ActionSection.vue b/resources/js/Components/ActionSection.vue index f54f18be..6d07781e 100644 --- a/resources/js/Components/ActionSection.vue +++ b/resources/js/Components/ActionSection.vue @@ -15,7 +15,7 @@ import SectionTitle from './SectionTitle.vue';
+ class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
diff --git a/resources/js/Components/ConfirmationModal.vue b/resources/js/Components/ConfirmationModal.vue index a771b506..abea8f8f 100644 --- a/resources/js/Components/ConfirmationModal.vue +++ b/resources/js/Components/ConfirmationModal.vue @@ -48,12 +48,11 @@ const close = () => {
-

+

-
+
diff --git a/resources/js/Components/CurrentSidebarTimer.vue b/resources/js/Components/CurrentSidebarTimer.vue new file mode 100644 index 00000000..da07403d --- /dev/null +++ b/resources/js/Components/CurrentSidebarTimer.vue @@ -0,0 +1,13 @@ + + + diff --git a/resources/js/Components/Dashboard/ActivityGraphCard.vue b/resources/js/Components/Dashboard/ActivityGraphCard.vue new file mode 100644 index 00000000..e0f9fb95 --- /dev/null +++ b/resources/js/Components/Dashboard/ActivityGraphCard.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/resources/js/Components/Dashboard/DashboardCard.vue b/resources/js/Components/Dashboard/DashboardCard.vue new file mode 100644 index 00000000..b07d2724 --- /dev/null +++ b/resources/js/Components/Dashboard/DashboardCard.vue @@ -0,0 +1,25 @@ + + + diff --git a/resources/js/Components/Dashboard/DayOverviewCardEntry.vue b/resources/js/Components/Dashboard/DayOverviewCardEntry.vue new file mode 100644 index 00000000..df8be660 --- /dev/null +++ b/resources/js/Components/Dashboard/DayOverviewCardEntry.vue @@ -0,0 +1,103 @@ + + + diff --git a/resources/js/Components/Dashboard/LastSevenDaysCard.vue b/resources/js/Components/Dashboard/LastSevenDaysCard.vue new file mode 100644 index 00000000..123f00a8 --- /dev/null +++ b/resources/js/Components/Dashboard/LastSevenDaysCard.vue @@ -0,0 +1,22 @@ + + + diff --git a/resources/js/Components/Dashboard/ProjectsChartCard.vue b/resources/js/Components/Dashboard/ProjectsChartCard.vue new file mode 100644 index 00000000..6d3221c5 --- /dev/null +++ b/resources/js/Components/Dashboard/ProjectsChartCard.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/resources/js/Components/Dashboard/RecentlyTrackedTasksCard.vue b/resources/js/Components/Dashboard/RecentlyTrackedTasksCard.vue new file mode 100644 index 00000000..35195a24 --- /dev/null +++ b/resources/js/Components/Dashboard/RecentlyTrackedTasksCard.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue b/resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue new file mode 100644 index 00000000..2356acfd --- /dev/null +++ b/resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/js/Components/Dashboard/TeamActivityCard.vue b/resources/js/Components/Dashboard/TeamActivityCard.vue new file mode 100644 index 00000000..9f262cd7 --- /dev/null +++ b/resources/js/Components/Dashboard/TeamActivityCard.vue @@ -0,0 +1,27 @@ + + + diff --git a/resources/js/Components/Dashboard/TeamActivityCardEntry.vue b/resources/js/Components/Dashboard/TeamActivityCardEntry.vue new file mode 100644 index 00000000..eeb5fb36 --- /dev/null +++ b/resources/js/Components/Dashboard/TeamActivityCardEntry.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/js/Components/Dashboard/ThisWeekOverview.vue b/resources/js/Components/Dashboard/ThisWeekOverview.vue new file mode 100644 index 00000000..ad4fefaa --- /dev/null +++ b/resources/js/Components/Dashboard/ThisWeekOverview.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/resources/js/Components/DialogModal.vue b/resources/js/Components/DialogModal.vue index 06534946..0d2468fe 100644 --- a/resources/js/Components/DialogModal.vue +++ b/resources/js/Components/DialogModal.vue @@ -30,11 +30,11 @@ const close = () => { :closeable="closeable" @close="close">
-
+
-
+
diff --git a/resources/js/Components/Dropdown.vue b/resources/js/Components/Dropdown.vue index f2470927..fb5709c9 100644 --- a/resources/js/Components/Dropdown.vue +++ b/resources/js/Components/Dropdown.vue @@ -1,22 +1,28 @@