From 3fe5d7f091e7b69219b71a8c1042a6adec8fcf3a Mon Sep 17 00:00:00 2001 From: Lewis Larsen Date: Tue, 18 Jun 2024 14:10:52 +0100 Subject: [PATCH 1/4] feat: added basic tagging system --- app/Http/Controllers/Tags/EditController.php | 18 ++++ .../BackupTasks/CreateBackupTaskForm.php | 12 ++- .../BackupTasks/UpdateBackupTaskForm.php | 12 +++ app/Livewire/Tags/CreateForm.php | 43 +++++++++ app/Livewire/Tags/DeleteTagButton.php | 37 +++++++ app/Livewire/Tags/IndexItem.php | 22 +++++ app/Livewire/Tags/IndexTable.php | 23 +++++ app/Livewire/Tags/UpdateForm.php | 54 +++++++++++ app/Models/BackupTask.php | 3 +- app/Models/Tag.php | 19 ++++ app/Models/Taggable.php | 25 +++++ app/Models/User.php | 5 + app/Policies/TagPolicy.php | 41 ++++++++ app/Traits/HasTags.php | 32 +++++++ database/factories/TagFactory.php | 18 ++++ .../2024_06_17_161233_create_tags_table.php | 26 +++++ database/seeders/DatabaseSeeder.php | 5 + resources/views/components/checkbox.blade.php | 4 +- .../create-backup-task-form.blade.php | 18 +++- .../update-backup-task-form.blade.php | 19 +++- .../livewire/layout/navigation.blade.php | 8 ++ .../views/livewire/tags/create-form.blade.php | 34 +++++++ .../livewire/tags/delete-tag-button.blade.php | 34 +++++++ .../views/livewire/tags/index-item.blade.php | 21 ++++ .../views/livewire/tags/index-table.blade.php | 47 +++++++++ .../views/livewire/tags/update-form.blade.php | 37 +++++++ resources/views/tags/create.blade.php | 14 +++ resources/views/tags/edit.blade.php | 14 +++ resources/views/tags/index.blade.php | 23 +++++ routes/breadcrumbs.php | 14 +++ routes/web.php | 17 ++++ .../Livewire/CreateBackupTaskFormTest.php | 47 +++++++++ .../Livewire/UpdateBackupTaskFormTest.php | 96 +++++++++++++++++++ .../Feature/Tags/Livewire/CreateFormTest.php | 42 ++++++++ tests/Feature/Tags/Livewire/DeleteTest.php | 48 ++++++++++ .../Feature/Tags/Livewire/UpdateFormTest.php | 73 ++++++++++++++ tests/Feature/Tags/Pages/CreatePageTest.php | 23 +++++ tests/Feature/Tags/Pages/EditPageTest.php | 48 ++++++++++ tests/Feature/Tags/Pages/IndexPageTest.php | 23 +++++ 39 files changed, 1093 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Tags/EditController.php create mode 100644 app/Livewire/Tags/CreateForm.php create mode 100644 app/Livewire/Tags/DeleteTagButton.php create mode 100644 app/Livewire/Tags/IndexItem.php create mode 100644 app/Livewire/Tags/IndexTable.php create mode 100644 app/Livewire/Tags/UpdateForm.php create mode 100644 app/Models/Tag.php create mode 100644 app/Models/Taggable.php create mode 100644 app/Policies/TagPolicy.php create mode 100644 app/Traits/HasTags.php create mode 100644 database/factories/TagFactory.php create mode 100644 database/migrations/2024_06_17_161233_create_tags_table.php create mode 100644 resources/views/livewire/tags/create-form.blade.php create mode 100644 resources/views/livewire/tags/delete-tag-button.blade.php create mode 100644 resources/views/livewire/tags/index-item.blade.php create mode 100644 resources/views/livewire/tags/index-table.blade.php create mode 100644 resources/views/livewire/tags/update-form.blade.php create mode 100644 resources/views/tags/create.blade.php create mode 100644 resources/views/tags/edit.blade.php create mode 100644 resources/views/tags/index.blade.php create mode 100644 tests/Feature/Tags/Livewire/CreateFormTest.php create mode 100644 tests/Feature/Tags/Livewire/DeleteTest.php create mode 100644 tests/Feature/Tags/Livewire/UpdateFormTest.php create mode 100644 tests/Feature/Tags/Pages/CreatePageTest.php create mode 100644 tests/Feature/Tags/Pages/EditPageTest.php create mode 100644 tests/Feature/Tags/Pages/IndexPageTest.php diff --git a/app/Http/Controllers/Tags/EditController.php b/app/Http/Controllers/Tags/EditController.php new file mode 100644 index 00000000..1b208de9 --- /dev/null +++ b/app/Http/Controllers/Tags/EditController.php @@ -0,0 +1,18 @@ + $tag, + ]); + } +} diff --git a/app/Livewire/BackupTasks/CreateBackupTaskForm.php b/app/Livewire/BackupTasks/CreateBackupTaskForm.php index 7de441fa..d513c63f 100644 --- a/app/Livewire/BackupTasks/CreateBackupTaskForm.php +++ b/app/Livewire/BackupTasks/CreateBackupTaskForm.php @@ -9,6 +9,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Redirect; +use Illuminate\Validation\Rule; use Illuminate\View\View; use Livewire\Component; use Livewire\Features\SupportRedirects\Redirector; @@ -58,6 +59,9 @@ class CreateBackupTaskForm extends Component public ?string $excludedDatabaseTables = null; + public ?Collection $availableTags; + public ?array $selectedTags; + public function updatedUseCustomCron(): void { $this->useCustomCron = (bool) $this->useCustomCron; @@ -81,6 +85,7 @@ public function updatedBackupType(): void public function mount(): void { + $this->availableTags = Auth::user()->tags; $this->userTimezone = Auth::user()->timezone ?? 'UTC'; $this->remoteServers = Auth::user()->remoteServers->where('database_password', null); @@ -107,6 +112,7 @@ public function mount(): void public function submit(): RedirectResponse|Redirector { $messages = [ + 'selectedTags.*.exists' => __('One or more of the selected tags do not exist.'), 'storePath.regex' => __('The path must be a valid Unix path.'), 'notifyEmail.email' => __('Please enter a valid email address.'), 'notifySlackWebhook.url' => __('Please enter a valid URL.'), @@ -137,6 +143,7 @@ public function submit(): RedirectResponse|Redirector if ($this->backupType === 'files') { $this->validate([ + 'selectedTags' => ['nullable', 'array', Rule::exists('tags', 'id')->where('user_id', Auth::id())], 'excludedDatabaseTables' => ['nullable', 'string', 'regex:/^([a-zA-Z0-9_]+(,[a-zA-Z0-9_]+)*)$/'], 'storePath' => ['nullable', 'string', 'regex:/^(\/[^\/\0]+)+\/?$/'], // Unix path regex 'notifyEmail' => ['nullable', 'email'], @@ -158,6 +165,7 @@ public function submit(): RedirectResponse|Redirector } $this->validate([ + 'selectedTags' => ['nullable', 'array', Rule::exists('tags', 'id')->where('user_id', Auth::id())], 'excludedDatabaseTables' => ['nullable', 'string', 'regex:/^([a-zA-Z0-9_]+(,[a-zA-Z0-9_]+)*)$/'], 'storePath' => ['nullable', 'string', 'regex:/^(\/[^\/\0]+)+\/?$/'], // Unix path regex 'notifyEmail' => ['nullable', 'email'], @@ -189,7 +197,7 @@ public function submit(): RedirectResponse|Redirector $this->timeToRun = Carbon::createFromFormat('H:i', $this->timeToRun, $this->userTimezone)?->setTimezone('UTC')->format('H:i'); } - BackupTask::create([ + $backupTask = BackupTask::create([ 'user_id' => Auth::id(), 'remote_server_id' => $this->remoteServerId, 'backup_destination_id' => $this->backupDestinationId, @@ -211,6 +219,8 @@ public function submit(): RedirectResponse|Redirector 'excluded_database_tables' => $this->excludedDatabaseTables, ]); + $backupTask->tags()->sync($this->selectedTags); + Toaster::success(__('Backup task has been added.')); return Redirect::route('backup-tasks.index'); diff --git a/app/Livewire/BackupTasks/UpdateBackupTaskForm.php b/app/Livewire/BackupTasks/UpdateBackupTaskForm.php index 361124ce..1f227bf6 100644 --- a/app/Livewire/BackupTasks/UpdateBackupTaskForm.php +++ b/app/Livewire/BackupTasks/UpdateBackupTaskForm.php @@ -9,6 +9,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Redirect; +use Illuminate\Validation\Rule; use Illuminate\View\View; use Livewire\Component; use Livewire\Features\SupportRedirects\Redirector; @@ -60,6 +61,9 @@ class UpdateBackupTaskForm extends Component public ?string $excludedDatabaseTables; + public ?Collection $availableTags; + public ?array $selectedTags; + public function updatedUseCustomCron(): void { $this->useCustomCron = (bool) $this->useCustomCron; @@ -83,6 +87,9 @@ public function updatedBackupType(): void public function mount(): void { + $this->availableTags = Auth::user()->tags; + $this->selectedTags = $this->backupTask->tags->pluck('id')->toArray(); + $this->backupTimes = collect(range(0, 47))->map(function ($halfHour) { $hour = intdiv($halfHour, 2); $minute = ($halfHour % 2) * 30; @@ -128,6 +135,7 @@ public function submit(): RedirectResponse|Redirector $this->authorize('update', $this->backupTask); $messages = [ + 'selectedTags.*.exists' => __('One or more of the selected tags do not exist.'), 'excludedDatabaseTables.regex' => __('Please enter a valid list of table names separated by commas.'), 'storePath.regex' => __('The path must be a valid Unix path.'), 'notifyEmail.email' => __('Please enter a valid email address.'), @@ -159,6 +167,7 @@ public function submit(): RedirectResponse|Redirector if ($this->backupType === 'files') { $this->validate([ + 'selectedTags' => ['nullable', 'array', Rule::exists('tags', 'id')->where('user_id', Auth::id())], 'excludedDatabaseTables' => ['nullable', 'string', 'regex:/^([a-zA-Z0-9_]+(,[a-zA-Z0-9_]+)*)$/'], 'storePath' => ['nullable', 'string', 'regex:/^(\/[^\/\0]+)+\/?$/'], // Unix path regex 'notifyEmail' => ['nullable', 'email'], @@ -180,6 +189,7 @@ public function submit(): RedirectResponse|Redirector } $this->validate([ + 'selectedTags' => ['nullable', 'array', Rule::exists('tags', 'id')->where('user_id', Auth::id())], 'excludedDatabaseTables' => ['nullable', 'string', 'regex:/^([a-zA-Z0-9_]+(,[a-zA-Z0-9_]+)*)$/'], 'storePath' => ['nullable', 'string', 'regex:/^(\/[^\/\0]+)+\/?$/'], // Unix path regex 'notifyEmail' => ['nullable', 'email'], @@ -232,6 +242,8 @@ public function submit(): RedirectResponse|Redirector 'store_path' => $this->storePath ?? null, ]); + $this->backupTask->tags()->sync($this->selectedTags); + Toaster::success(__('Backup task details saved.')); return Redirect::route('backup-tasks.index'); diff --git a/app/Livewire/Tags/CreateForm.php b/app/Livewire/Tags/CreateForm.php new file mode 100644 index 00000000..7d852464 --- /dev/null +++ b/app/Livewire/Tags/CreateForm.php @@ -0,0 +1,43 @@ +validate([ + 'label' => ['required', 'string'], + 'description' => ['nullable', 'string'], + ], [ + 'label.required' => __('Please enter a label.'), + ]); + + $tag = Tag::create([ + 'user_id' => Auth::id(), + 'label' => $this->label, + 'description' => $this->description ?? null, + ]); + + Toaster::success(__('The tag :label has been added.', ['label' => $tag->label])); + + return Redirect::route('tags.index'); + } + + public function render(): View + { + return view('livewire.tags.create-form'); + } +} diff --git a/app/Livewire/Tags/DeleteTagButton.php b/app/Livewire/Tags/DeleteTagButton.php new file mode 100644 index 00000000..09ba6b69 --- /dev/null +++ b/app/Livewire/Tags/DeleteTagButton.php @@ -0,0 +1,37 @@ +tag = $tag; + } + + public function delete(): RedirectResponse|Redirector + { + $this->authorize('forceDelete', $this->tag); + + Toaster::success("The tag {$this->tag->label} has been removed."); + + $this->tag->forceDelete(); + + return Redirect::route('tags.index'); + } + + public function render(): View + { + return view('livewire.tags.delete-tag-button'); + } +} diff --git a/app/Livewire/Tags/IndexItem.php b/app/Livewire/Tags/IndexItem.php new file mode 100644 index 00000000..b013f4fb --- /dev/null +++ b/app/Livewire/Tags/IndexItem.php @@ -0,0 +1,22 @@ +tag = $tag; + } + + public function render(): View + { + return view('livewire.tags.index-item'); + } +} diff --git a/app/Livewire/Tags/IndexTable.php b/app/Livewire/Tags/IndexTable.php new file mode 100644 index 00000000..8fd08cb4 --- /dev/null +++ b/app/Livewire/Tags/IndexTable.php @@ -0,0 +1,23 @@ +orderBy('created_at', 'desc') + ->paginate(30, pageName: 'tags'); + + return view('livewire.tags.index-table', ['tags' => $tags]); + } +} diff --git a/app/Livewire/Tags/UpdateForm.php b/app/Livewire/Tags/UpdateForm.php new file mode 100644 index 00000000..7e9aa68a --- /dev/null +++ b/app/Livewire/Tags/UpdateForm.php @@ -0,0 +1,54 @@ +tag = $tag; + $this->label = $tag->label; + $this->description = $tag->description ?? null; + } + + public function submit(): RedirectResponse|Redirector + { + $this->authorize('update', $this->tag); + + $this->validate([ + 'label' => ['required', 'string'], + 'description' => ['nullable', 'string'], + ], [ + 'label.required' => __('Please enter a label.'), + ]); + + $this->tag->update([ + 'label' => $this->label, + 'description' => $this->description ?? null, + ]); + + $this->tag->save(); + + Toaster::success(__('The tag :label has been updated.', ['label' => $this->tag->label])); + + return Redirect::route('tags.index'); + } + + public function render(): View + { + return view('livewire.tags.update-form'); + } +} diff --git a/app/Models/BackupTask.php b/app/Models/BackupTask.php index b7aa26a3..450907f5 100644 --- a/app/Models/BackupTask.php +++ b/app/Models/BackupTask.php @@ -7,6 +7,7 @@ use App\Jobs\RunDatabaseBackupTaskJob; use App\Jobs\RunFileBackupTaskJob; use App\Mail\BackupTasks\OutputMail; +use App\Traits\HasTags; use Cron\CronExpression; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,7 +21,7 @@ class BackupTask extends Model { - use HasFactory; + use HasFactory, HasTags; const string STATUS_READY = 'ready'; diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 00000000..a2171e6c --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,19 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/Taggable.php b/app/Models/Taggable.php new file mode 100644 index 00000000..64db046c --- /dev/null +++ b/app/Models/Taggable.php @@ -0,0 +1,25 @@ +belongsTo(Tag::class); + } + + public function taggable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 667bd059..b2e21a96 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -89,6 +89,11 @@ public function canLoginWithGithub(): bool return $this->github_id !== null; } + public function tags(): HasMany + { + return $this->hasMany(Tag::class); + } + protected function firstName(): Attribute { return new Attribute(function ($value) { diff --git a/app/Policies/TagPolicy.php b/app/Policies/TagPolicy.php new file mode 100644 index 00000000..609f08d8 --- /dev/null +++ b/app/Policies/TagPolicy.php @@ -0,0 +1,41 @@ +id === $tag->user_id + ? Response::allow() + : Response::deny('You do not own this tag.'); + } + + public function update(User $user, Tag $tag): Response + { + return $user->id === $tag->user_id + ? Response::allow() + : Response::deny('You do not own this tag.'); + } + + public function forceDelete(User $user, Tag $tag): Response + { + return $user->id === $tag->user_id + ? Response::allow() + : Response::deny('You do not own this tag.'); + } +} diff --git a/app/Traits/HasTags.php b/app/Traits/HasTags.php new file mode 100644 index 00000000..63695582 --- /dev/null +++ b/app/Traits/HasTags.php @@ -0,0 +1,32 @@ +morphToMany(Tag::class, 'taggable', 'taggables', 'taggable_id', 'tag_id'); + } + + public function tag($label): void + { + $tag = Tag::firstOrCreate(['label' => $label]) + ->where('user_id', auth()->id()); + + $this->tags()->syncWithoutDetaching($tag); + } + + public function untag($label): void + { + $tag = Tag::where('label', $label) + ->first(); + + if ($tag) { + $this->tags()->detach($tag); + } + } +} diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php new file mode 100644 index 00000000..5df9f9b9 --- /dev/null +++ b/database/factories/TagFactory.php @@ -0,0 +1,18 @@ + fake()->unique()->word(), + 'description' => fake()->sentence(), + 'user_id' => User::factory()->create()->id, + ]; + } +} diff --git a/database/migrations/2024_06_17_161233_create_tags_table.php b/database/migrations/2024_06_17_161233_create_tags_table.php new file mode 100644 index 00000000..5f264be4 --- /dev/null +++ b/database/migrations/2024_06_17_161233_create_tags_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('label'); + $table->text('description')->nullable(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + }); + + Schema::create('taggables', function (Blueprint $table) { + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + $table->morphs('taggable'); + $table->unique(['tag_id', 'taggable_id', 'taggable_type']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0ff152dd..23f5ab05 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -6,6 +6,7 @@ use App\Models\BackupTask; use App\Models\BackupTaskLog; use App\Models\RemoteServer; +use App\Models\Tag; use App\Models\User; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -20,6 +21,10 @@ public function run(): void 'email' => 'test@example.com', ]); + Tag::factory()->count(5)->create([ + 'user_id' => $user->id, + ]); + $remoteServer = RemoteServer::factory([ 'user_id' => $user->id, 'label' => 'Alpha', diff --git a/resources/views/components/checkbox.blade.php b/resources/views/components/checkbox.blade.php index 0d05da0e..560ef2c4 100644 --- a/resources/views/components/checkbox.blade.php +++ b/resources/views/components/checkbox.blade.php @@ -1,9 +1,9 @@ @props(['name', 'value' => null, 'label' => null])
- + @if ($label) -
+ @if ($availableTags->isNotEmpty()) + + {{ __('Tags') }} + +
+ + @foreach ($availableTags as $tag) + + @endforeach + + + {{ __('Tags are a way to categorize your backup tasks. You can use them to filter and search for tasks later.') }} + +
+ @endif
diff --git a/resources/views/livewire/backup-tasks/update-backup-task-form.blade.php b/resources/views/livewire/backup-tasks/update-backup-task-form.blade.php index 56f902b8..bce86bf9 100644 --- a/resources/views/livewire/backup-tasks/update-backup-task-form.blade.php +++ b/resources/views/livewire/backup-tasks/update-backup-task-form.blade.php @@ -198,10 +198,27 @@ class="text-sm text-gray-600 dark:text-gray-400 underline hover:text-gray-900 da {{ __('Input the Slack webhook to receive notifications on Slack.') }}
+ @if (Auth::user()->tags) + + {{ __('Tags') }} + +
+ + @foreach ($availableTags as $tag) + + @endforeach + + + {{ __('Tags are a way to categorize your backup tasks. You can use them to filter and search for tasks later.') }} + +
+ @endif
- + {{ __('Save changes') }}
diff --git a/resources/views/livewire/layout/navigation.blade.php b/resources/views/livewire/layout/navigation.blade.php index 734c768f..484a45ef 100644 --- a/resources/views/livewire/layout/navigation.blade.php +++ b/resources/views/livewire/layout/navigation.blade.php @@ -89,6 +89,10 @@ class="inline-flex items-center px-3 py-2 border border-transparent text-sm lead {{ __('Profile') }} + + {{ __('Tags') }} + + @if (Auth::user()->isAdmin()) {{ __('Laravel Pulse') }} @@ -152,6 +156,10 @@ class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark {{ __('Profile') }} + + {{ __('Tags') }} + + @if (Auth::user()->isAdmin()) {{ __('Laravel Pulse') }} diff --git a/resources/views/livewire/tags/create-form.blade.php b/resources/views/livewire/tags/create-form.blade.php new file mode 100644 index 00000000..7e3c441b --- /dev/null +++ b/resources/views/livewire/tags/create-form.blade.php @@ -0,0 +1,34 @@ +
+ +
+
+ + + +
+
+ + + +
+
+
+
+ + {{ __('Save') }} + +
+ +
+
+
+
+
diff --git a/resources/views/livewire/tags/delete-tag-button.blade.php b/resources/views/livewire/tags/delete-tag-button.blade.php new file mode 100644 index 00000000..f92dbdfb --- /dev/null +++ b/resources/views/livewire/tags/delete-tag-button.blade.php @@ -0,0 +1,34 @@ +
+ + +
+

+ {{ __('Confirm Tag Removal') }} +

+

+ {{ __('Are you sure you want to remove the tag ":label"?', ['label' => $tag->label]) }} +

+

+ {{ __('This action cannot be undone, this tag will be unlinked from associated tasks.') }} +

+
+
+ + {{ __('Confirm') }} + +
+
+ + {{ __('Cancel') }} + +
+
+
+
+
diff --git a/resources/views/livewire/tags/index-item.blade.php b/resources/views/livewire/tags/index-item.blade.php new file mode 100644 index 00000000..ecc79439 --- /dev/null +++ b/resources/views/livewire/tags/index-item.blade.php @@ -0,0 +1,21 @@ +
+ + + + + + +
diff --git a/resources/views/livewire/tags/index-table.blade.php b/resources/views/livewire/tags/index-table.blade.php new file mode 100644 index 00000000..2f8a3541 --- /dev/null +++ b/resources/views/livewire/tags/index-table.blade.php @@ -0,0 +1,47 @@ +
+ @if ($tags->isEmpty()) + + + @svg('heroicon-o-tag', 'h-16 w-16 text-primary-900 dark:text-white inline') + + + {{ __("You don't have any tags setup!") }} + + + {{ __("Tags are a great way to organize backup tasks! create your first tag below.") }} + + + + + {{ __('Make Tag') }} + + + + + @else + + + + {{ __('Label') }} + + + {{ __('Description') }} + + + {{ __('Created') }} + + + {{ __('Actions') }} + + + + @foreach ($tags as $tag) + @livewire('tags.index-item', ['tag' => $tag], key($tag->id)) + @endforeach + + +
+ {{ $tags->links() }} +
+ @endif +
diff --git a/resources/views/livewire/tags/update-form.blade.php b/resources/views/livewire/tags/update-form.blade.php new file mode 100644 index 00000000..c00d96c0 --- /dev/null +++ b/resources/views/livewire/tags/update-form.blade.php @@ -0,0 +1,37 @@ +
+ +
+
+ + + +
+
+ + + +
+
+
+
+ + {{ __('Save changes') }} + +
+ +
+
+
+
+
+ @livewire('tags.delete-tag-button', ['tag' => $tag]) +
+
diff --git a/resources/views/tags/create.blade.php b/resources/views/tags/create.blade.php new file mode 100644 index 00000000..d1244a81 --- /dev/null +++ b/resources/views/tags/create.blade.php @@ -0,0 +1,14 @@ +@section('title', 'Create Tag') + + +

+ {{ __('Create Tag') }} +

+
+ +
+
+ @livewire('tags.create-form') +
+
+
diff --git a/resources/views/tags/edit.blade.php b/resources/views/tags/edit.blade.php new file mode 100644 index 00000000..79509219 --- /dev/null +++ b/resources/views/tags/edit.blade.php @@ -0,0 +1,14 @@ +@section('title', 'Update Tag') + + +

+ {{ __('Update Tag') }} +

+
+ +
+
+ @livewire('tags.update-form', ['tag' => $tag]) +
+
+
diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php new file mode 100644 index 00000000..8f284ba7 --- /dev/null +++ b/resources/views/tags/index.blade.php @@ -0,0 +1,23 @@ +@section('title', 'Tags') + + +

+ {{ __('Tags') }} +

+
+ +
+
+ @if (!Auth::user()->tags->isEmpty()) + + @endif + @livewire('tags.index-table') +
+
+
diff --git a/routes/breadcrumbs.php b/routes/breadcrumbs.php index 3b1cdd73..282017b1 100644 --- a/routes/breadcrumbs.php +++ b/routes/breadcrumbs.php @@ -61,3 +61,17 @@ $trail->parent('remote-servers.index'); $trail->push(__('Update Remote Server'), route('remote-servers.edit', $remoteServer)); }); + +Breadcrumbs::for('tags.index', function (BreadcrumbTrail $trail) { + $trail->push(__('Tags'), route('tags.index')); +}); + +Breadcrumbs::for('tags.create', function (BreadcrumbTrail $trail) { + $trail->parent('tags.index'); + $trail->push(__('Create Tag'), route('tags.create')); +}); + +Breadcrumbs::for('tags.edit', function (BreadcrumbTrail $trail, $tag) { + $trail->parent('tags.index'); + $trail->push(__('Update Tag'), route('tags.edit', $tag)); +}); diff --git a/routes/web.php b/routes/web.php index cd166417..07a92ee3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\BackupDestinations\EditController as BackupDestinationEditController; use App\Http\Controllers\BackupTasks\EditController as BackupTaskEditController; use App\Http\Controllers\RemoteServers\EditController as RemoteServerEditController; +use App\Http\Controllers\Tags\EditController as TagEditControllerAlias; use Illuminate\Support\Facades\Route; Route::redirect('/', '/overview'); @@ -65,4 +66,20 @@ ->middleware('can:update,backupTask'); }); +Route::middleware(['auth']) + ->prefix('tags') + ->group(function () { + + Route::view('/', 'tags.index') + ->name('tags.index'); + + Route::view('create', 'tags.create') + ->name('tags.create'); + + Route::get('edit/{tag}', [TagEditControllerAlias::class, '__invoke']) + ->name('tags.edit') + ->middleware('can:update,tag'); + + }); + require __DIR__ . '/auth.php'; diff --git a/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php b/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php index 22b4e876..99dab343 100644 --- a/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php +++ b/tests/Feature/BackupTasks/Livewire/CreateBackupTaskFormTest.php @@ -3,6 +3,7 @@ use App\Livewire\BackupTasks\CreateBackupTaskForm; use App\Models\BackupDestination; use App\Models\RemoteServer; +use App\Models\Tag; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -23,6 +24,10 @@ }); test('users can create backup tasks', function () { + + $tag1 = Tag::factory()->create(['label' => 'Tag 1', 'user_id' => $this->user->id]); + $tag2 = Tag::factory()->create(['label' => 'Tag 2', 'user_id' => $this->user->id]); + Livewire::test(CreateBackupTaskForm::class) ->set('label', 'Test Backup Task') ->set('description', 'This is a test backup task.') @@ -37,6 +42,7 @@ ->set('notifySlackWebhook', 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX') ->set('storePath', '/my-cool-backups') ->set('excludedDatabaseTables', 'table1,table2') + ->set('selectedTags', [$tag1->id, $tag2->id]) ->call('submit'); $this->assertDatabaseHas('backup_tasks', [ @@ -56,6 +62,21 @@ 'store_path' => '/my-cool-backups', 'excluded_database_tables' => 'table1,table2', ]); + + $backupTask = \App\Models\BackupTask::latest()->first(); + + $this->assertDatabaseHas('taggables', [ + 'tag_id' => $tag1->id, + 'taggable_id' => $backupTask->id, + 'taggable_type' => 'App\Models\BackupTask', + ]); + + $this->assertDatabaseHas('taggables', [ + 'tag_id' => $tag2->id, + 'taggable_id' => $backupTask->id, + 'taggable_type' => 'App\Models\BackupTask', + ]); + }); test('users can create backup tasks with a custom cron expression', function () { @@ -223,3 +244,29 @@ ->call('submit') ->assertHasErrors('timeToRun'); }); + +test('users cannot add a tag that does not belong to them', function () { + + $tag = Tag::factory()->create(); + + Livewire::test(CreateBackupTaskForm::class) + ->set('label', 'Test Backup Task') + ->set('sourcePath', '/var/www/html') + ->set('remoteServerId', $this->server->id) + ->set('backupDestinationId', $this->destination->id) + ->set('selectedTags', [$tag->id]) + ->call('submit') + ->assertHasErrors('selectedTags'); +}); + +test('users cannot add a tag that does not exist', function () { + + Livewire::test(CreateBackupTaskForm::class) + ->set('label', 'Test Backup Task') + ->set('sourcePath', '/var/www/html') + ->set('remoteServerId', $this->server->id) + ->set('backupDestinationId', $this->destination->id) + ->set('selectedTags', [999]) + ->call('submit') + ->assertHasErrors('selectedTags'); +}); diff --git a/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php b/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php index f0665ad7..740906ac 100644 --- a/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php +++ b/tests/Feature/BackupTasks/Livewire/UpdateBackupTaskFormTest.php @@ -4,6 +4,7 @@ use App\Models\BackupDestination; use App\Models\BackupTask; use App\Models\RemoteServer; +use App\Models\Tag; use App\Models\User; use Livewire\Livewire; @@ -40,9 +41,15 @@ function createUserWithBackupTaskAndDependencies(): array }); test('backup task can be updated by the owner', function () { + + $tag1 = Tag::factory()->create(['label' => 'Tag 1', 'user_id' => $this->data['user']->id]); + $tag2 = Tag::factory()->create(['label' => 'Tag 2', 'user_id' => $this->data['user']->id]); + $tagIds = [$tag1->id, $tag2->id]; + $livewire = Livewire::test(UpdateBackupTaskForm::class, [ 'backupTask' => $this->data['backupTask'], 'remoteServers' => $this->data['user']->remoteServers, + 'availableTags' => $this->data['user']->tags, ]); $livewire->set('label', 'Updated Label') @@ -61,6 +68,7 @@ function createUserWithBackupTaskAndDependencies(): array ->set('notifySlackWebhook', 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX') ->set('storePath', '/my-cool-backups') ->set('excludedDatabaseTables', 'table1,table2') + ->set('selectedTags', $tagIds) ->call('submit') ->assertHasNoErrors(); @@ -81,6 +89,18 @@ function createUserWithBackupTaskAndDependencies(): array 'store_path' => '/my-cool-backups', 'excluded_database_tables' => 'table1,table2', ]); + + $this->assertDatabaseHas('taggables', [ + 'tag_id' => $tag1->id, + 'taggable_id' => $this->data['backupTask']->id, + 'taggable_type' => 'App\Models\BackupTask', + ]); + + $this->assertDatabaseHas('taggables', [ + 'tag_id' => $tag2->id, + 'taggable_id' => $this->data['backupTask']->id, + 'taggable_type' => 'App\Models\BackupTask', + ]); }); test('backup task can be updated by the owner with custom cron', function () { @@ -295,3 +315,79 @@ function createUserWithBackupTaskAndDependencies(): array ->call('submit') ->assertHasNoErrors(); }); + +test('users cannot set tags that do not belong them', function () { + + $tag = Tag::factory()->create(); + + $livewire = Livewire::test(UpdateBackupTaskForm::class, [ + 'backupTask' => $this->data['backupTask'], + 'remoteServers' => RemoteServer::all(), + ]); + + $livewire->set('selectedTags', [$tag->id]) + ->call('submit') + ->assertHasErrors([ + 'selectedTags' => 'exists', + ]); +}); + +test('users cannot set tags that do not exist', function () { + + $livewire = Livewire::test(UpdateBackupTaskForm::class, [ + 'backupTask' => $this->data['backupTask'], + 'remoteServers' => RemoteServer::all(), + ]); + + $livewire->set('selectedTags', [999]) + ->call('submit') + ->assertHasErrors([ + 'selectedTags' => 'exists', + ]); +}); + +test('a user can update their already existing tags', function () { + + $user = User::factory()->create(); + + $tag1 = Tag::factory()->create(['label' => 'Tag 1', 'user_id' => $user->id]); + $tag2 = Tag::factory()->create(['label' => 'Tag 2', 'user_id' => $user->id]); + $tag3 = Tag::factory()->create(['label' => 'Tag 3', 'user_id' => $user->id]); + + $backupTask = BackupTask::factory()->create([ + 'user_id' => $user->id, + ]); + + $backupTask->tags()->attach([$tag1->id, $tag2->id]); + + $this->actingAs($user); + + Livewire::test(UpdateBackupTaskForm::class, [ + 'backupTask' => $backupTask, + 'remoteServers' => RemoteServer::all(), + 'availableTags' => $user->tags, + ]) + ->set('selectedTags', [$tag3->id]) + ->set('sourcePath', '/var/www/html') + ->set('description', '') + ->call('submit') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('taggables', [ + 'tag_id' => $tag3->id, + 'taggable_id' => $backupTask->id, + 'taggable_type' => 'App\Models\BackupTask', + ]); + + $this->assertDatabaseMissing('taggables', [ + 'tag_id' => $tag1->id, + 'taggable_id' => $backupTask->id, + 'taggable_type' => 'App\Models\BackupTask', + ]); + + $this->assertDatabaseMissing('taggables', [ + 'tag_id' => $tag2->id, + 'taggable_id' => $backupTask->id, + 'taggable_type' => 'App\Models\BackupTask', + ]); +}); diff --git a/tests/Feature/Tags/Livewire/CreateFormTest.php b/tests/Feature/Tags/Livewire/CreateFormTest.php new file mode 100644 index 00000000..53004dad --- /dev/null +++ b/tests/Feature/Tags/Livewire/CreateFormTest.php @@ -0,0 +1,42 @@ +assertOk(); +}); + +test('a user can create a new tag', function () { + + $user = User::factory()->create(); + + $livewire = Livewire::actingAs($user) + ->test('tags.create-form') + ->set('label', 'New Tag') + ->set('description', 'This is a new tag') + ->call('submit'); + + $this->assertDatabaseHas('tags', [ + 'label' => 'New Tag', + 'description' => 'This is a new tag', + 'user_id' => $user->id, + ]); + + $livewire->assertRedirect(route('tags.index')); +}); + +test('a label is required', function () { + + $user = User::factory()->create(); + + $livewire = Livewire::actingAs($user) + ->test('tags.create-form') + ->set('label', '') + ->call('submit') + ->assertHasErrors(['label' => 'required']); + + $this->assertDatabaseCount('tags', 0); +}); diff --git a/tests/Feature/Tags/Livewire/DeleteTest.php b/tests/Feature/Tags/Livewire/DeleteTest.php new file mode 100644 index 00000000..1a2aa00e --- /dev/null +++ b/tests/Feature/Tags/Livewire/DeleteTest.php @@ -0,0 +1,48 @@ +create(); + + $livewire = Livewire::test('tags.delete-tag-button', ['tag' => $tag]); + + $livewire->assertOk(); +}); + +test('A user can delete their own tag', function () { + + $user = User::factory()->create(); + $tag = Tag::factory()->create([ + 'user_id' => $user->id, + ]); + + $livewire = Livewire::actingAs($user)->test('tags.delete-tag-button', ['tag' => $tag]) + ->call('delete'); + + $this->assertDatabaseMissing('tags', [ + 'id' => $tag->id, + ]); + + $livewire->assertRedirect(route('tags.index')); + $this->assertAuthenticatedAs($user); +}); + +test('Another user cannot delete a users tag', function () { + + $userOne = User::factory()->create(); + $userTwo = User::factory()->create(); + + $tag = Tag::factory()->create(['user_id' => $userOne->id]); + + $livewire = Livewire::actingAs($userTwo) + ->test('tags.delete-tag-button', ['tag' => $tag]) + ->call('delete') + ->assertForbidden(); + + $this->assertDatabaseHas('tags', [ + 'id' => $tag->id, + ]); +}); diff --git a/tests/Feature/Tags/Livewire/UpdateFormTest.php b/tests/Feature/Tags/Livewire/UpdateFormTest.php new file mode 100644 index 00000000..0a2c70aa --- /dev/null +++ b/tests/Feature/Tags/Livewire/UpdateFormTest.php @@ -0,0 +1,73 @@ +create(); + + $livewire = Livewire::test('tags.update-form', ['tag' => $tag]); + + $livewire->assertOk(); +}); + +test('A user can update their own tag', function () { + + $user = User::factory()->create(); + + $tag = Tag::factory()->create(['user_id' => $user->id, 'label' => 'Old Tag', 'description' => 'Old Description']); + + $livewire = Livewire::actingAs($user) + ->test('tags.update-form', ['tag' => $tag]) + ->set('label', 'New Tag') + ->set('description', 'New Description') + ->call('submit'); + + $this->assertDatabaseHas('tags', [ + 'label' => 'New Tag', + 'description' => 'New Description', + 'user_id' => $user->id, + ]); + + $livewire->assertRedirect(route('tags.index')); +}); + +test('Another user cannot update a users tag', function () { + + $userOne = User::factory()->create(); + $userTwo = User::factory()->create(); + + $tag = Tag::factory()->create(['user_id' => $userOne->id, 'label' => 'Old Tag', 'description' => 'Old Description']); + + $livewire = Livewire::actingAs($userTwo) + ->test('tags.update-form', ['tag' => $tag]) + ->set('label', 'New Tag') + ->set('description', 'New Description') + ->call('submit') + ->assertForbidden(); + + $this->assertDatabaseHas('tags', [ + 'label' => 'Old Tag', + 'description' => 'Old Description', + 'user_id' => $userOne->id, + ]); +}); + +test('a label is required', function () { + + $user = User::factory()->create(); + + $tag = Tag::factory()->create(['user_id' => $user->id]); + + $livewire = Livewire::actingAs($user) + ->test('tags.update-form', ['tag' => $tag]) + ->set('label', '') + ->call('submit') + ->assertHasErrors(['label' => 'required']); + + $this->assertDatabaseHas('tags', [ + 'label' => $tag->label, + 'user_id' => $user->id, + ]); +}); diff --git a/tests/Feature/Tags/Pages/CreatePageTest.php b/tests/Feature/Tags/Pages/CreatePageTest.php new file mode 100644 index 00000000..a29f095b --- /dev/null +++ b/tests/Feature/Tags/Pages/CreatePageTest.php @@ -0,0 +1,23 @@ +create(); + + $response = $this->actingAs($user)->get(route('tags.create')); + + $response->assertOk(); + + $this->assertAuthenticatedAs($user); +}); + +test('the page is not rendered by guests', function () { + + $response = $this->get(route('tags.create')); + + $response->assertRedirect(route('login')); + + $this->assertGuest(); +}); diff --git a/tests/Feature/Tags/Pages/EditPageTest.php b/tests/Feature/Tags/Pages/EditPageTest.php new file mode 100644 index 00000000..30b3a24f --- /dev/null +++ b/tests/Feature/Tags/Pages/EditPageTest.php @@ -0,0 +1,48 @@ +create(); + + $tag = Tag::factory()->create([ + 'user_id' => $user->id, + ]); + + $response = $this->actingAs($user)->get(route('tags.edit', $tag)); + + $response->assertOk(); + $response->assertViewIs('tags.edit'); + $response->assertViewHas('tag', $tag); + + $this->assertAuthenticatedAs($user); + $this->assertEquals($user->id, $tag->user_id); +}); + +test('the page is not rendered by unauthorized users', function () { + + $user = User::factory()->create(); + + $tag = Tag::factory()->create(); + + $response = $this->actingAs($user)->get(route('tags.edit', $tag)); + + $response->assertForbidden(); + + $this->assertAuthenticatedAs($user); + + $this->assertNotEquals($user->id, $tag->user_id); +}); + +test('the page is not rendered by guests', function () { + + $tag = Tag::factory()->create(); + + $response = $this->get(route('tags.edit', $tag)); + + $response->assertRedirect(route('login')); + + $this->assertGuest(); +}); diff --git a/tests/Feature/Tags/Pages/IndexPageTest.php b/tests/Feature/Tags/Pages/IndexPageTest.php new file mode 100644 index 00000000..f29d19e2 --- /dev/null +++ b/tests/Feature/Tags/Pages/IndexPageTest.php @@ -0,0 +1,23 @@ +create(); + + $response = $this->actingAs($user)->get(route('tags.index')); + + $response->assertOk(); + + $this->assertAuthenticatedAs($user); +}); + +test('the page is not rendered by guests', function () { + + $response = $this->get(route('tags.index')); + + $response->assertRedirect(route('login')); + + $this->assertGuest(); +}); From 335d20370d2ac0d82757555c079a72020a8c5ca0 Mon Sep 17 00:00:00 2001 From: Lewis Larsen Date: Tue, 18 Jun 2024 16:34:16 +0100 Subject: [PATCH 2/4] feat: added tagging icon to backup tasks --- app/Models/BackupTask.php | 9 +++++++++ .../livewire/backup-tasks/index-item.blade.php | 15 +++++++++++++++ tests/Unit/BackupTaskTest.php | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/app/Models/BackupTask.php b/app/Models/BackupTask.php index 450907f5..b1caf1b3 100644 --- a/app/Models/BackupTask.php +++ b/app/Models/BackupTask.php @@ -466,6 +466,15 @@ public function isAnotherTaskRunningOnSameRemoteServer(): bool ->exists(); } + public function listOfAttachedTagLabels(): ?string + { + if ($this->tags->isEmpty()) { + return null; + } + + return $this->tags->pluck('label')->implode(', '); + } + private function cronExpression(): CronExpression { return new CronExpression($this->custom_cron_expression); diff --git a/resources/views/livewire/backup-tasks/index-item.blade.php b/resources/views/livewire/backup-tasks/index-item.blade.php index 3f1bd355..177f9965 100644 --- a/resources/views/livewire/backup-tasks/index-item.blade.php +++ b/resources/views/livewire/backup-tasks/index-item.blade.php @@ -13,6 +13,11 @@ @endif
+ @if($backupTask->tags()->exists()) + + @svg('heroicon-o-tag', 'h-5 w-5 text-gray-400 dark:text-gray-600 inline') + + @endif {{ $backupTask->label }}
@@ -63,3 +68,13 @@ @endif
+ +@if($backupTask->tags()->exists()) + +@endif diff --git a/tests/Unit/BackupTaskTest.php b/tests/Unit/BackupTaskTest.php index 568d617a..91846fff 100644 --- a/tests/Unit/BackupTaskTest.php +++ b/tests/Unit/BackupTaskTest.php @@ -753,3 +753,21 @@ expect($task->isAnotherTaskRunningOnSameRemoteServer())->toBeFalse(); }); + +it('returns null if there are no attached tags', function () { + + $task = BackupTask::factory()->create(); + + expect($task->listOfAttachedTagLabels())->toBeNull(); +}); + +it('returns the attached tags as a string', function () { + + $task = BackupTask::factory()->create(); + $tag1 = \App\Models\Tag::factory()->create(['label' => 'Tag 1']); + $tag2 = \App\Models\Tag::factory()->create(['label' => 'Tag 2']); + + $task->tags()->attach([$tag1->id, $tag2->id]); + + expect($task->listOfAttachedTagLabels())->toBe('Tag 1, Tag 2'); +}); From 8260edeec8da22d26ed1f9a7ef120ed468e50184 Mon Sep 17 00:00:00 2001 From: lewislarsen Date: Tue, 18 Jun 2024 15:50:43 +0000 Subject: [PATCH 3/4] Dusting --- resources/views/livewire/backup-tasks/index-item.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/backup-tasks/index-item.blade.php b/resources/views/livewire/backup-tasks/index-item.blade.php index 177f9965..60f0752a 100644 --- a/resources/views/livewire/backup-tasks/index-item.blade.php +++ b/resources/views/livewire/backup-tasks/index-item.blade.php @@ -13,7 +13,7 @@ @endif
- @if($backupTask->tags()->exists()) + @if ($backupTask->tags()->exists()) @svg('heroicon-o-tag', 'h-5 w-5 text-gray-400 dark:text-gray-600 inline') @@ -69,7 +69,7 @@ @endif
-@if($backupTask->tags()->exists()) +@if ($backupTask->tags()->exists())