Skip to content

Commit

Permalink
feat: Added ability to copy tasks
Browse files Browse the repository at this point in the history
  • Loading branch information
lewislarsen committed Aug 21, 2024
1 parent a702275 commit 3bd9d4a
Show file tree
Hide file tree
Showing 5 changed files with 358 additions and 5 deletions.
119 changes: 119 additions & 0 deletions app/Livewire/BackupTasks/Modals/CopyTaskModal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace App\Livewire\BackupTasks\Modals;

use App\Models\BackupTask;
use App\Models\User;
use App\Rules\UniqueScheduledTimePerRemoteServer;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
use Toaster;

class CopyTaskModal extends Component
{
public ?int $backupTaskToCopyId = null;
public ?string $optionalNewLabel = null;
public string $frequency = 'daily';
public string $timeToRun = '00:00';
/** @var Collection<int, BackupTask> */
public Collection $backupTasks;

public function mount(): void
{
$user = Auth::user();
$this->backupTasks = $user instanceof User ? $user->getAttribute('backupTasks') : new Collection;
}

#[On('open-modal.copy-backup-task')]
public function resetModal(): void
{
$this->reset(['backupTaskToCopyId', 'optionalNewLabel', 'frequency', 'timeToRun']);
$user = Auth::user();
$this->backupTasks = $user instanceof User ? $user->getAttribute('backupTasks') : new Collection;
}

public function updatedBackupTaskToCopyId(?int $value): void
{
if ($value === null) {
return;
}

$task = BackupTask::find($value);
if ($task) {
$this->frequency = $task->frequency ?? 'daily';
$this->timeToRun = $task->time_to_run_at ?? '00:00';
}
}

public function copyTask(): void
{
$this->validate();

$originalTask = BackupTask::findOrFail($this->backupTaskToCopyId);

$newTask = $originalTask->replicate();
$newTask->label = $this->optionalNewLabel ?: $originalTask->label . ' (Copy)';
$newTask->frequency = $this->frequency;
$newTask->time_to_run_at = $this->timeToRun;
$newTask->custom_cron_expression = null; // Reset custom cron expression
$newTask->save();

// Copy relationships
$newTask->tags()->sync($originalTask->tags);
$newTask->notificationStreams()->sync($originalTask->notificationStreams);

$this->dispatch('task-copied');
$this->dispatch('close-modal', 'copy-backup-task');
$this->resetModal();
$this->dispatch('refreshBackupTasksTable');

Toaster::success('The backup task has been copied.');
}

public function render(): View
{
return view('livewire.backup-tasks.modals.copy-task-modal', [
'backupTasks' => $this->backupTasks,
'backupTimes' => $this->getBackupTimes(),
]);
}

/**
* @return array<string, mixed>
*/
protected function rules(): array
{
return [
'backupTaskToCopyId' => ['required', 'exists:backup_tasks,id'],
'optionalNewLabel' => ['nullable', 'string', 'max:255'],
'frequency' => ['required', 'string', Rule::in(['daily', 'weekly'])],
'timeToRun' => [
'required',
'string',
'regex:/^([01]?\d|2[0-3]):([0-5]\d)$/',
new UniqueScheduledTimePerRemoteServer((int) $this->getRemoteServerId()),
],
];
}

private function getRemoteServerId(): ?int
{
return BackupTask::find($this->backupTaskToCopyId)?->remote_server_id;
}

/**
* @return Collection<int, string>
*/
private function getBackupTimes(): Collection
{
return collect(range(0, 95))->map(function (int $quarterHour): string {
return sprintf('%02d:%02d', intdiv($quarterHour, 4), ($quarterHour % 4) * 15);
});
}
}
5 changes: 5 additions & 0 deletions app/Livewire/BackupTasks/Tables/IndexTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ class IndexTable extends Component
{
use WithPagination;

/**
* @var string[]
*/
protected $listeners = ['refreshBackupTasksTable' => '$refresh'];

/**
* Render the backup tasks index table.
*
Expand Down
16 changes: 11 additions & 5 deletions resources/views/livewire/backup-tasks/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
</x-slot>
<x-slot name="action">
@if (!Auth::user()->backupTasks->isEmpty())
<a href="{{ route('backup-tasks.create') }}" wire:navigate>
<x-primary-button centered>
{{ __('Add Backup Task') }}
</x-primary-button>
</a>
<div class="space-x-2 flex">
<x-secondary-button x-on:click="$dispatch('open-modal', 'copy-backup-task'); $dispatch('open-modal.copy-backup-task')">
@svg('heroicon-o-document-duplicate', 'h-5 w-5')
</x-secondary-button>
<a href="{{ route('backup-tasks.create') }}" wire:navigate>
<x-primary-button centered>
{{ __('Add Backup Task') }}
</x-primary-button>
</a>
</div>
@endif
</x-slot>
<div>
Expand All @@ -23,4 +28,5 @@
</div>
@endif
</div>
@livewire('backup-tasks.modals.copy-task-modal')
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<div>
<x-modal name="copy-backup-task" :show="$errors->isNotEmpty()" focusable>
<x-slot name="title">
{{ __('Copy Backup Task') }}
</x-slot>
<x-slot name="description">
{{ __('Create a new backup task by replicating an existing configuration.') }}
</x-slot>
<x-slot name="icon">
heroicon-o-document-duplicate
</x-slot>
<form wire:submit.prevent="copyTask">
<div>
<div>
<x-input-label for="backupTaskToCopyId" :value="__('Backup Task')" />
<x-select
id="backupTaskToCopyId"
name="backupTaskToCopyId"
class="mt-1 block w-full"
wire:model.live="backupTaskToCopyId">
<option value="">{{ __('Select a backup task') }}</option>
@foreach ($backupTasks as $task)
<option value="{{ $task->id }}">{{ $task->label }} ({{ $task->remoteServer->label }})</option>
@endforeach
</x-select>
<x-input-error :messages="$errors->get('backupTaskToCopyId')" class="mt-2" />
<x-input-explain>
{{ __('Please choose the backup task you wish to copy.') }}
</x-input-explain>
</div>
<div class="mt-4">
<x-input-label for="optionalNewLabel" :value="__('Label (Optional)')" />
<x-text-input
id="optionalNewLabel"
name="optionalNewLabel"
type="text"
class="mt-1 block w-full"
wire:model="optionalNewLabel"
/>
<x-input-error :messages="$errors->get('optionalNewLabel')" class="mt-2" />
<x-input-explain>
{{ __('Optionally, you may choose a new label for the copied backup task.') }}
</x-input-explain>
</div>
<div class="mt-4">
<div class="mt-2 flex space-x-4">
<div class="w-1/2">
<x-input-label for="frequency" :value="__('Frequency')" />
<x-select
id="frequency"
name="frequency"
class="mt-1 block w-full"
wire:model="frequency"
>
<option value="daily">{{ __('Daily') }}</option>
<option value="weekly">{{ __('Weekly') }}</option>
</x-select>
<x-input-error :messages="$errors->get('frequency')" class="mt-2" />
</div>
<div class="w-1/2">
<x-input-label for="timeToRun" :value="__('Time to Run')" />
<x-select
id="timeToRun"
name="timeToRun"
class="mt-1 block w-full"
wire:model="timeToRun"
>
@foreach ($backupTimes as $time)
<option value="{{ $time }}">{{ $time }}</option>
@endforeach
</x-select>
<x-input-error :messages="$errors->get('timeToRun')" class="mt-2" />
</div>
</div>
<x-input-explain>
{{ __('Select a non-conflicting schedule for this task. Each server allows only one task per time slot. For advanced scheduling using Cron, please edit the task after copying.') }}
</x-input-explain>
</div>
<div class="flex space-x-5">
<div class="w-4/6">
<x-primary-button type="button" class="mt-4" centered wire:click="copyTask" action="copyTask" loadingText="Copying...">
{{ __('Copy') }}
</x-primary-button>
</div>
<div class="w-2/6 ml-2">
<x-secondary-button type="button" class="mt-4" centered x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
</div>
</div>
</div>
</form>
</x-modal>
</div>
129 changes: 129 additions & 0 deletions tests/Feature/BackupTasks/Livewire/CopyTaskTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

use App\Livewire\BackupTasks\Modals\CopyTaskModal;
use App\Models\BackupTask;
use App\Models\NotificationStream;
use App\Models\User;
use Livewire\Livewire;

beforeEach(function (): void {
$this->user = User::factory()->create();
$this->backupTask = BackupTask::factory()->create([
'user_id' => $this->user->id,
'frequency' => 'daily',
'time_to_run_at' => '00:00',
]);
});

test('the component can be rendered', function (): void {
$this->actingAs($this->user);

Livewire::test(CopyTaskModal::class)
->assertOk();
});

test('it loads user backup tasks', function (): void {
$this->actingAs($this->user);

Livewire::test(CopyTaskModal::class)
->assertViewHas('backupTasks', fn ($backupTasks): bool => $backupTasks->count() === 1 && $backupTasks->first()->id === $this->backupTask->id
);
});

test('it updates frequency and time when task is selected', function (): void {
$this->actingAs($this->user);

Livewire::test(CopyTaskModal::class)
->set('backupTaskToCopyId', $this->backupTask->id)
->assertSet('frequency', $this->backupTask->frequency)
->assertSet('timeToRun', $this->backupTask->time_to_run_at);
});

test('it validates required fields', function (): void {
$this->actingAs($this->user);

Livewire::test(CopyTaskModal::class)
->set('backupTaskToCopyId', $this->backupTask->id)
->call('copyTask')
->assertHasErrors(['timeToRun']);
});

test('it validates frequency options', function (): void {
$this->actingAs($this->user);

Livewire::test(CopyTaskModal::class)
->set('backupTaskToCopyId', $this->backupTask->id)
->set('frequency', 'invalid')
->call('copyTask')
->assertHasErrors(['frequency']);
});

test('it validates time format', function (): void {
$this->actingAs($this->user);

Livewire::test(CopyTaskModal::class)
->set('backupTaskToCopyId', $this->backupTask->id)
->set('timeToRun', 'invalid')
->call('copyTask')
->assertHasErrors(['timeToRun']);
});

test('it successfully copies a task', function (): void {
$this->actingAs($this->user);

$newLabel = 'New Copied Task';

Livewire::test(CopyTaskModal::class)
->set('backupTaskToCopyId', $this->backupTask->id)
->set('optionalNewLabel', $newLabel)
->set('frequency', 'weekly')
->set('timeToRun', '12:00')
->call('copyTask')
->assertHasNoErrors()
->assertDispatched('task-copied')
->assertDispatched('close-modal')
->assertDispatched('refreshBackupTasksTable');

$this->assertDatabaseHas('backup_tasks', [
'label' => $newLabel,
'frequency' => 'weekly',
'time_to_run_at' => '12:00',
]);
});

test('it copies task relationships', function (): void {
$this->actingAs($this->user);

$tag = $this->backupTask->tags()->create(['label' => 'Test Tag', 'user_id' => $this->user->id]);
$stream = $this->backupTask->notificationStreams()->create(['label' => 'Test Stream', 'user_id' => $this->user->id, 'type' => NotificationStream::TYPE_EMAIL, 'value' => '[email protected]']);

Livewire::test(CopyTaskModal::class)
->set('backupTaskToCopyId', $this->backupTask->id)
->set('frequency', 'daily')
->set('timeToRun', '00:00')
->call('copyTask');

$newTask = BackupTask::latest()->first();

expect($newTask->tags)->toHaveCount(1)
->and($newTask->tags->first()->id)->toBe($tag->id)
->and($newTask->notificationStreams)->toHaveCount(1)
->and($newTask->notificationStreams->first()->id)->toBe($stream->id);
});

test('it resets form after copying', function (): void {
$this->actingAs($this->user);

Livewire::test(CopyTaskModal::class)
->set('backupTaskToCopyId', $this->backupTask->id)
->set('optionalNewLabel', 'Test Label')
->set('frequency', 'weekly')
->set('timeToRun', '12:00')
->call('copyTask')
->assertSet('backupTaskToCopyId', null)
->assertSet('optionalNewLabel', null)
->assertSet('frequency', 'daily')
->assertSet('timeToRun', '00:00');
});

0 comments on commit 3bd9d4a

Please sign in to comment.