-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 22 — History Page (CONV-347–CONV-363) #294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
86905b4
Merge main back into develop after v0.1.21
menvil 7babd11
CONV-347: Create history page route and shell
menvil bf948f1
CONV-348: Create conversion history table component skeleton
menvil 45ecad3
CONV-349: Test history shows current user conversions
menvil 84c5ada
CONV-350: Implement user-scoped history query
menvil 978b49b
CONV-351: Add history table columns
menvil 029e035
CONV-352: Add history status badges
menvil 98971dd
CONV-353: Test history search filter
menvil 87fac5a
CONV-354: Implement history search filter
menvil 5ea79cc
CONV-355: Test history status filter
menvil 0c30036
CONV-356: Implement history status filter
menvil bbd8e84
CONV-357: Test history date range filter
menvil eb88184
CONV-358: Implement history date range filter
menvil 76de0be
CONV-359: Test history format filters
menvil 20dbdfb
CONV-360: Implement history format filters
menvil 031395b
CONV-361: Add history row actions
menvil cd32d7c
CONV-362: Add credit cost column
menvil c3f37da
CONV-363: Add history page final smoke tests
menvil 05bf0a1
Fix review findings: credits truthy check, eager-load in convertAgain…
menvil File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Livewire; | ||
|
|
||
| use App\Enums\ConversionStatus; | ||
| use App\Models\ConversionJob; | ||
| use Illuminate\Contracts\Pagination\LengthAwarePaginator; | ||
| use Illuminate\Contracts\View\View; | ||
| use Illuminate\Database\Eloquent\Builder; | ||
| use Livewire\Component; | ||
| use Livewire\WithPagination; | ||
|
|
||
| class ConversionHistoryTable extends Component | ||
| { | ||
| use WithPagination; | ||
|
|
||
| public string $search = ''; | ||
|
|
||
| public string $status = 'all'; | ||
|
|
||
| public string $sourceFormat = 'all'; | ||
|
|
||
| public string $targetFormat = 'all'; | ||
|
|
||
| public ?string $dateFrom = null; | ||
|
|
||
| public ?string $dateTo = null; | ||
|
|
||
| public function updatedSearch(): void | ||
| { | ||
| $this->resetPage(); | ||
| } | ||
|
|
||
| public function updatedStatus(): void | ||
| { | ||
| $this->resetPage(); | ||
| } | ||
|
|
||
| public function updatedSourceFormat(): void | ||
| { | ||
| $this->resetPage(); | ||
| } | ||
|
|
||
| public function updatedTargetFormat(): void | ||
| { | ||
| $this->resetPage(); | ||
| } | ||
|
|
||
| public function updatedDateFrom(): void | ||
| { | ||
| $this->resetPage(); | ||
| } | ||
|
|
||
| public function updatedDateTo(): void | ||
| { | ||
| $this->resetPage(); | ||
| } | ||
|
|
||
| public function statusOptions(): array | ||
| { | ||
| return [ | ||
| 'all' => 'All', | ||
| 'queued' => 'Queued', | ||
| 'processing' => 'Processing', | ||
| 'completed' => 'Completed', | ||
| 'failed' => 'Failed', | ||
| 'cancelled' => 'Cancelled', | ||
| 'expired' => 'Expired', | ||
| ]; | ||
| } | ||
|
|
||
| public function formatOptions(): array | ||
| { | ||
| return [ | ||
| 'all' => 'All formats', | ||
| 'png' => 'PNG', | ||
| 'jpg' => 'JPG', | ||
| 'webp' => 'WEBP', | ||
| 'pdf' => 'PDF', | ||
| 'gif' => 'GIF', | ||
| 'bmp' => 'BMP', | ||
| 'svg' => 'SVG', | ||
| 'tiff' => 'TIFF', | ||
| ]; | ||
| } | ||
|
|
||
| public function jobs(): LengthAwarePaginator | ||
| { | ||
| return ConversionJob::query() | ||
| ->where('user_id', auth()->id()) | ||
| ->with(['sourceFile', 'resultFile', 'creditCharge']) | ||
| ->when(trim($this->search) !== '', function (Builder $query) { | ||
| $search = '%'.trim($this->search).'%'; | ||
|
|
||
| $query->where(function (Builder $query) use ($search) { | ||
| $query | ||
| ->where('source_format', 'like', $search) | ||
| ->orWhere('target_format', 'like', $search) | ||
| ->orWhereHas('sourceFile', fn (Builder $q) => $q->where('original_name', 'like', $search)) | ||
| ->orWhereHas('resultFile', fn (Builder $q) => $q->where('original_name', 'like', $search)); | ||
| }); | ||
| }) | ||
| ->when($this->status !== 'all', fn (Builder $query) => $query->where('status', $this->status)) | ||
| ->when($this->sourceFormat !== 'all', fn (Builder $query) => $query->where('source_format', $this->sourceFormat)) | ||
| ->when($this->targetFormat !== 'all', fn (Builder $query) => $query->where('target_format', $this->targetFormat)) | ||
| ->when($this->dateFrom, fn (Builder $query) => $query->whereDate('created_at', '>=', $this->dateFrom)) | ||
| ->when($this->dateTo, fn (Builder $query) => $query->whereDate('created_at', '<=', $this->dateTo)) | ||
| ->latest() | ||
| ->paginate(15); | ||
| } | ||
|
|
||
| public function canDownload(ConversionJob $job): bool | ||
| { | ||
| return $job->isCompleted() | ||
| && $job->result_file_id !== null | ||
| && $job->resultFile !== null | ||
| && ! $job->resultFile->isExpired(); | ||
| } | ||
|
|
||
| public function canConvertAgain(ConversionJob $job): bool | ||
| { | ||
| return $job->sourceFile !== null && ! $job->sourceFile->isExpired(); | ||
| } | ||
|
|
||
| public function convertAgain(int $jobId): void | ||
| { | ||
| $job = ConversionJob::query() | ||
| ->where('user_id', auth()->id()) | ||
| ->with('sourceFile') | ||
| ->find($jobId); | ||
|
|
||
| if (! $job || ! $this->canConvertAgain($job)) { | ||
| $this->dispatch('toast', type: 'error', message: 'Original file expired. Upload it again to repeat this conversion.'); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| $this->dispatch('conversion-repeat-requested', conversionJobId: $job->id); | ||
| $this->redirectRoute('dashboard'); | ||
| } | ||
|
|
||
| public function statusBadgeVariant(ConversionStatus $status): string | ||
| { | ||
| return match ($status) { | ||
| ConversionStatus::Queued => 'purple', | ||
| ConversionStatus::Processing => 'warning', | ||
| ConversionStatus::Completed => 'success', | ||
| ConversionStatus::Failed => 'danger', | ||
| default => 'neutral', | ||
| }; | ||
| } | ||
|
|
||
| public function formatBytes(?int $bytes): string | ||
| { | ||
| if ($bytes === null) { | ||
| return '—'; | ||
| } | ||
|
|
||
| if ($bytes >= 1_000_000_000) { | ||
| return round($bytes / 1_000_000_000, 1).' GB'; | ||
| } | ||
|
|
||
| if ($bytes >= 1_000_000) { | ||
| return round($bytes / 1_000_000, 1).' MB'; | ||
| } | ||
|
|
||
| if ($bytes >= 1_000) { | ||
| return round($bytes / 1_000).' KB'; | ||
| } | ||
|
|
||
| return $bytes.' B'; | ||
| } | ||
|
|
||
| public function render(): View | ||
| { | ||
| return view('livewire.conversion-history-table', [ | ||
| 'jobs' => $this->jobs(), | ||
| ]); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <x-layouts.app title="Conversion History — ConvertAI"> | ||
| <section class="flex flex-col gap-2"> | ||
| <h1 class="text-3xl font-semibold tracking-tight text-[var(--ca-text)]">Conversion History</h1> | ||
| <p class="text-[var(--ca-muted)]">Find and reuse your previous conversions.</p> | ||
| </section> | ||
|
|
||
| <div class="mt-8"> | ||
| <livewire:conversion-history-table /> | ||
| </div> | ||
| </x-layouts.app> |
135 changes: 135 additions & 0 deletions
135
resources/views/livewire/conversion-history-table.blade.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| <div> | ||
| {{-- Filters --}} | ||
| <div class="flex flex-wrap gap-3"> | ||
| <input | ||
| type="search" | ||
| wire:model.live.debounce.300ms="search" | ||
| placeholder="Search files or formats..." | ||
| class="w-full rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] placeholder-[var(--ca-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30 sm:max-w-xs" | ||
| > | ||
|
|
||
| <select | ||
| wire:model.live="status" | ||
| class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30" | ||
| > | ||
| @foreach ($this->statusOptions() as $value => $label) | ||
| <option value="{{ $value }}">{{ $label }}</option> | ||
| @endforeach | ||
| </select> | ||
|
|
||
| <select | ||
| wire:model.live="sourceFormat" | ||
| class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30" | ||
| > | ||
| @foreach ($this->formatOptions() as $value => $label) | ||
| <option value="{{ $value }}">From: {{ $label }}</option> | ||
| @endforeach | ||
| </select> | ||
|
|
||
| <select | ||
| wire:model.live="targetFormat" | ||
| class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30" | ||
| > | ||
| @foreach ($this->formatOptions() as $value => $label) | ||
| <option value="{{ $value }}">To: {{ $label }}</option> | ||
| @endforeach | ||
| </select> | ||
|
|
||
| <input | ||
| type="date" | ||
| wire:model.live="dateFrom" | ||
| class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30" | ||
| > | ||
|
|
||
| <input | ||
| type="date" | ||
| wire:model.live="dateTo" | ||
| class="rounded-[var(--ca-radius-md)] border border-[var(--ca-border)] bg-white px-3 py-2 text-sm text-[var(--ca-text)] focus:outline-none focus:ring-2 focus:ring-[var(--ca-primary)]/30" | ||
| > | ||
| </div> | ||
|
|
||
| {{-- Table --}} | ||
| <div class="mt-4"> | ||
| @if ($jobs->isEmpty()) | ||
| <div class="flex flex-col items-center justify-center rounded-[var(--ca-radius-lg)] border border-dashed border-[var(--ca-border)] bg-[var(--ca-surface-muted)]/40 px-6 py-12 text-center"> | ||
| <p class="text-base font-semibold text-[var(--ca-text)]">No conversions found</p> | ||
| <p class="mt-1 text-sm text-[var(--ca-muted)]">Try adjusting your filters or upload a file to start converting</p> | ||
| </div> | ||
| @else | ||
| <div class="overflow-hidden rounded-[var(--ca-radius-lg)] border border-[var(--ca-border)] bg-white shadow-[var(--ca-shadow-card)]"> | ||
| <table class="w-full text-sm"> | ||
| <thead> | ||
| <tr class="border-b border-[var(--ca-border)] bg-[var(--ca-surface-muted)]/40"> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">File Name</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">From</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">To</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Size</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Created</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Completed</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Status</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Credits</th> | ||
| <th class="px-4 py-3 text-left font-medium text-[var(--ca-muted)]">Actions</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody class="divide-y divide-[var(--ca-border)]"> | ||
| @foreach ($jobs as $job) | ||
| <tr class="hover:bg-[var(--ca-surface-muted)]/40"> | ||
| <td class="px-4 py-3 text-[var(--ca-text)]"> | ||
| {{ $job->sourceFile?->original_name ?? '—' }} | ||
| </td> | ||
| <td class="px-4 py-3 text-[var(--ca-text)]"> | ||
| {{ $job->source_format ? strtoupper($job->source_format) : '—' }} | ||
| </td> | ||
| <td class="px-4 py-3 text-[var(--ca-text)]"> | ||
| {{ $job->target_format ? strtoupper($job->target_format) : '—' }} | ||
| </td> | ||
| <td class="px-4 py-3 text-[var(--ca-text)]"> | ||
| {{ $this->formatBytes($job->sourceFile?->size_bytes) }} | ||
| </td> | ||
| <td class="px-4 py-3 text-[var(--ca-text)]"> | ||
| {{ $job->created_at->format('M d, Y H:i') }} | ||
| </td> | ||
| <td class="px-4 py-3 text-[var(--ca-text)]"> | ||
| {{ $job->completed_at?->format('M d, Y H:i') ?? '—' }} | ||
| </td> | ||
| <td class="px-4 py-3"> | ||
| <x-badge :variant="$this->statusBadgeVariant($job->status)"> | ||
| {{ ucfirst($job->status->value) }} | ||
| </x-badge> | ||
| </td> | ||
| <td class="px-4 py-3 text-[var(--ca-text)]"> | ||
| @if ($job->creditCharge !== null && $job->creditCharge->captured_amount !== null) | ||
| {{ $job->creditCharge->captured_amount }} {{ $job->creditCharge->captured_amount === 1 ? 'credit' : 'credits' }} | ||
| @else | ||
| — | ||
| @endif | ||
| </td> | ||
| <td class="px-4 py-3"> | ||
| <div class="flex items-center gap-2"> | ||
| @if ($this->canDownload($job)) | ||
| <a | ||
| href="{{ route('conversions.download', $job) }}" | ||
| class="text-sm font-medium text-[var(--ca-primary)] hover:underline" | ||
| >Download</a> | ||
| @endif | ||
|
|
||
| @if ($this->canConvertAgain($job)) | ||
| <button | ||
| wire:click="convertAgain({{ $job->id }})" | ||
| class="text-sm font-medium text-[var(--ca-muted)] hover:text-[var(--ca-text)] hover:underline" | ||
| >Convert Again</button> | ||
| @endif | ||
| </div> | ||
| </td> | ||
| </tr> | ||
| @endforeach | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
|
|
||
| <div class="mt-4"> | ||
| {{ $jobs->links() }} | ||
| </div> | ||
| @endif | ||
| </div> | ||
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use App\Models\User; | ||
|
|
||
| it('redirects guest from history page to login', function () { | ||
| $this->get('/history') | ||
| ->assertRedirect('/login'); | ||
| }); | ||
|
|
||
| it('allows authenticated user to access history page', function () { | ||
| $user = User::factory()->create(); | ||
|
|
||
| $this->actingAs($user) | ||
| ->get('/history') | ||
| ->assertOk() | ||
| ->assertSee('Conversion History'); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.