feat(project): add icons and improve persistent volume management

Add project icon storage and serving with local/S3 support, prevent deletion of compose-managed volumes, and optimize volume backups. Refine project and backup UI components with consistent styling and coverage.
This commit is contained in:
Andras Bacsai committed 2026-08-13 22:56:52 +02:00
1 parent a02d02d19b
commit fd5eb3e0cd
43 files changed
+728 -60

No files matched your search

@@ -318,6 +318,10 @@ it('queues instance database backup without redirecting when project context is
'backup' => $backup->fresh(),
'availableS3Storages' => collect(),
])
->assertSee('Retention')
->assertSee('S3 storage')
->assertSee('Local backups')
->assertSee('S3 backups')
->call('backupNow')
->assertDispatched('success', 'Backup queued. It will be available in a few minutes.')
->assertNoRedirect()
+3
View File
@@ -63,6 +63,9 @@ it('renders frontend-only application backup search data for volume names and fr
->assertDontSee('class="font-mono text-xs">daily', false)
->assertDontSee('backup-type-filter-trigger', false)
->assertDontSee('backup-sort-trigger', false);
expect(file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php')))
->toContain('wire:key="application-heading-backup-index-{{ $application->id }}"');
});
it('renders frontend-only database backup search data for database names and frequencies', function () {
@@ -0,0 +1,11 @@
<?php
it('uses the standard button height inside application forms and workspaces', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
$applicationStyles = file_get_contents(resource_path('css/app.css'));
expect($utilities)->toContain('px-2.5 h-9 min-h-9')
->and($applicationStyles)
->not->toContain('.application-settings-workspace .button')
->not->toContain('.application-settings-form .button');
});
+16
View File
@@ -0,0 +1,16 @@
<?php
it('renders a reusable compact copy button', function () {
$html = $this->blade('<x-copy-button value="backup/path.sql" label="Copy backup path" />');
$html->assertSee('Copy backup path')
->assertSee('backup\/path.sql', false)
->assertSee('window.copyToClipboard', false)
->assertSee('size-6', false);
});
it('uses the reusable copy button for database backup paths', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path" />');
});
@@ -32,3 +32,14 @@ it('returns from database backup settings to the database', function () {
->toContain("'Back to database'")
->toContain("except('backup_uuid')");
});
it('shows the backup path directly on every execution', function () {
$executions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($executions)
->toContain('Backup path:')
->toContain("data_get(\$execution, 'filename', 'N/A')")
->toContain('<span>Backup path</span>')
->toContain('class="select-all truncate font-mono text-[11px]')
->not->toContain('backup-executions-table-grid border-t');
});
+3 -2
View File
@@ -117,8 +117,9 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
->toContain('.logs-viewer-actions')
->toContain('.logs-viewer-deployment-actions')
->toContain('.logs-settings-section')
->toContain('padding: 0.5rem 0.75rem 2rem;')
->toContain('padding: 0.5rem 1rem 2rem;')
->toContain('padding: 0.5rem 0.75rem 0;')
->toContain('padding: 0.5rem 1rem 0;')
->toContain(".logs-viewer-viewport::after {\n content: \"\";\n flex: 0 0 2rem;")
->toContain('flex-direction: column')
->toContain('@media (min-width: 640px)');
+2 -2
View File
@@ -53,7 +53,7 @@ it('keeps backup execution actions compact', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($css)
->toContain('grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
->toContain('grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
->and($view)
->toContain('title="Download backup" aria-label="Download backup"')
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
@@ -71,7 +71,7 @@ it('uses the compact resource table styling for backup executions', function ()
->toContain('data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5')
->toContain('flex min-h-11 items-center justify-between border-t')
->and($css)
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*49rem;/');
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*66rem;/');
});
it('renders compact size without the full-page min height class', function () {
@@ -38,8 +38,8 @@ it('preselects the first result in every resource selection step', function () {
expect($view)
->toContain('preselectFirstResult()')
->toContain("this.selectedIndex = 0;")
->toContain("results[0].focus();")
->toContain('this.selectedIndex = 0;')
->toContain('results[0].focus();')
->and(substr_count($view, 'x-init="preselectFirstResult()"'))->toBe(4);
});
@@ -73,6 +73,13 @@ it('closes the client-side command palette without a Livewire request', function
->not->toContain('closeTimer');
});
it('closes the mobile sidebar when the command palette opens', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('@open-global-search.window="open = false"');
});
it('keeps palette content intact during the close animation to prevent flicker', function () {
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
@@ -50,6 +50,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Livewire\Livewire;
@@ -257,6 +258,82 @@ it('shows PR deployment suffix only for git-based applications', function () {
->assertSet('supportsPreviewSuffix', false)
->assertDontSee('Add suffix')
->assertDontSee('PR deployment suffix');
[$nonGitComposeApp] = createApplicationWithVolume([
'build_pack' => 'dockercompose',
'git_repository' => '',
'git_branch' => '',
]);
Livewire::test(All::class, ['resource' => $nonGitComposeApp])
->assertSet('supportsPreviewSuffix', false)
->assertDontSee('Add suffix');
});
it('allows stale compose volume metadata to be deleted', function () {
[$application, $volume] = createApplicationWithVolume([
'build_pack' => 'dockercompose',
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
YAML,
]);
Livewire::test(All::class, ['resource' => $application])
->assertSee('Delete stale volume entry')
->call('delete', $volume->id, 'password');
expect($volume->fresh())->toBeNull();
});
it('deletes the Docker volume only when explicitly selected', function () {
Process::fake();
DB::table('private_keys')->where('id', $this->server->private_key_id)->update([
'private_key' => encrypt('test-key'),
]);
[$application, $volume] = createApplicationWithVolume([
'build_pack' => 'dockercompose',
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
YAML,
]);
Livewire::test(All::class, ['resource' => $application])
->assertSet('deleteDockerVolume', false)
->call('delete', $volume->id, 'password', ['deleteDockerVolume'])
->assertSet('deleteDockerVolume', true);
Process::assertRan(fn () => true);
expect($volume->fresh())->toBeNull();
});
it('does not allow compose volume metadata that is still declared to be deleted', function () {
[$application, $volume] = createApplicationWithVolume([
'build_pack' => 'dockercompose',
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
volumes:
- data:/data
volumes:
data:
YAML,
]);
$volume->name = $application->uuid.'_data';
$volume->save();
Livewire::test(All::class, ['resource' => $application])
->assertDontSee('Delete stale volume entry')
->call('delete', $volume->id, 'password')
->assertDispatched('error');
expect($volume->fresh())->not->toBeNull();
});
it('hides PR deployment suffix for databases', function () {
+101
View File
@@ -0,0 +1,101 @@
<?php
use App\Livewire\Project\Edit;
use App\Livewire\Project\Index;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'avatar_storage_type' => 'local',
]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
});
it('stores a project icon using the instance image storage setting', function () {
Storage::fake('local');
$upload = UploadedFile::fake()->createWithContent('project.jpg', file_get_contents(base_path('tests/Fixtures/project-icon.jpg')));
Livewire::test(Edit::class, ['project_uuid' => $this->project->uuid])
->set('icon', $upload)
->call('uploadIcon')
->assertHasNoErrors();
$this->project->refresh();
expect($this->project->icon_path)
->toBe("project-icons/{$this->project->uuid}/icon.jpg")
->and($this->project->icon_storage_type)->toBe('local')
->and($this->project->icon_s3_storage_id)->toBeNull();
Storage::disk('local')->assertExists($this->project->icon_path);
});
it('serves a project icon only to a member of its team', function () {
Storage::fake('local');
$this->project->forceFill([
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",
'icon_storage_type' => 'local',
])->save();
Storage::disk('local')->put($this->project->icon_path, 'icon-content');
$this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid]))
->assertSuccessful()
->assertHeader('content-type', 'image/jpeg');
$otherUser = User::factory()->create();
$otherTeam = Team::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
$this->get(route('project.icon', ['project_uuid' => $this->project->uuid]))
->assertNotFound();
});
it('removes a project icon', function () {
Storage::fake('local');
$path = "project-icons/{$this->project->uuid}/icon.jpg";
$this->project->forceFill([
'icon_path' => $path,
'icon_storage_type' => 'local',
])->save();
Storage::disk('local')->put($path, 'icon-content');
Livewire::test(Edit::class, ['project_uuid' => $this->project->uuid])
->call('removeIcon')
->assertHasNoErrors();
expect($this->project->refresh()->icon_path)->toBeNull();
Storage::disk('local')->assertMissing($path);
});
it('exposes the icon URL on the projects index', function () {
$this->project->forceFill([
'icon_path' => "project-icons/{$this->project->uuid}/icon.jpg",
'icon_storage_type' => 'local',
])->save();
Livewire::test(Index::class)
->assertViewHas('projectsJs', fn (array $projects): bool => $projects[0]['iconUrl'] === route('project.icon', [
'project_uuid' => $this->project->uuid,
'v' => $this->project->updated_at->timestamp,
]));
});
@@ -0,0 +1,9 @@
<?php
it('uses the Coollabs gradient button treatment for deploy actions', function () {
$view = file_get_contents(resource_path('views/livewire/project/new/select.blade.php'));
expect($view)
->not->toContain('dark:group-hover:text-warning')
->and(substr_count($view, 'class="button button-highlighted ml-auto"'))->toBe(4);
});
@@ -0,0 +1,16 @@
<?php
it('matches project view control borders and rounding to standard buttons', function () {
$templates = [
resource_path('views/livewire/project/index.blade.php'),
resource_path('views/livewire/project/show.blade.php'),
resource_path('views/livewire/project/resource/index.blade.php'),
resource_path('views/livewire/project/service/configuration.blade.php'),
];
foreach ($templates as $template) {
expect(file_get_contents($template))->toContain(
'class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]"'
);
}
});
+39 -1
View File
@@ -38,6 +38,7 @@ it('renders the changelog modal above the desktop sidebar toggle', function () {
$component = Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar'])
->call('openWhatsNewModal')
->assertNotDispatched('whats-new-opened')
->assertSee("What's new", false)
->assertSee('Test Release')
->assertSee('Mark read')
@@ -47,5 +48,42 @@ it('renders the changelog modal above the desktop sidebar toggle', function () {
// Single-release layout: no nested card chrome / unread left accent bar
expect($component->html())
->not->toContain('dark:bg-raised')
->not->toContain('w-0.5 bg-accent');
->not->toContain('w-0.5 bg-accent')
->toContain('absolute right-2 top-2');
});
it('keeps the account menu mounted while the changelog modal opens', function () {
$dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php'));
$accountMenuView = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($dropdownView)
->not->toContain('wire:click="openWhatsNewModal" @click="open = false"')
->and($accountMenuView)
->not->toContain('@whats-new-opened.window="open = false"');
});
it('opens the changelog modal when there are no entries', function () {
$user = new User(['email' => 'test@example.com']);
$user->id = 1;
Auth::setUser($user);
app()->instance(ChangelogService::class, new class extends ChangelogService
{
public function getEntriesForUser(User $user): Collection
{
return collect();
}
public function getUnreadCountForUser(User $user): int
{
return 0;
}
});
Livewire::test(SettingsDropdown::class, ['trigger' => 'account-menu'])
->call('openWhatsNewModal')
->assertSet('showWhatsNewModal', true)
->assertSee("What's new", false)
->assertSee('No updates found');
});
+1 -1
View File
@@ -1599,7 +1599,7 @@ it('archives a named volume on its server', function () {
Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume inspect')
&& str_contains($process->command, 'docker run --rm --name ')
&& str_contains($process->command, 'app-data:/volume:ro')
&& str_contains($process->command, 'tar -czf -')
&& str_contains($process->command, "tar -I 'gzip -1' -cf -")
&& str_contains($process->command, '> ')
&& str_contains($process->command, '.tar.gz')
&& ! str_contains($process->command, ':/backup'));
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+4
View File
@@ -37,3 +37,7 @@ it('includes a Jean Server one-click service template with all deployment enviro
->toContain('JEAN_TOKEN=${SERVICE_PASSWORD_64_JEAN}');
}
});
it('ships the Jean service icon from the public path used by the service picker', function () {
expect(__DIR__.'/../../public/svgs/jean.png')->toBeFile();
});