Skip to content

Commit

Permalink
Export data (#64)
Browse files Browse the repository at this point in the history
* Add photo export functionality

* Add Export Data button

* Refactor to an Action

* Complete test for exporting photo data

* Add tooltip for exporting data

* Fix exporting large amounts of data
  • Loading branch information
GeniJaho authored May 18, 2024
1 parent 50a0881 commit 16d4f84
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 20 deletions.
27 changes: 27 additions & 0 deletions app/Actions/Photos/ExportPhotosAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Actions\Photos;

use App\DTO\PhotoExport;
use App\Models\User;
use Generator;
use Illuminate\Contracts\Database\Eloquent\Builder;

class ExportPhotosAction
{
public function run(User $user): Generator
{
$photos = $user
->photos()
->filter($user->settings->photo_filters)
->with(['photoItems' => fn (Builder $q) => $q
->with('item:id,name')
->with('tags:id,name'),
])
->lazyById();

foreach ($photos as $photo) {
yield PhotoExport::fromModel($photo);
}
}
}
3 changes: 2 additions & 1 deletion app/Console/Commands/GenerateRandomPhotos.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function handle(): void
{
/** @var User $user */
$user = User::query()
->where('email', 'trashkiller@litterhero.com')
->where('email', 'trashkiller@litterapp.com')
->first();

$bar = progress('Generating 1M photos with tags...', 1_000_000);
Expand All @@ -34,6 +34,7 @@ public function handle(): void
$photos[] = [
'user_id' => $user->id,
'path' => 'photos/default.jpg',
'original_file_name' => fake()->unique()->sentence().'default.jpg',
'created_at' => $now,
'updated_at' => $now,
];
Expand Down
46 changes: 46 additions & 0 deletions app/DTO/PhotoExport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace App\DTO;

use App\Models\Photo;
use App\Models\PhotoItem;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Data;

/** @phpstan-consistent-constructor */
class PhotoExport extends Data
{
/**
* @param Collection<int, array<int, mixed>> $items
*/
public function __construct(
public int $id,
public string $original_file_name,
public ?float $latitude,
public ?float $longitude,
public ?string $taken_at_local,
public string $created_at,
public Collection $items,
) {
}

public static function fromModel(Photo $photo): static
{
return new static(
id: $photo->id,
original_file_name: $photo->original_file_name,
latitude: $photo->latitude,
longitude: $photo->longitude,
taken_at_local: $photo->taken_at_local,
created_at: $photo->created_at?->toIso8601String(),
items: $photo->photoItems->map(fn (PhotoItem $photoItem): array => [
'name' => $photoItem->item?->name,
'picked_up' => $photoItem->picked_up,
'recycled' => $photoItem->recycled,
'deposit' => $photoItem->deposit,
'quantity' => $photoItem->quantity,
'tags' => $photoItem->tags->pluck('name')->toArray(),
]),
);
}
}
24 changes: 24 additions & 0 deletions app/Http/Controllers/Photos/ExportPhotosController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Http\Controllers\Photos;

use App\Actions\Photos\ExportPhotosAction;
use App\Http\Controllers\Controller;
use App\Models\User;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ExportPhotosController extends Controller
{
public function __invoke(ExportPhotosAction $action): StreamedResponse
{
/** @var User $user */
$user = auth()->user();

$photos = $action->run($user);

return response()->streamJson(['photos' => $photos], 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="photos.json"',
]);
}
}
2 changes: 1 addition & 1 deletion app/Models/PhotoItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

/**
* @property Collection<int, Tag> $tags
* @property Photo $photo
* @property-read Photo $photo
*/
class PhotoItem extends Pivot
{
Expand Down
5 changes: 4 additions & 1 deletion database/factories/PhotoItemFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Database\Factories;

use App\Models\Item;
use App\Models\Photo;
use App\Models\PhotoItem;
use Illuminate\Database\Eloquent\Factories\Factory;

Expand All @@ -18,7 +20,8 @@ class PhotoItemFactory extends Factory
public function definition(): array
{
return [
//
'photo_id' => Photo::factory(),
'item_id' => Item::factory(),
];
}
}
54 changes: 37 additions & 17 deletions resources/js/Pages/Photos/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ const filter = (filters) => {
clearSelection();
router.get(window.location.pathname, filters);
}
const exportData = () => {
window.location.href = route('photos.export');
}
</script>

<template>
Expand Down Expand Up @@ -195,27 +199,43 @@ const filter = (filters) => {
<div class="py-6 lg:py-16">
<div class="max-w-9xl mx-auto sm:px-6 lg:px-8">

<div class="flex flex-row gap-4 px-4 sm:px-0">
<PrimaryButton @click="showFilters = !showFilters">
{{ showFilters ? 'Hide' : 'Show' }} Filters
</PrimaryButton>
<div class="flex flex-col sm:flex-row sm:justify-between gap-4 px-4 sm:px-0">
<div class="flex flex-row gap-4">
<PrimaryButton @click="showFilters = !showFilters">
{{ showFilters ? 'Hide' : 'Show' }} Filters
</PrimaryButton>

<PrimaryButton @click="toggleSelecting">
<PrimaryButton @click="toggleSelecting">
<span v-if="isSelecting">
Clear Selection {{ selectedPhotos.length ? `(${selectedPhotos.length})` : '' }}
</span>
<span v-else>Select Photos</span>
</PrimaryButton>

<BulkTag
v-if="isSelecting && selectedPhotos.length"
:photoIds="selectedPhotos"
:tags="tags"
:items="items"
:tagShortcuts="tagShortcuts"
:tagShortcutsEnabled="tagShortcutsEnabled"
@closeModalWithSuccess="clearSelection"
></BulkTag>
<span v-else>Select Photos</span>
</PrimaryButton>

<BulkTag
v-if="isSelecting && selectedPhotos.length"
:photoIds="selectedPhotos"
:tags="tags"
:items="items"
:tagShortcuts="tagShortcuts"
:tagShortcutsEnabled="tagShortcutsEnabled"
@closeModalWithSuccess="clearSelection"
></BulkTag>
</div>

<div>
<PrimaryButton @click="exportData" class="group relative">
<Tooltip>
<div class="w-full min-w-32">
<div class="dark:text-white">
Downloads a JSON file with all the items and
tags of the photos you have filtered
</div>
</div>
</Tooltip>
Export Data
</PrimaryButton>
</div>
</div>

<Filters
Expand Down
2 changes: 2 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Http\Controllers\HomeController;
use App\Http\Controllers\Photos\BulkPhotoItemsController;
use App\Http\Controllers\Photos\CopyPhotoItemController;
use App\Http\Controllers\Photos\ExportPhotosController;
use App\Http\Controllers\Photos\PhotoItemsController;
use App\Http\Controllers\Photos\PhotoItemTagsController;
use App\Http\Controllers\Photos\PhotosController;
Expand Down Expand Up @@ -50,6 +51,7 @@
Route::post('/upload', [UploadPhotosController::class, 'store']);

Route::get('/my-photos', [PhotosController::class, 'index'])->name('my-photos');
Route::get('/photos/export', ExportPhotosController::class)->name('photos.export');
Route::get('/photos/{photo}', [PhotosController::class, 'show'])->name('photos.show');
Route::delete('/photos/{photo}', [PhotosController::class, 'destroy'])->name('photos.destroy');

Expand Down
38 changes: 38 additions & 0 deletions tests/Feature/Photos/ExportPhotosTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

use App\Models\Photo;
use App\Models\User;

test('a user can export their photos', function (): void {
$this->actingAs($user = User::factory()->create());

$photo = Photo::factory()->for($user)->create();

$response = $this->get('/photos/export');

$response->assertOk();
$response->assertDownload('photos.json');
$response->assertStreamedJsonContent([
'photos' => [[
'id' => $photo->id,
'original_file_name' => $photo->original_file_name,
'latitude' => $photo->latitude,
'longitude' => $photo->longitude,
'taken_at_local' => $photo->taken_at_local,
'created_at' => $photo->created_at->toIso8601String(),
'items' => [],
]],
]);
});

test('if there are no photos the export still works', function (): void {
$this->actingAs($user = User::factory()->create());

$response = $this->get('/photos/export');

$response->assertOk();
$response->assertDownload('photos.json');
$response->assertStreamedJsonContent([
'photos' => [],
]);
});
77 changes: 77 additions & 0 deletions tests/Unit/Actions/ExportPhotosActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

use App\Actions\Photos\ExportPhotosAction;
use App\DTO\PhotoExport;
use App\DTO\PhotoFilters;
use App\DTO\UserSettings;
use App\Models\Photo;
use App\Models\PhotoItem;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Support\Collection;

use function Pest\Laravel\freezeTime;

it('exports photos with items and tags', function (): void {
freezeTime();
$user = User::factory()->create();
$photo = Photo::factory()->create(['user_id' => $user->id]);
$photoItem = PhotoItem::factory()->create([
'photo_id' => $photo->id,
'picked_up' => true,
'recycled' => false,
'deposit' => true,
'quantity' => 2,
]);
$tag = Tag::factory()->create();
$photoItem->tags()->attach($tag->id);

$exportPhotosAction = new ExportPhotosAction();
$result = $exportPhotosAction->run($user);

expect($result)->toBeInstanceOf(Generator::class);

$result = iterator_to_array($result);

expect($result)->toHaveCount(1)
->and($result[0])->toBeInstanceOf(PhotoExport::class)
->id->toBe($photo->id)
->original_file_name->toBe($photo->original_file_name)
->latitude->toBe($photo->latitude)
->longitude->toBe($photo->longitude)
->taken_at_local->toBe($photo->taken_at_local)
->created_at->toEqual($photo->created_at->toIso8601String())
->items->toBeInstanceOf(Collection::class)
->items->toHaveCount(1)
->items->first()->toBe([
'name' => $photoItem->item->name,
'picked_up' => $photoItem->picked_up,
'recycled' => $photoItem->recycled,
'deposit' => $photoItem->deposit,
'quantity' => $photoItem->quantity,
'tags' => [$tag->name],
]);
});

it('follows user defined filters when returning a response', function (): void {
freezeTime();
$user = User::factory()->create([
'settings' => new UserSettings(photo_filters: new PhotoFilters(picked_up: true)),
]);

$pickedUpPhoto = Photo::factory()->create(['user_id' => $user->id]);
$pickedUpPhotoItem = PhotoItem::factory()->create(['photo_id' => $pickedUpPhoto->id, 'picked_up' => true]);

$notPickedUpPhoto = Photo::factory()->create(['user_id' => $user->id]);
$notPickedUpPhotoItem = PhotoItem::factory()->create(['photo_id' => $notPickedUpPhoto->id, 'picked_up' => false]);

$exportPhotosAction = new ExportPhotosAction();
$result = $exportPhotosAction->run($user);

expect($result)->toBeInstanceOf(Generator::class);

$result = iterator_to_array($result);

expect($result)->toHaveCount(1)
->and($result[0]->id)->toBe($pickedUpPhoto->id);
});

0 comments on commit 16d4f84

Please sign in to comment.