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:
Andras Bacsai committed 2026-08-06 15:37:30 +02:00
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');
});
+53 -14
View File
@@ -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')
+1
View File
@@ -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'));
+32 -3
View File
@@ -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 = ''\"");
});
+3 -1
View File
@@ -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');
});