mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 10:15:05 +00:00
feat(ui): add shared table controls and multi-select filters
Introduce x-table toolbar/search/filter/sort/loading components and multi-select deployment history filters with OR within groups and AND across groups. Add live database/service status for breadcrumbs, shared variables layout controls, logs viewer toolbar polish, and infrastructure list filter consistency. Document patterns in UI_REDESIGN.md and cover with feature tests.
This commit is contained in:
1 parent
66b202adfe
commit
2a5197fb30
57 files changed
+1415
-579
No files matched your search
@@ -180,6 +180,9 @@ it('refreshes the breadcrumb application status after it changes', function () {
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertDontSee('Running');
|
||||
|
||||
expect($component->instance()->getListeners())
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
|
||||
});
|
||||
|
||||
it('uses app-tab-active utility for resource heading active styles', function () {
|
||||
|
||||
@@ -20,3 +20,27 @@ test('teleported resource breadcrumbs use the same spacing as application breadc
|
||||
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
|
||||
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
|
||||
});
|
||||
|
||||
test('top breadcrumbs shrink and clip instead of overlapping on narrower screens', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('class="flex w-full min-w-0 items-center gap-0.5 text-[13px]"')
|
||||
->not->toContain('overflow-hidden text-[13px]')
|
||||
->toContain('class="min-w-0 shrink" x-data="{ collapsed: false }"')
|
||||
->not->toContain('class="shrink-0" x-data="{ collapsed: false }"')
|
||||
->and($layout)
|
||||
->toContain('class="relative flex min-w-0 flex-1 items-center"')
|
||||
->not->toContain('flex min-w-0 flex-1 items-center overflow-hidden')
|
||||
->not->toContain('<div class="flex-1"></div>');
|
||||
});
|
||||
|
||||
test('resource headings do not duplicate database and service breadcrumbs', function () {
|
||||
$headings = collect([
|
||||
resource_path('views/livewire/project/database/heading.blade.php'),
|
||||
resource_path('views/livewire/project/service/heading.blade.php'),
|
||||
])->map(fn (string $path): string => file_get_contents($path));
|
||||
|
||||
expect($headings->implode("\n"))->not->toContain("@teleport('#server-topbar-context')");
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Livewire\Project\Application\Deployment\Index;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('moves deployment history pagination by one page per action', function () {
|
||||
expect((new ReflectionMethod(Index::class, 'nextPage'))->getNumberOfParameters())->toBe(0)
|
||||
->and((new ReflectionMethod(Index::class, 'previousPage'))->getNumberOfParameters())->toBe(0);
|
||||
});
|
||||
|
||||
it('filters deployment history by server', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$firstServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Primary']);
|
||||
$secondServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Secondary']);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $firstServer->id)->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
foreach ([$firstServer, $secondServer] as $server) {
|
||||
ApplicationDeploymentQueue::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => "deployment-{$server->id}",
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
]);
|
||||
}
|
||||
|
||||
ApplicationDeploymentQueue::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => 'webhook-deployment',
|
||||
'server_id' => $secondServer->id,
|
||||
'server_name' => $secondServer->name,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
'is_webhook' => true,
|
||||
]);
|
||||
|
||||
$result = $application->deployments(filters: [
|
||||
'status:finished',
|
||||
'source:manual',
|
||||
'source:webhook',
|
||||
"server:{$secondServer->id}",
|
||||
]);
|
||||
|
||||
expect($result['count'])->toBe(2)
|
||||
->and($result['deployments']->pluck('server_id')->unique()->sole())->toBe($secondServer->id);
|
||||
});
|
||||
|
||||
it('always shows source filters and includes server filters', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$filterComponent = file_get_contents(resource_path('views/components/table/filter.blade.php'));
|
||||
$loadingComponent = file_get_contents(resource_path('views/components/table/loading.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('public array $serverFilterOptions = [];')
|
||||
->toContain('public array $deploymentFilters = [];')
|
||||
->toContain('public function toggleDeploymentFilter(string $filter): void')
|
||||
->toContain("'value' => \"server:{\$serverId}\"")
|
||||
->and($view)
|
||||
->toContain('@if (count($sourceFilterOptions) > 0)')
|
||||
->toContain('count($serverFilterOptions) > 0')
|
||||
->toContain('>Server</span>')
|
||||
->toContain('<x-table.filter')
|
||||
->toContain("wire:click=\"toggleDeploymentFilter('{{ \$option['value'] }}')\"")
|
||||
->toContain("in_array(\$option['value'], \$deploymentFilters, true)")
|
||||
->toContain('<x-table.loading id="deployment-table-filter-loading"')
|
||||
->not->toContain('class="size-3.5" wire:loading.remove')
|
||||
->not->toContain('<span>All deployments</span>')
|
||||
->and($filterComponent)
|
||||
->toContain('aria-multiselectable="true"')
|
||||
->toContain('Reset filters')
|
||||
->and($loadingComponent)
|
||||
->toContain('wire:loading.flex');
|
||||
});
|
||||
@@ -89,6 +89,7 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
|
||||
expect($deploymentView)
|
||||
->toContain('logs-viewer-toolbar')
|
||||
->toContain('logs-viewer-toolbar-controls')
|
||||
->toContain('logs-viewer-primary')
|
||||
->toContain('logs-viewer-meta')
|
||||
->toContain('logs-viewer-end')
|
||||
->toContain('logs-viewer-status-badge')
|
||||
@@ -119,22 +120,60 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
|
||||
->toContain('flex-direction: column')
|
||||
->toContain('@media (min-width: 640px)');
|
||||
|
||||
// Status badge lives in the right-side end group, immediately before cancel controls.
|
||||
$primaryGroup = str($deploymentView)
|
||||
->after('class="logs-viewer-primary"')
|
||||
->before('class="logs-viewer-end"')
|
||||
->toString();
|
||||
$endGroup = str($deploymentView)
|
||||
->after('class="logs-viewer-end"')
|
||||
->before('logs-viewer-viewport')
|
||||
->toString();
|
||||
|
||||
expect($endGroup)
|
||||
expect($primaryGroup)
|
||||
->toContain('logs-viewer-status-badge')
|
||||
->toContain('livewire:project.application.deployment-navbar');
|
||||
->not->toContain('livewire:project.application.deployment-navbar');
|
||||
|
||||
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
|
||||
$navbarPos = strpos($endGroup, 'livewire:project.application.deployment-navbar');
|
||||
$timestampPos = strpos($primaryGroup, 'Toggle Timestamps');
|
||||
$followPos = strpos($primaryGroup, 'Follow Logs');
|
||||
$debugPos = strpos($primaryGroup, 'wire:click="toggleDebug"');
|
||||
$fullscreenPos = strpos($primaryGroup, 'title="Fullscreen"');
|
||||
$copyPos = strpos($primaryGroup, 'title="Copy Logs"');
|
||||
$downloadPos = strpos($primaryGroup, 'title="Download Logs"');
|
||||
$badgePos = strpos($primaryGroup, 'logs-viewer-status-badge');
|
||||
|
||||
expect($badgePos)->not->toBeFalse()
|
||||
->and($navbarPos)->not->toBeFalse()
|
||||
->and($badgePos)->toBeLessThan($navbarPos);
|
||||
expect([$timestampPos, $followPos, $debugPos, $fullscreenPos, $copyPos, $downloadPos, $badgePos])
|
||||
->not->toContain(false)
|
||||
->and($timestampPos)->toBeLessThan($followPos)
|
||||
->and($followPos)->toBeLessThan($debugPos)
|
||||
->and($debugPos)->toBeLessThan($fullscreenPos)
|
||||
->and($fullscreenPos)->toBeLessThan($copyPos)
|
||||
->and($copyPos)->toBeLessThan($downloadPos)
|
||||
->and($downloadPos)->toBeLessThan($badgePos)
|
||||
->and($endGroup)->toContain('livewire:project.application.deployment-navbar')
|
||||
->toContain('Find in logs');
|
||||
|
||||
$runtimeActionsPos = strpos($sharedLogsView, 'class="logs-viewer-actions"');
|
||||
$runtimeEndPos = strpos($sharedLogsView, 'class="logs-viewer-end runtime-logs-viewer-end"');
|
||||
$runtimeLinesPos = strpos($sharedLogsView, 'class="logs-viewer-lines"');
|
||||
$runtimeSearchPos = strpos($sharedLogsView, 'placeholder="Find in logs"');
|
||||
|
||||
expect([$runtimeActionsPos, $runtimeEndPos, $runtimeLinesPos, $runtimeSearchPos])
|
||||
->not->toContain(false)
|
||||
->and($runtimeActionsPos)->toBeLessThan($runtimeEndPos)
|
||||
->and($runtimeEndPos)->toBeLessThan($runtimeLinesPos)
|
||||
->and($runtimeLinesPos)->toBeLessThan($runtimeSearchPos)
|
||||
->and($appCss)->toContain('.runtime-logs-viewer-end')
|
||||
->toContain('.dark .runtime-log-icon-button-active')
|
||||
->toContain('background: var(--color-coollabs)')
|
||||
->toContain(".dark .runtime-log-icon-button {\n color: var(--color-fg-faint);")
|
||||
->toContain(".dark .runtime-log-icon-button-active {\n background: var(--color-coollabs);\n color: #fff;")
|
||||
->toContain(".runtime-log-toolbar {\n position: relative;\n z-index: 20;")
|
||||
->and($sharedLogsView)
|
||||
->toContain('runtime-log-icon-button order-1')
|
||||
->toContain('runtime-log-icon-button order-2')
|
||||
->toContain('runtime-log-icon-button order-5')
|
||||
->toContain('runtime-log-icon-button order-6')
|
||||
->toContain('relative order-7 shrink-0');
|
||||
});
|
||||
|
||||
it('places cancel deployment controls inside the deployment logs toolbar', function () {
|
||||
@@ -180,15 +219,15 @@ it('places cancel deployment controls inside the deployment logs toolbar', funct
|
||||
->and($endPos)->toBeGreaterThan($toolbarPos)
|
||||
->and($endPos)->toBeLessThan($viewportPos);
|
||||
|
||||
// Within the right-side end group: status badge, then cancel controls.
|
||||
// Within the right-side end group: cancel controls, then log search.
|
||||
$endGroup = substr($content, $endPos, $viewportPos - $endPos);
|
||||
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
|
||||
$cancelPos = strpos($endGroup, 'Cancel deployment');
|
||||
$searchPos = strpos($endGroup, 'Find in logs');
|
||||
|
||||
expect($badgePos)->not->toBeFalse()
|
||||
->and($cancelPos)->not->toBeFalse()
|
||||
->and($badgePos)->toBeLessThan($cancelPos)
|
||||
->and($endGroup)->toContain('In progress');
|
||||
expect($cancelPos)->not->toBeFalse()
|
||||
->and($searchPos)->not->toBeFalse()
|
||||
->and($cancelPos)->toBeLessThan($searchPos)
|
||||
->and($content)->toContain('In progress');
|
||||
});
|
||||
|
||||
it('uses the shared mobile logs layout for proxy and sentinel log pages', function () {
|
||||
|
||||
@@ -9,6 +9,8 @@ test('resource environment variables table has a Managed column and no name-cell
|
||||
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
$filter = file_get_contents(resource_path('views/components/table/filter.blade.php'));
|
||||
$loading = file_get_contents(resource_path('views/components/table/loading.blade.php'));
|
||||
|
||||
// Header includes Managed between Name and Type.
|
||||
expect($all)
|
||||
@@ -17,13 +19,8 @@ test('resource environment variables table has a Managed column and no name-cell
|
||||
->toContain('toggleServiceFilter(@js($serviceName))')
|
||||
->toContain('$this->serviceFilterOptions')
|
||||
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
|
||||
->toContain('wire:loading.flex wire:target="clearFilters"')
|
||||
->toContain('wire:click="clearFilters"')
|
||||
->toContain('Clear filters')
|
||||
->toContain('max-h-80 overflow-y-auto p-1')
|
||||
->toContain('min-w-44! overflow-hidden! p-0!')
|
||||
->toContain('relative z-20 border-t')
|
||||
->toContain('dark:bg-[#171717]')
|
||||
->toContain('<x-table.loading target="clearFilters"')
|
||||
->toContain('reset-action="clearFilters"')
|
||||
->not->toContain("'all' => 'All variables'")
|
||||
->toContain("setTableSort('{{ \$value }}')")
|
||||
->toContain('Loading environment variables...')
|
||||
@@ -35,12 +32,17 @@ test('resource environment variables table has a Managed column and no name-cell
|
||||
->toContain("'literal' => 'Literal'")
|
||||
->toContain('$activeFilterCount')
|
||||
->toContain('$activeFilterText')
|
||||
->toContain("'button max-w-80 min-w-0'")
|
||||
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
|
||||
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
|
||||
->toContain('>Name</span>')
|
||||
->toContain('>Managed</span>')
|
||||
->toContain('>Type</span>');
|
||||
->toContain('>Type</span>')
|
||||
->and($filter)
|
||||
->toContain('Reset filters')
|
||||
->toContain('max-h-80 overflow-y-auto p-1')
|
||||
->toContain('min-w-44! overflow-hidden! p-0!')
|
||||
->and($loading)
|
||||
->toContain('wire:loading.flex');
|
||||
|
||||
// Name cell does not repeat the environment type; Type owns Production/Preview.
|
||||
expect($show)
|
||||
@@ -104,3 +106,11 @@ test('managed environment variables are ordered first', function () {
|
||||
->toContain("CASE WHEN key LIKE 'SERVICE_FQDN%'")
|
||||
->toMatch("/'kind' => 'hardcoded',[\\s\\S]+?'kind' => 'managed'/");
|
||||
});
|
||||
|
||||
test('environment variable toolbar does not use blade directives inside component attributes', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toMatch('/<x-table\.toolbar[^>]*@if/')
|
||||
->toContain('aria-busy="{{ ! $readyToLoad ? \'true\' : \'false\' }}"');
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
it('provides search and persisted grid and table views for infrastructure lists', function (string $view, string $item, string $storageKey) {
|
||||
$contents = file_get_contents(resource_path($view));
|
||||
|
||||
expect($contents)
|
||||
->toContain("'placeholder' => 'Search {$item}'")
|
||||
->toContain("localStorage.getItem('{$storageKey}') || 'table'")
|
||||
->toContain("localStorage.setItem('{$storageKey}', mode)")
|
||||
->toContain("@include('livewire.shared.list-search-empty'");
|
||||
})->with([
|
||||
'sources' => ['views/source/all.blade.php', 'sources', 'coolify-sources-view'],
|
||||
'destinations' => ['views/livewire/destination/index.blade.php', 'destinations', 'coolify-destinations-view'],
|
||||
'S3 storages' => ['views/livewire/storage/index.blade.php', 'S3 storages', 'coolify-s3-storages-view'],
|
||||
]);
|
||||
|
||||
it('renders shared search controls with result counts and view switchers', function () {
|
||||
$controls = file_get_contents(resource_path('views/livewire/shared/list-search-controls.blade.php'));
|
||||
$emptyState = file_get_contents(resource_path('views/livewire/shared/list-search-empty.blade.php'));
|
||||
|
||||
expect($controls)
|
||||
->toContain('x-model.debounce.150ms="search"')
|
||||
->toContain('filteredItems.length')
|
||||
->toContain("setViewMode('table')")
|
||||
->toContain("setViewMode('grid')")
|
||||
->toContain('aria-label="Table view"')
|
||||
->toContain('aria-label="Grid view"')
|
||||
->toContain('control-selected')
|
||||
->and($emptyState)->toContain('filteredItems.length === 0');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Status as DatabaseStatus;
|
||||
use App\Livewire\Project\Service\Status as ServiceStatus;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::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]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
});
|
||||
|
||||
it('refreshes the breadcrumb database status after it changes', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'postgres',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(DatabaseStatus::class, ['database' => $database])
|
||||
->assertSee('Running');
|
||||
|
||||
$database->update(['status' => 'exited']);
|
||||
|
||||
$component
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertDontSee('Running');
|
||||
|
||||
expect($component->instance()->getListeners())
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
|
||||
});
|
||||
|
||||
it('refreshes the breadcrumb service status after a child status changes', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$application = ServiceApplication::forceCreate([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'service_id' => $service->id,
|
||||
'name' => 'web',
|
||||
'human_name' => 'Web',
|
||||
'image' => 'nginx:alpine',
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ServiceStatus::class, ['service' => $service->fresh(['applications', 'databases'])])
|
||||
->assertSee('Running');
|
||||
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
$component
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertDontSee('Running');
|
||||
|
||||
expect($component->instance()->getListeners())
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
|
||||
});
|
||||
@@ -23,7 +23,9 @@ it('collapses server subsystem badges into one status summary', function () {
|
||||
->toContain('<x-loading compact />')
|
||||
->toContain('name="refresh" class="size-2.5 opacity-70"')
|
||||
->toContain('min-h-0! h-7! gap-1.5! px-2! py-1! text-[11px]')
|
||||
->not->toContain('@click="open = false" role="menuitem"');
|
||||
->toContain("href=\"{{ route('server.proxy', ['server_uuid' => \$server->uuid]) }}\"")
|
||||
->toContain("href=\"{{ route('server.sentinel', ['server_uuid' => \$server->uuid]) }}\"")
|
||||
->toContain('@click="open = false" role="menuitem"');
|
||||
|
||||
expect($badgeView)
|
||||
->not->toContain('text-neutral-500')
|
||||
|
||||
@@ -109,6 +109,7 @@ it('groups configured domains with their service redirect and excludes services
|
||||
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
|
||||
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
|
||||
->not->toContain("service-domain-group-{$this->webApp->id}")
|
||||
->and(substr_count($html, '2 domains'))->toBe(1)
|
||||
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared variables family: hide the page H1 on desktop; editor view toggle lives in layer-2 nav.
|
||||
*/
|
||||
test('shared variables pages hide the family title on desktop', function () {
|
||||
test('shared variables pages use the shared variables submenu instead of horizontal tabs', function () {
|
||||
$paths = [
|
||||
resource_path('views/livewire/shared-variables/index.blade.php'),
|
||||
resource_path('views/livewire/shared-variables/project/index.blade.php'),
|
||||
@@ -14,9 +11,54 @@ test('shared variables pages hide the family title on desktop', function () {
|
||||
|
||||
foreach ($paths as $path) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('section="shared-variables"')
|
||||
->toContain(':titleOnDesktop="false"');
|
||||
->toContain('<x-shared-variables.layout')
|
||||
->not->toContain('section="shared-variables"');
|
||||
}
|
||||
|
||||
$layout = file_get_contents(resource_path('views/components/shared-variables/layout.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain("'Overview'")
|
||||
->toContain("'Team'")
|
||||
->toContain("'Projects'")
|
||||
->toContain("'Environments'")
|
||||
->toContain("'Servers'")
|
||||
->toContain("request()->routeIs('shared-variables.project.*')")
|
||||
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]')
|
||||
->toContain("'menu-item-active' => \$menuItem['active']");
|
||||
});
|
||||
|
||||
test('shared variables are not registered as dashboard tabs', function () {
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
|
||||
expect($navbar)
|
||||
->not->toContain("'shared-variables' => [")
|
||||
->not->toContain('$stackTabsOnMobile')
|
||||
->not->toContain('$sharedVariableIcons');
|
||||
});
|
||||
|
||||
test('shared variable collection pages provide search and persistent grid and list views', function () {
|
||||
$paths = [
|
||||
resource_path('views/livewire/shared-variables/project/index.blade.php'),
|
||||
resource_path('views/livewire/shared-variables/environment/index.blade.php'),
|
||||
resource_path('views/livewire/shared-variables/server/index.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('<x-shared-variables.view-controls')
|
||||
->toContain("x-show=\"viewMode === 'grid'\"")
|
||||
->toContain("x-show=\"viewMode === 'list'\"")
|
||||
->toContain('matches(');
|
||||
}
|
||||
|
||||
$controls = file_get_contents(resource_path('views/components/shared-variables/view-controls.blade.php'));
|
||||
|
||||
expect($controls)
|
||||
->toContain('placeholder="Search {{ strtolower($label) }}"')
|
||||
->toContain('aria-label="List view"')
|
||||
->toContain('aria-label="Grid view"')
|
||||
->toContain("localStorage.setItem('{{ \$storageKey }}'");
|
||||
});
|
||||
|
||||
test('shared variables editor places the view toggle in the variables section title', function () {
|
||||
@@ -32,18 +74,6 @@ test('shared variables editor places the view toggle in the variables section ti
|
||||
->not->toContain('actionsInTitle');
|
||||
});
|
||||
|
||||
test('shared variables navigation uses the standard mobile settings menu', function () {
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
|
||||
expect($navbar)
|
||||
->toContain("\$stackTabsOnMobile = \$section === 'shared-variables'")
|
||||
->toContain('grid grid-cols-2 gap-0.5 border-y')
|
||||
->toContain("'menu-item'")
|
||||
->toContain("'menu-item-active' => \$item['active']")
|
||||
->toContain("\$sharedVariableIcons[\$item['label']]")
|
||||
->toContain('hidden lg:flex');
|
||||
});
|
||||
|
||||
test('shared variables table omits resource-only flag columns', function () {
|
||||
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
|
||||
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('keeps server submenu state independent from the Livewire update route', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
|
||||
$dynamicConfigurations = file_get_contents(resource_path('views/livewire/server/proxy/dynamic-configurations.blade.php'));
|
||||
$proxyConfiguration = file_get_contents(resource_path('views/livewire/server/proxy/show.blade.php'));
|
||||
$proxyLogs = file_get_contents(resource_path('views/livewire/server/proxy/logs.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'active' => \$activeMenu === 'proxy'")
|
||||
->toContain("'active' => \$activeSubMenu === 'dynamic-confs'")
|
||||
->not->toContain("'active' => request()->routeIs('server.proxy.dynamic-confs')")
|
||||
->and($dynamicConfigurations)->toContain('activeSubMenu="dynamic-confs"')
|
||||
->and($proxyConfiguration)->toContain('activeSubMenu="configuration"')
|
||||
->and($proxyLogs)->toContain('activeSubMenu="logs"');
|
||||
});
|
||||
|
||||
it('initializes persisted sidebar state before enabling layout transitions', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
|
||||
@@ -40,7 +55,7 @@ it('separates the mobile sidebar from the page with a visible border', function
|
||||
expect($layout)->toContain('max-w-56 min-w-0 flex-col border-l border-neutral-200 bg-white shadow-xl dark:border-white/[0.12] dark:bg-panel');
|
||||
});
|
||||
|
||||
it('truncates long team names within the mobile header', function () {
|
||||
it('shows the full team name in the header', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$switcher = file_get_contents(resource_path('views/livewire/switch-team.blade.php'));
|
||||
|
||||
@@ -49,8 +64,22 @@ it('truncates long team names within the mobile header', function () {
|
||||
->toContain('size-8 shrink-0 items-center justify-center rounded-lg')
|
||||
->toContain('flex shrink-0 items-center gap-1')
|
||||
->and($switcher)
|
||||
->toContain('group/team flex h-8 min-w-0 max-w-full items-center')
|
||||
->toContain('min-w-0 truncate text-[13px]');
|
||||
->toContain('group/team flex h-8 items-center')
|
||||
->toContain('whitespace-nowrap text-[13px]')
|
||||
->not->toContain('max-w-56 truncate text-[13px]');
|
||||
});
|
||||
|
||||
it('allows more of the team name to display in the desktop breadcrumb', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('class="shrink-0" x-data="{ collapsed: false }"')
|
||||
->toContain('<div class="flex min-w-0 items-center gap-0.5 text-[13px]">')
|
||||
->not->toContain('<div class="flex w-full min-w-0 items-center gap-0.5 text-[13px]">');
|
||||
|
||||
$switcher = file_get_contents(resource_path('views/livewire/switch-team.blade.php'));
|
||||
|
||||
expect($switcher)->toContain('whitespace-nowrap text-[13px]');
|
||||
});
|
||||
|
||||
it('shows section titles and descriptions above settings navigation on smaller screens', function (string $view, string $title, string $description) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('renders the standard table toolbar controls', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-table.toolbar>
|
||||
<x-slot:search>
|
||||
<x-table.search placeholder="Search deployments" wire:model.live="search" />
|
||||
</x-slot:search>
|
||||
<x-table.filter :active-count="2" reset-action="clearFilters">
|
||||
<button class="listbox-option">Success</button>
|
||||
</x-table.filter>
|
||||
<x-table.sort>
|
||||
<button class="listbox-option">Newest first</button>
|
||||
</x-table.sort>
|
||||
</x-table.toolbar>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('table-toolbar')
|
||||
->toContain('table-search')
|
||||
->toContain('wire:model.live="search"')
|
||||
->toContain('aria-multiselectable="true"')
|
||||
->toContain('Reset filters')
|
||||
->toContain('wire:click="clearFilters"')
|
||||
->toContain('Sort');
|
||||
});
|
||||
|
||||
it('renders the standard table loading overlay', function () {
|
||||
$html = Blade::render('<div class="relative"><x-table.loading target="applyFilters" text="Loading records..." /></div>');
|
||||
|
||||
expect($html)
|
||||
->toContain('table-loading-overlay')
|
||||
->toContain('wire:loading.flex')
|
||||
->toContain('wire:target="applyFilters"')
|
||||
->toContain('[&_.loading-indicator]:size-5')
|
||||
->toContain('aria-label="Loading records..."')
|
||||
->not->toContain('<span>Loading records...</span>');
|
||||
});
|
||||
|
||||
it('uses shared table controls on backend filtered tables', function () {
|
||||
$deployments = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$environmentVariables = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
foreach ([$deployments, $environmentVariables] as $view) {
|
||||
expect($view)
|
||||
->toContain('<x-table.toolbar')
|
||||
->toContain('<x-table.search')
|
||||
->toContain('<x-table.filter')
|
||||
->toContain('<x-table.sort')
|
||||
->toContain('<x-table.loading');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the standard search control for frontend filtered infrastructure tables', function () {
|
||||
$controls = file_get_contents(resource_path('views/livewire/shared/list-search-controls.blade.php'));
|
||||
|
||||
expect($controls)
|
||||
->toContain('<x-table.search')
|
||||
->toContain('clear-when="search"')
|
||||
->toContain("clear-action=\"search = ''\"");
|
||||
});
|
||||
@@ -45,11 +45,13 @@ it('uses bordered status badges in the top breadcrumb', function () {
|
||||
->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0);
|
||||
});
|
||||
|
||||
it('renders application status through a reactive livewire component', function () {
|
||||
it('renders resource statuses through reactive livewire components', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('<livewire:project.application.status')
|
||||
->toContain('<livewire:project.database.status')
|
||||
->toContain('<livewire:project.service.status')
|
||||
->not->toContain('$applicationStatus = str($currentApplication->status');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
it('respects the instance SPA navigation setting after application actions', function () {
|
||||
$heading = file_get_contents(__DIR__.'/../../app/Livewire/Project/Application/Heading.php');
|
||||
|
||||
expect(substr_count($heading, "return redirectRoute(\$this, 'project.application.deployment.show'"))
|
||||
->toBe(2)
|
||||
->and($heading)
|
||||
->not->toContain('navigate: false');
|
||||
});
|
||||
Reference in new issue
Block a user