diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index ddad7c2f..5759e372 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -3,3 +3,4 @@ e7bc3b942e1391b53c97663eea56d8eb57119290
9ea7e0a73de9cc356dbdcc01977492106548a9ae
80ae05cec1520a13efa53b8d8b007f42de61b5c8
52445316a5fdb2dfe46a9a147eba5cbd63a914a4
+8260edeec8da22d26ed1f9a7ef120ed468e50184
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..b1caf1b3 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';
@@ -465,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/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)
-
+
{{ $label }}
@endif
diff --git a/resources/views/livewire/backup-tasks/create-backup-task-form.blade.php b/resources/views/livewire/backup-tasks/create-backup-task-form.blade.php
index 01eed006..85545549 100644
--- a/resources/views/livewire/backup-tasks/create-backup-task-form.blade.php
+++ b/resources/views/livewire/backup-tasks/create-backup-task-form.blade.php
@@ -191,13 +191,29 @@ 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 ($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/index-item.blade.php b/resources/views/livewire/backup-tasks/index-item.blade.php
index 3f1bd355..60f0752a 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/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 @@
+
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 @@
+
+
+ {{ $tag->label }}
+
+
+ {{ $tag->description ?? __('—') }}
+
+
+ {{ $tag->created_at->timezone(auth()->user()->timezone)->format('F j, Y g:i A') }}
+
+
+
+
+
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 @@
+
+
+
+
+
+ @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();
+});
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');
+});